diff --git a/cmd/commands/delivery.go b/cmd/commands/delivery.go index 9d776c1c..397575f8 100644 --- a/cmd/commands/delivery.go +++ b/cmd/commands/delivery.go @@ -33,25 +33,39 @@ import ( // while it ships as a built-in command rather than a standalone plugin. const DeliveryKitCommandName = "delivery-kit" -func NewDeliveryCommand() (*cobra.Command, context.Context) { - server.DefaultAddress = "https://delivery-sync.deckhouse.ru" +// NewRootContext returns the process-wide graceful-termination context that the +// whole d8 command tree runs under: it installs the SIGINT/SIGTERM handler that +// Execute's graceful.Terminate and telemetry shutdown depend on, and carries the +// werf logger. +// +// It is separate from NewDeliveryCommand because the context is root +// infrastructure, still required when an installed plugin serves `delivery-kit` +// and the built-in werf tree is never constructed. +func NewRootContext() context.Context { + return logging.WithLogger(graceful.WithTermination(context.Background())) +} - terminationCtx := graceful.WithTermination(context.Background()) - defer graceful.Shutdown(terminationCtx, onShutdown) +// NewDeliveryCommand builds the built-in werf re-skin under the root context. +// It returns nil only on a path that has already asked for termination, which +// onShutdown turns into a process exit before the caller sees the nil. +func NewDeliveryCommand(ctx context.Context) *cobra.Command { + server.DefaultAddress = "https://delivery-sync.deckhouse.ru" - ctx := logging.WithLogger(terminationCtx) + // Drains a termination requested below - or a signal caught while werf builds + // its command tree - into the os.Exit that onShutdown performs. + defer graceful.Shutdown(ctx, onShutdown) const werfAlias = "dk" if err := setWerfSelfInvocationCommand(werfAlias); err != nil { graceful.Terminate(ctx, err, 1) - return nil, ctx + return nil } werfRootCmd, err := werfroot.ConstructRootCmd(ctx) if err != nil { graceful.Terminate(ctx, err, 1) - return nil, ctx + return nil } werfRootCmd.Use = DeliveryKitCommandName @@ -67,7 +81,7 @@ LICENSE NOTE: The Deckhouse Delivery Kit functionality is exclusively available removeKubectlCmd(werfRootCmd) - return werfRootCmd, ctx + return werfRootCmd } // setWerfSelfInvocationCommand sets environment variables to ensure werf knows how to call itself diff --git a/cmd/commands/kubectl.go b/cmd/commands/kubectl.go index 2f71e9d0..08c38a7f 100644 --- a/cmd/commands/kubectl.go +++ b/cmd/commands/kubectl.go @@ -605,7 +605,7 @@ func NewKubectlCommand() *cobra.Command { // Restore default OS signal handling for the kubectl subtree. // // The d8 root command installs a graceful-termination signal handler - // (see graceful.WithTermination in NewDeliveryCommand) that intercepts + // (see graceful.WithTermination in NewRootContext) that intercepts // SIGINT/SIGTERM, cancels the root context and then resets the signal // handlers. The kubectl subcommands (notably long-running ones such as // `proxy`, `port-forward`, `exec`, `attach`, `logs -f`) do not observe diff --git a/cmd/d8/root.go b/cmd/d8/root.go index ee748222..bd4da500 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -17,6 +17,7 @@ limitations under the License. package main import ( + "context" "errors" "fmt" "log" @@ -48,6 +49,7 @@ import ( packagecmd "github.com/deckhouse/deckhouse-cli/internal/packagecmd" pluginscmd "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd" "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" snapshot "github.com/deckhouse/deckhouse-cli/internal/snapshot/cmd" status "github.com/deckhouse/deckhouse-cli/internal/status/cmd" system "github.com/deckhouse/deckhouse-cli/internal/system/cmd" @@ -98,45 +100,164 @@ func NewRootCommand() *RootCommand { return rootCmd } +// overridable is a top-level command that an installed plugin of the same name +// replaces, falling back to the built-in when no such plugin is installed. +// +// builtin is a thunk rather than a ready command: it runs only when the built-in +// wins, so a replaced command's construction cost - the werf and virtualization +// trees are each assembled eagerly - is never paid. +// +// Matching is on the canonical name only, never an alias: a plugin must be named +// exactly like the command it takes over. The built-in's aliases carry over to the +// wrapper, so `d8 dk` and `d8 s` keep working once a plugin serves the command. +type overridable struct { + name string + short string + aliases []string + + // satisfiesPluginDep marks a name a plugin contract may depend on while the + // capability ships as a built-in command instead of a standalone plugin. + satisfiesPluginDep bool + + builtin func() *cobra.Command +} + +// overridableCommands lists the top-level commands a plugin may take over, in +// registration order. short duplicates the built-in's own Short because the thunk +// stays unevaluated when a plugin wins; it is only a fallback, as the wrapper +// prefers the description from the plugin's cached contract. +func (r *RootCommand) overridableCommands(ctx context.Context) []overridable { + return []overridable{ + { + name: commands.DeliveryKitCommandName, + short: "A set of tools for building, distributing, and deploying containerized applications", + aliases: []string{"dk"}, + satisfiesPluginDep: true, + builtin: func() *cobra.Command { return commands.NewDeliveryCommand(ctx) }, + }, + { + name: "data", + short: "Data operations (export/import)", + builtin: data.NewCommand, + }, + { + name: "snapshot", + short: "Snapshot operations (create, delete, download, restore, upload, get)", + builtin: snapshot.NewCommand, + }, + { + name: "iam", + short: "Manage Deckhouse users, groups, and access grants", + builtin: iam.NewCommand, + }, + { + name: "network", + short: "A group of commands to operate network related tasks in The Deckhouse Ecosystem.", + aliases: []string{"n"}, + builtin: network.NewCommand, + }, + { + name: "v", + short: "Commands to work with Deckhouse Virtualization Platform.", + aliases: []string{"virtualization"}, + builtin: commands.NewVirtualizationCommand, + }, + { + name: "stronghold", + short: "Deckhouse Stronghold commands", + builtin: commands.NewStrongholdCommand, + }, + { + name: pluginscmd.PackagePluginName, + short: "Package build and bootstrap tool for containerized packages", + satisfiesPluginDep: true, + builtin: packagecmd.NewCommand, + }, + { + name: pluginscmd.SystemPluginName, + short: "Operate system options in DKP", + aliases: []string{"s", "p", "platform"}, + builtin: system.NewCommand, + }, + } +} + func (r *RootCommand) registerCommands() { - deliveryCMD, ctx := commands.NewDeliveryCommand() - r.cmd.AddCommand(deliveryCMD) + // The termination context is root infrastructure - Execute's graceful.Terminate + // and telemetry shutdown run on it - so it is established before any command is + // built, and regardless of whether a plugin ends up serving delivery-kit. + ctx := commands.NewRootContext() r.cmd.SetContext(ctx) + installRoot, installed := r.installedPlugins() + + // Names still served by a built-in once the override pass is done. A name an + // installed plugin took over drops off the list: the plugin itself now satisfies + // that dependency, with the version checking a built-in cannot offer. + var builtinDeps []string + + for _, o := range r.overridableCommands(ctx) { + if _, override := installed[o.name]; override { + r.cmd.AddCommand(pluginscmd.NewPluginCommand( + o.name, + o.short, + o.aliases, + r.logger.Named(o.name+"-command"), + pluginscmd.WithInstallRoot(installRoot), + )) + + continue + } + + // A nil command means the built-in already asked for termination; adding it + // would panic in cobra before the pending exit runs. + if cmd := o.builtin(); cmd != nil { + r.cmd.AddCommand(cmd) + } + + if o.satisfiesPluginDep { + builtinDeps = append(builtinDeps, o.name) + } + } + r.cmd.AddCommand(backup.NewCommand()) - r.cmd.AddCommand(data.NewCommand()) - r.cmd.AddCommand(snapshot.NewCommand()) r.cmd.AddCommand(mirror.NewCommand()) r.cmd.AddCommand(cr.NewCommand()) r.cmd.AddCommand(status.NewCommand()) - r.cmd.AddCommand(iam.NewCommand()) // Backward-compatibility shim for the four UserOperation commands that // used to live at the top level (d8 user lock|unlock|reset-password|reset-2fa) // before they moved under d8 iam user. Hidden from help; emits a stderr // deprecation banner on each invocation pointing to the new path. r.cmd.AddCommand(iamuser.NewDeprecatedTopLevelCommand()) - r.cmd.AddCommand(network.NewCommand()) r.cmd.AddCommand(tools.NewCommand()) - r.cmd.AddCommand(commands.NewVirtualizationCommand()) r.cmd.AddCommand(commands.NewKubectlCommand()) r.cmd.AddCommand(commands.NewLoginCommand()) - r.cmd.AddCommand(commands.NewStrongholdCommand()) r.cmd.AddCommand(commands.NewHelpJSONCommand(r.cmd)) - if os.Getenv("DECKHOUSE_PLUGINS_ENABLED") != "true" { - r.cmd.AddCommand(system.NewCommand()) - } else { - r.cmd.AddCommand(pluginscmd.NewPluginCommand(pluginscmd.SystemPluginName, "Operate system options in DKP", []string{"s", "p", "platform"}, r.logger.Named("system-command"))) + r.cmd.AddCommand(distcmd.NewCommand(r.logger.Named("dist-command"), builtinDeps)) +} + +// installedPlugins returns the plugins root actually holding installs and the set of +// plugin names in it, empty when nothing is installed. +// +// This runs at registration time, before flag parsing, so only the DECKHOUSE_CLI_PATH +// env override (applied in NewRootCommand) can retarget it: --plugins-dir is parsed +// far too late to decide which commands get registered. +func (r *RootCommand) installedPlugins() (string, map[string]struct{}) { + root, names, ok := layout.ResolveInstalled(flags.DeckhousePluginsDir) + if !ok { + return "", nil + } + + set := make(map[string]struct{}, len(names)) + for _, name := range names { + set[name] = struct{}{} } - r.cmd.AddCommand(packagecmd.NewCommand()) + r.logger.Debug("resolved installed plugins for command override", + slog.String("root", root), slog.Any("plugins", names)) - // delivery-kit and package ship as built-in commands, not as plugins. Declaring - // them here satisfies a plugin's dependency on either name without a registry lookup. - r.cmd.AddCommand(distcmd.NewCommand( - r.logger.Named("dist-command"), - []string{commands.DeliveryKitCommandName, pluginscmd.PackagePluginName}, - )) + return root, set } func (r *RootCommand) Execute() error { diff --git a/docs/plugins.md b/docs/plugins.md index 5b3e6174..5c96f767 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -10,8 +10,8 @@ Plugins are versioned binaries distributed through the cluster registry. [Troubleshooting](#troubleshooting) · [Advanced](#advanced-hidden-flags) > [!NOTE] -> The `d8 dist plugins` command group is hidden from `d8 dist --help` while -> the plugin ecosystem rolls out. The commands below are fully functional. +> Installing a plugin that is already present updates it. There is no separate +> `update` command; `d8 dist plugins install --all` updates every installed plugin. ## Plugin source @@ -54,8 +54,8 @@ as described in | `d8 dist plugins install ` | installs the newest version compatible with your cluster | | `d8 dist plugins install --version X` | installs an exact version | | `d8 dist plugins install --use-major N` | switches majors explicitly | -| `d8 dist plugins update ` / `update all` | updates within the current major | -| `d8 dist plugins list` | shows installed plugins (the proxy serves no catalog, so available plugins are not listed) | +| `d8 dist plugins install --all` | updates every installed plugin, each within its own major | +| `d8 dist plugins list` | shows installed plugins, plus the published ones when the transport can enumerate them (`--source`) | | `d8 dist plugins contract ` | shows a plugin's contract: version, description, requirements | | `d8 dist plugins remove ` / `remove all` | removes plugins | @@ -83,8 +83,9 @@ active one: Rules that follow from this layout: -- `d8 dist plugins update` stays **within the installed major**. Crossing - majors is always an explicit decision: `--use-major N` or `--version X`. +- Installing a plugin that is already present **updates** it, staying **within + the installed major**. Crossing majors is always an explicit decision: + `--use-major N` or `--version X`. - Installing a version that is already on disk just repoints the symlink - no download. - Installing the active version says so and does nothing; `--force` @@ -125,7 +126,16 @@ modules) are only *verified* - d8 never changes the cluster for you. `d8 mirror pull` mirrors plugins into the images bundle automatically: every plugin whose contract names a mirrored module is selected (per bundled module version, newest compatible), along with its mandatory plugin dependencies; -`--include-plugin [@constraint]` adds more. After `d8 mirror push`, the +`--include-plugin [@constraint]` adds more. + +Mirroring the platform additionally pulls the plugins that ship with it - +**`package`** and **`system`** - unconditionally, with no module pairing. A +version still has to be one the mirrored platform can run, and a registry that +does not publish them yields a warning rather than a failed pull. + +Their dependencies come along as usual, including ones named after a built-in d8 +command (`package` depends on **`delivery-kit`**): the plugin is mirrored when the +registry has it, and the built-in command covers the dependency when it does not. After `d8 mirror push`, the plugins live at `/deckhouse-cli/plugins/` - exactly where the in-cluster registry-packages-proxy looks - so `d8 plugins install ` works in the air-gapped cluster with no extra setup. See @@ -146,7 +156,7 @@ is not supported. To see what a registry offers, use | `--rpp-ca-file` | `D8_RPP_CA_FILE` | PEM CA bundle to verify the proxy TLS certificate | | `--insecure-skip-tls-verify` | - | skip TLS verification of both the API server and the proxy (debugging only) | | `--version X` *(install only)* | - | install an exact version; may be a pre-release | -| `--use-major N` *(install, update)* | - | cross to major `N`; by default operations stay within the installed major | +| `--use-major N` *(install only)* | - | cross to major `N`; by default operations stay within the installed major | | `--force` *(install only)* | - | reinstall even if already current (re-pull and re-verify) | The persistent flags above are shared by every `d8 dist plugins` subcommand diff --git a/internal/dist/cmd/status.go b/internal/dist/cmd/status.go index 014b8bea..6db8c3ab 100644 --- a/internal/dist/cmd/status.go +++ b/internal/dist/cmd/status.go @@ -239,7 +239,7 @@ func writePluginsSection(b *strings.Builder, d summaryData) { } if outdated { - fmt.Fprintf(b, "%s\n", sumDim("Update a plugin with 'd8 dist plugins update ' or 'd8 dist plugins update all'.")) + fmt.Fprintf(b, "%s\n", sumDim("Update a plugin with 'd8 dist plugins install ' or 'd8 dist plugins install --all'.")) } } @@ -254,16 +254,8 @@ func latestCell(p pluginRow) string { // installedPluginsRoot reports the plugins root that actually holds an // install: the configured root, or the home fallback (~/.deckhouse-cli) - -// the same resolution `plugins update all` uses. ok=false means no plugins -// are installed anywhere. +// the same resolution `plugins install --all` and the root command's plugin +// override use. ok=false means no plugins are installed anywhere. func installedPluginsRoot() (string, bool) { - if layout.RootHasInstall(pluginflags.DeckhousePluginsDir) { - return pluginflags.DeckhousePluginsDir, true - } - - if fallback, err := layout.HomeFallbackPath(); err == nil && layout.RootHasInstall(fallback) { - return fallback, true - } - - return "", false + return layout.ResolveInstallRoot(pluginflags.DeckhousePluginsDir) } diff --git a/internal/dist/cmd/status_test.go b/internal/dist/cmd/status_test.go index 5bf335a1..a793fb4b 100644 --- a/internal/dist/cmd/status_test.go +++ b/internal/dist/cmd/status_test.go @@ -45,7 +45,7 @@ func TestRenderSummaryFull(t *testing.T) { " NAME VERSION LATEST STATUS", " dk 0.5.1 0.5.1 up to date", " system 1.2.0 1.3.0 update available", - "Update a plugin with 'd8 dist plugins update ' or 'd8 dist plugins update all'.", + "Update a plugin with 'd8 dist plugins install ' or 'd8 dist plugins install --all'.", "", }, "\n"), out) } diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index 2f1da359..85e6bc03 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -398,10 +398,11 @@ If the source registry has no `packages` repository (some public/CE registries), d8 CLI plugins are standalone binaries the CLI installs through the in-cluster registry-packages-proxy. A plugin declares its requirements (Deckhouse modules, other plugins, platform versions) in a contract - a base64-JSON annotation on its image manifest. The plugins phase runs **last**, after modules and packages, because it resolves against what the earlier phases actually put into the bundle. -Selection principle: **nothing extra**. A plugin enters the bundle only when: +Selection principle: **nothing extra**, with one standing exception. A plugin enters the bundle when: +- **the platform is mirrored** - `package` and `system` ship with the platform rather than with any module, so mirroring the platform mirrors them too, unconditionally and regardless of `--include-plugin`. A bundle without them can install the platform but not operate it. The version still has to be one the bundled platform can run (its `deckhouse` constraint is checked like any other), and a registry that does not publish them, or denies access, yields a warning rather than a failed pull. The list lives in `PlatformPlugins` (`internal/mirror/plugins/resolver.go`); - a mirrored module needs it - the plugin's contract names that module in its `mandatory` or `anyOf` requirements. For **each bundled version** of the module, the newest plugin version whose contract the bundle satisfies is picked (so a bundle carrying module v1.0.0 and v1.5.0 may get two plugin versions, deduplicated); -- it is a mandatory plugin dependency of another selected plugin (resolved recursively; a version already picked is shared when it satisfies the constraint); +- it is a mandatory plugin dependency of another selected plugin (resolved recursively; a version already picked is shared when it satisfies the constraint). A dependency whose name matches a **built-in d8 command** (`delivery-kit`, `package`) is mirrored too when the registry publishes it - the built-in is only the fallback, because an air-gapped cluster cannot fetch the plugin later and, once installed, it takes the command over. When it is not published the built-in satisfies the dependency and only a note is recorded, so the dependent is never dropped over it; - the user names it with `--include-plugin` (additive; an unmet explicit include fails the pull, unlike the automatic selection which skips with a reason in the summary). Registry layout (at the **bare root**, outside the edition segment - like the installer): diff --git a/internal/mirror/cmd/pull/summary.go b/internal/mirror/cmd/pull/summary.go index 197dcb61..7504f4ae 100644 --- a/internal/mirror/cmd/pull/summary.go +++ b/internal/mirror/cmd/pull/summary.go @@ -398,6 +398,10 @@ func writePlugins(b *strings.Builder, p mirror.PluginsStats, verbose bool) { parts := []string{cCount(fmt.Sprint(len(p.Plugins)))} counts := countPluginProvenance(p.Plugins) + if counts.withPlatform > 0 { + parts = append(parts, cDim(fmt.Sprintf("%d with the platform", counts.withPlatform))) + } + if counts.forModules > 0 { parts = append(parts, cDim(fmt.Sprintf("%d for modules", counts.forModules))) } @@ -432,14 +436,15 @@ func writePlugins(b *strings.Builder, p mirror.PluginsStats, verbose bool) { // pluginProvenanceCounts is the per-category tally of the aggregate line. type pluginProvenanceCounts struct { + withPlatform int forModules int dependencies int explicit int } // countPluginProvenance counts each plugin once by its strongest provenance: -// serving a mirrored module beats an explicit include, which beats being -// someone's dependency. +// shipping with the platform beats serving a mirrored module, which beats an +// explicit include, which beats being someone's dependency. func countPluginProvenance(plugins []mirror.PluginStat) pluginProvenanceCounts { var counts pluginProvenanceCounts @@ -447,6 +452,8 @@ func countPluginProvenance(plugins []mirror.PluginStat) pluginProvenanceCounts { provenance := pluginProvenance(plugin) switch { + case provenance.platform: + counts.withPlatform++ case len(provenance.modules) > 0: counts.forModules++ case provenance.explicit: @@ -466,6 +473,9 @@ type pluginProvenanceInfo struct { modules []string dependents []string explicit bool + // platform marks a plugin that ships with the platform, pulled because the + // platform was mirrored rather than because anything asked for it. + platform bool } func pluginProvenance(plugin mirror.PluginStat) pluginProvenanceInfo { @@ -491,6 +501,8 @@ func pluginProvenance(plugin mirror.PluginStat) pluginProvenanceInfo { } case "explicit": info.explicit = true + case "platform": + info.platform = true } } } @@ -512,6 +524,9 @@ type pluginTreeNode struct { // dependency plugins nested under their dependents and explicitly included // plugins in their own group. e.g.: // +// ║ platform +// ║ package [v0.0.34] +// ║ system [v1.2.0] // ║ postgresql // ║ postgresql-mgr [v1.1.0, v1.2.0] // ║ └ db-connector [v0.9.1] (dependency) @@ -543,6 +558,8 @@ func writePluginsTree(b *strings.Builder, plugins []mirror.PluginStat) { provenance := pluginProvenance(plugin) switch { + case provenance.platform: + addToGroup("platform", node) case len(provenance.modules) > 0: if len(provenance.modules) > 1 { node.note = cDim("(also for " + strings.Join(provenance.modules[1:], ", ") + ")") @@ -564,9 +581,10 @@ func writePluginsTree(b *strings.Builder, plugins []mirror.PluginStat) { } } - // Module groups sort alphabetically; the pseudo-groups (explicit, - // dependency orphans) always render after them. - pseudo := map[string]int{"dependencies": 1, "explicit": 2, "other": 3} + // Module groups sort alphabetically between the pseudo-groups: "platform" + // leads (those plugins are in every bundle that carries the platform), the + // rest (explicit, dependency orphans) always render after the modules. + pseudo := map[string]int{"platform": -1, "dependencies": 1, "explicit": 2, "other": 3} sort.Slice(groupNames, func(i, j int) bool { pi, pj := pseudo[groupNames[i]], pseudo[groupNames[j]] diff --git a/internal/mirror/cmd/pull/summary_test.go b/internal/mirror/cmd/pull/summary_test.go index a672a68d..434d34b9 100644 --- a/internal/mirror/cmd/pull/summary_test.go +++ b/internal/mirror/cmd/pull/summary_test.go @@ -505,6 +505,14 @@ func TestRenderPullSummary_Plugins(t *testing.T) { pluginsStats := mirror.PluginsStats{ Attempted: true, Plugins: []mirror.PluginStat{ + { + Name: "package", + Images: 1, + Versions: []mirror.PluginVersionStat{{ + Version: "v0.0.34", + Reasons: []mirror.PluginReason{{Kind: "platform", Subject: "platform"}}, + }}, + }, { Name: "db-connector", Images: 1, @@ -536,7 +544,7 @@ func TestRenderPullSummary_Plugins(t *testing.T) { Warnings: []string{ `plugin velero-helper@v0.3.0 (explicitly included): requires module "velero" which is not in the bundle; the target cluster must provide it`, }, - TotalImages: 4, + TotalImages: 5, } base := func() *mirror.PullSummary { @@ -554,6 +562,7 @@ func TestRenderPullSummary_Plugins(t *testing.T) { out := renderPullSummary(base(), false) require.Contains(t, out, "Plugins:") + require.Contains(t, out, "1 with the platform") require.Contains(t, out, "1 for modules") require.Contains(t, out, "1 dependency") require.Contains(t, out, "1 explicit") @@ -749,3 +758,47 @@ func TestPhysicalFileCount(t *testing.T) { }) } } + +// TestRenderPullSummary_PlatformPluginGroup: plugins that ship with the platform get +// their own tree group, rendered before the module groups - they are in every bundle +// that carries the platform, so they are not "other". +func TestRenderPullSummary_PlatformPluginGroup(t *testing.T) { + color.NoColor = true + + summary := &mirror.PullSummary{ + Elapsed: time.Minute, + Platform: mirror.ComponentStats{Attempted: true}, + Security: mirror.SecurityStats{Attempted: true, Available: true}, + Modules: mirror.ModulesStats{Attempted: true}, + Packages: mirror.PackagesStats{Attempted: true}, + Plugins: mirror.PluginsStats{ + Attempted: true, + Plugins: []mirror.PluginStat{ + { + Name: "system", + Images: 1, + Versions: []mirror.PluginVersionStat{{ + Version: "v1.2.0", + Reasons: []mirror.PluginReason{{Kind: "platform", Subject: "platform"}}, + }}, + }, + { + Name: "postgresql-mgr", + Images: 1, + Versions: []mirror.PluginVersionStat{{ + Version: "v1.2.0", + Reasons: []mirror.PluginReason{{Kind: "module", Subject: "postgresql", Constraint: ">=1.5.0"}}, + }}, + }, + }, + TotalImages: 2, + }, + } + + out := renderPullSummary(summary, true) + + require.Contains(t, out, "platform") + require.Contains(t, out, "system") + require.Less(t, strings.Index(out, "platform\n"), strings.Index(out, "postgresql\n"), + "the platform group leads the module groups") +} diff --git a/internal/mirror/plugins/doc.go b/internal/mirror/plugins/doc.go index e94cbd25..b17e5441 100644 --- a/internal/mirror/plugins/doc.go +++ b/internal/mirror/plugins/doc.go @@ -27,8 +27,16 @@ limitations under the License. // manifest. Reading a contract is a single manifest fetch, so deciding WHAT // to mirror needs no layer downloads. // -// Selection principle: nothing extra. A plugin enters the bundle only when a -// mirrored module needs it (its contract names that module), when another -// selected plugin requires it, or when the user asks for it explicitly with -// --include-plugin. +// Selection principle: nothing extra, with one standing exception. A plugin +// enters the bundle when a mirrored module needs it (its contract names that +// module), when another selected plugin requires it, or when the user asks for +// it explicitly with --include-plugin. +// +// The exception is PlatformPlugins: they ship with the platform rather than +// with any module, so mirroring the platform mirrors them too, unconditionally. +// A bundle without them can install the platform but not operate it. +// +// A dependency whose name matches a built-in d8 command is mirrored when it is +// published and falls back to the built-in when it is not, so it can never block +// the bundle - see ResolveInput.Builtins. package plugins diff --git a/internal/mirror/plugins/resolver.go b/internal/mirror/plugins/resolver.go index b4fb6bed..12718465 100644 --- a/internal/mirror/plugins/resolver.go +++ b/internal/mirror/plugins/resolver.go @@ -39,6 +39,20 @@ import ( // planner: deeper chains are a contract-authoring error, not a real graph. const maxResolveDepth = 16 +// PlatformSubject is the Reason.Subject of a ReasonPlatform edge - the plugin was +// pulled because the platform was mirrored, not because any module asked for it. +const PlatformSubject = "platform" + +// PlatformPlugins ship with the Deckhouse platform rather than with a module, so +// mirroring the platform mirrors them too - unconditionally, with no module pairing +// and regardless of --include-plugin. Without them a bundle can install the platform +// but not operate it. +// +// The names match the built-in commands of the same name (see SystemPluginName and +// PackagePluginName in internal/plugins/cmd): a plugin takes the command over once +// installed, so the bundle must carry it. +var PlatformPlugins = []string{"package", "system"} + // resolver implements Resolver: for every bundled version of every mirrored // module it picks the newest plugin version the bundle satisfies, resolves // transitive mandatory plugin dependencies, and applies --include-plugin on @@ -70,6 +84,15 @@ func (r *resolver) Resolve(ctx context.Context, in ResolveInput) (*Resolution, e st.bundle[module.Name] = module.Versions } + // Platform plugins first: they are unconditional, so a later module- or + // dependency-driven edge lands on an already-selected version as extra + // provenance rather than selecting a second one. + if len(in.PlatformVersions) > 0 { + if err := st.resolvePlatform(ctx); err != nil { + return nil, err + } + } + if len(st.bundle) > 0 { if err := st.resolveAuto(ctx); err != nil { return nil, err @@ -150,6 +173,118 @@ func (w *warningLog) add(msg string) { w.messages = append(w.messages, msg) } +// resolvePlatform selects the plugins that ship with the platform. It runs whenever +// the platform phase mirrored something, independently of whether any module was +// mirrored: these plugins belong to the platform itself. +// +// A platform plugin missing from the registry is a warning, not a failure. The +// platform mirrored fine, older registries predate these plugins, and failing the +// whole pull over one absent repository would be worse than an incomplete bundle the +// operator is told about. +func (st *resolveState) resolvePlatform(ctx context.Context) error { + for _, name := range PlatformPlugins { + if st.in.NoCatalog { + // No version listing to select from (--proxy-registry). An exact + // --include-plugin pin is the only way in. + st.warnings.add(fmt.Sprintf( + "plugin %s ships with the platform but cannot be selected without a version listing; pin it with --include-plugin %s@=", + name, name)) + + continue + } + + versions, err := st.catalog.PluginVersions(ctx, name) + if err != nil { + if isUnavailable(err) { + st.warnings.add(fmt.Sprintf( + "plugin %s ships with the platform but is not available in this registry; the bundle will not contain it", name)) + + continue + } + + return fmt.Errorf("list versions of platform plugin %q: %w", name, err) + } + + if len(versions) == 0 { + st.warnings.add(fmt.Sprintf( + "plugin %s ships with the platform but has no published versions; the bundle will not contain it", name)) + + continue + } + + failure, err := st.selectForPlatform(ctx, name, versions) + if err != nil { + return err + } + + if failure != "" { + st.skipped = append(st.skipped, SkippedPlugin{Name: name, Reason: failure}) + } + } + + return nil +} + +// selectForPlatform picks the newest version of a platform plugin the bundle +// satisfies and commits it with its dependency closure. It mirrors +// selectForModuleVersion, except the gate is the bundle alone: a platform plugin is +// paired with no module, so there is no pairingGate to pass. +func (st *resolveState) selectForPlatform(ctx context.Context, name string, versions []*semver.Version) (string, error) { + var firstReject string + + for _, candidate := range versions { + contract, err := st.catalog.Contract(ctx, name, candidate) + if err != nil { + if errors.Is(err, ErrInvalidContract) { + noteReject(&firstReject, fmt.Sprintf("%s: broken published contract", candidate.Original())) + + continue + } + + return "", err + } + + if why := st.bundleGate(contract, ""); why != "" { + noteReject(&firstReject, fmt.Sprintf("%s: %s", candidate.Original(), why)) + + continue + } + + reason := Reason{Kind: ReasonPlatform, Subject: PlatformSubject} + + if sv := st.selected.version(name, candidate); sv != nil { + addReason(sv, reason) + + return "", nil + } + + delta := &selectionDelta{} + + why, err := st.resolveDeps(ctx, contract, delta, + map[pluginName]bool{name: true}, []string{name + "@" + candidate.Original()}, 0, true) + if err != nil { + return "", err + } + + if why != "" { + noteReject(&firstReject, fmt.Sprintf("%s: %s", candidate.Original(), why)) + + continue + } + + st.selected.commit(name, candidate, contract, reason) + st.applyDelta(delta) + + return "", nil + } + + if firstReject == "" { + firstReject = "no published versions" + } + + return firstReject, nil +} + // resolveAuto selects plugins for the bundle's modules: every plugin in the // catalog whose contract names a mirrored module is paired with each bundled // version of that module. @@ -557,51 +692,71 @@ func (st *resolveState) resolveDeps(ctx context.Context, contract *internal.Plug } for _, req := range contract.Requirements.Plugins.Mandatory { - if _, builtin := st.in.Builtins[req.Name]; builtin { - // Built-in d8 commands satisfy a same-named dependency by - // presence; there is nothing to pull. - continue + reject, err := st.resolveDep(ctx, req, delta, visited, path, depth, enforceGate) + if err != nil { + return "", err } - // The dependency name comes from a published contract - external - // data with no grammar of its own - and becomes a registry route - // and a filesystem path. - if err := pluginlayout.ValidatePluginName(req.Name); err != nil { - return fmt.Sprintf("dependency of %s: %v", path[len(path)-1], err), nil + if reject == "" { + continue } - if visited[req.Name] { - return fmt.Sprintf("dependency cycle: %s -> %s", strings.Join(path, " -> "), req.Name), nil + // A built-in d8 command of the same name satisfies the dependency by its + // mere presence, so it can never block the bundle. Mirroring the real + // plugin is still preferred - once installed it takes the command over, + // and an air-gapped cluster has no other way to get it - so the pull is + // attempted first and only its failure degrades to a note. + if _, builtin := st.in.Builtins[req.Name]; builtin { + st.warnings.add(fmt.Sprintf( + "%s depends on %s, which was not mirrored (%s); the built-in d8 command satisfies it", + path[len(path)-1], req.Name, reject)) + + continue } - var constraint *semver.Constraints + return reject, nil + } - if req.Constraint != "" { - parsed, err := semver.NewConstraint(req.Constraint) - if err != nil { - return fmt.Sprintf("invalid constraint %q for dependency %q", req.Constraint, req.Name), nil - } + return "", nil +} - constraint = parsed - } +// resolveDep resolves one mandatory dependency: guard the name and the cycle, +// reuse an already-picked satisfying version, otherwise pick a fresh one. A +// non-empty return is the reason this dependency could not be satisfied from the +// registry; the caller decides whether that is fatal. +func (st *resolveState) resolveDep(ctx context.Context, req internal.PluginRequirement, delta *selectionDelta, visited map[pluginName]bool, path []string, depth int, enforceGate bool) (string, error) { + // The dependency name comes from a published contract - external data with + // no grammar of its own - and becomes a registry route and a filesystem path. + if err := pluginlayout.ValidatePluginName(req.Name); err != nil { + return fmt.Sprintf("dependency of %s: %v", path[len(path)-1], err), nil + } - reason := Reason{Kind: ReasonDependency, Subject: path[len(path)-1], Constraint: req.Constraint} + if visited[req.Name] { + return fmt.Sprintf("dependency cycle: %s -> %s", strings.Join(path, " -> "), req.Name), nil + } - // Union-reuse: a version already picked for the bundle that satisfies - // this constraint is shared instead of adding another one. - if reused := st.findSatisfying(delta, req.Name, constraint); reused != "" { - delta.reasons = append(delta.reasons, deltaReason{name: req.Name, version: reused, reason: reason}) + var constraint *semver.Constraints - continue + if req.Constraint != "" { + parsed, err := semver.NewConstraint(req.Constraint) + if err != nil { + return fmt.Sprintf("invalid constraint %q for dependency %q", req.Constraint, req.Name), nil } - reject, err := st.resolveDepFresh(ctx, req, constraint, reason, delta, visited, path, depth, enforceGate) - if reject != "" || err != nil { - return reject, err - } + constraint = parsed } - return "", nil + reason := Reason{Kind: ReasonDependency, Subject: path[len(path)-1], Constraint: req.Constraint} + + // Union-reuse: a version already picked for the bundle that satisfies this + // constraint is shared instead of adding another one. + if reused := st.findSatisfying(delta, req.Name, constraint); reused != "" { + delta.reasons = append(delta.reasons, deltaReason{name: req.Name, version: reused, reason: reason}) + + return "", nil + } + + return st.resolveDepFresh(ctx, req, constraint, reason, delta, visited, path, depth, enforceGate) } // resolveDepFresh picks the newest version of one dependency that satisfies diff --git a/internal/mirror/plugins/resolver_test.go b/internal/mirror/plugins/resolver_test.go index 52ffd43b..34ded0f0 100644 --- a/internal/mirror/plugins/resolver_test.go +++ b/internal/mirror/plugins/resolver_test.go @@ -343,9 +343,9 @@ func TestResolve_DisjointDependencyConstraints(t *testing.T) { "disjoint constraints require two bundled versions of the dependency") } -// TestResolve_BuiltinDependency: a dependency on a built-in d8 command is -// satisfied by presence - nothing is pulled and the catalog is not asked. The -// stub has no "package" entry, so any lookup would fail the dependent. +// TestResolve_BuiltinDependency: a dependency on a built-in d8 command that the +// registry does not publish. The lookup fails, but the built-in satisfies the +// dependency, so the dependent still enters the bundle with only a note. func TestResolve_BuiltinDependency(t *testing.T) { stub := newStub(). add("packer", "v1.0.0", needsPlugin(needsModule(plug("packer", "v1.0.0"), "m1", ""), "package", "")) @@ -356,8 +356,9 @@ func TestResolve_BuiltinDependency(t *testing.T) { }) assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "packer")) - assert.Nil(t, selectedVersions(res, "package"), "built-ins are never pulled") - assert.Empty(t, res.Skipped) + assert.Nil(t, selectedVersions(res, "package"), "nothing to pull: it is not published") + assert.Empty(t, res.Skipped, "the built-in command satisfies it, so the dependent is not skipped") + assert.Contains(t, res.Warnings[0], "built-in d8 command satisfies it") } // TestResolve_DependencyCycleSkips: a cycle in mandatory dependencies rejects @@ -1013,3 +1014,213 @@ func TestResolve_DeniedExplicitIncludeFails(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "UNAUTHORIZED") } + +// ---- platform plugins ---- + +// TestResolve_PlatformPullsItsPlugins is the requirement: mirroring the platform +// mirrors the plugins that ship with it, with no module involved at all. +func TestResolve_PlatformPullsItsPlugins(t *testing.T) { + stub := newStub(). + add("package", "v0.0.34", plug("package", "v0.0.34")). + add("system", "v1.2.0", plug("system", "v1.2.0")) + + res := resolve(t, stub, ResolveInput{PlatformVersions: semvers("v1.66.0")}) + + assert.Equal(t, []string{"v0.0.34"}, selectedVersions(res, "package")) + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "system")) + assert.Empty(t, res.Skipped) + + sv := selectedVersion(t, res, "package", "v0.0.34") + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonPlatform, Subject: PlatformSubject}) +} + +// TestResolve_PlatformPluginsNeedThePlatform: without a mirrored platform there is +// nothing to ship them with, so they are not pulled just for existing. +func TestResolve_PlatformPluginsNeedThePlatform(t *testing.T) { + stub := newStub(). + add("package", "v0.0.34", plug("package", "v0.0.34")). + add("system", "v1.2.0", plug("system", "v1.2.0")) + + res := resolve(t, stub, ResolveInput{Modules: []ModuleInBundle{mod("postgresql", "v1.0.0")}}) + + assert.Empty(t, selectedVersions(res, "package")) + assert.Empty(t, selectedVersions(res, "system")) +} + +// TestResolve_PlatformPluginHonoursDeckhouseConstraint: "always pulled" still means +// a version the mirrored platform can run, so a too-new one is passed over. +func TestResolve_PlatformPluginHonoursDeckhouseConstraint(t *testing.T) { + stub := newStub(). + add("package", "v2.0.0", needsDeckhouse(plug("package", "v2.0.0"), ">=1.70.0")). + add("package", "v1.0.0", needsDeckhouse(plug("package", "v1.0.0"), ">=1.60.0")). + add("system", "v1.0.0", plug("system", "v1.0.0")) + + res := resolve(t, stub, ResolveInput{PlatformVersions: semvers("v1.66.0")}) + + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "package"), + "v2.0.0 requires a newer platform than the bundle carries") +} + +// TestResolve_PlatformPluginMissingIsWarnedNotFatal: an older registry may not +// publish these plugins at all. The platform still mirrored, so the pull continues +// and the operator is told what the bundle lacks. +func TestResolve_PlatformPluginMissingIsWarnedNotFatal(t *testing.T) { + stub := newStub().add("system", "v1.2.0", plug("system", "v1.2.0")) + + res := resolve(t, stub, ResolveInput{PlatformVersions: semvers("v1.66.0")}) + + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "system"), "the available one is still pulled") + assert.Empty(t, selectedVersions(res, "package")) + assert.Contains(t, res.Warnings[0], "package") + assert.Contains(t, res.Warnings[0], "not available in this registry") +} + +// TestResolve_PlatformPluginDeniedIsWarnedNotFatal: a token-auth registry refusing +// the repository is indistinguishable from an absent one and must not fail the pull. +func TestResolve_PlatformPluginDeniedIsWarnedNotFatal(t *testing.T) { + stub := newStub(). + add("system", "v1.2.0", plug("system", "v1.2.0")). + denyRepository("package") + + res := resolve(t, stub, ResolveInput{PlatformVersions: semvers("v1.66.0")}) + + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "system")) + assert.Contains(t, res.Warnings[0], "package") +} + +// TestResolve_PlatformPluginPullsDependencies: a platform plugin drags its mandatory +// plugin dependencies in, exactly like a module-driven one. +func TestResolve_PlatformPluginPullsDependencies(t *testing.T) { + stub := newStub(). + add("package", "v0.0.34", needsPlugin(plug("package", "v0.0.34"), "db-connector", ">=0.9.0")). + add("system", "v1.2.0", plug("system", "v1.2.0")). + add("db-connector", "v0.9.1", plug("db-connector", "v0.9.1")) + + res := resolve(t, stub, ResolveInput{PlatformVersions: semvers("v1.66.0")}) + + assert.Equal(t, []string{"v0.9.1"}, selectedVersions(res, "db-connector")) + + sv := selectedVersion(t, res, "db-connector", "v0.9.1") + assert.Equal(t, ReasonDependency, sv.Reasons[0].Kind) +} + +// TestResolve_PlatformAndModuleReasonsMerge: when a module also pulls a platform +// plugin, the bundle carries one version with both provenance edges, not two entries. +func TestResolve_PlatformAndModuleReasonsMerge(t *testing.T) { + stub := newStub(). + add("package", "v0.0.34", plug("package", "v0.0.34")). + add("system", "v1.2.0", needsModule(plug("system", "v1.2.0"), "postgresql", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + PlatformVersions: semvers("v1.66.0"), + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "system"), "one version, not one per reason") + + sv := selectedVersion(t, res, "system", "v1.2.0") + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonPlatform, Subject: PlatformSubject}) + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonModule, Subject: "postgresql", Constraint: ">=1.0.0"}) +} + +// TestResolve_PlatformPluginsNeedAVersionListing: --proxy-registry serves none, so +// the selection cannot run and the operator is pointed at the exact-pin escape. +func TestResolve_PlatformPluginsNeedAVersionListing(t *testing.T) { + stub := newStub().add("package", "v0.0.34", plug("package", "v0.0.34")) + + res := resolve(t, stub, ResolveInput{PlatformVersions: semvers("v1.66.0"), NoCatalog: true}) + + assert.Empty(t, selectedVersions(res, "package")) + assert.Contains(t, res.Warnings[0], "--include-plugin package@=") +} + +// TestReasonKindPlatformString keeps the summary label stable. +func TestReasonKindPlatformString(t *testing.T) { + assert.Equal(t, "platform", ReasonPlatform.String()) +} + +// TestResolve_BuiltinDependencyIsMirroredWhenPublished: a built-in command is only +// the fallback. When the registry publishes the plugin, the bundle gets it - an +// air-gapped cluster cannot fetch it later, and once installed it takes the command +// over from the built-in. +func TestResolve_BuiltinDependencyIsMirroredWhenPublished(t *testing.T) { + stub := newStub(). + add("packer", "v1.0.0", needsPlugin(needsModule(plug("packer", "v1.0.0"), "m1", ""), "delivery-kit", ">=2.0.0")). + add("delivery-kit", "v2.1.0", plug("delivery-kit", "v2.1.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + Builtins: map[string]struct{}{"delivery-kit": {}}, + }) + + assert.Equal(t, []string{"v2.1.0"}, selectedVersions(res, "delivery-kit")) + assert.Empty(t, res.Warnings, "it was mirrored, so there is nothing to note") + + sv := selectedVersion(t, res, "delivery-kit", "v2.1.0") + assert.Equal(t, ReasonDependency, sv.Reasons[0].Kind) +} + +// TestResolve_PlatformPullsPackageSystemAndDeliveryKit is the end-to-end shape the +// bundle must have: mirroring the platform brings package and system, and package's +// own delivery-kit dependency comes along even though a built-in command of that +// name exists. +func TestResolve_PlatformPullsPackageSystemAndDeliveryKit(t *testing.T) { + stub := newStub(). + add("package", "v0.0.34", needsPlugin(plug("package", "v0.0.34"), "delivery-kit", ">=2.0.0")). + add("system", "v1.2.0", plug("system", "v1.2.0")). + add("delivery-kit", "v2.1.0", plug("delivery-kit", "v2.1.0")) + + res := resolve(t, stub, ResolveInput{ + PlatformVersions: semvers("v1.66.0"), + Builtins: map[string]struct{}{"delivery-kit": {}, "package": {}}, + }) + + assert.Equal(t, []string{"v0.0.34"}, selectedVersions(res, "package")) + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "system")) + assert.Equal(t, []string{"v2.1.0"}, selectedVersions(res, "delivery-kit")) + assert.Empty(t, res.Skipped) + assert.Empty(t, res.Warnings) + + assert.Contains(t, selectedVersion(t, res, "package", "v0.0.34").Reasons, + Reason{Kind: ReasonPlatform, Subject: PlatformSubject}) + assert.Contains(t, selectedVersion(t, res, "delivery-kit", "v2.1.0").Reasons, + Reason{Kind: ReasonDependency, Subject: "package@v0.0.34", Constraint: ">=2.0.0"}) +} + +// TestResolve_PlatformPluginKeepsGoingWithoutDeliveryKit: "if it is available" cuts +// both ways - an unpublished delivery-kit must not cost the bundle its package +// plugin, because the built-in command still satisfies the dependency. +func TestResolve_PlatformPluginKeepsGoingWithoutDeliveryKit(t *testing.T) { + stub := newStub(). + add("package", "v0.0.34", needsPlugin(plug("package", "v0.0.34"), "delivery-kit", ">=2.0.0")). + add("system", "v1.2.0", plug("system", "v1.2.0")) + + res := resolve(t, stub, ResolveInput{ + PlatformVersions: semvers("v1.66.0"), + Builtins: map[string]struct{}{"delivery-kit": {}, "package": {}}, + }) + + assert.Equal(t, []string{"v0.0.34"}, selectedVersions(res, "package"), "still mirrored") + assert.Empty(t, selectedVersions(res, "delivery-kit")) + assert.Empty(t, res.Skipped) + assert.Contains(t, res.Warnings[0], "delivery-kit") + assert.Contains(t, res.Warnings[0], "built-in d8 command satisfies it") +} + +// TestResolve_NonBuiltinDependencyStillFatal: the fallback is for built-ins only. +// An ordinary missing dependency still rejects its dependent. +func TestResolve_NonBuiltinDependencyStillFatal(t *testing.T) { + stub := newStub(). + add("package", "v0.0.34", needsPlugin(plug("package", "v0.0.34"), "db-connector", ">=1.0.0")). + add("system", "v1.2.0", plug("system", "v1.2.0")) + + res := resolve(t, stub, ResolveInput{ + PlatformVersions: semvers("v1.66.0"), + Builtins: map[string]struct{}{"delivery-kit": {}}, + }) + + assert.Empty(t, selectedVersions(res, "package")) + require.Len(t, res.Skipped, 1) + assert.Equal(t, "package", res.Skipped[0].Name) + assert.Contains(t, res.Skipped[0].Reason, "db-connector") +} diff --git a/internal/mirror/plugins/types.go b/internal/mirror/plugins/types.go index ecd25a5d..564391a4 100644 --- a/internal/mirror/plugins/types.go +++ b/internal/mirror/plugins/types.go @@ -60,8 +60,10 @@ type ResolveInput struct { // additive: they are pulled on top of the module-driven selection. Filter *modules.Filter // Builtins are d8 built-in command names (e.g. delivery-kit, package) - // that satisfy a same-named plugin dependency by presence. They are - // never pulled. + // that satisfy a same-named plugin dependency by presence, so such a + // dependency never blocks the bundle. The plugin is still mirrored when + // the registry publishes it - once installed it takes the command over, + // and an air-gapped cluster has no other way to obtain it. Builtins map[string]struct{} // NoCatalog means the registry serves no plugin version listing // (--proxy-registry). Dependencies then resolve only against versions @@ -82,6 +84,10 @@ const ( // ReasonExplicit marks a plugin named by --include-plugin. // Reason.Subject is the flag expression. ReasonExplicit + // ReasonPlatform marks a plugin that ships with the platform and is + // therefore pulled whenever the platform is mirrored, with no module + // pairing. Reason.Subject is PlatformSubject. + ReasonPlatform ) // String returns the stable lowercase label of the kind, used by the pull @@ -94,6 +100,8 @@ func (k ReasonKind) String() string { return "dependency" case ReasonExplicit: return "explicit" + case ReasonPlatform: + return "platform" default: return "unknown" } diff --git a/internal/plugins/README.md b/internal/plugins/README.md index effff235..7af799a2 100644 --- a/internal/plugins/README.md +++ b/internal/plugins/README.md @@ -20,22 +20,52 @@ machinery/commands split `internal/selfupdate` / `internal/dist/cmd` uses. | Command | What it does | |---|---| -| `d8 dist plugins install [--version X] [--use-major N] [--force]` | install or switch a plugin version | -| `d8 dist plugins update [--use-major N]` | update to the newest cluster-compatible version within the current major | -| `d8 dist plugins update all` | the same for every installed plugin | -| `d8 dist plugins list` | list installed plugins (the proxy serves no catalog, so available plugins cannot be listed) | -| `d8 dist plugins versions ` | list all published versions of one plugin (installed one marked; same verb as `d8 dist versions`) | +| `d8 dist plugins install [--version X] [--use-major N] [--force]` | install a plugin, switch its version, or update it - installing one that is already present updates it to the newest cluster-compatible version within its current major | +| `d8 dist plugins install --all [--force]` | the same for every installed plugin at once, each within its own major. Rejects `--version` and `--use-major`, which pin a single plugin | +| `d8 dist plugins list` | the plugins installed on disk, plus - only on a transport that can enumerate, i.e. `--source` - those published in the registry and ready to install | +| `d8 dist plugins versions ` | list all published versions of one plugin (installed one marked; same verb as `d8 dist versions`). A release is published one tag per platform; those are collapsed into one line per version listing the platforms it was built for | | `d8 dist plugins contract ` | show a plugin's contract | | `d8 dist plugins remove ` | remove an installed plugin | -| `d8 ...` *(wrapper, with `DECKHOUSE_PLUGINS_ENABLED=true`)* | run an installed plugin; auto-installs it on first use | +| `d8 ...` *(wrapper)* | run an installed plugin; auto-installs it on first use | -## Plugin source +## Overriding a built-in command -The `pluginSource` interface (`source.go`) has two implementations, chosen in -`InitPluginServices` (`init.go`) by whether the hidden `--source` flag is set: +A handful of top-level commands are **overridable**: `delivery-kit`, `data`, `snapshot`, `iam`, `network`, `v`, `stronghold`, `package` and `system`. Install a plugin named exactly like one of them and it takes the command over; with no such plugin installed, the built-in implementation serves it. The table lives in `overridableCommands` (`cmd/d8/root.go`). -- **`rppPluginSource` (`rpp_source.go`) - the default and only supported - source.** Plugins are pulled through the in-cluster registry-packages-proxy +Details worth knowing: + +- **Already-installed only.** The override never reaches the registry: a plugin that exists upstream but is not installed locally does not displace a built-in, so `d8` starts with no network access and no surprises. Auto-install on first use therefore applies only to plugins that are *not* shadowing a built-in. +- **Canonical names only.** A plugin must match the command's own name, never one of its aliases - there is no `dk` plugin, only `delivery-kit`. The built-in's aliases carry over to the wrapper, so `d8 dk` and `d8 s` keep working after an override. +- **Resolved before flag parsing.** Which commands get registered is decided at startup, so `--plugins-dir` cannot influence it; only the `DECKHOUSE_CLI_PATH` env var can. Both the configured root and the `~/.deckhouse-cli` fallback are searched. +- **Dependency bookkeeping.** `delivery-kit` and `package` satisfy a plugin's dependency on that name while they ship as built-ins. Once a plugin takes one over, the name drops off that list and the dependency resolves against the real plugin, version constraints included. + +## Transports + +Plugins reach the registry over one of two transports, chosen in +`InitPluginServices` (`init.go`) by whether the hidden `--source` flag is set. Both +implement `pluginSource` (`source.go`) and report which one they are via +`Transport()`, so a message can name the transport instead of leaving it implicit. + +They differ in one capability, expressed as the separate `pluginCatalog` interface: + +| | `TransportRPP` | `TransportRegistry` | +|---|---|---| +| selected by | default | `--source` | +| credentials | none (kubeconfig identity) | registry login / license | +| install, update, versions, contract (by exact name) | yes | yes | +| **enumerate published plugins** (`pluginCatalog`) | **no** | yes | +| cluster-side requirement checks | enforced | force-skipped | + +Enumeration is a property of the transport, not a runtime failure. The catalog is +the tag list of the `deckhouse-cli/plugins` repository, and the proxy allowlist +(`isAllowedCLIImagePath` in the Deckhouse repo) admits only `deckhouse-cli` and +`deckhouse-cli/plugins/`, explicitly refusing the bare `deckhouse-cli/plugins` +path. So `rppPluginSource` does not implement `pluginCatalog` and never issues the +request; `AvailablePlugins` returns `ErrCatalogUnsupported` naming the transport, and +`d8 dist plugins list` prints its installed half as usual. + +- **`rppPluginSource` (`rpp_source.go`) - `TransportRPP`, the default and only + supported source.** Plugins are pulled through the in-cluster registry-packages-proxy using the **kubeconfig identity**, with no registry credentials on the user side (ADR #386: deckhouse-cli reaches the registry exclusively through the proxy, so every command needs a reachable cluster). See @@ -44,8 +74,8 @@ The `pluginSource` interface (`source.go`) has two implementations, chosen in ClusterRole as self-update, because both travel the `/v1/images/` route. The plugin routes are `/v1/images/deckhouse-cli/plugins//{tags,manifests/,images/}`. -- **`registryPluginSource` (`source_legacy.go`) - a temporary, hidden `--source` - bypass.** It pulls straight from a registry repo with go-containerregistry, +- **`registryPluginSource` (`source_legacy.go`) - `TransportRegistry`, a temporary, + hidden `--source` bypass.** It pulls straight from a registry repo with go-containerregistry, skipping the proxy and the cluster, and force-sets `--skip-cluster-checks`. It exists for pre-#386 workflows and is documented for removal (grep marker `legacy --source`). @@ -169,8 +199,9 @@ them - install/update work as usual. See `internal/mirror/README.MD` ## Boundaries and deliberate decisions -- Listing the full plugin catalog over RPP is not supported (the proxy has no - catalog endpoint); install/update by name works. +- Enumerating the published plugins works only over `TransportRegistry`; the proxy + allowlist refuses the plugins-repository path, so `list` reports that half as + unsupported and still prints the installed half. See **Transports** above. - Idempotency compares the version reported by the binary itself; a plugin that prints a non-semver banner is re-pulled on every explicit `update`. - Dependency resolution is dry-run during selection (a candidate whose chain @@ -192,7 +223,7 @@ them - install/update work as usual. See `internal/mirror/README.MD` | `install.go` | the install pipeline: lock, staged download, smoke, atomic swap, idempotency | | `select.go` | newest-compatible version selection, contract memoization | | `planner.go` | plugin-to-plugin dependency resolution: constraint-aware planning, conflict/cycle/depth guards, upgrade-only | -| `update.go` | `UpdateAll`, installed-plugin discovery, home-fallback switch | +| `update.go` | `UpdateAll` (behind `install --all`), installed-plugin discovery, home-fallback switch | | `remove.go` | `Remove` / `RemoveAll` | | `validators.go` | plugin-to-plugin requirement checks + the Manager glue over `requirements/` (snapshot cache, kubeconfig clients, `--skip-cluster-checks`) | | `requirements/` | cluster-side requirements: the one-shot cluster snapshot (k8s / Deckhouse / modules) and the named checks against it | diff --git a/internal/plugins/cmd/doc.go b/internal/plugins/cmd/doc.go index ee29c4fa..1679ee13 100644 --- a/internal/plugins/cmd/doc.go +++ b/internal/plugins/cmd/doc.go @@ -20,8 +20,7 @@ limitations under the License. // d8 dist plugins list list installed plugins // d8 dist plugins versions list published versions of a plugin // d8 dist plugins contract show a plugin's contract -// d8 dist plugins install install a plugin -// d8 dist plugins update update installed plugins +// d8 dist plugins install install a plugin, or update one (--all: every installed) // d8 dist plugins remove remove an installed plugin // // The subtree is mounted under `d8 dist` (internal/dist/cmd) and inherits the diff --git a/internal/plugins/cmd/install.go b/internal/plugins/cmd/install.go index aa42aed1..e9748eb2 100644 --- a/internal/plugins/cmd/install.go +++ b/internal/plugins/cmd/install.go @@ -17,6 +17,9 @@ limitations under the License. package pluginscmd import ( + "errors" + "fmt" + "github.com/spf13/cobra" "github.com/deckhouse/deckhouse-cli/internal/plugins" @@ -27,21 +30,30 @@ func newInstallCommand(manager *plugins.Manager) *cobra.Command { version string useMajor int force bool + all bool ) cmd := &cobra.Command{ - Use: "install ", - Short: "Install a Deckhouse CLI plugin", + Use: "install | --all", + Short: "Install or update Deckhouse CLI plugins", Long: "Install a plugin: the newest version compatible with this cluster by default,\n" + "an exact one with --version.\n\n" + + "Installing a plugin that is already present updates it, so this is also how a\n" + + "plugin is brought up to date; --all does that for every installed plugin at once,\n" + + "each within its own current major.\n\n" + "Plugins this one depends on are installed/upgraded automatically. With --use-major\n" + "dependencies may also cross their own major to satisfy a constraint.\n\n" + "A version already on disk is activated by repointing the 'current' symlink -\n" + "no download. Plugin requirements are always checked before the switch.", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - pluginName := args[0] - ctx := cmd.Context() + if err := validateInstallArgs(cmd, args, all); err != nil { + return err + } + + if all { + return runInstallAll(cmd, manager, force) + } opts := []plugins.InstallOption{ plugins.InstallWithVersion(version), @@ -52,13 +64,61 @@ func newInstallCommand(manager *plugins.Manager) *cobra.Command { opts = append(opts, plugins.InstallWithForce()) } - return manager.InstallPlugin(ctx, pluginName, opts...) + return manager.InstallPlugin(cmd.Context(), args[0], opts...) }, } cmd.Flags().StringVar(&version, "version", "", "Exact version to install. Skips compatibility selection and may install a pre-release.") - cmd.Flags().IntVar(&useMajor, "use-major", -1, "Pin to a specific major version. By default an install/update stays within the installed plugin's major; pass this to cross majors (dependencies may cross theirs too).") + cmd.Flags().IntVar(&useMajor, "use-major", -1, "Pin to a specific major version. By default an install stays within the installed plugin's major; pass this to cross majors (dependencies may cross theirs too).") cmd.Flags().BoolVar(&force, "force", false, "Reinstall even if the selected version is already installed (re-pull + re-verify).") + cmd.Flags().BoolVar(&all, "all", false, "Update every installed plugin instead of naming one, each within its own current major.") return cmd } + +// validateInstallArgs rejects the combinations that cannot mean anything, rather +// than letting them resolve to a silent surprise: --all takes no plugin name, and +// the options that pin one plugin to one version cannot apply to a whole set. +func validateInstallArgs(cmd *cobra.Command, args []string, all bool) error { + if !all { + if len(args) == 0 { + return errors.New("provide a plugin name, or --all to update every installed plugin") + } + + return nil + } + + if len(args) > 0 { + return fmt.Errorf("--all updates every installed plugin and takes no plugin name (got %q)", args[0]) + } + + for _, flag := range []string{"version", "use-major"} { + if cmd.Flags().Changed(flag) { + return fmt.Errorf("--%s pins a single plugin and cannot be combined with --all", flag) + } + } + + return nil +} + +// runInstallAll updates every installed plugin. A per-plugin failure is reported by +// the manager as it happens and does not stop the rest, so the error here means at +// least one failed - the successes still stand. +func runInstallAll(cmd *cobra.Command, manager *plugins.Manager, force bool) error { + out := cmd.OutOrStdout() + + fmt.Fprintln(out, "Updating all installed plugins...") + + var opts []plugins.InstallOption + if force { + opts = append(opts, plugins.InstallWithForce()) + } + + if err := manager.UpdateAll(cmd.Context(), opts...); err != nil { + return err + } + + fmt.Fprintln(out, "✓ All plugins updated successfully!") + + return nil +} diff --git a/internal/plugins/cmd/install_test.go b/internal/plugins/cmd/install_test.go new file mode 100644 index 00000000..c8aaf280 --- /dev/null +++ b/internal/plugins/cmd/install_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pluginscmd + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// installFlagSet mirrors the flags newInstallCommand registers, so validation can +// be exercised without building a Manager (which would want a plugins root). +func installFlagSet(t *testing.T, changed ...string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: "install"} + cmd.Flags().String("version", "", "") + cmd.Flags().Int("use-major", -1, "") + cmd.Flags().Bool("force", false, "") + cmd.Flags().Bool("all", false, "") + + for _, name := range changed { + require.NoError(t, cmd.Flags().Set(name, flagValueFor(name))) + } + + return cmd +} + +func flagValueFor(name string) string { + switch name { + case "version": + return "v1.2.3" + case "use-major": + return "2" + default: + return "true" + } +} + +func TestValidateInstallArgsSinglePlugin(t *testing.T) { + assert.NoError(t, validateInstallArgs(installFlagSet(t), []string{"stronghold"}, false)) +} + +// A bare `install` names nothing to install and must say what to do, rather than +// failing somewhere deeper on an empty plugin name. +func TestValidateInstallArgsRequiresNameOrAll(t *testing.T) { + err := validateInstallArgs(installFlagSet(t), nil, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--all") +} + +func TestValidateInstallArgsAllTakesNoName(t *testing.T) { + err := validateInstallArgs(installFlagSet(t, "all"), []string{"stronghold"}, true) + + require.Error(t, err) + assert.Contains(t, err.Error(), "takes no plugin name") +} + +// --version and --use-major pin one plugin to one version, which cannot mean +// anything applied to every installed plugin at once. +func TestValidateInstallArgsAllRejectsPinningFlags(t *testing.T) { + for _, flag := range []string{"version", "use-major"} { + t.Run(flag, func(t *testing.T) { + err := validateInstallArgs(installFlagSet(t, "all", flag), nil, true) + + require.Error(t, err) + assert.Contains(t, err.Error(), "--"+flag) + assert.Contains(t, err.Error(), "--all") + }) + } +} + +// --force applies uniformly (re-pull every plugin), so it is the one option that +// combines with --all. +func TestValidateInstallArgsAllAllowsForce(t *testing.T) { + assert.NoError(t, validateInstallArgs(installFlagSet(t, "all", "force"), nil, true)) +} diff --git a/internal/plugins/cmd/list.go b/internal/plugins/cmd/list.go index b3c06525..a5c489e1 100644 --- a/internal/plugins/cmd/list.go +++ b/internal/plugins/cmd/list.go @@ -17,24 +17,77 @@ limitations under the License. package pluginscmd import ( + "errors" "fmt" + "io" + "log/slog" "github.com/spf13/cobra" + dkplog "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/deckhouse-cli/internal/plugins" + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/errdetect" + "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" ) -func newListCommand(manager *plugins.Manager) *cobra.Command { +const tableRule = "-------------------------------------------" + +func newListCommand(manager *plugins.Manager, logger *dkplog.Logger) *cobra.Command { + // sourceErr records why the registry could not be reached, so the installed half + // of the listing still prints and the published half can explain itself. + var sourceErr error + return &cobra.Command{ Use: "list", - Short: "List installed Deckhouse CLI plugins", - Long: "Show installed plugins.\n\n" + - "The registry-packages-proxy serves only allow-listed images by name and exposes no\n" + - "catalog, so the set of available plugins cannot be listed - inspect a plugin by name\n" + - "with 'd8 dist plugins versions '.", + Short: "List Deckhouse CLI plugins", + Long: "Show the plugins installed locally and, when the transport can enumerate\n" + + "them, the plugins published in the registry and ready to install.\n\n" + + "The published set is read from the tags of the plugins repository, where each\n" + + "plugin has an image tagged with its name. Only direct registry access (--source)\n" + + "reaches that repository: the registry-packages-proxy addresses plugins by exact\n" + + "name and does not serve the plugins path, so over the proxy this half is\n" + + "reported as unsupported rather than attempted.\n\n" + + "Whatever cannot be resolved is reported in place rather than dropped: a plugin\n" + + "whose versions cannot be read is still listed by name, and an unreachable\n" + + "registry leaves the installed list intact.", Args: cobra.NoArgs, + // Replaces the parent hook: unlike its sibling commands, list has something + // worth showing without the registry, so an unreachable cluster must not + // suppress the installed list, which is read straight from disk. + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + // The plugins directory was captured at registration time, BEFORE flag + // parsing - re-read it here so --plugins-dir is honored (the env + // path DECKHOUSE_CLI_PATH is applied earlier, at registration). + manager.SetDirectory(flags.DeckhousePluginsDir) + + if err := manager.EnsureInstallRoot(); err != nil { + logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error())) + } + + if err := manager.InitPluginServices(cmd.Context()); err != nil { + if diag := errdetect.Diagnose(err); diag != nil { + err = diag + } + + sourceErr = err + } + + return nil + }, RunE: func(cmd *cobra.Command, _ []string) error { - printInstalledPlugins(manager.List()) + out := cmd.OutOrStdout() + + printInstalledPlugins(out, manager.List()) + + if sourceErr != nil { + printAvailablePlugins(out, nil, sourceErr) + + return nil + } + + available, err := manager.AvailablePlugins(cmd.Context()) + printAvailablePlugins(out, available, err) return nil }, @@ -42,21 +95,73 @@ func newListCommand(manager *plugins.Manager) *cobra.Command { } // printInstalledPlugins renders the installed-plugins table. -func printInstalledPlugins(installed []plugins.PluginInfo) { - fmt.Println("Installed Plugins:") - fmt.Println("-------------------------------------------") - fmt.Printf("%-20s %-15s %-40s\n", "NAME", "VERSION", "DESCRIPTION") - fmt.Println("-------------------------------------------") +func printInstalledPlugins(out io.Writer, installed []plugins.PluginInfo) { + fmt.Fprintln(out, "Installed plugins:") + fmt.Fprintln(out, tableRule) + fmt.Fprintf(out, "%-20s %-15s %-40s\n", "NAME", "VERSION", "DESCRIPTION") + fmt.Fprintln(out, tableRule) if len(installed) == 0 { - fmt.Println("No plugins installed") + fmt.Fprintln(out, "No plugins installed") } else { for _, plugin := range installed { - fmt.Printf("%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) + fmt.Fprintf(out, "%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) } } - fmt.Println() - fmt.Printf("Total: %d plugin(s) installed\n", len(installed)) - fmt.Println("\nThe registry serves no catalog; install a plugin by name with 'd8 dist plugins install '.") + fmt.Fprintln(out) + fmt.Fprintf(out, "Total: %d plugin(s) installed\n", len(installed)) +} + +// printAvailablePlugins renders the registry half of the listing. err is why the +// published set could not be enumerated at all; it is reported in place of the +// table, leaving the installed half above untouched. +func printAvailablePlugins(out io.Writer, available []plugins.RemotePluginInfo, err error) { + fmt.Fprintln(out) + fmt.Fprintln(out, "Available in the registry:") + fmt.Fprintln(out, tableRule) + + if err != nil { + fmt.Fprintf(out, "Could not list published plugins: %v\n", err) + + // Addressing a plugin by name works on every transport, so point at the + // lookups that still work rather than leaving a dead end. + if errors.Is(err, plugins.ErrCatalogUnsupported) { + fmt.Fprintln(out, "Inspect a plugin by name with 'd8 dist plugins versions ',") + fmt.Fprintln(out, "or pass --source to reach the registry directly, which can enumerate them.") + } + + return + } + + if len(available) == 0 { + fmt.Fprintln(out, "No plugins found in the registry") + + return + } + + fmt.Fprintf(out, "%-20s %-15s %-40s\n", "NAME", "LATEST", "STATUS") + fmt.Fprintln(out, tableRule) + + for _, plugin := range available { + fmt.Fprintf(out, "%-20s %-15s %-40s\n", plugin.Name, plugin.Version, remotePluginStatus(plugin)) + } + + fmt.Fprintln(out) + fmt.Fprintf(out, "Total: %d plugin(s) published\n", len(available)) + fmt.Fprintln(out, "\nInstall a plugin with 'd8 dist plugins install '.") +} + +// remotePluginStatus is the STATUS cell: why the version is missing when it is, +// otherwise whether the plugin is already on disk. +func remotePluginStatus(plugin plugins.RemotePluginInfo) string { + if plugin.Note != "" { + return plugin.Note + } + + if plugin.Installed { + return "installed" + } + + return "" } diff --git a/internal/plugins/cmd/list_test.go b/internal/plugins/cmd/list_test.go new file mode 100644 index 00000000..df6b4c6e --- /dev/null +++ b/internal/plugins/cmd/list_test.go @@ -0,0 +1,114 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pluginscmd + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/deckhouse/deckhouse-cli/internal/plugins" +) + +// TestPrintAvailablePluginsRendersNotes: a plugin whose version could not be +// resolved is still named, with the reason in its own row. +func TestPrintAvailablePluginsRendersNotes(t *testing.T) { + var out bytes.Buffer + + printAvailablePlugins(&out, []plugins.RemotePluginInfo{ + {Name: "package", Version: "v0.0.34", Installed: true}, + {Name: "stronghold", Version: "v1.3.0"}, + {Name: "broken", Note: "no versions found"}, + }, nil) + + rendered := out.String() + + assert.Contains(t, rendered, "Available in the registry:") + assert.Contains(t, rendered, "NAME LATEST STATUS") + assert.Contains(t, rendered, "package v0.0.34 installed") + assert.Contains(t, rendered, "stronghold v1.3.0") + assert.Contains(t, rendered, "broken no versions found") + assert.Contains(t, rendered, "Total: 3 plugin(s) published") +} + +// TestPrintAvailablePluginsNoneFound distinguishes "the registry answered, and it +// holds nothing" from a failure to ask. +func TestPrintAvailablePluginsNoneFound(t *testing.T) { + var out bytes.Buffer + + printAvailablePlugins(&out, nil, nil) + + assert.Contains(t, out.String(), "No plugins found in the registry") + assert.NotContains(t, out.String(), "Could not list") +} + +// TestPrintAvailablePluginsReportsFailure: an unreachable registry replaces the +// table with its reason and nothing else - the installed half printed earlier stands. +func TestPrintAvailablePluginsReportsFailure(t *testing.T) { + var out bytes.Buffer + + printAvailablePlugins(&out, nil, errors.New("route not allowed")) + + rendered := out.String() + + assert.Contains(t, rendered, "Could not list published plugins: route not allowed") + assert.NotContains(t, rendered, "No plugins found") + assert.NotContains(t, rendered, "Total:") +} + +// TestPrintInstalledPluginsEmpty keeps the installed half honest when nothing is +// on disk, so an empty local list never reads as a failure. +func TestPrintInstalledPluginsEmpty(t *testing.T) { + var out bytes.Buffer + + printInstalledPlugins(&out, nil) + + rendered := out.String() + + assert.Contains(t, rendered, "No plugins installed") + assert.Contains(t, rendered, "Total: 0 plugin(s) installed") +} + +// TestPrintAvailablePluginsHintsOnUnsupportedTransport: a transport that cannot +// enumerate must not dead-end - naming a plugin still works on every transport, and +// direct registry access can enumerate. +func TestPrintAvailablePluginsHintsOnUnsupportedTransport(t *testing.T) { + var out bytes.Buffer + + printAvailablePlugins(&out, nil, + fmt.Errorf("%w (%s)", plugins.ErrCatalogUnsupported, plugins.TransportRPP)) + + rendered := out.String() + + assert.Contains(t, rendered, "not supported by this transport") + assert.Contains(t, rendered, string(plugins.TransportRPP)) + assert.Contains(t, rendered, "d8 dist plugins versions ") + assert.Contains(t, rendered, "--source") +} + +// TestPrintAvailablePluginsNoHintOnOtherFailures keeps the workaround tied to the +// one cause it addresses, so a transport failure is not mislabeled. +func TestPrintAvailablePluginsNoHintOnOtherFailures(t *testing.T) { + var out bytes.Buffer + + printAvailablePlugins(&out, nil, errors.New("proxy unreachable")) + + assert.NotContains(t, out.String(), "d8 dist plugins versions ") +} diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index 5c824e02..9aad953a 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -29,26 +29,55 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/plugins" ) +// SystemPluginName and PackagePluginName name the two capabilities that are both +// overridable commands and possible plugin dependencies (see overridableCommands in +// cmd/d8/root.go): each is served by an installed plugin of that name, or by its +// built-in implementation when no such plugin is installed. While `package` is +// served by the built-in it is passed to SetBuiltinCommands, so a plugin depending +// on "package" is satisfied without a registry lookup. const ( - SystemPluginName = "system" - // PackagePluginName names the capability, not a wrapper command: `d8 package` - // is always built in (cmd/d8/root.go). It is passed to SetBuiltinCommands so a - // plugin depending on "package" is satisfied without a registry lookup. - // TODO(Glitchy-Sheep): swap the built-in for NewPluginCommand during full plugin system implementation. + SystemPluginName = "system" PackagePluginName = "package" ) +// PluginCommandOption configures the wrapper returned by NewPluginCommand. +type PluginCommandOption func(*pluginCommandOptions) + +type pluginCommandOptions struct { + installRoot string +} + +// WithInstallRoot pins the wrapper to an install root already known to hold the +// plugin, skipping the EnsureInstallRoot probe and its home-fallback switch. +// +// Without it the wrapper starts from the configured root, which is the wrong one +// whenever the installs live in the ~/.deckhouse-cli fallback: EnsureInstallRoot +// switches over only on a permission error, so a configured root that exists but +// is empty keeps the wrapper looking in a directory holding no plugin at all. +func WithInstallRoot(root string) PluginCommandOption { + return func(o *pluginCommandOptions) { o.installRoot = root } +} + // NewPluginCommand returns the wrapper command that runs an installed plugin // (e.g. `d8 system`), installing it first when missing. -// TODO: add options pattern -func NewPluginCommand(commandName, description string, aliases []string, logger *dkplog.Logger) *cobra.Command { +func NewPluginCommand(commandName, description string, aliases []string, logger *dkplog.Logger, opts ...PluginCommandOption) *cobra.Command { + var options pluginCommandOptions + for _, opt := range opts { + opt(&options) + } + manager := plugins.NewManager(logger.Named("plugins-command")) - if err := manager.EnsureInstallRoot(); err != nil { - // Warn but keep building the command: a nil return makes the caller's - // cobra.AddCommand panic and takes down the whole CLI. RunInstalled - // surfaces the root error at invocation time. - logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error())) + switch { + case options.installRoot != "": + manager.SetDirectory(options.installRoot) + default: + if err := manager.EnsureInstallRoot(); err != nil { + // Warn but keep building the command: a nil return makes the caller's + // cobra.AddCommand panic and takes down the whole CLI. RunInstalled + // surfaces the root error at invocation time. + logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error())) + } } // Drive the help text from the cached contract (description + declared flags/env). diff --git a/internal/plugins/cmd/plugins.go b/internal/plugins/cmd/plugins.go index 82b3047f..e750a771 100644 --- a/internal/plugins/cmd/plugins.go +++ b/internal/plugins/cmd/plugins.go @@ -43,14 +43,14 @@ func NewCommand(logger *dkplog.Logger, builtinCommands []string) *cobra.Command Long: "Manage Deckhouse CLI plugins.\n\n" + "Plugins are pulled from the in-cluster registry-packages-proxy, authenticated by the\n" + "current kubeconfig identity.\n\n" + - "Update on demand with 'd8 dist plugins update ' or 'd8 dist plugins update all'.\n\n" + + "Installing a plugin that is already present updates it: 'd8 dist plugins install ',\n" + + "or 'd8 dist plugins install --all' for every installed plugin at once.\n\n" + "Environment variables:\n" + " " + flags.EnvSkipClusterChecks + "=1 skip cluster-side plugin requirement checks\n" + " " + flags.EnvPluginsDir + " plugins directory (same as --plugins-dir)\n" + " " + rppflags.EnvEndpoint + " registry-packages-proxy base URL\n" + " " + rppflags.EnvCAFile + " PEM CA bundle for proxy TLS verification\n" + " KUBECONFIG path to the kubeconfig file", - Hidden: true, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { // The plugins directory was captured at registration time, BEFORE flag // parsing - re-read it here so --plugins-dir is honored (the env @@ -76,11 +76,10 @@ func NewCommand(logger *dkplog.Logger, builtinCommands []string) *cobra.Command }, } - cmd.AddCommand(newListCommand(manager)) + cmd.AddCommand(newListCommand(manager, logger)) cmd.AddCommand(newVersionsCommand(manager)) cmd.AddCommand(newContractCommand(manager, logger)) cmd.AddCommand(newInstallCommand(manager)) - cmd.AddCommand(newUpdateCommand(manager)) cmd.AddCommand(newRemoveCommand(manager)) // Only the plugin-specific flags: the cluster access flags (kubeconfig/ diff --git a/internal/plugins/cmd/update.go b/internal/plugins/cmd/update.go deleted file mode 100644 index 22716aef..00000000 --- a/internal/plugins/cmd/update.go +++ /dev/null @@ -1,72 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package pluginscmd - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/deckhouse/deckhouse-cli/internal/plugins" -) - -func newUpdateCommand(manager *plugins.Manager) *cobra.Command { - var useMajor int - - cmd := &cobra.Command{ - Use: "update ", - Short: "Update an installed plugin", - Long: "Update an installed plugin to the newest version compatible with this cluster,\n" + - "within its current major version. Plugins it depends on are installed/upgraded\n" + - "automatically.\n\n" + - "To cross majors use --use-major N (dependencies may then cross their major too)\n" + - "or pick an exact version with 'd8 dist plugins install --version X'.", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - pluginName := args[0] - fmt.Printf("Updating plugin: %s\n", pluginName) - - return manager.InstallPlugin(cmd.Context(), pluginName, plugins.InstallWithMajorVersion(useMajor)) - }, - } - - cmd.Flags().IntVar(&useMajor, "use-major", -1, "Cross to a specific major version (dependencies may cross theirs too). By default the update stays within the installed major.") - - // Add subcommands - cmd.AddCommand(newUpdateAllCommand(manager)) - - return cmd -} - -func newUpdateAllCommand(manager *plugins.Manager) *cobra.Command { - return &cobra.Command{ - Use: "all", - Short: "Update all installed plugins", - Long: "Update all installed plugins to their newest cluster-compatible version within each plugin's current major.", - RunE: func(cmd *cobra.Command, _ []string) error { - fmt.Println("Updating all installed plugins...") - - if err := manager.UpdateAll(cmd.Context()); err != nil { - return err - } - - fmt.Println("✓ All plugins updated successfully!") - - return nil - }, - } -} diff --git a/internal/plugins/cmd/versions.go b/internal/plugins/cmd/versions.go index ca847236..3a8f9622 100644 --- a/internal/plugins/cmd/versions.go +++ b/internal/plugins/cmd/versions.go @@ -36,6 +36,9 @@ func newVersionsCommand(manager *plugins.Manager) *cobra.Command { Short: "List all versions of a plugin", Long: "List all published versions of a plugin, newest first. The installed version is\n" + "marked, versions newer than it are highlighted.\n\n" + + "A plugin is published one image per platform, so a release reaches the registry as\n" + + "several tags. They are collapsed into one line per version, listing the platforms\n" + + "that version was built for.\n\n" + "Versions are fetched by the plugin's name through the registry-packages-proxy, so no\n" + "catalog access is needed. Install a specific version with\n" + "'d8 dist plugins install --version X' - a version already on disk is switched to\n" + @@ -47,8 +50,8 @@ func newVersionsCommand(manager *plugins.Manager) *cobra.Command { } // Completion must stay instant and offline, so it offers the installed - // plugins (read from disk); the remote catalog is not available through - // the rpp source anyway. + // plugins (read from disk) rather than reaching the registry for the + // published set. names, err := manager.InstalledPluginNames() if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp @@ -95,12 +98,17 @@ func newVersionsCommand(manager *plugins.Manager) *cobra.Command { } } +// groupColumnWidth is the width of the trailing group word ("current"/"newer"), +// held fixed so the platform lists that follow it line up into a column. +const groupColumnWidth = len("current") + // formatPluginVersionList renders the version list newest-first: versions newer // than the installed one are green, the installed one is starred and cyan, // older ones are dimmed - the same grouping `d8 dist versions` uses. A nil // current (plugin not installed, version unknown) produces a plain uncolored -// list. Reports whether current appeared in the list. -func formatPluginVersionList(versions []*semver.Version, current *semver.Version) ([]string, bool) { +// list. Each line carries the platforms that version was published for. +// Reports whether current appeared in the list. +func formatPluginVersionList(versions []plugins.PluginVersion, current *semver.Version) ([]string, bool) { var ( newer = color.New(color.FgGreen) actual = color.New(color.FgCyan, color.Bold) @@ -109,32 +117,49 @@ func formatPluginVersionList(versions []*semver.Version, current *semver.Version widest int ) - for _, v := range versions { - if len(v.Original()) > widest { - widest = len(v.Original()) + for _, version := range versions { + if len(version.Version.Original()) > widest { + widest = len(version.Version.Original()) } } lines := make([]string, 0, len(versions)) - for _, v := range versions { - var entry string + for _, version := range versions { + var ( + tint *color.Color + group string + marker = " " + ) switch { case current == nil: - entry = fmt.Sprintf(" %-*s", widest, v.Original()) - case v.Equal(current): - listed = true - entry = actual.Sprintf("* %-*s current", widest, v.Original()) - case v.GreaterThan(current): - entry = newer.Sprintf(" %-*s newer", widest, v.Original()) + // Left uncolored and ungrouped: with no installed version to compare + // against, no entry is newer, older or current. + case version.Version.Equal(current): + listed, tint, group, marker = true, actual, "current", "*" + case version.Version.GreaterThan(current): + tint, group = newer, "newer" default: - entry = older.Sprintf(" %-*s", widest, v.Original()) + tint = older + } + + entry := fmt.Sprintf("%s %-*s %-*s %s", + marker, widest, version.Version.Original(), + groupColumnWidth, group, strings.Join(version.Platforms, ", ")) + + // Both columns are padded to a fixed width so the platform lists line up; + // a row missing the group word or the platforms would otherwise carry + // invisible trailing spaces. A plugin published without platform suffixes + // has every platform list empty, and the output collapses back to the bare + // version plus its group word. + entry = strings.TrimRight(entry, " ") + + if tint != nil { + entry = tint.Sprint(entry) } - // The padding is for the trailing group word; entries without one would - // otherwise carry invisible trailing spaces. - lines = append(lines, strings.TrimRight(entry, " ")) + lines = append(lines, entry) } return lines, listed diff --git a/internal/plugins/cmd/versions_test.go b/internal/plugins/cmd/versions_test.go index 9b5e6f44..26176155 100644 --- a/internal/plugins/cmd/versions_test.go +++ b/internal/plugins/cmd/versions_test.go @@ -23,23 +23,37 @@ import ( "github.com/fatih/color" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/plugins" ) -func mustSemvers(t *testing.T, raw ...string) []*semver.Version { +// mustSemvers builds platform-less releases - the shape a plugin published as a +// single platform-independent tag produces. +func mustSemvers(t *testing.T, raw ...string) []plugins.PluginVersion { t.Helper() - versions := make([]*semver.Version, 0, len(raw)) + versions := make([]plugins.PluginVersion, 0, len(raw)) for _, r := range raw { v, err := semver.NewVersion(r) require.NoError(t, err) - versions = append(versions, v) + versions = append(versions, plugins.PluginVersion{Version: v}) } return versions } +// withPlatforms builds one release published for several platforms. +func withPlatforms(t *testing.T, raw string, platforms ...string) plugins.PluginVersion { + t.Helper() + + v, err := semver.NewVersion(raw) + require.NoError(t, err) + + return plugins.PluginVersion{Version: v, Platforms: platforms} +} + func withoutColor(t *testing.T) { t.Helper() @@ -86,3 +100,37 @@ func TestFormatPluginVersionListNotInstalledIsPlain(t *testing.T) { " v0.0.21", }, lines) } + +// TestFormatPluginVersionListShowsPlatforms also pins the alignment: the group +// column keeps a fixed width so the platform lists form a column of their own. +func TestFormatPluginVersionListShowsPlatforms(t *testing.T) { + withoutColor(t) + + lines, listed := formatPluginVersionList([]plugins.PluginVersion{ + withPlatforms(t, "v0.0.35", "linux/amd64"), + withPlatforms(t, "v0.0.34", "linux/amd64", "darwin/arm64"), + withPlatforms(t, "v0.0.33", "linux/amd64"), + }, semver.MustParse("v0.0.34")) + + assert.True(t, listed) + assert.Equal(t, []string{ + " v0.0.35 newer linux/amd64", + "* v0.0.34 current linux/amd64, darwin/arm64", + " v0.0.33 linux/amd64", + }, lines) +} + +// TestFormatPluginVersionListPlatformlessIsUnpadded guards the plugin published as +// one platform-independent tag: with no platforms to list, the fixed-width columns +// must trim away entirely rather than leave a ragged tail of spaces. +func TestFormatPluginVersionListPlatformlessIsUnpadded(t *testing.T) { + withoutColor(t) + + lines, _ := formatPluginVersionList( + mustSemvers(t, "v0.1.2", "v0.0.21"), semver.MustParse("v0.0.21")) + + assert.Equal(t, []string{ + " v0.1.2 newer", + "* v0.0.21 current", + }, lines) +} diff --git a/internal/plugins/install_test.go b/internal/plugins/install_test.go index 9653d31d..0a9bf957 100644 --- a/internal/plugins/install_test.go +++ b/internal/plugins/install_test.go @@ -52,6 +52,8 @@ type fakeInstallSource struct { contractByTag map[string]*internal.Plugin } +func (f *fakeInstallSource) Transport() Transport { return TransportRPP } + func (f *fakeInstallSource) ListPluginTags(_ context.Context, pluginName string) ([]string, error) { f.listedTags = append(f.listedTags, pluginName) diff --git a/internal/plugins/layout/layout.go b/internal/plugins/layout/layout.go index 42910858..9e915f5f 100644 --- a/internal/plugins/layout/layout.go +++ b/internal/plugins/layout/layout.go @@ -118,25 +118,69 @@ func InstallLockPath(installRoot, pluginName string) string { return path.Join(installRoot, pluginsDirName, pluginName, "install"+lockFileSuffix) } -// RootHasInstall reports whether /plugins holds at least one installed -// plugin - a directory with a `current` symlink. Requiring the symlink (not just -// any subdir) means a leftover empty v dir from a failed install does not -// count as an install. The cache dir is a sibling, never miscounted. -func RootHasInstall(root string) bool { +// InstalledNames returns the names of the plugins installed under /plugins - +// the directories carrying a `current` symlink. Requiring the symlink (not just any +// subdir) means a leftover empty v dir from a failed install does not count +// as an install. The cache dir is a sibling, never miscounted. +func InstalledNames(root string) ([]string, error) { entries, err := os.ReadDir(PluginsRoot(root)) if err != nil { - return false + return nil, err } + names := make([]string, 0, len(entries)) + for _, entry := range entries { if !entry.IsDir() { continue } - if _, err := os.Lstat(CurrentLinkPath(root, entry.Name())); err == nil { - return true + if _, err := os.Lstat(CurrentLinkPath(root, entry.Name())); err != nil { + continue } + + names = append(names, entry.Name()) + } + + return names, nil +} + +// RootHasInstall reports whether /plugins holds at least one installed plugin. +func RootHasInstall(root string) bool { + names, err := InstalledNames(root) + + return err == nil && len(names) > 0 +} + +// ResolveInstalled reports the plugins root that actually holds an install, the +// plugin names in it, and whether one was found: the configured root, or the home +// fallback (~/.deckhouse-cli) that EnsureInstallRoot switches to when the configured +// one is not writable. The final result is false when no plugins are installed +// anywhere, and the root is then empty and the names nil. +// +// Callers resolving a plugin by name must use this rather than the configured root +// alone: with an unwritable default root the installs live in the fallback, and +// looking only at the configured root would miss every one of them. +func ResolveInstalled(configured string) (string, []string, bool) { + if names, err := InstalledNames(configured); err == nil && len(names) > 0 { + return configured, names, true + } + + fallback, err := HomeFallbackPath() + if err != nil || fallback == configured { + return "", nil, false } - return false + if names, err := InstalledNames(fallback); err == nil && len(names) > 0 { + return fallback, names, true + } + + return "", nil, false +} + +// ResolveInstallRoot reports just the root resolved by ResolveInstalled. +func ResolveInstallRoot(configured string) (string, bool) { + root, _, ok := ResolveInstalled(configured) + + return root, ok } diff --git a/internal/plugins/layout/layout_test.go b/internal/plugins/layout/layout_test.go index 13f2e2d8..4f0910a4 100644 --- a/internal/plugins/layout/layout_test.go +++ b/internal/plugins/layout/layout_test.go @@ -17,11 +17,24 @@ limitations under the License. package layout import ( + "os" + "path" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// installPlugin gives root the on-disk shape of an installed plugin: a version +// directory holding the binary, plus the `current` symlink pointing at it. +func installPlugin(t *testing.T, root, name string) { + t.Helper() + + require.NoError(t, os.MkdirAll(VersionDir(root, name, 1), 0o755)) + require.NoError(t, os.WriteFile(BinaryPath(root, name, 1), []byte("#!/bin/sh\n"), 0o755)) + require.NoError(t, os.Symlink(BinaryPath(root, name, 1), CurrentLinkPath(root, name))) +} + // TestValidatePluginName pins the name grammar: one lowercase OCI path // component. Anything that could leave the plugins root or change a registry // route is rejected. @@ -48,3 +61,75 @@ func TestValidatePluginName(t *testing.T) { assert.Error(t, ValidatePluginName(name), name) } } + +// TestInstalledNames pins what counts as installed: a directory under +// /plugins carrying a `current` symlink. A leftover version directory from a +// failed install has no symlink, so it must not be reported - the root command +// would otherwise hand the command to a plugin that has no binary to run. +func TestInstalledNames(t *testing.T) { + root := t.TempDir() + + installPlugin(t, root, "stronghold") + installPlugin(t, root, "system") + + // Leftover from a failed install: a version directory, but no `current` symlink. + require.NoError(t, os.MkdirAll(VersionDir(root, "halfway", 1), 0o755)) + + // A stray file in the plugins root is not a plugin either. + require.NoError(t, os.WriteFile(path.Join(PluginsRoot(root), "README"), nil, 0o644)) + + names, err := InstalledNames(root) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"stronghold", "system"}, names) + assert.True(t, RootHasInstall(root)) +} + +func TestInstalledNamesMissingRoot(t *testing.T) { + root := t.TempDir() // no plugins/ subdirectory at all + + _, err := InstalledNames(root) + assert.Error(t, err) + assert.False(t, RootHasInstall(root)) +} + +// TestResolveInstalledPrefersConfiguredRoot: the home fallback is consulted only +// when the configured root holds nothing, never in preference to it. +func TestResolveInstalledPrefersConfiguredRoot(t *testing.T) { + configured, home := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + + installPlugin(t, configured, "system") + installPlugin(t, path.Join(home, ".deckhouse-cli"), "stronghold") + + root, names, ok := ResolveInstalled(configured) + + require.True(t, ok) + assert.Equal(t, configured, root) + assert.Equal(t, []string{"system"}, names) +} + +// TestResolveInstalledFallsBackToHome covers the unwritable-default case: installs +// land in ~/.deckhouse-cli, and looking only at the configured root would miss them. +func TestResolveInstalledFallsBackToHome(t *testing.T) { + configured, home := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + + fallback := path.Join(home, ".deckhouse-cli") + installPlugin(t, fallback, "stronghold") + + root, names, ok := ResolveInstalled(configured) + + require.True(t, ok) + assert.Equal(t, fallback, root) + assert.Equal(t, []string{"stronghold"}, names) +} + +func TestResolveInstalledNothingInstalled(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + root, names, ok := ResolveInstalled(t.TempDir()) + + assert.False(t, ok) + assert.Empty(t, root) + assert.Nil(t, names) +} diff --git a/internal/plugins/list.go b/internal/plugins/list.go index 8c492fb6..98650c15 100644 --- a/internal/plugins/list.go +++ b/internal/plugins/list.go @@ -17,9 +17,12 @@ limitations under the License. package plugins import ( + "context" + "errors" "fmt" "log/slog" "os" + "sort" "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" ) @@ -31,10 +34,8 @@ type PluginInfo struct { Description string } -// List returns the installed plugins. The registry-packages-proxy serves only -// allow-listed images by exact name and exposes no catalog endpoint, so the set -// of available plugins cannot be listed - a plugin is inspected by name with -// `d8 dist plugins versions `. +// List returns the plugins installed on disk. For the plugins published in the +// registry and available to install, see AvailablePlugins. func (m *Manager) List() []PluginInfo { installed, err := m.fetchInstalledPlugins() if err != nil { @@ -89,3 +90,94 @@ func (m *Manager) fetchInstalledPlugins() ([]PluginInfo, error) { return res, nil } + +// RemotePluginInfo is one plugin published in the registry: its name, the newest +// stable version on offer, and whether it is already installed locally. Note says +// why Version is empty - a plugin that could only be named is still listed, never +// silently dropped. +type RemotePluginInfo struct { + Name string + Version string + Installed bool + Note string +} + +// ErrCatalogUnsupported means the transport in use cannot enumerate the published +// plugins. It is a property of the transport, not a runtime failure: the proxy +// allowlist admits deckhouse-cli and deckhouse-cli/plugins/ and refuses the +// bare deckhouse-cli/plugins path, so over TransportRPP the request is never made. +// Addressing a plugin by exact name is unaffected - install, update and versions +// work on every transport. +var ErrCatalogUnsupported = errors.New("listing published plugins is not supported by this transport") + +// AvailablePlugins enumerates the plugins published in the registry, by name. +// +// The catalog is the tag list of the plugins repository, where each plugin has an +// image tagged with its name; a source that can reach it declares pluginCatalog. +// Only a failure to enumerate at all is returned as an error - a plugin whose +// versions cannot be resolved still appears, carrying a Note that says so. +func (m *Manager) AvailablePlugins(ctx context.Context) ([]RemotePluginInfo, error) { + if m.service == nil { + return nil, errors.New("plugin source is not initialized") + } + + catalog, ok := m.service.(pluginCatalog) + if !ok { + return nil, fmt.Errorf("%w (%s)", ErrCatalogUnsupported, m.service.Transport()) + } + + names, err := catalog.ListPluginNames(ctx) + if err != nil { + return nil, err + } + + sort.Strings(names) + + available := make([]RemotePluginInfo, 0, len(names)) + for _, name := range names { + available = append(available, m.remotePluginInfo(ctx, name)) + } + + return available, nil +} + +// remotePluginInfo resolves one published plugin's newest stable version. Every +// failure lands in the row's Note rather than aborting the listing: a plugin whose +// versions cannot be read is still worth showing by name. +func (m *Manager) remotePluginInfo(ctx context.Context, name string) RemotePluginInfo { + info := RemotePluginInfo{Name: name} + + // The name arrives as a registry tag, so it is external input: anything that + // could not address a plugin repository is reported, never turned into a route. + if err := layout.ValidatePluginName(name); err != nil { + info.Note = "not a valid plugin name" + + return info + } + + info.Installed, _ = m.checkInstalled(name) + + tags, err := m.listTags(ctx, name) + if err != nil { + m.logger.Debug("cannot list versions of a published plugin", + slog.String("plugin", name), slog.String("error", err.Error())) + + info.Note = "versions unavailable" + + return info + } + + candidates := stableVersions(sortedSemverDesc(tags)) + if len(candidates) == 0 { + info.Note = "no versions found" + + return info + } + + // The newest stable tag may be one of the per-platform child images, so collapse + // it to the release itself rather than advertising a single platform's build. + clean, _ := SplitPlatform(candidates[0]) + info.Version = clean.Original() + + return info +} diff --git a/internal/plugins/list_test.go b/internal/plugins/list_test.go new file mode 100644 index 00000000..0c79fcae --- /dev/null +++ b/internal/plugins/list_test.go @@ -0,0 +1,204 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugins + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal" +) + +// TestAvailablePluginsResolvesLatest: the catalog is the tag list of the plugins +// repository, and each name is resolved to its newest stable release. +func TestAvailablePluginsResolvesLatest(t *testing.T) { + m := plannerManager(t, &multiPluginSource{ + tags: map[string][]string{ + // Listed second by the source, but the result is sorted by name. + "stronghold": {"v1.2.3", "v1.3.0", "v1.3.0-rc.1"}, + "package": {"v0.0.33", "v0.0.34"}, + }, + }) + + available, err := m.AvailablePlugins(context.Background()) + require.NoError(t, err) + + require.Len(t, available, 2) + assert.Equal(t, "package", available[0].Name) + assert.Equal(t, "v0.0.34", available[0].Version) + assert.Empty(t, available[0].Note) + + // The release candidate must not win over the stable release. + assert.Equal(t, "stronghold", available[1].Name) + assert.Equal(t, "v1.3.0", available[1].Version) +} + +// TestAvailablePluginsCollapsesPlatformTags: a release published only as +// per-platform images still shows as the release, not one platform's build. +func TestAvailablePluginsCollapsesPlatformTags(t *testing.T) { + m := plannerManager(t, &multiPluginSource{ + tags: map[string][]string{ + "package": {"v0.0.34-linux-amd64", "v0.0.34-darwin-arm64"}, + }, + }) + + available, err := m.AvailablePlugins(context.Background()) + require.NoError(t, err) + + require.Len(t, available, 1) + assert.Equal(t, "v0.0.34", available[0].Version) + assert.Empty(t, available[0].Note) +} + +// TestAvailablePluginsReportsPerPluginFailures is the partial-output guarantee: a +// plugin whose versions cannot be resolved is still listed by name, with the reason +// in its own row, and never takes the rest of the listing down with it. +func TestAvailablePluginsReportsPerPluginFailures(t *testing.T) { + m := plannerManager(t, &multiPluginSource{ + tags: map[string][]string{ + "healthy": {"v1.0.0"}, + "unreadable": {}, + "nostable": {"v1.0.0-rc.1"}, + "untagged": {"latest"}, + }, + tagErrors: map[string]error{"unreadable": errors.New("proxy exploded")}, + }) + + available, err := m.AvailablePlugins(context.Background()) + require.NoError(t, err) + + byName := make(map[string]RemotePluginInfo, len(available)) + for _, plugin := range available { + byName[plugin.Name] = plugin + } + + require.Len(t, byName, 4) + + assert.Equal(t, "v1.0.0", byName["healthy"].Version) + assert.Empty(t, byName["healthy"].Note) + + assert.Empty(t, byName["unreadable"].Version) + assert.Equal(t, "versions unavailable", byName["unreadable"].Note) + + // Listed, but nothing installable: only a genuine pre-release is published. + assert.Empty(t, byName["nostable"].Version) + assert.Equal(t, "no versions found", byName["nostable"].Note) + + // A tag that is not semver at all leaves the plugin with no usable version. + assert.Empty(t, byName["untagged"].Version) + assert.Equal(t, "no versions found", byName["untagged"].Note) +} + +// TestAvailablePluginsRejectsUnusableName: names arrive as registry tags, so a value +// that could not address a plugin repository is reported, never turned into a route. +func TestAvailablePluginsRejectsUnusableName(t *testing.T) { + m := plannerManager(t, &multiPluginSource{ + tags: map[string][]string{"../escape": {"v1.0.0"}}, + }) + + available, err := m.AvailablePlugins(context.Background()) + require.NoError(t, err) + + require.Len(t, available, 1) + assert.Equal(t, "../escape", available[0].Name) + assert.Empty(t, available[0].Version) + assert.Equal(t, "not a valid plugin name", available[0].Note) +} + +// TestAvailablePluginsMarksInstalled cross-references the on-disk installs so the +// listing says which published plugins are already present. +func TestAvailablePluginsMarksInstalled(t *testing.T) { + m := plannerManager(t, &multiPluginSource{ + tags: map[string][]string{"package": {"v0.0.34"}, "stronghold": {"v1.0.0"}}, + }) + installPluginFixture(t, m.pluginDirectory, "package", 0) + + available, err := m.AvailablePlugins(context.Background()) + require.NoError(t, err) + + require.Len(t, available, 2) + assert.True(t, available[0].Installed, "package is installed") + assert.False(t, available[1].Installed, "stronghold is not") +} + +// TestAvailablePluginsCatalogFailure: a transport that can enumerate but fails to +// is a plain error, distinct from one that cannot enumerate at all. +func TestAvailablePluginsCatalogFailure(t *testing.T) { + m := plannerManager(t, &multiPluginSource{}) + m.service = &catalogFailureSource{multiPluginSource: &multiPluginSource{}} + + _, err := m.AvailablePlugins(context.Background()) + assert.Error(t, err) + assert.NotErrorIs(t, err, ErrCatalogUnsupported, "a failed listing is not an absent capability") +} + +// TestAvailablePluginsUnsupportedTransport is the RPP case: the proxy cannot serve +// the plugins path, so the source does not implement pluginCatalog and the request +// is never made. The error names the transport. +func TestAvailablePluginsUnsupportedTransport(t *testing.T) { + m := plannerManager(t, &multiPluginSource{}) + m.service = &noCatalogSource{} + + _, err := m.AvailablePlugins(context.Background()) + assert.ErrorIs(t, err, ErrCatalogUnsupported) + assert.ErrorContains(t, err, string(TransportRPP)) +} + +// TestAvailablePluginsWithoutSource guards the command path that reaches the manager +// after InitPluginServices failed. +func TestAvailablePluginsWithoutSource(t *testing.T) { + m := testManager() + + _, err := m.AvailablePlugins(context.Background()) + assert.ErrorContains(t, err, "not initialized") +} + +// catalogFailureSource enumerates nothing: the plugins repository tag listing fails +// for a reason other than the route being absent (transport, auth, proxy). +type catalogFailureSource struct { + *multiPluginSource +} + +func (s *catalogFailureSource) ListPluginNames(context.Context) ([]string, error) { + return nil, errors.New("proxy unreachable") +} + +// noCatalogSource stands in for the proxy transport: it satisfies pluginSource and +// deliberately NOT pluginCatalog. It is written out in full rather than embedding a +// catalog-capable fake, whose ListPluginNames would be promoted and make the +// capability assertion succeed. +type noCatalogSource struct{} + +var _ pluginSource = (*noCatalogSource)(nil) + +func (s *noCatalogSource) Transport() Transport { return TransportRPP } + +func (s *noCatalogSource) ListPluginTags(context.Context, string) ([]string, error) { + return nil, nil +} + +func (s *noCatalogSource) GetPluginContract(context.Context, string, string) (*internal.Plugin, error) { + return nil, nil +} + +func (s *noCatalogSource) ExtractPlugin(context.Context, string, string, string) error { + return nil +} diff --git a/internal/plugins/planner_test.go b/internal/plugins/planner_test.go index fe3c8832..3bf2938a 100644 --- a/internal/plugins/planner_test.go +++ b/internal/plugins/planner_test.go @@ -49,6 +49,19 @@ type multiPluginSource struct { tagErrors map[string]error } +// multiPluginSource stands in for the registry transport: it can enumerate, so it +// satisfies pluginCatalog as well as pluginSource. +func (s *multiPluginSource) Transport() Transport { return TransportRegistry } + +func (s *multiPluginSource) ListPluginNames(context.Context) ([]string, error) { + names := make([]string, 0, len(s.tags)) + for name := range s.tags { + names = append(names, name) + } + + return names, nil +} + func (s *multiPluginSource) ListPluginTags(_ context.Context, name string) ([]string, error) { if err, ok := s.tagErrors[name]; ok { return nil, err diff --git a/internal/plugins/platform.go b/internal/plugins/platform.go new file mode 100644 index 00000000..5cebd34d --- /dev/null +++ b/internal/plugins/platform.go @@ -0,0 +1,150 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugins + +import ( + "strings" + + "github.com/Masterminds/semver/v3" +) + +// PluginVersion is one published release: the version with any platform suffix +// removed, plus the platforms that release was published for. Platforms is empty +// for a plugin published as a single platform-independent tag. +type PluginVersion struct { + Version *semver.Version + Platforms []string +} + +// knownGOOS and knownGOARCH bound what may be read as a platform suffix. Without +// them a genuine prerelease such as v2.0.0-rc.1 would be mistaken for one and its +// release collapsed into the stable version of the same number. +var ( + knownGOOS = map[string]struct{}{ + "aix": {}, "android": {}, "darwin": {}, "dragonfly": {}, "freebsd": {}, + "hurd": {}, "illumos": {}, "ios": {}, "js": {}, "linux": {}, "nacl": {}, + "netbsd": {}, "openbsd": {}, "plan9": {}, "solaris": {}, "wasip1": {}, + "windows": {}, "zos": {}, + } + + knownGOARCH = map[string]struct{}{ + "386": {}, "amd64": {}, "amd64p32": {}, "arm": {}, "arm64": {}, + "arm64be": {}, "armbe": {}, "loong64": {}, "mips": {}, "mips64": {}, + "mips64le": {}, "mips64p32": {}, "mips64p32le": {}, "mipsle": {}, + "ppc": {}, "ppc64": {}, "ppc64le": {}, "riscv": {}, "riscv64": {}, + "s390": {}, "s390x": {}, "sparc": {}, "sparc64": {}, "wasm": {}, + } +) + +// splitPlatformSuffix reads the platform off the END of a prerelease and returns +// what precedes it plus the platform as "os/arch". +// +// The platform is the last two hyphen-separated tokens, not the whole prerelease: a +// plugin is published one image per platform, and that suffix is appended to +// whatever prerelease the release already carried. So a stable release yields +// "linux-amd64" with nothing before it, while a release named "test" yields +// "test-windows-amd64" - and only the "windows-amd64" tail is the platform. +func splitPlatformSuffix(prerelease string) (string, string, bool) { + archAt := strings.LastIndex(prerelease, "-") + if archAt <= 0 { + return "", "", false + } + + arch := prerelease[archAt+1:] + head := prerelease[:archAt] + + // With no further dash the whole head is the OS and nothing precedes the + // platform; otherwise the OS is the last token and the rest is the prerelease. + osName, remainder := head, "" + if osAt := strings.LastIndex(head, "-"); osAt >= 0 { + osName, remainder = head[osAt+1:], head[:osAt] + } + + if _, ok := knownGOOS[osName]; !ok { + return "", "", false + } + + if _, ok := knownGOARCH[arch]; !ok { + return "", "", false + } + + return remainder, osName + "/" + arch, true +} + +// SplitPlatform separates a version's platform suffix from the version itself. +// Plugin images are published one tag per platform, so a single release reaches the +// registry as v0.0.34-linux-amd64, v0.0.34-darwin-arm64 and so on - the os-arch pair +// riding at the end of the semver prerelease slot. +// +// A release that is itself a pre-release keeps that identity: v0.0.1-test-linux-amd64 +// splits into v0.0.1-test and linux/amd64, so its per-platform tags collapse onto the +// pre-release rather than onto the stable version of the same number. A version whose +// prerelease carries no platform tail comes back untouched with an empty platform. +func SplitPlatform(version *semver.Version) (*semver.Version, string) { + prerelease := version.Prerelease() + if prerelease == "" { + return version, "" + } + + _, platform, ok := splitPlatformSuffix(prerelease) + if !ok { + return version, "" + } + + // Trim the suffix off the original text rather than rebuilding the version, so + // the "v" prefix and any surviving prerelease keep their original formatting. + clean, err := semver.NewVersion( + strings.TrimSuffix(version.Original(), "-"+strings.ReplaceAll(platform, "/", "-"))) + if err != nil { + return version, "" + } + + return clean, platform +} + +// collapsePlatformTags folds the per-platform tags of one release into a single +// entry, keeping the newest-first order of the input and, within an entry, the order +// the platforms were listed in. Versions with no platform suffix pass through as +// entries with no platforms. +func collapsePlatformTags(versions []*semver.Version) []PluginVersion { + collapsed := make([]PluginVersion, 0, len(versions)) + positions := make(map[string]int, len(versions)) + + for _, version := range versions { + clean, platform := SplitPlatform(version) + + position, seen := positions[clean.String()] + if !seen { + positions[clean.String()] = len(collapsed) + + entry := PluginVersion{Version: clean} + if platform != "" { + entry.Platforms = []string{platform} + } + + collapsed = append(collapsed, entry) + + continue + } + + if platform != "" { + collapsed[position].Platforms = append(collapsed[position].Platforms, platform) + } + } + + return collapsed +} diff --git a/internal/plugins/platform_test.go b/internal/plugins/platform_test.go new file mode 100644 index 00000000..f86be5c7 --- /dev/null +++ b/internal/plugins/platform_test.go @@ -0,0 +1,155 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugins + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSplitPlatform pins which prereleases are read as a platform. The dangerous +// case is the genuine prerelease: treating "rc.1" as a platform would collapse a +// release candidate into the stable release of the same number and hide it. +func TestSplitPlatform(t *testing.T) { + cases := []struct { + raw string + version string + platform string + }{ + {raw: "v0.0.34-linux-amd64", version: "v0.0.34", platform: "linux/amd64"}, + {raw: "v0.0.34-darwin-arm64", version: "v0.0.34", platform: "darwin/arm64"}, + {raw: "1.2.3-windows-386", version: "1.2.3", platform: "windows/386"}, + + // No prerelease at all: a platform-independent tag. + {raw: "v1.2.3", version: "v1.2.3", platform: ""}, + + // Genuine prereleases must survive untouched. + {raw: "v2.0.0-rc.1", version: "v2.0.0", platform: ""}, + {raw: "v2.0.0-alpha", version: "v2.0.0", platform: ""}, + + // Neither half names a real GOOS/GOARCH. + {raw: "v1.2.3-foo-bar", version: "v1.2.3", platform: ""}, + {raw: "v1.2.3-linux-pentium", version: "v1.2.3", platform: ""}, + + // A pre-release that also carries a platform keeps its pre-release identity: + // only the os-arch tail is the platform. + {raw: "v1.2.3-rc.1-linux-amd64", version: "v1.2.3-rc.1", platform: "linux/amd64"}, + {raw: "v0.0.1-test-windows-amd64", version: "v0.0.1-test", platform: "windows/amd64"}, + {raw: "v0.0.1-test-darwin-arm64", version: "v0.0.1-test", platform: "darwin/arm64"}, + + // A two-token prerelease whose head is not a GOOS is not a platform. + {raw: "v1.2.3-beta-1", version: "v1.2.3-beta-1", platform: ""}, + } + + for _, tc := range cases { + t.Run(tc.raw, func(t *testing.T) { + parsed, err := semver.NewVersion(tc.raw) + require.NoError(t, err) + + clean, platform := SplitPlatform(parsed) + + assert.Equal(t, tc.platform, platform) + + if tc.platform == "" { + // Untouched: the original string, prerelease and all. + assert.Equal(t, tc.raw, clean.Original()) + + return + } + + // Stripped: only the platform tail is gone, and the original "v" prefix + // and any real prerelease survive. + assert.Equal(t, tc.version, clean.Original()) + }) + } +} + +// TestCollapsePlatformTags folds the per-platform tags of one release into a single +// entry while preserving the newest-first order handed to it. +func TestCollapsePlatformTags(t *testing.T) { + collapsed := collapsePlatformTags(sortedSemverDesc([]string{ + "v0.0.34-linux-amd64", + "v0.0.34-darwin-arm64", + "v0.0.34-linux-arm64", + "v0.0.33-linux-amd64", + "v0.0.33-darwin-arm64", + })) + + require.Len(t, collapsed, 2) + + assert.Equal(t, "v0.0.34", collapsed[0].Version.Original()) + assert.ElementsMatch(t, + []string{"linux/amd64", "linux/arm64", "darwin/arm64"}, collapsed[0].Platforms) + + assert.Equal(t, "v0.0.33", collapsed[1].Version.Original()) + assert.ElementsMatch(t, []string{"linux/amd64", "darwin/arm64"}, collapsed[1].Platforms) +} + +// TestCollapsePlatformTagsKeepsPrereleasesDistinct: a release candidate is its own +// release, never merged into the stable version that shares its numbers - even +// though both are published per platform. +func TestCollapsePlatformTagsKeepsPrereleasesDistinct(t *testing.T) { + collapsed := collapsePlatformTags(sortedSemverDesc([]string{ + "v2.0.0-linux-amd64", + "v2.0.0-darwin-arm64", + "v2.0.0-rc.1-linux-amd64", + "v2.0.0-rc.1", + })) + + // Descending semver puts "rc.1..." above "linux-amd64"/"darwin-arm64" (all are + // prerelease identifiers, compared as ASCII), so the candidate leads. + require.Len(t, collapsed, 2) + + assert.Equal(t, "v2.0.0-rc.1", collapsed[0].Version.Original()) + assert.Equal(t, []string{"linux/amd64"}, collapsed[0].Platforms) + + assert.Equal(t, "v2.0.0", collapsed[1].Version.Original()) + assert.ElementsMatch(t, []string{"linux/amd64", "darwin/arm64"}, collapsed[1].Platforms) +} + +// TestCollapsePlatformTagsPrereleaseWithIndex reproduces the reported listing: a +// pre-release published as an index plus one tag per platform must fold into a +// single "v0.0.1-test" row, not five. +func TestCollapsePlatformTagsPrereleaseWithIndex(t *testing.T) { + collapsed := collapsePlatformTags(sortedSemverDesc([]string{ + "v0.0.1-test-windows-amd64", + "v0.0.1-test-linux-amd64", + "v0.0.1-test-darwin-arm64", + "v0.0.1-test-darwin-amd64", + "v0.0.1-test", + })) + + require.Len(t, collapsed, 1) + assert.Equal(t, "v0.0.1-test", collapsed[0].Version.Original()) + assert.ElementsMatch(t, []string{ + "windows/amd64", "linux/amd64", "darwin/arm64", "darwin/amd64", + }, collapsed[0].Platforms) +} + +// TestCollapsePlatformTagsPlatformlessTag: a plugin published as one +// platform-independent tag yields an entry with no platforms. +func TestCollapsePlatformTagsPlatformlessTag(t *testing.T) { + collapsed := collapsePlatformTags(sortedSemverDesc([]string{"v1.2.3", "v1.2.2"})) + + require.Len(t, collapsed, 2) + assert.Equal(t, "v1.2.3", collapsed[0].Version.Original()) + assert.Empty(t, collapsed[0].Platforms) + assert.Empty(t, collapsed[1].Platforms) +} diff --git a/internal/plugins/rpp_source.go b/internal/plugins/rpp_source.go index a5cf8016..580ac86a 100644 --- a/internal/plugins/rpp_source.go +++ b/internal/plugins/rpp_source.go @@ -64,6 +64,11 @@ func newRppPluginSource(client *rpp.Client, logger *dkplog.Logger) *rppPluginSou var _ pluginSource = (*rppPluginSource)(nil) +// Transport reports that this source reaches the registry through the proxy. It +// deliberately does not implement pluginCatalog: the proxy allowlist refuses the +// bare deckhouse-cli/plugins path, so enumeration is not attempted over it. +func (s *rppPluginSource) Transport() Transport { return TransportRPP } + func (s *rppPluginSource) ListPluginTags(ctx context.Context, pluginName string) ([]string, error) { ref, err := rpp.PluginImage(pluginName) if err != nil { diff --git a/internal/plugins/select_test.go b/internal/plugins/select_test.go index bf85a9ef..26f7d6d1 100644 --- a/internal/plugins/select_test.go +++ b/internal/plugins/select_test.go @@ -38,6 +38,8 @@ type fakeSelectSource struct { contractCalls map[string]int } +func (f *fakeSelectSource) Transport() Transport { return TransportRPP } + func (f *fakeSelectSource) ListPluginTags(context.Context, string) ([]string, error) { return f.tags, nil } diff --git a/internal/plugins/source.go b/internal/plugins/source.go index 613e7a5d..992ba6c8 100644 --- a/internal/plugins/source.go +++ b/internal/plugins/source.go @@ -22,13 +22,40 @@ import ( "github.com/deckhouse/deckhouse-cli/internal" ) +// Transport names how the plugin subsystem reaches the registry. The two differ in +// more than plumbing - see pluginCatalog for the capability that only one of them +// has - so the transport in use is named in errors rather than left implicit. +type Transport string + +const ( + // TransportRPP goes through the in-cluster registry-packages-proxy, authenticated + // by the caller's kubeconfig identity and needing no registry credentials. It is + // the default and the only supported transport (ADR #386). + TransportRPP Transport = "registry-packages-proxy" + + // TransportRegistry talks to a registry repository directly, with credentials, + // selected by the legacy --source bypass (see source_legacy.go). + TransportRegistry Transport = "registry" +) + // pluginSource is the backend the plugin commands pull from: it lists a plugin's -// versions, reads a plugin contract, and extracts a plugin binary to disk. The -// in-cluster registry-packages-proxy client (rppPluginSource) implements it. -// Listing the whole catalog is not part of the contract: the proxy serves only -// allow-listed images by exact name and exposes no catalog endpoint. +// versions, reads a plugin contract, and extracts a plugin binary to disk. Every +// transport provides this much, addressing a plugin by exact name. type pluginSource interface { + Transport() Transport ListPluginTags(ctx context.Context, pluginName string) ([]string, error) GetPluginContract(ctx context.Context, pluginName, tag string) (*internal.Plugin, error) ExtractPlugin(ctx context.Context, pluginName, tag, destination string) error } + +// pluginCatalog is the optional capability of enumerating the published plugins, +// which a source declares by implementing it. The catalog is the tag list of the +// plugins repository, where each plugin has an image tagged with its name. +// +// Only TransportRegistry has it. The proxy cannot serve it: its allowlist admits +// deckhouse-cli and deckhouse-cli/plugins/ and explicitly refuses the bare +// deckhouse-cli/plugins path, so over TransportRPP there is nothing to ask and the +// request is never made. +type pluginCatalog interface { + ListPluginNames(ctx context.Context) ([]string, error) +} diff --git a/internal/plugins/source_legacy.go b/internal/plugins/source_legacy.go index db36b220..9067c560 100644 --- a/internal/plugins/source_legacy.go +++ b/internal/plugins/source_legacy.go @@ -155,6 +155,22 @@ func (s *registryPluginSource) pluginClient(pluginName string) dkpreg.Client { return s.client.WithSegment(legacyPluginsSegment, pluginName) } +// Transport reports that this source talks to the registry directly. +func (s *registryPluginSource) Transport() Transport { return TransportRegistry } + +// ListPluginNames enumerates the published plugins from the tags of the plugins +// repository, where each plugin has an image tagged with its name. Direct registry +// access reaches that repository, so this transport - and only this one - satisfies +// pluginCatalog. +func (s *registryPluginSource) ListPluginNames(ctx context.Context) ([]string, error) { + names, err := s.client.WithSegment(legacyPluginsSegment).ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("list plugin names: %w", err) + } + + return names, nil +} + func (s *registryPluginSource) ListPluginTags(ctx context.Context, pluginName string) ([]string, error) { tags, err := s.pluginClient(pluginName).ListTags(ctx) if err != nil { diff --git a/internal/plugins/update.go b/internal/plugins/update.go index d849c5e1..bc71404b 100644 --- a/internal/plugins/update.go +++ b/internal/plugins/update.go @@ -30,7 +30,11 @@ import ( // UpdateAll updates every installed plugin to its newest cluster-compatible // version within the current major. A per-plugin failure does not stop the // others; the failures are reported together in the returned error. -func (m *Manager) UpdateAll(ctx context.Context) error { +// +// opts are forwarded to every plugin's install, so only options that make sense +// applied uniformly belong here - the command layer rejects the rest (an exact +// --version or a --use-major pin cannot mean anything across a whole set). +func (m *Manager) UpdateAll(ctx context.Context, opts ...InstallOption) error { plugins, err := m.InstalledPluginNames() if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to read plugins directory: %w", err) @@ -38,7 +42,7 @@ func (m *Manager) UpdateAll(ctx context.Context) error { // A non-root install lives in the home fallback (~/.deckhouse-cli), so this // update must look there too when the configured root has nothing - otherwise - // `d8 dist plugins update all` would be a silent no-op for that install. + // `d8 dist plugins install --all` would be a silent no-op for that install. if len(plugins) == 0 && m.switchToFallbackRoot() { if plugins, err = m.InstalledPluginNames(); err != nil { return fmt.Errorf("failed to read plugins directory: %w", err) @@ -51,7 +55,7 @@ func (m *Manager) UpdateAll(ctx context.Context) error { var failed []string for _, plugin := range plugins { - if err := m.InstallPlugin(ctx, plugin); err != nil { + if err := m.InstallPlugin(ctx, plugin, opts...); err != nil { // Render a child HelpfulError in full so the per-plugin failure keeps // its cause/solution detail instead of flattening to one line. var he *diagnostic.HelpfulError @@ -95,24 +99,5 @@ func (m *Manager) switchToFallbackRoot() bool { // failed install has no symlink and is excluded, so it cannot become an install // target for a plugin the user never had. func (m *Manager) InstalledPluginNames() ([]string, error) { - entries, err := os.ReadDir(layout.PluginsRoot(m.pluginDirectory)) - if err != nil { - return nil, err - } - - names := make([]string, 0, len(entries)) - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - if _, err := os.Lstat(layout.CurrentLinkPath(m.pluginDirectory, entry.Name())); err != nil { - continue - } - - names = append(names, entry.Name()) - } - - return names, nil + return layout.InstalledNames(m.pluginDirectory) } diff --git a/internal/plugins/update_test.go b/internal/plugins/update_test.go index e36cfcf7..47a02827 100644 --- a/internal/plugins/update_test.go +++ b/internal/plugins/update_test.go @@ -66,7 +66,7 @@ func TestInstalledPluginNames(t *testing.T) { func TestUpdateAllFallsBackToHomeInstallRoot(t *testing.T) { // A non-root install lives in ~/.deckhouse-cli while the configured root is - // empty; `d8 dist plugins update all` runs against the configured root and must + // empty; `d8 dist plugins install --all` runs against the configured root and must // still find (and update) the fallback install. t.Setenv("HOME", t.TempDir()) diff --git a/internal/plugins/versions.go b/internal/plugins/versions.go index 2dfa2f4f..e13e2b81 100644 --- a/internal/plugins/versions.go +++ b/internal/plugins/versions.go @@ -23,15 +23,17 @@ import ( "github.com/Masterminds/semver/v3" ) -// PublishedVersions lists the plugin's published tags and returns them as -// semver versions, newest first (unparseable tags are dropped). -func (m *Manager) PublishedVersions(ctx context.Context, pluginName string) ([]*semver.Version, error) { +// PublishedVersions lists the plugin's published releases, newest first +// (unparseable tags are dropped). A plugin is published one tag per platform, so +// the per-platform tags of a release are collapsed into a single entry carrying +// the platforms it was built for. +func (m *Manager) PublishedVersions(ctx context.Context, pluginName string) ([]PluginVersion, error) { tags, err := m.service.ListPluginTags(ctx, pluginName) if err != nil { return nil, fmt.Errorf("failed to list plugin tags: %w", err) } - return sortedSemverDesc(tags), nil + return collapsePlatformTags(sortedSemverDesc(tags)), nil } // InstalledVersionOrNil returns the active installed version of the @@ -47,5 +49,10 @@ func (m *Manager) InstalledVersionOrNil(pluginName string) *semver.Version { return nil } - return current + // A plugin binary reports the version of its own per-platform build, so the + // suffix rides along here too; drop it so the value compares against the + // collapsed releases PublishedVersions returns. + clean, _ := SplitPlatform(current) + + return clean } diff --git a/internal/system/README.md b/internal/system/README.md index e972efc4..5be4caa2 100644 --- a/internal/system/README.md +++ b/internal/system/README.md @@ -56,7 +56,7 @@ d8 system (aliases: s, p, platform) The `s` alias is the recommended short form (`d8 s module list`). `p` and `platform` are legacy aliases kept for backward compatibility with older documentation. -> **Availability:** the built-in `system` command is registered only when the environment variable `DECKHOUSE_PLUGINS_ENABLED` is **not** `true`. When plugins are enabled, `d8 system` is served by a plugin shim instead, and the exact surface may differ from what is documented here. +> **Availability:** the built-in `system` command documented here is registered only when no plugin named `system` is installed. When one is, `d8 system` is served by that plugin instead and the exact surface may differ. Check with `d8 dist plugins list`; `d8 dist plugins remove system` restores the built-in. --- diff --git a/testing/e2e/plugins/test.sh b/testing/e2e/plugins/test.sh index 0e289f91..9e169ba4 100755 --- a/testing/e2e/plugins/test.sh +++ b/testing/e2e/plugins/test.sh @@ -31,3 +31,14 @@ $(PWD)/bin/d8 dist plugins install package echo "" echo "--- TEST INSTALL SECOND PLUGIN ---" $(PWD)/bin/d8 dist plugins install system + +echo "" +echo "--- TEST UPDATE ALL INSTALLED PLUGINS ---" +$(PWD)/bin/d8 dist plugins install --all + +echo "" +echo "--- TEST --all REJECTS A PLUGIN NAME ---" +if $(PWD)/bin/d8 dist plugins install --all package; then + echo "FAIL: --all accepted a plugin name" + exit 1 +fi