diff --git a/.gitignore b/.gitignore index 0df1eea36..54aff79b9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ bin/ # Release assets d8 -dist/ +/dist/ /build/ tmp/ diff --git a/cmd/d8/root.go b/cmd/d8/root.go index e6edb65f1..cb8c23d73 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -40,6 +40,7 @@ import ( backup "github.com/deckhouse/deckhouse-cli/internal/backup/cmd" cr "github.com/deckhouse/deckhouse-cli/internal/cr/cmd" data "github.com/deckhouse/deckhouse-cli/internal/data/cmd" + distcmd "github.com/deckhouse/deckhouse-cli/internal/dist/cmd" iam "github.com/deckhouse/deckhouse-cli/internal/iam/cmd" iamuser "github.com/deckhouse/deckhouse-cli/internal/iam/user/cmd" mirror "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd" @@ -47,7 +48,6 @@ 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" - selfupdatecmd "github.com/deckhouse/deckhouse-cli/internal/selfupdate/cmd" 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" @@ -133,12 +133,10 @@ func (r *RootCommand) registerCommands() { // 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(pluginscmd.NewCommand( - r.logger.Named("plugins-command"), + r.cmd.AddCommand(distcmd.NewCommand( + r.logger.Named("dist-command"), []string{commands.DeliveryKitCommandName, pluginscmd.PackagePluginName}, )) - - r.cmd.AddCommand(selfupdatecmd.NewCommand(r.logger.Named("cli-command"))) } func (r *RootCommand) Execute() error { diff --git a/docs/plugins.md b/docs/plugins.md index bc6f38645..a7e643a2e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,4 +1,4 @@ -# d8 Plugins (`d8 plugins`) +# d8 Plugins (`d8 dist plugins`) Plugins are versioned binaries distributed through the cluster registry. `d8` installs, updates, and removes them for you. @@ -10,14 +10,15 @@ Plugins are versioned binaries distributed through the cluster registry. [Troubleshooting](#troubleshooting) · [Advanced](#advanced-hidden-flags) > [!NOTE] -> The `d8 plugins` command group is hidden from the root `--help` while the -> plugin ecosystem rolls out. The commands below are fully functional. +> The `d8 dist plugins` command group is hidden from `d8 dist --help` while +> the plugin ecosystem rolls out. The commands below are fully functional. ## Plugin source Plugins are pulled from the in-cluster **registry-packages-proxy**, the same -channel as d8 self-update. This is the only supported path: every `d8 plugins` -command reaches the registry through the proxy, so a reachable cluster is +channel as d8 self-update. This is the only supported path: every +`d8 dist plugins` command reaches the registry through the proxy, so a +reachable cluster is required. (A hidden, temporary `--source` flag pulls straight from a registry repo instead - see [Advanced](#advanced-hidden-flags) - but it bypasses the cluster and is not the intended flow.) The access model: @@ -41,22 +42,22 @@ plugins need `packages-download`, CLI self-update needs `cli-download`. | Command | What it does | |---|---| -| `d8 plugins versions ` | lists all published versions of one plugin | -| `d8 plugins install ` | installs the newest version compatible with your cluster | -| `d8 plugins install --version X` | installs an exact version | -| `d8 plugins install --use-major N` | switches majors explicitly | -| `d8 plugins update ` / `update all` | updates within the current major | -| `d8 plugins list` | shows installed plugins (the proxy serves no catalog, so available plugins are not listed) | -| `d8 plugins contract ` | shows a plugin's contract: version, description, requirements | -| `d8 plugins remove ` / `remove all` | removes plugins | +| `d8 dist plugins versions ` | lists all published versions of one plugin | +| `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 contract ` | shows a plugin's contract: version, description, requirements | +| `d8 dist plugins remove ` / `remove all` | removes plugins | ```console -$ d8 plugins versions package +$ d8 dist plugins versions package v0.1.2 newer * v0.0.21 current v0.0.20 -$ d8 plugins install package +$ d8 dist plugins install package Installing plugin: package Tag: v0.0.21 ... @@ -74,7 +75,7 @@ active one: Rules that follow from this layout: -- `d8 plugins update` stays **within the installed major**. Crossing majors is +- `d8 dist plugins update` stays **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. @@ -98,7 +99,7 @@ is downloaded or switched: enforced only if that plugin is already installed. ```console -$ d8 plugins install package +$ d8 dist plugins install package ... Error: plugin requirements not satisfied # e.g. requires plugin delivery-kit ``` @@ -125,17 +126,19 @@ modules) are only *verified* - d8 never changes the cluster for you. | `--use-major N` *(install, update)* | - | 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 plugins` subcommand; the -`--source*` family is hidden - see [Advanced](#advanced-hidden-flags). +The persistent flags above are shared by every `d8 dist plugins` subcommand +(the kubeconfig and `--rpp-*` flags live on the parent `d8 dist` group and are +inherited); the `--source*` family is hidden - see +[Advanced](#advanced-hidden-flags). ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| `image or tag not found` (404) | that plugin - or that specific version - is not published in this cluster's registry | check with `d8 plugins versions `; publishing is the plugin CI's job | +| `image or tag not found` (404) | that plugin - or that specific version - is not published in this cluster's registry | check with `d8 dist plugins versions `; publishing is the plugin CI's job | | `... unauthorized (401)` | no accepted Bearer token (a client-certificate kubeconfig is not enough) | use an OIDC-token kubeconfig (Kubeconfig Generator or `d8 login`) | | `... forbidden (403)` | your identity may not download plugins | ask an admin to bind the ClusterRole `d8:registry-packages-proxy:packages-download`; authorization is cached ~5 min, so retry with a fresh token | -| `... requirements not satisfied` | mandatory **plugin** dependencies are missing or version-incompatible | run `d8 plugins contract `; on `install` deps auto-install, but at plugin *run* time install them manually as the hint says (`d8 plugins install `) | +| `... requirements not satisfied` | mandatory **plugin** dependencies are missing or version-incompatible | run `d8 dist plugins contract `; on `install` deps auto-install, but at plugin *run* time install them manually as the hint says (`d8 dist plugins install `) | | `... requires Kubernetes/Deckhouse/module ...` | a **cluster-side** requirement is unmet (a different message from the row above) | upgrade the cluster/module, or pass `--skip-cluster-checks` to bypass verification | | `... upstream error (5xx)` | the proxy could not reach the backing registry | retry shortly, or check the `registry-packages-proxy` pods in `d8-cloud-instance-manager` | | `endpoint discovery ... failed`, `x509:` to the API server | endpoint discovery goes through your kubeconfig's **API server** (not the proxy), which was unreachable or had an invalid certificate | confirm the API server is reachable with a valid cert, or skip discovery with `--rpp-endpoint https://registry-packages-proxy.` (`D8_RPP_ENDPOINT`) | diff --git a/docs/self-update.md b/docs/self-update.md index 31a9d3991..094ca3f30 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -1,4 +1,4 @@ -# d8 Self-Update (`d8 cli`) +# d8 Self-Update (`d8 dist`) `d8` updates itself **through the cluster**. No registry credentials needed: @@ -14,13 +14,13 @@ [Flags & env](#flags-and-environment-variables) · [Troubleshooting](#troubleshooting) -> Plugin management (`d8 plugins`) uses the same access model and is covered -> in [plugins.md](plugins.md). +> Plugin management (`d8 dist plugins`) uses the same access model and is +> covered in [plugins.md](plugins.md). ## How access works ``` -d8 cli update +d8 dist update │ Bearer token from your kubeconfig ▼ registry-packages-proxy. (found automatically via Ingress) @@ -63,24 +63,34 @@ kubectl create clusterrolebinding d8-cli-download \ | Command | What it does | |---|---| -| `d8 cli check` | reports whether a newer version is available | -| `d8 cli versions` (alias: `list`) | lists published versions, newest first | -| `d8 cli update [--version X]` | installs a version and switches to it | -| `d8 cli use ` | switches to a version; instant if it is already installed | +| `d8 dist status` | prints a distribution summary: the d8 version, installed plugins, what is outdated (local data only when the cluster is unreachable) | +| `d8 dist check` | reports whether a newer version is available | +| `d8 dist versions` (alias: `list`) | lists published versions, newest first | +| `d8 dist update [--version X]` | installs a version and switches to it | +| `d8 dist use ` | switches to a version; instant if it is already installed | ```console -$ d8 cli check -A newer deckhouse-cli is available: v0.14.0 (current: v0.13.1). Run 'd8 cli update' to upgrade. +$ d8 dist status +deckhouse-cli (d8) + Version: v0.13.1 + Latest: v0.14.0 update available - run 'd8 dist update' -$ d8 cli versions +Plugins (1 installed): + NAME VERSION LATEST STATUS + system 1.2.0 1.2.0 up to date + +$ d8 dist check +A newer deckhouse-cli is available: v0.14.0 (current: v0.13.1). Run 'd8 dist update' to upgrade. + +$ d8 dist versions v0.14.0 newer * v0.13.1 current installed v0.13.0 installed -$ d8 cli update +$ d8 dist update Updating deckhouse-cli to v0.14.0... deckhouse-cli updated to v0.14.0. -Previous version v0.13.1 remains installed - switch back with 'd8 cli use v0.13.1'. +Previous version v0.13.1 remains installed - switch back with 'd8 dist use v0.13.1'. ``` ## How versions are stored @@ -108,17 +118,17 @@ What this gives you: ## Switching and rollback ```console -$ d8 cli use v0.13.1 # already installed: instant, no cluster access +$ d8 dist use v0.13.1 # already installed: instant, no cluster access Switched deckhouse-cli to v0.13.1 (installed locally). -Previous version v0.14.0 remains installed - switch back with 'd8 cli use v0.14.0'. +Previous version v0.14.0 remains installed - switch back with 'd8 dist use v0.14.0'. -$ d8 cli use 0.13.0 # the "v" prefix is optional -$ d8 cli use v0.13.0 # repeated: "deckhouse-cli is already at v0.13.0." +$ d8 dist use 0.13.0 # the "v" prefix is optional +$ d8 dist use v0.13.0 # repeated: "deckhouse-cli is already at v0.13.0." ``` -- Rollback after an update: `d8 cli use ` - the previous version +- Rollback after an update: `d8 dist use ` - the previous version stays installed. -- `d8 cli use ` completes the locally installed versions (enable shell +- `d8 dist use ` completes the locally installed versions (enable shell completion with `d8 completion`). ## Flags and environment variables @@ -140,4 +150,4 @@ $ d8 cli use v0.13.0 # repeated: "deckhouse-cli is already at v0.13.0 | `x509: certificate signed by unknown authority` | the proxy endpoint uses a CA your system does not trust | pass `--rpp-ca-file ` | | `x509: ... doesn't contain any IP SANs` | you are connecting to a pod IP instead of the Ingress host | set `--rpp-endpoint https://registry-packages-proxy.` | | `deckhouse-cli is already up to date` | you run the latest version | use `--version X` to install an exact (older) one | -| `d8 cli use X` downloads although X was installed before | the local store was cleaned, or X was installed on another machine/user | it will download once and stay installed | +| `d8 dist use X` downloads although X was installed before | the local store was cleaned, or X was installed on another machine/user | it will download once and stay installed | diff --git a/internal/dist/cmd/check.go b/internal/dist/cmd/check.go new file mode 100644 index 000000000..125b71360 --- /dev/null +++ b/internal/dist/cmd/check.go @@ -0,0 +1,55 @@ +/* +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 distcmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/version" +) + +func newCheckCommand(logger *dkplog.Logger) *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Report whether a newer deckhouse-cli version is available", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + updater, err := newUpdater(cmd.Context(), cmd, logger) + if err != nil { + return err + } + + latest, newer, err := updater.LatestVersion(cmd.Context(), version.Version) + if err != nil { + return err + } + + if newer { + fmt.Printf("A newer deckhouse-cli is available: %s (current: %s). Run 'd8 dist update' to upgrade.\n", + verNew.Sprint(latest), verOld.Sprint(version.Version)) + } else { + fmt.Printf("deckhouse-cli is up to date (%s).\n", verCur.Sprint(version.Version)) + } + + return nil + }, + } +} diff --git a/internal/dist/cmd/dist.go b/internal/dist/cmd/dist.go new file mode 100644 index 000000000..27113a3bd --- /dev/null +++ b/internal/dist/cmd/dist.go @@ -0,0 +1,98 @@ +/* +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 distcmd + +import ( + "github.com/spf13/cobra" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/dist/cmd/errdetect" + pluginscmd "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd" + pluginflags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" +) + +// NewCommand returns the `d8 dist` command tree - management of the d8 +// distribution: the deckhouse-cli binary itself and its plugins. It reaches +// the registry-packages-proxy with the caller's kubeconfig identity. +// builtinCommands are built-in command names that satisfy a plugin dependency +// of the same name (see pluginscmd.NewCommand). +func NewCommand(logger *dkplog.Logger, builtinCommands []string) *cobra.Command { + cmd := &cobra.Command{ + Use: "dist", + Short: "Manage the d8 distribution: the deckhouse-cli binary and its plugins", + Long: "Manage the d8 distribution - the deckhouse-cli binary and its plugins.\n\n" + + "See the state of the distribution with 'd8 dist status': the d8 version, installed\n" + + "plugins, and what is outdated.\n\n" + + "Versions are served by the in-cluster registry-packages-proxy, authenticated by the\n" + + "current kubeconfig identity.\n\n" + + "Update the binary with 'd8 dist update'; manage plugins under 'd8 dist plugins'.\n\n" + + "Environment variables:\n" + + " " + rppflags.EnvEndpoint + " registry-packages-proxy base URL (otherwise discovered from the cluster)\n" + + " " + rppflags.EnvCAFile + " PEM CA bundle to verify the proxy TLS certificate\n" + + " KUBECONFIG path to the kubeconfig file", + // Print help when called bare, like the d8 root. The explicit Run makes + // Args effective: without it cobra shows help before validating args, + // and a stray argument ('d8 dist junk') would pass silently. + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + _ = cmd.Help() + }, + } + + cmd.AddCommand(newStatusCommand(logger)) + cmd.AddCommand(newCheckCommand(logger)) + cmd.AddCommand(newUpdateCommand(logger)) + cmd.AddCommand(newUseCommand(logger)) + cmd.AddCommand(newVersionsCommand(logger)) + + // Cluster access flags (kubeconfig/context, rpp-*) are owned by the dist + // root for the whole tree; the plugins subtree adds only its own flags. + pluginflags.AddKubeFlags(cmd.PersistentFlags()) + rppflags.AddFlags(cmd.PersistentFlags()) + + // Wrap before mounting plugins: the plugins subtree classifies its + // failures with its own errdetect. + wrapProxyDiagnostics(cmd) + + cmd.AddCommand(pluginscmd.NewCommand(logger.Named("plugins"), builtinCommands)) + + return cmd +} + +// wrapProxyDiagnostics turns recognized registry-packages-proxy failures into +// colored diagnostics at the command level (per pkg/diagnostic: classify in the +// command, never in root.go). It wraps every RunE in the tree; errdetect.Diagnose +// returns nil for non-proxy and already-diagnosed errors, leaving them untouched. +func wrapProxyDiagnostics(cmd *cobra.Command) { + if cmd.RunE != nil { + inner := cmd.RunE + cmd.RunE = func(c *cobra.Command, args []string) error { + err := inner(c, args) + if diag := errdetect.Diagnose(err); diag != nil { + return diag + } + + return err + } + } + + for _, sub := range cmd.Commands() { + wrapProxyDiagnostics(sub) + } +} diff --git a/internal/dist/cmd/doc.go b/internal/dist/cmd/doc.go new file mode 100644 index 000000000..979047a62 --- /dev/null +++ b/internal/dist/cmd/doc.go @@ -0,0 +1,34 @@ +/* +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 distcmd implements the `d8 dist` command tree - management of the +// d8 distribution: the deckhouse-cli binary itself and its plugins. +// +// d8 dist status summary: the d8 version, plugins, what is outdated +// d8 dist check report whether a newer deckhouse-cli is available +// d8 dist update update the deckhouse-cli binary +// d8 dist use switch the deckhouse-cli binary to a specific version +// d8 dist versions list deckhouse-cli versions published in the registry +// d8 dist plugins the plugins subtree (implemented in internal/plugins/cmd) +// +// The package holds only the command layer. The binary version machinery +// (store, updater) lives in internal/selfupdate; the plugins machinery in +// internal/plugins. +// +// File layout: dist.go assembles the tree, one command per file (check.go, +// status.go, update.go, use.go, versions.go), updater.go builds the shared +// Updater, ui.go holds the output palette. +package distcmd diff --git a/internal/selfupdate/cmd/errdetect/diagnose.go b/internal/dist/cmd/errdetect/diagnose.go similarity index 97% rename from internal/selfupdate/cmd/errdetect/diagnose.go rename to internal/dist/cmd/errdetect/diagnose.go index 45f819cb2..18eb2590d 100644 --- a/internal/selfupdate/cmd/errdetect/diagnose.go +++ b/internal/dist/cmd/errdetect/diagnose.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package errdetect maps registry-packages-proxy failures from `d8 cli` to +// Package errdetect maps registry-packages-proxy failures from `d8 dist` to // HelpfulErrors with CLI-specific guidance. package errdetect @@ -46,7 +46,7 @@ func Diagnose(err error) *diagnostic.HelpfulError { case errors.Is(err, rpp.ErrNotFound): return help(err, "registry-packages-proxy: version not found (404)", "this deckhouse-cli version is not published", - "list available versions with 'd8 cli versions'") + "list available versions with 'd8 dist versions'") case errors.Is(err, rpp.ErrUpstream): return help(err, "registry-packages-proxy: upstream error (5xx)", "the proxy could not reach the backing registry", diff --git a/internal/selfupdate/cmd/errdetect/diagnose_test.go b/internal/dist/cmd/errdetect/diagnose_test.go similarity index 96% rename from internal/selfupdate/cmd/errdetect/diagnose_test.go rename to internal/dist/cmd/errdetect/diagnose_test.go index 80a315d7e..cd77cd619 100644 --- a/internal/selfupdate/cmd/errdetect/diagnose_test.go +++ b/internal/dist/cmd/errdetect/diagnose_test.go @@ -38,7 +38,7 @@ func TestDiagnose(t *testing.T) { }{ {"401", rpp.ErrUnauthorized, "unauthorized (401)", "OIDC"}, {"403", rpp.ErrForbidden, "forbidden (403)", "cli-download"}, - {"404", rpp.ErrNotFound, "version not found (404)", "d8 cli versions"}, + {"404", rpp.ErrNotFound, "version not found (404)", "d8 dist versions"}, {"5xx", rpp.ErrUpstream, "upstream error (5xx)", "registry-packages-proxy pods"}, {"discovery", rpp.ErrEndpointDiscovery, "endpoint discovery via the Kubernetes API failed", "--rpp-endpoint"}, } diff --git a/internal/dist/cmd/status.go b/internal/dist/cmd/status.go new file mode 100644 index 000000000..014b8bea9 --- /dev/null +++ b/internal/dist/cmd/status.go @@ -0,0 +1,269 @@ +/* +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 distcmd + +import ( + "context" + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/plugins" + pluginflags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/internal/selfupdate" +) + +// pluginStatus is the freshness verdict for one installed plugin. +type pluginStatus int + +const ( + statusUnknown pluginStatus = iota // no freshness data (cluster unreachable or lookup failed) + statusCurrent + statusOutdated +) + +type pluginRow struct { + name string + installed string + latest string // "" when unknown + status pluginStatus +} + +// summaryData is everything the summary renders. Freshness fields stay empty +// when the cluster was unreachable; offlineReason then carries the cause. +type summaryData struct { + current string + latest string // "" when unknown + newer bool + plugins []pluginRow + offlineReason string +} + +// newStatusCommand returns `d8 dist status` - a summary of the distribution: +// the running d8 version and installed plugins, with update status from the +// registry-packages-proxy. A cluster/proxy failure degrades the summary to +// local data instead of failing the command. +func newStatusCommand(logger *dkplog.Logger) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the distribution status: the d8 version, plugins, what is outdated", + Long: "Show the state of the d8 distribution: the running deckhouse-cli version and the\n" + + "installed plugins, with update status from the registry-packages-proxy.\n\n" + + "When the cluster is unreachable, prints the local data and a warning instead of failing.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + fmt.Print(renderSummary(collectSummary(cmd.Context(), cmd, logger))) + + return nil + }, + } +} + +// collectSummary gathers the local state (always) and the freshness state +// (best-effort): the first cluster/proxy failure switches the summary to the +// degraded, local-only form. +func collectSummary(ctx context.Context, cmd *cobra.Command, logger *dkplog.Logger) summaryData { + store, err := selfupdate.NewStore() + if err != nil { + // A nil store is fine here: the summary then reports the compiled-in version. + logger.Debug("version store unavailable", dkplog.Err(err)) + } + + data := summaryData{current: activeVersionTag(store)} + + manager := plugins.NewManager(logger.Named("plugins")) + + // The summary is read-only: resolve the root that actually holds an + // install instead of creating one (EnsureInstallRoot is for the mutating + // plugins commands). No install anywhere = no plugins, silently. + if root, ok := installedPluginsRoot(); ok { + manager.SetDirectory(root) + + for _, p := range manager.List() { + data.plugins = append(data.plugins, pluginRow{name: p.Name, installed: p.Version}) + } + } + + updater, err := newUpdater(ctx, cmd, logger) + if err != nil { + data.offlineReason = err.Error() + + return data + } + + latest, newer, err := updater.LatestVersion(ctx, data.current) + if err != nil { + data.offlineReason = err.Error() + + return data + } + + data.latest, data.newer = latest, newer + + if len(data.plugins) == 0 { + return data + } + + if err := manager.InitPluginServices(ctx); err != nil { + data.offlineReason = err.Error() + + return data + } + + // Per-plugin freshness uses the machinery's own notion of "latest" (the + // highest stable version, the one a default install would pick). One + // failed lookup leaves that row unknown, not the whole summary degraded. + for i := range data.plugins { + row := &data.plugins[i] + + latest, err := manager.LatestVersion(ctx, row.name) + if err != nil { + logger.Debug("plugin freshness unavailable", dkplog.Err(err)) + + continue + } + + row.latest = latest.Original() + row.status = statusCurrent + + if installed, err := semver.NewVersion(row.installed); err == nil && latest.GreaterThan(installed) { + row.status = statusOutdated + } + } + + return data +} + +// renderSummary renders the summary. Pure: all data is in summaryData, so the +// layout is unit-testable. Verdicts are worded in plain text, not by colour +// alone (colour is lost in piped output and to colour-blind readers). +func renderSummary(d summaryData) string { + var b strings.Builder + + fmt.Fprintf(&b, "%s\n", sumTitle("deckhouse-cli (d8)")) + fmt.Fprintf(&b, " %s %s\n", sumLabel(fmt.Sprintf("%-9s", "Version:")), verCur.Sprint(d.current)) + + if d.latest != "" { + if d.newer { + fmt.Fprintf(&b, " %s %s %s\n", + sumLabel(fmt.Sprintf("%-9s", "Latest:")), verNew.Sprint(d.latest), + sumWarn("update available - run 'd8 dist update'")) + } else { + fmt.Fprintf(&b, " %s %s %s\n", + sumLabel(fmt.Sprintf("%-9s", "Latest:")), sumDim(d.latest), sumGood("up to date")) + } + } + + b.WriteString("\n") + writePluginsSection(&b, d) + + if d.offlineReason != "" { + fmt.Fprintf(&b, "\n%s\n", sumWarn("Warning: could not check for updates - cluster unreachable.")) + fmt.Fprintf(&b, "%s\n", sumDim(" ("+d.offlineReason+")")) + } + + return b.String() +} + +// writePluginsSection renders the installed-plugins table. The LATEST/STATUS +// columns appear only when freshness was checked (the summary is not degraded). +func writePluginsSection(b *strings.Builder, d summaryData) { + if len(d.plugins) == 0 { + fmt.Fprintf(b, "%s none installed\n", sumTitle("Plugins:")) + fmt.Fprintf(b, "%s\n", sumDim("Install with 'd8 dist plugins install '.")) + + return + } + + fmt.Fprintf(b, "%s\n", sumTitle(fmt.Sprintf("Plugins (%d installed):", len(d.plugins)))) + + withFreshness := d.offlineReason == "" + + nameW, verW, latestW := len("NAME"), len("VERSION"), len("LATEST") + for _, p := range d.plugins { + nameW = max(nameW, len(p.name)) + verW = max(verW, len(p.installed)) + latestW = max(latestW, len(latestCell(p))) + } + + if withFreshness { + fmt.Fprintf(b, " %s\n", sumDim(fmt.Sprintf("%-*s %-*s %-*s %s", nameW, "NAME", verW, "VERSION", latestW, "LATEST", "STATUS"))) + } else { + fmt.Fprintf(b, " %s\n", sumDim(fmt.Sprintf("%-*s %s", nameW, "NAME", "VERSION"))) + } + + outdated := false + + for _, p := range d.plugins { + if !withFreshness { + fmt.Fprintf(b, " %-*s %s\n", nameW, p.name, p.installed) + + continue + } + + latest := fmt.Sprintf("%-*s", latestW, latestCell(p)) + + var status string + + switch p.status { + case statusOutdated: + outdated = true + latest = verNew.Sprint(latest) + status = sumWarn("update available") + case statusCurrent: + status = sumGood("up to date") + default: + status = sumDim("unknown") + } + + fmt.Fprintf(b, " %-*s %-*s %s %s\n", nameW, p.name, verW, p.installed, latest, status) + } + + if outdated { + fmt.Fprintf(b, "%s\n", sumDim("Update a plugin with 'd8 dist plugins update ' or 'd8 dist plugins update all'.")) + } +} + +// latestCell is the LATEST column value: "?" when the lookup failed. +func latestCell(p pluginRow) string { + if p.latest == "" { + return "?" + } + + return p.latest +} + +// 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. +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 +} diff --git a/internal/dist/cmd/status_test.go b/internal/dist/cmd/status_test.go new file mode 100644 index 000000000..5bf335a1a --- /dev/null +++ b/internal/dist/cmd/status_test.go @@ -0,0 +1,117 @@ +/* +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 distcmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRenderSummaryFull(t *testing.T) { + withoutColor(t) + + out := renderSummary(summaryData{ + current: "v0.33.5", + latest: "v0.34.0", + newer: true, + plugins: []pluginRow{ + {name: "dk", installed: "0.5.1", latest: "0.5.1", status: statusCurrent}, + {name: "system", installed: "1.2.0", latest: "1.3.0", status: statusOutdated}, + }, + }) + + assert.Equal(t, strings.Join([]string{ + "deckhouse-cli (d8)", + " Version: v0.33.5", + " Latest: v0.34.0 update available - run 'd8 dist update'", + "", + "Plugins (2 installed):", + " 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'.", + "", + }, "\n"), out) +} + +func TestRenderSummaryUpToDate(t *testing.T) { + withoutColor(t) + + out := renderSummary(summaryData{ + current: "v0.34.0", + latest: "v0.34.0", + plugins: []pluginRow{ + {name: "dk", installed: "0.5.1", latest: "0.5.1", status: statusCurrent}, + }, + }) + + assert.Contains(t, out, "Latest: v0.34.0 up to date") + assert.NotContains(t, out, "Update a plugin", "no update hint when nothing is outdated") +} + +func TestRenderSummaryDegraded(t *testing.T) { + withoutColor(t) + + out := renderSummary(summaryData{ + current: "v0.33.5", + offlineReason: "set up kubernetes client: no kubeconfig", + plugins: []pluginRow{ + {name: "dk", installed: "0.5.1"}, + }, + }) + + assert.Equal(t, strings.Join([]string{ + "deckhouse-cli (d8)", + " Version: v0.33.5", + "", + "Plugins (1 installed):", + " NAME VERSION", + " dk 0.5.1", + "", + "Warning: could not check for updates - cluster unreachable.", + " (set up kubernetes client: no kubeconfig)", + "", + }, "\n"), out) +} + +func TestRenderSummaryNoPlugins(t *testing.T) { + withoutColor(t) + + out := renderSummary(summaryData{current: "v0.33.5", latest: "v0.33.5"}) + + assert.Contains(t, out, "Plugins: none installed") + assert.Contains(t, out, "Install with 'd8 dist plugins install '.") +} + +func TestRenderSummaryUnknownPluginFreshness(t *testing.T) { + withoutColor(t) + + // One plugin's tag listing failed: its row degrades to "?"/unknown, the + // summary itself stays fresh. + out := renderSummary(summaryData{ + current: "v0.33.5", + latest: "v0.33.5", + plugins: []pluginRow{ + {name: "dk", installed: "0.5.1", status: statusUnknown}, + }, + }) + + assert.Contains(t, out, " dk 0.5.1 ? unknown") + assert.NotContains(t, out, "Warning:") +} diff --git a/internal/dist/cmd/ui.go b/internal/dist/cmd/ui.go new file mode 100644 index 000000000..5b80c528d --- /dev/null +++ b/internal/dist/cmd/ui.go @@ -0,0 +1,40 @@ +/* +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 distcmd + +import "github.com/fatih/color" + +// Semantic output palette shared by the dist commands: green = the version +// being moved to, cyan+bold = the active version, faint = a superseded +// version. fatih/color drops ANSI on a non-TTY and under NO_COLOR. +var ( + okMark = color.New(color.FgGreen, color.Bold) + verNew = color.New(color.FgGreen) + verCur = color.New(color.FgCyan, color.Bold) + verOld = color.New(color.Faint) +) + +// Summary accents, following the semantic palette convention of +// internal/mirror/summaryui. Apply colours AFTER width padding: the escape +// codes are zero-width on screen but count toward fmt's field widths. +var ( + sumTitle = color.New(color.FgCyan, color.Bold).SprintFunc() // section headers + sumLabel = color.New(color.FgCyan).SprintFunc() // field labels (scan anchors) + sumGood = color.New(color.FgGreen).SprintFunc() // up to date + sumWarn = color.New(color.FgYellow).SprintFunc() // attention (outdated, degraded) + sumDim = color.New(color.Faint).SprintFunc() // secondary text and hints +) diff --git a/internal/dist/cmd/update.go b/internal/dist/cmd/update.go new file mode 100644 index 000000000..21116a010 --- /dev/null +++ b/internal/dist/cmd/update.go @@ -0,0 +1,75 @@ +/* +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 distcmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/version" +) + +func newUpdateCommand(logger *dkplog.Logger) *cobra.Command { + var targetVersion string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update deckhouse-cli to the latest version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + updater, err := newUpdater(cmd.Context(), cmd, logger) + if err != nil { + return err + } + + tag := targetVersion + if tag == "" { + latest, newer, err := updater.LatestVersion(cmd.Context(), version.Version) + if err != nil { + return err + } + + if !newer { + fmt.Printf("deckhouse-cli is already up to date (%s).\n", verCur.Sprint(version.Version)) + + return nil + } + + tag = latest + } + + fmt.Printf("Updating deckhouse-cli to %s...\n", verNew.Sprint(tag)) + + res, err := updater.Apply(cmd.Context(), tag) + if err != nil { + return err + } + + fmt.Printf("%s deckhouse-cli updated to %s.\n", okMark.Sprint("✓"), verNew.Sprint(tag)) + printSwitchNotes(res) + + return nil + }, + } + + cmd.Flags().StringVar(&targetVersion, "version", "", "Exact version to install; downgrades are allowed (default: the latest).") + + return cmd +} diff --git a/internal/dist/cmd/updater.go b/internal/dist/cmd/updater.go new file mode 100644 index 000000000..e63066b1e --- /dev/null +++ b/internal/dist/cmd/updater.go @@ -0,0 +1,76 @@ +/* +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 distcmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" + rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" + "github.com/deckhouse/deckhouse-cli/internal/selfupdate" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" + "github.com/deckhouse/deckhouse-cli/internal/version" +) + +// activeVersionTag reports the running deckhouse-cli version. For a +// store-managed install the store's `current` symlink names the active version +// reliably even when the binary was built without version ldflags; trust it +// only when this invocation runs through the store. +func activeVersionTag(store *selfupdate.Store) string { + current := version.Version + + if exePath, err := selfupdate.CurrentExecutable(); err == nil && store.Contains(exePath) { + if tag := store.CurrentTag(); tag != "" { + current = tag + } + } + + return current +} + +// newUpdater builds an Updater backed by the registry-packages-proxy, reached +// with the kubeconfig identity from the command's flags. +func newUpdater(ctx context.Context, cmd *cobra.Command, logger *dkplog.Logger) (*selfupdate.Updater, error) { + kubeconfig, _ := cmd.Flags().GetString("kubeconfig") + kubeContext, _ := cmd.Flags().GetString("context") + + restConfig, kube, err := utilk8s.SetupK8sClientSet(kubeconfig, kubeContext) + if err != nil { + return nil, fmt.Errorf("set up kubernetes client: %w", err) + } + + client, err := rpp.NewClusterClient( + ctx, kube, restConfig, logger.Named("registry-packages-proxy"), + rppflags.Endpoint, rppflags.CAFile, rppflags.InsecureSkipTLSVerify, + ) + if err != nil { + return nil, fmt.Errorf("build registry-packages-proxy client: %w", err) + } + + store, err := selfupdate.NewStore() + if err != nil { + // A nil store only disables retention for `d8 dist use`; updating still works. + logger.Debug("version store unavailable", dkplog.Err(err)) + } + + return selfupdate.NewUpdater(selfupdate.NewRPPSource(client), store, logger.Named("selfupdate")), nil +} diff --git a/internal/selfupdate/cmd/use.go b/internal/dist/cmd/use.go similarity index 96% rename from internal/selfupdate/cmd/use.go rename to internal/dist/cmd/use.go index db6d68c04..33c04c581 100644 --- a/internal/selfupdate/cmd/use.go +++ b/internal/dist/cmd/use.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package selfupdatecmd +package distcmd import ( "fmt" @@ -29,7 +29,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/version" ) -// newUseCommand returns `d8 cli use ` - switch the d8 binary to a +// newUseCommand returns `d8 dist use ` - switch the d8 binary to a // specific version by repointing the version store's `current` symlink, // preferring locally installed versions over a download. func newUseCommand(logger *dkplog.Logger) *cobra.Command { @@ -130,11 +130,11 @@ func printSwitchNotes(res selfupdate.SwitchResult) { } if res.PrevTag != "" { - fmt.Printf("Previous version %s remains installed - switch back with 'd8 cli use %s'.\n", verOld.Sprint(res.PrevTag), res.PrevTag) + fmt.Printf("Previous version %s remains installed - switch back with 'd8 dist use %s'.\n", verOld.Sprint(res.PrevTag), res.PrevTag) } } -// completeStoredVersions offers the locally installed versions for `d8 cli use +// completeStoredVersions offers the locally installed versions for `d8 dist use // `. Completion must stay instant and side-effect-free (the same contract // root.go enforces for __complete), so it reads only the store. // Versions that switch offline are exactly the ones worth suggesting. diff --git a/internal/selfupdate/cmd/use_test.go b/internal/dist/cmd/use_test.go similarity index 94% rename from internal/selfupdate/cmd/use_test.go rename to internal/dist/cmd/use_test.go index 685e746f9..338fe3952 100644 --- a/internal/selfupdate/cmd/use_test.go +++ b/internal/dist/cmd/use_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package selfupdatecmd +package distcmd import ( "context" @@ -28,7 +28,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/selfupdate" ) -// TestStoredVersionCompletions checks the `d8 cli use ` source: stored +// TestStoredVersionCompletions checks the `d8 dist use ` source: stored // versions newest-first, filtered by the typed prefix, with a description. func TestStoredVersionCompletions(t *testing.T) { dir := t.TempDir() diff --git a/internal/selfupdate/cmd/list.go b/internal/dist/cmd/versions.go similarity index 86% rename from internal/selfupdate/cmd/list.go rename to internal/dist/cmd/versions.go index 00bd95b6f..f2e10c0dc 100644 --- a/internal/selfupdate/cmd/list.go +++ b/internal/dist/cmd/versions.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package selfupdatecmd +package distcmd import ( "fmt" @@ -27,7 +27,6 @@ import ( dkplog "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse-cli/internal/selfupdate" - "github.com/deckhouse/deckhouse-cli/internal/version" ) func newVersionsCommand(logger *dkplog.Logger) *cobra.Command { @@ -59,16 +58,7 @@ func newVersionsCommand(logger *dkplog.Logger) *cobra.Command { installed := store.List() - // For a store-managed install the `current` symlink names the active - // version reliably even when the binary was built without version - // ldflags; trust it only when this invocation runs through the store. - current := version.Version - - if exePath, err := selfupdate.CurrentExecutable(); err == nil && store.Contains(exePath) { - if tag := store.CurrentTag(); tag != "" { - current = tag - } - } + current := activeVersionTag(store) lines, currentListed := formatVersionList(versions, current, installed) for _, line := range lines { @@ -76,7 +66,7 @@ func newVersionsCommand(logger *dkplog.Logger) *cobra.Command { } if extra := storedOnly(installed, versions); len(extra) > 0 { - fmt.Println("\nInstalled locally (switch with 'd8 cli use'), not published in the registry:") + fmt.Println("\nInstalled locally (switch with 'd8 dist use'), not published in the registry:") for _, v := range extra { fmt.Printf(" %s\n", v.Original()) @@ -94,7 +84,7 @@ func newVersionsCommand(logger *dkplog.Logger) *cobra.Command { // formatVersionList renders the version list newest-first: versions newer than // current are green, the current one is starred and cyan, older ones are dimmed. -// Versions present in the local store carry an "installed" marker - `d8 cli use` +// Versions present in the local store carry an "installed" marker - `d8 dist use` // switches to them without a download. A non-semver current (dev build) produces // a plain uncolored list. Reports whether the current version appeared in the list. func formatVersionList(versions []*semver.Version, current string, installed []*semver.Version) ([]string, bool) { @@ -156,7 +146,7 @@ func formatVersionList(versions []*semver.Version, current string, installed []* } // storedOnly returns stored versions absent from the published list (the registry -// was re-pointed or pruned); they remain switchable via `d8 cli use`. +// was re-pointed or pruned); they remain switchable via `d8 dist use`. func storedOnly(installed, published []*semver.Version) []*semver.Version { extra := make([]*semver.Version, 0, len(installed)) diff --git a/internal/selfupdate/cmd/list_test.go b/internal/dist/cmd/versions_test.go similarity index 99% rename from internal/selfupdate/cmd/list_test.go rename to internal/dist/cmd/versions_test.go index de184ad02..aaba60e1e 100644 --- a/internal/selfupdate/cmd/list_test.go +++ b/internal/dist/cmd/versions_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package selfupdatecmd +package distcmd import ( "testing" diff --git a/internal/plugins/README.md b/internal/plugins/README.md index 4a791065e..343bb820e 100644 --- a/internal/plugins/README.md +++ b/internal/plugins/README.md @@ -3,9 +3,10 @@ The `internal/plugins` package manages d8 plugins: standalone binaries published to an OCI registry that d8 installs, updates, and runs as if they were native subcommands. The machinery lives in this package (the -`Manager`); the `d8 plugins` cobra commands are a thin layer on top of it in -`internal/plugins/cmd` (package `pluginscmd`), one file per command - the same -split `internal/selfupdate` / `internal/selfupdate/cmd` uses. +`Manager`); the `d8 dist plugins` cobra commands are a thin layer on top of it +in `internal/plugins/cmd` (package `pluginscmd`), one file per command, +mounted as a subtree of `d8 dist` (`internal/dist/cmd`) - the same +machinery/commands split `internal/selfupdate` / `internal/dist/cmd` uses. ## Why @@ -19,13 +20,13 @@ split `internal/selfupdate` / `internal/selfupdate/cmd` uses. | Command | What it does | |---|---| -| `d8 plugins install [--version X] [--use-major N] [--force]` | install or switch a plugin version | -| `d8 plugins update [--use-major N]` | update to the newest cluster-compatible version within the current major | -| `d8 plugins update all` | the same for every installed plugin | -| `d8 plugins list` | list installed plugins (the proxy serves no catalog, so available plugins cannot be listed) | -| `d8 plugins versions ` | list all published versions of one plugin (installed one marked; same verb as `d8 cli versions`) | -| `d8 plugins contract ` | show a plugin's contract | -| `d8 plugins remove ` | remove an installed plugin | +| `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 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 | ## Plugin source @@ -184,8 +185,8 @@ A failure at any step leaves the previous version installed and working. | `source_legacy.go` | the hidden `--source` direct-registry bypass (temporary, pre-#386; force-enables `--skip-cluster-checks`) | | `builtins.go` | built-in command names (`delivery-kit`, `package`) that satisfy a same-named plugin dependency by presence - no version check, no registry lookup | | `layout/` | on-disk path layout | -| `flags/` | the `d8 plugins` flag set | -| `cmd/` | the `d8 plugins ...` command tree and the per-plugin wrapper command, one file per command | +| `flags/` | the plugin-specific flag set (`--plugins-dir`, `--skip-cluster-checks`); the kubeconfig and `rpp-*` flags live on the `d8 dist` root and are inherited | +| `cmd/` | the `d8 dist plugins ...` command tree and the per-plugin wrapper command, one file per command | | `cmd/errdetect/` | maps registry-packages-proxy errors (401/403/404/5xx/endpoint-discovery) to actionable hints | Related: `internal/rpp` (proxy HTTP client), `internal/lockfile` (install lock), diff --git a/internal/plugins/cmd/doc.go b/internal/plugins/cmd/doc.go new file mode 100644 index 000000000..ee29c4fa0 --- /dev/null +++ b/internal/plugins/cmd/doc.go @@ -0,0 +1,30 @@ +/* +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 implements the `d8 dist plugins` command tree and the +// per-plugin wrapper command on top of the internal/plugins machinery. +// +// 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 remove remove an installed plugin +// +// The subtree is mounted under `d8 dist` (internal/dist/cmd) and inherits the +// cluster access flags (kubeconfig/context, rpp-*) from it. File layout: one +// command per file, plugin.go holds the per-plugin wrapper (e.g. `d8 system`). +package pluginscmd diff --git a/internal/plugins/cmd/errdetect/diagnose.go b/internal/plugins/cmd/errdetect/diagnose.go index 731fed55f..99b3b2432 100644 --- a/internal/plugins/cmd/errdetect/diagnose.go +++ b/internal/plugins/cmd/errdetect/diagnose.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package errdetect maps registry-packages-proxy failures from `d8 plugins` to +// Package errdetect maps registry-packages-proxy failures from `d8 dist plugins` to // HelpfulErrors with plugin-specific guidance. package errdetect @@ -46,7 +46,7 @@ func Diagnose(err error) *diagnostic.HelpfulError { case errors.Is(err, rpp.ErrNotFound): return help(err, "registry-packages-proxy: plugin or version not found (404)", "this plugin or version is not published", - "check the name and version with 'd8 plugins versions '", + "check the name and version with 'd8 dist plugins versions '", "confirm it is published under 'deckhouse-cli/plugins/'") case errors.Is(err, rpp.ErrUpstream): return help(err, "registry-packages-proxy: upstream error (5xx)", diff --git a/internal/plugins/cmd/list.go b/internal/plugins/cmd/list.go index f1ce51622..b3c065258 100644 --- a/internal/plugins/cmd/list.go +++ b/internal/plugins/cmd/list.go @@ -31,7 +31,7 @@ func newListCommand(manager *plugins.Manager) *cobra.Command { 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 plugins versions '.", + "with 'd8 dist plugins versions '.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { printInstalledPlugins(manager.List()) @@ -58,5 +58,5 @@ func printInstalledPlugins(installed []plugins.PluginInfo) { 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 plugins install '.") + fmt.Println("\nThe registry serves no catalog; install a plugin by name with 'd8 dist plugins install '.") } diff --git a/internal/plugins/cmd/plugins.go b/internal/plugins/cmd/plugins.go index 26306bf45..82b3047f2 100644 --- a/internal/plugins/cmd/plugins.go +++ b/internal/plugins/cmd/plugins.go @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package pluginscmd implements the `d8 plugins` command tree and the -// per-plugin wrapper command on top of the internal/plugins machinery. package pluginscmd import ( @@ -31,7 +29,7 @@ import ( rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" ) -// NewCommand returns the `d8 plugins` command tree for managing plugins. +// NewCommand returns the `d8 dist plugins` command tree for managing plugins. // builtinCommands are built-in command names that satisfy a plugin dependency of // the same name (e.g. delivery-kit) until such capabilities ship as standalone // plugins. @@ -45,7 +43,7 @@ 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 plugins update ' or 'd8 plugins update all'.\n\n" + + "Update on demand with 'd8 dist plugins update ' or 'd8 dist plugins update all'.\n\n" + "Environment variables:\n" + " " + flags.EnvSkipClusterChecks + "=1 skip cluster-side plugin requirement checks\n" + " " + flags.EnvPluginsDir + " plugins directory (same as --plugins-dir)\n" + @@ -85,6 +83,8 @@ func NewCommand(logger *dkplog.Logger, builtinCommands []string) *cobra.Command cmd.AddCommand(newUpdateCommand(manager)) cmd.AddCommand(newRemoveCommand(manager)) + // Only the plugin-specific flags: the cluster access flags (kubeconfig/ + // context, rpp-*) are owned by the parent `d8 dist` command. flags.AddFlags(cmd.PersistentFlags()) // legacy --source bypass (temporary, hidden): direct registry access, see diff --git a/internal/plugins/cmd/update.go b/internal/plugins/cmd/update.go index 1b909d19d..22716aef3 100644 --- a/internal/plugins/cmd/update.go +++ b/internal/plugins/cmd/update.go @@ -34,7 +34,7 @@ func newUpdateCommand(manager *plugins.Manager) *cobra.Command { "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 plugins install --version X'.", + "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] diff --git a/internal/plugins/cmd/versions.go b/internal/plugins/cmd/versions.go index c11ff4e11..ca8472365 100644 --- a/internal/plugins/cmd/versions.go +++ b/internal/plugins/cmd/versions.go @@ -27,8 +27,8 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/plugins" ) -// newVersionsCommand returns `d8 plugins versions ` - list all -// published versions of one plugin, the same verb `d8 cli versions` uses for +// newVersionsCommand returns `d8 dist plugins versions ` - list all +// published versions of one plugin, the same verb `d8 dist versions` uses for // the CLI itself. func newVersionsCommand(manager *plugins.Manager) *cobra.Command { return &cobra.Command{ @@ -38,7 +38,7 @@ func newVersionsCommand(manager *plugins.Manager) *cobra.Command { "marked, versions newer than it are highlighted.\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 plugins install --version X' - a version already on disk is switched to\n" + + "'d8 dist plugins install --version X' - a version already on disk is switched to\n" + "instantly, without a download.", Args: cobra.ExactArgs(1), ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -97,7 +97,7 @@ func newVersionsCommand(manager *plugins.Manager) *cobra.Command { // 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 cli versions` uses. A nil +// 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) { diff --git a/internal/plugins/flags/doc.go b/internal/plugins/flags/doc.go index 909c01f94..faf61570c 100644 --- a/internal/plugins/flags/doc.go +++ b/internal/plugins/flags/doc.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package flags defines the shared CLI flag set used by the d8 plugins +// Package flags defines the shared CLI flag set used by the `d8 dist plugins` // management subcommands and consumed when building the registry-packages-proxy // client and enforcing cluster-side requirements. package flags diff --git a/internal/plugins/flags/flags.go b/internal/plugins/flags/flags.go index 6c26718e5..7065d4293 100644 --- a/internal/plugins/flags/flags.go +++ b/internal/plugins/flags/flags.go @@ -21,7 +21,6 @@ import ( "github.com/spf13/pflag" - rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" "github.com/deckhouse/deckhouse-cli/internal/utilk8s" ) @@ -65,6 +64,9 @@ func defaultKubeconfigPath() string { return utilk8s.DefaultKubeconfigPath() } +// AddFlags registers the plugin-specific flags: the plugins directory and the +// cluster-checks toggle. Cluster access flags (kubeconfig/context, rpp-*) are +// registered separately, so a parent command can own them for a whole tree. func AddFlags(flagSet *pflag.FlagSet) { flagSet.StringVar( &DeckhousePluginsDir, @@ -72,6 +74,18 @@ func AddFlags(flagSet *pflag.FlagSet) { DeckhousePluginsDir, "Path to the d8 plugins directory. Defaults to $"+EnvPluginsDir+".", ) + flagSet.BoolVar( + &SkipClusterChecks, + "skip-cluster-checks", + SkipClusterChecks, + "Skip enforcement of cluster-side plugin requirements (Kubernetes/Deckhouse/module versions) when the cluster is unreachable. Defaults to $"+EnvSkipClusterChecks+".", + ) +} + +// AddKubeFlags registers the kubeconfig/context pair that locates the cluster. +// The flags are bound to Kubeconfig/KubeContext, so reading them by flag name +// and through the package variables gives the same value. +func AddKubeFlags(flagSet *pflag.FlagSet) { flagSet.StringVarP( &Kubeconfig, "kubeconfig", @@ -85,12 +99,4 @@ func AddFlags(flagSet *pflag.FlagSet) { KubeContext, "Kubeconfig context to use. Used to reach registry-packages-proxy and to enforce cluster-side plugin requirements.", ) - flagSet.BoolVar( - &SkipClusterChecks, - "skip-cluster-checks", - SkipClusterChecks, - "Skip enforcement of cluster-side plugin requirements (Kubernetes/Deckhouse/module versions) when the cluster is unreachable. Defaults to $"+EnvSkipClusterChecks+".", - ) - - rppflags.AddFlags(flagSet) } diff --git a/internal/plugins/install.go b/internal/plugins/install.go index c3eb7b523..ef390e696 100644 --- a/internal/plugins/install.go +++ b/internal/plugins/install.go @@ -222,7 +222,7 @@ func (m *Manager) planForExplicit(ctx context.Context, pluginName string, versio Category: fmt.Sprintf("cannot install plugin %q %s", pluginName, version.Original()), Suggestions: []diagnostic.Suggestion{{ Cause: reason.summary(), - Solutions: []string{fmt.Sprintf("inspect the plugin's requirements: d8 plugins contract %s", pluginName)}, + Solutions: []string{fmt.Sprintf("inspect the plugin's requirements: d8 dist plugins contract %s", pluginName)}, }}, } } @@ -679,7 +679,7 @@ func (m *Manager) linkCurrent(paths pluginPaths) error { // cacheContract writes the plugin contract JSON to // /cache/contracts/.json for later lookups by -// validatePluginConflicts and `d8 plugins list`. The write is atomic (temp + +// validatePluginConflicts and `d8 dist plugins list`. The write is atomic (temp + // rename): the runtime gate reads this file lock-free before every plugin run, // and a torn contract would hard-block the plugin until a --force reinstall. func (m *Manager) cacheContract(pluginName string, plugin *internal.Plugin) error { diff --git a/internal/plugins/install_test.go b/internal/plugins/install_test.go index 86706bcc0..9653d31d2 100644 --- a/internal/plugins/install_test.go +++ b/internal/plugins/install_test.go @@ -409,7 +409,7 @@ func TestValidateInstalledRequirementsNamesMissingDep(t *testing.T) { missing, ok := findSuggestion(he, "dep is not installed") require.True(t, ok, "the diagnostic names the missing dependency") - assert.Contains(t, strings.Join(missing.Solutions, " "), "d8 plugins install dep", + assert.Contains(t, strings.Join(missing.Solutions, " "), "d8 dist plugins install dep", "it points at how to install the dependency") } diff --git a/internal/plugins/list.go b/internal/plugins/list.go index 18273dd3a..8c492fb6c 100644 --- a/internal/plugins/list.go +++ b/internal/plugins/list.go @@ -34,7 +34,7 @@ type PluginInfo struct { // 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 plugins versions `. +// `d8 dist plugins versions `. func (m *Manager) List() []PluginInfo { installed, err := m.fetchInstalledPlugins() if err != nil { diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go index de26ba46d..78c6c971a 100644 --- a/internal/plugins/plugins.go +++ b/internal/plugins/plugins.go @@ -30,7 +30,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" ) -// Manager is the plugin machinery shared by every `d8 plugins ...` subcommand +// Manager is the plugin machinery shared by every `d8 dist plugins ...` subcommand // and the per-plugin wrapper command (see internal/plugins/cmd): it installs, // updates, removes, lists and runs plugins from the configured source. type Manager struct { diff --git a/internal/plugins/run.go b/internal/plugins/run.go index 45c845080..2d2b0b4b1 100644 --- a/internal/plugins/run.go +++ b/internal/plugins/run.go @@ -92,7 +92,7 @@ func (m *Manager) RunInstalled(ctx context.Context, pluginName string, args []st contract = nil default: - return fmt.Errorf("read %q contract (reinstall with 'd8 plugins install %s --force'): %w", + return fmt.Errorf("read %q contract (reinstall with 'd8 dist plugins install %s --force'): %w", pluginName, pluginName, err) } diff --git a/internal/plugins/select.go b/internal/plugins/select.go index d88c94ab4..effb92280 100644 --- a/internal/plugins/select.go +++ b/internal/plugins/select.go @@ -166,7 +166,7 @@ func noCompatibleError(pluginName string, rejected []rejectedCandidate) error { Category: fmt.Sprintf("no stable version of plugin %q is published", pluginName), Suggestions: []diagnostic.Suggestion{{ Cause: "only pre-releases (rc, alpha, beta) exist", - Solutions: []string{fmt.Sprintf("install a pre-release explicitly: d8 plugins install %s --version ", pluginName)}, + Solutions: []string{fmt.Sprintf("install a pre-release explicitly: d8 dist plugins install %s --version ", pluginName)}, }}, } } @@ -186,8 +186,8 @@ func noCompatibleError(pluginName string, rejected []rejectedCandidate) error { suggestions = append(suggestions, diagnostic.Suggestion{ Cause: "no version could be installed", Solutions: []string{ - fmt.Sprintf("inspect a version's requirements: d8 plugins contract %s", pluginName), - fmt.Sprintf("or install an exact version: d8 plugins install %s --version ", pluginName), + fmt.Sprintf("inspect a version's requirements: d8 dist plugins contract %s", pluginName), + fmt.Sprintf("or install an exact version: d8 dist plugins install %s --version ", pluginName), }, }) diff --git a/internal/plugins/update.go b/internal/plugins/update.go index 118407341..d849c5e15 100644 --- a/internal/plugins/update.go +++ b/internal/plugins/update.go @@ -38,7 +38,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 plugins update all` would be a silent no-op for that install. + // `d8 dist plugins update 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) diff --git a/internal/plugins/update_test.go b/internal/plugins/update_test.go index 8cdd8bd5a..e36cfcf7e 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 plugins update all` runs against the configured root and must + // empty; `d8 dist plugins update 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/validators.go b/internal/plugins/validators.go index 247cff7ac..b4e36088b 100644 --- a/internal/plugins/validators.go +++ b/internal/plugins/validators.go @@ -126,12 +126,12 @@ func (fc failedConstraints) helpfulError(category string) *diagnostic.HelpfulErr if constraint := fc[name]; constraint == nil { suggestions = append(suggestions, diagnostic.Suggestion{ Cause: fmt.Sprintf("%s is not installed", safe), - Solutions: []string{fmt.Sprintf("install it: d8 plugins install %s", safe)}, + Solutions: []string{fmt.Sprintf("install it: d8 dist plugins install %s", safe)}, }) } else { suggestions = append(suggestions, diagnostic.Suggestion{ Cause: fmt.Sprintf("%s must satisfy %s", safe, constraint), - Solutions: []string{fmt.Sprintf("install a matching version: d8 plugins install %s --version ", safe)}, + Solutions: []string{fmt.Sprintf("install a matching version: d8 dist plugins install %s --version ", safe)}, }) } } @@ -401,7 +401,7 @@ func (m *Manager) validateClusterRequirements(ctx context.Context, plugin *inter state, err := m.clusterState(ctx) if err != nil { return fmt.Errorf("cannot reach the cluster to verify %q requirements "+ - "(set "+d8flags.EnvSkipClusterChecks+"=1, or pass --skip-cluster-checks to 'd8 plugins ...', to skip verification): %w", + "(set "+d8flags.EnvSkipClusterChecks+"=1, or pass --skip-cluster-checks to 'd8 dist plugins ...', to skip verification): %w", plugin.Name, err) } diff --git a/internal/plugins/validators_test.go b/internal/plugins/validators_test.go index 9f10074ed..e48ef093e 100644 --- a/internal/plugins/validators_test.go +++ b/internal/plugins/validators_test.go @@ -61,7 +61,7 @@ func TestFailedConstraintsHelpfulError(t *testing.T) { "a version mismatch suggests installing a matching version") assert.Equal(t, "zeta is not installed", he.Suggestions[1].Cause) - assert.Contains(t, strings.Join(he.Suggestions[1].Solutions, " "), "d8 plugins install zeta", + assert.Contains(t, strings.Join(he.Suggestions[1].Solutions, " "), "d8 dist plugins install zeta", "a missing dep points at how to install it") } diff --git a/internal/selfupdate/README.md b/internal/selfupdate/README.md index b929d61c8..bf3bc8d53 100644 --- a/internal/selfupdate/README.md +++ b/internal/selfupdate/README.md @@ -1,4 +1,4 @@ -# d8 self-update (`d8 cli`) +# d8 self-update (`d8 dist`) The `internal/selfupdate` package updates the `d8` binary itself through the cluster. @@ -15,10 +15,10 @@ cluster. | Command | What it does | |---|---| -| `d8 cli check` | reports whether a version newer than the current one is available | -| `d8 cli update [--version X]` | installs the version into the store and repoints `current` at it | -| `d8 cli use ` | switches to a version: an installed one is a pure symlink repoint (instant, offline), a missing one is downloaded first | -| `d8 cli versions` *(alias `list`)* | lists published versions newest-first; the current one is starred, locally installed ones are marked `installed` | +| `d8 dist check` | reports whether a version newer than the current one is available | +| `d8 dist update [--version X]` | installs the version into the store and repoints `current` at it | +| `d8 dist use ` | switches to a version: an installed one is a pure symlink repoint (instant, offline), a missing one is downloaded first | +| `d8 dist versions` *(alias `list`)* | lists published versions newest-first; the current one is starred, locally installed ones are marked `installed` | ## The version store and the `current` symlink (`store.go`) @@ -46,7 +46,7 @@ layout the plugin installer uses: backs it up as `.old` and replaces the PATH entry with a symlink to `current`. If external tooling later overwrites the symlink with a real file, the next update/use simply migrates again - self-healing. -- `d8 cli use ` resolves the request against the store by **semver +- `d8 dist use ` resolves the request against the store by **semver value** (`0.13.1` finds `v0.13.1`); a hit needs no network and no kubeconfig, a miss falls back to the regular download path and stays installed afterwards. - Store entries are immutable (re-installing an existing tag is a no-op) and are @@ -56,7 +56,7 @@ layout the plugin installer uses: - The store is per-user (`~/.deckhouse-cli/cli`): after migration the PATH entry points into the home of the user who ran it. On shared machines each user who manages d8 gets their own store; on cluster masters that user is root. -- `d8 cli use ` shell-completes from the store (newest-first, prefix +- `d8 dist use ` shell-completes from the store (newest-first, prefix filtered). Completion never touches the network - the offline-switchable versions are exactly the ones worth suggesting. @@ -98,7 +98,7 @@ platform's container registry (module `registry-packages-proxy`, ns and the PATH entry becomes a symlink to `current` (rolled back if the link cannot be created). Store-managed installs skip this step entirely. -Rollback is `d8 cli use ` - the previous version remains installed +Rollback is `d8 dist use ` - the previous version remains installed (the command prints it). The `.old` file exists only as the migration backup. Version selection: @@ -135,14 +135,14 @@ Platforms (`rpp_source.go`): | File | Responsibility | |---|---| -| `cmd/command.go` | the `d8 cli ...` cobra commands; building the `Updater` | -| `cmd/list.go` | `d8 cli versions`: rendering the version list | -| `cmd/use.go` | `d8 cli use`: switching versions, store-first; shell completion | | `update.go` | `Updater` and `SwitchTo`: version selection, store install, repoint, migration | | `store.go` | the version store + `current` symlink (`~/.deckhouse-cli/cli`) | | `source.go` / `rpp_source.go` | the `Source` interface and its RPP implementation | Related: +- `internal/dist/cmd` (package `distcmd`) - the `d8 dist ...` cobra commands on + top of this machinery: one file per command, the distribution summary, and + the `Updater` builder; - `internal/rpp` - the HTTP client for the proxy (transport, discovery, tar extraction); - `internal/lockfile` - the file lock (shared with plugin installs). diff --git a/internal/selfupdate/cmd/command.go b/internal/selfupdate/cmd/command.go deleted file mode 100644 index 24bbadbb0..000000000 --- a/internal/selfupdate/cmd/command.go +++ /dev/null @@ -1,200 +0,0 @@ -/* -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 selfupdatecmd implements the `d8 cli` command tree on top of the -// internal/selfupdate machinery (store, updater). -package selfupdatecmd - -import ( - "context" - "fmt" - - "github.com/fatih/color" - "github.com/spf13/cobra" - - dkplog "github.com/deckhouse/deckhouse/pkg/log" - - "github.com/deckhouse/deckhouse-cli/internal/rpp" - rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" - "github.com/deckhouse/deckhouse-cli/internal/selfupdate" - "github.com/deckhouse/deckhouse-cli/internal/selfupdate/cmd/errdetect" - systemflags "github.com/deckhouse/deckhouse-cli/internal/system/flags" - "github.com/deckhouse/deckhouse-cli/internal/utilk8s" - "github.com/deckhouse/deckhouse-cli/internal/version" -) - -// Output styling, aligned with `d8 cli versions` (list.go): green = the version being -// moved to, cyan+bold = the active version, faint = a superseded version. fatih/color -// drops ANSI on a non-TTY and under NO_COLOR. -var ( - okMark = color.New(color.FgGreen, color.Bold) - verNew = color.New(color.FgGreen) - verCur = color.New(color.FgCyan, color.Bold) - verOld = color.New(color.Faint) -) - -// NewCommand returns the `d8 cli` command tree for managing the d8 binary itself. -// It reaches the registry-packages-proxy with the caller's kubeconfig identity. -func NewCommand(logger *dkplog.Logger) *cobra.Command { - cmd := &cobra.Command{ - Use: "cli", - Short: "Manage the deckhouse-cli (d8) binary", - Long: "Check for and install newer deckhouse-cli versions via the in-cluster registry-packages-proxy.\n\n" + - "Update on demand with 'd8 cli update'.\n\n" + - "Environment variables:\n" + - " " + rppflags.EnvEndpoint + " registry-packages-proxy base URL (otherwise discovered from the cluster)\n" + - " " + rppflags.EnvCAFile + " PEM CA bundle to verify the proxy TLS certificate\n" + - " KUBECONFIG path to the kubeconfig file", - } - - cmd.AddCommand(newCheckCommand(logger)) - cmd.AddCommand(newUpdateCommand(logger)) - cmd.AddCommand(newUseCommand(logger)) - cmd.AddCommand(newVersionsCommand(logger)) - - systemflags.AddPersistentFlags(cmd) - rppflags.AddFlags(cmd.PersistentFlags()) - - wrapProxyDiagnostics(cmd) - - return cmd -} - -// wrapProxyDiagnostics turns recognized registry-packages-proxy failures into -// colored diagnostics at the command level (per pkg/diagnostic: classify in the -// command, never in root.go). It wraps every RunE in the tree; errdetect.Diagnose -// returns nil for non-proxy and already-diagnosed errors, leaving them untouched. -func wrapProxyDiagnostics(cmd *cobra.Command) { - if cmd.RunE != nil { - inner := cmd.RunE - cmd.RunE = func(c *cobra.Command, args []string) error { - err := inner(c, args) - if diag := errdetect.Diagnose(err); diag != nil { - return diag - } - - return err - } - } - - for _, sub := range cmd.Commands() { - wrapProxyDiagnostics(sub) - } -} - -func newCheckCommand(logger *dkplog.Logger) *cobra.Command { - return &cobra.Command{ - Use: "check", - Short: "Report whether a newer deckhouse-cli version is available", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - updater, err := newUpdater(cmd.Context(), cmd, logger) - if err != nil { - return err - } - - latest, newer, err := updater.LatestVersion(cmd.Context(), version.Version) - if err != nil { - return err - } - - if newer { - fmt.Printf("A newer deckhouse-cli is available: %s (current: %s). Run 'd8 cli update' to upgrade.\n", - verNew.Sprint(latest), verOld.Sprint(version.Version)) - } else { - fmt.Printf("deckhouse-cli is up to date (%s).\n", verCur.Sprint(version.Version)) - } - - return nil - }, - } -} - -func newUpdateCommand(logger *dkplog.Logger) *cobra.Command { - var targetVersion string - - cmd := &cobra.Command{ - Use: "update", - Short: "Update deckhouse-cli to the latest version", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - updater, err := newUpdater(cmd.Context(), cmd, logger) - if err != nil { - return err - } - - tag := targetVersion - if tag == "" { - latest, newer, err := updater.LatestVersion(cmd.Context(), version.Version) - if err != nil { - return err - } - - if !newer { - fmt.Printf("deckhouse-cli is already up to date (%s).\n", verCur.Sprint(version.Version)) - - return nil - } - - tag = latest - } - - fmt.Printf("Updating deckhouse-cli to %s...\n", verNew.Sprint(tag)) - - res, err := updater.Apply(cmd.Context(), tag) - if err != nil { - return err - } - - fmt.Printf("%s deckhouse-cli updated to %s.\n", okMark.Sprint("✓"), verNew.Sprint(tag)) - printSwitchNotes(res) - - return nil - }, - } - - cmd.Flags().StringVar(&targetVersion, "version", "", "Exact version to install; downgrades are allowed (default: the latest).") - - return cmd -} - -// newUpdater builds an Updater backed by the registry-packages-proxy, reached -// with the kubeconfig identity from the command's flags. -func newUpdater(ctx context.Context, cmd *cobra.Command, logger *dkplog.Logger) (*selfupdate.Updater, error) { - kubeconfig, _ := cmd.Flags().GetString("kubeconfig") - kubeContext, _ := cmd.Flags().GetString("context") - - restConfig, kube, err := utilk8s.SetupK8sClientSet(kubeconfig, kubeContext) - if err != nil { - return nil, fmt.Errorf("set up kubernetes client: %w", err) - } - - client, err := rpp.NewClusterClient( - ctx, kube, restConfig, logger.Named("registry-packages-proxy"), - rppflags.Endpoint, rppflags.CAFile, rppflags.InsecureSkipTLSVerify, - ) - if err != nil { - return nil, fmt.Errorf("build registry-packages-proxy client: %w", err) - } - - store, err := selfupdate.NewStore() - if err != nil { - // A nil store only disables retention for `d8 cli use`; updating still works. - logger.Debug("version store unavailable", dkplog.Err(err)) - } - - return selfupdate.NewUpdater(selfupdate.NewRPPSource(client), store, logger.Named("selfupdate")), nil -} diff --git a/internal/selfupdate/doc.go b/internal/selfupdate/doc.go index f3062fd15..49809c94d 100644 --- a/internal/selfupdate/doc.go +++ b/internal/selfupdate/doc.go @@ -17,7 +17,7 @@ limitations under the License. // Package selfupdate lets the d8 binary update itself through the cluster // (registry-packages-proxy, kubeconfig identity - no registry credentials). // -// It implements the `d8 cli` command tree: +// It backs the `d8 dist` command tree: // // - check - is a newer version available // - versions - list published versions (alias: list) @@ -34,7 +34,7 @@ limitations under the License. // - Every downloaded binary is smoke-tested (`--version`) before it becomes // active; a corrupt or wrong-platform artifact never replaces a working d8. // -// Wiring: the cobra commands live in the cmd subpackage; this package holds +// Wiring: the cobra commands live in internal/dist/cmd; this package holds // the update flow (update.go) and the store (store.go). Downloads go through // the Source interface (source.go) backed by internal/rpp. // Details, trade-offs, and the full file map are in README.md next to this file. diff --git a/internal/selfupdate/switch_test.go b/internal/selfupdate/switch_test.go index ec129c38f..3e56edfd3 100644 --- a/internal/selfupdate/switch_test.go +++ b/internal/selfupdate/switch_test.go @@ -31,7 +31,7 @@ import ( ) // TestSwitchToStoredVersionRepointsSymlink exercises the store-hit half of -// `d8 cli use` for a managed install: no stage function, no .old churn - just +// `d8 dist use` for a managed install: no stage function, no .old churn - just // an atomic repoint of the `current` symlink. func TestSwitchToStoredVersionRepointsSymlink(t *testing.T) { dir := t.TempDir() diff --git a/testing/e2e/plugins/test.sh b/testing/e2e/plugins/test.sh index daef73186..0e289f91c 100755 --- a/testing/e2e/plugins/test.sh +++ b/testing/e2e/plugins/test.sh @@ -22,12 +22,12 @@ trap cleanup_on_error EXIT ERR # tests echo "--- TEST LIST PLUGINS ---" -$(PWD)/bin/d8 plugins list # run task build before required +$(PWD)/bin/d8 dist plugins list # run task build before required echo "" echo "--- TEST CLEAN INSTALL PLUGIN ---" -$(PWD)/bin/d8 plugins install package +$(PWD)/bin/d8 dist plugins install package echo "" echo "--- TEST INSTALL SECOND PLUGIN ---" -$(PWD)/bin/d8 plugins install system +$(PWD)/bin/d8 dist plugins install system