diff --git a/.golangci.yml b/.golangci.yml index 233fba910..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 @@ -110,6 +118,21 @@ 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" + # 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" + # cli/manifest/inventory renders tt package list as YAML, the + # machine-readable default for non-TTY output. + - "gopkg.in/yaml.v3" test: files: - "$test" @@ -122,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 10483897d..b56de2dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,47 @@ 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 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 b7244ae8a..1e1e3d12b 100644 --- a/cli/cmd/package.go +++ b/cli/cmd/package.go @@ -6,14 +6,20 @@ 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" ) @@ -29,25 +35,273 @@ var ( packageWithoutDeps bool ) +// Flags shared by the scope-addressed subcommands: install, list and uninstall. +var ( + packageScope string + packageUpgrade bool + packageForce bool + 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( newPackageBuildCmd(), 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{ + 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 := absoluteWorkingDir() + 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{ @@ -123,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 } @@ -193,8 +442,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 { @@ -216,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/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..7179fcc1f --- /dev/null +++ b/cli/manifest/install/errors.go @@ -0,0 +1,75 @@ +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 ( + // 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..91295571b --- /dev/null +++ b/cli/manifest/install/extract.go @@ -0,0 +1,101 @@ +package install + +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 +// 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 state.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 == state.ManifestsDirName { + return "", false + } + + if dep, ok := rocksDepName(rest); ok { + if _, skip := skipNames[dep]; skip { + return "", false + } + } + + if scope == ScopeProject { + return state.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..9666fc44b --- /dev/null +++ b/cli/manifest/install/install.go @@ -0,0 +1,392 @@ +// 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" + "github.com/tarantool/tt/cli/manifest/state" +) + +// 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, 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 := state.ResolveLayout(opts.Scope, opts.ProjectDir) + if err != nil { + return nil, stateErrorf("%w", err) + } + + installed, err := state.Packages(lay, opts.Scope) + if err != nil { + return nil, stateErrorf("%w", 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 []state.Package) (bool, error) { + name := header.Manifest.Package.Name + + existing, ok := state.Find(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 state.Layout, installed []state.Package, +) 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 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, + 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 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 new file mode 100644 index 000000000..b0944f2be --- /dev/null +++ b/cli/manifest/install/install_internal_test.go @@ -0,0 +1,237 @@ +package install + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "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. +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 := state.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..b4b38afe5 --- /dev/null +++ b/cli/manifest/install/plan.go @@ -0,0 +1,249 @@ +package install + +import ( + "fmt" + "os" + + "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. +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 []state.Package, lay state.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 []state.Package, lay state.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 := 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 && !state.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 && state.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 []state.Package, +) []contribution { + contributions := []contribution{{ + pkg: newManifest.Package.Name, + pin: newPin, + constraint: declaredConstraint(newManifest, dep), + }} + + for _, pkg := range installed { + if pkg.Lock == nil { + continue + } + + pin, ok := state.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 +} + +// 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 state.Layout, plan installPlan) error { + for _, decision := range plan.decisions { + if decision.removeVersion == "" { + continue + } + + 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", + 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..5849b6d16 --- /dev/null +++ b/cli/manifest/install/plan_internal_test.go @@ -0,0 +1,68 @@ +package install + +import ( + "testing" + + "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 +// 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 := state.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 := state.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..4bac89db9 --- /dev/null +++ b/cli/manifest/install/runtime.go @@ -0,0 +1,123 @@ +package install + +import ( + "path/filepath" + + "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 state.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 []state.Package) error { + if !header.WithDeps || opts.Scope != ScopeProject { + return nil + } + + primary, ok := state.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 +} + +// 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..52cc54773 --- /dev/null +++ b/cli/manifest/install/scope.go @@ -0,0 +1,32 @@ +package install + +import "github.com/tarantool/tt/cli/manifest/state" + +// 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. + 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) { + scope, err := state.ParseScope(raw) + if err != nil { + return "", stateErrorf("%w", err) + } + + return scope, nil +} diff --git a/cli/manifest/install/state.go b/cli/manifest/install/state.go new file mode 100644 index 000000000..a7ef9a0f9 --- /dev/null +++ b/cli/manifest/install/state.go @@ -0,0 +1,26 @@ +package install + +import ( + "fmt" + + "github.com/tarantool/tt/cli/manifest/state" +) + +// writeMetadata records a freshly installed guest's metadata under +// 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) + } + + 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/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/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..55507af6c --- /dev/null +++ b/cli/manifest/state/packages.go @@ -0,0 +1,327 @@ +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 +} + +// 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. +// +// 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{} + + 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..12ae83847 --- /dev/null +++ b/cli/manifest/state/packages_test.go @@ -0,0 +1,339 @@ +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 +} + +// 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 new file mode 100644 index 000000000..526dbcfef --- /dev/null +++ b/cli/manifest/state/rocks.go @@ -0,0 +1,120 @@ +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. +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..78f18d6f2 --- /dev/null +++ b/cli/manifest/state/rocks_test.go @@ -0,0 +1,192 @@ +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")) +} + +// 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/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) +} 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")