From 48f6506ea2c67e30dc1f28b13615fad432f07d96 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 23 Jul 2026 08:12:59 +0300 Subject: [PATCH 1/3] cli/manifest/install: tt package install and joint resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unpack a .tt package archive into a scope and bring the tree to a runnable state. A with-deps archive in the project scope is a plain offline extraction (tar xf, no network); a --without-deps archive additionally refetches its dependency closure from the registry by the lock's exact pins, reusing tt package fetch's install-with-DepsNone loop. user and system scopes accept only --without-deps archives — a with-deps archive there is refused from the archive header before anything touches disk. Joint resolution handles several packages sharing one project .rocks/ tree: a dependency they lock at different versions is reconciled to the highest locked version that satisfies every package's declared constraint, or the install fails with a breakdown of who pinned what. The choice is made only among versions already locked — no registry is consulted — over go-luarocks' version and constraint primitives. Per-package metadata (manifest, lock, VERSION) is recorded under .rocks/manifests// for inventory and dependency refcounting. --locked refuses an archive whose lock diverges from its manifest, --upgrade installs only a strictly higher version, --force reinstalls over a collision, --yes skips the reconciliation prompt. A single archive surfaces its own failure code; a multi-archive run where some succeed and some fail exits 3. The archive reader is new: pack shipped a write-only tar+zstd writer, so install carries its own reader, with a tar-slip guard on every entry. Closes TNTP-8613 --- .golangci.yml | 3 + CHANGELOG.md | 16 + cli/cmd/package.go | 100 ++++- cli/manifest/install/archive.go | 314 ++++++++++++++ cli/manifest/install/archive_internal_test.go | 141 +++++++ cli/manifest/install/errors.go | 78 ++++ cli/manifest/install/extract.go | 97 +++++ cli/manifest/install/helpers_internal_test.go | 210 ++++++++++ cli/manifest/install/install.go | 391 ++++++++++++++++++ cli/manifest/install/install_internal_test.go | 236 +++++++++++ cli/manifest/install/plan.go | 315 ++++++++++++++ cli/manifest/install/plan_internal_test.go | 67 +++ cli/manifest/install/reconcile.go | 171 ++++++++ .../install/reconcile_internal_test.go | 93 +++++ cli/manifest/install/refetch_internal_test.go | 89 ++++ .../install/roundtrip_internal_test.go | 101 +++++ cli/manifest/install/runtime.go | 135 ++++++ cli/manifest/install/scope.go | 127 ++++++ cli/manifest/install/scope_internal_test.go | 54 +++ cli/manifest/install/state.go | 216 ++++++++++ 20 files changed, 2952 insertions(+), 2 deletions(-) create mode 100644 cli/manifest/install/archive.go create mode 100644 cli/manifest/install/archive_internal_test.go create mode 100644 cli/manifest/install/errors.go create mode 100644 cli/manifest/install/extract.go create mode 100644 cli/manifest/install/helpers_internal_test.go create mode 100644 cli/manifest/install/install.go create mode 100644 cli/manifest/install/install_internal_test.go create mode 100644 cli/manifest/install/plan.go create mode 100644 cli/manifest/install/plan_internal_test.go create mode 100644 cli/manifest/install/reconcile.go create mode 100644 cli/manifest/install/reconcile_internal_test.go create mode 100644 cli/manifest/install/refetch_internal_test.go create mode 100644 cli/manifest/install/roundtrip_internal_test.go create mode 100644 cli/manifest/install/runtime.go create mode 100644 cli/manifest/install/scope.go create mode 100644 cli/manifest/install/scope_internal_test.go create mode 100644 cli/manifest/install/state.go diff --git a/.golangci.yml b/.golangci.yml index 233fba910..a00029cd8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -110,6 +110,9 @@ linters: - "github.com/klauspost/compress/zstd" # cli/manifest/pack drives the build before archiving its result. - "github.com/tarantool/tt/cli/manifest/build" + # cli/manifest/install refetches dependencies through the luarocks + # adapter and reuses the build package's exit-code machinery. + - "github.com/tarantool/tt/cli/manifest/rocks" test: files: - "$test" diff --git a/CHANGELOG.md b/CHANGELOG.md index 10483897d..ad9d2b7cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. the active tt environment when it satisfies `[platform]`, with a warning. Archives are byte-reproducible for a given commit; set `SOURCE_DATE_EPOCH` to pin the build timestamp explicitly. +- `tt package install`: unpack a `.tt` archive into a scope and bring the tree + to a runnable state. `--scope project` (default) accepts both archive modes; a + with-deps archive in the project scope is a plain offline extraction, while a + `--without-deps` archive additionally refetches its dependency closure from + the registry using the lock's pins. `--scope user`/`--scope system` accept + only `--without-deps` archives — a with-deps archive there is refused from the + archive header before anything is written. Several packages installed into one + project share a `.rocks/` tree, so a dependency they lock at different + versions is reconciled to the highest locked version satisfying every + package's constraints, or the install fails with a breakdown. Per-package + metadata (manifest, lock, VERSION) is recorded under `.rocks/manifests//` + for inventory and dependency refcounting. `--locked` refuses an archive whose + lock diverges from its manifest, `--upgrade` installs only a strictly higher + version, `--force` reinstalls over a collision, and `--yes` skips the + reconciliation prompt. Installing several archives at once exits 3 when some + succeed and some fail. - `tt pack`: support nested `.packignore` at the root of tt environment. - `tt status`: add `--format` option to support JSON and YAML output formats for machine-readable output. diff --git a/cli/cmd/package.go b/cli/cmd/package.go index b7244ae8a..688aa89ca 100644 --- a/cli/cmd/package.go +++ b/cli/cmd/package.go @@ -11,9 +11,11 @@ import ( "github.com/spf13/cobra" "github.com/tarantool/tt/cli/manifest/build" + "github.com/tarantool/tt/cli/manifest/install" "github.com/tarantool/tt/cli/manifest/pack" manifestrocks "github.com/tarantool/tt/cli/manifest/rocks" oldrocks "github.com/tarantool/tt/cli/rocks" + "github.com/tarantool/tt/cli/util" ttversion "github.com/tarantool/tt/cli/version" ) @@ -29,6 +31,14 @@ var ( packageWithoutDeps bool ) +// Flags specific to `tt package install`. +var ( + packageScope string + packageUpgrade bool + packageForce bool + packageYes bool +) + // NewPackageCmd creates the `tt package` command group: the manifest build // pipeline (build, fetch and pack; install is not implemented yet). func NewPackageCmd() *cobra.Command { @@ -43,11 +53,97 @@ func NewPackageCmd() *cobra.Command { newPackageBuildCmd(), newPackageFetchCmd(), newPackagePackCmd(), + newPackageInstallCmd(), ) return packageCmd } +// newPackageInstallCmd wires `tt package install ARCHIVE...`. +func newPackageInstallCmd() *cobra.Command { + installCmd := &cobra.Command{ + Use: "install ARCHIVE...", + Short: "Install a .tt package archive into a scope", + Long: "Unpack a package archive into the chosen scope and bring the tree " + + "to a runnable state. A with-deps archive in the project scope is a " + + "plain offline extraction; otherwise the package is unpacked and its " + + "dependencies are refetched from the registry using the lock's pins. " + + "Several packages installed into one project share a .rocks/ tree, so a " + + "dependency they lock at different versions is reconciled to one both " + + "accept, or the install fails with an explanation.", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runPackageInstall(args); err != nil { + log.Error(err.Error()) + os.Exit(install.ExitCode(err)) + } + }, + } + + installCmd.Flags().StringVar(&packageScope, "scope", "project", + "install scope: project, user or system") + installCmd.Flags().BoolVar(&packageLocked, "locked", false, + "fail if the archive lock does not match its manifest") + installCmd.Flags().BoolVar(&packageUpgrade, "upgrade", false, + "install over an existing package only when the archive version is higher") + installCmd.Flags().BoolVar(&packageForce, "force", false, + "reinstall over an existing package (destructive)") + installCmd.Flags().BoolVarP(&packageYes, "yes", "y", false, + "skip the confirmation prompt when reconciling shared dependencies") + + return installCmd +} + +// runPackageInstall assembles install.Options from the environment and installs +// every archive. Tarantool facts are gathered best-effort: an offline with-deps +// install needs none, so a missing tarantool is only fatal when a dependency +// must actually be refetched. +func runPackageInstall(archives []string) error { + projectDir, err := os.Getwd() + if err != nil { + return err + } + + projectDir, err = filepath.Abs(projectDir) + if err != nil { + return err + } + + // Missing tarantool is tolerated here; refetch surfaces its own error only + // if the install actually reaches the registry. + tntInfo, _ := tarantoolInfo() + + result, err := install.Run(context.Background(), install.Options{ + ProjectDir: projectDir, + Scope: install.Scope(packageScope), + Archives: archives, + Locked: packageLocked, + Upgrade: packageUpgrade, + Force: packageForce, + Yes: packageYes, + Tarantool: tntInfo, + Warn: func(msg string) { log.Warn(msg) }, + Confirm: func(prompt string) bool { + ok, askErr := util.AskConfirm(os.Stdin, prompt) + + return askErr == nil && ok + }, + }) + if err != nil { + return err + } + + for _, one := range result.Installed { + if one.Skipped { + continue + } + + fmt.Printf("installed %s %s into %s scope\n", one.Package, one.Version, one.Scope) + } + + return nil +} + // newPackageBuildCmd wires `tt package build [COMPONENT]`. func newPackageBuildCmd() *cobra.Command { buildCmd := &cobra.Command{ @@ -193,8 +289,8 @@ func runtimeRequest(ctx context.Context, tntInfo manifestrocks.TarantoolInfo) pa } // runtimeCacheDir returns the runtime cache root, /tt/runtimes. -// RFC 0010 leaves the exact path an open item and `tt runtime install` is a v1 -// command; until then this is the directory a user populates by hand. +// The exact path is not yet finalized and nothing populates it automatically +// yet; until then this is the directory a user populates by hand. func runtimeCacheDir() string { base, err := os.UserCacheDir() if err != nil { diff --git a/cli/manifest/install/archive.go b/cli/manifest/install/archive.go new file mode 100644 index 000000000..06287e07a --- /dev/null +++ b/cli/manifest/install/archive.go @@ -0,0 +1,314 @@ +package install + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/klauspost/compress/zstd" + + "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/util" +) + +// File modes install writes with. Directories and executables get 0755, plain +// files 0644 — the same two modes the pack writer normalized every entry to. +const ( + dirPerm os.FileMode = 0o755 + execPerm os.FileMode = 0o755 + filePerm os.FileMode = 0o644 + // execBit is the owner-executable bit tested to tell the two modes apart. + execBit = 0o100 +) + +// Archive is a read handle on a .tt package archive (tar+zstd). It is the read +// counterpart of cli/manifest/pack's write-only archive writer: pack never +// produced a reader, so install carries its own. Each method opens the file +// afresh, so an Archive is cheap to hold and safe to reuse. +type Archive struct { + path string +} + +// OpenArchive returns a read handle on the archive at path. It only checks that +// the file exists; the tar+zstd stream is validated lazily on the first read. +func OpenArchive(archivePath string) (*Archive, error) { + _, err := os.Stat(archivePath) + if err != nil { + return nil, fmt.Errorf("%w: %w", errBadArchive, err) + } + + return &Archive{path: archivePath}, nil +} + +// Path is the archive's filesystem path. +func (a *Archive) Path() string { return a.path } + +// SHA256 returns the archive's checksum, lowercase hex — the same value +// tt package pack reported when it wrote the file. +func (a *Archive) SHA256() (string, error) { + sum, err := util.FileSHA256Hex(a.path) + if err != nil { + return "", fmt.Errorf("checksumming archive: %w", err) + } + + return sum, nil +} + +// Header is the tt-owned metadata read from an archive without extracting it: +// the manifest, the lock and the declared version, plus whether the archive +// bundles a runtime (a with-deps archive) — the facts install needs to decide +// scope compatibility, collisions and reconciliation before touching disk. +type Header struct { + // Manifest is the parsed app.manifest.toml. + Manifest *manifest.Manifest + // ManifestBytes is its raw bytes, kept for the --locked hash check. + ManifestBytes []byte + // Lock is the parsed app.manifest.lock. + Lock *manifest.Lock + // Version is the VERSION file's content, trimmed. + Version string + // WithDeps reports whether the archive carries _runtime/ and the full + // dependency closure (the default pack mode). A --without-deps archive has + // neither, and installing it refetches the closure from the registry. + WithDeps bool +} + +// ReadHeader scans the archive once, reading the three tt-owned metadata members +// into memory and noting whether a _runtime/ tree is present. It never writes to +// disk, so a scope or runtime rejection happens before any extraction. +func (a *Archive) ReadHeader() (*Header, error) { + manifestBytes, lockBytes, versionBytes, hasRuntime, err := a.readMetaMembers() + if err != nil { + return nil, err + } + + if manifestBytes == nil { + return nil, fmt.Errorf("%w: missing %s", errBadArchive, manifestFileName) + } + + if lockBytes == nil { + return nil, fmt.Errorf("%w: missing %s", errBadArchive, lockFileName) + } + + man, _, err := manifest.ParseManifest(manifestBytes) + if err != nil { + return nil, fmt.Errorf("%w: %w", errBadArchive, err) + } + + lock, err := manifest.ParseLock(lockBytes) + if err != nil { + return nil, fmt.Errorf("%w: %w", errBadArchive, err) + } + + return &Header{ + Manifest: man, + ManifestBytes: manifestBytes, + Lock: lock, + Version: strings.TrimSpace(string(versionBytes)), + WithDeps: hasRuntime, + }, nil +} + +// Extract writes archive entries into dstDir. For each entry mapEntry is given +// the entry's slash-path and returns the destination path relative to dstDir and +// whether to write it at all; returning ok=false skips the entry. This lets a +// caller both filter (skip a dependency's subtree) and remap (strip the .rocks/ +// prefix for a user-scope tree). Every resolved destination is validated to stay +// within dstDir; an escaping entry is a hard error and nothing more is written. +func (a *Archive) Extract(dstDir string, mapEntry func(name string) (string, bool)) error { + err := os.MkdirAll(dstDir, dirPerm) + if err != nil { + return fmt.Errorf("creating %s: %w", dstDir, err) + } + + return a.walk(func(hdr *tar.Header, reader io.Reader) (bool, error) { + name := path.Clean(filepath.ToSlash(hdr.Name)) + + rel, ok := name, true + if mapEntry != nil { + rel, ok = mapEntry(name) + } + + if !ok || rel == "" { + return true, nil + } + + dst, err := safeJoin(dstDir, rel) + if err != nil { + return false, err + } + + switch hdr.Typeflag { + case tar.TypeDir: + return true, mkdirWrapped(dst) + case tar.TypeReg: + return true, writeFile(dst, reader, entryMode(hdr)) + default: + // The pack writer emits only files and directories; anything else is + // skipped rather than trusted. + return true, nil + } + }) +} + +// readMetaMembers scans the archive once and returns the raw bytes of the three +// tt-owned metadata members plus whether a _runtime/ tree is present. +func (a *Archive) readMetaMembers() ([]byte, []byte, []byte, bool, error) { + var ( + manifestBytes []byte + lockBytes []byte + versionBytes []byte + hasRuntime bool + ) + + err := a.walk(func(hdr *tar.Header, reader io.Reader) (bool, error) { + name := path.Clean(filepath.ToSlash(hdr.Name)) + + if top, _, _ := strings.Cut(name, "/"); top == runtimeDirName { + hasRuntime = true + } + + if hdr.Typeflag != tar.TypeReg { + return true, nil + } + + data, readErr := io.ReadAll(reader) + if readErr != nil { + return false, fmt.Errorf("%w: reading %s: %w", errBadArchive, name, readErr) + } + + switch name { + case manifestFileName: + manifestBytes = data + case lockFileName: + lockBytes = data + case versionFileName: + versionBytes = data + } + + return true, nil + }) + if err != nil { + return nil, nil, nil, false, err + } + + return manifestBytes, lockBytes, versionBytes, hasRuntime, nil +} + +// walk opens the archive, decompresses it and calls visit for each tar entry. +// visit returns whether to continue and an error that stops the walk. The reader +// passed to visit is valid only for that call. +func (a *Archive) walk(visit func(*tar.Header, io.Reader) (bool, error)) error { + file, err := os.Open(a.path) + if err != nil { + return fmt.Errorf("%w: %w", errBadArchive, err) + } + + defer func() { _ = file.Close() }() + + zstdReader, err := zstd.NewReader(file) + if err != nil { + return fmt.Errorf("%w: opening zstd stream: %w", errBadArchive, err) + } + + defer zstdReader.Close() + + tarReader := tar.NewReader(zstdReader) + + for { + hdr, err := tarReader.Next() + if errors.Is(err, io.EOF) { + return nil + } + + if err != nil { + return fmt.Errorf("%w: reading tar stream: %w", errBadArchive, err) + } + + cont, err := visit(hdr, tarReader) + if err != nil { + return err + } + + if !cont { + return nil + } + } +} + +// entryMode is the file mode an archive entry is written with: 0755 when the +// stored mode carries the executable bit (runtime binaries, scripts), 0644 +// otherwise. The pack writer already normalized modes to exactly these two. +func entryMode(hdr *tar.Header) os.FileMode { + if hdr.Mode&execBit != 0 { + return execPerm + } + + return filePerm +} + +// mkdirWrapped creates dir and its parents, wrapping the external error. +func mkdirWrapped(dir string) error { + err := os.MkdirAll(dir, dirPerm) + if err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + + return nil +} + +// writeFile creates dst (and its parents) and copies reader into it with mode +// perm. +func writeFile(dst string, reader io.Reader, perm os.FileMode) error { + err := mkdirWrapped(filepath.Dir(dst)) + if err != nil { + return err + } + + // Path is validated by safeJoin; the archive is the user's own package. + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) //nolint:gosec + if err != nil { + return fmt.Errorf("creating %s: %w", dst, err) + } + + _, err = io.Copy(out, reader) + if err != nil { + _ = out.Close() + + return fmt.Errorf("writing %s: %w", dst, err) + } + + err = out.Close() + if err != nil { + return fmt.Errorf("closing %s: %w", dst, err) + } + + return nil +} + +// safeJoin joins a slash-separated archive entry name onto dstDir, refusing any +// name that is absolute or climbs out of dstDir (a tar-slip attempt). +func safeJoin(dstDir, name string) (string, error) { + if name == "" || name == "." { + return dstDir, nil + } + + if path.IsAbs(name) || strings.HasPrefix(name, "../") || + name == ".." || strings.Contains(name, "/../") { + return "", fmt.Errorf("%w: %q", errUnsafePath, name) + } + + joined := filepath.Join(dstDir, filepath.FromSlash(name)) + + rel, err := filepath.Rel(dstDir, joined) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("%w: %q", errUnsafePath, name) + } + + return joined, nil +} diff --git a/cli/manifest/install/archive_internal_test.go b/cli/manifest/install/archive_internal_test.go new file mode 100644 index 000000000..fef70a3b2 --- /dev/null +++ b/cli/manifest/install/archive_internal_test.go @@ -0,0 +1,141 @@ +package install + +import ( + "archive/tar" + "os" + "path/filepath" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReadHeaderWithDeps reads the metadata of a with-deps archive and reports +// it carries a runtime. +func TestReadHeaderWithDeps(t *testing.T) { + t.Parallel() + + path := archiveSpec{ + name: "my-app", version: "1.0.0", withRuntime: "3.0.5", + lockDeps: []LockDep{{Name: "luasocket", Version: "3.0.4", Source: "registry"}}, + }.build(t) + + ar, err := OpenArchive(path) + require.NoError(t, err) + + header, err := ar.ReadHeader() + require.NoError(t, err) + + assert.Equal(t, "my-app", header.Manifest.Package.Name) + assert.Equal(t, "1.0.0", header.Version) + assert.True(t, header.WithDeps) + assert.Equal(t, "3.0.5", header.Lock.BundledTarantool) +} + +// TestReadHeaderWithoutDeps reports a runtime-less archive as without-deps. +func TestReadHeaderWithoutDeps(t *testing.T) { + t.Parallel() + + path := archiveSpec{name: "some-lib", version: "2.0.0"}.build(t) + + ar, err := OpenArchive(path) + require.NoError(t, err) + + header, err := ar.ReadHeader() + require.NoError(t, err) + + assert.False(t, header.WithDeps) + assert.Empty(t, header.Lock.BundledTarantool) +} + +// TestExtractProjectKeepsRocksPrefix extracts into a project root and checks the +// .rocks/ prefix survives while root metadata does not leak into the tree. +func TestExtractProjectKeepsRocksPrefix(t *testing.T) { + t.Parallel() + + path := archiveSpec{ + name: "my-app", version: "1.0.0", withRuntime: "3.0.5", + files: rockFiles("my-app", "1.0.0"), + }.build(t) + + ar, err := OpenArchive(path) + require.NoError(t, err) + + root := t.TempDir() + mapper := extractMapper(ScopeProject, nil, true) + require.NoError(t, ar.Extract(root, mapper)) + + assert.FileExists(t, filepath.Join(root, ".rocks", "share", "tarantool", "my-app", "init.lua")) + assert.FileExists(t, filepath.Join(root, "_runtime", "tarantool", "bin", "tarantool")) + + // Root metadata must not be laid into the tree. + assert.NoFileExists(t, filepath.Join(root, "app.manifest.toml")) + assert.NoFileExists(t, filepath.Join(root, "VERSION")) +} + +// TestExtractSkipsDependency verifies a dependency in the skip set is not +// written from the archive. +func TestExtractSkipsDependency(t *testing.T) { + t.Parallel() + + path := archiveSpec{ + name: "my-app", version: "1.0.0", withRuntime: "3.0.5", + files: mergeFiles(rockFiles("my-app", "1.0.0"), rockFiles("luasocket", "3.0.4")), + }.build(t) + + ar, err := OpenArchive(path) + require.NoError(t, err) + + root := t.TempDir() + skip := map[string]struct{}{"luasocket": {}} + require.NoError(t, ar.Extract(root, extractMapper(ScopeProject, skip, true))) + + assert.FileExists(t, filepath.Join(root, ".rocks", "share", "tarantool", "my-app", "init.lua")) + assert.NoFileExists(t, + filepath.Join(root, ".rocks", "share", "tarantool", "luasocket", "init.lua")) +} + +// TestExtractRejectsTraversal guards the tar-slip defense: an entry climbing out +// of the destination is refused and nothing is written. +func TestExtractRejectsTraversal(t *testing.T) { + t.Parallel() + + dst := filepath.Join(t.TempDir(), "evil.tt") + writeRawTarZst(t, dst, "../escape.txt", "pwned") + + ar, err := OpenArchive(dst) + require.NoError(t, err) + + root := t.TempDir() + + err = ar.Extract(root, nil) + require.ErrorIs(t, err, errUnsafePath) + + assert.NoFileExists(t, filepath.Join(filepath.Dir(root), "escape.txt")) +} + +// writeRawTarZst writes a single-entry tar+zstd stream with an arbitrary (even +// unsafe) name, for the traversal guard test. +func writeRawTarZst(t *testing.T, dst, name, content string) { + t.Helper() + + f, err := os.Create(dst) //nolint:gosec // Test writes to a temp path. + require.NoError(t, err) + + defer func() { require.NoError(t, f.Close()) }() + + zstdWriter, err := zstd.NewWriter(f) + require.NoError(t, err) + + tarWriter := tar.NewWriter(zstdWriter) + require.NoError(t, tarWriter.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, Name: name, Mode: 0o644, + Size: int64(len(content)), Format: tar.FormatPAX, + })) + + _, err = tarWriter.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, tarWriter.Close()) + require.NoError(t, zstdWriter.Close()) +} diff --git a/cli/manifest/install/errors.go b/cli/manifest/install/errors.go new file mode 100644 index 000000000..ac0ed60e7 --- /dev/null +++ b/cli/manifest/install/errors.go @@ -0,0 +1,78 @@ +package install + +import ( + "errors" + "fmt" + + "github.com/tarantool/tt/cli/manifest/build" +) + +// Names install reads from inside a .tt archive. They mirror the reserved set +// tt writes at pack time. +const ( + manifestFileName = "app.manifest.toml" + lockFileName = "app.manifest.lock" + versionFileName = "VERSION" + runtimeDirName = "_runtime" +) + +// Process exit codes install maps failures to. A usage or state error (name +// collision without --force/--upgrade, incompatible runtime, a with-deps +// archive into user/system, a stale lock under --locked, an unreconcilable +// shared dependency) exits 1. A multi-archive run where some targets installed +// and some failed exits 3. +const ( + exitStateError = 1 + exitPartialError = 3 +) + +var ( + // errUnknownScope reports a --scope value that is not project, user or + // system. + errUnknownScope = errors.New("unknown scope") + // errWithDepsScope reports a with-deps archive aimed at user or system, + // which accept only --without-deps archives. Caught from the archive header, + // before anything is written. + errWithDepsScope = errors.New( + "a with-deps archive can only be installed into the project scope") + // errNameCollision reports a package already installed in the scope while + // neither --force nor --upgrade was given. + errNameCollision = errors.New("package already installed") + // errLockStale reports that the archive's lock no longer matches its manifest + // while --locked forbids proceeding. + errLockStale = errors.New("lock is out of date") + // errRuntimeMismatch reports a bundled runtime whose version conflicts with + // the one the project's primary package (or an earlier install) already + // fixed. + errRuntimeMismatch = errors.New("bundled runtime is incompatible") + // errIncompatibleDeps reports a shared dependency no locked version can + // satisfy across all packages that require it. + errIncompatibleDeps = errors.New("incompatible shared dependency") + // errBadArchive reports a .tt archive that cannot be read as tar+zstd or that + // is missing a tt-owned member. + errBadArchive = errors.New("invalid archive") + // errUnsafePath reports an archive entry whose path escapes the destination + // directory (a tar-slip attempt). + errUnsafePath = errors.New("archive entry escapes destination") + // errPartialInstall reports a multi-archive run where some archives + // installed and some failed; it carries exit code 3. + errPartialInstall = errors.New("some archives failed to install") +) + +// ExitError re-exports build.ExitError so install returns the same typed error +// the build and pack commands do; ExitCode reads the code back through the +// chain. +type ExitError = build.ExitError + +// ExitCode returns the process exit code for err, reusing the build package's +// mapping so build, pack and install all agree. A nil error is 0. +func ExitCode(err error) int { + return build.ExitCode(err) +} + +// stateErrorf wraps a formatted error as a state error (exit 1). +// +//nolint:err113 // Formatting helper, mirrors fmt.Errorf; callers pass %w wraps. +func stateErrorf(format string, args ...any) error { + return &build.ExitError{Code: exitStateError, Err: fmt.Errorf(format, args...)} +} diff --git a/cli/manifest/install/extract.go b/cli/manifest/install/extract.go new file mode 100644 index 000000000..34b6eea91 --- /dev/null +++ b/cli/manifest/install/extract.go @@ -0,0 +1,97 @@ +package install + +import "strings" + +// Path-segment counts for a rock's location under the .rocks/ tree: +// share|lib / tarantool / is three deep, and the rock-manifest path +// share/tarantool/rocks/ is four. +const ( + rockPathDepth = 3 + rockManifestDepth = 4 +) + +// extractMapper builds the per-entry mapper Extract uses for one install. It +// decides, for each archive entry, where it lands under the install-root and +// whether it is written at all: +// +// - the tt-owned root metadata (manifest, lock, VERSION) and payload files +// are never laid into the tree — a guest's manifest must not overwrite the +// project's own, and the metadata is recorded under manifests/ separately; +// - _runtime/ is written only for a project-scope install that is placing it +// for the first time (extractRuntime); +// - a dependency whose reconciled version stays what is already installed is +// skipped (skipNames); +// - for the user and system scopes the leading .rocks/ prefix is stripped so +// files land directly in the shared tree. +func extractMapper( + scope Scope, skipNames map[string]struct{}, extractRuntime bool, +) func(string) (string, bool) { + return func(name string) (string, bool) { + top, rest, _ := strings.Cut(name, "/") + + switch top { + case runtimeDirName: + if scope != ScopeProject || !extractRuntime { + return "", false + } + + return name, true + case rocksDirName: + return mapRocksEntry(scope, rest, skipNames) + default: + return "", false + } + } +} + +// mapRocksEntry maps one entry under the archive's .rocks/ tree. rest is the +// path with the .rocks/ prefix already stripped. +func mapRocksEntry(scope Scope, rest string, skipNames map[string]struct{}) (string, bool) { + if rest == "" { + return "", false + } + + // tt's own install state never travels inside an archive; ignore it if it + // somehow appears. + if top, _, _ := strings.Cut(rest, "/"); top == manifestsDirName { + return "", false + } + + if dep, ok := rocksDepName(rest); ok { + if _, skip := skipNames[dep]; skip { + return "", false + } + } + + if scope == ScopeProject { + return rocksDirName + "/" + rest, true + } + + // user / system: the shared tree has no .rocks/ level. + return rest, true +} + +// rocksDepName extracts the rock name owning a path under the .rocks/ tree: +// share/tarantool//…, lib/tarantool//…, or the rock-manifest path +// share/tarantool/rocks///…. It returns false for anything that +// is not inside a named rock's subtree. +func rocksDepName(rest string) (string, bool) { + parts := strings.Split(rest, "/") + if len(parts) < rockPathDepth { + return "", false + } + + if parts[1] != "tarantool" || (parts[0] != "share" && parts[0] != "lib") { + return "", false + } + + if parts[2] == "rocks" { + if len(parts) < rockManifestDepth { + return "", false + } + + return parts[3], true + } + + return parts[2], true +} diff --git a/cli/manifest/install/helpers_internal_test.go b/cli/manifest/install/helpers_internal_test.go new file mode 100644 index 000000000..e2c424f86 --- /dev/null +++ b/cli/manifest/install/helpers_internal_test.go @@ -0,0 +1,210 @@ +package install + +import ( + "archive/tar" + "maps" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest" +) + +// LockDep is a test-local shorthand for a resolved lock dependency. +type LockDep = manifest.LockDependency + +// archiveSpec describes a .tt archive to fabricate for a test, without a build +// or a real pack. It is the read side of what cli/manifest/pack writes. +type archiveSpec struct { + // name is the package name written into the manifest. + name string + // version is the VERSION file content. + version string + // tarantoolConstraint / ttConstraint are the [platform] constraints; empty + // values fall back to permissive defaults. + tarantoolConstraint string + ttConstraint string + // deps is the registry dependency map declared in [dependencies]. + deps map[string]string + // lockDeps is the resolved dependency closure written into the lock. + lockDeps []manifest.LockDependency + // withRuntime bundles a fake _runtime/ and stamps bundled_* into the lock, + // marking the archive as with-deps. Bundled tarantool version; "" means + // --without-deps. + withRuntime string + // files are extra archive entries: archive-relative slash path -> content. + // Used to place package and dependency subtrees under .rocks/. + files map[string]string +} + +// manifestTOML renders the spec's manifest. +func (s archiveSpec) manifestTOML() string { + tnt := s.tarantoolConstraint + if tnt == "" { + tnt = ">=3.0.0,<4.0.0" + } + + tt := s.ttConstraint + if tt == "" { + tt = ">=3.1.0" + } + + out := "manifest_version = '0.1'\n\n[package]\nname = '" + s.name + "'\n\n" + + "[platform]\ntarantool = '" + tnt + "'\ntt = '" + tt + "'\n\n" + + "[products.default]\ncomponents = ['lua']\ndefault = true\n\n" + + "[components.lua]\npath = '.'\n" + + if len(s.deps) == 0 { + return out + } + + names := make([]string, 0, len(s.deps)) + for n := range s.deps { + names = append(names, n) + } + + sort.Strings(names) + + var builder strings.Builder + + builder.WriteString(out) + builder.WriteString("\n[dependencies]\n") + + for _, n := range names { + builder.WriteString(n + " = '" + s.deps[n] + "'\n") + } + + return builder.String() +} + +// lockBytes builds the spec's lock, with manifest_hash matching the rendered +// manifest so a --locked install passes by default. +func (s archiveSpec) lockBytes(t *testing.T) []byte { + t.Helper() + + lock := manifest.Lock{ + LockVersion: manifest.LockVersion, + ManifestVersion: manifest.ManifestVersion, + GeneratedBy: "tt 3.0.0", + ManifestHash: manifest.HashBytes([]byte(s.manifestTOML())), + Products: map[string]manifest.LockProduct{ + "default": {Dependencies: s.lockDeps}, + }, + } + + if s.withRuntime != "" { + lock.BundledTarantool = s.withRuntime + lock.BundledTt = "3.2.0" + } + + data, err := lock.Marshal() + require.NoError(t, err) + + return data +} + +// build writes the archive to a temp file and returns its path. +func (s archiveSpec) build(t *testing.T) string { + t.Helper() + + entries := map[string]string{ + manifestFileName: s.manifestTOML(), + lockFileName: string(s.lockBytes(t)), + versionFileName: s.version + "\n", + } + + if s.withRuntime != "" { + entries["_runtime/tarantool/bin/tarantool"] = "#!/bin/sh\n" + } + + maps.Copy(entries, s.files) + + dst := filepath.Join(t.TempDir(), s.name+".tt") + writeTarZst(t, dst, entries) + + return dst +} + +// writeTarZst writes entries as a tar+zstd stream, mirroring the pack writer's +// normalized layout closely enough for the reader under test. +func writeTarZst(t *testing.T, dst string, entries map[string]string) { + t.Helper() + + f, err := os.Create(dst) //nolint:gosec // Test writes to a temp path. + require.NoError(t, err) + + defer func() { require.NoError(t, f.Close()) }() + + zstdWriter, err := zstd.NewWriter(f) + require.NoError(t, err) + + tarWriter := tar.NewWriter(zstdWriter) + + names := make([]string, 0, len(entries)) + for n := range entries { + names = append(names, n) + } + + sort.Strings(names) + + for _, name := range names { + content := entries[name] + + mode := int64(0o644) + if filepath.Base(name) == "tarantool" { + mode = 0o755 + } + + require.NoError(t, tarWriter.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: name, + Mode: mode, + Size: int64(len(content)), + Format: tar.FormatPAX, + })) + + _, err := tarWriter.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, tarWriter.Close()) + require.NoError(t, zstdWriter.Close()) +} + +// rockFiles returns the archive entries for a rock's own subtree under .rocks/: +// a share module and its rock-manifest directory at the given version. +func rockFiles(name, version string) map[string]string { + module := ".rocks/share/tarantool/" + name + "/init.lua" + rockManifest := ".rocks/share/tarantool/rocks/" + name + "/" + version + "/rock" + + return map[string]string{ + module: "-- " + name + "\n", + rockManifest: name + " " + version + "\n", + } +} + +// mergeFiles unions several archive-entry maps. +func mergeFiles(fileMaps ...map[string]string) map[string]string { + out := map[string]string{} + + for _, m := range fileMaps { + maps.Copy(out, m) + } + + return out +} + +// readFile returns a file's content or fails the test. +func readFile(t *testing.T, path string) string { + t.Helper() + + data, err := os.ReadFile(path) //nolint:gosec // Test reads from a temp path. + require.NoError(t, err) + + return string(data) +} diff --git a/cli/manifest/install/install.go b/cli/manifest/install/install.go new file mode 100644 index 000000000..2232d4e52 --- /dev/null +++ b/cli/manifest/install/install.go @@ -0,0 +1,391 @@ +// Package install implements tt package install: it unpacks a .tt package +// archive into a chosen scope and brings the tree to a runnable state. A +// with-deps archive installed into the project scope is a plain offline +// extraction; every other case extracts the package's own files and refetches +// its dependency closure from the registry using the lock's pins. +// +// The one genuinely new problem it solves is joint resolution: several packages +// installed side by side in one project share a single .rocks/ tree, so a +// dependency two of them lock at different versions must be reconciled to one +// version both can accept — or fail with an explanation rather than a silent +// pick. See reconcile.go. +package install + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/tarantool/go-luarocks/client" + + "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/rocks" +) + +// Source constants mirror the closed set the resolver writes into the lock. +const sourceRegistry = "registry" +const sourcePath = "path" + +// Options configures one install run over one or more archives. +type Options struct { + // ProjectDir is the install-root for project scope (the caller's cwd). It + // must be absolute; user and system scopes derive their roots from the + // environment and ignore it. + ProjectDir string + // Scope selects where the packages are installed. The zero value ("") is + // project. + Scope Scope + // Archives are the .tt files to install, in order. + Archives []string + // Locked refuses an archive whose lock no longer matches its manifest. + Locked bool + // Upgrade installs over an already-installed package only when the archive's + // version is higher; a not-higher --upgrade is a no-op. + Upgrade bool + // Force reinstalls over an already-installed package (destructive). + Force bool + // Yes skips the confirmation prompt raised when reconciliation changes an + // installed dependency's version. + Yes bool + // Tarantool carries the facts the rocks adapter needs to refetch + // dependencies; required only when an install refetches from the registry + // (a --without-deps archive or a reconciled version the archive lacks). + Tarantool rocks.TarantoolInfo + // Servers overrides the rock-server list; nil uses the adapter default. + Servers []string + // Logger receives the adapter's structured logs; nil disables it. + Logger *slog.Logger + // Warn receives non-fatal diagnostics; nil drops them. + Warn func(string) + // Confirm is asked before a reconciliation changes an installed dependency's + // version; returning false aborts that archive. Nil proceeds (the CLI wires a + // real prompt; Yes bypasses it entirely). + Confirm func(prompt string) bool +} + +func (o Options) warn(msg string) { + if o.Warn != nil { + o.Warn(msg) + } +} + +// OneResult reports the outcome of installing a single archive. +type OneResult struct { + // Archive is the archive path. + Archive string + // Package is the installed package's name. + Package string + // Version is its version. + Version string + // Scope is where it was installed. + Scope Scope + // Skipped is set when a --upgrade found nothing newer and did nothing. + Skipped bool +} + +// Failure pairs an archive with the error installing it produced. +type Failure struct { + Archive string + Err error +} + +// Result reports what an install run produced across all its archives. +type Result struct { + // Installed holds one entry per archive that succeeded (Skipped ones + // included). + Installed []OneResult + // Failed holds one entry per archive that failed. + Failed []Failure +} + +// Run installs every archive in opts.Archives into the selected scope and maps +// the outcome to a process exit code through an *ExitError: a single archive +// surfaces its own failure code; a multi-archive run where some succeeded and +// some failed exits 3. A nil error means every archive installed (or was a +// no-op upgrade). +func Run(ctx context.Context, opts Options) (*Result, error) { + scope, err := ParseScope(string(opts.Scope)) + if err != nil { + return nil, stateErrorf("%w", err) + } + + opts.Scope = scope + + result := &Result{Installed: nil, Failed: nil} + + for _, archivePath := range opts.Archives { + one, err := installOne(ctx, opts, archivePath) + if err != nil { + result.Failed = append(result.Failed, Failure{Archive: archivePath, Err: err}) + + continue + } + + result.Installed = append(result.Installed, *one) + } + + return result, aggregateError(result) +} + +// aggregateError collapses per-archive outcomes into the run's error: none for +// an all-clear run, a partial-failure (exit 3) when some archives passed and +// some failed, and the single failure's own error otherwise (so a lone archive +// keeps its exit code). +func aggregateError(result *Result) error { + if len(result.Failed) == 0 { + return nil + } + + if len(result.Installed) > 0 { + total := len(result.Installed) + len(result.Failed) + + return &ExitError{ + Code: exitPartialError, + Err: fmt.Errorf("%w (%d of %d)", errPartialInstall, len(result.Failed), total), + } + } + + return result.Failed[0].Err +} + +// installOne installs a single archive, returning its outcome or a typed error +// carrying the right exit code. +func installOne(ctx context.Context, opts Options, archivePath string) (*OneResult, error) { + archive, err := OpenArchive(archivePath) + if err != nil { + return nil, stateErrorf("%w", err) + } + + header, err := archive.ReadHeader() + if err != nil { + return nil, stateErrorf("%w", err) + } + + // A with-deps archive into user/system is refused from the header alone, + // before anything touches disk. + if header.WithDeps && !opts.Scope.AcceptsWithDeps() { + return nil, stateErrorf("%w (scope %s)", errWithDepsScope, opts.Scope) + } + + if opts.Locked { + if want := manifest.HashBytes(header.ManifestBytes); header.Lock.ManifestHash != want { + return nil, stateErrorf("%w: archive lock does not match its manifest", errLockStale) + } + } + + lay, err := resolveLayout(opts.Scope, opts.ProjectDir) + if err != nil { + return nil, err + } + + installed, err := installedPackages(lay, opts.Scope) + if err != nil { + return nil, err + } + + skip, err := checkCollision(opts, header, installed) + if err != nil { + return nil, err + } + + if skip { + return &OneResult{ + Archive: archivePath, Package: header.Manifest.Package.Name, + Version: header.Version, Scope: opts.Scope, Skipped: true, + }, nil + } + + err = checkRuntime(opts, header, installed) + if err != nil { + return nil, err + } + + err = apply(ctx, opts, archive, header, lay, installed) + if err != nil { + return nil, err + } + + return &OneResult{ + Archive: archivePath, Package: header.Manifest.Package.Name, + Version: header.Version, Scope: opts.Scope, Skipped: false, + }, nil +} + +// checkCollision enforces the name-collision policy and reports whether the +// install is a no-op (a --upgrade with nothing newer). A collision without +// --force or --upgrade is exit 1. +func checkCollision(opts Options, header *Header, installed []installedPackage) (bool, error) { + name := header.Manifest.Package.Name + + existing, ok := findInstalled(installed, name) + if !ok { + return false, nil + } + + if existing.primary { + return false, stateErrorf( + "%w: %q is the project's primary package, not a guest", errNameCollision, name) + } + + switch { + case opts.Force: + return false, nil + case opts.Upgrade: + if !versionHigher(header.Version, existing.version) { + opts.warn(fmt.Sprintf( + "%s %s is not newer than the installed %s; nothing to do", + name, header.Version, existing.version)) + + return true, nil + } + + return false, nil + default: + return false, stateErrorf("%w: %q %s (use --upgrade or --force)", + errNameCollision, name, existing.version) + } +} + +// apply resolves the dependency plan and realizes it: remove stale versions, +// extract the archive, and refetch any registry-sourced dependency. +func apply( + ctx context.Context, opts Options, archive *Archive, + header *Header, lay layout, installed []installedPackage, +) error { + productName, err := selectProduct(header.Manifest) + if err != nil { + return err + } + + plan, err := planDeps(header, productName, installed, lay, header.WithDeps) + if err != nil { + return err + } + + err = confirmReconciliation(opts, plan) + if err != nil { + return err + } + + err = removeStaleVersions(lay, plan) + if err != nil { + return err + } + + extractRuntime := header.WithDeps && !runtimeExists(lay) + + mapper := extractMapper(opts.Scope, plan.skipNames, extractRuntime) + + err = archive.Extract(lay.root, mapper) + if err != nil { + return fmt.Errorf("extracting %s: %w", archive.Path(), err) + } + + err = refetch(ctx, opts, lay, plan) + if err != nil { + return err + } + + return writeMetadata(lay, header) +} + +// confirmReconciliation asks for confirmation when the plan changes an +// already-installed dependency's version. --yes and a nil Confirm proceed. +func confirmReconciliation(opts Options, plan installPlan) error { + var changed []string + + for _, d := range plan.decisions { + if d.removeVersion != "" { + changed = append(changed, fmt.Sprintf("%s -> %s", d.name, d.version)) + } + } + + if len(changed) == 0 || opts.Yes || opts.Confirm == nil { + return nil + } + + prompt := fmt.Sprintf("reconciling shared dependencies changes: %v; proceed?", changed) + if !opts.Confirm(prompt) { + return stateErrorf("aborted at reconciliation") + } + + return nil +} + +// rockInstaller is the slice of go-luarocks' *client.Rocks the refetch loop +// drives: install a pinned rock into the tree. *client.Rocks satisfies it; +// tests fake it so the loop is exercised without a registry. +type rockInstaller interface { + Install(ctx context.Context, name string, opts client.InstallOpts) error +} + +// refetch installs every registry-sourced dependency of the plan into the tree +// at its reconciled version. It builds the rocks adapter lazily, so an offline +// with-deps install (no registry decisions) never needs Tarantool. +func refetch(ctx context.Context, opts Options, lay layout, plan installPlan) error { + pending := registryDecisions(plan) + if len(pending) == 0 { + return nil + } + + adapter := rocks.New(rocks.BuildConfig(opts.Tarantool, rocks.ConfigOptions{ + Tree: lay.tree, + WorkingDir: lay.root, + Servers: opts.Servers, + Logger: opts.Logger, + })) + + rockClient, err := adapter.Client(client.BackendNative) + if err != nil { + return fmt.Errorf("rocks client: %w", err) + } + + return installDeps(ctx, rockClient, pending, opts.Servers, opts.warn) +} + +// registryDecisions selects the plan's decisions that must be refetched from the +// registry. +func registryDecisions(plan installPlan) []depDecision { + var pending []depDecision + + for _, d := range plan.decisions { + if d.source == fromRegistry { + pending = append(pending, d) + } + } + + return pending +} + +// installDeps refetches each decision's pinned rock into the tree with +// dependency resolution off: the closure is already complete and ordered, so no +// rock re-resolves its own dependencies — the same discipline tt package fetch +// uses, here against the archive's lock. +func installDeps( + ctx context.Context, installer rockInstaller, + decisions []depDecision, servers []string, warn func(string), +) error { + for _, decision := range decisions { + if warn != nil { + warn(fmt.Sprintf("fetching %s %s", decision.name, decision.version)) + } + + installErr := installer.Install(ctx, decision.name, client.InstallOpts{ + Version: decision.version, Servers: servers, Deps: client.DepsNone, + }) + if installErr != nil { + return fmt.Errorf("installing %s %s: %w", + decision.name, decision.version, installErr) + } + } + + return nil +} + +// runtimeExists reports whether the install-root already holds a _runtime/ tree. +func runtimeExists(lay layout) bool { + info, err := os.Stat(runtimePath(lay)) + + return err == nil && info.IsDir() +} diff --git a/cli/manifest/install/install_internal_test.go b/cli/manifest/install/install_internal_test.go new file mode 100644 index 000000000..2146feb41 --- /dev/null +++ b/cli/manifest/install/install_internal_test.go @@ -0,0 +1,236 @@ +package install + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest" +) + +// sharedDep is the single registry dependency the multi-package tests share. +const sharedDep = "luasocket" + +// withRocks is a with-deps archive spec for a package that ships its own files +// and the shared registry dependency, at the given locked version and declared +// constraint. +func withRocks(name, version, depVer, depConstraint string) archiveSpec { + return archiveSpec{ + name: name, version: version, withRuntime: "3.0.5", + deps: map[string]string{sharedDep: depConstraint}, + lockDeps: []LockDep{{Name: sharedDep, Version: depVer, Source: "registry"}}, + files: mergeFiles(rockFiles(name, version), rockFiles(sharedDep, depVer)), + } +} + +// installInto runs one install into a fresh project directory. +func installInto(t *testing.T, dir string, opts Options, archives ...string) (*Result, error) { + t.Helper() + + opts.ProjectDir = dir + opts.Archives = archives + opts.Yes = true + + return Run(context.Background(), opts) +} + +// TestInstallWithDepsOffline covers the headline case: a with-deps archive into +// an empty project is a pure offline extraction — the tree lands and no network +// is touched (no Tarantool facts are provided). +func TestInstallWithDepsOffline(t *testing.T) { + t.Parallel() + + archive := withRocks("my-app", "1.0.0", "3.0.4", ">=3.0.0").build(t) + + dir := t.TempDir() + result, err := installInto(t, dir, Options{Scope: ScopeProject}, archive) + require.NoError(t, err) + require.Len(t, result.Installed, 1) + + assert.FileExists(t, filepath.Join(dir, ".rocks", "share", "tarantool", "my-app", "init.lua")) + assert.FileExists(t, + filepath.Join(dir, ".rocks", "share", "tarantool", "luasocket", "init.lua")) + assert.FileExists(t, filepath.Join(dir, "_runtime", "tarantool", "bin", "tarantool")) +} + +// TestInstallWritesMetadata checks the per-package metadata install records for +// list/uninstall and refcounting. +func TestInstallWritesMetadata(t *testing.T) { + t.Parallel() + + archive := withRocks("my-app", "1.2.3", "3.0.4", ">=3.0.0").build(t) + + dir := t.TempDir() + _, err := installInto(t, dir, Options{Scope: ScopeProject}, archive) + require.NoError(t, err) + + metaDir := filepath.Join(dir, ".rocks", "manifests", "my-app") + assert.FileExists(t, filepath.Join(metaDir, "manifest.toml")) + assert.FileExists(t, filepath.Join(metaDir, "lock.toml")) + assert.Equal(t, "1.2.3\n", readFile(t, filepath.Join(metaDir, "VERSION"))) + + lock, err := manifest.ParseLock([]byte(readFile(t, filepath.Join(metaDir, "lock.toml")))) + require.NoError(t, err) + + pin, ok := lockedVersion(lock, "luasocket") + require.True(t, ok) + assert.Equal(t, "3.0.4", pin) +} + +// TestInstallWithDepsIntoUserRejected pins the header check: a with-deps archive +// aimed at user or system is exit 1, before anything is written. +func TestInstallWithDepsIntoUserRejected(t *testing.T) { + t.Parallel() + + archive := withRocks("my-app", "1.0.0", "3.0.4", ">=3.0.0").build(t) + + _, err := installInto(t, t.TempDir(), Options{Scope: ScopeUser}, archive) + require.ErrorIs(t, err, errWithDepsScope) + assert.Equal(t, exitStateError, ExitCode(err)) +} + +// TestInstallCollision covers the name-collision policy: a second install of the +// same package without --force/--upgrade is exit 1; --force reinstalls. +func TestInstallCollision(t *testing.T) { + t.Parallel() + + archive := withRocks("my-app", "1.0.0", "3.0.4", ">=3.0.0").build(t) + dir := t.TempDir() + + _, err := installInto(t, dir, Options{Scope: ScopeProject}, archive) + require.NoError(t, err) + + _, err = installInto(t, dir, Options{Scope: ScopeProject}, archive) + require.ErrorIs(t, err, errNameCollision) + assert.Equal(t, exitStateError, ExitCode(err)) + + _, err = installInto(t, dir, Options{Scope: ScopeProject, Force: true}, archive) + require.NoError(t, err, "--force reinstalls over the collision") +} + +// TestInstallUpgrade covers --upgrade: it installs only a strictly higher +// version and no-ops otherwise. +func TestInstallUpgrade(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + v1 := withRocks("my-app", "1.0.0", "3.0.4", ">=3.0.0").build(t) + _, err := installInto(t, dir, Options{Scope: ScopeProject}, v1) + require.NoError(t, err) + + // Same version under --upgrade: nothing to do. + result, err := installInto(t, dir, Options{Scope: ScopeProject, Upgrade: true}, v1) + require.NoError(t, err) + require.Len(t, result.Installed, 1) + assert.True(t, result.Installed[0].Skipped) + + // Higher version under --upgrade: installed. + v2 := withRocks("my-app", "2.0.0", "3.0.4", ">=3.0.0").build(t) + + result, err = installInto(t, dir, Options{Scope: ScopeProject, Upgrade: true}, v2) + require.NoError(t, err) + assert.False(t, result.Installed[0].Skipped) + assert.Equal(t, "2.0.0\n", + readFile(t, filepath.Join(dir, ".rocks", "manifests", "my-app", "VERSION"))) +} + +// installedRockVersions lists the version directories present for a rock. +func installedRockVersions(t *testing.T, dir, rock string) []string { + t.Helper() + + entries, err := os.ReadDir( + filepath.Join(dir, ".rocks", "share", "tarantool", "rocks", rock)) + require.NoError(t, err) + + var versions []string + + for _, entry := range entries { + if entry.IsDir() { + versions = append(versions, entry.Name()) + } + } + + return versions +} + +// TestInstallMultiSharedSameVersion covers two packages that lock the shared +// dependency at the same version: it lands exactly once. +func TestInstallMultiSharedSameVersion(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + router := withRocks("router", "1.0.0", "3.0.4", ">=3.0.0").build(t) + storage := withRocks("storage", "1.0.0", "3.0.4", ">=3.0.0").build(t) + + _, err := installInto(t, dir, Options{Scope: ScopeProject}, router) + require.NoError(t, err) + + _, err = installInto(t, dir, Options{Scope: ScopeProject}, storage) + require.NoError(t, err) + + assert.Equal(t, []string{"3.0.4"}, installedRockVersions(t, dir, "luasocket")) +} + +// TestInstallMultiSharedReconciled covers two packages whose compatible +// constraints lock the shared dependency at different versions: the higher +// version is reconciled to and the older one is removed. +func TestInstallMultiSharedReconciled(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + router := withRocks("router", "1.0.0", "3.0.4", ">=3.0.0").build(t) + storage := withRocks("storage", "1.0.0", "3.1.0", ">=3.0.0,<4.0.0").build(t) + + _, err := installInto(t, dir, Options{Scope: ScopeProject}, router) + require.NoError(t, err) + + _, err = installInto(t, dir, Options{Scope: ScopeProject}, storage) + require.NoError(t, err) + + assert.Equal(t, []string{"3.1.0"}, installedRockVersions(t, dir, "luasocket"), + "reconciled to the higher version, old one removed") +} + +// TestInstallMultiSharedIncompatible covers two packages whose constraints no +// locked version can satisfy: exit 1 with a breakdown. +func TestInstallMultiSharedIncompatible(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + router := withRocks("router", "1.0.0", "3.0.4", "<3.1.0").build(t) + storage := withRocks("storage", "1.0.0", "3.1.0", ">=3.1.0").build(t) + + _, err := installInto(t, dir, Options{Scope: ScopeProject}, router) + require.NoError(t, err) + + _, err = installInto(t, dir, Options{Scope: ScopeProject}, storage) + require.ErrorIs(t, err, errIncompatibleDeps) + assert.Equal(t, exitStateError, ExitCode(err)) + assert.Contains(t, err.Error(), "router pinned 3.0.4") +} + +// TestInstallPartialMultiExit3 covers one invocation over several archives where +// some install and some fail: exit code 3. +func TestInstallPartialMultiExit3(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + router := withRocks("router", "1.0.0", "3.0.4", "<3.1.0").build(t) + storage := withRocks("storage", "1.0.0", "3.1.0", ">=3.1.0").build(t) + + result, err := installInto(t, dir, Options{Scope: ScopeProject}, router, storage) + require.Error(t, err) + assert.Equal(t, exitPartialError, ExitCode(err)) + assert.Len(t, result.Installed, 1) + assert.Len(t, result.Failed, 1) + assert.Equal(t, "router", result.Installed[0].Package) +} diff --git a/cli/manifest/install/plan.go b/cli/manifest/install/plan.go new file mode 100644 index 000000000..bd10b2b4e --- /dev/null +++ b/cli/manifest/install/plan.go @@ -0,0 +1,315 @@ +package install + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/tarantool/go-luarocks/deps" + + "github.com/tarantool/tt/cli/manifest" +) + +// depSource says where a dependency's files come from in the resolved plan. +type depSource int + +const ( + // fromArchive extracts the dependency's subtree straight from the .tt archive + // (a with-deps install of the version the archive already carries). + fromArchive depSource = iota + // fromRegistry refetches the pinned version from the registry (a + // --without-deps install, or a project whose reconciled version the archive + // does not carry). + fromRegistry + // skipDep leaves the dependency untouched: it is already installed at the + // reconciled version. + skipDep +) + +// depDecision is the plan for one dependency of the package being installed. +type depDecision struct { + name string + // version is the reconciled version to end up on disk. + version string + // source says how to realize that version. + source depSource + // removeVersion is a stale on-disk version directory to delete first, empty + // when nothing is being replaced. + removeVersion string +} + +// installPlan is the resolved decision for every registry dependency of the +// package being installed. +type installPlan struct { + decisions []depDecision + // skipNames is the set of dependency names whose files must NOT be taken + // from the archive (their reconciled version stays whatever is on disk). + skipNames map[string]struct{} +} + +// planDeps reconciles every registry dependency the package brings against what +// is already installed and decides, per dependency, whether to extract it from +// the archive, refetch it from the registry, or leave the installed copy in +// place. withDeps selects the archive vs registry source for a dependency the +// installed tree does not already satisfy. +func planDeps( + header *Header, productName string, installed []installedPackage, lay layout, withDeps bool, +) (installPlan, error) { + prod, ok := header.Lock.Products[productName] + if !ok { + var zero installPlan + + return zero, stateErrorf("archive lock has no closure for product %q", productName) + } + + plan := installPlan{decisions: nil, skipNames: map[string]struct{}{}} + + for _, dep := range prod.Dependencies { + if dep.Source != sourceRegistry { + // Path dependencies are the package's own sub-projects; a with-deps + // archive carries them, and there is no registry to refetch them from. + if withDeps { + plan.decisions = append(plan.decisions, depDecision{ + name: dep.Name, version: dep.Version, source: fromArchive, removeVersion: "", + }) + } + + continue + } + + decision, err := planRegistryDep(dep, header, installed, lay, withDeps) + if err != nil { + var zero installPlan + + return zero, err + } + + plan.decisions = append(plan.decisions, decision) + + if decision.source == skipDep { + plan.skipNames[dep.Name] = struct{}{} + } + } + + return plan, nil +} + +// planRegistryDep reconciles one registry dependency and decides its source. +func planRegistryDep( + dep manifest.LockDependency, header *Header, + installed []installedPackage, lay layout, withDeps bool, +) (depDecision, error) { + contributions := collectContributions(dep.Name, dep.Version, header.Manifest, installed) + + winner, err := reconcile(dep.Name, contributions) + if err != nil { + var zero depDecision + + return zero, err + } + + onDisk, hasOnDisk := installedVersion(lay, dep.Name) + + switch { + case winner == dep.Version: + // The archive carries the winning version. Replace a differing installed + // copy; a with-deps archive extracts it, a --without-deps one refetches it. + remove := "" + if hasOnDisk && !sameVersion(onDisk, winner) { + remove = onDisk + } + + src := fromRegistry + if withDeps { + src = fromArchive + } + + return depDecision{name: dep.Name, version: winner, source: src, removeVersion: remove}, nil + case hasOnDisk && sameVersion(onDisk, winner): + // The reconciled version is already installed; leave it in place. + return depDecision{ + name: dep.Name, version: winner, source: skipDep, removeVersion: "", + }, nil + default: + // The winner is neither the archive's version nor what is on disk. Only + // the registry can supply it; a with-deps offline install cannot. + if withDeps { + var zero depDecision + + return zero, stateErrorf( + "%w: reconciled %s to %s, which this offline archive does not carry", + errIncompatibleDeps, dep.Name, winner) + } + + remove := "" + if hasOnDisk { + remove = onDisk + } + + return depDecision{ + name: dep.Name, version: winner, source: fromRegistry, removeVersion: remove, + }, nil + } +} + +// collectContributions gathers the pin and declared constraint every relevant +// package puts on a shared dependency: the package being installed, plus every +// already-installed package whose lock also pins it. +func collectContributions( + dep, newPin string, newManifest *manifest.Manifest, installed []installedPackage, +) []contribution { + contributions := []contribution{{ + pkg: newManifest.Package.Name, + pin: newPin, + constraint: declaredConstraint(newManifest, dep), + }} + + for _, pkg := range installed { + if pkg.lock == nil { + continue + } + + pin, ok := lockedVersion(pkg.lock, dep) + if !ok { + continue + } + + contributions = append(contributions, contribution{ + pkg: pkg.name, + pin: pin, + constraint: declaredConstraint(pkg.manifest, dep), + }) + } + + return contributions +} + +// declaredConstraint returns the version constraint a manifest declares for a +// dependency, across the global map and every component map. Multiple +// declarations are AND-ed by comma-joining, matching the resolver. An unknown +// or path dependency contributes no constraint. +func declaredConstraint(man *manifest.Manifest, dep string) string { + if man == nil { + return "" + } + + var parts []string + + collect := func(deps map[string]manifest.Dependency) { + if decl, ok := deps[dep]; ok && decl.Source != sourcePath && decl.Version != "" { + parts = append(parts, decl.Version) + } + } + + collect(man.Dependencies) + + for _, comp := range man.Components { + collect(comp.Dependencies) + } + + return joinConstraints(parts) +} + +// joinConstraints comma-joins constraint expressions into one, dropping empties. +func joinConstraints(parts []string) string { + out := "" + + for _, part := range parts { + if part == "" { + continue + } + + if out == "" { + out = part + } else { + out += "," + part + } + } + + return out +} + +// lockedVersion returns the version a lock pins for a dependency across all its +// products. +func lockedVersion(lock *manifest.Lock, dep string) (string, bool) { + for _, prod := range lock.Products { + for _, d := range prod.Dependencies { + if d.Name == dep { + return d.Version, true + } + } + } + + return "", false +} + +// installedVersion reads the version of a rock currently installed in the tree +// from /share/tarantool/rocks///. It returns the first +// version directory found; a well-formed tree holds exactly one per rock. +func installedVersion(lay layout, dep string) (string, bool) { + rocksDir := filepath.Join(lay.share, "rocks", dep) + + entries, err := os.ReadDir(rocksDir) + if err != nil { + return "", false + } + + for _, entry := range entries { + if entry.IsDir() { + return entry.Name(), true + } + } + + return "", false +} + +// sameVersion reports whether two version strings denote the same version, +// tolerating formatting differences (a trailing "-1" revision, say). +func sameVersion(left, right string) bool { + if left == right { + return true + } + + parsedLeft, err := deps.ParseVersion(left) + if err != nil { + return false + } + + parsedRight, err := deps.ParseVersion(right) + if err != nil { + return false + } + + return deps.Compare(parsedLeft, parsedRight) == 0 +} + +// rockRemovePaths lists the on-disk paths that hold one version of a rock: its +// module trees under share/ and lib/, and its rock-manifest directory. +func rockRemovePaths(lay layout, dep, version string) []string { + return []string{ + filepath.Join(lay.share, dep), + filepath.Join(lay.lib, dep), + filepath.Join(lay.share, "rocks", dep, version), + } +} + +// removeStaleVersions deletes the on-disk files of any dependency version the +// plan is replacing, so a reconciled upgrade does not leave two versions of a +// rock side by side. +func removeStaleVersions(lay layout, plan installPlan) error { + for _, decision := range plan.decisions { + if decision.removeVersion == "" { + continue + } + + for _, stalePath := range rockRemovePaths(lay, decision.name, decision.removeVersion) { + err := os.RemoveAll(stalePath) + if err != nil { + return fmt.Errorf("removing stale %s %s: %w", + decision.name, decision.removeVersion, err) + } + } + } + + return nil +} diff --git a/cli/manifest/install/plan_internal_test.go b/cli/manifest/install/plan_internal_test.go new file mode 100644 index 000000000..fbb95d1c8 --- /dev/null +++ b/cli/manifest/install/plan_internal_test.go @@ -0,0 +1,67 @@ +package install + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// headerFor parses an archiveSpec into a Header without writing an archive, for +// plan-level tests. +func headerFor(t *testing.T, spec archiveSpec) *Header { + t.Helper() + + ar, err := OpenArchive(spec.build(t)) + require.NoError(t, err) + + header, err := ar.ReadHeader() + require.NoError(t, err) + + return header +} + +// TestPlanWithoutDepsRoutesToRegistry covers the --without-deps routing: a +// dependency the archive does not carry and the tree does not have is fetched +// from the registry. +func TestPlanWithoutDepsRoutesToRegistry(t *testing.T) { + t.Parallel() + + header := headerFor(t, archiveSpec{ + name: "some-lib", version: "2.0.0", + deps: map[string]string{"luasocket": ">=3.0.0"}, + lockDeps: []LockDep{{Name: "luasocket", Version: "3.0.4", Source: "registry"}}, + }) + + lay, err := resolveLayout(ScopeProject, t.TempDir()) + require.NoError(t, err) + + plan, err := planDeps(header, "default", nil, lay, false) + require.NoError(t, err) + + require.Len(t, plan.decisions, 1) + assert.Equal(t, "luasocket", plan.decisions[0].name) + assert.Equal(t, "3.0.4", plan.decisions[0].version) + assert.Equal(t, fromRegistry, plan.decisions[0].source) +} + +// TestPlanWithDepsRoutesToArchive covers the with-deps routing: the same +// dependency is taken from the archive, not the registry. +func TestPlanWithDepsRoutesToArchive(t *testing.T) { + t.Parallel() + + header := headerFor(t, archiveSpec{ + name: "my-app", version: "1.0.0", withRuntime: "3.0.5", + deps: map[string]string{"luasocket": ">=3.0.0"}, + lockDeps: []LockDep{{Name: "luasocket", Version: "3.0.4", Source: "registry"}}, + }) + + lay, err := resolveLayout(ScopeProject, t.TempDir()) + require.NoError(t, err) + + plan, err := planDeps(header, "default", nil, lay, true) + require.NoError(t, err) + + require.Len(t, plan.decisions, 1) + assert.Equal(t, fromArchive, plan.decisions[0].source) +} diff --git a/cli/manifest/install/reconcile.go b/cli/manifest/install/reconcile.go new file mode 100644 index 000000000..fbdae5594 --- /dev/null +++ b/cli/manifest/install/reconcile.go @@ -0,0 +1,171 @@ +package install + +import ( + "fmt" + "sort" + "strings" + + luarocks "github.com/tarantool/go-luarocks" + "github.com/tarantool/go-luarocks/deps" +) + +// contribution is one package's stake in a shared dependency: the version that +// package's lock pinned, and the version constraint its manifest declared. The +// constraint is empty for a purely transitive dependency — one a package pulls +// in without declaring, so it pins a version but restricts nothing. +type contribution struct { + // pkg is the package that brought this dependency. + pkg string + // pin is the exact version the package's lock recorded. + pin string + // constraint is the version expression the package's manifest declared, + // e.g. ">=3.0.0,<4.0.0". Empty when the dependency is transitive. + constraint string +} + +// reconcile chooses the single locked version of a shared dependency that all +// contributing packages can live with. The choice is made only among the +// versions the packages already locked — no registry is consulted and nothing +// is re-resolved: +// +// - if every package pinned the same version, that version is used; +// - otherwise the pins that satisfy every package's declared constraint are +// the candidates, and the highest of them wins; +// - if no pin satisfies all constraints, it is a hard error carrying a +// breakdown of who pinned what and whose constraint excluded it. +// +// dep is the dependency name, used only for diagnostics. contributions must be +// non-empty. +func reconcile(dep string, contributions []contribution) (string, error) { + if len(contributions) == 0 { + return "", fmt.Errorf("%w %q: no contributors", errIncompatibleDeps, dep) + } + + pins, err := parsePins(dep, contributions) + if err != nil { + return "", err + } + + if allEqual(pins) { + return contributions[0].pin, nil + } + + constraints, err := gatherConstraints(dep, contributions) + if err != nil { + return "", err + } + + best, ok := highestMatching(pins, constraints) + if ok { + return best, nil + } + + return "", incompatibleError(dep, contributions) +} + +// parsePin pairs a contribution's raw pin string with its parsed version. +type parsedPin struct { + raw string + version luarocks.Version +} + +// parsePins parses every contribution's pin into a comparable version. +func parsePins(dep string, contributions []contribution) ([]parsedPin, error) { + pins := make([]parsedPin, 0, len(contributions)) + + for _, contrib := range contributions { + version, err := deps.ParseVersion(contrib.pin) + if err != nil { + return nil, fmt.Errorf("%w %q: package %q pinned unparseable version %q: %w", + errIncompatibleDeps, dep, contrib.pkg, contrib.pin, err) + } + + pins = append(pins, parsedPin{raw: contrib.pin, version: version}) + } + + return pins, nil +} + +// allEqual reports whether every pin is the same version. +func allEqual(pins []parsedPin) bool { + for i := 1; i < len(pins); i++ { + if deps.Compare(pins[i].version, pins[0].version) != 0 { + return false + } + } + + return true +} + +// gatherConstraints parses and unions every declared constraint. Transitive +// contributions (empty constraint) add nothing. The union is an AND: a +// candidate must satisfy all of them at once. +func gatherConstraints( + dep string, contributions []contribution, +) ([]luarocks.VersionConstraint, error) { + all := make([]luarocks.VersionConstraint, 0, len(contributions)) + + for _, contrib := range contributions { + if contrib.constraint == "" { + continue + } + + parsed, err := deps.ParseConstraints(contrib.constraint) + if err != nil { + return nil, fmt.Errorf("%w %q: package %q declared unparseable constraint %q: %w", + errIncompatibleDeps, dep, contrib.pkg, contrib.constraint, err) + } + + all = append(all, parsed...) + } + + return all, nil +} + +// highestMatching returns the highest pin that satisfies every constraint, and +// whether any pin did. With no constraints every pin qualifies, so the highest +// pin wins outright. +func highestMatching( + pins []parsedPin, constraints []luarocks.VersionConstraint, +) (string, bool) { + // Highest first, so the first match is the answer. + sorted := make([]parsedPin, len(pins)) + copy(sorted, pins) + sort.SliceStable(sorted, func(i, j int) bool { + return deps.Compare(sorted[i].version, sorted[j].version) > 0 + }) + + for _, pin := range sorted { + if len(constraints) == 0 || deps.Match(pin.version, constraints) { + return pin.raw, true + } + } + + return "", false +} + +// incompatibleError builds the diagnostic for a shared dependency no locked +// version satisfies: every package's pin, and every declared constraint, so the +// user can see which requirement excluded which version. +func incompatibleError(dep string, contributions []contribution) error { + pins := make([]string, 0, len(contributions)) + constraints := make([]string, 0, len(contributions)) + + for _, contrib := range contributions { + pins = append(pins, fmt.Sprintf("%s pinned %s", contrib.pkg, contrib.pin)) + + if contrib.constraint != "" { + constraints = append(constraints, + fmt.Sprintf("%s requires %s", contrib.pkg, contrib.constraint)) + } + } + + msg := fmt.Sprintf("%s: %s", dep, strings.Join(pins, ", ")) + if len(constraints) > 0 { + msg += "; " + strings.Join(constraints, ", ") + } + + msg += "; no locked version satisfies every requirement" + + return &ExitError{Code: exitStateError, Err: fmt.Errorf("%w: %s", errIncompatibleDeps, msg)} +} diff --git a/cli/manifest/install/reconcile_internal_test.go b/cli/manifest/install/reconcile_internal_test.go new file mode 100644 index 000000000..e7c4faf6d --- /dev/null +++ b/cli/manifest/install/reconcile_internal_test.go @@ -0,0 +1,93 @@ +package install + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestReconcileIdenticalPins keeps the trivial case: when every package locked +// the same version, that version is chosen and constraints never matter. +func TestReconcileIdenticalPins(t *testing.T) { + t.Parallel() + + got, err := reconcile("luasocket", []contribution{ + {pkg: "router", pin: "3.0.4", constraint: ">=3.0.0"}, + {pkg: "storage", pin: "3.0.4", constraint: ">=3.0.0,<4.0.0"}, + }) + require.NoError(t, err) + assert.Equal(t, "3.0.4", got) +} + +// TestReconcileHighestCompatible pins the core rule: among divergent pins, the +// highest that satisfies every declared constraint wins. +func TestReconcileHighestCompatible(t *testing.T) { + t.Parallel() + + got, err := reconcile("luasocket", []contribution{ + {pkg: "router", pin: "3.0.4", constraint: ">=3.0.0"}, + {pkg: "storage", pin: "3.1.0", constraint: ">=3.0.0,<4.0.0"}, + }) + require.NoError(t, err) + assert.Equal(t, "3.1.0", got) +} + +// TestReconcileConstraintExcludesHighest covers a constraint that rules the +// highest pin out, so the next-highest satisfying pin is chosen instead. +func TestReconcileConstraintExcludesHighest(t *testing.T) { + t.Parallel() + + got, err := reconcile("luasocket", []contribution{ + {pkg: "router", pin: "3.0.4", constraint: ">=3.0.0"}, + {pkg: "storage", pin: "3.1.0", constraint: "<3.1.0"}, + }) + require.NoError(t, err) + assert.Equal(t, "3.0.4", got, "3.1.0 is excluded by storage's <3.1.0") +} + +// TestReconcileIncompatible covers constraints that cannot be jointly satisfied +// by any locked version: a hard error with a breakdown, carrying exit code 1. +func TestReconcileIncompatible(t *testing.T) { + t.Parallel() + + _, err := reconcile("luasocket", []contribution{ + {pkg: "router", pin: "3.0.4", constraint: "<3.1.0"}, + {pkg: "storage", pin: "3.1.0", constraint: ">=3.1.0"}, + }) + require.ErrorIs(t, err, errIncompatibleDeps) + + var exit *ExitError + require.ErrorAs(t, err, &exit) + assert.Equal(t, exitStateError, exit.Code) + + assert.Contains(t, err.Error(), "router pinned 3.0.4") + assert.Contains(t, err.Error(), "storage pinned 3.1.0") + assert.Contains(t, err.Error(), "requires") +} + +// TestReconcileTransitiveNoConstraint covers a purely transitive shared +// dependency: no package declares a constraint, so the highest pin wins. +func TestReconcileTransitiveNoConstraint(t *testing.T) { + t.Parallel() + + got, err := reconcile("inspect", []contribution{ + {pkg: "router", pin: "3.1.2"}, + {pkg: "storage", pin: "3.1.3"}, + }) + require.NoError(t, err) + assert.Equal(t, "3.1.3", got) +} + +// TestReconcileOneConstraintDrivesChoice covers divergent pins where only one +// package constrains the range; the highest pin still inside that range wins. +func TestReconcileOneConstraintDrivesChoice(t *testing.T) { + t.Parallel() + + got, err := reconcile("inspect", []contribution{ + {pkg: "router", pin: "3.1.3"}, + {pkg: "storage", pin: "3.1.2", constraint: "==3.1.2"}, + }) + require.NoError(t, err) + assert.Equal(t, "3.1.2", got, "router's 3.1.3 is excluded by storage's ==3.1.2") +} diff --git a/cli/manifest/install/refetch_internal_test.go b/cli/manifest/install/refetch_internal_test.go new file mode 100644 index 000000000..be8d26c82 --- /dev/null +++ b/cli/manifest/install/refetch_internal_test.go @@ -0,0 +1,89 @@ +package install + +import ( + "context" + "errors" + "testing" + + "github.com/tarantool/go-luarocks/client" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeInstaller records the Install calls the refetch loop makes, standing in +// for a registry so the --without-deps download path is exercised offline. +type fakeInstaller struct { + calls []client.InstallOpts + names []string + err error +} + +func (f *fakeInstaller) Install(_ context.Context, name string, opts client.InstallOpts) error { + f.names = append(f.names, name) + f.calls = append(f.calls, opts) + + return f.err +} + +// TestInstallDepsRefetchesPins covers the --without-deps download loop: each +// registry decision is installed at its exact pin with dependency resolution +// off, so nothing re-resolves its own closure. +func TestInstallDepsRefetchesPins(t *testing.T) { + t.Parallel() + + fake := &fakeInstaller{calls: nil, names: nil, err: nil} + + decisions := []depDecision{ + {name: "luasocket", version: "3.0.4", source: fromRegistry, removeVersion: ""}, + {name: "inspect", version: "3.1.3", source: fromRegistry, removeVersion: ""}, + } + + err := installDeps(context.Background(), fake, decisions, []string{"srv"}, nil) + require.NoError(t, err) + + assert.Equal(t, []string{"luasocket", "inspect"}, fake.names) + + for _, opts := range fake.calls { + assert.Equal(t, client.DepsNone, opts.Deps, "the closure is complete; no re-resolution") + assert.Equal(t, []string{"srv"}, opts.Servers) + } + + assert.Equal(t, "3.0.4", fake.calls[0].Version) + assert.Equal(t, "3.1.3", fake.calls[1].Version) +} + +// TestInstallDepsPropagatesError covers a registry failure surfacing with the +// dependency named. +func TestInstallDepsPropagatesError(t *testing.T) { + t.Parallel() + + fake := &fakeInstaller{calls: nil, names: nil, err: errors.New("registry down")} + + decisions := []depDecision{ + {name: "luasocket", version: "3.0.4", source: fromRegistry, removeVersion: ""}, + } + + err := installDeps(context.Background(), fake, decisions, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "luasocket 3.0.4") +} + +// TestRegistryDecisionsSelectsOnlyRegistry checks that only registry-sourced +// decisions are refetched — archived and skipped ones never hit the network. +func TestRegistryDecisionsSelectsOnlyRegistry(t *testing.T) { + t.Parallel() + + plan := installPlan{ + decisions: []depDecision{ + {name: "a", version: "1", source: fromArchive, removeVersion: ""}, + {name: "b", version: "2", source: fromRegistry, removeVersion: ""}, + {name: "c", version: "3", source: skipDep, removeVersion: ""}, + }, + skipNames: nil, + } + + got := registryDecisions(plan) + require.Len(t, got, 1) + assert.Equal(t, "b", got[0].name) +} diff --git a/cli/manifest/install/roundtrip_internal_test.go b/cli/manifest/install/roundtrip_internal_test.go new file mode 100644 index 000000000..4cdcdde4e --- /dev/null +++ b/cli/manifest/install/roundtrip_internal_test.go @@ -0,0 +1,101 @@ +package install + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/build" + "github.com/tarantool/tt/cli/manifest/pack" + "github.com/tarantool/tt/cli/manifest/rocks" +) + +// roundtripManifest is a dependency-free pure-Lua package: it packs and installs +// with no compiler, no registry and no tarantool binary. +const roundtripManifest = `manifest_version = '0.1' + +[package] +name = 'round-app' +include = ['README.md'] + +[platform] +tarantool = '>=3.0.0,<4.0.0' +tt = '>=3.1.0' + +[products.default] +components = ['lua'] +default = true + +[components.lua] +path = '.' +include = ['*.lua'] +` + +// TestPackInstallRoundtrip drives the real pack writer into the real install +// reader: a --without-deps archive of a dependency-free app is packed, then +// installed offline into a fresh project, and its files and metadata land where +// they belong. This is the headline offline path with no fabricated archive in +// the middle. +func TestPackInstallRoundtrip(t *testing.T) { + t.Parallel() + + src := t.TempDir() + writeSource(t, src, map[string]string{ + "app.manifest.toml": roundtripManifest, + "VERSION": "1.0.0\n", + "init.lua": "return 1\n", + "README.md": "round-app\n", + }) + + packed, err := pack.Run(context.Background(), pack.Options{ + ProjectDir: src, + WithoutDeps: true, + OutputDir: filepath.Join(src, "_out"), + Build: build.Options{ + TtVersion: "tt test", + Tarantool: rocks.TarantoolInfo{Prefix: "/nonexistent", Version: "3.0.0"}, + }, + }) + require.NoError(t, err) + + deploy := t.TempDir() + result, err := Run(context.Background(), Options{ + ProjectDir: deploy, + Scope: ScopeProject, + Archives: []string{packed.Path}, + Yes: true, + }) + require.NoError(t, err) + require.Len(t, result.Installed, 1) + assert.Equal(t, "round-app", result.Installed[0].Package) + + // The package's own files land in the shared tree. + assert.FileExists(t, + filepath.Join(deploy, ".rocks", "share", "tarantool", "round-app", "init.lua")) + assert.FileExists(t, + filepath.Join(deploy, ".rocks", "share", "tarantool", "round-app", "version.lua")) + + // Metadata lands under manifests/ for list/uninstall. + metaDir := filepath.Join(deploy, ".rocks", "manifests", "round-app") + assert.FileExists(t, filepath.Join(metaDir, "manifest.toml")) + assert.FileExists(t, filepath.Join(metaDir, "lock.toml")) + assert.Equal(t, "1.0.0\n", readFile(t, filepath.Join(metaDir, "VERSION"))) + + // The project's own manifest is not overwritten by the guest's. + assert.NoFileExists(t, filepath.Join(deploy, "app.manifest.toml")) +} + +// writeSource writes a set of files into dir, creating parents. +func writeSource(t *testing.T, dir string, files map[string]string) { + t.Helper() + + for name, content := range files { + path := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) //nolint:gosec + } +} diff --git a/cli/manifest/install/runtime.go b/cli/manifest/install/runtime.go new file mode 100644 index 000000000..04a802045 --- /dev/null +++ b/cli/manifest/install/runtime.go @@ -0,0 +1,135 @@ +package install + +import ( + "path/filepath" + + "github.com/tarantool/go-luarocks/deps" + + "github.com/tarantool/tt/cli/manifest" +) + +// runtimePath is the install-root's _runtime/ directory. +func runtimePath(lay layout) string { + return filepath.Join(lay.root, runtimeDirName) +} + +// versionHigher reports whether candidate is a strictly higher version than +// current. An unparseable or empty current counts as lower, so a first install +// (no recorded version) always upgrades. +func versionHigher(candidate, current string) bool { + cand, err := deps.ParseVersion(candidate) + if err != nil { + return false + } + + cur, err := deps.ParseVersion(current) + if err != nil { + return true + } + + return deps.Compare(cand, cur) > 0 +} + +// selectProduct picks the product whose closure the archive's lock carries: the +// only product, or the one marked default. A build/pack always resolves against +// one product, so the archive lock normally holds exactly one. +func selectProduct(man *manifest.Manifest) (string, error) { + if len(man.Products) == 1 { + for name := range man.Products { + return name, nil + } + } + + for name, prod := range man.Products { + if prod.Default { + return name, nil + } + } + + if len(man.Products) == 0 { + return "", stateErrorf("archive manifest defines no products") + } + + return "", stateErrorf("archive manifest has several products and none is default") +} + +// checkRuntime validates the bundled runtime of a with-deps project install +// against the runtime the project already fixed. The primary package's +// [platform] constraints govern; when there is no primary package the first +// install sets the versions and later ones must match what is on disk. +// +// The on-disk runtime carries no queryable version, so the check is only as +// strong as the primary package's declared constraints: a bundled runtime that +// violates them is refused (exit 1); with no primary package and no constraints, +// the runtime is accepted and shared (the first install's tree is kept). +func checkRuntime(opts Options, header *Header, installed []installedPackage) error { + if !header.WithDeps || opts.Scope != ScopeProject { + return nil + } + + primary, ok := findPrimary(installed) + if !ok { + return nil + } + + plat := primary.manifest.Platform + + checks := []struct { + name string + bundled string + constraint manifest.Constraint + }{ + {"tarantool", header.Lock.BundledTarantool, plat.Tarantool}, + {"tt", header.Lock.BundledTt, plat.Tt}, + {"tcm", header.Lock.BundledTcm, plat.Tcm}, + } + + for _, check := range checks { + if check.bundled == "" || check.constraint.Version == "" { + continue + } + + ok, err := runtimeSatisfies(check.bundled, check.constraint.Version) + if err != nil { + return err + } + + if !ok { + return stateErrorf( + "%w: bundled %s %s does not satisfy the project's requirement %q (from %s)", + errRuntimeMismatch, check.name, check.bundled, check.constraint.Version, + primary.name) + } + } + + return nil +} + +// findPrimary returns the project's primary package among the installed set. +func findPrimary(installed []installedPackage) (installedPackage, bool) { + for _, pkg := range installed { + if pkg.primary && pkg.manifest != nil { + return pkg, true + } + } + + var zero installedPackage + + return zero, false +} + +// runtimeSatisfies reports whether a concrete bundled version satisfies a +// constraint expression. +func runtimeSatisfies(version, constraint string) (bool, error) { + parsed, err := deps.ParseVersion(version) + if err != nil { + return false, stateErrorf("unparseable bundled version %q: %w", version, err) + } + + constraints, err := deps.ParseConstraints(constraint) + if err != nil { + return false, stateErrorf("unparseable platform constraint %q: %w", constraint, err) + } + + return deps.Match(parsed, constraints), nil +} diff --git a/cli/manifest/install/scope.go b/cli/manifest/install/scope.go new file mode 100644 index 000000000..7cf356017 --- /dev/null +++ b/cli/manifest/install/scope.go @@ -0,0 +1,127 @@ +package install + +import ( + "fmt" + "os" + "path/filepath" +) + +// Scope is where tt package install lays a package down. The three scopes +// differ in install-root and in the on-disk layout Tarantool's loaders expect; +// they also differ in what archive they accept (only project takes a with-deps +// archive). +type Scope string + +const ( + // ScopeProject installs into /.rocks/, the self-contained deployment + // tree. It is the default and the only scope that accepts a with-deps + // archive (the bundled _runtime/ has no place in a user or system tree). + ScopeProject Scope = "project" + // ScopeUser installs into ~/.luarocks/, the personal LuaRocks tree. It + // accepts only a --without-deps archive. + ScopeUser Scope = "user" + // ScopeSystem installs into /usr/, the OS-package-style tree. It needs root + // and accepts only a --without-deps archive. + ScopeSystem Scope = "system" +) + +// ParseScope validates a --scope value, defaulting an empty string to project. +func ParseScope(raw string) (Scope, error) { + switch Scope(raw) { + case "", ScopeProject: + return ScopeProject, nil + case ScopeUser: + return ScopeUser, nil + case ScopeSystem: + return ScopeSystem, nil + default: + return "", fmt.Errorf("%w %q (want project, user or system)", errUnknownScope, raw) + } +} + +// AcceptsWithDeps reports whether the scope may receive a with-deps archive. +// Only project does: user and system are shared LuaRocks trees where bundling a +// runtime is meaningless, so a with-deps archive is rejected before any write. +func (scope Scope) AcceptsWithDeps() bool { + return scope == ScopeProject +} + +// layout is the resolved set of directories one scope installs into. +type layout struct { + // root is the install-root: for project, the tree root otherwise. + root string + // tree is the rocks tree root the go-luarocks adapter reads and installs + // into (/.rocks for project, the root itself for user/system). + tree string + // share and lib are the module directories a package's own files and its + // dependencies land under, relative subtrees of the tree. + share string + lib string + // manifests is where tt records per-package install metadata + // (.rocks/manifests//) used by list/uninstall and dependency + // refcounting. + manifests string +} + +// resolveLayout maps a scope to its concrete directories. projectDir is the +// install-root for project scope (the caller's cwd); user and system derive +// their roots from the environment. The returned paths are absolute. +func resolveLayout(scope Scope, projectDir string) (layout, error) { + switch scope { + case ScopeProject: + tree := filepath.Join(projectDir, rocksDirName) + + return layout{ + root: projectDir, + tree: tree, + share: filepath.Join(tree, shareTarantool), + lib: filepath.Join(tree, libTarantool), + manifests: filepath.Join(tree, manifestsDirName), + }, nil + case ScopeUser: + home, err := os.UserHomeDir() + if err != nil { + var zero layout + + return zero, fmt.Errorf("locating home directory: %w", err) + } + + root := filepath.Join(home, ".luarocks") + + return layout{ + root: root, + tree: root, + share: filepath.Join(root, shareLua), + lib: filepath.Join(root, libLua), + manifests: filepath.Join(root, manifestsDirName), + }, nil + case ScopeSystem: + root := systemRoot + + return layout{ + root: root, + tree: root, + share: filepath.Join(root, shareTarantool), + lib: filepath.Join(root, libTarantool), + manifests: filepath.Join(root, manifestsDirName), + }, nil + default: + var zero layout + + return zero, fmt.Errorf("%w %q", errUnknownScope, scope) + } +} + +// Rocks-tree layout roots. The project and system scopes follow the Tarantool +// convention (share/tarantool, lib/tarantool); the user scope follows the +// LuaRocks 5.1 convention. +const ( + rocksDirName = ".rocks" + manifestsDirName = "manifests" + shareTarantool = "share/tarantool" + libTarantool = "lib/tarantool" + shareLua = "share/lua/5.1" + libLua = "lib/lua/5.1" + // systemRoot is the OS-package install-root. + systemRoot = "/usr" +) diff --git a/cli/manifest/install/scope_internal_test.go b/cli/manifest/install/scope_internal_test.go new file mode 100644 index 000000000..2a6fd6943 --- /dev/null +++ b/cli/manifest/install/scope_internal_test.go @@ -0,0 +1,54 @@ +package install + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestParseScope covers the accepted values and the default. +func TestParseScope(t *testing.T) { + t.Parallel() + + for input, want := range map[string]Scope{ + "": ScopeProject, + "project": ScopeProject, + "user": ScopeUser, + "system": ScopeSystem, + } { + got, err := ParseScope(input) + require.NoError(t, err, input) + assert.Equal(t, want, got, input) + } + + _, err := ParseScope("global") + require.Error(t, err) + assert.ErrorIs(t, err, errUnknownScope) +} + +// TestAcceptsWithDeps pins the rule that only project takes a with-deps archive. +func TestAcceptsWithDeps(t *testing.T) { + t.Parallel() + + assert.True(t, ScopeProject.AcceptsWithDeps()) + assert.False(t, ScopeUser.AcceptsWithDeps()) + assert.False(t, ScopeSystem.AcceptsWithDeps()) +} + +// TestResolveLayoutProject fixes the project-scope directory layout. +func TestResolveLayoutProject(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + lay, err := resolveLayout(ScopeProject, dir) + require.NoError(t, err) + + assert.Equal(t, dir, lay.root) + assert.Equal(t, filepath.Join(dir, ".rocks"), lay.tree) + assert.Equal(t, filepath.Join(dir, ".rocks", "share", "tarantool"), lay.share) + assert.Equal(t, filepath.Join(dir, ".rocks", "lib", "tarantool"), lay.lib) + assert.Equal(t, filepath.Join(dir, ".rocks", "manifests"), lay.manifests) +} diff --git a/cli/manifest/install/state.go b/cli/manifest/install/state.go new file mode 100644 index 000000000..cc5457944 --- /dev/null +++ b/cli/manifest/install/state.go @@ -0,0 +1,216 @@ +package install + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/tarantool/tt/cli/manifest" +) + +// Per-package metadata file names inside .rocks/manifests//. +const ( + metaManifestFile = "manifest.toml" + metaLockFile = "lock.toml" + metaVersionFile = "VERSION" + // primaryManifestFile / primaryLockFile are the primary package's files in + // the install-root itself, not under manifests/. + primaryManifestFile = manifestFileName + primaryLockFile = lockFileName + // metaFilePerm is the mode metadata files are written with: world-readable, + // matching the rocks tree they sit beside. + metaFilePerm = 0o644 +) + +// installedPackage is one package already present in a scope: the primary +// package the project develops (project scope only) or a guest installed +// earlier. Its manifest and lock drive collision checks and the joint +// resolution of shared dependencies. +type installedPackage struct { + // name is the package name. + name string + // manifest is its parsed manifest; nil if unreadable (a guest may predate a + // manifest-writing tt, and the primary package need not exist). + manifest *manifest.Manifest + // lock is its parsed lock; nil if unreadable. + lock *manifest.Lock + // version is the recorded version string, for --upgrade comparisons. + version string + // primary marks the project's own package, which uninstall refuses to touch. + primary bool +} + +// installedPackages enumerates every package already present in the scope: the +// primary package (project scope, when /app.manifest.toml exists) plus +// every guest under manifests/. A guest whose metadata is missing or unreadable +// is skipped rather than failing the whole install — a half-written earlier +// install must not wedge every later one. +func installedPackages(lay layout, scope Scope) ([]installedPackage, error) { + var packages []installedPackage + + if scope == ScopeProject { + if primary, ok := readPrimary(lay.root); ok { + packages = append(packages, primary) + } + } + + entries, err := os.ReadDir(lay.manifests) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return packages, nil + } + + return nil, fmt.Errorf("reading install state %s: %w", lay.manifests, err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + guest, ok := readGuest(lay.manifests, entry.Name()) + if ok { + packages = append(packages, guest) + } + } + + return packages, nil +} + +// findInstalled returns the installed package with the given name, if any. +func findInstalled(packages []installedPackage, name string) (installedPackage, bool) { + for _, p := range packages { + if p.name == name { + return p, true + } + } + + var zero installedPackage + + return zero, false +} + +// readPrimary reads the project's own package from the install-root. The +// manifest is optional (a bare deploy directory has none); when present its lock +// beside it is read too. +func readPrimary(root string) (installedPackage, bool) { + var zero installedPackage + + //nolint:gosec // Reads tt's own install-state files. + manBytes, err := os.ReadFile(filepath.Join(root, primaryManifestFile)) + if err != nil { + return zero, false + } + + man, _, err := manifest.ParseManifest(manBytes) + if err != nil { + return zero, false + } + + pkg := installedPackage{ + name: man.Package.Name, + manifest: man, + lock: readLockFile(filepath.Join(root, primaryLockFile)), + version: "", + primary: true, + } + + return pkg, true +} + +// readGuest reads one installed guest's metadata from manifests//. +func readGuest(manifestsDir, name string) (installedPackage, bool) { + var zero installedPackage + + dir := filepath.Join(manifestsDir, name) + + //nolint:gosec // Reads tt's own install-state files. + manBytes, err := os.ReadFile(filepath.Join(dir, metaManifestFile)) + if err != nil { + return zero, false + } + + man, _, err := manifest.ParseManifest(manBytes) + if err != nil { + return zero, false + } + + pkg := installedPackage{ + name: man.Package.Name, + manifest: man, + lock: readLockFile(filepath.Join(dir, metaLockFile)), + version: readVersionFile(filepath.Join(dir, metaVersionFile)), + primary: false, + } + + return pkg, true +} + +// readLockFile parses a lock file, returning nil when it is absent or unreadable +// — a guest may predate a lock-writing tt, and the primary package need not have +// a lock at all. +func readLockFile(path string) *manifest.Lock { + data, err := os.ReadFile(path) //nolint:gosec // Reads tt's own install-state files. + if err != nil { + return nil + } + + lock, err := manifest.ParseLock(data) + if err != nil { + return nil + } + + return lock +} + +// readVersionFile reads a VERSION file, returning "" when it is absent. +func readVersionFile(path string) string { + data, err := os.ReadFile(path) //nolint:gosec // Reads tt's own install-state files. + if err != nil { + return "" + } + + return strings.TrimSpace(string(data)) +} + +// writeMetadata records a freshly installed guest's metadata under +// manifests//: the manifest, the lock and the version. This is the +// inventory list/uninstall read and the ledger dependency refcounting stands +// on — a dependency is owned by every package whose lock lists it. +func writeMetadata(lay layout, header *Header) error { + dir := filepath.Join(lay.manifests, header.Manifest.Package.Name) + + err := os.MkdirAll(dir, dirPerm) + if err != nil { + return fmt.Errorf("creating metadata directory: %w", err) + } + + lockBytes, err := header.Lock.Marshal() + if err != nil { + return fmt.Errorf("serializing lock metadata: %w", err) + } + + files := []struct { + name string + data []byte + }{ + {metaManifestFile, header.ManifestBytes}, + {metaLockFile, lockBytes}, + {metaVersionFile, []byte(header.Version + "\n")}, + } + + for _, file := range files { + // Install metadata is world-readable by design, matching the rocks tree. + path := filepath.Join(dir, file.name) + + err := os.WriteFile(path, file.data, metaFilePerm) + if err != nil { + return fmt.Errorf("writing %s metadata: %w", file.name, err) + } + } + + return nil +} From 65650ce74796c16a9bf8edcb9143ae420a50f0e0 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 27 Jul 2026 11:58:23 +0300 Subject: [PATCH 2/3] cli/manifest/state: extract the on-disk install state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install writes the per-package metadata under manifests//, and list/uninstall read it back. The three have to agree on the layout down to the directory name, so keeping the scope resolution, the metadata file names and the installed-package reader inside the install package would mean the inventory commands either duplicate them or import a package named after the one operation they do not perform. Move them into cli/manifest/state: Scope and ResolveLayout, the Package reader over manifests/ plus the primary package, WriteMetadata and its new inverse RemoveMetadata, and the rock path helpers install already used to drop a stale version. install keeps Scope as an alias so the CLI wiring is unchanged, and wraps the state errors so a bad --scope still exits 1. The ownership ledger uninstall will stand on is derived, not stored: a dependency is owned by every installed package whose lock pins it, so LockedDependencies over what is left under manifests/ is the whole count. Nothing has to be kept in sync by hand. Also read the primary package's VERSION file, which install had no use for but list does, and note in .golangci.yml that depguard matches the longest prefix — adding a sub-package to an allow list shadows the broader entry for its siblings. Part of TNTP-8614 --- .golangci.yml | 9 + cli/manifest/install/errors.go | 3 - cli/manifest/install/extract.go | 12 +- cli/manifest/install/install.go | 35 ++- cli/manifest/install/install_internal_test.go | 3 +- cli/manifest/install/plan.go | 92 +----- cli/manifest/install/plan_internal_test.go | 5 +- cli/manifest/install/runtime.go | 26 +- cli/manifest/install/scope.go | 131 ++------ cli/manifest/install/scope_internal_test.go | 54 ---- cli/manifest/install/state.go | 208 +------------ cli/manifest/state/helpers_test.go | 143 +++++++++ cli/manifest/state/packages.go | 292 ++++++++++++++++++ cli/manifest/state/packages_test.go | 290 +++++++++++++++++ cli/manifest/state/rocks.go | 69 +++++ cli/manifest/state/rocks_test.go | 88 ++++++ cli/manifest/state/scope.go | 157 ++++++++++ cli/manifest/state/scope_test.go | 114 +++++++ 18 files changed, 1240 insertions(+), 491 deletions(-) delete mode 100644 cli/manifest/install/scope_internal_test.go create mode 100644 cli/manifest/state/helpers_test.go create mode 100644 cli/manifest/state/packages.go create mode 100644 cli/manifest/state/packages_test.go create mode 100644 cli/manifest/state/rocks.go create mode 100644 cli/manifest/state/rocks_test.go create mode 100644 cli/manifest/state/scope.go create mode 100644 cli/manifest/state/scope_test.go diff --git a/.golangci.yml b/.golangci.yml index a00029cd8..4285b88eb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -113,6 +113,15 @@ linters: # cli/manifest/install refetches dependencies through the luarocks # adapter and reuses the build package's exit-code machinery. - "github.com/tarantool/tt/cli/manifest/rocks" + # cli/manifest/install and cli/manifest/inventory both read and write + # the on-disk install state that cli/manifest/state owns. + # + # NOTE: depguard matches the longest prefix, not any prefix. Listing a + # sub-package here shadows the broader "…/cli/manifest" entry for its + # siblings, so an entry added for one package can start flagging + # imports that used to pass. Add sub-packages only where the broader + # entry is already shadowed, as it is in this list. + - "github.com/tarantool/tt/cli/manifest/state" test: files: - "$test" diff --git a/cli/manifest/install/errors.go b/cli/manifest/install/errors.go index ac0ed60e7..7179fcc1f 100644 --- a/cli/manifest/install/errors.go +++ b/cli/manifest/install/errors.go @@ -27,9 +27,6 @@ const ( ) var ( - // errUnknownScope reports a --scope value that is not project, user or - // system. - errUnknownScope = errors.New("unknown scope") // errWithDepsScope reports a with-deps archive aimed at user or system, // which accept only --without-deps archives. Caught from the archive header, // before anything is written. diff --git a/cli/manifest/install/extract.go b/cli/manifest/install/extract.go index 34b6eea91..91295571b 100644 --- a/cli/manifest/install/extract.go +++ b/cli/manifest/install/extract.go @@ -1,6 +1,10 @@ package install -import "strings" +import ( + "strings" + + "github.com/tarantool/tt/cli/manifest/state" +) // Path-segment counts for a rock's location under the .rocks/ tree: // share|lib / tarantool / is three deep, and the rock-manifest path @@ -36,7 +40,7 @@ func extractMapper( } return name, true - case rocksDirName: + case state.RocksDirName: return mapRocksEntry(scope, rest, skipNames) default: return "", false @@ -53,7 +57,7 @@ func mapRocksEntry(scope Scope, rest string, skipNames map[string]struct{}) (str // tt's own install state never travels inside an archive; ignore it if it // somehow appears. - if top, _, _ := strings.Cut(rest, "/"); top == manifestsDirName { + if top, _, _ := strings.Cut(rest, "/"); top == state.ManifestsDirName { return "", false } @@ -64,7 +68,7 @@ func mapRocksEntry(scope Scope, rest string, skipNames map[string]struct{}) (str } if scope == ScopeProject { - return rocksDirName + "/" + rest, true + return state.RocksDirName + "/" + rest, true } // user / system: the shared tree has no .rocks/ level. diff --git a/cli/manifest/install/install.go b/cli/manifest/install/install.go index 2232d4e52..9666fc44b 100644 --- a/cli/manifest/install/install.go +++ b/cli/manifest/install/install.go @@ -21,6 +21,7 @@ import ( "github.com/tarantool/tt/cli/manifest" "github.com/tarantool/tt/cli/manifest/rocks" + "github.com/tarantool/tt/cli/manifest/state" ) // Source constants mirror the closed set the resolver writes into the lock. @@ -107,7 +108,7 @@ type Result struct { func Run(ctx context.Context, opts Options) (*Result, error) { scope, err := ParseScope(string(opts.Scope)) if err != nil { - return nil, stateErrorf("%w", err) + return nil, err } opts.Scope = scope @@ -174,14 +175,14 @@ func installOne(ctx context.Context, opts Options, archivePath string) (*OneResu } } - lay, err := resolveLayout(opts.Scope, opts.ProjectDir) + lay, err := state.ResolveLayout(opts.Scope, opts.ProjectDir) if err != nil { - return nil, err + return nil, stateErrorf("%w", err) } - installed, err := installedPackages(lay, opts.Scope) + installed, err := state.Packages(lay, opts.Scope) if err != nil { - return nil, err + return nil, stateErrorf("%w", err) } skip, err := checkCollision(opts, header, installed) @@ -215,15 +216,15 @@ func installOne(ctx context.Context, opts Options, archivePath string) (*OneResu // checkCollision enforces the name-collision policy and reports whether the // install is a no-op (a --upgrade with nothing newer). A collision without // --force or --upgrade is exit 1. -func checkCollision(opts Options, header *Header, installed []installedPackage) (bool, error) { +func checkCollision(opts Options, header *Header, installed []state.Package) (bool, error) { name := header.Manifest.Package.Name - existing, ok := findInstalled(installed, name) + existing, ok := state.Find(installed, name) if !ok { return false, nil } - if existing.primary { + if existing.Primary { return false, stateErrorf( "%w: %q is the project's primary package, not a guest", errNameCollision, name) } @@ -232,10 +233,10 @@ func checkCollision(opts Options, header *Header, installed []installedPackage) case opts.Force: return false, nil case opts.Upgrade: - if !versionHigher(header.Version, existing.version) { + if !versionHigher(header.Version, existing.Version) { opts.warn(fmt.Sprintf( "%s %s is not newer than the installed %s; nothing to do", - name, header.Version, existing.version)) + name, header.Version, existing.Version)) return true, nil } @@ -243,7 +244,7 @@ func checkCollision(opts Options, header *Header, installed []installedPackage) return false, nil default: return false, stateErrorf("%w: %q %s (use --upgrade or --force)", - errNameCollision, name, existing.version) + errNameCollision, name, existing.Version) } } @@ -251,7 +252,7 @@ func checkCollision(opts Options, header *Header, installed []installedPackage) // extract the archive, and refetch any registry-sourced dependency. func apply( ctx context.Context, opts Options, archive *Archive, - header *Header, lay layout, installed []installedPackage, + header *Header, lay state.Layout, installed []state.Package, ) error { productName, err := selectProduct(header.Manifest) if err != nil { @@ -277,7 +278,7 @@ func apply( mapper := extractMapper(opts.Scope, plan.skipNames, extractRuntime) - err = archive.Extract(lay.root, mapper) + err = archive.Extract(lay.Root, mapper) if err != nil { return fmt.Errorf("extracting %s: %w", archive.Path(), err) } @@ -323,15 +324,15 @@ type rockInstaller interface { // refetch installs every registry-sourced dependency of the plan into the tree // at its reconciled version. It builds the rocks adapter lazily, so an offline // with-deps install (no registry decisions) never needs Tarantool. -func refetch(ctx context.Context, opts Options, lay layout, plan installPlan) error { +func refetch(ctx context.Context, opts Options, lay state.Layout, plan installPlan) error { pending := registryDecisions(plan) if len(pending) == 0 { return nil } adapter := rocks.New(rocks.BuildConfig(opts.Tarantool, rocks.ConfigOptions{ - Tree: lay.tree, - WorkingDir: lay.root, + Tree: lay.Tree, + WorkingDir: lay.Root, Servers: opts.Servers, Logger: opts.Logger, })) @@ -384,7 +385,7 @@ func installDeps( } // runtimeExists reports whether the install-root already holds a _runtime/ tree. -func runtimeExists(lay layout) bool { +func runtimeExists(lay state.Layout) bool { info, err := os.Stat(runtimePath(lay)) return err == nil && info.IsDir() diff --git a/cli/manifest/install/install_internal_test.go b/cli/manifest/install/install_internal_test.go index 2146feb41..b0944f2be 100644 --- a/cli/manifest/install/install_internal_test.go +++ b/cli/manifest/install/install_internal_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/state" ) // sharedDep is the single registry dependency the multi-package tests share. @@ -76,7 +77,7 @@ func TestInstallWritesMetadata(t *testing.T) { lock, err := manifest.ParseLock([]byte(readFile(t, filepath.Join(metaDir, "lock.toml")))) require.NoError(t, err) - pin, ok := lockedVersion(lock, "luasocket") + pin, ok := state.LockedVersion(lock, "luasocket") require.True(t, ok) assert.Equal(t, "3.0.4", pin) } diff --git a/cli/manifest/install/plan.go b/cli/manifest/install/plan.go index bd10b2b4e..b4b38afe5 100644 --- a/cli/manifest/install/plan.go +++ b/cli/manifest/install/plan.go @@ -3,11 +3,9 @@ package install import ( "fmt" "os" - "path/filepath" - - "github.com/tarantool/go-luarocks/deps" "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/state" ) // depSource says where a dependency's files come from in the resolved plan. @@ -53,7 +51,7 @@ type installPlan struct { // place. withDeps selects the archive vs registry source for a dependency the // installed tree does not already satisfy. func planDeps( - header *Header, productName string, installed []installedPackage, lay layout, withDeps bool, + header *Header, productName string, installed []state.Package, lay state.Layout, withDeps bool, ) (installPlan, error) { prod, ok := header.Lock.Products[productName] if !ok { @@ -97,7 +95,7 @@ func planDeps( // planRegistryDep reconciles one registry dependency and decides its source. func planRegistryDep( dep manifest.LockDependency, header *Header, - installed []installedPackage, lay layout, withDeps bool, + installed []state.Package, lay state.Layout, withDeps bool, ) (depDecision, error) { contributions := collectContributions(dep.Name, dep.Version, header.Manifest, installed) @@ -108,14 +106,14 @@ func planRegistryDep( return zero, err } - onDisk, hasOnDisk := installedVersion(lay, dep.Name) + onDisk, hasOnDisk := state.InstalledVersion(lay, dep.Name) switch { case winner == dep.Version: // The archive carries the winning version. Replace a differing installed // copy; a with-deps archive extracts it, a --without-deps one refetches it. remove := "" - if hasOnDisk && !sameVersion(onDisk, winner) { + if hasOnDisk && !state.SameVersion(onDisk, winner) { remove = onDisk } @@ -125,7 +123,7 @@ func planRegistryDep( } return depDecision{name: dep.Name, version: winner, source: src, removeVersion: remove}, nil - case hasOnDisk && sameVersion(onDisk, winner): + case hasOnDisk && state.SameVersion(onDisk, winner): // The reconciled version is already installed; leave it in place. return depDecision{ name: dep.Name, version: winner, source: skipDep, removeVersion: "", @@ -156,7 +154,7 @@ func planRegistryDep( // package puts on a shared dependency: the package being installed, plus every // already-installed package whose lock also pins it. func collectContributions( - dep, newPin string, newManifest *manifest.Manifest, installed []installedPackage, + dep, newPin string, newManifest *manifest.Manifest, installed []state.Package, ) []contribution { contributions := []contribution{{ pkg: newManifest.Package.Name, @@ -165,19 +163,19 @@ func collectContributions( }} for _, pkg := range installed { - if pkg.lock == nil { + if pkg.Lock == nil { continue } - pin, ok := lockedVersion(pkg.lock, dep) + pin, ok := state.LockedVersion(pkg.Lock, dep) if !ok { continue } contributions = append(contributions, contribution{ - pkg: pkg.name, + pkg: pkg.Name, pin: pin, - constraint: declaredConstraint(pkg.manifest, dep), + constraint: declaredConstraint(pkg.Manifest, dep), }) } @@ -229,80 +227,16 @@ func joinConstraints(parts []string) string { return out } -// lockedVersion returns the version a lock pins for a dependency across all its -// products. -func lockedVersion(lock *manifest.Lock, dep string) (string, bool) { - for _, prod := range lock.Products { - for _, d := range prod.Dependencies { - if d.Name == dep { - return d.Version, true - } - } - } - - return "", false -} - -// installedVersion reads the version of a rock currently installed in the tree -// from /share/tarantool/rocks///. It returns the first -// version directory found; a well-formed tree holds exactly one per rock. -func installedVersion(lay layout, dep string) (string, bool) { - rocksDir := filepath.Join(lay.share, "rocks", dep) - - entries, err := os.ReadDir(rocksDir) - if err != nil { - return "", false - } - - for _, entry := range entries { - if entry.IsDir() { - return entry.Name(), true - } - } - - return "", false -} - -// sameVersion reports whether two version strings denote the same version, -// tolerating formatting differences (a trailing "-1" revision, say). -func sameVersion(left, right string) bool { - if left == right { - return true - } - - parsedLeft, err := deps.ParseVersion(left) - if err != nil { - return false - } - - parsedRight, err := deps.ParseVersion(right) - if err != nil { - return false - } - - return deps.Compare(parsedLeft, parsedRight) == 0 -} - -// rockRemovePaths lists the on-disk paths that hold one version of a rock: its -// module trees under share/ and lib/, and its rock-manifest directory. -func rockRemovePaths(lay layout, dep, version string) []string { - return []string{ - filepath.Join(lay.share, dep), - filepath.Join(lay.lib, dep), - filepath.Join(lay.share, "rocks", dep, version), - } -} - // removeStaleVersions deletes the on-disk files of any dependency version the // plan is replacing, so a reconciled upgrade does not leave two versions of a // rock side by side. -func removeStaleVersions(lay layout, plan installPlan) error { +func removeStaleVersions(lay state.Layout, plan installPlan) error { for _, decision := range plan.decisions { if decision.removeVersion == "" { continue } - for _, stalePath := range rockRemovePaths(lay, decision.name, decision.removeVersion) { + for _, stalePath := range state.RockPaths(lay, decision.name, decision.removeVersion) { err := os.RemoveAll(stalePath) if err != nil { return fmt.Errorf("removing stale %s %s: %w", diff --git a/cli/manifest/install/plan_internal_test.go b/cli/manifest/install/plan_internal_test.go index fbb95d1c8..5849b6d16 100644 --- a/cli/manifest/install/plan_internal_test.go +++ b/cli/manifest/install/plan_internal_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tarantool/tt/cli/manifest/state" ) // headerFor parses an archiveSpec into a Header without writing an archive, for @@ -33,7 +34,7 @@ func TestPlanWithoutDepsRoutesToRegistry(t *testing.T) { lockDeps: []LockDep{{Name: "luasocket", Version: "3.0.4", Source: "registry"}}, }) - lay, err := resolveLayout(ScopeProject, t.TempDir()) + lay, err := state.ResolveLayout(ScopeProject, t.TempDir()) require.NoError(t, err) plan, err := planDeps(header, "default", nil, lay, false) @@ -56,7 +57,7 @@ func TestPlanWithDepsRoutesToArchive(t *testing.T) { lockDeps: []LockDep{{Name: "luasocket", Version: "3.0.4", Source: "registry"}}, }) - lay, err := resolveLayout(ScopeProject, t.TempDir()) + lay, err := state.ResolveLayout(ScopeProject, t.TempDir()) require.NoError(t, err) plan, err := planDeps(header, "default", nil, lay, true) diff --git a/cli/manifest/install/runtime.go b/cli/manifest/install/runtime.go index 04a802045..4bac89db9 100644 --- a/cli/manifest/install/runtime.go +++ b/cli/manifest/install/runtime.go @@ -6,11 +6,12 @@ import ( "github.com/tarantool/go-luarocks/deps" "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/state" ) // runtimePath is the install-root's _runtime/ directory. -func runtimePath(lay layout) string { - return filepath.Join(lay.root, runtimeDirName) +func runtimePath(lay state.Layout) string { + return filepath.Join(lay.Root, runtimeDirName) } // versionHigher reports whether candidate is a strictly higher version than @@ -62,17 +63,17 @@ func selectProduct(man *manifest.Manifest) (string, error) { // strong as the primary package's declared constraints: a bundled runtime that // violates them is refused (exit 1); with no primary package and no constraints, // the runtime is accepted and shared (the first install's tree is kept). -func checkRuntime(opts Options, header *Header, installed []installedPackage) error { +func checkRuntime(opts Options, header *Header, installed []state.Package) error { if !header.WithDeps || opts.Scope != ScopeProject { return nil } - primary, ok := findPrimary(installed) + primary, ok := state.FindPrimary(installed) if !ok { return nil } - plat := primary.manifest.Platform + plat := primary.Manifest.Platform checks := []struct { name string @@ -98,26 +99,13 @@ func checkRuntime(opts Options, header *Header, installed []installedPackage) er return stateErrorf( "%w: bundled %s %s does not satisfy the project's requirement %q (from %s)", errRuntimeMismatch, check.name, check.bundled, check.constraint.Version, - primary.name) + primary.Name) } } return nil } -// findPrimary returns the project's primary package among the installed set. -func findPrimary(installed []installedPackage) (installedPackage, bool) { - for _, pkg := range installed { - if pkg.primary && pkg.manifest != nil { - return pkg, true - } - } - - var zero installedPackage - - return zero, false -} - // runtimeSatisfies reports whether a concrete bundled version satisfies a // constraint expression. func runtimeSatisfies(version, constraint string) (bool, error) { diff --git a/cli/manifest/install/scope.go b/cli/manifest/install/scope.go index 7cf356017..52cc54773 100644 --- a/cli/manifest/install/scope.go +++ b/cli/manifest/install/scope.go @@ -1,127 +1,32 @@ package install -import ( - "fmt" - "os" - "path/filepath" -) +import "github.com/tarantool/tt/cli/manifest/state" -// Scope is where tt package install lays a package down. The three scopes -// differ in install-root and in the on-disk layout Tarantool's loaders expect; -// they also differ in what archive they accept (only project takes a with-deps -// archive). -type Scope string +// Scope and its values are re-exported from cli/manifest/state, which owns the +// on-disk layout install writes and list/uninstall read. The aliases keep +// install.Scope usable from the CLI wiring without every caller reaching into +// the state package for a flag value. +type Scope = state.Scope const ( // ScopeProject installs into /.rocks/, the self-contained deployment // tree. It is the default and the only scope that accepts a with-deps - // archive (the bundled _runtime/ has no place in a user or system tree). - ScopeProject Scope = "project" - // ScopeUser installs into ~/.luarocks/, the personal LuaRocks tree. It - // accepts only a --without-deps archive. - ScopeUser Scope = "user" - // ScopeSystem installs into /usr/, the OS-package-style tree. It needs root - // and accepts only a --without-deps archive. - ScopeSystem Scope = "system" + // archive. + ScopeProject = state.ScopeProject + // ScopeUser installs into ~/.luarocks/, the personal LuaRocks tree. + ScopeUser = state.ScopeUser + // ScopeSystem installs into /usr/, the OS-package-style tree. + ScopeSystem = state.ScopeSystem ) // ParseScope validates a --scope value, defaulting an empty string to project. +// A bad value is a state error, so a mistyped --scope exits 1 like every other +// usage failure rather than escaping as an untyped error. func ParseScope(raw string) (Scope, error) { - switch Scope(raw) { - case "", ScopeProject: - return ScopeProject, nil - case ScopeUser: - return ScopeUser, nil - case ScopeSystem: - return ScopeSystem, nil - default: - return "", fmt.Errorf("%w %q (want project, user or system)", errUnknownScope, raw) + scope, err := state.ParseScope(raw) + if err != nil { + return "", stateErrorf("%w", err) } -} - -// AcceptsWithDeps reports whether the scope may receive a with-deps archive. -// Only project does: user and system are shared LuaRocks trees where bundling a -// runtime is meaningless, so a with-deps archive is rejected before any write. -func (scope Scope) AcceptsWithDeps() bool { - return scope == ScopeProject -} - -// layout is the resolved set of directories one scope installs into. -type layout struct { - // root is the install-root: for project, the tree root otherwise. - root string - // tree is the rocks tree root the go-luarocks adapter reads and installs - // into (/.rocks for project, the root itself for user/system). - tree string - // share and lib are the module directories a package's own files and its - // dependencies land under, relative subtrees of the tree. - share string - lib string - // manifests is where tt records per-package install metadata - // (.rocks/manifests//) used by list/uninstall and dependency - // refcounting. - manifests string -} - -// resolveLayout maps a scope to its concrete directories. projectDir is the -// install-root for project scope (the caller's cwd); user and system derive -// their roots from the environment. The returned paths are absolute. -func resolveLayout(scope Scope, projectDir string) (layout, error) { - switch scope { - case ScopeProject: - tree := filepath.Join(projectDir, rocksDirName) - - return layout{ - root: projectDir, - tree: tree, - share: filepath.Join(tree, shareTarantool), - lib: filepath.Join(tree, libTarantool), - manifests: filepath.Join(tree, manifestsDirName), - }, nil - case ScopeUser: - home, err := os.UserHomeDir() - if err != nil { - var zero layout - return zero, fmt.Errorf("locating home directory: %w", err) - } - - root := filepath.Join(home, ".luarocks") - - return layout{ - root: root, - tree: root, - share: filepath.Join(root, shareLua), - lib: filepath.Join(root, libLua), - manifests: filepath.Join(root, manifestsDirName), - }, nil - case ScopeSystem: - root := systemRoot - - return layout{ - root: root, - tree: root, - share: filepath.Join(root, shareTarantool), - lib: filepath.Join(root, libTarantool), - manifests: filepath.Join(root, manifestsDirName), - }, nil - default: - var zero layout - - return zero, fmt.Errorf("%w %q", errUnknownScope, scope) - } + return scope, nil } - -// Rocks-tree layout roots. The project and system scopes follow the Tarantool -// convention (share/tarantool, lib/tarantool); the user scope follows the -// LuaRocks 5.1 convention. -const ( - rocksDirName = ".rocks" - manifestsDirName = "manifests" - shareTarantool = "share/tarantool" - libTarantool = "lib/tarantool" - shareLua = "share/lua/5.1" - libLua = "lib/lua/5.1" - // systemRoot is the OS-package install-root. - systemRoot = "/usr" -) diff --git a/cli/manifest/install/scope_internal_test.go b/cli/manifest/install/scope_internal_test.go deleted file mode 100644 index 2a6fd6943..000000000 --- a/cli/manifest/install/scope_internal_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package install - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestParseScope covers the accepted values and the default. -func TestParseScope(t *testing.T) { - t.Parallel() - - for input, want := range map[string]Scope{ - "": ScopeProject, - "project": ScopeProject, - "user": ScopeUser, - "system": ScopeSystem, - } { - got, err := ParseScope(input) - require.NoError(t, err, input) - assert.Equal(t, want, got, input) - } - - _, err := ParseScope("global") - require.Error(t, err) - assert.ErrorIs(t, err, errUnknownScope) -} - -// TestAcceptsWithDeps pins the rule that only project takes a with-deps archive. -func TestAcceptsWithDeps(t *testing.T) { - t.Parallel() - - assert.True(t, ScopeProject.AcceptsWithDeps()) - assert.False(t, ScopeUser.AcceptsWithDeps()) - assert.False(t, ScopeSystem.AcceptsWithDeps()) -} - -// TestResolveLayoutProject fixes the project-scope directory layout. -func TestResolveLayoutProject(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - - lay, err := resolveLayout(ScopeProject, dir) - require.NoError(t, err) - - assert.Equal(t, dir, lay.root) - assert.Equal(t, filepath.Join(dir, ".rocks"), lay.tree) - assert.Equal(t, filepath.Join(dir, ".rocks", "share", "tarantool"), lay.share) - assert.Equal(t, filepath.Join(dir, ".rocks", "lib", "tarantool"), lay.lib) - assert.Equal(t, filepath.Join(dir, ".rocks", "manifests"), lay.manifests) -} diff --git a/cli/manifest/install/state.go b/cli/manifest/install/state.go index cc5457944..a7ef9a0f9 100644 --- a/cli/manifest/install/state.go +++ b/cli/manifest/install/state.go @@ -1,215 +1,25 @@ package install import ( - "errors" "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/state" ) -// Per-package metadata file names inside .rocks/manifests//. -const ( - metaManifestFile = "manifest.toml" - metaLockFile = "lock.toml" - metaVersionFile = "VERSION" - // primaryManifestFile / primaryLockFile are the primary package's files in - // the install-root itself, not under manifests/. - primaryManifestFile = manifestFileName - primaryLockFile = lockFileName - // metaFilePerm is the mode metadata files are written with: world-readable, - // matching the rocks tree they sit beside. - metaFilePerm = 0o644 -) - -// installedPackage is one package already present in a scope: the primary -// package the project develops (project scope only) or a guest installed -// earlier. Its manifest and lock drive collision checks and the joint -// resolution of shared dependencies. -type installedPackage struct { - // name is the package name. - name string - // manifest is its parsed manifest; nil if unreadable (a guest may predate a - // manifest-writing tt, and the primary package need not exist). - manifest *manifest.Manifest - // lock is its parsed lock; nil if unreadable. - lock *manifest.Lock - // version is the recorded version string, for --upgrade comparisons. - version string - // primary marks the project's own package, which uninstall refuses to touch. - primary bool -} - -// installedPackages enumerates every package already present in the scope: the -// primary package (project scope, when /app.manifest.toml exists) plus -// every guest under manifests/. A guest whose metadata is missing or unreadable -// is skipped rather than failing the whole install — a half-written earlier -// install must not wedge every later one. -func installedPackages(lay layout, scope Scope) ([]installedPackage, error) { - var packages []installedPackage - - if scope == ScopeProject { - if primary, ok := readPrimary(lay.root); ok { - packages = append(packages, primary) - } - } - - entries, err := os.ReadDir(lay.manifests) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return packages, nil - } - - return nil, fmt.Errorf("reading install state %s: %w", lay.manifests, err) - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - guest, ok := readGuest(lay.manifests, entry.Name()) - if ok { - packages = append(packages, guest) - } - } - - return packages, nil -} - -// findInstalled returns the installed package with the given name, if any. -func findInstalled(packages []installedPackage, name string) (installedPackage, bool) { - for _, p := range packages { - if p.name == name { - return p, true - } - } - - var zero installedPackage - - return zero, false -} - -// readPrimary reads the project's own package from the install-root. The -// manifest is optional (a bare deploy directory has none); when present its lock -// beside it is read too. -func readPrimary(root string) (installedPackage, bool) { - var zero installedPackage - - //nolint:gosec // Reads tt's own install-state files. - manBytes, err := os.ReadFile(filepath.Join(root, primaryManifestFile)) - if err != nil { - return zero, false - } - - man, _, err := manifest.ParseManifest(manBytes) - if err != nil { - return zero, false - } - - pkg := installedPackage{ - name: man.Package.Name, - manifest: man, - lock: readLockFile(filepath.Join(root, primaryLockFile)), - version: "", - primary: true, - } - - return pkg, true -} - -// readGuest reads one installed guest's metadata from manifests//. -func readGuest(manifestsDir, name string) (installedPackage, bool) { - var zero installedPackage - - dir := filepath.Join(manifestsDir, name) - - //nolint:gosec // Reads tt's own install-state files. - manBytes, err := os.ReadFile(filepath.Join(dir, metaManifestFile)) - if err != nil { - return zero, false - } - - man, _, err := manifest.ParseManifest(manBytes) - if err != nil { - return zero, false - } - - pkg := installedPackage{ - name: man.Package.Name, - manifest: man, - lock: readLockFile(filepath.Join(dir, metaLockFile)), - version: readVersionFile(filepath.Join(dir, metaVersionFile)), - primary: false, - } - - return pkg, true -} - -// readLockFile parses a lock file, returning nil when it is absent or unreadable -// — a guest may predate a lock-writing tt, and the primary package need not have -// a lock at all. -func readLockFile(path string) *manifest.Lock { - data, err := os.ReadFile(path) //nolint:gosec // Reads tt's own install-state files. - if err != nil { - return nil - } - - lock, err := manifest.ParseLock(data) - if err != nil { - return nil - } - - return lock -} - -// readVersionFile reads a VERSION file, returning "" when it is absent. -func readVersionFile(path string) string { - data, err := os.ReadFile(path) //nolint:gosec // Reads tt's own install-state files. - if err != nil { - return "" - } - - return strings.TrimSpace(string(data)) -} - // writeMetadata records a freshly installed guest's metadata under -// manifests//: the manifest, the lock and the version. This is the -// inventory list/uninstall read and the ledger dependency refcounting stands -// on — a dependency is owned by every package whose lock lists it. -func writeMetadata(lay layout, header *Header) error { - dir := filepath.Join(lay.manifests, header.Manifest.Package.Name) - - err := os.MkdirAll(dir, dirPerm) - if err != nil { - return fmt.Errorf("creating metadata directory: %w", err) - } - +// manifests//. The format and the directory belong to cli/manifest/state, +// which list and uninstall read back; install only supplies the archive's +// manifest, its lock and the version it carried. +func writeMetadata(lay state.Layout, header *Header) error { lockBytes, err := header.Lock.Marshal() if err != nil { return fmt.Errorf("serializing lock metadata: %w", err) } - files := []struct { - name string - data []byte - }{ - {metaManifestFile, header.ManifestBytes}, - {metaLockFile, lockBytes}, - {metaVersionFile, []byte(header.Version + "\n")}, - } - - for _, file := range files { - // Install metadata is world-readable by design, matching the rocks tree. - path := filepath.Join(dir, file.name) - - err := os.WriteFile(path, file.data, metaFilePerm) - if err != nil { - return fmt.Errorf("writing %s metadata: %w", file.name, err) - } + err = state.WriteMetadata( + lay, header.Manifest.Package.Name, header.ManifestBytes, lockBytes, header.Version) + if err != nil { + return fmt.Errorf("recording install metadata: %w", err) } return nil diff --git a/cli/manifest/state/helpers_test.go b/cli/manifest/state/helpers_test.go new file mode 100644 index 000000000..ae7049d87 --- /dev/null +++ b/cli/manifest/state/helpers_test.go @@ -0,0 +1,143 @@ +package state_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/state" +) + +// manifestTOML renders a minimal but valid manifest for a package, declaring the +// given registry dependency constraints. +func manifestTOML(name string, deps map[string]string) string { + out := "manifest_version = '0.1'\n\n[package]\nname = '" + name + "'\n\n" + + "[platform]\ntarantool = '>=3.0.0'\ntt = '>=3.1.0'\n\n" + + "[products.default]\ncomponents = ['lua']\ndefault = true\n\n" + + "[components.lua]\npath = '.'\n" + + if len(deps) == 0 { + return out + } + + var builder strings.Builder + + builder.WriteString(out) + builder.WriteString("\n[dependencies]\n") + + for _, name := range sortedKeys(deps) { + builder.WriteString(name + " = '" + deps[name] + "'\n") + } + + return builder.String() +} + +// lockTOML renders a lock pinning the given dependencies under the default +// product, with a manifest hash matching the rendered manifest. +func lockTOML(t *testing.T, manifestSource string, pins map[string]string) []byte { + t.Helper() + + depList := make([]manifest.LockDependency, 0, len(pins)) + for _, name := range sortedKeys(pins) { + depList = append(depList, manifest.LockDependency{ + Name: name, Version: pins[name], Source: "registry", + }) + } + + lock := manifest.Lock{ + LockVersion: manifest.LockVersion, + ManifestVersion: manifest.ManifestVersion, + GeneratedBy: "tt 3.0.0", + ManifestHash: manifest.HashBytes([]byte(manifestSource)), + Products: map[string]manifest.LockProduct{ + "default": {Dependencies: depList}, + }, + } + + data, err := lock.Marshal() + require.NoError(t, err) + + return data +} + +// sortedKeys returns a map's keys in sorted order, so rendered TOML is stable. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + // A tiny insertion sort keeps the helper dependency-free and the order fixed. + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && keys[j] < keys[j-1]; j-- { + keys[j], keys[j-1] = keys[j-1], keys[j] + } + } + + return keys +} + +// writeGuest lays a guest package's metadata into manifests//, as an +// install would. +func writeGuest(t *testing.T, lay state.Layout, name, version string, + deps, pins map[string]string, +) { + t.Helper() + + source := manifestTOML(name, deps) + + require.NoError(t, state.WriteMetadata( + lay, name, []byte(source), lockTOML(t, source, pins), version)) +} + +// writePrimary lays the project's own manifest, lock and VERSION into the +// install root, as a developed project would have them. +func writePrimary(t *testing.T, lay state.Layout, name, version string, + deps, pins map[string]string, +) { + t.Helper() + + source := manifestTOML(name, deps) + + writeFile(t, filepath.Join(lay.Root, state.PrimaryManifestFile), []byte(source)) + writeFile(t, filepath.Join(lay.Root, state.PrimaryLockFile), lockTOML(t, source, pins)) + + if version != "" { + writeFile(t, filepath.Join(lay.Root, state.PrimaryVersionFile), []byte(version+"\n")) + } +} + +// writeRock materializes a rock's files in the tree the way an install leaves +// them: a module under share/, a shared object under lib/, and a rock-manifest +// directory at the pinned version. +func writeRock(t *testing.T, lay state.Layout, name, version string) { + t.Helper() + + writeFile(t, filepath.Join(lay.Share, name, "init.lua"), []byte("-- "+name+"\n")) + writeFile(t, filepath.Join(lay.Lib, name, name+".so"), []byte("binary\n")) + writeFile(t, + filepath.Join(lay.Share, "rocks", name, version, "rock_manifest"), + []byte(name+" "+version+"\n")) +} + +// writeFile writes content at path, creating parent directories. +func writeFile(t *testing.T, path string, content []byte) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(t, os.WriteFile(path, content, 0o600)) +} + +// projectLayout resolves a project-scope layout over a fresh temp directory. +func projectLayout(t *testing.T) state.Layout { + t.Helper() + + lay, err := state.ResolveLayout(state.ScopeProject, t.TempDir()) + require.NoError(t, err) + + return lay +} diff --git a/cli/manifest/state/packages.go b/cli/manifest/state/packages.go new file mode 100644 index 000000000..75dfc99b8 --- /dev/null +++ b/cli/manifest/state/packages.go @@ -0,0 +1,292 @@ +package state + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/tarantool/tt/cli/manifest" +) + +// Per-package metadata file names inside /manifests//. +const ( + MetaManifestFile = "manifest.toml" + MetaLockFile = "lock.toml" + MetaVersionFile = "VERSION" + // PrimaryManifestFile / PrimaryLockFile are the primary package's files in + // the install-root itself, not under manifests/. PrimaryVersionFile is the + // optional SemVer pin the version derivation also honours. + PrimaryManifestFile = "app.manifest.toml" + PrimaryLockFile = "app.manifest.lock" + PrimaryVersionFile = "VERSION" +) + +const ( + // metaFilePerm is the mode metadata files are written with: world-readable, + // matching the rocks tree they sit beside. + metaFilePerm = 0o644 + // metaDirPerm is the mode metadata directories are created with. + metaDirPerm = 0o755 +) + +// Package is one package present in a scope: the primary package the project +// develops (project scope only) or a guest installed from an archive. Its +// manifest and lock drive collision checks, the joint resolution of shared +// dependencies at install time, and the ownership ledger at uninstall time. +type Package struct { + // Name is the package name. + Name string + // Manifest is its parsed manifest; nil if unreadable (a guest may predate a + // manifest-writing tt, and the primary package need not exist). + Manifest *manifest.Manifest + // Lock is its parsed lock; nil if unreadable. + Lock *manifest.Lock + // Version is the recorded version string, for --upgrade comparisons and for + // the list inventory. Empty when nothing recorded one. + Version string + // Primary marks the project's own package, which uninstall refuses to touch. + Primary bool +} + +// Packages enumerates every package present in the scope: the primary package +// (project scope, when /app.manifest.toml exists) plus every guest under +// manifests/. A guest whose metadata is missing or unreadable is skipped rather +// than failing the whole call — a half-written earlier install must not wedge +// every later one, nor make the inventory unreadable. +// +// The primary package, when present, comes first; guests follow in directory +// order, which os.ReadDir sorts by name. +func Packages(lay Layout, scope Scope) ([]Package, error) { + var packages []Package + + if scope.HasPrimary() { + if primary, ok := ReadPrimary(lay.Root); ok { + packages = append(packages, primary) + } + } + + entries, err := os.ReadDir(lay.Manifests) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return packages, nil + } + + return nil, fmt.Errorf("reading install state %s: %w", lay.Manifests, err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + guest, ok := ReadGuest(lay.Manifests, entry.Name()) + if ok { + packages = append(packages, guest) + } + } + + return packages, nil +} + +// Find returns the package with the given name, if any. +func Find(packages []Package, name string) (Package, bool) { + for _, p := range packages { + if p.Name == name { + return p, true + } + } + + var zero Package + + return zero, false +} + +// FindPrimary returns the project's primary package among the set, requiring a +// readable manifest — a primary with no manifest constrains nothing. +func FindPrimary(packages []Package) (Package, bool) { + for _, pkg := range packages { + if pkg.Primary && pkg.Manifest != nil { + return pkg, true + } + } + + var zero Package + + return zero, false +} + +// ReadPrimary reads the project's own package from the install-root. The +// manifest is optional (a bare deploy directory has none); when present its lock +// and VERSION file beside it are read too. +func ReadPrimary(root string) (Package, bool) { + var zero Package + + //nolint:gosec // Reads tt's own install-state files. + manBytes, err := os.ReadFile(filepath.Join(root, PrimaryManifestFile)) + if err != nil { + return zero, false + } + + man, _, err := manifest.ParseManifest(manBytes) + if err != nil { + return zero, false + } + + pkg := Package{ + Name: man.Package.Name, + Manifest: man, + Lock: ReadLockFile(filepath.Join(root, PrimaryLockFile)), + // The primary package's version is not recorded by an install — it is the + // project's own. A VERSION pin next to the manifest is the one source that + // can be read without shelling out to git, which an inventory listing has + // no business doing. + Version: readVersionFile(filepath.Join(root, PrimaryVersionFile)), + Primary: true, + } + + return pkg, true +} + +// ReadGuest reads one installed guest's metadata from manifests//. +func ReadGuest(manifestsDir, name string) (Package, bool) { + var zero Package + + dir := filepath.Join(manifestsDir, name) + + //nolint:gosec // Reads tt's own install-state files. + manBytes, err := os.ReadFile(filepath.Join(dir, MetaManifestFile)) + if err != nil { + return zero, false + } + + man, _, err := manifest.ParseManifest(manBytes) + if err != nil { + return zero, false + } + + pkg := Package{ + Name: man.Package.Name, + Manifest: man, + Lock: ReadLockFile(filepath.Join(dir, MetaLockFile)), + Version: readVersionFile(filepath.Join(dir, MetaVersionFile)), + Primary: false, + } + + return pkg, true +} + +// ReadLockFile parses a lock file, returning nil when it is absent or unreadable +// — a guest may predate a lock-writing tt, and the primary package need not have +// a lock at all. +func ReadLockFile(path string) *manifest.Lock { + data, err := os.ReadFile(path) //nolint:gosec // Reads tt's own install-state files. + if err != nil { + return nil + } + + lock, err := manifest.ParseLock(data) + if err != nil { + return nil + } + + return lock +} + +// readVersionFile reads a VERSION file, returning "" when it is absent. +func readVersionFile(path string) string { + data, err := os.ReadFile(path) //nolint:gosec // Reads tt's own install-state files. + if err != nil { + return "" + } + + return strings.TrimSpace(string(data)) +} + +// LockedVersion returns the version a lock pins for a dependency, across all its +// products. A lock normally carries one product's closure, but nothing forbids +// several, and a dependency pinned in any of them is still brought in. +func LockedVersion(lock *manifest.Lock, dep string) (string, bool) { + if lock == nil { + return "", false + } + + for _, prod := range lock.Products { + for _, d := range prod.Dependencies { + if d.Name == dep { + return d.Version, true + } + } + } + + return "", false +} + +// LockedDependencies lists every dependency a lock pins, deduplicated by name +// across products and in no particular order. It is the set of rocks the +// package brought into the tree, which is what ownership is counted over. +func LockedDependencies(lock *manifest.Lock) map[string]string { + pinned := map[string]string{} + + if lock == nil { + return pinned + } + + for _, prod := range lock.Products { + for _, dep := range prod.Dependencies { + if _, seen := pinned[dep.Name]; !seen { + pinned[dep.Name] = dep.Version + } + } + } + + return pinned +} + +// WriteMetadata records a package's metadata under manifests//: the +// manifest, the lock and the version. This is the inventory list reads and the +// ledger uninstall's refcounting stands on — a dependency is owned by every +// package whose lock lists it. +func WriteMetadata(lay Layout, name string, manifestBytes, lockBytes []byte, version string) error { + dir := filepath.Join(lay.Manifests, name) + + err := os.MkdirAll(dir, metaDirPerm) + if err != nil { + return fmt.Errorf("creating metadata directory: %w", err) + } + + files := []struct { + name string + data []byte + }{ + {MetaManifestFile, manifestBytes}, + {MetaLockFile, lockBytes}, + {MetaVersionFile, []byte(version + "\n")}, + } + + for _, file := range files { + // Install metadata is world-readable by design, matching the rocks tree. + path := filepath.Join(dir, file.name) + + err := os.WriteFile(path, file.data, metaFilePerm) + if err != nil { + return fmt.Errorf("writing %s metadata: %w", file.name, err) + } + } + + return nil +} + +// RemoveMetadata deletes a package's manifests// directory. Because +// ownership is derived from the locks left under manifests/, this single +// removal is what drops the package from every dependency's owner set. +func RemoveMetadata(lay Layout, name string) error { + err := os.RemoveAll(filepath.Join(lay.Manifests, name)) + if err != nil { + return fmt.Errorf("removing %s metadata: %w", name, err) + } + + return nil +} diff --git a/cli/manifest/state/packages_test.go b/cli/manifest/state/packages_test.go new file mode 100644 index 000000000..9455da5fb --- /dev/null +++ b/cli/manifest/state/packages_test.go @@ -0,0 +1,290 @@ +package state_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/state" +) + +// TestPackagesEmptyTree pins that a scope with nothing installed reads as an +// empty inventory rather than an error: a fresh project has no manifests/ at +// all, and that is not a failure. +func TestPackagesEmptyTree(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + assert.Empty(t, packages) +} + +// TestPackagesListsPrimaryAndGuests covers the shape list reads: the primary +// package first, then guests in name order. +func TestPackagesListsPrimaryAndGuests(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writePrimary(t, lay, "my-app", "1.2.3", + map[string]string{"luasocket": ">=3.0.0"}, + map[string]string{"luasocket": "3.0.4"}) + writeGuest(t, lay, "monitoring", "2.0.0", nil, map[string]string{"metrics": "1.0.0"}) + writeGuest(t, lay, "alerting", "0.5.0", nil, nil) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 3) + + assert.Equal(t, "my-app", packages[0].Name) + assert.True(t, packages[0].Primary) + assert.Equal(t, "1.2.3", packages[0].Version) + + // Guests follow in directory order, which os.ReadDir sorts by name. + assert.Equal(t, "alerting", packages[1].Name) + assert.False(t, packages[1].Primary) + assert.Equal(t, "0.5.0", packages[1].Version) + + assert.Equal(t, "monitoring", packages[2].Name) + assert.Equal(t, "2.0.0", packages[2].Version) +} + +// TestPackagesSkipsPrimaryOutsideProjectScope pins that user and system trees +// hold guests only: a stray app.manifest.toml at the root of a shared tree is +// not the project's primary package. +func TestPackagesSkipsPrimaryOutsideProjectScope(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writePrimary(t, lay, "shared-tree-app", "1.2.3", nil, nil) + writeGuest(t, lay, "some-lib", "2.0.0", nil, nil) + + packages, err := state.Packages(lay, state.ScopeUser) + require.NoError(t, err) + require.Len(t, packages, 1) + assert.Equal(t, "some-lib", packages[0].Name) +} + +// TestPackagesSkipsUnreadableGuest pins the robustness rule: a half-written or +// corrupt metadata directory is skipped, not fatal, so one bad install cannot +// make the whole inventory unreadable. +func TestPackagesSkipsUnreadableGuest(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "good", "1.0.0", nil, nil) + + // A directory with no manifest at all. + require.NoError(t, os.MkdirAll(filepath.Join(lay.Manifests, "empty"), 0o750)) + + // A directory whose manifest is not parseable TOML. + writeFile(t, filepath.Join(lay.Manifests, "broken", state.MetaManifestFile), + []byte("this is not = valid toml [")) + + // A stray regular file where a package directory would be. + writeFile(t, filepath.Join(lay.Manifests, "stray.txt"), []byte("junk")) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + assert.Equal(t, "good", packages[0].Name) +} + +// TestPackagesTolerantOfMissingLock pins that a guest without a lock still +// lists: it simply owns no dependencies. +func TestPackagesTolerantOfMissingLock(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "some-lib", "1.0.0", nil, nil) + require.NoError(t, os.Remove( + filepath.Join(lay.Manifests, "some-lib", state.MetaLockFile))) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + assert.Nil(t, packages[0].Lock) + assert.Empty(t, state.LockedDependencies(packages[0].Lock)) +} + +// TestPackagesNameComesFromManifest pins that the package's identity is the +// name inside its manifest, not the directory it happens to sit in. +func TestPackagesNameComesFromManifest(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + source := manifestTOML("real-name", nil) + writeFile(t, filepath.Join(lay.Manifests, "dir-name", state.MetaManifestFile), + []byte(source)) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + assert.Equal(t, "real-name", packages[0].Name) +} + +// TestGuestManifestCarriesDeclaredConstraints pins that a guest's metadata +// preserves what its manifest declared, not just what its lock pinned. Install's +// reconciliation reads those constraints back off disk when a later package +// wants the same dependency at another version. +func TestGuestManifestCarriesDeclaredConstraints(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "monitoring", "2.0.0", + map[string]string{"metrics": ">=1.0.0,<2.0.0"}, + map[string]string{"metrics": "1.4.0"}) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + require.NotNil(t, packages[0].Manifest) + + assert.Equal(t, ">=1.0.0,<2.0.0", + packages[0].Manifest.Dependencies["metrics"].Version) +} + +// TestPrimaryWithoutVersionFile pins that a project with no VERSION pin still +// lists, with an empty version — the inventory does not shell out to git to +// invent one. +func TestPrimaryWithoutVersionFile(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writePrimary(t, lay, "my-app", "", nil, nil) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + assert.True(t, packages[0].Primary) + assert.Empty(t, packages[0].Version) +} + +// TestFind covers lookup by name and the miss. +func TestFind(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "monitoring", "2.0.0", nil, nil) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + + found, ok := state.Find(packages, "monitoring") + require.True(t, ok) + assert.Equal(t, "2.0.0", found.Version) + + _, ok = state.Find(packages, "absent") + assert.False(t, ok) +} + +// TestFindPrimary covers the primary lookup and that a tree of guests alone has +// none. +func TestFindPrimary(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "monitoring", "2.0.0", nil, nil) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + + _, ok := state.FindPrimary(packages) + assert.False(t, ok) + + writePrimary(t, lay, "my-app", "1.0.0", nil, nil) + + packages, err = state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + + primary, ok := state.FindPrimary(packages) + require.True(t, ok) + assert.Equal(t, "my-app", primary.Name) +} + +// TestLockedDependencies pins the ledger's input: every dependency a lock +// pins, keyed by name. +func TestLockedDependencies(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "monitoring", "2.0.0", nil, + map[string]string{"metrics": "1.0.0", "checks": "3.1.0"}) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + + assert.Equal(t, + map[string]string{"metrics": "1.0.0", "checks": "3.1.0"}, + state.LockedDependencies(packages[0].Lock)) + + pin, ok := state.LockedVersion(packages[0].Lock, "metrics") + require.True(t, ok) + assert.Equal(t, "1.0.0", pin) + + _, ok = state.LockedVersion(packages[0].Lock, "absent") + assert.False(t, ok) + + // A nil lock is the "predates a lock-writing tt" case and must not panic. + _, ok = state.LockedVersion(nil, "metrics") + assert.False(t, ok) + assert.Empty(t, state.LockedDependencies(nil)) +} + +// TestWriteAndRemoveMetadata covers the round trip the ledger stands on: +// metadata written by an install reads back, and removing it drops the package +// from the inventory entirely. +func TestWriteAndRemoveMetadata(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "monitoring", "2.0.0", nil, map[string]string{"metrics": "1.0.0"}) + + metaDir := filepath.Join(lay.Manifests, "monitoring") + assert.FileExists(t, filepath.Join(metaDir, state.MetaManifestFile)) + assert.FileExists(t, filepath.Join(metaDir, state.MetaLockFile)) + assert.Equal(t, "2.0.0\n", string(mustRead(t, filepath.Join(metaDir, state.MetaVersionFile)))) + + require.NoError(t, state.RemoveMetadata(lay, "monitoring")) + assert.NoDirExists(t, metaDir) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + assert.Empty(t, packages) +} + +// TestRemoveMetadataAbsentIsNoOp pins that unwinding a package twice is not an +// error, so a partially failed uninstall can be re-run. +func TestRemoveMetadataAbsentIsNoOp(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + require.NoError(t, state.RemoveMetadata(lay, "never-installed")) +} + +// mustRead reads a file or fails the test. +func mustRead(t *testing.T, path string) []byte { + t.Helper() + + data, err := os.ReadFile(path) //nolint:gosec // Test reads from a temp path. + require.NoError(t, err) + + return data +} diff --git a/cli/manifest/state/rocks.go b/cli/manifest/state/rocks.go new file mode 100644 index 000000000..8d528bfa3 --- /dev/null +++ b/cli/manifest/state/rocks.go @@ -0,0 +1,69 @@ +package state + +import ( + "os" + "path/filepath" + + "github.com/tarantool/go-luarocks/deps" +) + +// rocksManifestDir is the tree subdirectory LuaRocks keeps per-rock manifests +// in: /rocks///. +const rocksManifestDir = "rocks" + +// InstalledVersion reads the version of a rock currently installed in the tree +// from /rocks///. It returns the first version directory +// found; a well-formed tree holds exactly one per rock. +func InstalledVersion(lay Layout, dep string) (string, bool) { + entries, err := os.ReadDir(filepath.Join(lay.Share, rocksManifestDir, dep)) + if err != nil { + return "", false + } + + for _, entry := range entries { + if entry.IsDir() { + return entry.Name(), true + } + } + + return "", false +} + +// RockPaths lists the on-disk paths that hold one version of a rock: its module +// trees under share/ and lib/, and its rock-manifest directory. Install deletes +// these to replace a stale version; uninstall deletes them to drop a dependency +// nobody owns any more. +func RockPaths(lay Layout, dep, version string) []string { + return []string{ + filepath.Join(lay.Share, dep), + filepath.Join(lay.Lib, dep), + filepath.Join(lay.Share, rocksManifestDir, dep, version), + } +} + +// RockRoot is the rock-manifest directory of a rock across all its versions, +// /rocks//. Uninstall removes it once the last version is gone, so +// no empty husk is left behind to make the rock look installed. +func RockRoot(lay Layout, dep string) string { + return filepath.Join(lay.Share, rocksManifestDir, dep) +} + +// SameVersion reports whether two version strings denote the same version, +// tolerating formatting differences (a trailing "-1" revision, say). +func SameVersion(left, right string) bool { + if left == right { + return true + } + + parsedLeft, err := deps.ParseVersion(left) + if err != nil { + return false + } + + parsedRight, err := deps.ParseVersion(right) + if err != nil { + return false + } + + return deps.Compare(parsedLeft, parsedRight) == 0 +} diff --git a/cli/manifest/state/rocks_test.go b/cli/manifest/state/rocks_test.go new file mode 100644 index 000000000..2a71ba96a --- /dev/null +++ b/cli/manifest/state/rocks_test.go @@ -0,0 +1,88 @@ +package state_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/state" +) + +// TestInstalledVersion reads a rock's version back out of the tree, and reports +// a miss for a rock that was never installed. +func TestInstalledVersion(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeRock(t, lay, "metrics", "1.0.0") + + version, ok := state.InstalledVersion(lay, "metrics") + require.True(t, ok) + assert.Equal(t, "1.0.0", version) + + _, ok = state.InstalledVersion(lay, "absent") + assert.False(t, ok) +} + +// TestInstalledVersionIgnoresStrayFiles pins that only a directory counts as a +// version: a stray file under rocks// must not be read as one. +func TestInstalledVersionIgnoresStrayFiles(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeFile(t, filepath.Join(lay.Share, "rocks", "metrics", "README"), []byte("junk\n")) + + _, ok := state.InstalledVersion(lay, "metrics") + assert.False(t, ok) +} + +// TestRockPathsCoverEveryTree pins that removing a rock's paths clears it from +// share/, lib/ and the rock-manifest tree at once — the invariant uninstall's +// dependency removal depends on. +func TestRockPathsCoverEveryTree(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeRock(t, lay, "metrics", "1.0.0") + + for _, path := range state.RockPaths(lay, "metrics", "1.0.0") { + require.NoError(t, os.RemoveAll(path)) + } + + assert.NoDirExists(t, filepath.Join(lay.Share, "metrics")) + assert.NoDirExists(t, filepath.Join(lay.Lib, "metrics")) + assert.NoDirExists(t, filepath.Join(lay.Share, "rocks", "metrics", "1.0.0")) + + _, ok := state.InstalledVersion(lay, "metrics") + assert.False(t, ok) +} + +// TestRockRoot points at the per-rock manifest directory across versions. +func TestRockRoot(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + assert.Equal(t, + filepath.Join(lay.Share, "rocks", "metrics"), + state.RockRoot(lay, "metrics")) +} + +// TestSameVersion covers the tolerance the reconciler and uninstall both need: +// a LuaRocks revision suffix is not a different version. +func TestSameVersion(t *testing.T) { + t.Parallel() + + assert.True(t, state.SameVersion("1.0.0", "1.0.0")) + assert.True(t, state.SameVersion("1.0.0", "1.0.0-1")) + assert.False(t, state.SameVersion("1.0.0", "1.0.1")) + assert.False(t, state.SameVersion("nonsense", "1.0.0")) + assert.False(t, state.SameVersion("1.0.0", "nonsense")) + assert.True(t, state.SameVersion("nonsense", "nonsense")) +} diff --git a/cli/manifest/state/scope.go b/cli/manifest/state/scope.go new file mode 100644 index 000000000..df2ba21f9 --- /dev/null +++ b/cli/manifest/state/scope.go @@ -0,0 +1,157 @@ +// Package state owns tt's on-disk install state: where a scope puts a package's +// files, and the per-package metadata tt records under manifests//. +// +// Install writes that state, list reads it and uninstall unwinds it, so the +// three commands must agree on the layout down to the directory name. Keeping +// it in one package is what makes that agreement checkable instead of +// duplicated: a scope's directories are resolved once, by ResolveLayout, and +// the metadata file names are constants nobody re-spells. +// +// The dependency ledger uninstall stands on lives here too, and it is derived +// rather than stored: a dependency is owned by every installed package whose +// lock pins it, so removing a package's manifests// directory is what +// decrements the count. Nothing has to be kept in sync by hand, and a +// half-written install cannot corrupt a counter that does not exist. +package state + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// Scope is where tt package install lays a package down, and where list and +// uninstall look for it. The three scopes differ in install-root and in the +// on-disk layout Tarantool's loaders expect; they also differ in what archive +// they accept (only project takes a with-deps archive). +type Scope string + +const ( + // ScopeProject installs into /.rocks/, the self-contained deployment + // tree. It is the default and the only scope that accepts a with-deps + // archive (the bundled _runtime/ has no place in a user or system tree). + ScopeProject Scope = "project" + // ScopeUser installs into ~/.luarocks/, the personal LuaRocks tree. It + // accepts only a --without-deps archive. + ScopeUser Scope = "user" + // ScopeSystem installs into /usr/, the OS-package-style tree. It needs root + // and accepts only a --without-deps archive. + ScopeSystem Scope = "system" +) + +// ErrUnknownScope reports a --scope value that is not project, user or system. +var ErrUnknownScope = errors.New("unknown scope") + +// ParseScope validates a --scope value, defaulting an empty string to project. +func ParseScope(raw string) (Scope, error) { + switch Scope(raw) { + case "", ScopeProject: + return ScopeProject, nil + case ScopeUser: + return ScopeUser, nil + case ScopeSystem: + return ScopeSystem, nil + default: + return "", fmt.Errorf("%w %q (want project, user or system)", ErrUnknownScope, raw) + } +} + +// AcceptsWithDeps reports whether the scope may receive a with-deps archive. +// Only project does: user and system are shared LuaRocks trees where bundling a +// runtime is meaningless, so a with-deps archive is rejected before any write. +func (scope Scope) AcceptsWithDeps() bool { + return scope == ScopeProject +} + +// HasPrimary reports whether the scope can hold a primary package — the package +// the project itself develops, declared by app.manifest.toml in the install +// root. Only the project scope can: user and system trees are shared +// destinations with no project of their own, so everything in them is a guest. +func (scope Scope) HasPrimary() bool { + return scope == ScopeProject +} + +// Layout is the resolved set of directories one scope installs into. +type Layout struct { + // Root is the install-root: for project, the tree root otherwise. + Root string + // Tree is the rocks tree root the go-luarocks adapter reads and installs + // into (/.rocks for project, the root itself for user/system). + Tree string + // Share and Lib are the module directories a package's own files and its + // dependencies land under, relative subtrees of the tree. + Share string + Lib string + // Manifests is where tt records per-package install metadata + // (.rocks/manifests//) used by list/uninstall and dependency + // refcounting. + Manifests string +} + +// ResolveLayout maps a scope to its concrete directories. projectDir is the +// install-root for project scope (the caller's cwd); user and system derive +// their roots from the environment. The returned paths are absolute. +func ResolveLayout(scope Scope, projectDir string) (Layout, error) { + switch scope { + case ScopeProject: + tree := filepath.Join(projectDir, RocksDirName) + + return Layout{ + Root: projectDir, + Tree: tree, + Share: filepath.Join(tree, shareTarantool), + Lib: filepath.Join(tree, libTarantool), + Manifests: filepath.Join(tree, ManifestsDirName), + }, nil + case ScopeUser: + home, err := os.UserHomeDir() + if err != nil { + var zero Layout + + return zero, fmt.Errorf("locating home directory: %w", err) + } + + root := filepath.Join(home, ".luarocks") + + return Layout{ + Root: root, + Tree: root, + Share: filepath.Join(root, shareLua), + Lib: filepath.Join(root, libLua), + Manifests: filepath.Join(root, ManifestsDirName), + }, nil + case ScopeSystem: + root := SystemRoot + + return Layout{ + Root: root, + Tree: root, + Share: filepath.Join(root, shareTarantool), + Lib: filepath.Join(root, libTarantool), + Manifests: filepath.Join(root, ManifestsDirName), + }, nil + default: + var zero Layout + + return zero, fmt.Errorf("%w %q", ErrUnknownScope, scope) + } +} + +// Rocks-tree layout roots. The project and system scopes follow the Tarantool +// convention (share/tarantool, lib/tarantool); the user scope follows the +// LuaRocks 5.1 convention. +const ( + // RocksDirName is the project scope's tree directory, and the prefix archive + // entries carry. + RocksDirName = ".rocks" + // ManifestsDirName is the per-package metadata directory inside a tree. It is + // a reserved package name for exactly this reason. + ManifestsDirName = "manifests" + shareTarantool = "share/tarantool" + libTarantool = "lib/tarantool" + shareLua = "share/lua/5.1" + libLua = "lib/lua/5.1" + // SystemRoot is the OS-package install-root. + SystemRoot = "/usr" +) diff --git a/cli/manifest/state/scope_test.go b/cli/manifest/state/scope_test.go new file mode 100644 index 000000000..588845c72 --- /dev/null +++ b/cli/manifest/state/scope_test.go @@ -0,0 +1,114 @@ +package state_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/state" +) + +// TestParseScope covers the accepted values and the default. +func TestParseScope(t *testing.T) { + t.Parallel() + + for input, want := range map[string]state.Scope{ + "": state.ScopeProject, + "project": state.ScopeProject, + "user": state.ScopeUser, + "system": state.ScopeSystem, + } { + got, err := state.ParseScope(input) + require.NoError(t, err, input) + assert.Equal(t, want, got, input) + } +} + +// TestParseScopeRejectsUnknown pins the error for a value outside the closed +// set, and that the raw value is echoed back so the message is actionable. +func TestParseScopeRejectsUnknown(t *testing.T) { + t.Parallel() + + _, err := state.ParseScope("global") + require.Error(t, err) + require.ErrorIs(t, err, state.ErrUnknownScope) + assert.Contains(t, err.Error(), "global") +} + +// TestAcceptsWithDeps pins the rule that only project takes a with-deps archive. +func TestAcceptsWithDeps(t *testing.T) { + t.Parallel() + + assert.True(t, state.ScopeProject.AcceptsWithDeps()) + assert.False(t, state.ScopeUser.AcceptsWithDeps()) + assert.False(t, state.ScopeSystem.AcceptsWithDeps()) +} + +// TestHasPrimary pins the rule that only the project scope has a primary +// package; user and system trees hold guests only. +func TestHasPrimary(t *testing.T) { + t.Parallel() + + assert.True(t, state.ScopeProject.HasPrimary()) + assert.False(t, state.ScopeUser.HasPrimary()) + assert.False(t, state.ScopeSystem.HasPrimary()) +} + +// TestResolveLayoutProject fixes the project-scope directory layout. +func TestResolveLayoutProject(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + lay, err := state.ResolveLayout(state.ScopeProject, dir) + require.NoError(t, err) + + assert.Equal(t, dir, lay.Root) + assert.Equal(t, filepath.Join(dir, ".rocks"), lay.Tree) + assert.Equal(t, filepath.Join(dir, ".rocks", "share", "tarantool"), lay.Share) + assert.Equal(t, filepath.Join(dir, ".rocks", "lib", "tarantool"), lay.Lib) + assert.Equal(t, filepath.Join(dir, ".rocks", "manifests"), lay.Manifests) +} + +// TestResolveLayoutUser fixes the user-scope layout: the LuaRocks 5.1 +// convention under ~/.luarocks, with no .rocks/ level. +func TestResolveLayoutUser(t *testing.T) { + t.Parallel() + + // The user scope derives its root from the environment, so the test asserts + // the shape relative to whatever home it landed on rather than a fixed path. + lay, err := state.ResolveLayout(state.ScopeUser, "/ignored") + require.NoError(t, err) + + assert.Equal(t, lay.Root, lay.Tree) + assert.Equal(t, filepath.Join(lay.Root, "share", "lua", "5.1"), lay.Share) + assert.Equal(t, filepath.Join(lay.Root, "lib", "lua", "5.1"), lay.Lib) + assert.Equal(t, filepath.Join(lay.Root, "manifests"), lay.Manifests) + assert.Equal(t, ".luarocks", filepath.Base(lay.Root)) +} + +// TestResolveLayoutSystem fixes the system-scope layout under /usr. +func TestResolveLayoutSystem(t *testing.T) { + t.Parallel() + + lay, err := state.ResolveLayout(state.ScopeSystem, "/ignored") + require.NoError(t, err) + + assert.Equal(t, "/usr", lay.Root) + assert.Equal(t, "/usr", lay.Tree) + assert.Equal(t, filepath.Join("/usr", "share", "tarantool"), lay.Share) + assert.Equal(t, filepath.Join("/usr", "lib", "tarantool"), lay.Lib) + assert.Equal(t, filepath.Join("/usr", "manifests"), lay.Manifests) +} + +// TestResolveLayoutRejectsUnknown pins that an unvalidated scope value cannot +// silently resolve to a directory. +func TestResolveLayoutRejectsUnknown(t *testing.T) { + t.Parallel() + + _, err := state.ResolveLayout(state.Scope("global"), t.TempDir()) + require.Error(t, err) + require.ErrorIs(t, err, state.ErrUnknownScope) +} From a7c0e087ce429e0f254ec2f11bde8dd44bb4123c Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 27 Jul 2026 15:50:34 +0300 Subject: [PATCH 3/3] cli/manifest/inventory: tt package list and tt package uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands work over what an install left on disk and nothing else: the source of truth is the metadata under .rocks/manifests//, so neither contacts a registry, runs a build, nor re-resolves anything. list reports the packages present in a scope — in project the project's own package plus every guest. -o picks the format, defaulting to a table on a terminal and YAML otherwise, so piped output is parseable without the caller remembering a flag. What it reports is what is on disk, deliberately not what a manifest declares. --tree renders the scope as one tree rooted at the project's own package. Guests hang off it because they are installed into its .rocks/ and share it, marked (guest) rather than presented as things the project requires. Each rock sits under whatever pulled it in: a lock stores the dependency closure flattened, with the parent discarded, but LuaRocks records every installed rock's own requirements in the tree manifest, so the edges survive there. Rocks the walk cannot root — a tree carrying no manifest, or incomplete edges — are still listed, under a group that says the parent is not recorded rather than implying the package asked for them. uninstall removes a guest and the shared dependencies it was the last to hold. Ownership is derived rather than counted: a dependency belongs to every installed package whose lock pins it, so setting the target aside and re-reading the remaining locks is the whole ledger, with no separate counter to drift out of sync. Two consequences are worth naming. Ownership covers the closure and not just declarations, so a rock no manifest ever named still goes with its last owner. And a guest that another package merely depends on is never swept away as a shared rock — it has its own metadata, so only an uninstall naming it removes it. Removal order is files first, metadata last, so an interrupted run can be repeated: the package still reads as installed until its files are gone. The package name is validated before anything is removed, since it becomes a path. Every rock in --tree is annotated with what an uninstall would do to it, and a test asserts that prediction against a real removal, so the annotation cannot drift from the refcounting it describes. The machine output keys the rock index as "rocks" rather than "dependencies": a package entry already carries a "dependencies" object, and one name must not stand for two shapes. Tested at both levels: Go tests over fixture trees for the ownership rules, the refusals, the graph walk and the rendering, and a pytest slice driving the real binary for the CLI surface. The system scope installs into /usr, so its slice runs in a container against a disposable tree. Closes TNTP-8614 --- .golangci.yml | 13 + CHANGELOG.md | 25 + cli/cmd/package.go | 192 ++++- cli/manifest/inventory/errors.go | 50 ++ cli/manifest/inventory/format.go | 451 ++++++++++ cli/manifest/inventory/format_test.go | 194 +++++ cli/manifest/inventory/helpers_test.go | 302 +++++++ cli/manifest/inventory/list.go | 255 ++++++ cli/manifest/inventory/list_test.go | 203 +++++ cli/manifest/inventory/scope_test.go | 94 +++ cli/manifest/inventory/tree_test.go | 769 ++++++++++++++++++ cli/manifest/inventory/uninstall.go | 330 ++++++++ cli/manifest/inventory/uninstall_test.go | 458 +++++++++++ cli/manifest/state/packages.go | 39 +- cli/manifest/state/packages_test.go | 49 ++ cli/manifest/state/rocks.go | 51 ++ cli/manifest/state/rocks_test.go | 104 +++ cli/manifest/validate.go | 9 + test/integration/package/conftest.py | 248 ++++++ test/integration/package/test_package_list.py | 98 +++ .../package/test_package_list_tree.py | 181 +++++ .../package/test_package_system_scope.py | 124 +++ .../package/test_package_uninstall.py | 150 ++++ 23 files changed, 4365 insertions(+), 24 deletions(-) create mode 100644 cli/manifest/inventory/errors.go create mode 100644 cli/manifest/inventory/format.go create mode 100644 cli/manifest/inventory/format_test.go create mode 100644 cli/manifest/inventory/helpers_test.go create mode 100644 cli/manifest/inventory/list.go create mode 100644 cli/manifest/inventory/list_test.go create mode 100644 cli/manifest/inventory/scope_test.go create mode 100644 cli/manifest/inventory/tree_test.go create mode 100644 cli/manifest/inventory/uninstall.go create mode 100644 cli/manifest/inventory/uninstall_test.go create mode 100644 test/integration/package/conftest.py create mode 100644 test/integration/package/test_package_list.py create mode 100644 test/integration/package/test_package_list_tree.py create mode 100644 test/integration/package/test_package_system_scope.py create mode 100644 test/integration/package/test_package_uninstall.py diff --git a/.golangci.yml b/.golangci.yml index 4285b88eb..af0dbbd73 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -70,6 +70,14 @@ linters: - [] - - skip-package-name-checks: true + tagliatelle: + case: + rules: + # tt's machine-readable payloads are snake_case, matching the manifest + # and lock files they are derived from. + json: snake + yaml: snake + exhaustive: # A switch with a default clause is treated as exhaustive. default-signifies-exhaustive: true @@ -122,6 +130,9 @@ linters: # imports that used to pass. Add sub-packages only where the broader # entry is already shadowed, as it is in this list. - "github.com/tarantool/tt/cli/manifest/state" + # cli/manifest/inventory renders tt package list as YAML, the + # machine-readable default for non-TTY output. + - "gopkg.in/yaml.v3" test: files: - "$test" @@ -134,3 +145,5 @@ linters: - "github.com/tarantool/go-luarocks" # cli/manifest/pack tests decompress the archive they just wrote. - "github.com/klauspost/compress/zstd" + # cli/manifest/inventory tests round-trip the YAML they rendered. + - "gopkg.in/yaml.v3" diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9d2b7cd..b56de2dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,31 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. version, `--force` reinstalls over a collision, and `--yes` skips the reconciliation prompt. Installing several archives at once exits 3 when some succeed and some fail. +- `tt package list`: show the packages installed in a scope — in the project + scope the project's own package plus every guest installed from an archive. + The source is the metadata `tt package install` records under + `.rocks/manifests//`, so nothing is fetched and nothing is resolved; this + reports what is on disk, not what the current manifest declares. `--scope` + selects the tree and `-o/--format` picks the output: `table` by default on a + terminal, `yaml` by default otherwise, or an explicit `table`/`json`/`yaml`. + `--tree` renders the scope as a single tree rooted at the project's own + package, with each guest beneath it and each rock beneath whatever requires + it, read from the LuaRocks tree manifest. Every rock is annotated with whether + another package holds it too — that is, whether `tt package uninstall` would + take it along or leave it. A rock the walk cannot root, because the tree + carries no manifest or its edges are incomplete, is still shown, under a group + that says its parent is not recorded. In the machine formats the same + information arrives as a top-level `rocks` index giving each rock's owners and + what it `requires`, plus a per-package `direct` list naming the declared + subset of the closure. The index is `rocks` rather than `dependencies` because + a package entry already carries a `dependencies` object, and one name must not + stand for two shapes. +- `tt package uninstall `: remove an installed guest package — its files + and its metadata, plus every shared dependency nothing else still needs. A + dependency is owned by every installed package whose lock pins it, so one + another package still holds stays in place and is reported as kept. The + project's own package is not a guest and uninstall refuses to remove it. + `--scope` selects the tree and `--yes` skips the confirmation prompt. - `tt pack`: support nested `.packignore` at the root of tt environment. - `tt status`: add `--format` option to support JSON and YAML output formats for machine-readable output. diff --git a/cli/cmd/package.go b/cli/cmd/package.go index 688aa89ca..1e1e3d12b 100644 --- a/cli/cmd/package.go +++ b/cli/cmd/package.go @@ -6,14 +6,18 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/apex/log" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "github.com/tarantool/tt/cli/manifest/build" "github.com/tarantool/tt/cli/manifest/install" + "github.com/tarantool/tt/cli/manifest/inventory" "github.com/tarantool/tt/cli/manifest/pack" manifestrocks "github.com/tarantool/tt/cli/manifest/rocks" + "github.com/tarantool/tt/cli/manifest/state" oldrocks "github.com/tarantool/tt/cli/rocks" "github.com/tarantool/tt/cli/util" ttversion "github.com/tarantool/tt/cli/version" @@ -31,7 +35,7 @@ var ( packageWithoutDeps bool ) -// Flags specific to `tt package install`. +// Flags shared by the scope-addressed subcommands: install, list and uninstall. var ( packageScope string packageUpgrade bool @@ -39,14 +43,21 @@ var ( packageYes bool ) +// Flags specific to `tt package list`. +var ( + packageFormat string + packageTree bool +) + // NewPackageCmd creates the `tt package` command group: the manifest build -// pipeline (build, fetch and pack; install is not implemented yet). +// pipeline (build, fetch and pack), installation, and the inventory over what +// is installed (list and uninstall). func NewPackageCmd() *cobra.Command { packageCmd := &cobra.Command{ Use: "package", Short: "Manage tt manifest packages", - Long: "Build, fetch and pack Tarantool packages described by " + - "app.manifest.toml.", + Long: "Build, fetch, pack and install Tarantool packages described by " + + "app.manifest.toml, and manage what is installed.", } packageCmd.AddCommand( @@ -54,11 +65,163 @@ func NewPackageCmd() *cobra.Command { newPackageFetchCmd(), newPackagePackCmd(), newPackageInstallCmd(), + newPackageListCmd(), + newPackageUninstallCmd(), ) return packageCmd } +// newPackageListCmd wires `tt package list`. +func newPackageListCmd() *cobra.Command { + listCmd := &cobra.Command{ + Use: "list", + Short: "List the packages installed in a scope", + Long: "Show the packages present in the chosen scope: in the project " + + "scope the project's own package plus every guest installed from an " + + "archive. The source is the metadata install records under " + + ".rocks/manifests/, so nothing is fetched and nothing is resolved. " + + "This is what is on disk — not what the current manifest declares, " + + "which is what tt package deps reports. --tree additionally shows " + + "each package's dependencies and, for every one of them, whether " + + "another package holds it too — that is, whether it would be removed " + + "along with the package or stay.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if err := runPackageList(); err != nil { + log.Error(err.Error()) + os.Exit(inventory.ExitCode(err)) + } + }, + } + + listCmd.Flags().StringVar(&packageScope, "scope", "project", + "scope to list: project, user or system") + listCmd.Flags().StringVarP(&packageFormat, "format", "o", "", + "output format: table, json or yaml (default: table on a terminal, yaml otherwise)") + listCmd.Flags().BoolVar(&packageTree, "tree", false, + "show each package's dependencies and which of them are shared") + + return listCmd +} + +// runPackageList reads the inventory and renders it to stdout in the resolved +// format. +func runPackageList() error { + projectDir, err := absoluteWorkingDir() + if err != nil { + return err + } + + // The default format follows stdout: a terminal gets the human table, a pipe + // or a file gets YAML, so piped output is parseable without a flag. + format, err := inventory.ParseFormat(packageFormat, isatty.IsTerminal(os.Stdout.Fd())) + if err != nil { + return err + } + + listing, err := inventory.List(inventory.ListOptions{ + ProjectDir: projectDir, + Scope: state.Scope(packageScope), + Rocks: packageTree, + }) + if err != nil { + return err + } + + return inventory.Render(os.Stdout, listing, format) +} + +// newPackageUninstallCmd wires `tt package uninstall NAME`. +func newPackageUninstallCmd() *cobra.Command { + uninstallCmd := &cobra.Command{ + Use: "uninstall NAME", + Short: "Remove an installed guest package from a scope", + Long: "Remove a guest package: its files and its metadata, plus every " + + "shared dependency that nothing else still needs. A dependency is " + + "owned by every installed package whose lock pins it, so one that " + + "another package still holds stays in place. The project's own " + + "package — the one app.manifest.toml in the working directory " + + "declares — is not a guest, and uninstall refuses to remove it.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if err := runPackageUninstall(args[0]); err != nil { + log.Error(err.Error()) + os.Exit(inventory.ExitCode(err)) + } + }, + } + + uninstallCmd.Flags().StringVar(&packageScope, "scope", "project", + "scope to remove from: project, user or system") + uninstallCmd.Flags().BoolVarP(&packageYes, "yes", "y", false, + "skip the confirmation prompt") + + return uninstallCmd +} + +// runPackageUninstall removes one package and reports what went with it. +func runPackageUninstall(name string) error { + projectDir, err := absoluteWorkingDir() + if err != nil { + return err + } + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: projectDir, + Scope: state.Scope(packageScope), + Package: name, + Yes: packageYes, + Warn: func(msg string) { log.Warn(msg) }, + Confirm: func(prompt string) bool { + ok, askErr := util.AskConfirm(os.Stdin, prompt) + + return askErr == nil && ok + }, + }) + if err != nil { + return err + } + + fmt.Printf("removed %s %s from %s scope\n", result.Package, result.Version, result.Scope) + + for _, dep := range result.RemovedDependencies { + fmt.Printf(" removed unused dependency %s\n", dep) + } + + for _, dep := range result.KeptDependencies { + fmt.Printf(" kept %s (%s)\n", dep.Name, keptReason(dep)) + } + + return nil +} + +// keptReason says why a dependency survived the uninstall. A rock another +// package pins is kept for that reference; a rock installed as a package in its +// own right is kept because only an uninstall naming it may remove it, which is +// a different thing and reads wrong as "required by itself". +func keptReason(dep inventory.KeptDependency) string { + switch { + case len(dep.HeldBy) > 0 && dep.Installed: + return "installed as a package; also required by " + strings.Join(dep.HeldBy, ", ") + case dep.Installed: + return "installed as a package in its own right" + default: + return "required by " + strings.Join(dep.HeldBy, ", ") + } +} + +// absoluteWorkingDir returns the caller's working directory as an absolute +// path, which is the install-root every project-scope command works against. +func absoluteWorkingDir() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + return filepath.Abs(dir) +} + // newPackageInstallCmd wires `tt package install ARCHIVE...`. func newPackageInstallCmd() *cobra.Command { installCmd := &cobra.Command{ @@ -99,12 +262,7 @@ func newPackageInstallCmd() *cobra.Command { // install needs none, so a missing tarantool is only fatal when a dependency // must actually be refetched. func runPackageInstall(archives []string) error { - projectDir, err := os.Getwd() - if err != nil { - return err - } - - projectDir, err = filepath.Abs(projectDir) + projectDir, err := absoluteWorkingDir() if err != nil { return err } @@ -219,12 +377,7 @@ func newPackagePackCmd() *cobra.Command { // runPackagePack assembles pack.Options from the environment, packs, and prints // the resulting archive path to stdout so it can be piped. func runPackagePack() error { - projectDir, err := os.Getwd() - if err != nil { - return err - } - - projectDir, err = filepath.Abs(projectDir) + projectDir, err := absoluteWorkingDir() if err != nil { return err } @@ -312,12 +465,7 @@ func runPackageCmd(args []string, fetchOnly bool) { // runPackage assembles build.Options from the environment and drives the build. func runPackage(args []string, fetchOnly bool) error { - projectDir, err := os.Getwd() - if err != nil { - return err - } - - projectDir, err = filepath.Abs(projectDir) + projectDir, err := absoluteWorkingDir() if err != nil { return err } diff --git a/cli/manifest/inventory/errors.go b/cli/manifest/inventory/errors.go new file mode 100644 index 000000000..cc63001a7 --- /dev/null +++ b/cli/manifest/inventory/errors.go @@ -0,0 +1,50 @@ +package inventory + +import ( + "errors" + "fmt" + + "github.com/tarantool/tt/cli/manifest/build" +) + +// exitStateError is the process exit code for a usage or state failure: an +// unknown --scope, a package that is not installed, a refusal to remove the +// primary package, an unreadable tree. It matches what build, pack and install +// use, so every package command agrees. +const exitStateError = 1 + +var ( + // ErrNotInstalled reports an uninstall aimed at a package the scope does not + // hold. + ErrNotInstalled = errors.New("package is not installed") + // ErrPrimaryPackage reports an uninstall aimed at the project's own package. + // Its place in the tree is the project's to decide, not a guest's, so + // uninstall refuses rather than gutting the working directory. + ErrPrimaryPackage = errors.New("cannot uninstall the project's primary package") + // ErrBadPackageName reports an argument that is not a well-formed package + // name. Uninstall turns the name into a path, so the shape is checked before + // anything is removed. + ErrBadPackageName = errors.New("invalid package name") + // ErrAborted reports that the user declined the confirmation prompt; nothing + // was removed. + ErrAborted = errors.New("aborted") + // ErrUnknownFormat reports an -o value that is not table, json or yaml. + ErrUnknownFormat = errors.New("unknown output format") +) + +// ExitError re-exports build.ExitError so inventory returns the same typed +// error the build, pack and install commands do. +type ExitError = build.ExitError + +// ExitCode returns the process exit code for err, reusing the build package's +// mapping so every package command agrees. A nil error is 0. +func ExitCode(err error) int { + return build.ExitCode(err) +} + +// stateErrorf wraps a formatted error as a state error (exit 1). +// +//nolint:err113 // Formatting helper, mirrors fmt.Errorf; callers pass %w wraps. +func stateErrorf(format string, args ...any) error { + return &build.ExitError{Code: exitStateError, Err: fmt.Errorf(format, args...)} +} diff --git a/cli/manifest/inventory/format.go b/cli/manifest/inventory/format.go new file mode 100644 index 000000000..3d5b97347 --- /dev/null +++ b/cli/manifest/inventory/format.go @@ -0,0 +1,451 @@ +package inventory + +import ( + "encoding/json" + "fmt" + "io" + "slices" + "strings" + "text/tabwriter" + + "gopkg.in/yaml.v3" +) + +// yamlIndent is tt's usual YAML indent, and tabColumnPadding the gap between +// table columns. +const ( + yamlIndent = 2 + tabColumnPadding = 2 +) + +// Format is how a listing is rendered. +type Format string + +const ( + // FormatTable is the human-readable column layout. + FormatTable Format = "table" + // FormatJSON is machine-readable JSON. + FormatJSON Format = "json" + // FormatYAML is machine-readable YAML. + FormatYAML Format = "yaml" +) + +// ParseFormat resolves the -o value against whether stdout is a terminal. +// +// An explicit value always wins. With none given the default follows the tt +// convention: a terminal gets the table, and anything else — a pipe, a file, CI +// — gets YAML, so a command whose output is being consumed produces something +// parseable without the caller having to remember a flag. +func ParseFormat(raw string, tty bool) (Format, error) { + switch Format(raw) { + case "": + if tty { + return FormatTable, nil + } + + return FormatYAML, nil + case FormatTable: + return FormatTable, nil + case FormatJSON: + return FormatJSON, nil + case FormatYAML: + return FormatYAML, nil + default: + return "", stateErrorf("%w %q (want table, json or yaml)", ErrUnknownFormat, raw) + } +} + +// Render writes a listing in the chosen format. +// +// The human format has two shapes, chosen by the listing rather than by a +// separate argument: a listing carrying the dependency index renders as a tree, +// a plain one as a flat table. The machine formats need no such split — the +// index is simply another key when it is present. +func Render(out io.Writer, listing *Listing, format Format) error { + switch format { + case FormatJSON: + return renderJSON(out, listing) + case FormatYAML: + return renderYAML(out, listing) + case FormatTable: + if listing.Rocks != nil { + return renderTree(out, listing) + } + + return renderTable(out, listing) + default: + return stateErrorf("%w %q", ErrUnknownFormat, format) + } +} + +// renderJSON writes indented JSON with a trailing newline, so the output is +// pleasant both piped into jq and read directly. +func renderJSON(out io.Writer, listing *Listing) error { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + + err := encoder.Encode(listing) + if err != nil { + return fmt.Errorf("rendering JSON: %w", err) + } + + return nil +} + +// renderYAML writes YAML at tt's usual two-space indent. +func renderYAML(out io.Writer, listing *Listing) error { + encoder := yaml.NewEncoder(out) + encoder.SetIndent(yamlIndent) + + err := encoder.Encode(listing) + if err != nil { + return fmt.Errorf("rendering YAML: %w", err) + } + + return encoder.Close() //nolint:wrapcheck // Close reports the same encode error. +} + +// renderTable writes the human-readable listing: one row per package, with the +// primary package marked so it is obvious which one uninstall will refuse. +// +// An empty scope prints a sentence rather than a bare header — "nothing is +// installed" is the answer, and a lone header row reads like a bug. +func renderTable(out io.Writer, listing *Listing) error { + if len(listing.Packages) == 0 { + _, err := fmt.Fprintf(out, "no packages installed in %s scope (%s)\n", + listing.Scope, listing.Root) + if err != nil { + return fmt.Errorf("rendering table: %w", err) + } + + return nil + } + + table := tabwriter.NewWriter(out, 0, 0, tabColumnPadding, ' ', 0) + + _, err := fmt.Fprintln(table, "NAME\tVERSION\tKIND\tDESCRIPTION") + if err != nil { + return fmt.Errorf("rendering table: %w", err) + } + + for _, entry := range listing.Packages { + _, err = fmt.Fprintf(table, "%s\t%s\t%s\t%s\n", + entry.Name, dash(entry.Version), kindOf(entry), oneLine(entry.Description)) + if err != nil { + return fmt.Errorf("rendering table: %w", err) + } + } + + err = table.Flush() + if err != nil { + return fmt.Errorf("rendering table: %w", err) + } + + return nil +} + +// Tree drawing. A child that closes its level uses the corner, everything else +// the tee; the continuation keeps deeper levels connected to the branch above. +const ( + treeBranch = "\u251c\u2500\u2500 " + treeLast = "\u2514\u2500\u2500 " + treeContinuation = "\u2502 " + treeIndent = " " + // transitiveLabel heads the rocks that are in a package's closure but that no + // declared rock reaches through the tree manifest's edges — a tree with no + // manifest at all, or one whose edges are incomplete. They are shown without + // a parent rather than dropped or attached to a guess. + transitiveLabel = "pulled in transitively (parent not recorded)" + // noDepsLabel stands in for an empty child list, so a leaf never renders as a + // bare header that looks truncated. + noDepsLabel = "(no dependencies)" +) + +// treeNode is one rendered line with whatever hangs off it. +type treeNode struct { + label string + children []treeNode +} + +// renderTree writes the dependency view as one tree. +// +// The root is the project's own package: guests are installed into its tree and +// share its .rocks/, so the project is what contains them even though it does +// not depend on them — hence the (guest) marker rather than presenting them as +// requirements. A scope with no primary package has nothing to root at, so the +// scope itself becomes the root and every package hangs off it. +// +// The annotation on each rock is the point of the view. Before an uninstall the +// useful question is not what a package depends on but what would leave with it, +// so every rock says which side it falls on. +func renderTree(out io.Writer, listing *Listing) error { + if len(listing.Packages) == 0 { + return renderTable(out, listing) + } + + root := buildTree(listing) + + _, err := fmt.Fprintf(out, "%s scope \u2014 %s\n\n%s\n", + listing.Scope, listing.Root, root.label) + if err != nil { + return fmt.Errorf("rendering tree: %w", err) + } + + return renderChildren(out, root.children, "") +} + +// buildTree assembles the single-rooted node tree: the primary package with its +// own rocks, then every guest as a subtree beneath it. +func buildTree(listing *Listing) treeNode { + index := make(map[string]RockEntry, len(listing.Rocks)) + for _, dep := range listing.Rocks { + index[dep.Name] = dep + } + + var ( + root treeNode + guests []Entry + rooted bool + ) + + for _, entry := range listing.Packages { + if entry.Primary && !rooted { + root = treeNode{ + label: packageLabel(entry) + " (primary)", + children: dependencyNodes(entry, index), + } + rooted = true + + continue + } + + guests = append(guests, entry) + } + + if !rooted { + // No project package to root at — a shared tree, or a deploy directory + // holding guests only. + root = treeNode{label: "(no project package)", children: nil} + } + + for _, guest := range guests { + root.children = append(root.children, treeNode{ + label: packageLabel(guest) + " (guest)", + children: orNoDeps(dependencyNodes(guest, index)), + }) + } + + root.children = orNoDeps(root.children) + + return root +} + +// renderChildren writes one level and recurses, extending the prefix so deeper +// levels stay visually attached to the branch that carries them. +func renderChildren(out io.Writer, children []treeNode, prefix string) error { + for i, child := range children { + last := i == len(children)-1 + + _, err := fmt.Fprintf(out, "%s%s%s\n", prefix, branchOf(last), child.label) + if err != nil { + return fmt.Errorf("rendering tree: %w", err) + } + + err = renderChildren(out, child.children, prefix+continuationOf(last)) + if err != nil { + return err + } + } + + return nil +} + +// dependencyNodes turns a package's closure into its branches. +// +// The rocks the manifest declared hang off the package, and each of those +// carries whatever it requires in turn, walked through the edges LuaRocks +// records in the tree manifest. A rock in the closure that no walk reaches — +// because the tree carries no manifest, or its edges are incomplete — is still +// listed, under a group that admits its parent is unknown rather than dropping +// it or guessing. +func dependencyNodes(entry Entry, index map[string]RockEntry) []treeNode { + direct, _ := partitionDepth(entry) + + // A rock is only nested under a parent if this package actually brought it; + // the tree manifest is scope-wide and knows about rocks other packages own. + closure := entry.Dependencies + + reached := map[string]struct{}{} + nodes := make([]treeNode, 0, len(direct)+1) + + for _, name := range direct { + nodes = append(nodes, requirementNode(entry, name, index, closure, reached, nil)) + } + + // Whatever the walk did not reach: an unrooted remainder, kept visible. + var orphans []string + + for _, name := range sortedKeys(closure) { + if _, seen := reached[name]; !seen { + orphans = append(orphans, name) + } + } + + if len(orphans) == 0 { + return nodes + } + + group := treeNode{label: transitiveLabel, children: make([]treeNode, 0, len(orphans))} + + for _, name := range orphans { + group.children = append(group.children, + requirementNode(entry, name, index, closure, reached, nil)) + } + + return append(nodes, group) +} + +// requirementNode builds one rock's branch and, beneath it, the rocks it +// requires. +// +// path carries the ancestors of this node so a dependency cycle terminates +// instead of recursing forever. LuaRocks closures are acyclic in principle, but +// the tree manifest is data on disk and a view must not hang on a malformed one. +func requirementNode( + entry Entry, name string, index map[string]RockEntry, + closure map[string]string, reached map[string]struct{}, path []string, +) treeNode { + reached[name] = struct{}{} + + node := treeNode{label: depLabel(entry, name, index), children: nil} + + for _, required := range index[name].Requires { + // Only rocks this package brought, and never one already on the path + // above us. + if _, inClosure := closure[required]; !inClosure { + continue + } + + if slices.Contains(path, required) || required == name { + continue + } + + node.children = append(node.children, + requirementNode(entry, required, index, closure, reached, append(path, name))) + } + + return node +} + +// partitionDepth splits a package's closure into what its manifest declared and +// what came in behind those declarations, each in name order. +func partitionDepth(entry Entry) ([]string, []string) { + declared := make(map[string]struct{}, len(entry.Direct)) + for _, name := range entry.Direct { + declared[name] = struct{}{} + } + + var direct, transitive []string + + for _, name := range sortedKeys(entry.Dependencies) { + if _, ok := declared[name]; ok { + direct = append(direct, name) + } else { + transitive = append(transitive, name) + } + } + + return direct, transitive +} + +// orNoDeps substitutes the placeholder for an empty child list. +func orNoDeps(children []treeNode) []treeNode { + if len(children) > 0 { + return children + } + + return []treeNode{{label: noDepsLabel, children: nil}} +} + +// packageLabel names a package and its version. +func packageLabel(entry Entry) string { + if entry.Version == "" { + return entry.Name + } + + return entry.Name + " " + entry.Version +} + +// depLabel names a rock, the version in the tree, and what would become of it. +func depLabel(entry Entry, name string, index map[string]RockEntry) string { + return name + " " + entry.Dependencies[name] + shareNote(entry.Name, index[name]) +} + +// branchOf picks the tee or the closing corner. +func branchOf(last bool) string { + if last { + return treeLast + } + + return treeBranch +} + +// continuationOf is the prefix a child's own children inherit: blank once the +// branch has closed, a vertical bar while siblings still follow. +func continuationOf(last bool) string { + if last { + return treeIndent + } + + return treeContinuation +} + +// shareNote says what would happen to a rock if the package holding this branch +// were uninstalled: nothing when someone else holds it, removal when nobody +// does. +// +// A rock that is itself an installed package is neither — it survives on its own +// metadata, not on anyone's reference — so it is labelled as what it is. +func shareNote(owner string, dep RockEntry) string { + if dep.Installed { + return " (installed package)" + } + + others := make([]string, 0, len(dep.Owners)) + + for _, holder := range dep.Owners { + if holder != owner { + others = append(others, holder) + } + } + + if len(others) == 0 { + return " (only here)" + } + + return " (shared with " + strings.Join(others, ", ") + ")" +} + +// kindOf labels a row as the project's own package or a guest. +func kindOf(entry Entry) string { + if entry.Primary { + return "primary" + } + + return "guest" +} + +// dash renders an empty field as "-", so a column never looks truncated. +func dash(value string) string { + if value == "" { + return "-" + } + + return value +} + +// oneLine collapses a multi-line description onto one row; a newline inside a +// cell would break the column alignment. +func oneLine(text string) string { + return strings.Join(strings.Fields(text), " ") +} diff --git a/cli/manifest/inventory/format_test.go b/cli/manifest/inventory/format_test.go new file mode 100644 index 000000000..81a601b73 --- /dev/null +++ b/cli/manifest/inventory/format_test.go @@ -0,0 +1,194 @@ +package inventory_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/tarantool/tt/cli/manifest/inventory" + "github.com/tarantool/tt/cli/manifest/state" +) + +// render writes a listing in the given format and returns it as a string. +func render(t *testing.T, listing *inventory.Listing, format inventory.Format) string { + t.Helper() + + var out strings.Builder + + require.NoError(t, inventory.Render(&out, listing, format)) + + return out.String() +} + +// TestParseFormatExplicit covers the three accepted values. +func TestParseFormatExplicit(t *testing.T) { + t.Parallel() + + for raw, want := range map[string]inventory.Format{ + "table": inventory.FormatTable, + "json": inventory.FormatJSON, + "yaml": inventory.FormatYAML, + } { + for _, tty := range []bool{true, false} { + got, err := inventory.ParseFormat(raw, tty) + require.NoError(t, err, raw) + assert.Equal(t, want, got, raw) + } + } +} + +// TestParseFormatDefaultsByTTY pins the convention: a terminal gets the human +// table, a pipe gets YAML so piped output is parseable without a flag. +func TestParseFormatDefaultsByTTY(t *testing.T) { + t.Parallel() + + got, err := inventory.ParseFormat("", true) + require.NoError(t, err) + assert.Equal(t, inventory.FormatTable, got) + + got, err = inventory.ParseFormat("", false) + require.NoError(t, err) + assert.Equal(t, inventory.FormatYAML, got) +} + +// TestParseFormatRejectsUnknown pins that a mistyped -o is exit 1, not a silent +// fallback to the table. +func TestParseFormatRejectsUnknown(t *testing.T) { + t.Parallel() + + _, err := inventory.ParseFormat("xml", true) + require.Error(t, err) + require.ErrorIs(t, err, inventory.ErrUnknownFormat) + assert.Equal(t, 1, inventory.ExitCode(err)) + assert.Contains(t, err.Error(), "xml") +} + +// TestRenderYAMLIsValid pins that the YAML output round-trips. +func TestRenderYAMLIsValid(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installPrimary(t, pkg{name: "my-app", version: "1.2.3"}) + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0"}}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + var decoded inventory.Listing + + require.NoError(t, yaml.Unmarshal([]byte(render(t, listing, inventory.FormatYAML)), &decoded)) + + assert.Equal(t, state.ScopeProject, decoded.Scope) + require.Len(t, decoded.Packages, 2) + assert.Equal(t, "my-app", decoded.Packages[0].Name) + assert.True(t, decoded.Packages[0].Primary) + assert.Equal(t, map[string]string{"metrics": "1.0.0"}, decoded.Packages[1].Dependencies) +} + +// TestRenderTable pins the human output: a header, one row per package, and the +// primary package marked so it is obvious which one uninstall will refuse. +func TestRenderTable(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installPrimary(t, pkg{name: "my-app", version: "1.2.3", description: "The application"}) + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0"}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + out := render(t, listing, inventory.FormatTable) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + require.Len(t, lines, 3) + assert.Contains(t, lines[0], "NAME") + assert.Contains(t, lines[0], "VERSION") + assert.Contains(t, lines[0], "KIND") + + assert.Contains(t, lines[1], "my-app") + assert.Contains(t, lines[1], "1.2.3") + assert.Contains(t, lines[1], "primary") + assert.Contains(t, lines[1], "The application") + + assert.Contains(t, lines[2], "monitoring") + assert.Contains(t, lines[2], "guest") +} + +// TestRenderTableEmpty pins that an empty scope says so in a sentence rather +// than printing a lone header row, which reads like a bug. +func TestRenderTableEmpty(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + out := render(t, listing, inventory.FormatTable) + + assert.Contains(t, out, "no packages installed") + assert.NotContains(t, out, "NAME") +} + +// TestRenderTableMissingVersion pins that an empty column renders as "-" rather +// than a gap that looks like truncation. +func TestRenderTableMissingVersion(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + // A project with a manifest but no VERSION pin next to it. + tr.installPrimary(t, pkg{name: "my-app", version: ""}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + out := render(t, listing, inventory.FormatTable) + + assert.Contains(t, out, "my-app") + assert.Contains(t, out, "-") +} + +// TestRenderTableFlattensDescription pins that a multi-line description cannot +// break the column alignment. +func TestRenderTableFlattensDescription(t *testing.T) { + t.Parallel() + + listing := &inventory.Listing{ + Scope: state.ScopeProject, + Root: "/tmp/project", + Packages: []inventory.Entry{{ + Name: "my-app", Version: "1.0.0", Primary: false, + Description: "first line\nsecond line", + Dependencies: nil, + }}, + } + + out := render(t, listing, inventory.FormatTable) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + require.Len(t, lines, 2, "a multi-line description must not add rows") + assert.Contains(t, lines[1], "first line second line") +} + +// TestRenderRejectsUnknownFormat pins that an unvalidated format cannot fall +// through to a silent default. +func TestRenderRejectsUnknownFormat(t *testing.T) { + t.Parallel() + + listing := &inventory.Listing{ + Scope: state.ScopeProject, Root: "/tmp/project", Packages: nil, + } + + var out strings.Builder + + err := inventory.Render(&out, listing, inventory.Format("xml")) + require.Error(t, err) + require.ErrorIs(t, err, inventory.ErrUnknownFormat) +} diff --git a/cli/manifest/inventory/helpers_test.go b/cli/manifest/inventory/helpers_test.go new file mode 100644 index 000000000..27a2beb22 --- /dev/null +++ b/cli/manifest/inventory/helpers_test.go @@ -0,0 +1,302 @@ +package inventory_test + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/state" +) + +// tree is a fixture install tree: a project directory with a .rocks/ layout that +// tests populate the way an install would, without ever running one. +type tree struct { + // dir is the install-root (the project directory for project scope). + dir string + // lay is the resolved layout of the scope under test. + lay state.Layout +} + +// newTree resolves a fresh install tree for the given scope over a temp +// directory. Only the project scope is rooted in a temp dir; user and system +// point at real machine paths, so tests that need those layouts build the +// project layout and pass the scope separately. +func newTree(t *testing.T) tree { + t.Helper() + + dir := t.TempDir() + + lay, err := state.ResolveLayout(state.ScopeProject, dir) + require.NoError(t, err) + + return tree{dir: dir, lay: lay} +} + +// pkg describes one package to lay into a fixture tree. +type pkg struct { + // name is the package name. + name string + // version is the recorded version, also the version its files sit at. + version string + // description is the manifest's package description. + description string + // deps are the version constraints its manifest declares. + deps map[string]string + // pins are the exact versions its lock records, keyed by rock name. Every + // pinned rock gets files in the tree too. + pins map[string]string + // noLock omits the lock entirely, as a guest installed by a tt that predates + // lock metadata would be. + noLock bool +} + +// installGuest lays a guest package into the tree: its metadata under +// manifests//, its own files, and the files of every rock it pins. +func (tr tree) installGuest(t *testing.T, spec pkg) { + t.Helper() + + source := manifestTOML(spec) + + var lockBytes []byte + if !spec.noLock { + lockBytes = lockTOML(t, source, spec.pins) + } + + require.NoError(t, + state.WriteMetadata(tr.lay, spec.name, []byte(source), lockBytes, spec.version)) + + tr.installFiles(t, spec) +} + +// installGuestManifest lays a guest whose manifest is supplied verbatim, for +// shapes the pkg spec does not render (a component-level dependency, say). +func (tr tree) installGuestManifest( + t *testing.T, name, version, manifestSource string, pins map[string]string, +) { + t.Helper() + + require.NoError(t, state.WriteMetadata( + tr.lay, name, []byte(manifestSource), lockTOML(t, manifestSource, pins), version)) + + tr.installRock(t, name, version) + + for rock, rockVersion := range pins { + tr.installRock(t, rock, rockVersion) + } +} + +// manifestWithComponentDep renders a manifest declaring its dependency on the +// component rather than in the global map. The resolver treats both as +// declarations, so the inventory has to as well. +func manifestWithComponentDep(name, dep, constraint string) string { + return "manifest_version = '0.1'\n\n[package]\nname = '" + name + "'\n\n" + + "[platform]\ntarantool = '>=3.0.0'\ntt = '>=3.1.0'\n\n" + + "[products.default]\ncomponents = ['lua']\ndefault = true\n\n" + + "[components.lua]\npath = '.'\n\n" + + "[components.lua.dependencies]\n" + dep + " = '" + constraint + "'\n" +} + +// installPrimary lays the project's own package into the install root: its +// manifest, lock and VERSION, plus the files of every rock it pins. +func (tr tree) installPrimary(t *testing.T, spec pkg) { + t.Helper() + + source := manifestTOML(spec) + + tr.write(t, filepath.Join(tr.lay.Root, state.PrimaryManifestFile), source) + + if !spec.noLock { + tr.write(t, filepath.Join(tr.lay.Root, state.PrimaryLockFile), + string(lockTOML(t, source, spec.pins))) + } + + if spec.version != "" { + tr.write(t, filepath.Join(tr.lay.Root, state.PrimaryVersionFile), spec.version+"\n") + } + + tr.installFiles(t, spec) +} + +// installFiles materializes the package's own rock subtree and one for every +// rock it pins. Rocks pinned by several packages are simply written twice, which +// is what sharing one copy in the tree looks like. +func (tr tree) installFiles(t *testing.T, spec pkg) { + t.Helper() + + if spec.version != "" { + tr.installRock(t, spec.name, spec.version) + } + + for name, version := range spec.pins { + tr.installRock(t, name, version) + } +} + +// installRock writes the three places one rock version occupies: a module under +// share/, a shared object under lib/, and a rock-manifest directory. +func (tr tree) installRock(t *testing.T, name, version string) { + t.Helper() + + tr.write(t, filepath.Join(tr.lay.Share, name, "init.lua"), "-- "+name+"\n") + tr.write(t, filepath.Join(tr.lay.Lib, name, name+".so"), "binary\n") + tr.write(t, + filepath.Join(tr.lay.Share, "rocks", name, version, "rock_manifest"), + name+" "+version+"\n") +} + +// writeTreeManifest lays the LuaRocks tree manifest that records which rock +// requires which, at /rocks/manifest. +// +// It is written by hand in LuaRocks' own Lua-table format rather than through a +// serializer, so the reader under test is exercised against the real on-disk +// shape and not against a round trip of its own assumptions. +func (tr tree) writeTreeManifest( + t *testing.T, edges map[string][]string, versions map[string]string, +) { + t.Helper() + + var builder strings.Builder + + // LuaRocks manifests are bare assignments loaded into an environment, not a + // returned table — the parser rejects a "return {...}" wrapper. + builder.WriteString("commands = {}\nmodules = {}\nrepository = {}\ndependencies = {\n") + + for _, rock := range sortedKeys(edges) { + version := versions[rock] + if version == "" { + version = "0.0.0-1" + } + + // Bracket-quote every key: a rock name with a hyphen is not a valid Lua + // bare key, and LuaRocks quotes such names too. + builder.WriteString(" [\"" + rock + "\"] = {\n") + builder.WriteString(" [\"" + version + "\"] = {\n") + + for _, dep := range edges[rock] { + builder.WriteString(" {\n") + builder.WriteString(" name = \"" + dep + "\",\n") + builder.WriteString(" constraints = {}\n") + builder.WriteString(" },\n") + } + + builder.WriteString(" }\n },\n") + } + + builder.WriteString("}\n") + + tr.write(t, filepath.Join(tr.lay.Share, "rocks", "manifest"), builder.String()) +} + +// write creates a file and its parents. +func (tr tree) write(t *testing.T, path, content string) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +// rockExists reports whether a rock still has files anywhere in the tree. It +// checks all three locations, so a partial removal fails the assertion rather +// than passing on the one directory that happened to go. +func (tr tree) rockExists(name string) bool { + for _, path := range []string{ + filepath.Join(tr.lay.Share, name), + filepath.Join(tr.lay.Lib, name), + filepath.Join(tr.lay.Share, "rocks", name), + } { + if _, err := os.Stat(path); err == nil { + return true + } + } + + return false +} + +// rockManifestPath is where one version of a rock records itself in the tree. +func (tr tree) rockManifestPath(name, version string) string { + return filepath.Join(tr.lay.Share, "rocks", name, version) +} + +// removeAll deletes a path, for fixtures that simulate a tree diverging from +// what a lock recorded. +func removeAll(path string) error { + return os.RemoveAll(path) +} + +// metadataExists reports whether a package still has metadata under manifests/. +func (tr tree) metadataExists(name string) bool { + _, err := os.Stat(filepath.Join(tr.lay.Manifests, name)) + + return err == nil +} + +// manifestTOML renders a package's manifest from its spec. +func manifestTOML(spec pkg) string { + var builder strings.Builder + + builder.WriteString("manifest_version = '0.1'\n\n[package]\nname = '" + spec.name + "'\n") + + if spec.description != "" { + builder.WriteString("description = '" + spec.description + "'\n") + } + + builder.WriteString("\n[platform]\ntarantool = '>=3.0.0'\ntt = '>=3.1.0'\n\n" + + "[products.default]\ncomponents = ['lua']\ndefault = true\n\n" + + "[components.lua]\npath = '.'\n") + + if len(spec.deps) > 0 { + builder.WriteString("\n[dependencies]\n") + + for _, name := range sortedKeys(spec.deps) { + builder.WriteString(name + " = '" + spec.deps[name] + "'\n") + } + } + + return builder.String() +} + +// lockTOML renders a lock pinning the given rocks under the default product, +// with a manifest hash matching the rendered manifest. +func lockTOML(t *testing.T, manifestSource string, pins map[string]string) []byte { + t.Helper() + + depList := make([]manifest.LockDependency, 0, len(pins)) + for _, name := range sortedKeys(pins) { + depList = append(depList, manifest.LockDependency{ + Name: name, Version: pins[name], Source: "registry", + }) + } + + lock := manifest.Lock{ + LockVersion: manifest.LockVersion, + ManifestVersion: manifest.ManifestVersion, + GeneratedBy: "tt 3.0.0", + ManifestHash: manifest.HashBytes([]byte(manifestSource)), + Products: map[string]manifest.LockProduct{ + "default": {Dependencies: depList}, + }, + } + + data, err := lock.Marshal() + require.NoError(t, err) + + return data +} + +// sortedKeys returns a map's keys in sorted order, so rendered fixtures are +// stable. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + + sort.Strings(keys) + + return keys +} diff --git a/cli/manifest/inventory/list.go b/cli/manifest/inventory/list.go new file mode 100644 index 000000000..997f8c475 --- /dev/null +++ b/cli/manifest/inventory/list.go @@ -0,0 +1,255 @@ +// Package inventory implements the two commands that work over what an install +// left on disk: tt package list, which reports the packages present in a scope, +// and tt package uninstall, which removes a guest package and the shared +// dependencies it was the last to hold. +// +// Both are pure metadata operations. Nothing here contacts a registry, runs a +// build, or re-resolves anything: the source of truth is manifests//, which +// install writes and cli/manifest/state reads back. +// +// List reports what is on disk, which is deliberately not what tt package deps +// reports — deps prints the dependencies the current manifest declares, whether +// or not they were ever installed. +package inventory + +import ( + "sort" + + "github.com/tarantool/tt/cli/manifest/state" +) + +// ListOptions selects what to list. +type ListOptions struct { + // ProjectDir is the install-root for project scope (the caller's cwd). It + // must be absolute; user and system scopes derive their roots from the + // environment and ignore it. + ProjectDir string + // Scope selects which tree to read. The zero value ("") is project. + Scope state.Scope + // Rocks asks for the rock index alongside the packages: every rock in the + // scope with the packages that brought it and what it requires in turn. It is + // the question a plain listing cannot answer — which of a package's rocks + // would go with it, and which another package still holds — so it is computed + // only when asked for. + Rocks bool +} + +// Entry is one installed package in a listing. +type Entry struct { + // Name is the package name, taken from its manifest. + Name string `json:"name" yaml:"name"` + // Version is the recorded version. Empty when nothing recorded one: a guest + // installed by a tt that predates the VERSION metadata, or a project with no + // VERSION pin next to its manifest. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // Primary marks the project's own package rather than a guest installed from + // an archive. Only the project scope can hold one, and uninstall refuses to + // touch it. + Primary bool `json:"primary" yaml:"primary"` + // Description is the manifest's package description, when it declares one. + Description string `json:"description,omitempty" yaml:"description,omitempty"` + // Dependencies are the versions this package's lock pins, keyed by rock name. + // This is the whole transitive closure, not just what the manifest asked for: + // a package owns every rock it caused to be installed, at whatever depth, and + // that overlap is what uninstall counts over. + Dependencies map[string]string `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` + // Direct are the subset of Dependencies the manifest declares itself, in name + // order; the rest of the closure arrived behind them. Which rock pulled an + // indirect one in comes from RockEntry.Requires, not from the lock, + // which stores the closure flattened. + Direct []string `json:"direct,omitempty" yaml:"direct,omitempty"` +} + +// RockEntry is one rock present in the scope, with every package that brought +// it. It is the reverse of Entry.Dependencies: the same ownership relation read +// from the rock's side, which is the side that answers what an uninstall would +// take with it. +type RockEntry struct { + // Name is the rock name. + Name string `json:"name" yaml:"name"` + // Version is the version in the tree, falling back to what a lock pinned when + // the rock has no files (a lock recording a dependency that was never laid + // down). + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // Owners are the packages whose lock pins it, in name order. + Owners []string `json:"owners" yaml:"owners"` + // Shared marks a dependency more than one package brought. It is the question + // the view exists to answer, so it is stated rather than left to be counted. + Shared bool `json:"shared" yaml:"shared"` + // Installed marks a dependency that is also an installed package in its own + // right. Those are never removed as collateral — only an uninstall naming + // them removes them — so the distinction matters when reading the tree. + Installed bool `json:"installed,omitempty" yaml:"installed,omitempty"` + // Requires are the rocks this one pulls in, in name order, read from the + // tree manifest LuaRocks maintains. Empty when the tree records no edges for + // it — either the rock needs nothing, or the tree carries no manifest at all. + Requires []string `json:"requires,omitempty" yaml:"requires,omitempty"` +} + +// Listing is the result of tt package list. +type Listing struct { + // Scope is the tree that was read. + Scope state.Scope `json:"scope" yaml:"scope"` + // Root is the install-root of that scope, so a machine-readable listing says + // which tree it describes. + Root string `json:"root" yaml:"root"` + // Packages are the packages present, primary first and guests in name order. + Packages []Entry `json:"packages" yaml:"packages"` + // Rocks is the reverse ownership index, in name order. Nil unless + // ListOptions.Rocks asked for it. + // + // It is deliberately not called "dependencies": a package entry already has a + // "dependencies" key holding a name→version object, and reusing the name for + // an array of records would put two shapes behind one word. + Rocks []RockEntry `json:"rocks,omitempty" yaml:"rocks,omitempty"` +} + +// List reports the packages installed in the selected scope. A scope with +// nothing installed is an empty listing, not an error: a project that has never +// been installed into is a normal state, not a broken one. +func List(opts ListOptions) (*Listing, error) { + scope, lay, err := resolve(opts.Scope, opts.ProjectDir) + if err != nil { + return nil, err + } + + packages, err := state.Packages(lay, scope) + if err != nil { + return nil, stateErrorf("%w", err) + } + + listing := &Listing{ + Scope: scope, + Root: lay.Root, + Packages: make([]Entry, 0, len(packages)), + // Nil unless asked for. The renderer reads nil as "no rock view was + // requested" and falls back to the flat table, so an empty-but-non-nil + // index — a scope that was asked for the view and holds nothing — still + // renders as the tree. + Rocks: nil, + } + + for _, pkg := range packages { + listing.Packages = append(listing.Packages, entryOf(pkg)) + } + + if opts.Rocks { + listing.Rocks = rockIndex(lay, packages) + } + + return listing, nil +} + +// rockIndex inverts the per-package pins into one entry per rock, carrying the +// packages that brought it. +// +// The version reported is the one in the tree rather than any single lock's pin: +// packages sharing a rock hold one copy between them, and a later install can +// reconcile it to a version an earlier package's lock never named. What is on +// disk is what an uninstall would remove, so that is what the view shows. +func rockIndex(lay state.Layout, packages []state.Package) []RockEntry { + owners := map[string][]string{} + pins := map[string]string{} + edges := state.DependencyEdges(lay) + + for _, pkg := range packages { + for name, version := range state.LockedDependencies(pkg.Lock) { + owners[name] = append(owners[name], pkg.Name) + + if _, seen := pins[name]; !seen { + pins[name] = version + } + } + } + + entries := make([]RockEntry, 0, len(owners)) + + for _, name := range sortedKeys(owners) { + holders := owners[name] + sort.Strings(holders) + + version, onDisk := state.InstalledVersion(lay, name) + if !onDisk { + version = pins[name] + } + + _, installed := state.Find(packages, name) + + entries = append(entries, RockEntry{ + Name: name, + Version: version, + Owners: holders, + Shared: len(holders) > 1, + Installed: installed, + Requires: edges[name], + }) + } + + return entries +} + +// sortedKeys returns a map's keys in sorted order, so the index is +// deterministic. +func sortedKeys[V any](m map[string]V) []string { + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + + sort.Strings(names) + + return names +} + +// entryOf projects an installed package onto its listing entry. +func entryOf(pkg state.Package) Entry { + entry := Entry{ + Name: pkg.Name, + Version: pkg.Version, + Primary: pkg.Primary, + Description: "", + Dependencies: state.LockedDependencies(pkg.Lock), + Direct: nil, + } + + if pkg.Manifest != nil { + entry.Description = pkg.Manifest.Package.Description + } + + // A manifest can declare a dependency the lock does not pin (a dev-only or + // unresolved one), so intersect rather than taking the declarations whole: + // Direct must stay a subset of what is actually installed. + declared := state.DeclaredDependencies(pkg.Manifest) + + for _, name := range sortedKeys(entry.Dependencies) { + if _, ok := declared[name]; ok { + entry.Direct = append(entry.Direct, name) + } + } + + // An empty map would render as an explicit "dependencies: {}" in the machine + // output; nil omits the key entirely, which reads as "brought nothing". + if len(entry.Dependencies) == 0 { + entry.Dependencies = nil + } + + return entry +} + +// resolve validates the scope and resolves its directories, mapping both +// failures to exit 1. +func resolve(raw state.Scope, projectDir string) (state.Scope, state.Layout, error) { + var zero state.Layout + + scope, err := state.ParseScope(string(raw)) + if err != nil { + return "", zero, stateErrorf("%w", err) + } + + lay, err := state.ResolveLayout(scope, projectDir) + if err != nil { + return "", zero, stateErrorf("%w", err) + } + + return scope, lay, nil +} diff --git a/cli/manifest/inventory/list_test.go b/cli/manifest/inventory/list_test.go new file mode 100644 index 000000000..11b70ce06 --- /dev/null +++ b/cli/manifest/inventory/list_test.go @@ -0,0 +1,203 @@ +package inventory_test + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/inventory" + "github.com/tarantool/tt/cli/manifest/state" +) + +// TestListPrimaryAndGuests is the task's headline case: a project holding its +// own package and two guests lists all three. +func TestListPrimaryAndGuests(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installPrimary(t, pkg{ + name: "my-app", version: "1.2.3", description: "The application", + deps: map[string]string{"metrics": ">=1.0.0"}, + pins: map[string]string{"metrics": "1.0.0"}, + }) + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", description: "Monitoring guest", + pins: map[string]string{"metrics": "1.0.0"}, + }) + tr.installGuest(t, pkg{name: "alerting", version: "0.5.0"}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + require.Len(t, listing.Packages, 3) + assert.Equal(t, state.ScopeProject, listing.Scope) + assert.Equal(t, tr.dir, listing.Root) + + assert.Equal(t, "my-app", listing.Packages[0].Name) + assert.True(t, listing.Packages[0].Primary) + assert.Equal(t, "1.2.3", listing.Packages[0].Version) + assert.Equal(t, "The application", listing.Packages[0].Description) + + assert.Equal(t, "alerting", listing.Packages[1].Name) + assert.False(t, listing.Packages[1].Primary) + + assert.Equal(t, "monitoring", listing.Packages[2].Name) + assert.Equal(t, map[string]string{"metrics": "1.0.0"}, listing.Packages[2].Dependencies) +} + +// TestListJSONIsValid pins the second half of the task's headline case: the +// machine output parses, and carries the same three packages. +func TestListJSONIsValid(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installPrimary(t, pkg{name: "my-app", version: "1.2.3"}) + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0"}}) + tr.installGuest(t, pkg{name: "alerting", version: "0.5.0"}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + rendered := render(t, listing, inventory.FormatJSON) + + var decoded struct { + Scope string `json:"scope"` + Root string `json:"root"` + Packages []struct { + Name string `json:"name"` + Version string `json:"version"` + Primary bool `json:"primary"` + Dependencies map[string]string `json:"dependencies"` + } `json:"packages"` + } + + require.NoError(t, json.Unmarshal([]byte(rendered), &decoded)) + + assert.Equal(t, "project", decoded.Scope) + assert.Equal(t, tr.dir, decoded.Root) + require.Len(t, decoded.Packages, 3) + + assert.Equal(t, "my-app", decoded.Packages[0].Name) + assert.True(t, decoded.Packages[0].Primary) + assert.Equal(t, map[string]string{"metrics": "1.0.0"}, decoded.Packages[2].Dependencies) +} + +// TestListEmptyScope pins that a project nothing was installed into lists as +// empty rather than failing. A fresh checkout has no .rocks/ at all. +func TestListEmptyScope(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + assert.Empty(t, listing.Packages) + assert.Equal(t, state.ScopeProject, listing.Scope) +} + +// TestListGuestsOnly covers a tree with guests but no primary package — a +// deploy directory rather than a development one. +func TestListGuestsOnly(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0"}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + require.Len(t, listing.Packages, 1) + assert.False(t, listing.Packages[0].Primary) +} + +// TestListOmitsEmptyDependencies pins that a package that brought nothing has no +// dependencies key at all, rather than an empty object in the machine output. +func TestListOmitsEmptyDependencies(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "alerting", version: "0.5.0"}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + require.Len(t, listing.Packages, 1) + assert.Nil(t, listing.Packages[0].Dependencies) + + assert.NotContains(t, render(t, listing, inventory.FormatJSON), "dependencies") +} + +// TestListTolerantOfBrokenGuest pins that one corrupt metadata directory does +// not take the whole inventory down: list is a diagnostic tool, and it is most +// needed exactly when something is wrong. +func TestListTolerantOfBrokenGuest(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0"}) + tr.write(t, + filepath.Join(tr.lay.Manifests, "broken", state.MetaManifestFile), + "not = valid toml [") + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + require.Len(t, listing.Packages, 1) + assert.Equal(t, "monitoring", listing.Packages[0].Name) +} + +// TestListGuestWithoutLock pins that a guest installed before lock metadata +// existed still lists, owning nothing. +func TestListGuestWithoutLock(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "legacy", version: "1.0.0", noLock: true}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + require.Len(t, listing.Packages, 1) + assert.Equal(t, "legacy", listing.Packages[0].Name) + assert.Nil(t, listing.Packages[0].Dependencies) +} + +// TestListRejectsUnknownScope pins that a bad --scope fails with exit 1 rather +// than silently listing the project tree. +func TestListRejectsUnknownScope(t *testing.T) { + t.Parallel() + + _, err := inventory.List(inventory.ListOptions{ + ProjectDir: t.TempDir(), Scope: state.Scope("global"), + }) + require.Error(t, err) + require.ErrorIs(t, err, state.ErrUnknownScope) + assert.Equal(t, 1, inventory.ExitCode(err)) +} + +// TestListDefaultsToProjectScope pins that the zero scope is project, so the +// CLI's default and the library's default cannot drift apart. +func TestListDefaultsToProjectScope(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0"}) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir, Scope: ""}) + require.NoError(t, err) + + assert.Equal(t, state.ScopeProject, listing.Scope) + require.Len(t, listing.Packages, 1) +} diff --git a/cli/manifest/inventory/scope_test.go b/cli/manifest/inventory/scope_test.go new file mode 100644 index 000000000..1a96610f6 --- /dev/null +++ b/cli/manifest/inventory/scope_test.go @@ -0,0 +1,94 @@ +package inventory_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/inventory" + "github.com/tarantool/tt/cli/manifest/state" +) + +// userTree points a fixture tree at a fake home directory, so the user scope +// resolves into a temp dir instead of the developer's real ~/.luarocks. +// +// These tests cannot run in parallel: t.Setenv is process-wide. +func userTree(t *testing.T) tree { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + + lay, err := state.ResolveLayout(state.ScopeUser, "") + require.NoError(t, err) + + require.Equal(t, filepath.Join(home, ".luarocks"), lay.Root, + "the user scope must resolve under the overridden home") + + return tree{dir: home, lay: lay} +} + +// TestUninstallUserScope is the task's user-scope case: the removal happens in +// the user tree, addressed through --scope rather than the project directory. +func TestUninstallUserScope(t *testing.T) { + tr := userTree(t) + + tr.installGuest(t, pkg{ + name: "some-lib", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0"}, + }) + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + Scope: state.ScopeUser, Package: "some-lib", Yes: true, + }) + require.NoError(t, err) + + assert.Equal(t, state.ScopeUser, result.Scope) + assert.Equal(t, []string{"metrics"}, result.RemovedDependencies) + + assert.False(t, tr.rockExists("some-lib")) + assert.False(t, tr.metadataExists("some-lib")) + assert.False(t, tr.rockExists("metrics")) +} + +// TestListUserScope covers the read side of the same tree, including that a +// stray app.manifest.toml at the root of a shared tree is not read as a primary +// package — user trees hold guests only. +func TestListUserScope(t *testing.T) { + tr := userTree(t) + + tr.installGuest(t, pkg{name: "some-lib", version: "2.0.0"}) + tr.write(t, filepath.Join(tr.lay.Root, state.PrimaryManifestFile), + manifestTOML(pkg{name: "not-a-primary", version: "9.9.9"})) + + listing, err := inventory.List(inventory.ListOptions{Scope: state.ScopeUser}) + require.NoError(t, err) + + assert.Equal(t, state.ScopeUser, listing.Scope) + require.Len(t, listing.Packages, 1) + assert.Equal(t, "some-lib", listing.Packages[0].Name) + assert.False(t, listing.Packages[0].Primary) +} + +// TestUninstallUserScopeIgnoresProjectDir pins that the user scope is addressed +// by scope alone: a ProjectDir pointing at an unrelated tree must not redirect +// the removal. +func TestUninstallUserScopeIgnoresProjectDir(t *testing.T) { + tr := userTree(t) + + tr.installGuest(t, pkg{name: "some-lib", version: "2.0.0"}) + + // A project tree holding a same-named guest, which must be left alone. + project := newTree(t) + project.installGuest(t, pkg{name: "some-lib", version: "2.0.0"}) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: project.dir, Scope: state.ScopeUser, Package: "some-lib", Yes: true, + }) + require.NoError(t, err) + + assert.False(t, tr.metadataExists("some-lib"), "the user tree's copy is removed") + assert.True(t, project.metadataExists("some-lib"), "the project tree is untouched") +} diff --git a/cli/manifest/inventory/tree_test.go b/cli/manifest/inventory/tree_test.go new file mode 100644 index 000000000..188203e08 --- /dev/null +++ b/cli/manifest/inventory/tree_test.go @@ -0,0 +1,769 @@ +package inventory_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/inventory" + "github.com/tarantool/tt/cli/manifest/state" +) + +// treeFixture lays out every case the dependency view has to distinguish, at +// both depths. A lock carries the whole transitive closure, so monitoring's lock +// pins four rocks while its manifest declares only two: +// +// metrics — declared by monitoring, held only by it (would go with it) +// shared-lib — declared by monitoring, but installed in its own right +// checks — transitive for monitoring, declared by my-app and alerting +// luasocket — transitive for monitoring, held only by it +func treeFixture(t *testing.T) tree { + t.Helper() + + tr := newTree(t) + + tr.installPrimary(t, pkg{ + name: "my-app", version: "1.2.3", + deps: map[string]string{"checks": ">=3.0.0"}, + pins: map[string]string{"checks": "3.1.0"}, + }) + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + deps: map[string]string{"metrics": ">=1.0.0", "shared-lib": ">=1.0.0"}, + pins: map[string]string{ + "metrics": "1.0.0", "shared-lib": "1.0.0", + "checks": "3.1.0", "luasocket": "3.0.4", + }, + }) + tr.installGuest(t, pkg{ + name: "alerting", version: "0.5.0", + deps: map[string]string{"checks": ">=3.0.0"}, + pins: map[string]string{"checks": "3.1.0"}, + }) + tr.installGuest(t, pkg{name: "shared-lib", version: "1.0.0"}) + + // The edges LuaRocks records in the tree: metrics pulls checks, shared-lib + // pulls luasocket. Those two are exactly monitoring's transitive rocks. + tr.writeTreeManifest(t, + map[string][]string{ + "metrics": {"checks"}, + "shared-lib": {"luasocket"}, + }, + map[string]string{"metrics": "1.0.0", "shared-lib": "1.0.0"}) + + return tr +} + +// listTree reads the inventory with the dependency index populated. +func listTree(t *testing.T, tr tree) *inventory.Listing { + t.Helper() + + listing, err := inventory.List(inventory.ListOptions{ + ProjectDir: tr.dir, Rocks: true, + }) + require.NoError(t, err) + + return listing +} + +// TestRockIndexOwners pins the reverse index: every rock with the packages +// that brought it, in name order. +func TestRockIndexOwners(t *testing.T) { + t.Parallel() + + listing := listTree(t, treeFixture(t)) + + byName := map[string]inventory.RockEntry{} + for _, dep := range listing.Rocks { + byName[dep.Name] = dep + } + + names := make([]string, 0, len(listing.Rocks)) + for _, dep := range listing.Rocks { + names = append(names, dep.Name) + } + + assert.Equal(t, []string{"checks", "luasocket", "metrics", "shared-lib"}, names, + "the index is in name order and spans the whole closure, not just declarations") + + // checks is transitive for monitoring and declared by the other two; ownership + // does not care which, so all three own it. + assert.Equal(t, []string{"alerting", "monitoring", "my-app"}, byName["checks"].Owners) + assert.True(t, byName["checks"].Shared) + + assert.Equal(t, []string{"monitoring"}, byName["metrics"].Owners) + assert.False(t, byName["metrics"].Shared) + assert.Equal(t, "1.0.0", byName["metrics"].Version) + + // A purely transitive rock is owned exactly like a declared one. + assert.Equal(t, []string{"monitoring"}, byName["luasocket"].Owners) + assert.False(t, byName["luasocket"].Shared) +} + +// TestRockIndexMarksInstalledPackages pins that a dependency which is also +// an installed package is flagged: it survives on its own metadata, so the +// shared/exclusive reading does not apply to it. +func TestRockIndexMarksInstalledPackages(t *testing.T) { + t.Parallel() + + listing := listTree(t, treeFixture(t)) + + byName := map[string]inventory.RockEntry{} + for _, dep := range listing.Rocks { + byName[dep.Name] = dep + } + + assert.True(t, byName["shared-lib"].Installed) + assert.False(t, byName["metrics"].Installed) + assert.False(t, byName["checks"].Installed) +} + +// TestRockIndexAbsentByDefault pins that a plain listing does not carry +// the index — it is computed only when asked for. +func TestRockIndexAbsentByDefault(t *testing.T) { + t.Parallel() + + tr := treeFixture(t) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + assert.Nil(t, listing.Rocks) + assert.NotContains(t, render(t, listing, inventory.FormatJSON), `"rocks": [`) +} + +// TestRockIndexEmptyScope pins that a scope with nothing installed yields +// an empty index rather than a nil-versus-empty surprise in the renderer. +func TestRockIndexEmptyScope(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + listing := listTree(t, tr) + + assert.Empty(t, listing.Rocks) + assert.Empty(t, listing.Packages) +} + +// TestRockIndexPrefersOnDiskVersion pins that the index reports the +// version in the tree, not a lock's pin, when the two disagree. A later install +// can reconcile a shared rock to a version an earlier lock never named, and the +// tree is what an uninstall would remove. +func TestRockIndexPrefersOnDiskVersion(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0", pins: map[string]string{ + "metrics": "1.0.0", + }}) + + require.NoError(t, removeAll(tr.rockManifestPath("metrics", "1.0.0"))) + tr.installRock(t, "metrics", "1.4.0") + + listing := listTree(t, tr) + + require.Len(t, listing.Rocks, 1) + assert.Equal(t, "metrics", listing.Rocks[0].Name) + assert.Equal(t, "1.4.0", listing.Rocks[0].Version) +} + +// TestRockIndexFallsBackToPin pins the other side of that rule: a +// dependency a lock records but that has no files reports the pin, so it is not +// silently versionless. +func TestRockIndexFallsBackToPin(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "monitoring", version: "2.0.0", pins: map[string]string{ + "metrics": "1.0.0", + }}) + require.NoError(t, removeAll(tr.rockManifestPath("metrics", "1.0.0"))) + + listing := listTree(t, tr) + + require.Len(t, listing.Rocks, 1) + assert.Equal(t, "1.0.0", listing.Rocks[0].Version) +} + +// TestDirectIsSubsetOfClosure pins the depth split: a lock carries the whole +// transitive closure, and Direct names the part the manifest actually asked for. +func TestDirectIsSubsetOfClosure(t *testing.T) { + t.Parallel() + + listing := listTree(t, treeFixture(t)) + + byName := map[string]inventory.Entry{} + for _, entry := range listing.Packages { + byName[entry.Name] = entry + } + + monitoring := byName["monitoring"] + assert.Len(t, monitoring.Dependencies, 4, "the closure is what the lock pins") + assert.Equal(t, []string{"metrics", "shared-lib"}, monitoring.Direct, + "only the declared rocks are direct, in name order") + + assert.Equal(t, []string{"checks"}, byName["my-app"].Direct) + assert.Empty(t, byName["shared-lib"].Direct) +} + +// TestDirectIgnoresUndeclaredAndUnresolved pins that Direct stays a subset of +// what is installed. A manifest can declare a dependency the lock never pinned — +// unresolved, or dev-only — and that must not appear as an installed direct +// dependency. +func TestDirectIgnoresUndeclaredAndUnresolved(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + // Declares two, but only one made it into the lock. + deps: map[string]string{"metrics": ">=1.0.0", "never-resolved": ">=9.0.0"}, + pins: map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}, + }) + + listing := listTree(t, tr) + + require.Len(t, listing.Packages, 1) + assert.Equal(t, []string{"metrics"}, listing.Packages[0].Direct) + assert.NotContains(t, listing.Packages[0].Dependencies, "never-resolved") +} + +// TestDeclaredInComponentCountsAsDirect pins that a dependency declared by a +// component, not the global map, is direct too — the resolver treats both as +// declarations, so the view must agree. +func TestDeclaredInComponentCountsAsDirect(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuestManifest(t, "monitoring", "2.0.0", + manifestWithComponentDep("monitoring", "metrics", ">=1.0.0"), + map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}) + + listing := listTree(t, tr) + + require.Len(t, listing.Packages, 1) + assert.Equal(t, []string{"metrics"}, listing.Packages[0].Direct) +} + +// TestTransitiveOnlyDependencyIsRefcounted is the correctness case behind the +// whole depth question: ownership is counted over the closure, not over +// declarations. A rock nothing declares but two packages pulled in is shared and +// must survive one of them going. +func TestTransitiveOnlyDependencyIsRefcounted(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + // Neither package declares luasocket; both locks pin it transitively. + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + deps: map[string]string{"metrics": ">=1.0.0"}, + pins: map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}, + }) + tr.installGuest(t, pkg{ + name: "alerting", version: "0.5.0", + deps: map[string]string{"checks": ">=3.0.0"}, + pins: map[string]string{"checks": "3.1.0", "luasocket": "3.0.4"}, + }) + + listing := listTree(t, tr) + + byName := map[string]inventory.RockEntry{} + for _, dep := range listing.Rocks { + byName[dep.Name] = dep + } + + assert.Equal(t, []string{"alerting", "monitoring"}, byName["luasocket"].Owners) + assert.True(t, byName["luasocket"].Shared, + "a rock nobody declared is still owned by everyone whose lock pins it") + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"metrics"}, result.RemovedDependencies) + assert.True(t, tr.rockExists("luasocket"), + "a transitive rock another package still pulls in must survive") + + require.Len(t, result.KeptDependencies, 1) + assert.Equal(t, "luasocket", result.KeptDependencies[0].Name) + assert.Equal(t, []string{"alerting"}, result.KeptDependencies[0].HeldBy) +} + +// TestTransitiveOnlyDependencyIsRemovedWithItsLastOwner is the other half: the +// same rock goes when nothing else pulls it in, even though no manifest ever +// named it. +func TestTransitiveOnlyDependencyIsRemovedWithItsLastOwner(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + deps: map[string]string{"metrics": ">=1.0.0"}, + pins: map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}, + }) + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"luasocket", "metrics"}, result.RemovedDependencies) + assert.False(t, tr.rockExists("luasocket")) + assert.False(t, tr.rockExists("metrics")) +} + +// TestRenderTreeNestsUnderTheRequiringRock is the point of reading the tree +// manifest: a rock hangs off whatever pulled it in, not off a group. +// +// The lock alone cannot say this — it stores the closure flattened, with the +// parent thrown away — but LuaRocks records each installed rock's own resolved +// requirements in the tree manifest, so the edges are recoverable from the tree. +func TestRenderTreeNestsUnderTheRequiringRock(t *testing.T) { + t.Parallel() + + out := render(t, listTree(t, treeFixture(t)), inventory.FormatTable) + + metrics := subtreeOf(t, out, "metrics 1.0.0") + require.Len(t, metrics, 2, "metrics carries the rock it requires") + assert.Contains(t, treeLabel(metrics[1]), "checks 3.1.0") + + sharedLib := subtreeOf(t, out, "shared-lib 1.0.0 (installed package)") + require.Len(t, sharedLib, 2) + assert.Contains(t, treeLabel(sharedLib[1]), "luasocket 3.0.4") + + // Nothing is left over, so the unknown-parent group does not appear at all. + assert.NotContains(t, out, transitiveGroupLabel) +} + +// TestRenderTreeEdgesAreScopedToThePackage pins that the tree manifest is read +// as scope-wide data, not as one package's graph: a rock another package brought +// must not be grafted under this one just because an edge exists. +func TestRenderTreeEdgesAreScopedToThePackage(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + // alerting requires checks; monitoring requires only metrics. The edge + // metrics -> checks exists tree-wide, but checks is not in monitoring's + // closure, so it must not appear beneath it. + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + deps: map[string]string{"metrics": ">=1.0.0"}, + pins: map[string]string{"metrics": "1.0.0"}, + }) + tr.installGuest(t, pkg{ + name: "alerting", version: "0.5.0", + deps: map[string]string{"checks": ">=3.0.0"}, + pins: map[string]string{"checks": "3.1.0"}, + }) + tr.writeTreeManifest(t, + map[string][]string{"metrics": {"checks"}}, + map[string]string{"metrics": "1.0.0"}) + + out := render(t, listTree(t, tr), inventory.FormatTable) + + monitoring := subtreeOf(t, out, "monitoring 2.0.0") + assert.NotContains(t, strings.Join(monitoring, "\n"), "checks", + "a rock outside this package's closure is not grafted under it") +} + +// TestRenderTreeFallsBackWithoutTreeManifest pins the graceful path: a tree that +// carries no LuaRocks manifest has no edges to walk, so indirect rocks are still +// listed — under a group that says their parent is not recorded. +func TestRenderTreeFallsBackWithoutTreeManifest(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + deps: map[string]string{"metrics": ">=1.0.0"}, + pins: map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}, + }) + + out := render(t, listTree(t, tr), inventory.FormatTable) + + assert.Contains(t, out, transitiveGroupLabel) + assert.Contains(t, out, "luasocket 3.0.4 (only here)", + "an unreachable rock is still shown, not dropped") +} + +// TestRenderTreeSurvivesCyclicEdges pins that a malformed tree manifest cannot +// hang the view. LuaRocks closures are acyclic in principle, but this is data on +// disk and the walk must terminate regardless. +func TestRenderTreeSurvivesCyclicEdges(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + deps: map[string]string{"metrics": ">=1.0.0"}, + pins: map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}, + }) + // metrics -> luasocket -> metrics. + tr.writeTreeManifest(t, + map[string][]string{"metrics": {"luasocket"}, "luasocket": {"metrics"}}, + map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}) + + out := render(t, listTree(t, tr), inventory.FormatTable) + + assert.Contains(t, out, "metrics 1.0.0") + assert.Contains(t, out, "luasocket 3.0.4") +} + +// TestRenderTreeWithoutTransitiveRocks pins that a package whose closure is +// entirely declared shows no empty group. +func TestRenderTreeWithoutTransitiveRocks(t *testing.T) { + t.Parallel() + + out := render(t, listTree(t, treeFixture(t)), inventory.FormatTable) + + assert.NotContains(t, subtreeText(t, out, "alerting 0.5.0"), "pulled in transitively") + + // my-app's own closure is entirely declared too. Its subtree is the whole + // tree now that it is the root, so check its own rocks rather than its + // guests' — those are separate packages with closures of their own. + for _, line := range treeLines(out) { + if treeDepth(line) == 1 && !strings.Contains(line, "(guest)") { + assert.NotContains(t, line, transitiveGroupLabel) + } + } +} + +// transitiveGroupLabel is the heading the renderer uses for indirect rocks. +const transitiveGroupLabel = "pulled in transitively" + +// TestRenderTreeAllTransitive pins the opposite shape: a package whose manifest +// is unreadable declares nothing, so its whole closure is reported as transitive +// rather than silently promoted to direct. +func TestRenderTreeAllTransitive(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0"}, + }) + + out := render(t, listTree(t, tr), inventory.FormatTable) + + assert.Contains(t, out, "pulled in transitively") + assert.Contains(t, out, "metrics 1.0.0 (only here)") +} + +// TestRenderTreeHasSingleRoot pins the shape: one tree, rooted at the project's +// own package. Guests are installed into that project's tree, so they hang off +// it rather than standing as separate roots. +func TestRenderTreeHasSingleRoot(t *testing.T) { + t.Parallel() + + lines := treeLines(render(t, listTree(t, treeFixture(t)), inventory.FormatTable)) + + var roots []string + + for _, line := range lines { + if treeDepth(line) == 0 { + roots = append(roots, treeLabel(line)) + } + } + + require.Len(t, roots, 1, "the tree must have exactly one root:\n%s", + strings.Join(lines, "\n")) + assert.Equal(t, "my-app 1.2.3 (primary)", roots[0]) +} + +// TestRenderTreeGuestsHangOffPrimary pins that every guest is a child of the +// root, marked as a guest rather than presented as something the project +// requires — the project does not depend on them, it merely contains them. +func TestRenderTreeGuestsHangOffPrimary(t *testing.T) { + t.Parallel() + + lines := treeLines(render(t, listTree(t, treeFixture(t)), inventory.FormatTable)) + + var children []string + + for _, line := range lines { + if treeDepth(line) == 1 { + children = append(children, treeLabel(line)) + } + } + + // The project's own rock first, then the guests in listing order. + assert.Equal(t, []string{ + "checks 3.1.0 (shared with alerting, monitoring)", + "alerting 0.5.0 (guest)", + "monitoring 2.0.0 (guest)", + "shared-lib 1.0.0 (guest)", + }, children) +} + +// TestRenderTreeShape pins the whole rendering at once, prefixes included, so a +// change to the drawing has to be deliberate. +func TestRenderTreeShape(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{ + "my-app 1.2.3 (primary)", + "├── checks 3.1.0 (shared with alerting, monitoring)", + "├── alerting 0.5.0 (guest)", + "│ └── checks 3.1.0 (shared with monitoring, my-app)", + "├── monitoring 2.0.0 (guest)", + "│ ├── metrics 1.0.0 (only here)", + "│ │ └── checks 3.1.0 (shared with alerting, my-app)", + "│ └── shared-lib 1.0.0 (installed package)", + "│ └── luasocket 3.0.4 (only here)", + "└── shared-lib 1.0.0 (guest)", + " └── (no dependencies)", + }, treeLines(render(t, listTree(t, treeFixture(t)), inventory.FormatTable))) +} + +// TestRenderTreeWithoutPrimary pins the rootless case: a shared tree or a deploy +// directory has no project package, so the root says so and the guests still +// hang off one node instead of scattering. +func TestRenderTreeWithoutPrimary(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + deps: map[string]string{"metrics": ">=1.0.0"}, + pins: map[string]string{"metrics": "1.0.0"}, + }) + tr.installGuest(t, pkg{name: "alerting", version: "0.5.0"}) + + lines := treeLines(render(t, listTree(t, tr), inventory.FormatTable)) + + require.Equal(t, "(no project package)", lines[0]) + + var roots int + + for _, line := range lines { + if treeDepth(line) == 0 { + roots++ + } + } + + assert.Equal(t, 1, roots) + assert.Equal(t, "├── alerting 0.5.0 (guest)", lines[1]) + assert.Equal(t, "└── monitoring 2.0.0 (guest)", lines[3]) +} + +// TestRenderTreeContinuationBars pins that a nested level stays visually +// attached: while siblings follow, deeper lines carry the vertical bar; once the +// branch closes they are plain indentation. +func TestRenderTreeContinuationBars(t *testing.T) { + t.Parallel() + + lines := treeLines(render(t, listTree(t, treeFixture(t)), inventory.FormatTable)) + + // monitoring is not the last child, so its rocks keep the bar. + assert.Contains(t, lines, "│ ├── metrics 1.0.0 (only here)") + // shared-lib is the last child, so its rock is plainly indented. + assert.Contains(t, lines, " └── (no dependencies)") +} + +// TestRenderTreeAnnotatesEveryDependency is the view's whole point: each branch +// says whether the dependency would leave with its package or stay. +func TestRenderTreeAnnotatesEveryDependency(t *testing.T) { + t.Parallel() + + out := render(t, listTree(t, treeFixture(t)), inventory.FormatTable) + + assert.Contains(t, out, "my-app 1.2.3 (primary)") + + // Under monitoring: one shared, one exclusive, one an installed package. + monitoring := subtreeText(t, out, "monitoring 2.0.0") + assert.Contains(t, monitoring, "checks 3.1.0 (shared with alerting, my-app)") + assert.Contains(t, monitoring, "metrics 1.0.0 (only here)") + assert.Contains(t, monitoring, "shared-lib 1.0.0 (installed package)") + + // The owner is never listed as sharing with itself. + assert.NotContains(t, monitoring, "shared with monitoring") + + // A package that brought nothing says so rather than showing a bare header. + // The guest node is named explicitly: "shared-lib 1.0.0" also matches the + // dependency line under monitoring. + assert.Contains(t, subtreeText(t, out, "shared-lib 1.0.0 (guest)"), "(no dependencies)") +} + +// TestRenderTreeEmptyScope pins that an empty scope falls back to the plain +// sentence instead of printing a header with nothing under it. +func TestRenderTreeEmptyScope(t *testing.T) { + t.Parallel() + + out := render(t, listTree(t, newTree(t)), inventory.FormatTable) + + assert.Contains(t, out, "no packages installed") + assert.NotContains(t, out, "├") + assert.NotContains(t, out, "└") +} + +// TestTreeInMachineFormat pins that --tree is not a table-only view: the index +// travels in the machine output too, which is what makes it scriptable. +func TestTreeInMachineFormat(t *testing.T) { + t.Parallel() + + rendered := render(t, listTree(t, treeFixture(t)), inventory.FormatJSON) + + var decoded struct { + Rocks []struct { + Name string `json:"name"` + Version string `json:"version"` + Owners []string `json:"owners"` + Shared bool `json:"shared"` + Installed bool `json:"installed"` + } `json:"rocks"` + } + + require.NoError(t, json.Unmarshal([]byte(rendered), &decoded)) + require.Len(t, decoded.Rocks, 4) + + byName := map[string]bool{} + for _, dep := range decoded.Rocks { + byName[dep.Name] = dep.Shared + } + + assert.Equal(t, "checks", decoded.Rocks[0].Name) + assert.True(t, decoded.Rocks[0].Shared) + assert.Equal(t, []string{"alerting", "monitoring", "my-app"}, decoded.Rocks[0].Owners) + assert.False(t, byName["luasocket"], "a transitive rock with one owner is not shared") + assert.Equal(t, "shared-lib", decoded.Rocks[3].Name) + assert.True(t, decoded.Rocks[3].Installed) +} + +// TestTreeMatchesUninstallOutcome is the guarantee the view is worth having: what +// the tree says would happen is what uninstall actually does. +func TestTreeMatchesUninstallOutcome(t *testing.T) { + t.Parallel() + + tr := treeFixture(t) + + before := subtreeText(t, render(t, listTree(t, tr), inventory.FormatTable), "monitoring 2.0.0") + require.Contains(t, before, "metrics 1.0.0 (only here)") + require.Contains(t, before, "luasocket 3.0.4 (only here)") + require.Contains(t, before, "checks 3.1.0 (shared with alerting, my-app)") + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + // Both "only here" rocks were removed, the declared one and the transitive + // one alike; "shared with" and the installed package stayed. + assert.Equal(t, []string{"luasocket", "metrics"}, result.RemovedDependencies) + assert.False(t, tr.rockExists("metrics")) + assert.False(t, tr.rockExists("luasocket")) + assert.True(t, tr.rockExists("checks")) + assert.True(t, tr.rockExists("shared-lib")) +} + +// TestTreeScopeIsReported pins that the tree header names the tree it describes, +// the same fact the machine output carries. +func TestTreeScopeIsReported(t *testing.T) { + t.Parallel() + + tr := treeFixture(t) + listing := listTree(t, tr) + + out := render(t, listing, inventory.FormatTable) + + assert.Contains(t, out, string(state.ScopeProject)) + assert.Contains(t, out, tr.dir) +} + +// treeLines returns the rendered tree without the scope header, so line indices +// are indices into the tree itself. +func treeLines(out string) []string { + _, tree, _ := strings.Cut(out, "\n\n") + + return strings.Split(strings.TrimRight(tree, "\n"), "\n") +} + +// treeDepth reports how deep a rendered line sits: 0 for the root, 1 for its +// children, and so on. The prefix is built from fixed four-rune groups, so the +// depth is just how many of them precede the label. +func treeDepth(line string) int { + runes := []rune(line) + + for n := 0; n+4 <= len(runes); n += 4 { + switch string(runes[n : n+4]) { + case "│ ", " ": + continue + case "├── ", "└── ": + return n/4 + 1 + default: + return 0 + } + } + + return 0 +} + +// treeLabel strips a line's prefix, leaving the node's own text. +func treeLabel(line string) string { + runes := []rune(line) + + for n := 0; n+4 <= len(runes); n += 4 { + switch string(runes[n : n+4]) { + case "│ ", " ": + continue + case "├── ", "└── ": + return string(runes[n+4:]) + default: + return line + } + } + + return line +} + +// subtreeOf returns the node whose label starts with prefix, together with every +// line nested under it. +func subtreeOf(t *testing.T, out, prefix string) []string { + t.Helper() + + lines := treeLines(out) + + for i, line := range lines { + if !strings.HasPrefix(treeLabel(line), prefix) { + continue + } + + depth := treeDepth(line) + subtree := []string{line} + + for _, next := range lines[i+1:] { + if treeDepth(next) <= depth { + break + } + + subtree = append(subtree, next) + } + + return subtree + } + + t.Fatalf("no %q node in:\n%s", prefix, out) + + return nil +} + +// subtreeText joins a subtree for substring assertions. +func subtreeText(t *testing.T, out, prefix string) string { + t.Helper() + + return strings.Join(subtreeOf(t, out, prefix), "\n") +} diff --git a/cli/manifest/inventory/uninstall.go b/cli/manifest/inventory/uninstall.go new file mode 100644 index 000000000..98c9fe155 --- /dev/null +++ b/cli/manifest/inventory/uninstall.go @@ -0,0 +1,330 @@ +package inventory + +import ( + "fmt" + "os" + "sort" + "strings" + + "github.com/tarantool/tt/cli/manifest" + "github.com/tarantool/tt/cli/manifest/state" +) + +// UninstallOptions configures one uninstall run. +type UninstallOptions struct { + // ProjectDir is the install-root for project scope (the caller's cwd). It + // must be absolute; user and system scopes derive their roots from the + // environment and ignore it. + ProjectDir string + // Scope selects which tree to remove from. The zero value ("") is project. + Scope state.Scope + // Package is the name of the guest package to remove. + Package string + // Yes skips the confirmation prompt. + Yes bool + // Confirm is asked before anything is removed; returning false aborts with + // nothing touched. Nil proceeds (the CLI wires a real prompt; Yes bypasses it + // entirely). + Confirm func(prompt string) bool + // Warn receives non-fatal diagnostics; nil drops them. + Warn func(string) +} + +func (o UninstallOptions) warn(msg string) { + if o.Warn != nil { + o.Warn(msg) + } +} + +// KeptDependency is a dependency the removed package brought that survives, +// because another installed package holds it too. +type KeptDependency struct { + // Name is the rock name. + Name string `json:"name" yaml:"name"` + // Version is the version left in the tree. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // HeldBy are the packages whose lock still pins it, in name order. It can be + // empty when Installed is set: nothing references the rock, but it stands on + // its own metadata. + HeldBy []string `json:"held_by" yaml:"held_by"` + // Installed marks a dependency that is also an installed package in its own + // right. It is kept for a different reason than a shared rock — not because + // something references it, but because only an uninstall naming it may + // remove it — so the two are reported apart rather than conflated. + Installed bool `json:"installed,omitempty" yaml:"installed,omitempty"` +} + +// UninstallResult reports what an uninstall removed and what it deliberately +// left behind. +type UninstallResult struct { + // Package is the removed package's name. + Package string `json:"package" yaml:"package"` + // Version is the version that was installed. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // Scope is the tree it was removed from. + Scope state.Scope `json:"scope" yaml:"scope"` + // RemovedDependencies are the shared dependencies dropped along with it, + // because nothing else owned them. In name order. + //nolint:lll // A struct tag is one string literal and cannot be wrapped. + RemovedDependencies []string `json:"removed_dependencies,omitempty" yaml:"removed_dependencies,omitempty"` + // KeptDependencies are the ones another package still holds. In name order. + //nolint:lll // A struct tag is one string literal and cannot be wrapped. + KeptDependencies []KeptDependency `json:"kept_dependencies,omitempty" yaml:"kept_dependencies,omitempty"` +} + +// Uninstall removes a guest package from a scope: its own files, its metadata, +// and every dependency it brought that nothing else still owns. +// +// Ownership is derived rather than counted: a dependency belongs to every +// installed package whose lock pins it, so after the target is set aside, a +// dependency with no remaining owner is dead and one with an owner stays. That +// is why removing the target's manifests// directory is the only bookkeeping +// step — there is no separate counter to drift out of sync. +// +// Removal order matters for a re-run: files first, metadata last. An uninstall +// interrupted halfway leaves the metadata in place, so running it again finishes +// the job; the reverse would orphan files that nothing remembers owning. +func Uninstall(opts UninstallOptions) (*UninstallResult, error) { + scope, lay, err := resolve(opts.Scope, opts.ProjectDir) + if err != nil { + return nil, err + } + + // The name becomes a path, so its shape is checked before anything is + // removed: a traversal-shaped or reserved argument must never reach RemoveAll. + if !manifest.ValidPackageName(opts.Package) { + return nil, stateErrorf("%w: %q", ErrBadPackageName, opts.Package) + } + + packages, err := state.Packages(lay, scope) + if err != nil { + return nil, stateErrorf("%w", err) + } + + target, ok := state.Find(packages, opts.Package) + if !ok { + return nil, stateErrorf("%w: %q in %s scope", ErrNotInstalled, opts.Package, scope) + } + + if target.Primary { + return nil, stateErrorf("%w: %q is declared by %s in %s", + ErrPrimaryPackage, target.Name, state.PrimaryManifestFile, lay.Root) + } + + plan := planUninstall(lay, packages, target) + + // Removing a package another one locks is the user's call — the argument was + // explicit — but it leaves that package's dependency unsatisfied, so say so + // rather than letting it be discovered at runtime. + if dependents := plan.dependents; len(dependents) > 0 { + opts.warn(fmt.Sprintf("%s is still required by %s", + target.Name, strings.Join(dependents, ", "))) + } + + err = confirm(opts, plan) + if err != nil { + return nil, err + } + + err = apply(opts, lay, plan) + if err != nil { + return nil, err + } + + return plan.result(scope), nil +} + +// removal is one directory set to delete: a package's or a dependency's files +// at a concrete version. +type removal struct { + name string + version string +} + +// uninstallPlan is everything one uninstall will do, decided before anything is +// written so the confirmation prompt describes the real outcome. +type uninstallPlan struct { + // target is the package being removed, with the version found on disk. + target removal + // dead are the dependencies losing their last owner. + dead []removal + // kept are the dependencies another package still holds. + kept []KeptDependency + // dependents are the surviving packages whose lock pins the target itself — + // they lose a dependency by this removal. + dependents []string +} + +// planUninstall decides what the uninstall removes. It reads only; the decision +// is fully made before apply touches the tree. +func planUninstall( + lay state.Layout, packages []state.Package, target state.Package, +) uninstallPlan { + survivors := make([]state.Package, 0, len(packages)) + + for _, pkg := range packages { + if pkg.Name != target.Name { + survivors = append(survivors, pkg) + } + } + + plan := uninstallPlan{ + target: removal{ + name: target.Name, + version: onDiskVersion(lay, target.Name, target.Version), + }, + dead: nil, + kept: nil, + // The target's own name is looked up the same way a dependency is: any + // survivor pinning it loses a dependency when it goes. + dependents: pinnedBy(survivors, target.Name), + } + + brought := state.LockedDependencies(target.Lock) + + for _, dep := range sortedKeys(brought) { + holders := pinnedBy(survivors, dep) + _, installed := state.Find(survivors, dep) + + // A rock survives if anything still pins it, or if it is an installed + // package in its own right — those are different reasons, and the result + // reports which one applied. + if len(holders) > 0 || installed { + plan.kept = append(plan.kept, KeptDependency{ + Name: dep, + Version: onDiskVersion(lay, dep, brought[dep]), + HeldBy: holders, + Installed: installed, + }) + + continue + } + + plan.dead = append(plan.dead, removal{ + name: dep, version: onDiskVersion(lay, dep, brought[dep]), + }) + } + + return plan +} + +// pinnedBy lists the packages whose lock pins dep, in name order. +func pinnedBy(survivors []state.Package, dep string) []string { + var owners []string + + for _, pkg := range survivors { + if _, pinned := state.LockedVersion(pkg.Lock, dep); pinned { + owners = append(owners, pkg.Name) + } + } + + sort.Strings(owners) + + return owners +} + +// onDiskVersion prefers the version actually present in the rocks tree over the +// one the lock recorded. They normally agree; when they do not — a dependency +// reconciled to another version by a later install — the tree is what has to be +// removed. +func onDiskVersion(lay state.Layout, name, recorded string) string { + if installed, ok := state.InstalledVersion(lay, name); ok { + return installed + } + + return recorded +} + +// confirm asks before anything is removed, describing the dependencies that go +// with the package. --yes and a nil Confirm proceed. +func confirm(opts UninstallOptions, plan uninstallPlan) error { + if opts.Yes || opts.Confirm == nil { + return nil + } + + prompt := "remove " + plan.target.name + + if len(plan.dead) > 0 { + names := make([]string, 0, len(plan.dead)) + for _, dep := range plan.dead { + names = append(names, dep.name) + } + + prompt += " and its now-unused dependencies " + strings.Join(names, ", ") + } + + if !opts.Confirm(prompt + "?") { + return stateErrorf("%w", ErrAborted) + } + + return nil +} + +// apply realizes the plan: the package's files, then the dead dependencies' +// files, then the metadata. Metadata goes last so an interrupted run can be +// repeated — the package still reads as installed until its files are gone. +func apply(opts UninstallOptions, lay state.Layout, plan uninstallPlan) error { + err := removeFiles(lay, plan.target) + if err != nil { + return err + } + + for _, dep := range plan.dead { + opts.warn(fmt.Sprintf("removing unused dependency %s %s", dep.name, dep.version)) + + err = removeFiles(lay, dep) + if err != nil { + return err + } + } + + err = state.RemoveMetadata(lay, plan.target.name) + if err != nil { + return stateErrorf("%w", err) + } + + return nil +} + +// removeFiles deletes one name's module trees and its rock-manifest directory. +// The rock root is pruned too, so no empty husk under rocks// is left to +// make the rock look installed to the next plan. +func removeFiles(lay state.Layout, target removal) error { + for _, path := range state.RockPaths(lay, target.name, target.version) { + err := os.RemoveAll(path) + if err != nil { + return stateErrorf("removing %s %s: %w", target.name, target.version, err) + } + } + + // Prune the now-empty rock root so nothing under rocks// makes the rock + // look installed to the next plan. Remove() rather than RemoveAll(): a root + // still holding another version directory must survive, and the ENOTEMPTY + // that produces is the signal to leave it alone. Either way the failure is + // cosmetic — an empty directory, not a broken tree — so it is not returned. + _ = os.Remove(state.RockRoot(lay, target.name)) + + return nil +} + +// result projects the plan onto the reported outcome. +func (p uninstallPlan) result(scope state.Scope) *UninstallResult { + removed := make([]string, 0, len(p.dead)) + for _, dep := range p.dead { + removed = append(removed, dep.name) + } + + result := &UninstallResult{ + Package: p.target.name, + Version: p.target.version, + Scope: scope, + RemovedDependencies: removed, + KeptDependencies: p.kept, + } + + if len(removed) == 0 { + result.RemovedDependencies = nil + } + + return result +} diff --git a/cli/manifest/inventory/uninstall_test.go b/cli/manifest/inventory/uninstall_test.go new file mode 100644 index 000000000..86c9e1463 --- /dev/null +++ b/cli/manifest/inventory/uninstall_test.go @@ -0,0 +1,458 @@ +package inventory_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/tt/cli/manifest/inventory" + "github.com/tarantool/tt/cli/manifest/state" +) + +// sharedTree lays out the task's uninstall scenario: two guests, one dependency +// only the first holds, and one both hold. +// +// monitoring -> metrics (only owner), checks (shared) +// alerting -> checks (shared) +func sharedTree(t *testing.T) tree { + t.Helper() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0", "checks": "3.1.0"}, + }) + tr.installGuest(t, pkg{ + name: "alerting", version: "0.5.0", + pins: map[string]string{"checks": "3.1.0"}, + }) + + return tr +} + +// TestUninstallGuestDropsSoleDependencyKeepsShared is the task's headline case: +// the package's files and metadata go, the dependency only it held goes with +// them, and the one another package still holds stays. +func TestUninstallGuestDropsSoleDependencyKeepsShared(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + // The package itself is gone, files and metadata both. + assert.False(t, tr.rockExists("monitoring"), "package files must be removed") + assert.False(t, tr.metadataExists("monitoring"), "package metadata must be removed") + + // The dependency only monitoring held is gone. + assert.False(t, tr.rockExists("metrics"), "sole-owner dependency must be removed") + assert.Equal(t, []string{"metrics"}, result.RemovedDependencies) + + // The dependency alerting still holds stays, and the report says who holds it. + assert.True(t, tr.rockExists("checks"), "shared dependency must survive") + require.Len(t, result.KeptDependencies, 1) + assert.Equal(t, "checks", result.KeptDependencies[0].Name) + assert.Equal(t, []string{"alerting"}, result.KeptDependencies[0].HeldBy) + + // The other package is untouched. + assert.True(t, tr.rockExists("alerting")) + assert.True(t, tr.metadataExists("alerting")) + + assert.Equal(t, "monitoring", result.Package) + assert.Equal(t, "2.0.0", result.Version) + assert.Equal(t, state.ScopeProject, result.Scope) +} + +// TestUninstallLeavesListingConsistent pins the two commands against each other: +// after an uninstall, list no longer reports the package. +func TestUninstallLeavesListingConsistent(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + listing, err := inventory.List(inventory.ListOptions{ProjectDir: tr.dir}) + require.NoError(t, err) + + require.Len(t, listing.Packages, 1) + assert.Equal(t, "alerting", listing.Packages[0].Name) +} + +// TestUninstallKeepsDependencyHeldByPrimary pins that the project's own package +// counts as an owner: a dependency it locks survives a guest's removal even +// though no other guest holds it. +func TestUninstallKeepsDependencyHeldByPrimary(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installPrimary(t, pkg{ + name: "my-app", version: "1.0.0", + pins: map[string]string{"metrics": "1.0.0"}, + }) + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0"}, + }) + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + assert.True(t, tr.rockExists("metrics"), "a dependency the project holds must survive") + assert.Empty(t, result.RemovedDependencies) + require.Len(t, result.KeptDependencies, 1) + assert.Equal(t, []string{"my-app"}, result.KeptDependencies[0].HeldBy) +} + +// TestUninstallRefusesPrimaryPackage pins the task's refusal: the package the +// project develops is not a guest, and its place in the tree is the project's to +// decide. +func TestUninstallRefusesPrimaryPackage(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installPrimary(t, pkg{name: "my-app", version: "1.0.0"}) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "my-app", Yes: true, + }) + require.Error(t, err) + require.ErrorIs(t, err, inventory.ErrPrimaryPackage) + assert.Equal(t, 1, inventory.ExitCode(err)) + + // Nothing was touched. + assert.True(t, tr.rockExists("my-app")) +} + +// TestUninstallRefusesPrimaryEvenWithSameNameGuestAbsent guards the lookup +// order: the primary package is found by name first, so its name cannot be +// smuggled through as if it were a guest. +func TestUninstallRefusesPrimaryEvenWithSameNameGuestAbsent(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installPrimary(t, pkg{ + name: "my-app", version: "1.0.0", + pins: map[string]string{"metrics": "1.0.0"}, + }) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "my-app", Yes: true, + }) + require.ErrorIs(t, err, inventory.ErrPrimaryPackage) + + assert.True(t, tr.rockExists("metrics"), "a refusal must remove nothing") +} + +// TestUninstallNotInstalled pins the error for a package the scope does not +// hold. +func TestUninstallNotInstalled(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "absent", Yes: true, + }) + require.Error(t, err) + require.ErrorIs(t, err, inventory.ErrNotInstalled) + assert.Equal(t, 1, inventory.ExitCode(err)) + + // A miss must not disturb what is installed. + assert.True(t, tr.rockExists("monitoring")) + assert.True(t, tr.rockExists("metrics")) +} + +// TestUninstallRejectsBadPackageName pins the guard that runs before any +// removal. The name becomes a path, so a traversal-shaped argument must be +// refused rather than resolved. +func TestUninstallRejectsBadPackageName(t *testing.T) { + t.Parallel() + + for _, name := range []string{ + "../../etc", + "..", + ".", + "/absolute", + "has/slash", + "Upper", + "", + // Reserved: manifests/ is the metadata directory itself, bin/ the tree's + // executables. Neither is a package that can be removed. + "manifests", + "bin", + } { + tr := sharedTree(t) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: name, Yes: true, + }) + require.Error(t, err, name) + require.ErrorIs(t, err, inventory.ErrBadPackageName, name) + assert.Equal(t, 1, inventory.ExitCode(err), name) + + // The tree is intact. + assert.True(t, tr.metadataExists("monitoring"), name) + assert.True(t, tr.rockExists("metrics"), name) + } +} + +// TestUninstallConfirmationDeclinedRemovesNothing pins that declining the prompt +// is a full abort, not a partial removal. +func TestUninstallConfirmationDeclinedRemovesNothing(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", + Confirm: func(string) bool { return false }, + }) + require.Error(t, err) + require.ErrorIs(t, err, inventory.ErrAborted) + + assert.True(t, tr.rockExists("monitoring")) + assert.True(t, tr.metadataExists("monitoring")) + assert.True(t, tr.rockExists("metrics")) +} + +// TestUninstallConfirmationAccepted covers the other branch, and that the prompt +// names the dependencies that go along — the whole point of asking. +func TestUninstallConfirmationAccepted(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + var prompt string + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", + Confirm: func(p string) bool { prompt = p; return true }, + }) + require.NoError(t, err) + + assert.Contains(t, prompt, "monitoring") + assert.Contains(t, prompt, "metrics") + assert.NotContains(t, prompt, "checks", "a surviving dependency must not be announced") + + assert.False(t, tr.metadataExists("monitoring")) +} + +// TestUninstallYesSkipsConfirmation pins that --yes never asks. +func TestUninstallYesSkipsConfirmation(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + asked := false + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + Confirm: func(string) bool { asked = true; return false }, + }) + require.NoError(t, err) + + assert.False(t, asked, "--yes must not prompt") + assert.False(t, tr.metadataExists("monitoring")) +} + +// TestUninstallNilConfirmProceeds pins the library default: a caller that wires +// no prompt is not blocked by one. +func TestUninstallNilConfirmProceeds(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", + }) + require.NoError(t, err) + + assert.False(t, tr.metadataExists("monitoring")) +} + +// TestUninstallGuestWithoutLock covers a guest whose metadata predates locks: it +// owns nothing, so only its own files and metadata go. +func TestUninstallGuestWithoutLock(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{name: "legacy", version: "1.0.0", noLock: true}) + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0"}, + }) + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "legacy", Yes: true, + }) + require.NoError(t, err) + + assert.Empty(t, result.RemovedDependencies) + assert.False(t, tr.rockExists("legacy")) + assert.True(t, tr.rockExists("metrics"), "another package's dependency is untouched") +} + +// TestUninstallDependencyThatIsAlsoAPackage pins the guard that matters most for +// not eating a user's install: a guest that another package happens to depend on +// is removed only when it is named, never as a mere shared rock. +func TestUninstallDependencyThatIsAlsoAPackage(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + // monitoring depends on the "shared-lib" rock, which is also installed as a + // package in its own right. + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + pins: map[string]string{"shared-lib": "1.0.0"}, + }) + tr.installGuest(t, pkg{name: "shared-lib", version: "1.0.0"}) + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + assert.True(t, tr.rockExists("shared-lib"), "an installed package is not a disposable rock") + assert.True(t, tr.metadataExists("shared-lib")) + assert.Empty(t, result.RemovedDependencies) + require.Len(t, result.KeptDependencies, 1) + + // It is kept for being a package, not for being referenced: nothing pins it + // once monitoring is gone, and the two reasons are reported apart. + assert.Equal(t, "shared-lib", result.KeptDependencies[0].Name) + assert.True(t, result.KeptDependencies[0].Installed) + assert.Empty(t, result.KeptDependencies[0].HeldBy) +} + +// TestUninstallWarnsWhenOthersStillDependOnIt pins that removing a package +// another one locks is allowed — the argument was explicit — but reported, so +// the breakage is not discovered at runtime. +func TestUninstallWarnsWhenOthersStillDependOnIt(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + pins: map[string]string{"shared-lib": "1.0.0"}, + }) + tr.installGuest(t, pkg{name: "shared-lib", version: "1.0.0"}) + + var warnings []string + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "shared-lib", Yes: true, + Warn: func(msg string) { warnings = append(warnings, msg) }, + }) + require.NoError(t, err) + + assert.False(t, tr.rockExists("shared-lib"), "an explicit removal still goes through") + require.NotEmpty(t, warnings) + assert.Contains(t, warnings[0], "monitoring") +} + +// TestUninstallIsRepeatable pins the ordering rule: files go before metadata, so +// an uninstall that is run twice — or resumed after an interruption — converges +// instead of failing. +func TestUninstallIsRepeatable(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + opts := inventory.UninstallOptions{ProjectDir: tr.dir, Package: "monitoring", Yes: true} + + _, err := inventory.Uninstall(opts) + require.NoError(t, err) + + // The second run finds nothing left to remove, which is a clean "not + // installed" rather than a crash. + _, err = inventory.Uninstall(opts) + require.ErrorIs(t, err, inventory.ErrNotInstalled) + + assert.True(t, tr.rockExists("checks"), "the shared dependency is still not collateral") +} + +// TestUninstallPrunesEmptyRockRoot pins that no husk is left under rocks// +// once the last version is gone — a leftover empty directory would make the rock +// read as installed to the next plan. +func TestUninstallPrunesEmptyRockRoot(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + _, found := state.InstalledVersion(tr.lay, "metrics") + assert.False(t, found, "the removed dependency must not read as installed") + + _, found = state.InstalledVersion(tr.lay, "monitoring") + assert.False(t, found, "the removed package must not read as installed") + + version, found := state.InstalledVersion(tr.lay, "checks") + require.True(t, found, "the surviving dependency must still read as installed") + assert.Equal(t, "3.1.0", version) +} + +// TestUninstallRemovesVersionFoundOnDisk pins that the tree wins over the lock +// when they disagree. A later install can reconcile a dependency to another +// version without rewriting the earlier package's lock, and it is the files that +// actually have to go. +func TestUninstallRemovesVersionFoundOnDisk(t *testing.T) { + t.Parallel() + + tr := newTree(t) + + tr.installGuest(t, pkg{ + name: "monitoring", version: "2.0.0", + pins: map[string]string{"metrics": "1.0.0"}, + }) + + // Simulate a reconciliation: the tree now carries 1.4.0 while the lock still + // records 1.0.0. + require.NoError(t, removeAll(tr.rockManifestPath("metrics", "1.0.0"))) + tr.installRock(t, "metrics", "1.4.0") + + result, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Package: "monitoring", Yes: true, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"metrics"}, result.RemovedDependencies) + assert.False(t, tr.rockExists("metrics"), "the on-disk version must be the one removed") +} + +// TestUninstallRejectsUnknownScope pins that a bad --scope fails before any +// lookup. +func TestUninstallRejectsUnknownScope(t *testing.T) { + t.Parallel() + + tr := sharedTree(t) + + _, err := inventory.Uninstall(inventory.UninstallOptions{ + ProjectDir: tr.dir, Scope: state.Scope("global"), Package: "monitoring", Yes: true, + }) + require.Error(t, err) + require.ErrorIs(t, err, state.ErrUnknownScope) + assert.Equal(t, 1, inventory.ExitCode(err)) + + assert.True(t, tr.metadataExists("monitoring")) +} diff --git a/cli/manifest/state/packages.go b/cli/manifest/state/packages.go index 75dfc99b8..55507af6c 100644 --- a/cli/manifest/state/packages.go +++ b/cli/manifest/state/packages.go @@ -224,9 +224,44 @@ func LockedVersion(lock *manifest.Lock, dep string) (string, bool) { return "", false } +// DeclaredDependencies returns the names a manifest declares for itself, across +// the global dependency map and every component's. +// +// A lock records the whole transitive closure as one flat, name-sorted list with +// no edges, so it cannot say which rocks the package actually asked for — only +// the manifest can. The edges between rocks come from elsewhere, the LuaRocks +// tree manifest; see DependencyEdges. +// +// Dev dependencies are excluded: they are not part of a product's runtime +// closure and nothing installs them. +func DeclaredDependencies(man *manifest.Manifest) map[string]struct{} { + declared := map[string]struct{}{} + + if man == nil { + return declared + } + + collect := func(deps map[string]manifest.Dependency) { + for name := range deps { + declared[name] = struct{}{} + } + } + + collect(man.Dependencies) + + for _, comp := range man.Components { + collect(comp.Dependencies) + } + + return declared +} + // LockedDependencies lists every dependency a lock pins, deduplicated by name -// across products and in no particular order. It is the set of rocks the -// package brought into the tree, which is what ownership is counted over. +// across products and in no particular order. +// +// This is the whole transitive closure, not just what the manifest declared, so +// it is the correct set to count ownership over: a package owns every rock it +// caused to be installed, at whatever depth. func LockedDependencies(lock *manifest.Lock) map[string]string { pinned := map[string]string{} diff --git a/cli/manifest/state/packages_test.go b/cli/manifest/state/packages_test.go index 9455da5fb..12ae83847 100644 --- a/cli/manifest/state/packages_test.go +++ b/cli/manifest/state/packages_test.go @@ -288,3 +288,52 @@ func mustRead(t *testing.T, path string) []byte { return data } + +// TestDeclaredDependencies covers the only depth information the install state +// carries: which of a lock's rocks the manifest actually asked for. +func TestDeclaredDependencies(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeGuest(t, lay, "monitoring", "2.0.0", + map[string]string{"metrics": ">=1.0.0"}, + map[string]string{"metrics": "1.0.0", "luasocket": "3.0.4"}) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + + declared := state.DeclaredDependencies(packages[0].Manifest) + + assert.Contains(t, declared, "metrics") + assert.NotContains(t, declared, "luasocket", + "a rock the lock pins but the manifest never named is not declared") + + // A nil manifest is the unreadable-metadata case and must not panic. + assert.Empty(t, state.DeclaredDependencies(nil)) +} + +// TestDeclaredDependenciesIncludesComponents pins that a component's own +// dependency counts as declared: the resolver treats global and component +// declarations alike, so the state reader has to as well. +func TestDeclaredDependenciesIncludesComponents(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + source := "manifest_version = '0.1'\n\n[package]\nname = 'monitoring'\n\n" + + "[platform]\ntarantool = '>=3.0.0'\ntt = '>=3.1.0'\n\n" + + "[products.default]\ncomponents = ['lua']\ndefault = true\n\n" + + "[components.lua]\npath = '.'\n\n" + + "[components.lua.dependencies]\nmetrics = '>=1.0.0'\n" + + writeFile(t, filepath.Join(lay.Manifests, "monitoring", state.MetaManifestFile), + []byte(source)) + + packages, err := state.Packages(lay, state.ScopeProject) + require.NoError(t, err) + require.Len(t, packages, 1) + + assert.Contains(t, state.DeclaredDependencies(packages[0].Manifest), "metrics") +} diff --git a/cli/manifest/state/rocks.go b/cli/manifest/state/rocks.go index 8d528bfa3..526dbcfef 100644 --- a/cli/manifest/state/rocks.go +++ b/cli/manifest/state/rocks.go @@ -3,14 +3,65 @@ package state import ( "os" "path/filepath" + "sort" "github.com/tarantool/go-luarocks/deps" + "github.com/tarantool/go-luarocks/manif" ) // rocksManifestDir is the tree subdirectory LuaRocks keeps per-rock manifests // in: /rocks///. const rocksManifestDir = "rocks" +// DependencyEdges reads which rock requires which, from the tree manifest +// LuaRocks maintains at /rocks/manifest. Its `dependencies` table records +// every installed rock's own resolved requirements, so the edges a lock throws +// away — it stores the closure flattened, with no parent — are recoverable from +// the tree itself. +// +// The result maps a rock to the rocks it pulls in, deduplicated across versions +// and in name order. A tree with no manifest, or one that cannot be parsed, +// yields nil rather than an error: the edges are an enrichment, and a tree +// assembled by an offline extraction may not carry them. Callers fall back to +// reporting indirect rocks without a parent. +func DependencyEdges(lay Layout) map[string][]string { + tree, err := manif.ReadTreeManifest(filepath.Join(lay.Share, rocksManifestDir)) + if err != nil { + return nil + } + + edges := make(map[string][]string, len(tree.Dependencies)) + + for rock, versions := range tree.Dependencies { + required := map[string]struct{}{} + + for _, deps := range versions { + for _, dep := range deps { + // A rock never counts as its own requirement; a self-edge would make + // the graph walk cycle on the first step. + if dep.Name != rock { + required[dep.Name] = struct{}{} + } + } + } + + if len(required) == 0 { + continue + } + + names := make([]string, 0, len(required)) + for name := range required { + names = append(names, name) + } + + sort.Strings(names) + + edges[rock] = names + } + + return edges +} + // InstalledVersion reads the version of a rock currently installed in the tree // from /rocks///. It returns the first version directory // found; a well-formed tree holds exactly one per rock. diff --git a/cli/manifest/state/rocks_test.go b/cli/manifest/state/rocks_test.go index 2a71ba96a..78f18d6f2 100644 --- a/cli/manifest/state/rocks_test.go +++ b/cli/manifest/state/rocks_test.go @@ -86,3 +86,107 @@ func TestSameVersion(t *testing.T) { assert.False(t, state.SameVersion("1.0.0", "nonsense")) assert.True(t, state.SameVersion("nonsense", "nonsense")) } + +// writeTreeManifestFixture lays a LuaRocks tree manifest by hand, in the bare +// assignment format LuaRocks actually writes — not a "return {...}" table, and +// with every key bracket-quoted so a hyphenated rock name stays valid Lua. +func writeTreeManifestFixture(t *testing.T, lay state.Layout, body string) { + t.Helper() + + writeFile(t, filepath.Join(lay.Share, "rocks", "manifest"), []byte(body)) +} + +// TestDependencyEdges reads the rock-to-rock edges out of the tree manifest. +// These are what the lock throws away: it stores the closure flattened, so the +// tree is the only place the parent of an indirect rock survives. +func TestDependencyEdges(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeTreeManifestFixture(t, lay, `commands = {} +modules = {} +repository = {} +dependencies = { + ["metrics"] = { + ["1.0.0"] = { + { name = "checks", constraints = {} }, + { name = "luasocket", constraints = {} }, + } + }, + ["shared-lib"] = { + ["1.0.0"] = { + { name = "luasocket", constraints = {} }, + } + }, +} +`) + + edges := state.DependencyEdges(lay) + + assert.Equal(t, []string{"checks", "luasocket"}, edges["metrics"], + "a rock's requirements are deduplicated and in name order") + assert.Equal(t, []string{"luasocket"}, edges["shared-lib"]) + assert.NotContains(t, edges, "checks", "a rock that requires nothing has no entry") +} + +// TestDependencyEdgesMergesVersions pins that edges are unioned across the +// versions of a rock the tree happens to record. +func TestDependencyEdgesMergesVersions(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeTreeManifestFixture(t, lay, `dependencies = { + ["metrics"] = { + ["1.0.0"] = { + { name = "checks", constraints = {} }, + }, + ["2.0.0"] = { + { name = "luasocket", constraints = {} }, + } + }, +} +`) + + assert.Equal(t, []string{"checks", "luasocket"}, state.DependencyEdges(lay)["metrics"]) +} + +// TestDependencyEdgesDropsSelfReference pins the guard that keeps a graph walk +// from cycling on its first step. +func TestDependencyEdgesDropsSelfReference(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + writeTreeManifestFixture(t, lay, `dependencies = { + ["metrics"] = { + ["1.0.0"] = { + { name = "metrics", constraints = {} }, + { name = "checks", constraints = {} }, + } + }, +} +`) + + assert.Equal(t, []string{"checks"}, state.DependencyEdges(lay)["metrics"]) +} + +// TestDependencyEdgesAbsentTree pins the graceful path: the edges are an +// enrichment, so a tree with no manifest — or an unparseable one — yields +// nothing rather than failing the whole listing. +func TestDependencyEdgesAbsentTree(t *testing.T) { + t.Parallel() + + lay := projectLayout(t) + + assert.Nil(t, state.DependencyEdges(lay), "no manifest at all") + + writeTreeManifestFixture(t, lay, "this is not a lua table") + assert.Nil(t, state.DependencyEdges(lay), "an unparseable manifest") + + // A "return {...}" wrapper is not the format LuaRocks writes, and must not be + // silently half-read. + writeTreeManifestFixture(t, lay, "return { dependencies = {} }") + assert.Nil(t, state.DependencyEdges(lay)) +} diff --git a/cli/manifest/validate.go b/cli/manifest/validate.go index b36ec4c07..eb79c3678 100644 --- a/cli/manifest/validate.go +++ b/cli/manifest/validate.go @@ -93,6 +93,15 @@ func (m *Manifest) validateVersion() error { return nil } +// ValidPackageName reports whether name is a well-formed, non-reserved package +// name. Commands that take a package name as an argument and turn it into a +// path — uninstall, which removes manifests// and the rock subtrees named +// after it — must check it first: the shape is what rules out a traversal or a +// name that would resolve onto the metadata directory itself. +func ValidPackageName(name string) bool { + return nameRe.MatchString(name) && !isReservedPackageName(name) +} + func (m *Manifest) validatePackage() error { name := m.Package.Name switch { diff --git a/test/integration/package/conftest.py b/test/integration/package/conftest.py new file mode 100644 index 000000000..55692c65d --- /dev/null +++ b/test/integration/package/conftest.py @@ -0,0 +1,248 @@ +import subprocess +from pathlib import Path + +import pytest + +MANIFEST_TEMPLATE = """manifest_version = '0.1' + +[package] +name = '{name}' +description = '{description}' + +[platform] +tarantool = '>=3.0.0' +tt = '>=3.1.0' + +[products.default] +components = ['lua'] +default = true + +[components.lua] +path = '.' +""" + +LOCK_HEADER = """lock_version = '0.1' +manifest_version = '0.1' +generated_by = 'tt 3.0.0' +manifest_hash = 'sha256:0000000000000000000000000000000000000000000000000000000000000000' +""" + +LOCK_DEPENDENCY = """ +[[lock.products.default.dependencies]] +name = '{name}' +version = '{version}' +source = 'registry' +""" + + +def render_lock(pins: dict[str, str]) -> str: + """Render a lock pinning the given rocks under the default product. + + The nesting is `lock.products..dependencies`, which is what + cli/manifest writes and parses; a flatter shape parses as an empty closure + and would silently make every refcounting assertion vacuous. + """ + body = "".join( + LOCK_DEPENDENCY.format(name=name, version=version) for name, version in sorted(pins.items()) + ) + return LOCK_HEADER + body + + +class Tree: + """A fixture install tree, populated the way `tt package install` would. + + These tests deliberately never run an install: the task under test is pure + metadata handling, so building the state by hand keeps the suite offline and + independent of the installer's own correctness. + """ + + def __init__(self, root: Path, *, system: bool = False) -> None: + """Mirror the layout `state.ResolveLayout` resolves for a scope. + + The project scope nests everything under `.rocks/`; the system scope is + the tree root itself, so a staging directory for it has no `.rocks/` + level. Getting this wrong would make a system-scope test pass against + paths tt never reads. + """ + self.root = root + self.rocks = root if system else root / ".rocks" + self.share = self.rocks / "share" / "tarantool" + self.lib = self.rocks / "lib" / "tarantool" + self.manifests = self.rocks / "manifests" + + def install_primary( + self, + name: str, + version: str, + pins: dict[str, str] | None = None, + deps: dict[str, str] | None = None, + ) -> None: + """Lay the project's own package into the install root.""" + pins = pins or {} + _write(self.root / "app.manifest.toml", _manifest(name, deps)) + _write(self.root / "app.manifest.lock", render_lock(pins)) + _write(self.root / "VERSION", version + "\n") + self._install_files(name, version, pins) + + def install_guest( + self, + name: str, + version: str, + pins: dict[str, str] | None = None, + deps: dict[str, str] | None = None, + ) -> None: + """Lay a guest package's metadata under manifests// and its files. + + `pins` is the lock's whole transitive closure; `deps` is what the manifest + declares, a subset of it. The two differ on purpose: the depth split the + tree view reports is exactly that difference. + """ + pins = pins or {} + meta = self.manifests / name + _write(meta / "manifest.toml", _manifest(name, deps)) + _write(meta / "lock.toml", render_lock(pins)) + _write(meta / "VERSION", version + "\n") + self._install_files(name, version, pins) + + def _install_files(self, name: str, version: str, pins: dict[str, str]) -> None: + self.install_rock(name, version) + for rock, rock_version in pins.items(): + self.install_rock(rock, rock_version) + + def install_rock(self, name: str, version: str) -> None: + """Write the three places one rock version occupies in the tree.""" + _write(self.share / name / "init.lua", f"-- {name}\n") + _write(self.lib / name / f"{name}.so", "binary\n") + _write(self.share / "rocks" / name / version / "rock_manifest", f"{name} {version}\n") + + def write_tree_manifest( + self, + edges: dict[str, list[str]], + versions: dict[str, str] | None = None, + ) -> None: + """Write the LuaRocks tree manifest recording which rock requires which. + + LuaRocks manifests are bare assignments loaded into an environment, not a + returned table, and every key is bracket-quoted because a rock name may + contain a hyphen. Writing it by hand keeps the test honest about the real + on-disk format. + """ + versions = versions or {} + body = "commands = {}\nmodules = {}\nrepository = {}\ndependencies = {\n" + for rock in sorted(edges): + version = versions.get(rock, "0.0.0-1") + body += f' ["{rock}"] = {{\n ["{version}"] = {{\n' + for dep in edges[rock]: + body += f' {{ name = "{dep}", constraints = {{}} }},\n' + body += " }\n },\n" + body += "}\n" + _write(self.share / "rocks" / "manifest", body) + + def rock_exists(self, name: str) -> bool: + """True if a rock still has files in any of the three locations.""" + return any( + path.exists() + for path in ( + self.share / name, + self.lib / name, + self.share / "rocks" / name, + ) + ) + + def metadata_exists(self, name: str) -> bool: + return (self.manifests / name).exists() + + +def _manifest(name: str, deps: dict[str, str] | None = None) -> str: + body = MANIFEST_TEMPLATE.format(name=name, description=f"{name} description") + if deps: + body += "\n[dependencies]\n" + "".join( + f"{dep} = '{constraint}'\n" for dep, constraint in sorted(deps.items()) + ) + return body + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +@pytest.fixture() +def tree(tmp_path: Path) -> Tree: + return Tree(tmp_path) + + +@pytest.fixture() +def shared_tree(tree: Tree) -> Tree: + """The refcounting scenario: `metrics` has one owner, `checks` has two. + + monitoring -> metrics, checks + alerting -> checks + """ + tree.install_primary("my-app", "1.2.3") + tree.install_guest("monitoring", "2.0.0", {"metrics": "1.0.0", "checks": "3.1.0"}) + tree.install_guest("alerting", "0.5.0", {"checks": "3.1.0"}) + return tree + + +@pytest.fixture() +def tree_fixture(tree: Tree) -> Tree: + """Every case the dependency view distinguishes. + + metrics -- declared by monitoring, held only by it (would go with it) + shared-lib -- declared by monitoring, but installed in its own right + checks -- transitive for monitoring, declared by my-app and alerting + luasocket -- transitive for monitoring, held only by it + """ + tree.install_primary("my-app", "1.2.3", {"checks": "3.1.0"}, {"checks": ">=3.0.0"}) + tree.install_guest( + "monitoring", + "2.0.0", + # The lock's whole closure: two declared rocks and two that came in behind + # them. + {"checks": "3.1.0", "metrics": "1.0.0", "shared-lib": "1.0.0", "luasocket": "3.0.4"}, + {"metrics": ">=1.0.0", "shared-lib": ">=1.0.0"}, + ) + tree.install_guest("alerting", "0.5.0", {"checks": "3.1.0"}, {"checks": ">=3.0.0"}) + tree.install_guest("shared-lib", "1.0.0") + # The edges LuaRocks records: metrics pulls checks, shared-lib pulls + # luasocket. Those two are exactly monitoring's indirect rocks. + tree.write_tree_manifest( + {"metrics": ["checks"], "shared-lib": ["luasocket"]}, + {"metrics": "1.0.0", "shared-lib": "1.0.0"}, + ) + return tree + + +@pytest.fixture() +def system_staging(tmp_path: Path) -> Tree: + """A system-layout staging tree: two guests, one dependency shared. + + Built through the same Tree helper the project-scope tests use, so the + metadata format cannot drift between the two scopes. The caller copies it + over a container's /usr; nothing here touches the host's. + """ + staging = Tree(tmp_path / "staging", system=True) + staging.install_guest("monitoring", "2.0.0", {"metrics": "1.0.0", "checks": "3.1.0"}) + staging.install_guest("alerting", "0.5.0", {"checks": "3.1.0"}) + return staging + + +@pytest.fixture() +def run_tt(tt_cmd, tree: Tree): + """Run `tt` in the fixture tree and return the completed process.""" + + def run( + *args: str, + stdin: str | None = None, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess: + return subprocess.run( + [str(tt_cmd), *args], + cwd=cwd or tree.root, + input=stdin, + capture_output=True, + text=True, + ) + + return run diff --git a/test/integration/package/test_package_list.py b/test/integration/package/test_package_list.py new file mode 100644 index 000000000..3b2d45cbc --- /dev/null +++ b/test/integration/package/test_package_list.py @@ -0,0 +1,98 @@ +import json + +import pytest +import yaml + + +@pytest.mark.usefixtures("shared_tree") +def test_list_shows_primary_and_guests(run_tt): + """The task's headline case: primary plus two guests, all three listed.""" + result = run_tt("package", "list", "-o", "table") + assert result.returncode == 0, result.stderr + + lines = result.stdout.strip().splitlines() + assert lines[0].split() == ["NAME", "VERSION", "KIND", "DESCRIPTION"] + assert len(lines) == 4 + + rows = {line.split()[0]: line for line in lines[1:]} + assert set(rows) == {"my-app", "monitoring", "alerting"} + assert "primary" in rows["my-app"] + assert "1.2.3" in rows["my-app"] + assert "guest" in rows["monitoring"] + assert "guest" in rows["alerting"] + + +def test_list_json_is_valid(run_tt, shared_tree): + """`-o json` parses, and carries the dependency pins read off disk.""" + result = run_tt("package", "list", "-o", "json") + assert result.returncode == 0, result.stderr + + payload = json.loads(result.stdout) + assert payload["scope"] == "project" + assert payload["root"] == str(shared_tree.root) + + by_name = {entry["name"]: entry for entry in payload["packages"]} + assert set(by_name) == {"my-app", "monitoring", "alerting"} + assert by_name["my-app"]["primary"] is True + assert by_name["monitoring"]["primary"] is False + assert by_name["monitoring"]["dependencies"] == {"metrics": "1.0.0", "checks": "3.1.0"} + assert by_name["alerting"]["dependencies"] == {"checks": "3.1.0"} + + +@pytest.mark.usefixtures("shared_tree") +def test_list_defaults_to_yaml_when_piped(run_tt): + """Captured output is not a terminal, so the default format is YAML. + + This is the convention that makes `tt package list | ...` parseable without + the caller having to remember `-o`. + """ + result = run_tt("package", "list") + assert result.returncode == 0, result.stderr + + payload = yaml.safe_load(result.stdout) + assert payload["scope"] == "project" + assert {entry["name"] for entry in payload["packages"]} == { + "my-app", + "monitoring", + "alerting", + } + + +@pytest.mark.usefixtures("tree") +def test_list_empty_project(run_tt): + """A project nothing was installed into is an empty listing, not an error.""" + result = run_tt("package", "list", "-o", "table") + assert result.returncode == 0, result.stderr + assert "no packages installed" in result.stdout + + result = run_tt("package", "list", "-o", "json") + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["packages"] == [] + + +@pytest.mark.usefixtures("shared_tree") +def test_list_rejects_unknown_format(run_tt): + """A mistyped -o is exit 1, not a silent fallback.""" + result = run_tt("package", "list", "-o", "xml") + assert result.returncode == 1 + assert "unknown output format" in result.stderr + + +@pytest.mark.usefixtures("shared_tree") +def test_list_rejects_unknown_scope(run_tt): + result = run_tt("package", "list", "--scope", "global") + assert result.returncode == 1 + assert "unknown scope" in result.stderr + + +def test_list_tolerates_broken_metadata(run_tt, shared_tree): + """One corrupt metadata directory must not take the whole inventory down.""" + broken = shared_tree.manifests / "broken" + broken.mkdir(parents=True, exist_ok=True) + (broken / "manifest.toml").write_text("this is not = valid toml [") + + result = run_tt("package", "list", "-o", "json") + assert result.returncode == 0, result.stderr + + names = {entry["name"] for entry in json.loads(result.stdout)["packages"]} + assert names == {"my-app", "monitoring", "alerting"} diff --git a/test/integration/package/test_package_list_tree.py b/test/integration/package/test_package_list_tree.py new file mode 100644 index 000000000..18bc020a3 --- /dev/null +++ b/test/integration/package/test_package_list_tree.py @@ -0,0 +1,181 @@ +import json + +import pytest + +PREFIXES = ("\u2502 ", " ") +BRANCHES = ("\u251c\u2500\u2500 ", "\u2514\u2500\u2500 ") + + +def tree_lines(output: str) -> list[str]: + """The rendered tree without the scope header.""" + return output.split("\n\n", 1)[1].rstrip("\n").split("\n") + + +def depth(line: str) -> int: + """How deep a rendered line sits: 0 for the root, 1 for its children.""" + n = 0 + while n + 4 <= len(line): + chunk = line[n : n + 4] + if chunk in PREFIXES: + n += 4 + elif chunk in BRANCHES: + return n // 4 + 1 + else: + break + return 0 + + +def label(line: str) -> str: + """A line's own text, with the tree prefix stripped.""" + n = 0 + while n + 4 <= len(line): + chunk = line[n : n + 4] + if chunk in PREFIXES: + n += 4 + elif chunk in BRANCHES: + return line[n + 4 :] + else: + break + return line + + +def subtree_of(output: str, prefix: str) -> str: + """The node whose label starts with `prefix`, plus everything nested under it.""" + lines = tree_lines(output) + for i, line in enumerate(lines): + if not label(line).startswith(prefix): + continue + own = depth(line) + block = [line] + for nxt in lines[i + 1 :]: + if depth(nxt) <= own: + break + block.append(nxt) + return "\n".join(block) + raise AssertionError(f"no {prefix!r} node in:\n{output}") + + +@pytest.mark.usefixtures("tree_fixture") +def test_tree_annotates_every_dependency(run_tt): + """Each branch says whether the dependency would leave with its package.""" + result = run_tt("package", "list", "--tree", "-o", "table") + assert result.returncode == 0, result.stderr + + monitoring = subtree_of(result.stdout, "monitoring 2.0.0") + assert "checks 3.1.0 (shared with alerting, my-app)" in monitoring + assert "metrics 1.0.0 (only here)" in monitoring + assert "shared-lib 1.0.0 (installed package)" in monitoring + assert "shared with monitoring" not in monitoring + + # One tree, rooted at the project's own package, with the guests beneath it. + roots = [label(line) for line in tree_lines(result.stdout) if depth(line) == 0] + assert roots == ["my-app 1.2.3 (primary)"] + + assert "(no dependencies)" in subtree_of(result.stdout, "shared-lib 1.0.0 (guest)") + + +@pytest.mark.usefixtures("tree_fixture") +def test_tree_nests_under_the_requiring_rock(run_tt): + """A rock hangs off whatever pulled it in, read from the tree manifest. + + The lock cannot say this — it stores the closure flattened, parent discarded — + but LuaRocks records each installed rock's own requirements in the tree. + """ + result = run_tt("package", "list", "--tree", "-o", "table") + assert result.returncode == 0, result.stderr + + metrics = subtree_of(result.stdout, "metrics 1.0.0").split("\n") + assert len(metrics) == 2 + assert "checks 3.1.0" in label(metrics[1]) + + shared_lib = subtree_of(result.stdout, "shared-lib 1.0.0 (installed package)").split("\n") + assert len(shared_lib) == 2 + assert "luasocket 3.0.4" in label(shared_lib[1]) + + # Nothing is left unrooted, so the unknown-parent group does not appear. + assert "parent not recorded" not in result.stdout + + +@pytest.mark.usefixtures("shared_tree") +def test_tree_without_manifest_falls_back(run_tt): + """A tree with no LuaRocks manifest still lists indirect rocks, ungrouped.""" + result = run_tt("package", "list", "--tree", "-o", "table") + assert result.returncode == 0, result.stderr + + # shared_tree writes no tree manifest, and its packages declare nothing, so + # every rock is unrooted — and still shown. + assert "parent not recorded" in result.stdout + assert "metrics 1.0.0" in result.stdout + assert "checks 3.1.0" in result.stdout + + +@pytest.mark.usefixtures("tree_fixture") +def test_tree_in_json(run_tt): + """`--tree` is not a table-only view: the index travels in machine output.""" + result = run_tt("package", "list", "--tree", "-o", "json") + assert result.returncode == 0, result.stderr + + deps = json.loads(result.stdout)["rocks"] + by_name = {dep["name"]: dep for dep in deps} + + assert [dep["name"] for dep in deps] == ["checks", "luasocket", "metrics", "shared-lib"] + assert by_name["checks"]["owners"] == ["alerting", "monitoring", "my-app"] + assert by_name["checks"]["shared"] is True + assert by_name["metrics"]["owners"] == ["monitoring"] + assert by_name["metrics"]["shared"] is False + assert by_name["shared-lib"]["installed"] is True + + # A rock nobody declared is owned exactly like a declared one. + assert by_name["luasocket"]["owners"] == ["monitoring"] + assert by_name["luasocket"]["shared"] is False + + # The edges travel in the machine output too. + assert by_name["metrics"]["requires"] == ["checks"] + assert by_name["shared-lib"]["requires"] == ["luasocket"] + assert "requires" not in by_name["checks"] + + +@pytest.mark.usefixtures("shared_tree") +def test_list_without_tree_has_no_index(run_tt): + """The index is computed only when asked for. + + A package entry still carries its own "dependencies" object; the top-level + index is a separate key precisely so the two shapes do not share a name. + """ + result = run_tt("package", "list", "-o", "json") + assert result.returncode == 0, result.stderr + + payload = json.loads(result.stdout) + assert "rocks" not in payload + assert "dependencies" in payload["packages"][1] + + +def test_tree_empty_scope_falls_back(run_tt, tmp_path): + """An empty scope says so rather than printing a header with nothing under it.""" + empty = tmp_path / "empty" + empty.mkdir() + + result = run_tt("package", "list", "--tree", "-o", "table", cwd=empty) + assert result.returncode == 0, result.stderr + + assert "no packages installed" in result.stdout + assert "├" not in result.stdout + assert "└" not in result.stdout + + +def test_tree_predicts_uninstall_outcome(run_tt, tree_fixture): + """What the tree says would happen is what uninstall actually does.""" + listed = run_tt("package", "list", "--tree", "-o", "table") + assert listed.returncode == 0, listed.stderr + + monitoring = subtree_of(listed.stdout, "monitoring 2.0.0") + assert "metrics 1.0.0 (only here)" in monitoring + assert "checks 3.1.0 (shared with alerting, my-app)" in monitoring + + removed = run_tt("package", "uninstall", "monitoring", "--yes") + assert removed.returncode == 0, removed.stderr + + assert "removed unused dependency metrics" in removed.stdout + assert not tree_fixture.rock_exists("metrics") + assert tree_fixture.rock_exists("checks") + assert tree_fixture.rock_exists("shared-lib") diff --git a/test/integration/package/test_package_system_scope.py b/test/integration/package/test_package_system_scope.py new file mode 100644 index 000000000..ae5ae901a --- /dev/null +++ b/test/integration/package/test_package_system_scope.py @@ -0,0 +1,124 @@ +"""System-scope coverage for `tt package list` and `tt package uninstall`. + +The system scope installs into `/usr`, so exercising it means writing to a tree +no test may touch on a developer machine and no CI job should own. A container +gives a disposable `/usr` and a root user, which is the only way these paths get +run at all — every other test drives the project or user scope. +""" + +import json +import shutil +import subprocess + +import pytest + +# The image only needs a shell; tt is mounted in and runs standalone. +IMAGE = "ubuntu" + +pytestmark = pytest.mark.docker + + +def docker_available() -> bool: + if shutil.which("docker") is None: + return False + return subprocess.run(["docker", "ps"], capture_output=True).returncode == 0 + + +def run_in_container(tt_cmd, staging, script: str) -> subprocess.CompletedProcess: + """Copy the staging tree over /usr in a container and run `script` there. + + tt is mounted read-only at /tt. `cp -a /staging/.` merges the fixture into + the image's own /usr rather than replacing it, so the container keeps its + shell and coreutils. + """ + return subprocess.run( + [ + "docker", + "run", + "--rm", + "-v", + f"{tt_cmd}:/tt:ro", + "-v", + f"{staging}:/staging:ro", + IMAGE, + "bash", + "-c", + f"cp -a /staging/. /usr/ && {script}", + ], + capture_output=True, + text=True, + ) + + +@pytest.fixture(autouse=True) +def require_docker(tt_cmd): + """Skip unless docker runs and the built tt is executable in the image. + + tt is built for the host, so a macOS binary cannot run in a Linux container. + Skipping on that is honest: on Linux CI the same test executes for real. + """ + if not docker_available(): + pytest.skip("docker is not available on this system") + + probe = subprocess.run( + ["docker", "run", "--rm", "-v", f"{tt_cmd}:/tt:ro", IMAGE, "/tt", "version"], + capture_output=True, + text=True, + ) + if probe.returncode != 0: + pytest.skip(f"the built tt does not run in {IMAGE}: {probe.stderr.strip()[:200]}") + + +def test_system_scope_list(tt_cmd, system_staging): + """`--scope system` reads /usr, not the working directory's .rocks/.""" + result = run_in_container( + tt_cmd, + system_staging.root, + "/tt package list --scope system -o json", + ) + assert result.returncode == 0, result.stderr + + payload = json.loads(result.stdout) + assert payload["scope"] == "system" + assert payload["root"] == "/usr" + assert {entry["name"] for entry in payload["packages"]} == {"monitoring", "alerting"} + + # A system tree holds guests only: nothing there is the project's own package. + assert all(entry["primary"] is False for entry in payload["packages"]) + + +def test_system_scope_uninstall_refcounts(tt_cmd, system_staging): + """Removal in /usr drops the sole-owner dependency and keeps the shared one.""" + script = ( + "/tt package uninstall monitoring --scope system --yes && " + "echo --- && " + "ls /usr/share/tarantool && " + "echo --- && " + "ls /usr/manifests" + ) + result = run_in_container(tt_cmd, system_staging.root, script) + assert result.returncode == 0, result.stderr + + assert "removed unused dependency metrics" in result.stdout + assert "kept checks (required by alerting)" in result.stdout + + _, modules, manifests = result.stdout.split("---") + assert "monitoring" not in modules.split() + assert "metrics" not in modules.split() + assert "checks" in modules.split() + assert "alerting" in modules.split() + assert manifests.split() == ["alerting"] + + +def test_system_scope_uninstall_unknown_package(tt_cmd, system_staging): + """A miss in the system scope is exit 1, and touches nothing.""" + result = run_in_container( + tt_cmd, + system_staging.root, + "/tt package uninstall nosuch --scope system --yes; echo exit=$?; ls /usr/manifests", + ) + assert result.returncode == 0, result.stderr + + assert "not installed" in result.stderr + assert "exit=1" in result.stdout + assert "monitoring" in result.stdout diff --git a/test/integration/package/test_package_uninstall.py b/test/integration/package/test_package_uninstall.py new file mode 100644 index 000000000..0a5e53acf --- /dev/null +++ b/test/integration/package/test_package_uninstall.py @@ -0,0 +1,150 @@ +import json + +import pytest + + +def test_uninstall_drops_sole_dependency_keeps_shared(run_tt, shared_tree): + """The task's headline case, end to end through the CLI. + + Removing `monitoring` takes its files, its metadata and `metrics` (which only + it held); `checks` survives because `alerting` still holds it. + """ + result = run_tt("package", "uninstall", "monitoring", "--yes") + assert result.returncode == 0, result.stderr + + assert not shared_tree.rock_exists("monitoring") + assert not shared_tree.metadata_exists("monitoring") + assert not shared_tree.rock_exists("metrics") + + assert shared_tree.rock_exists("checks") + assert shared_tree.rock_exists("alerting") + assert shared_tree.metadata_exists("alerting") + + assert "removed unused dependency metrics" in result.stdout + assert "kept checks (required by alerting)" in result.stdout + + +@pytest.mark.usefixtures("shared_tree") +def test_uninstall_then_list_is_consistent(run_tt): + """After a removal, the inventory no longer reports the package.""" + assert run_tt("package", "uninstall", "monitoring", "--yes").returncode == 0 + + result = run_tt("package", "list", "-o", "json") + assert result.returncode == 0, result.stderr + + names = {entry["name"] for entry in json.loads(result.stdout)["packages"]} + assert names == {"my-app", "alerting"} + + +def test_uninstall_refuses_primary_package(run_tt, shared_tree): + """The project's own package is not a guest; its place is the project's call.""" + result = run_tt("package", "uninstall", "my-app", "--yes") + assert result.returncode == 1 + assert "primary package" in result.stderr + + assert (shared_tree.root / "app.manifest.toml").exists() + assert shared_tree.rock_exists("my-app") + + +def test_uninstall_unknown_package(run_tt, shared_tree): + result = run_tt("package", "uninstall", "nosuch", "--yes") + assert result.returncode == 1 + assert "not installed" in result.stderr + + assert shared_tree.metadata_exists("monitoring") + + +@pytest.mark.parametrize( + "name", + ["../../etc", "..", "has/slash", "Upper", "manifests", "bin"], +) +def test_uninstall_rejects_bad_package_name(run_tt, shared_tree, name): + """The name becomes a path, so its shape is checked before any removal.""" + result = run_tt("package", "uninstall", name, "--yes") + assert result.returncode == 1 + assert "invalid package name" in result.stderr + + assert shared_tree.metadata_exists("monitoring") + assert shared_tree.rock_exists("metrics") + + +def test_uninstall_declined_removes_nothing(run_tt, shared_tree): + """Declining the prompt is a full abort, not a partial removal.""" + result = run_tt("package", "uninstall", "monitoring", stdin="n\n") + assert result.returncode == 1 + + assert shared_tree.metadata_exists("monitoring") + assert shared_tree.rock_exists("monitoring") + assert shared_tree.rock_exists("metrics") + + +def test_uninstall_accepted_at_prompt(run_tt, shared_tree): + """Accepting removes the package, and the prompt named what goes with it.""" + result = run_tt("package", "uninstall", "monitoring", stdin="y\n") + assert result.returncode == 0, result.stderr + + prompt = result.stdout + result.stderr + assert "metrics" in prompt + assert not shared_tree.metadata_exists("monitoring") + + +def test_uninstall_is_repeatable(run_tt, shared_tree): + """A second run reports a clean miss rather than crashing on a half-state.""" + assert run_tt("package", "uninstall", "monitoring", "--yes").returncode == 0 + + result = run_tt("package", "uninstall", "monitoring", "--yes") + assert result.returncode == 1 + assert "not installed" in result.stderr + + assert shared_tree.rock_exists("checks") + + +def test_uninstall_keeps_dependency_held_by_primary(run_tt, tree): + """The project's own package counts as an owner of what its lock pins.""" + tree.install_primary("my-app", "1.0.0", {"metrics": "1.0.0"}) + tree.install_guest("monitoring", "2.0.0", {"metrics": "1.0.0"}) + + result = run_tt("package", "uninstall", "monitoring", "--yes") + assert result.returncode == 0, result.stderr + + assert tree.rock_exists("metrics") + assert "kept metrics (required by my-app)" in result.stdout + + +def test_uninstall_reports_why_a_dependency_was_kept(run_tt, tree_fixture): + """The two reasons a rock survives are reported apart, not conflated. + + `checks` stays because other packages still pin it; `shared-lib` stays + because it is a package in its own right, which is a different thing and + would read wrong as "required by shared-lib". + """ + result = run_tt("package", "uninstall", "monitoring", "--yes") + assert result.returncode == 0, result.stderr + + assert "kept checks (required by alerting, my-app)" in result.stdout + assert "kept shared-lib (installed as a package in its own right)" in result.stdout + assert "required by shared-lib" not in result.stdout + + assert tree_fixture.rock_exists("checks") + assert tree_fixture.rock_exists("shared-lib") + + +def test_uninstall_removes_transitive_dependency(run_tt, tree_fixture): + """A rock no manifest ever named is still removed when its last owner goes. + + A lock records the whole transitive closure, so ownership covers every rock a + package caused to be installed, at any depth. + """ + result = run_tt("package", "uninstall", "monitoring", "--yes") + assert result.returncode == 0, result.stderr + + assert "removed unused dependency luasocket" in result.stdout + assert not tree_fixture.rock_exists("luasocket") + + +def test_uninstall_rejects_unknown_scope(run_tt, shared_tree): + result = run_tt("package", "uninstall", "monitoring", "--scope", "global", "--yes") + assert result.returncode == 1 + assert "unknown scope" in result.stderr + + assert shared_tree.metadata_exists("monitoring")