diff --git a/cmd/d8/root.go b/cmd/d8/root.go index 3b3504a0..7ec4b5de 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -45,8 +45,9 @@ import ( mirror "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd" network "github.com/deckhouse/deckhouse-cli/internal/network" packagecmd "github.com/deckhouse/deckhouse-cli/internal/packagecmd" - plugins "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/flags" + 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" status "github.com/deckhouse/deckhouse-cli/internal/status/cmd" system "github.com/deckhouse/deckhouse-cli/internal/system/cmd" "github.com/deckhouse/deckhouse-cli/internal/tools" @@ -85,16 +86,11 @@ func NewRootCommand() *RootCommand { }, } - envCliPath := os.Getenv("DECKHOUSE_CLI_PATH") + envCliPath := os.Getenv(flags.EnvPluginsDir) if envCliPath != "" { flags.DeckhousePluginsDir = envCliPath } - envRegistryRepo := os.Getenv("DECKHOUSE_REGISTRY_REPO") - if envRegistryRepo != "" { - flags.SourceRegistryRepo = envRegistryRepo - } - rootCmd.registerCommands() rootCmd.cmd.SetGlobalNormalizationFunc(cliflag.WordSepNormalizeFunc) @@ -128,12 +124,14 @@ func (r *RootCommand) registerCommands() { if os.Getenv("DECKHOUSE_PLUGINS_ENABLED") != "true" { r.cmd.AddCommand(system.NewCommand()) } else { - r.cmd.AddCommand(plugins.NewPluginCommand(plugins.SystemPluginName, "Operate system options in DKP", []string{"s", "p", "platform"}, r.logger.Named("system-command"))) + r.cmd.AddCommand(pluginscmd.NewPluginCommand(pluginscmd.SystemPluginName, "Operate system options in DKP", []string{"s", "p", "platform"}, r.logger.Named("system-command"))) } r.cmd.AddCommand(packagecmd.NewCommand()) - r.cmd.AddCommand(plugins.NewCommand(r.logger.Named("plugins-command"))) + r.cmd.AddCommand(pluginscmd.NewCommand(r.logger.Named("plugins-command"))) + + r.cmd.AddCommand(selfupdatecmd.NewCommand(r.logger.Named("cli-command"))) } func (r *RootCommand) Execute() error { diff --git a/docs/mirror-pull-dry-run.md b/docs/mirror-pull-dry-run.md index 724d2095..e4e46ec9 100644 --- a/docs/mirror-pull-dry-run.md +++ b/docs/mirror-pull-dry-run.md @@ -13,16 +13,21 @@ The key distinction from a no-op: |------|-------------|---------| | Validate registry access | yes | yes | | Resolve versions / channels | yes | yes | -| Pull installer to tmpDir | yes | **yes** (needed to read `images_digests.json`) | +| Read installer `images_digests.json` (platform) | via OCI layout in tmpDir | **streamed from the registry** (no layout) | +| Stage installer/security/module OCI layout dirs in tmpDir | yes (with blobs) | **scaffolding only** (no image blobs) | | Pull release-channel metadata | yes | yes | | Download platform/module/security blobs | yes | **no** | | Write `platform.tar`, `security.tar`, module tarballs | yes | **no** | | Write `deckhousereleases.yaml` | yes | **no** | | Compute GOST digests | yes | **no** | -Installer OCI layouts land in `--tmp-dir` (or `/.tmp`) so the tool can -extract the built-in image digest list from `deckhouse/candi/images_digests.json`. -The **bundle directory** (first positional argument) remains empty. +In dry-run the **platform** service streams the built-in image digest list +(`images_tags.json` / `images_digests.json`) straight from the remote installer image, +layer by layer, without writing an OCI layout. The `installer`, `security` and `modules` +services still create their OCI layout directories under `--tmp-dir` (or +`/.tmp`), but in dry-run they pull no image blobs (only layout scaffolding), +so `--tmp-dir` ends up non-empty while the **bundle directory** (first positional +argument) remains empty. --- @@ -51,18 +56,22 @@ Expected output (abbreviated): ``` INFO Skipped releases lookup as tag "v1.69.0" is specifically requested with --deckhouse-tag INFO Deckhouse releases to pull: [1.69.0] -INFO ╔ Pull release channels and installers -INFO ║ [1 / 1] Pulling registry.deckhouse.io/deckhouse/install:v1.69.0 -INFO ╚ Pull release channels and installers succeeded in … -INFO Extracting images digests from Deckhouse installer v1.69.0 +INFO Searching for Deckhouse built-in modules digests +INFO [dry-run] Streaming installer metadata for v1.69.0 from registry INFO Deckhouse digests found: 319 -INFO Found 320 images INFO [dry-run] Platform images that would be pulled: -INFO registry.deckhouse.io/deckhouse/fe@sha256:… -INFO registry.deckhouse.io/deckhouse/fe/release-channel:v1.69.0 +INFO Deckhouse components: 319 images +INFO registry.deckhouse.io/deckhouse/fe@sha256:… +INFO Release channels: 1 +INFO registry.deckhouse.io/deckhouse/fe/release-channel:v1.69.0 +INFO Installer: 1 +INFO registry.deckhouse.io/deckhouse/fe/install:v1.69.0 +INFO Standalone installer: 1 +INFO Total: 322 platform images +INFO [dry-run] Installer images that would be pulled: INFO registry.deckhouse.io/deckhouse/fe/install:v1.69.0 … -INFO [dry-run] Done. No images were downloaded. + No images were downloaded (dry-run). ``` ### All components @@ -134,7 +143,7 @@ go test ./internal/mirror/... -run 'TestDryRun' -v -timeout 120s # Pull-command level (flag registration, no bundle output, exit 0) go test ./internal/mirror/cmd/pull/ -run 'TestDryRun' -v -# Platform service level (installer pulled to tmpDir, bundle stays empty) +# Platform service level (digests streamed, no platform OCI layout, bundle stays empty) go test ./internal/mirror/platform/ -run 'TestDryRun' -v ``` @@ -148,7 +157,8 @@ go test ./internal/mirror/platform/ -run 'TestDryRun' -v | `TestDryRunWithDeckhouseTag` | specific `--deckhouse-tag` works in dry-run | | `TestDryRunExitsZeroOnSuccess` | `Execute()` returns `nil` | | `TestDryRun_NoBundleFilesWritten` | platform service: bundleDir empty | -| `TestDryRun_InstallerPulledToTmpDir` | platform service: `/platform/install/` exists | +| `TestDryRun_NoOCILayoutCreated` | platform service: `/platform/install/` is **not** created (digests are streamed) | +| `TestDryRun_WorkingDirHasLayouts` | installer / security service: OCI layout dir staged in workingDir, bundleDir empty | ### Integration smoke test (real registry) @@ -171,9 +181,10 @@ D8_TEST_LICENSE_TOKEN= \ The test asserts: 1. `Execute()` returns `nil` -2. The bundle directory is **empty** — no `.tar` output -3. The tmp directory is **non-empty** — OCI layouts were written (installer pull), proving - `images_digests.json` extraction was attempted +2. The bundle directory has no `.tar` / `.chunk` output +3. The tmp directory is **non-empty** - the `installer`, `security` and `modules` services + stage OCI layout scaffolding there (no image blobs); the platform digest list itself is + streamed from the registry, not staged Sample passing output: @@ -181,10 +192,11 @@ Sample passing output: === RUN TestDryRunRealRegistry … INFO Deckhouse digests found: 319 -INFO Found 320 images INFO [dry-run] Platform images that would be pulled: -INFO registry.deckhouse.io/deckhouse/fe@sha256:e927fc9… - … 320 lines … +INFO Deckhouse components: 319 images +INFO registry.deckhouse.io/deckhouse/fe@sha256:e927fc9… +INFO Total: 322 platform images + … more dry-run plans for installer / security / modules … pull_realregistry_test.go:94: tmpDir files written during dry-run: 51 pull_realregistry_test.go:95: bundleDir entries (must be 0): 0 --- PASS: TestDryRunRealRegistry (79.99s) @@ -203,13 +215,12 @@ Puller.Execute() │ ├─ findTagsToMirror() ← real network call │ ├─ downloadList.FillDeckhouseImages() ← in-memory │ └─ pullDeckhousePlatform() - │ ├─ pullDeckhouseReleaseChannels() ← writes to tmpDir - │ ├─ pullInstallers() ← writes to tmpDir ← KEY STEP - │ ├─ pullStandaloneInstallers() ← writes to tmpDir - │ │ (pullDeckhouseImages SKIPPED in dry-run) - │ ├─ ExtractImageDigestsFromInstaller ← reads images_digests.json - │ └─ [dry-run guard] print plan → return nil - │ (no platform.tar written) + │ └─ [DryRun] pullDeckhousePlatformDryRun() + │ ├─ extractImageDigestsFromRemote() ← streams images_tags.json / + │ │ images_digests.json from the remote image (no OCI layout) + │ └─ print plan → return nil + │ (pullDeckhouseReleaseChannels / pullInstallers / + │ pullStandaloneInstallers / pullDeckhouseImages all SKIPPED) ├─ installer.Service.PullInstaller() [DryRun=true] │ ├─ validateInstallerAccess() │ ├─ findTagsToMirror() @@ -225,15 +236,16 @@ Puller.Execute() └─ [dry-run guard] print plan → return nil After Pull() returns: - if DryRun → print "[dry-run] Done." → return nil + if DryRun → print summary "No images were downloaded (dry-run)." → return nil else → computeGOSTDigests, finalCleanup ``` -The installer image **is** pulled to `tmpDir` in dry-run mode because -`images_digests.json` inside it is the only source for the ~300 component image -digest references that the platform bundle would contain. Without this step, -dry-run could only report the 5–10 top-level image tags, missing the vast majority -of what a real pull actually downloads. +The platform service reads `images_digests.json` (or `images_tags.json`) **without** +pulling the installer to `tmpDir`: `extractImageDigestsFromRemote` streams just the layer +containing that file straight from the registry. It is the only source for the ~300 +component image digest references that the platform bundle would contain - without it, +dry-run could only report the 5-10 top-level image tags, missing the vast majority of +what a real pull actually downloads. --- @@ -241,9 +253,9 @@ of what a real pull actually downloads. | Location | Created in dry-run? | Description | |----------|---------------------|-------------| -| `/platform/install/` | **yes** | Installer OCI layout | -| `/platform/install-standalone/` | **yes** | Standalone installer OCI layout | -| `/platform/release/` | **yes** | Release-channel metadata OCI layout | +| `/platform/...` | **no** | Platform digests are streamed; no platform OCI layout is written | +| `/installer/` | **yes** | Installer OCI layout dir (scaffolding only, no blobs) | +| `/security*/`, `/modules*/` | **yes** | Security / module OCI layout dirs (scaffolding only, no blobs) | | `/platform.tar` | no | Not created | | `/security.tar` | no | Not created | | `/modules-*.tar` | no | Not created | diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..4b1f5d28 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,117 @@ +# d8 Plugins (`d8 plugins`) + +Plugins are versioned binaries distributed through the cluster registry. +`d8` installs, updates, and removes them for you. + +**Contents:** [Source](#plugin-source) · [Commands](#commands) · +[Versions & majors](#versions-majors-and-switching) · +[Requirements](#requirements) · +[Flags & env](#flags-and-environment-variables) · +[Troubleshooting](#troubleshooting) + +> [!NOTE] +> The `d8 plugins` command group is hidden from the root `--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. There is no direct-registry path: every `d8 plugins` +command reaches the registry through the proxy, so a reachable cluster is +required. The access model: + +- Authentication: the **Bearer token** from your kubeconfig (client + certificates do not work). +- Authorization: the ClusterRole `d8:registry-packages-proxy:cli-download`, + bound by the cluster administrator. +- Endpoint: discovered automatically; override with `--rpp-endpoint` / + `D8_RPP_ENDPOINT`, pass a private CA with `--rpp-ca-file`. + +See [self-update.md - How access works](self-update.md#how-access-works) for +the full picture (RBAC binding example, OIDC kubeconfig, endpoint discovery). + +## Commands + +| 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 | + +```console +$ d8 plugins versions package + v0.1.2 newer +* v0.0.21 current + v0.0.20 + +$ d8 plugins install package +Installing plugin: package +Tag: v0.0.21 +... +✓ Plugin 'package' successfully installed! +``` + +## Versions, majors and switching + +Plugins are stored per major version, with a `current` symlink selecting the +active one: + +``` +/opt/deckhouse/lib/deckhouse-cli/plugins//v/ +``` + +Rules that follow from this layout: + +- `d8 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. +- Installing the active version says so and does nothing; `--force` + re-downloads. +- No root access to `/opt/deckhouse/lib`? Plugins go to `~/.deckhouse-cli` + automatically. + +## Requirements + +A plugin's contract may declare requirements: + +- other plugins; +- Kubernetes / Deckhouse versions; +- enabled modules. + +They are validated **before** anything is downloaded or switched: + +```console +$ d8 plugins install package +... +Error: plugin requirements not satisfied # e.g. requires plugin delivery-kit +``` + +Plugins this one depends on are installed/upgraded automatically. + +- `--skip-cluster-checks` (or `D8_PLUGINS_SKIP_CLUSTER_CHECKS=1`) - skip + cluster-side checks, e.g. in air-gapped scenarios. + +## Flags and environment variables + +| Flag | Env | Purpose | +|---|---|---| +| `--kubeconfig`, `-k` / `--context` | `KUBECONFIG` | cluster identity (the Bearer token source) | +| `--plugins-dir` | `DECKHOUSE_CLI_PATH` | plugins directory | +| `--skip-cluster-checks` | `D8_PLUGINS_SKIP_CLUSTER_CHECKS=1` | skip cluster-side requirement checks | +| `--rpp-endpoint` | `D8_RPP_ENDPOINT` | proxy base URL; discovered from the cluster when empty | +| `--rpp-ca-file` | `D8_RPP_CA_FILE` | PEM CA bundle to verify the proxy TLS certificate | +| `--rpp-insecure-skip-tls-verify` | - | skip proxy TLS verification (debugging only) | + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `image or tag not found` (404) on a plugin | the plugin is not published in this cluster's registry | check with `d8 plugins versions `; publishing is the plugin CI's job | +| `plugin requirements not satisfied` | the contract requires other plugins or cluster versions/modules | see `d8 plugins contract `; plugin deps are auto-installed, cluster requirements are not | +| 401 / 403 / `x509: ...` reaching the proxy | access or TLS issue with the registry-packages-proxy | see [self-update.md - Troubleshooting](self-update.md#troubleshooting) - the access model is shared | diff --git a/docs/self-update.md b/docs/self-update.md new file mode 100644 index 00000000..31a9d399 --- /dev/null +++ b/docs/self-update.md @@ -0,0 +1,143 @@ +# d8 Self-Update (`d8 cli`) + +`d8` updates itself **through the cluster**. No registry credentials needed: + +- Artifacts are served by the in-cluster **registry-packages-proxy**. +- You authenticate with your **ordinary kubeconfig** - the same identity you + use for `kubectl`. +- The cluster administrator grants (and revokes) download permission with a + regular RBAC binding. + +**Contents:** [Access](#how-access-works) · [Commands](#commands) · +[Version store](#how-versions-are-stored) · +[Switching & rollback](#switching-and-rollback) · +[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). + +## How access works + +``` +d8 cli update + │ Bearer token from your kubeconfig + ▼ +registry-packages-proxy. (found automatically via Ingress) + │ TokenReview + SubjectAccessReview (kube-rbac-proxy) + ▼ +cluster registry (credentials live only inside the cluster) +``` + +### Authentication + +- The proxy accepts the **Bearer token** from your kubeconfig. +- Client certificates do **not** work (for example, the root + `kubernetes-admin` config on master nodes). + +> [!TIP] +> Get a personal OIDC kubeconfig from your cluster's Kubeconfig Generator: +> `https://kubeconfig.`. + +### Authorization + +Download permission is the ClusterRole +`d8:registry-packages-proxy:cli-download`. By default it is bound to +**nobody** - the administrator decides who may download: + +```bash +kubectl create clusterrolebinding d8-cli-download \ + --clusterrole=d8:registry-packages-proxy:cli-download \ + --group= # or --user=... / --serviceaccount=... +``` + +### Endpoint + +- Discovered automatically from the cluster (the `registry-packages-proxy` + Ingress). +- In closed environments, set it explicitly: `--rpp-endpoint` / + `D8_RPP_ENDPOINT`. +- Private CA? Pass the bundle with `--rpp-ca-file`. + +## Commands + +| 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 | + +```console +$ d8 cli check +A newer deckhouse-cli is available: v0.14.0 (current: v0.13.1). Run 'd8 cli update' to upgrade. + +$ d8 cli versions + v0.14.0 newer +* v0.13.1 current installed + v0.13.0 installed + +$ d8 cli 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'. +``` + +## How versions are stored + +Installed versions live in a per-user store. A symlink selects the active one: + +``` +/opt/deckhouse/bin/d8 -> ~/.deckhouse-cli/cli/current -> versions/v0.14.0/d8 +``` + +What this gives you: + +- **Switching is instant** - just a symlink repoint. No download, no network, + no `sudo`. +- **Old versions stay installed** - rollback is one command. +- **Migration is automatic** - the first `update` or `use` converts a + plain-file installation to this layout; the original binary is kept with a + `.old` suffix. + +> [!NOTE] +> Every downloaded binary is verified (a smoke run of `--version`) **before** +> it becomes active. A corrupt or wrong-platform artifact never replaces a +> working d8. + +## Switching and rollback + +```console +$ d8 cli 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'. + +$ 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." +``` + +- Rollback after an update: `d8 cli use ` - the previous version + stays installed. +- `d8 cli use ` completes the locally installed versions (enable shell + completion with `d8 completion`). + +## Flags and environment variables + +| Flag | Env | Purpose | +|---|---|---| +| `--kubeconfig`, `-k` / `--context` | `KUBECONFIG` | cluster identity (the Bearer token source) | +| `--rpp-endpoint` | `D8_RPP_ENDPOINT` | proxy base URL; discovered from the cluster when empty | +| `--rpp-ca-file` | `D8_RPP_CA_FILE` | PEM CA bundle to verify the proxy TLS certificate | +| `--rpp-insecure-skip-tls-verify` | - | skip proxy TLS verification (debugging only) | + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `... unauthorized` (401) | no token in kubeconfig, or a client-certificate identity | use an OIDC kubeconfig from the Kubeconfig Generator | +| `... forbidden` (403) | the `cli-download` role is not bound to you | ask the administrator for the ClusterRoleBinding | +| 403 right after the role was bound | the proxy caches authorization for ~5 min per token | retry with a fresh token or wait 5 minutes | +| `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 | diff --git a/go.mod b/go.mod index 04b4b1cd..f990f378 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,8 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require github.com/docker/cli v29.3.0+incompatible + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.16.0 // indirect @@ -199,7 +201,6 @@ require ( github.com/djherbis/nio/v3 v3.0.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/docker/buildx v0.13.0-rc2 // indirect - github.com/docker/cli v29.3.0+incompatible // indirect github.com/docker/cli-docs-tool v0.7.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect diff --git a/internal/iam/access/cmd/resolve.go b/internal/iam/access/cmd/resolve.go index e6358a2b..32119a3c 100644 --- a/internal/iam/access/cmd/resolve.go +++ b/internal/iam/access/cmd/resolve.go @@ -174,9 +174,10 @@ func normalizeAuthRule(obj *unstructured.Unstructured) []normalizedGrant { return nil } - var grants []normalizedGrant + subjects := readSubjectRefs(obj) + grants := make([]normalizedGrant, 0, len(subjects)) - for _, sub := range readSubjectRefs(obj) { + for _, sub := range subjects { if sub.Kind == iamtypes.KindServiceAccount { continue } @@ -360,7 +361,7 @@ func capabilityNote(implicit bool) string { } func (s *effectiveSummary) String() string { - var parts []string + parts := make([]string, 0, len(s.Namespaced)+1) if s.ClusterLevel != "" { parts = append(parts, s.ClusterLevel+"[*]") } diff --git a/internal/lockfile/doc.go b/internal/lockfile/doc.go new file mode 100644 index 00000000..3c0b0beb --- /dev/null +++ b/internal/lockfile/doc.go @@ -0,0 +1,58 @@ +/* +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 lockfile serializes mutating d8 operations across concurrent processes. +// +// # Problem +// +// Self-update and plugin install/update rewrite binaries, symlinks, and caches on +// disk. Two such commands can run concurrently (separate terminals or scripts); +// without a cross-process lock they would step on each other. +// +// # Call sites +// +// Two features share this package (same mechanism, different paths): +// +// - CLI self-update: ~/.deckhouse-cli/cli/install.lock +// - Plugin install: //install.lock +// +// # Usage +// +// Hold the lock for the whole critical section. Do not block waiting - if another +// process holds a live lock, return a user-facing error: +// +// release, err := lockfile.Acquire(path, staleAfter, onReclaim) +// if errors.Is(err, lockfile.ErrLocked) { +// return fmt.Errorf("operation already in progress") +// } +// if err != nil { +// return err +// } +// defer release() +// +// # Stale locks +// +// A holder killed with SIGKILL never runs release(). Acquire treats a lock file +// older than staleAfter as orphaned, reclaims it, and optionally notifies via +// onReclaim. Callers typically use one hour. +// +// # Implementation +// +// - Capture: atomic O_EXCL create (empty file at path; no open fd required). +// - Reclaim: rename to a scratch path, verify file identity, then remove. +// Plain Remove(path) races with a concurrent acquirer and can delete their +// fresh lock; identity-checked rename closes that hole. +package lockfile diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go new file mode 100644 index 00000000..361b841c --- /dev/null +++ b/internal/lockfile/lockfile.go @@ -0,0 +1,102 @@ +/* +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 lockfile + +import ( + "errors" + "fmt" + "os" + "time" +) + +// ErrLocked means another process holds a live lock at the path. +// Callers should fail fast with a user-facing message instead of waiting. +var ErrLocked = errors.New("lock is already held") + +// Acquire creates an exclusive lock file at path for the duration of a mutating +// operation (self-update or plugin install). See package lockfile for call sites, +// the usage pattern, and reclaim semantics. +// +// - Returns release; call it exactly once when done (typically defer release()). +// - Returns ErrLocked if another process already holds a non-stale lock. +// - Reclaims a lock older than staleAfter before trying to acquire. +// - Calls onReclaim (optional) with the orphan's age after a successful reclaim. +func Acquire(path string, staleAfter time.Duration, onReclaim func(age time.Duration)) (func(), error) { + info, err := os.Stat(path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("check lock file %s: %w", path, err) + } + + stale := err == nil && time.Since(info.ModTime()) > staleAfter + if stale { + if err := reclaim(path, info, onReclaim); err != nil { + return nil, err + } + } + + // O_EXCL makes create atomic: a racer between reclaim and create loses here. + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if os.IsExist(err) { + return nil, fmt.Errorf("%w: %s", ErrLocked, path) + } + + if err != nil { + return nil, fmt.Errorf("create lock file %s: %w", path, err) + } + + _ = file.Close() + + return func() { _ = os.Remove(path) }, nil +} + +// reclaim removes the orphaned lock described by stale. +// +// - Renames path to a unique scratch path; exactly one concurrent reclaimer wins. +// - Checks file identity: if the moved file is not stale, a concurrent acquirer +// created a fresh lock after our staleness check - restore it and return ErrLocked. +// - Otherwise removes the scratch file and optionally calls onReclaim. +func reclaim(path string, stale os.FileInfo, onReclaim func(age time.Duration)) error { + reclaimScratch := fmt.Sprintf("%s.reclaim.%d", path, os.Getpid()) + + err := os.Rename(path, reclaimScratch) + if os.IsNotExist(err) { + // Another reclaimer won the rename; fall through to O_EXCL create. + return nil + } + + if err != nil { + return fmt.Errorf("reclaim stale lock %s: %w", path, err) + } + + moved, err := os.Stat(reclaimScratch) + // Rename may have moved a fresh lock created after our Stat in Acquire. + grabbedFresh := err == nil && !os.SameFile(stale, moved) + + if grabbedFresh { + if err := os.Rename(reclaimScratch, path); err != nil { + _ = os.Remove(reclaimScratch) + } + + return fmt.Errorf("%w: %s", ErrLocked, path) + } + + if onReclaim != nil { + onReclaim(time.Since(stale.ModTime())) + } + + return os.Remove(reclaimScratch) +} diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go new file mode 100644 index 00000000..fec34c77 --- /dev/null +++ b/internal/lockfile/lockfile_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 lockfile + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const staleAfter = time.Hour + +func TestAcquireIsExclusive(t *testing.T) { + lock := filepath.Join(t.TempDir(), "x.lock") + + release, err := Acquire(lock, staleAfter, nil) + require.NoError(t, err) + require.NotNil(t, release) + + _, err = Acquire(lock, staleAfter, nil) + require.ErrorIs(t, err, ErrLocked, "a held lock blocks a second acquirer") + + release() + + release2, err := Acquire(lock, staleAfter, nil) + require.NoError(t, err, "a released lock can be re-acquired") + release2() +} + +func TestAcquireReclaimsStale(t *testing.T) { + lock := filepath.Join(t.TempDir(), "x.lock") + + require.NoError(t, os.WriteFile(lock, nil, 0o644)) + old := time.Now().Add(-2 * staleAfter) + require.NoError(t, os.Chtimes(lock, old, old)) + + var reclaimedAge time.Duration + + release, err := Acquire(lock, staleAfter, func(age time.Duration) { reclaimedAge = age }) + require.NoError(t, err, "a stale lock is reclaimed (orphaned by a hard kill)") + assert.Greater(t, reclaimedAge, staleAfter, "onReclaim reports the orphan's age") + + release() + + _, statErr := os.Stat(lock) + assert.True(t, os.IsNotExist(statErr), "release removes the lock") +} + +func TestReclaimDoesNotStealFreshLock(t *testing.T) { + // remove-by-path race: A and B both judge the lock stale. + // + // - A reclaims and creates a fresh lock. + // - B continues with its old FileInfo, must detect the mismatch, + // leave A's lock in place, and return ErrLocked. + lock := filepath.Join(t.TempDir(), "x.lock") + + require.NoError(t, os.WriteFile(lock, nil, 0o644)) + old := time.Now().Add(-2 * staleAfter) + require.NoError(t, os.Chtimes(lock, old, old)) + + staleInfo, err := os.Stat(lock) + require.NoError(t, err) + + // A wins reclaim and holds a fresh lock. + releaseA, err := Acquire(lock, staleAfter, nil) + require.NoError(t, err) + + // B replays reclaim from the stale FileInfo captured earlier. + err = reclaim(lock, staleInfo, nil) + require.ErrorIs(t, err, ErrLocked, "B must report the lock held, not reclaim it") + + _, statErr := os.Stat(lock) + require.NoError(t, statErr, "A's fresh lock is still in place") + + releaseA() +} diff --git a/internal/plugin.go b/internal/plugin.go index 597861c4..16291174 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -85,9 +85,9 @@ type PluginRequirementsGroup struct { } // ModuleRequirementsGroup splits module requirements into Mandatory, Conditional, and AnyOf. -// - Mandatory: the module must be in the cluster AND satisfy the constraint. -// - Conditional: only enforced if the module is in the cluster. -// - AnyOf: at least one module per group must be in the cluster and satisfy its constraint. +// - Mandatory: the module must be enabled AND satisfy the constraint. +// - Conditional: only enforced if the module is enabled. +// - AnyOf: at least one module per group must be enabled and satisfy its constraint. type ModuleRequirementsGroup struct { Mandatory []ModuleRequirement Conditional []ModuleRequirement diff --git a/internal/plugins/README.md b/internal/plugins/README.md new file mode 100644 index 00000000..dc73c099 --- /dev/null +++ b/internal/plugins/README.md @@ -0,0 +1,168 @@ +# d8 plugins + +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. + +## Why + +- Isolate dependencies and let teams develop plugins independently of d8. +- Keep `d8` itself compact - heavy functionality ships as plugins. +- Guarantee compatibility: a plugin declares requirements (Kubernetes, + Deckhouse, modules, other plugins) and d8 enforces them both at install time + and before every run. + +## Commands + +| 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 ...` *(wrapper, with `DECKHOUSE_PLUGINS_ENABLED=true`)* | run an installed plugin; auto-installs it on first use | + +## Plugin source + +`rppPluginSource` (`rpp_source.go`) implements the `PluginSource` interface +(`source.go`) and is the only source: plugins are pulled through the in-cluster +registry-packages-proxy using the **kubeconfig identity**, with no registry +credentials on the user side (ADR: deckhouse-cli reaches the registry +exclusively through the proxy, so every command needs a reachable cluster). +See `internal/selfupdate/README.md` for what RPP is and how authorization works; +the plugin routes are `/v1/images/deckhouse-cli/plugins//...`. + +## What a plugin image contains + +- `plugin` - the executable; +- `contract.yaml` - the contract: name, version, description, requested env + vars, flags, and `requirements` (Kubernetes / Deckhouse / modules / plugins). + +The RPP source reads the `contract.yaml` file from the image tar. + +## On-disk layout + +``` +/ # /opt/deckhouse/lib/deckhouse-cli by default +├── plugins// +│ ├── v/ # one binary per major version +│ ├── current -> v/ # the active version (atomic symlink swap) +│ └── install.lock # one install lock per plugin +└── cache/contracts/.json # contract of the installed version (atomic writes) +``` + +- `--plugins-dir` / `DECKHOUSE_CLI_PATH` override the root; if it is not + writable, installs fall back to `~/.deckhouse-cli`. +- "Installed" means "has a `current` symlink" - a leftover directory from a + failed install is never treated as an installed plugin. + +## How install works (`InstallPlugin`) + +1. Validate the plugin name (a single OCI path component - nothing else may + reach filesystem paths or registry routes). +2. Pick the version (see policy below) and take the per-plugin lock. +3. If the selected version is already current - nothing to do (`--force` re-pulls). +4. Fetch the contract and validate ALL requirements BEFORE any switch - + including the fast path that merely repoints `current` to an already + installed version. +5. Download into a staged file (`.new`) - the live binary keeps working + for the whole download. +6. Smoke-test the staged binary (`--version`, fallback `version`; only a clean + exit is required) - a corrupt or wrong-platform artifact is rejected before + it replaces anything. +7. Atomically swap the new binary in (rename over the live one - the original is + untouched on failure), write the contract cache, then atomically repoint `current`. + +A failure at any step leaves the previous version installed and working. + +## Version selection policy + +- Default pick: the **newest stable** semver tag whose **cluster-side + requirements are satisfied** AND whose **plugin->plugin dependency chain is + resolvable** - versions are probed newest to oldest and the first that passes + both wins (a too-new release, or one whose dependencies cannot be satisfied, + does not block updates). +- Updates stay **within the installed major**; crossing majors requires an + explicit `--use-major N`. The major is read from disk (the `current` + symlink), so a broken binary cannot drop the pin. +- **Downgrade guard**: the implicit path never installs a version older than the + installed one - e.g. when the newest tag's contract is temporarily unreadable. + Downgrades are explicit only (`--version`, `--use-major`). +- Pre-releases (`rc`/`alpha`/`beta`) are never picked by default; install them + via `--version`. +- An unreachable cluster or a malformed contract is a hard error, not a silent + fallback to an older version. + +## Requirements enforcement + +- **Cluster-side** (`kubernetes`, `deckhouse`, `modules` incl. + mandatory/conditional/anyOf): verified against a one-shot cluster snapshot + (the `requirements/` package); the cluster is queried only when the plugin + actually declares such requirements, so contract-less plugins install offline. +- **Plugin-to-plugin**: a plugin's mandatory dependencies are installed and + upgraded automatically (the resolution planner: constraint-aware, newest + satisfying version, within each dependency's own major - or across it when + `--use-major` cascades). Conflicts with already-installed plugins skip a + candidate during selection. +- **At runtime**: the wrapper re-validates requirements before EVERY plugin run + (the gate is skipped for purely local queries: `--help`, `--version`, + `completion`). +- Escape hatch for air-gapped setups: `--skip-cluster-checks` / + `D8_PLUGINS_SKIP_CLUSTER_CHECKS=1` (downgrades the check to a warning). + +## Running a plugin (the wrapper) + +- All arguments are forwarded verbatim (the wrapper parses no flags itself). +- Env requested by the contract is injected: `KUBECONFIG` (the path d8 uses) + and `PLUGINS_CALLER` (the d8 executable); everything else passes through. +- stdin/stdout/stderr are inherited; the plugin's exact exit code is propagated. +- On d8's own termination the plugin gets SIGTERM and a grace period, not an + instant SIGKILL. + +## Switches + +| Need | How | +|---|---| +| install root | `--plugins-dir` / `DECKHOUSE_CLI_PATH` | +| identity (rpp + cluster checks) | `-k/--kubeconfig`, `--context` | +| RPP endpoint / TLS | `--rpp-endpoint`, `--rpp-ca-file`, `--rpp-insecure-skip-tls-verify` | +| skip cluster-side requirement checks | `--skip-cluster-checks` / `D8_PLUGINS_SKIP_CLUSTER_CHECKS=1` | + +## 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. +- 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 + cannot be resolved is skipped); the chain is actually installed only for the + finally chosen version. Recursion has a cycle guard and a depth cap. +- Dependencies are only upgraded, never downgraded, to satisfy a constraint. + +## Package map + +| File | Responsibility | +|---|---| +| `plugins.go` | the `Manager`: shared state of the plugin machinery | +| `install.go` | the install pipeline: lock, staged download, smoke, atomic swap, idempotency | +| `select.go` | newest-compatible version selection, contract memoization | +| `update.go` | `UpdateAll`, 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 | +| `run.go` | running an installed plugin: requirement gate, env injection, exec | +| `list.go` / `versions.go` | data for the `list` / `versions` commands | +| `source.go` / `rpp_source.go` / `init.go` | the `PluginSource` interface, its RPP implementation, source wiring | +| `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 | + +Related: `internal/rpp` (proxy HTTP client), `internal/lockfile` (install lock), +`internal/selfupdate` (the same store-and-symlink update pattern for the d8 +binary itself). diff --git a/internal/plugins/cmd/contract.go b/internal/plugins/cmd/contract.go index 6f2853be..074bfa7d 100644 --- a/internal/plugins/cmd/contract.go +++ b/internal/plugins/cmd/contract.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package pluginscmd import ( "encoding/json" @@ -24,37 +24,38 @@ import ( "github.com/spf13/cobra" "sigs.k8s.io/yaml" + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/plugins" "github.com/deckhouse/deckhouse-cli/pkg/registry/service" ) -func (pc *PluginsCommand) pluginsContractCommand() *cobra.Command { - var ( - version string - useMajor int - ) - - cmd := &cobra.Command{ - Use: "contract [plugin-name]", - Short: "Get the contract for a specific plugin", - Long: "Retrieve and display the contract specification for a specific plugin from the registry", +func newContractCommand(manager *plugins.Manager, logger *dkplog.Logger) *cobra.Command { + return &cobra.Command{ + Use: "contract ", + Short: "Show a plugin's contract", + Long: "Show the latest published contract of a plugin: version, description and requirements.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { pluginName := args[0] + if err := plugins.ValidatePluginName(pluginName); err != nil { + return err + } + ctx := cmd.Context() - latestVersion, err := pc.fetchLatestVersion(ctx, pluginName) + latestVersion, err := manager.LatestVersion(ctx, pluginName) if err != nil { return fmt.Errorf("failed to fetch latest version: %w", err) } tag := latestVersion.Original() - pc.logger.Debug("Fetching contract for plugin", slog.String("plugin", pluginName), slog.String("tag", tag)) + logger.Debug("Fetching contract for plugin", slog.String("plugin", pluginName), slog.String("tag", tag)) - // Use service to get plugin contract - plugin, err := pc.service.GetPluginContract(ctx, pluginName, tag) + plugin, err := manager.PluginContract(ctx, pluginName, tag) if err != nil { - pc.logger.Warn("Failed to get plugin contract", + logger.Warn("Failed to get plugin contract", slog.String("plugin", pluginName), slog.String("tag", tag), slog.String("error", err.Error())) @@ -80,9 +81,4 @@ func (pc *PluginsCommand) pluginsContractCommand() *cobra.Command { return nil }, } - - cmd.Flags().StringVar(&version, "version", "", "Specific version of the plugin contract to retrieve") - cmd.Flags().IntVar(&useMajor, "use-major", 0, "Use specific major version (e.g., 1, 2)") - - return cmd } diff --git a/internal/plugins/cmd/errdetect/diagnose.go b/internal/plugins/cmd/errdetect/diagnose.go new file mode 100644 index 00000000..731fed55 --- /dev/null +++ b/internal/plugins/cmd/errdetect/diagnose.go @@ -0,0 +1,72 @@ +/* +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 errdetect maps registry-packages-proxy failures from `d8 plugins` to +// HelpfulErrors with plugin-specific guidance. +package errdetect + +import ( + "errors" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +// Diagnose returns a HelpfulError for a recognized proxy failure, or nil for a nil, +// unrecognized, or already-diagnosed error - so the caller keeps the original. +func Diagnose(err error) *diagnostic.HelpfulError { + var he *diagnostic.HelpfulError + if err == nil || errors.As(err, &he) { + return nil + } + + switch { + case errors.Is(err, rpp.ErrUnauthorized): + return help(err, "registry-packages-proxy: unauthorized (401)", + "no accepted Bearer token (a client-certificate kubeconfig is not enough)", + "use a kubeconfig with an OIDC token (Kubeconfig Generator or 'd8 login')") + case errors.Is(err, rpp.ErrForbidden): + return help(err, "registry-packages-proxy: forbidden (403)", + "the identity may not download plugins", + "bind the ClusterRole 'd8:registry-packages-proxy:packages-download' to the user/group", + "authorization is cached ~5 min - after binding, retry with a fresh token") + 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 '", + "confirm it is published under 'deckhouse-cli/plugins/'") + case errors.Is(err, rpp.ErrUpstream): + return help(err, "registry-packages-proxy: 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") + case errors.Is(err, rpp.ErrEndpointDiscovery): + return help(err, "registry-packages-proxy: endpoint discovery via the Kubernetes API failed", + "discovery reaches the proxy through your kubeconfig's API server, which was unreachable or presented an invalid certificate", + "this is the Kubernetes API endpoint (kubeconfig 'server:'), not the proxy - confirm it is reachable and its TLS certificate is valid for that host", + "skip discovery: pass --rpp-endpoint https://registry-packages-proxy. (or set D8_RPP_ENDPOINT)", + "on a master node, point the kubeconfig at the local API (https://127.0.0.1:6445, CA /etc/kubernetes/pki/ca.crt) with an OIDC token") + default: + return nil + } +} + +func help(err error, category, cause string, solutions ...string) *diagnostic.HelpfulError { + return &diagnostic.HelpfulError{ + Category: category, + OriginalErr: err, + Suggestions: []diagnostic.Suggestion{{Cause: cause, Solutions: solutions}}, + } +} diff --git a/internal/plugins/cmd/errdetect/diagnose_test.go b/internal/plugins/cmd/errdetect/diagnose_test.go new file mode 100644 index 00000000..655e4683 --- /dev/null +++ b/internal/plugins/cmd/errdetect/diagnose_test.go @@ -0,0 +1,62 @@ +/* +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 errdetect + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +func TestDiagnose(t *testing.T) { + cases := []struct { + name string + sentinel error + wantCat string + wantSol string + }{ + {"401", rpp.ErrUnauthorized, "unauthorized (401)", "OIDC"}, + {"403", rpp.ErrForbidden, "forbidden (403)", "packages-download"}, + {"404", rpp.ErrNotFound, "plugin or version not found (404)", "deckhouse-cli/plugins"}, + {"5xx", rpp.ErrUpstream, "upstream error (5xx)", "registry-packages-proxy pods"}, + {"discovery", rpp.ErrEndpointDiscovery, "endpoint discovery via the Kubernetes API failed", "--rpp-endpoint"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + he := Diagnose(fmt.Errorf("GET /v1/images/deckhouse-cli/plugins/x/tags: %w", tc.sentinel)) + require.NotNil(t, he) + assert.Contains(t, he.Category, tc.wantCat) + require.Len(t, he.Suggestions, 1) + assert.Contains(t, strings.Join(he.Suggestions[0].Solutions, " "), tc.wantSol) + assert.ErrorIs(t, he, tc.sentinel, "the original cause is preserved") + }) + } +} + +func TestDiagnoseReturnsNil(t *testing.T) { + assert.Nil(t, Diagnose(nil)) + assert.Nil(t, Diagnose(errors.New("some other failure")), "an unrecognized error is left alone") + assert.Nil(t, Diagnose(&diagnostic.HelpfulError{Category: "preexisting"}), "an already-diagnosed error is left alone") +} diff --git a/internal/plugins/cmd/flags/flags.go b/internal/plugins/cmd/flags/flags.go deleted file mode 100644 index 397a68c0..00000000 --- a/internal/plugins/cmd/flags/flags.go +++ /dev/null @@ -1,90 +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 flags - -import ( - "os" - - "github.com/spf13/pflag" -) - -const ( - deckhouseRegistryHost = "registry.deckhouse.io" - - EnterpriseEditionRepo = deckhouseRegistryHost + "/deckhouse/ee" - - DefaultDeckhousePluginsDir = "/opt/deckhouse/lib/deckhouse-cli" -) - -// CLI Parameters -var ( - DeckhousePluginsDir = DefaultDeckhousePluginsDir - - Insecure bool - TLSSkipVerify bool - - SourceRegistryRepo = EnterpriseEditionRepo // Fallback to EE if nothing was given as source. - SourceRegistryLogin string - SourceRegistryPassword string - DeckhouseLicenseToken string -) - -func AddFlags(flagSet *pflag.FlagSet) { - flagSet.StringVar( - &SourceRegistryRepo, - "source", - SourceRegistryRepo, - "Source registry to pull Deckhouse plugins from.", - ) - flagSet.StringVar( - &SourceRegistryLogin, - "source-login", - os.Getenv("D8_MIRROR_SOURCE_LOGIN"), - "Source registry login.", - ) - flagSet.StringVar( - &SourceRegistryPassword, - "source-password", - os.Getenv("D8_MIRROR_SOURCE_PASSWORD"), - "Source registry password.", - ) - flagSet.StringVarP( - &DeckhouseLicenseToken, - "license", - "l", - os.Getenv("D8_MIRROR_LICENSE_TOKEN"), - "Deckhouse license key. Shortcut for --source-login=license-token --source-password=<>.", - ) - flagSet.BoolVar( - &TLSSkipVerify, - "tls-skip-verify", - false, - "Disable TLS certificate validation.", - ) - flagSet.BoolVar( - &Insecure, - "insecure", - false, - "Interact with registries over HTTP.", - ) - flagSet.StringVar( - &DeckhousePluginsDir, - "plugins-dir", - DeckhousePluginsDir, - "Path to the d8 plugins directory.", - ) -} diff --git a/internal/plugins/cmd/init.go b/internal/plugins/cmd/init.go deleted file mode 100644 index 474a90d3..00000000 --- a/internal/plugins/cmd/init.go +++ /dev/null @@ -1,169 +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 plugins - -import ( - "log/slog" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - - dkplog "github.com/deckhouse/deckhouse/pkg/log" - regclient "github.com/deckhouse/deckhouse/pkg/registry/client" - - d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/flags" - pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" - "github.com/deckhouse/deckhouse-cli/pkg/registry/service" -) - -func (pc *PluginsCommand) InitPluginServices() { - pc.logger.Debug("Initializing plugin services") - - // Extract registry host from the source registry repo - // SourceRegistryRepo can be: - // - Just hostname: "registry.deckhouse.io" - // - Full path: "registry.deckhouse.io/deckhouse/ee" - sourceRepo := d8flags.SourceRegistryRepo - registryHost := sourceRepo - registryPath, edition := service.GetEditionFromRegistryPath(sourceRepo) - - // If it's just a hostname (no slashes), use it directly - // Otherwise parse to extract the hostname - if containsSlash(registryHost) { - // Has path components, parse to extract registry - ref, err := name.ParseReference(registryHost) - if err == nil { - registryHost = ref.Context().RegistryStr() - pc.logger.Debug("Extracted registry from path", - slog.String("extracted", registryHost)) - } - } - - auth := getPluginRegistryAuthProvider(registryHost, pc.logger) - - pc.logger.Debug("Creating plugin registry client", - slog.String("registry_host", registryHost), - slog.Bool("insecure", d8flags.Insecure), - slog.Bool("tls_skip_verify", d8flags.TLSSkipVerify)) - - // Create base client with registry host only - clientOpts := []regclient.Option{ - regclient.WithAuth(auth), - regclient.WithInsecure(d8flags.Insecure), - regclient.WithTLSSkipVerify(d8flags.TLSSkipVerify), - regclient.WithLogger(pc.logger.Named("registry-client")), - } - pc.pluginRegistryClient = pkgclient.NewFromOptions(registryPath, clientOpts...) - - pc.logger.Debug("Creating plugin service with scoped client", - slog.String("path", registryPath)) - - registryService := service.NewService( - pc.pluginRegistryClient, - edition, - pc.logger.Named("registry-service"), - ) - - pc.service = registryService.PluginService() - - pc.logger.Debug("Plugin services initialized successfully") -} - -// containsSlash checks if a string contains a forward slash -func containsSlash(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] == '/' { - return true - } - } - - return false -} - -func getPluginRegistryAuthProvider(registryHost string, logger *dkplog.Logger) authn.Authenticator { - // Priority 1: Explicit username/password from flags - if d8flags.SourceRegistryLogin != "" { - logger.Debug("Using explicit credentials from flags", - slog.String("username", d8flags.SourceRegistryLogin)) - - return authn.FromConfig(authn.AuthConfig{ - Username: d8flags.SourceRegistryLogin, - Password: d8flags.SourceRegistryPassword, - }) - } - - // Priority 2: License token from flags - if d8flags.DeckhouseLicenseToken != "" { - logger.Debug("Using license token from flags") - - return authn.FromConfig(authn.AuthConfig{ - Username: "license-token", - Password: d8flags.DeckhouseLicenseToken, - }) - } - - // Priority 3: Try to get credentials from Docker config (~/.docker/config.json) - // Parse the registry hostname to create a Registry object for DefaultKeychain - var ( - reg name.Registry - err error - ) - - // If registryHost contains a slash, it might be a full path - extract just the host - - if containsSlash(registryHost) { - ref, parseErr := name.ParseReference(registryHost) - if parseErr == nil { - reg = ref.Context().Registry - } else { - // Fallback: just use the part before the first slash - idx := 0 - - for i := 0; i < len(registryHost); i++ { - if registryHost[i] == '/' { - idx = i - break - } - } - - reg, err = name.NewRegistry(registryHost[:idx]) - } - } else { - // Just a hostname, parse it directly - reg, err = name.NewRegistry(registryHost) - } - - if err == nil { - auth, err := authn.DefaultKeychain.Resolve(reg) - if err == nil && auth != authn.Anonymous { - // Verify that auth is not anonymous by trying to get the config - cfg, err := auth.Authorization() - if err == nil && (cfg.Username != "" || cfg.Password != "" || cfg.Auth != "" || cfg.IdentityToken != "") { - logger.Debug("Using credentials from Docker config", - slog.String("registry", reg.String())) - - return auth - } - } - } - - // Priority 4: Anonymous access - logger.Debug("Using anonymous access for registry", - slog.String("registry", registryHost)) - - return authn.Anonymous -} diff --git a/internal/plugins/cmd/install.go b/internal/plugins/cmd/install.go index ff9bc6a0..aa42aed1 100644 --- a/internal/plugins/cmd/install.go +++ b/internal/plugins/cmd/install.go @@ -14,387 +14,51 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package pluginscmd import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "path/filepath" - - "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" - "github.com/deckhouse/deckhouse-cli/internal" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" - "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + "github.com/deckhouse/deckhouse-cli/internal/plugins" ) -func (pc *PluginsCommand) pluginsInstallCommand() *cobra.Command { +func newInstallCommand(manager *plugins.Manager) *cobra.Command { var ( - version string - useMajor int - resolvePluginsConflicts bool + version string + useMajor int + force bool ) cmd := &cobra.Command{ - Use: "install [plugin-name]", + Use: "install ", Short: "Install a Deckhouse CLI plugin", - Long: "Install a new plugin", - Args: cobra.ExactArgs(1), + Long: "Install a plugin: the newest version compatible with this cluster by default,\n" + + "an exact one with --version.\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), RunE: func(cmd *cobra.Command, args []string) error { pluginName := args[0] ctx := cmd.Context() - opts := []installPluginOption{ - installWithVersion(version), - installWithMajorVersion(useMajor), + opts := []plugins.InstallOption{ + plugins.InstallWithVersion(version), + plugins.InstallWithMajorVersion(useMajor), } - if resolvePluginsConflicts { - opts = append(opts, installWithResolvePluginsConflicts()) + if force { + opts = append(opts, plugins.InstallWithForce()) } - return pc.InstallPlugin(ctx, pluginName, opts...) + return manager.InstallPlugin(ctx, pluginName, opts...) }, } - cmd.Flags().StringVar(&version, "version", "", "Specific version of the plugin to install") - cmd.Flags().IntVar(&useMajor, "use-major", -1, "Use specific major version (e.g., 1, 2)") - cmd.Flags().BoolVar(&resolvePluginsConflicts, "resolve-plugins-conflicts", false, "Resolve conflicts between plugins requirements") + 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().BoolVar(&force, "force", false, "Reinstall even if the selected version is already installed (re-pull + re-verify).") return cmd } - -type installPluginOptions struct { - version string - majorVersion int - resolvePluginsConflicts bool -} - -type installPluginOption func(*installPluginOptions) - -func installWithMajorVersion(majorVersion int) installPluginOption { - return func(opts *installPluginOptions) { - opts.majorVersion = majorVersion - } -} - -func installWithVersion(version string) installPluginOption { - return func(opts *installPluginOptions) { - opts.version = version - } -} - -func installWithResolvePluginsConflicts() installPluginOption { - return func(opts *installPluginOptions) { - opts.resolvePluginsConflicts = true - } -} - -// InstallPlugin checks if plugin can be installed, creates folders layout and then installs plugin, creates symlink "current" and caches contract.json. -// version - semver version string (e.g. v1.0.0), default: "" (use latest version) -// useMajor - major version to install, default: -1 (use latest major version) -// resolvePluginsConflicts - resolve conflicts between installed plugins, default: false -func (pc *PluginsCommand) InstallPlugin(ctx context.Context, pluginName string, opts ...installPluginOption) error { - // check if version is specified - var installVersion *semver.Version - - options := &installPluginOptions{ - majorVersion: -1, - } - - for _, opt := range opts { - opt(options) - } - - if options.version != "" { - var err error - - installVersion, err = semver.NewVersion(options.version) - if err != nil { - return fmt.Errorf("failed to parse version: %w", err) - } - - return pc.installPlugin(ctx, pluginName, installVersion, options.resolvePluginsConflicts) - } - - versions, err := pc.service.ListPluginTags(ctx, pluginName) - if err != nil { - pc.logger.Warn("Failed to list plugin tags", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return fmt.Errorf("failed to list plugin tags: %w", err) - } - - if options.majorVersion >= 0 { - versions = pc.filterMajorVersion(versions, options.majorVersion) - if len(versions) == 0 { - return fmt.Errorf("no versions found for major version: %d", options.majorVersion) - } - } - - installVersion, err = pc.findLatestVersion(versions) - if err != nil { - pc.logger.Warn("Failed to fetch latest version", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return fmt.Errorf("failed to fetch latest version: %w", err) - } - - return pc.installPlugin(ctx, pluginName, installVersion, options.resolvePluginsConflicts) -} - -// pluginPaths bundles the filesystem locations an install operates on. -// Created once by preparePluginDirs and threaded through the install pipeline. -type pluginPaths struct { - pluginDir string // /plugins/ - versionDir string // /plugins//v - binaryPath string // /plugins//v/ - lockPath string // /plugins//v/.lock - currentLink string // /plugins//current -} - -// installPlugin is the install pipeline orchestrator. Each step delegates to -// a focused helper below; the order is significant and stays identical to -// the pre-split monolith. -func (pc *PluginsCommand) installPlugin(ctx context.Context, pluginName string, version *semver.Version, resolvePluginsConflicts bool) error { - paths, err := pc.preparePluginDirs(pluginName, version) - if err != nil { - return err - } - - release, err := pc.acquireInstallLock(paths.lockPath) - if err != nil { - return err - } - defer release() - - plugin, err := pc.fetchAndDisplayContract(ctx, pluginName, version) - if err != nil { - return err - } - - if err := pc.validateAndResolveConflicts(ctx, plugin, resolvePluginsConflicts); err != nil { - return err - } - - if err := pc.backupOldBinary(paths.binaryPath); err != nil { - return err - } - - if err := pc.downloadAndExtract(ctx, pluginName, version, paths.binaryPath); err != nil { - return err - } - - if err := pc.linkCurrent(paths); err != nil { - return err - } - - if err := pc.cacheContract(pluginName, plugin); err != nil { - return err - } - - fmt.Printf("✓ Plugin '%s' successfully installed!\n", pluginName) - - return nil -} - -// preparePluginDirs creates plugins//v on disk and returns the -// paths derived from used by the rest of the pipeline. -func (pc *PluginsCommand) preparePluginDirs(pluginName string, version *semver.Version) (pluginPaths, error) { - major := int(version.Major()) - paths := pluginPaths{ - pluginDir: layout.PluginDir(pc.pluginDirectory, pluginName), - versionDir: layout.VersionDir(pc.pluginDirectory, pluginName, major), - binaryPath: layout.BinaryPath(pc.pluginDirectory, pluginName, major), - lockPath: layout.LockPath(pc.pluginDirectory, pluginName, major), - currentLink: layout.CurrentLinkPath(pc.pluginDirectory, pluginName), - } - - if err := os.MkdirAll(paths.pluginDir, 0755); err != nil { - return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) - } - - if err := os.MkdirAll(paths.versionDir, 0755); err != nil { - return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) - } - - return paths, nil -} - -// acquireInstallLock creates the lock file at lockFilePath; if it already -// exists, returns an error without touching it. The caller must invoke the -// returned release func when finished (typically via defer). -func (pc *PluginsCommand) acquireInstallLock(lockFilePath string) (func(), error) { - _, err := os.Stat(lockFilePath) - if err == nil { - // File exists, plugin is locked - return nil, fmt.Errorf("plugin is locked by: %s", lockFilePath) - } - // Some other error occurred (permissions, etc.) - if !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to check lock file %s: %w", lockFilePath, err) - } - - lockFile, err := os.Create(lockFilePath) - if err != nil { - return nil, fmt.Errorf("failed to create lock file: %w", err) - } - - lockFile.Close() - - return func() { os.Remove(lockFilePath) }, nil -} - -// fetchAndDisplayContract pulls the contract for from -// the registry and prints the installing-plugin banner. -func (pc *PluginsCommand) fetchAndDisplayContract(ctx context.Context, pluginName string, version *semver.Version) (*internal.Plugin, error) { - tag := version.Original() - - fmt.Printf("Installing plugin: %s\n", pluginName) - fmt.Printf("Tag: %s\n", tag) - - plugin, err := pc.service.GetPluginContract(ctx, pluginName, tag) - if err != nil { - return nil, fmt.Errorf("failed to get plugin contract: %w", err) - } - - fmt.Printf("Plugin: %s %s\n", plugin.Name, plugin.Version) - fmt.Printf("Description: %s\n", plugin.Description) - - return plugin, nil -} - -// validateAndResolveConflicts runs validateRequirements; if requirements are -// not satisfied and resolvePluginsConflicts is true, attempts to fix them -// recursively; otherwise returns an error. -func (pc *PluginsCommand) validateAndResolveConflicts(ctx context.Context, plugin *internal.Plugin, resolvePluginsConflicts bool) error { - pc.logger.Debug("validating requirements", slog.String("plugin", plugin.Name)) - - failedConstraints, err := pc.validateRequirements(plugin) - if err != nil { - return fmt.Errorf("failed to validate requirements: %w", err) - } - - if len(failedConstraints) > 0 && !resolvePluginsConflicts { - return fmt.Errorf("plugin requirements not satisfied") - } - - if len(failedConstraints) > 0 && resolvePluginsConflicts { - if err := pc.resolvePluginConflicts(ctx, failedConstraints); err != nil { - return fmt.Errorf("failed to resolve conflicts: %w", err) - } - } - - return nil -} - -// backupOldBinary renames an already-installed binary to .old so -// a fresh extract has a clean destination. No-op if no binary present yet. -func (pc *PluginsCommand) backupOldBinary(binaryPath string) error { - info, err := os.Stat(binaryPath) - if err != nil || info.IsDir() { - return nil - } - - if err := os.Rename(binaryPath, binaryPath+".old"); err != nil { - return fmt.Errorf("failed to save old version: %w", err) - } - - return nil -} - -// downloadAndExtract pulls the plugin image tag and writes the embedded -// binary to . -func (pc *PluginsCommand) downloadAndExtract(ctx context.Context, pluginName string, version *semver.Version, binaryPath string) error { - tag := version.Original() - - fmt.Printf("Installing to: %s\n", binaryPath) - fmt.Println("Downloading and extracting plugin...") - - if err := pc.service.ExtractPlugin(ctx, pluginName, tag, binaryPath); err != nil { - pc.logger.Warn("Failed to extract plugin", - slog.String("plugin", pluginName), - slog.String("tag", tag), - slog.String("destination", binaryPath), - slog.String("error", err.Error())) - - return fmt.Errorf("failed to extract plugin: %w", err) - } - - return nil -} - -// linkCurrent (re)points /current to the freshly installed binary -// using an absolute target path. -func (pc *PluginsCommand) linkCurrent(paths pluginPaths) error { - _ = os.Remove(paths.currentLink) - - absPath, err := filepath.Abs(paths.binaryPath) - if err != nil { - return fmt.Errorf("failed to compute absolute path: %w", err) - } - - if err := os.Symlink(absPath, paths.currentLink); err != nil { - return fmt.Errorf("failed to create symlink: %w", err) - } - - return nil -} - -// cacheContract writes the plugin contract JSON to -// /cache/contracts/.json for later lookups by -// validatePluginConflicts and `d8 plugins list`. -func (pc *PluginsCommand) cacheContract(pluginName string, plugin *internal.Plugin) error { - if err := os.MkdirAll(layout.ContractsDir(pc.pluginDirectory), 0755); err != nil { - return fmt.Errorf("failed to create contract directory: %w", err) - } - - contract := service.DomainToContract(plugin) - - contractFile, err := os.OpenFile(layout.ContractFile(pc.pluginDirectory, pluginName), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return fmt.Errorf("failed to open contract file: %w", err) - } - defer contractFile.Close() - - enc := json.NewEncoder(contractFile) - enc.SetIndent("", " ") - enc.SetEscapeHTML(false) - - if err := enc.Encode(contract); err != nil { - return fmt.Errorf("failed to cache contract: %w", err) - } - - return nil -} - -func (pc *PluginsCommand) filterMajorVersion(versions []string, majorVersion int) []string { - res := make([]string, 0, 1) - - for _, ver := range versions { - version, err := semver.NewVersion(ver) - if err != nil { - continue - } - - if version.Major() == uint64(majorVersion) { - res = append(res, ver) - } - } - - return res -} - -func (pc *PluginsCommand) resolvePluginConflicts(ctx context.Context, failedConstraints FailedConstraints) error { - // for each failed constraint, try to install the plugin - for pluginName := range failedConstraints { - pc.logger.Debug("resolving plugin conflict", slog.String("plugin", pluginName)) - - err := pc.InstallPlugin(ctx, pluginName, installWithResolvePluginsConflicts()) - if err != nil { - return fmt.Errorf("failed to install plugin: %w", err) - } - } - - return nil -} diff --git a/internal/plugins/cmd/list.go b/internal/plugins/cmd/list.go index 95dd5cf2..f1ce5162 100644 --- a/internal/plugins/cmd/list.go +++ b/internal/plugins/cmd/list.go @@ -14,261 +14,49 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package pluginscmd import ( - "context" "fmt" - "log/slog" - "os" "github.com/spf13/cobra" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" + "github.com/deckhouse/deckhouse-cli/internal/plugins" ) -// pluginDisplayInfo holds all information needed to display a plugin -type pluginDisplayInfo struct { - Name string - Version string - Description string -} - -// pluginsListData holds all data for the list command -type pluginsListData struct { - Installed []pluginDisplayInfo - Available []pluginDisplayInfo - RegistryError error -} - -func (pc *PluginsCommand) pluginsListCommand() *cobra.Command { - var ( - showInstalledOnly bool - showAvailableOnly bool - ) - - cmd := &cobra.Command{ +func newListCommand(manager *plugins.Manager) *cobra.Command { + return &cobra.Command{ Use: "list", - Short: "List Deckhouse CLI plugins", - Long: "Display detailed information about installed plugins and available plugins from the registry", + 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 plugins versions '.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - // Prepare all data before printing - data := pc.preparePluginsListData(ctx, showInstalledOnly, showAvailableOnly) - - // Print all prepared data - pc.printPluginsList(data, showInstalledOnly, showAvailableOnly) + printInstalledPlugins(manager.List()) return nil }, } - - cmd.Flags().BoolVar(&showInstalledOnly, "installed", false, "Show only installed plugins") - cmd.Flags().BoolVar(&showAvailableOnly, "available", false, "Show only available plugins from registry") - - return cmd -} - -// preparePluginsListData fetches and prepares all data needed for display -func (pc *PluginsCommand) preparePluginsListData(ctx context.Context, showInstalledOnly, showAvailableOnly bool) *pluginsListData { - data := &pluginsListData{ - Installed: []pluginDisplayInfo{}, - Available: []pluginDisplayInfo{}, - } - - // Fetch installed plugins if needed - if !showAvailableOnly { - installed, err := pc.fetchInstalledPlugins() - if err != nil { - pc.logger.Warn("Failed to fetch installed plugins", slog.String("error", err.Error())) - } else { - data.Installed = installed - } - } - - // Fetch available plugins from registry if needed - if !showInstalledOnly { - available, err := pc.fetchAvailablePlugins(ctx) - if err != nil { - pc.logger.Warn("Failed to fetch available plugins", slog.String("error", err.Error())) - data.RegistryError = err - } else { - data.Available = available - } - } - - return data -} - -// fetchInstalledPlugins retrieves installed plugins from filesystem -func (pc *PluginsCommand) fetchInstalledPlugins() ([]pluginDisplayInfo, error) { - plugins, err := os.ReadDir(layout.PluginsRoot(pc.pluginDirectory)) - if err != nil { - return nil, fmt.Errorf("failed to read plugins directory: %w", err) - } - - res := make([]pluginDisplayInfo, 0, len(plugins)) - - for _, plugin := range plugins { - version, err := pc.getInstalledPluginVersion(plugin.Name()) - if err != nil { - res = append(res, pluginDisplayInfo{ - Name: plugin.Name(), - Version: "ERROR", - Description: err.Error(), - }) - - continue - } - - contract, err := pc.getInstalledPluginContract(plugin.Name()) - if err != nil { - res = append(res, pluginDisplayInfo{ - Name: plugin.Name(), - Version: version.Original(), - Description: "failed to get description", - }) - - continue - } - - displayInfo := pluginDisplayInfo{ - Name: plugin.Name(), - Version: version.Original(), - Description: contract.Description, - } - - res = append(res, displayInfo) - } - - return res, nil -} - -// fetchAvailablePlugins retrieves and prepares available plugins from registry -func (pc *PluginsCommand) fetchAvailablePlugins(ctx context.Context) ([]pluginDisplayInfo, error) { - pluginNames, err := pc.service.ListPlugins(ctx) - if err != nil { - pc.logger.Warn("Failed to list plugins", slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to list plugins: %w", err) - } - - if len(pluginNames) == 0 { - return []pluginDisplayInfo{}, nil - } - - plugins := make([]pluginDisplayInfo, 0, len(pluginNames)) - - // Fetch contract for each plugin to get version and description - for _, pluginName := range pluginNames { - plugin := pluginDisplayInfo{ - Name: pluginName, - } - - // fetch versions to get latest version - latestVersion, err := pc.fetchLatestVersion(ctx, pluginName) - if err != nil { - return nil, fmt.Errorf("failed to fetch latest version: %w", err) - } - - // Get the latest version contract - contract, err := pc.service.GetPluginContract(ctx, pluginName, latestVersion.Original()) - if err != nil { - // Log the error for debugging - pc.logger.Warn("Failed to get plugin contract", - slog.String("plugin", pluginName), - slog.String("tag", latestVersion.Original()), - slog.String("error", err.Error())) - - // Show ERROR in version column and error description in description column - plugin.Version = "ERROR" - plugin.Description = "failed to get plugin contract" - } else { - plugin.Version = latestVersion.Original() - plugin.Description = contract.Description - - // Truncate description if too long - if len(plugin.Description) > 40 { - plugin.Description = plugin.Description[:37] + "..." - } - } - - plugins = append(plugins, plugin) - } - - return plugins, nil -} - -// printPluginsList prints all prepared data -func (pc *PluginsCommand) printPluginsList(data *pluginsListData, showInstalledOnly, showAvailableOnly bool) { - // Print installed plugins section - if !showAvailableOnly { - pc.printInstalledSection(data) - } - - // Print available plugins section - if !showInstalledOnly { - pc.printAvailableSection(data) - } } -// printInstalledSection prints the installed plugins section -func (pc *PluginsCommand) printInstalledSection(data *pluginsListData) { +// 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("-------------------------------------------") - if len(data.Installed) == 0 { + if len(installed) == 0 { fmt.Println("No plugins installed") } else { - for _, plugin := range data.Installed { + for _, plugin := range installed { fmt.Printf("%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) } } fmt.Println() - fmt.Printf("Total: %d plugin(s) installed\n", len(data.Installed)) - fmt.Println() -} - -// printAvailableSection prints the available plugins section -func (pc *PluginsCommand) printAvailableSection(data *pluginsListData) { - fmt.Println("Available Plugins in Registry:") - fmt.Println("-------------------------------------------") - - // Handle registry error - if data.RegistryError != nil { - fmt.Println() - fmt.Println("⚠ Unable to connect to plugin registry") - fmt.Println() - fmt.Println("The registry may not be accessible or catalog listing may be disabled.") - fmt.Println("You can still use specific plugins if you know their names:") - fmt.Println(" - Use 'plugins contract ' to view plugin details") - fmt.Println(" - Use 'plugins install ' to install a plugin") - - return - } - - // Handle empty registry - if len(data.Available) == 0 { - fmt.Println("No plugins found in registry") - return - } - - // Print plugins table - fmt.Printf("%-20s %-15s %-40s\n", "NAME", "VERSION", "DESCRIPTION") - fmt.Println("-------------------------------------------") - - for _, plugin := range data.Available { - fmt.Printf("%-20s %-15s %-40s\n", plugin.Name, plugin.Version, plugin.Description) - } - - // Print summary - fmt.Println() - fmt.Printf("Total: %d plugin(s) available\n", len(data.Available)) - - fmt.Println() - fmt.Println("Use 'plugins contract ' to see detailed information about a plugin") - fmt.Println("Use 'plugins install ' to install a plugin") + 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 '.") } diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index 96facdf2..52d3bb39 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -13,21 +13,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package pluginscmd import ( - "context" "fmt" "log/slog" "os" - "os/exec" - "path/filepath" + "strings" "github.com/spf13/cobra" dkplog "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins" ) const ( @@ -35,83 +34,79 @@ const ( // PackagePluginName = "package" TODO(Glitchy-Sheep): will be added later during full plugin system implementation ) +// 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 { - pc := NewPluginsCommand(logger.Named("plugins-command")) + manager := plugins.NewManager(logger.Named("plugins-command")) - if err := pc.ensureInstallRoot(); err != nil { + 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())) - return nil } - if cached := pc.cachedDescription(commandName); cached != "" { - description = cached + // Drive the help text from the cached contract (description + declared flags/env). + // The contract carries only names, so this lists what is available. + short, long := description, description + + if contract, err := manager.InstalledPluginContract(commandName); err == nil && contract != nil { + if contract.Description != "" { + short, long = contract.Description, contract.Description + } + + long = withContractHelp(long, contract) } return &cobra.Command{ - Use: commandName, - Short: description, - Aliases: aliases, - Long: description, + Use: commandName, + Short: short, + Aliases: aliases, + Long: long, + // Flags are forwarded verbatim, so the wrapper parses no d8-level flags. + // The registry-packages-proxy is configured by env only (KUBECONFIG, D8_RPP_*). + // The source initializes lazily in RunInstalled: an already-installed + // plugin needs no cluster access. DisableFlagParsing: true, - PreRun: func(_ *cobra.Command, _ []string) { pc.InitPluginServices() }, Run: func(cmd *cobra.Command, args []string) { - if err := pc.runInstalledPlugin(cmd.Context(), commandName, args); err != nil { - logger.Warn("plugin failed", slog.String("error", err.Error())) + if err := manager.RunInstalled(cmd.Context(), commandName, args); err != nil { + // Plain CLI error to stderr (not a structured log line) so the gate's + // reason and escape hint read like a normal command failure. + fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } }, } } -// runInstalledPlugin ensures the plugin is installed and execs its binary with args. -// stdin/stdout/stderr are inherited from the current process. -func (pc *PluginsCommand) runInstalledPlugin(ctx context.Context, pluginName string, args []string) error { - installed, err := pc.checkInstalled(pluginName) - if err != nil { - return fmt.Errorf("check installed: %w", err) - } - - if !installed { - fmt.Println("Not installed, installing...") - - if err := pc.InstallPlugin(ctx, pluginName); err != nil { - return fmt.Errorf("install: %w", err) - } - - fmt.Println("Installed successfully") - } - - absPath, err := filepath.Abs(layout.CurrentLinkPath(pc.pluginDirectory, pluginName)) - if err != nil { - return fmt.Errorf("absolute path: %w", err) - } +// withContractHelp augments a plugin's long help with the flags it declares and the +// environment d8 provides, taken from its contract. The contract carries only names +// (no per-flag text or command tree), so this lists what is available. +func withContractHelp(long string, contract *internal.Plugin) string { + var b strings.Builder - pc.logger.Debug("Executing plugin", slog.String("plugin", pluginName), slog.Any("args", args)) - cmd := exec.CommandContext(ctx, absPath, args...) + b.WriteString(long) - cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("plugin run: %w", err) - } - - return nil -} + if len(contract.Flags) > 0 { + b.WriteString("\n\nFlags forwarded to the plugin:") -func (pc *PluginsCommand) checkInstalled(commandName string) (bool, error) { - absPath, err := filepath.Abs(layout.CurrentLinkPath(pc.pluginDirectory, commandName)) - if err != nil { - return false, fmt.Errorf("failed to compute absolute path: %w", err) + for _, flag := range contract.Flags { + b.WriteString("\n " + flag.Name) + } } - _, err = os.Stat(absPath) - if err != nil && os.IsNotExist(err) { - return false, nil - } + if len(contract.Env) > 0 { + b.WriteString("\n\nEnvironment requested by the plugin:") - if err != nil { - return false, err + for _, env := range contract.Env { + if plugins.ProvidesEnv(env.Name) { + b.WriteString("\n " + env.Name + " (provided by d8)") + } else { + b.WriteString("\n " + env.Name + " (not provided by d8 yet; passed through if set)") + } + } } - return true, nil + return b.String() } diff --git a/internal/plugins/cmd/plugin_test.go b/internal/plugins/cmd/plugin_test.go new file mode 100644 index 00000000..f9941c0f --- /dev/null +++ b/internal/plugins/cmd/plugin_test.go @@ -0,0 +1,79 @@ +/* +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 ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" +) + +func TestWithContractHelp(t *testing.T) { + contract := &internal.Plugin{ + Name: "p", + Description: "does things", + Flags: []internal.Flag{{Name: "--my-feature-flag"}}, + Env: []internal.EnvVar{{Name: "KUBECONFIG"}}, + } + + help := withContractHelp("does things", contract) + + assert.Contains(t, help, "does things") + assert.Contains(t, help, "--my-feature-flag") + assert.Contains(t, help, "Flags forwarded to the plugin:") + assert.Contains(t, help, "Environment requested by the plugin:") + assert.Contains(t, help, "KUBECONFIG (provided by d8)") +} + +func TestWithContractHelpMarksUnprovidedEnv(t *testing.T) { + contract := &internal.Plugin{ + Name: "p", + Env: []internal.EnvVar{{Name: "MODULE_CONFIG_INFO"}}, + } + + help := withContractHelp("desc", contract) + assert.Contains(t, help, "MODULE_CONFIG_INFO (not provided by d8 yet") +} + +func TestWithContractHelpNoFlagsOrEnv(t *testing.T) { + help := withContractHelp("just a description", &internal.Plugin{Name: "p"}) + assert.Equal(t, "just a description", help, "no extra sections when the contract declares none") +} + +func TestNewPluginCommandReturnsCommandWhenInstallRootFails(t *testing.T) { + // Make EnsureInstallRoot fail with a non-permission error (a path component is a + // regular file -> ENOTDIR), so no home fallback is attempted and the test is hermetic. + tmp := t.TempDir() + blocker := filepath.Join(tmp, "blocker") + require.NoError(t, os.WriteFile(blocker, nil, 0o644)) + + orig := flags.DeckhousePluginsDir + flags.DeckhousePluginsDir = filepath.Join(blocker, "sub") + t.Cleanup(func() { flags.DeckhousePluginsDir = orig }) + + cmd := NewPluginCommand("system", "Operate system options", []string{"s"}, dkplog.NewNop()) + require.NotNil(t, cmd, "a failed install root must not yield a nil command (nil panics cobra.AddCommand)") + assert.Equal(t, "system", cmd.Use) +} diff --git a/internal/plugins/cmd/plugins.go b/internal/plugins/cmd/plugins.go index d3e2b6f1..caabd86c 100644 --- a/internal/plugins/cmd/plugins.go +++ b/internal/plugins/cmd/plugins.go @@ -14,102 +14,98 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +// Package pluginscmd implements the `d8 plugins` command tree and the +// per-plugin wrapper command on top of the internal/plugins machinery. +package pluginscmd import ( - "errors" - "fmt" "log/slog" - "os" "github.com/spf13/cobra" dkplog "github.com/deckhouse/deckhouse/pkg/log" - client "github.com/deckhouse/deckhouse/pkg/registry" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/flags" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" - "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + "github.com/deckhouse/deckhouse-cli/internal/plugins" + "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/errdetect" + "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" ) -// PluginsCommand holds shared state for every `d8 plugins ...` subcommand -// and is also reused by the per-plugin wrapper command (see plugin.go). -type PluginsCommand struct { - service *service.PluginService - pluginRegistryClient client.Client - pluginDirectory string +// NewCommand returns the `d8 plugins` command tree for managing plugins. +func NewCommand(logger *dkplog.Logger) *cobra.Command { + manager := plugins.NewManager(logger) - logger *dkplog.Logger -} + cmd := &cobra.Command{ + Use: "plugins", + Short: "Manage Deckhouse CLI plugins", + 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" + + "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 + // path DECKHOUSE_CLI_PATH is applied earlier, at registration). + manager.SetDirectory(flags.DeckhousePluginsDir) + + // init plugin services for subcommands after flags are parsed. + // PersistentPreRunE is outside wrapProxyDiagnostics, so classify + // proxy/discovery failures here too. + if err := manager.InitPluginServices(cmd.Context()); err != nil { + if diag := errdetect.Diagnose(err); diag != nil { + return diag + } + + return err + } -func NewPluginsCommand(logger *dkplog.Logger) *PluginsCommand { - return &PluginsCommand{ - pluginDirectory: flags.DeckhousePluginsDir, - logger: logger, - } -} + if err := manager.EnsureInstallRoot(); err != nil { + logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error())) + } -// ensureInstallRoot creates /plugins; on permission denied -// falls back to ~/.deckhouse-cli, updates pc.pluginDirectory, and retries. -func (pc *PluginsCommand) ensureInstallRoot() error { - err := os.MkdirAll(layout.PluginsRoot(pc.pluginDirectory), 0755) - if !errors.Is(err, os.ErrPermission) { - return err + return nil + }, } - pc.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", - slog.String("was", pc.pluginDirectory), dkplog.Err(err)) + cmd.AddCommand(newListCommand(manager)) + cmd.AddCommand(newVersionsCommand(manager)) + cmd.AddCommand(newContractCommand(manager, logger)) + cmd.AddCommand(newInstallCommand(manager)) + cmd.AddCommand(newUpdateCommand(manager)) + cmd.AddCommand(newRemoveCommand(manager)) - fallback, ferr := layout.HomeFallbackPath() - if ferr != nil { - return fmt.Errorf("home fallback: %w", ferr) - } + flags.AddFlags(cmd.PersistentFlags()) - pc.pluginDirectory = fallback + wrapProxyDiagnostics(cmd) - return os.MkdirAll(layout.PluginsRoot(pc.pluginDirectory), 0755) + return cmd } -// cachedDescription returns the description from the on-disk plugin contract -// cache, or "" if the cache is missing or unreadable. -func (pc *PluginsCommand) cachedDescription(pluginName string) string { - contract, err := service.GetPluginContractFromFile(layout.ContractFile(pc.pluginDirectory, pluginName)) - if err != nil { - pc.logger.Debug("failed to get plugin contract from cache", slog.String("error", err.Error())) - return "" - } +// 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, so those pass through. +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 + } - if contract == nil { - return "" + return err + } } - return contract.Description -} - -func NewCommand(logger *dkplog.Logger) *cobra.Command { - pc := NewPluginsCommand(logger) - - cmd := &cobra.Command{ - Use: "plugins", - Short: "Manage Deckhouse CLI plugins", - Hidden: true, - PersistentPreRun: func(_ *cobra.Command, _ []string) { - // init plugin services for subcommands after flags are parsed - pc.InitPluginServices() - - if err := pc.ensureInstallRoot(); err != nil { - pc.logger.Warn("failed to ensure plugin root directory", slog.String("error", err.Error())) - } - }, + for _, sub := range cmd.Commands() { + wrapProxyDiagnostics(sub) } - - cmd.AddCommand(pc.pluginsListCommand()) - cmd.AddCommand(pc.pluginsContractCommand()) - cmd.AddCommand(pc.pluginsInstallCommand()) - cmd.AddCommand(pc.pluginsUpdateCommand()) - cmd.AddCommand(pc.pluginsRemoveCommand()) - - flags.AddFlags(cmd.PersistentFlags()) - - return cmd } diff --git a/internal/plugins/cmd/plugins_test.go b/internal/plugins/cmd/plugins_test.go new file mode 100644 index 00000000..ad0c7dcb --- /dev/null +++ b/internal/plugins/cmd/plugins_test.go @@ -0,0 +1,83 @@ +/* +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 ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" +) + +// minimalKubeconfig is just enough for SetupK8sClientSet to build a clientset; no +// connection is made (the command under test issues no proxy request). +const minimalKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- name: test + cluster: + server: https://127.0.0.1:6443 +contexts: +- name: test + context: + cluster: test + user: test +current-context: test +users: +- name: test + user: + token: test-token +` + +func TestPluginsDirFlagIsHonored(t *testing.T) { + // The command object captures the plugins dir at REGISTRATION time; the + // --plugins-dir flag is parsed later and must still take effect. + // + // Startup eagerly builds the registry-packages-proxy client, so point the + // kubeconfig and endpoint at throwaway values: 'list' reads only the local + // plugins dir and makes no request, so no cluster is needed. + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") + require.NoError(t, os.WriteFile(kubeconfig, []byte(minimalKubeconfig), 0o600)) + + prevDir, prevKube, prevEndpoint := flags.DeckhousePluginsDir, flags.Kubeconfig, rppflags.Endpoint + t.Cleanup(func() { + flags.DeckhousePluginsDir, flags.Kubeconfig, rppflags.Endpoint = prevDir, prevKube, prevEndpoint + }) + flags.Kubeconfig = kubeconfig + rppflags.Endpoint = "https://127.0.0.1:4219" + + dir := t.TempDir() + "/custom-root" + + cmd := NewCommand(dkplog.NewNop()) + cmd.SetContext(context.Background()) + cmd.SetArgs([]string{"list", "--plugins-dir", dir}) + cmd.SetOut(io.Discard) + + require.NoError(t, cmd.Execute()) + + _, err := os.Stat(layout.PluginsRoot(dir)) + require.NoError(t, err, "the install root must be created under the --plugins-dir value, not the default") +} diff --git a/internal/plugins/cmd/remove.go b/internal/plugins/cmd/remove.go index de7819a0..5fffb425 100644 --- a/internal/plugins/cmd/remove.go +++ b/internal/plugins/cmd/remove.go @@ -14,81 +14,49 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package pluginscmd import ( "fmt" - "os" "github.com/spf13/cobra" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" + "github.com/deckhouse/deckhouse-cli/internal/plugins" ) -func (pc *PluginsCommand) pluginsRemoveCommand() *cobra.Command { +func newRemoveCommand(manager *plugins.Manager) *cobra.Command { cmd := &cobra.Command{ - Use: "remove [plugin-name]", + Use: "remove ", Aliases: []string{"uninstall", "delete"}, Short: "Remove an installed plugin", - Long: "Remove a specific plugin from the Deckhouse CLI", + Long: "Remove an installed plugin from disk.", Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { pluginName := args[0] - fmt.Printf("Removing plugin: %s\n", pluginName) - - pluginDir := layout.PluginDir(pc.pluginDirectory, pluginName) - fmt.Printf("Removing plugin from: %s\n", pluginDir) - - err := os.RemoveAll(pluginDir) - if err != nil { - return fmt.Errorf("failed to remove plugin directory: %w", err) - } - - fmt.Println("Cleaning up plugin files...") - os.Remove(layout.ContractFile(pc.pluginDirectory, pluginName)) - - fmt.Printf("✓ Plugin '%s' successfully removed!\n", pluginName) + fmt.Printf("Removing plugin: %s\n", pluginName) - return nil + // Manager.Remove validates the name before touching the filesystem. + return manager.Remove(pluginName) }, } // Add subcommands - cmd.AddCommand(pc.pluginsRemoveAllCommand()) + cmd.AddCommand(newRemoveAllCommand(manager)) return cmd } -func (pc *PluginsCommand) pluginsRemoveAllCommand() *cobra.Command { - cmd := &cobra.Command{ +func newRemoveAllCommand(manager *plugins.Manager) *cobra.Command { + return &cobra.Command{ Use: "all", Short: "Remove all installed plugins", Long: "Remove all plugins from the Deckhouse CLI at once", RunE: func(_ *cobra.Command, _ []string) error { fmt.Println("Removing all installed plugins...") - plugins, err := os.ReadDir(layout.PluginsRoot(pc.pluginDirectory)) - if err != nil { - return fmt.Errorf("failed to read plugins directory: %w", err) - } - - fmt.Println("Found", len(plugins), "plugins to remove:") - - for _, plugin := range plugins { - pluginDir := layout.PluginDir(pc.pluginDirectory, plugin.Name()) - fmt.Printf("Removing plugin from: %s\n", pluginDir) - - err := os.RemoveAll(pluginDir) - if err != nil { - return fmt.Errorf("failed to remove plugin directory: %w", err) - } - - fmt.Printf("Cleaning up plugin files for '%s'...\n", plugin.Name()) - - os.Remove(layout.ContractFile(pc.pluginDirectory, plugin.Name())) - - fmt.Printf("✓ Plugin '%s' successfully removed!\n", plugin.Name()) + if err := manager.RemoveAll(); err != nil { + return err } fmt.Println("✓ All plugins successfully removed!") @@ -96,6 +64,4 @@ func (pc *PluginsCommand) pluginsRemoveAllCommand() *cobra.Command { return nil }, } - - return cmd } diff --git a/internal/plugins/cmd/update.go b/internal/plugins/cmd/update.go index 0de4f4fa..1b909d19 100644 --- a/internal/plugins/cmd/update.go +++ b/internal/plugins/cmd/update.go @@ -14,59 +14,54 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package pluginscmd import ( "fmt" - "os" "github.com/spf13/cobra" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" + "github.com/deckhouse/deckhouse-cli/internal/plugins" ) -func (pc *PluginsCommand) pluginsUpdateCommand() *cobra.Command { +func newUpdateCommand(manager *plugins.Manager) *cobra.Command { + var useMajor int + cmd := &cobra.Command{ - Use: "update [plugin-name]", + Use: "update ", Short: "Update an installed plugin", - Long: "Update a specific plugin to its latest available version", - Args: cobra.ExactArgs(1), + 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 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) - ctx := cmd.Context() - - return pc.InstallPlugin(ctx, 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(pc.pluginsUpdateAllCommand()) + cmd.AddCommand(newUpdateAllCommand(manager)) return cmd } -func (pc *PluginsCommand) pluginsUpdateAllCommand() *cobra.Command { - cmd := &cobra.Command{ +func newUpdateAllCommand(manager *plugins.Manager) *cobra.Command { + return &cobra.Command{ Use: "all", Short: "Update all installed plugins", - Long: "Update all installed plugins to their latest available versions", + Long: "Update all installed plugins to their newest cluster-compatible version within each plugin's current major.", RunE: func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - fmt.Println("Updating all installed plugins...") - plugins, err := os.ReadDir(layout.PluginsRoot(pc.pluginDirectory)) - if err != nil { - return fmt.Errorf("failed to read plugins directory: %w", err) - } - - for _, plugin := range plugins { - err := pc.InstallPlugin(ctx, plugin.Name()) - if err != nil { - return fmt.Errorf("failed to update plugin: %w", err) - } + if err := manager.UpdateAll(cmd.Context()); err != nil { + return err } fmt.Println("✓ All plugins updated successfully!") @@ -74,6 +69,4 @@ func (pc *PluginsCommand) pluginsUpdateAllCommand() *cobra.Command { return nil }, } - - return cmd } diff --git a/internal/plugins/cmd/validators.go b/internal/plugins/cmd/validators.go deleted file mode 100644 index 47f3e306..00000000 --- a/internal/plugins/cmd/validators.go +++ /dev/null @@ -1,443 +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 plugins - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "os/exec" - "strings" - - "github.com/Masterminds/semver/v3" - - "github.com/deckhouse/deckhouse-cli/internal" - "github.com/deckhouse/deckhouse-cli/internal/plugins/cmd/layout" - "github.com/deckhouse/deckhouse-cli/pkg/registry/service" -) - -// getInstalledPluginContract reads the cached contract from -// /cache/contracts/.json and converts it to a domain object. -func (pc *PluginsCommand) getInstalledPluginContract(pluginName string) (*internal.Plugin, error) { - contractFile := layout.ContractFile(pc.pluginDirectory, pluginName) - - file, err := os.Open(contractFile) - if err != nil { - return nil, fmt.Errorf("failed to read contract file: %w", err) - } - defer file.Close() - - contract := new(service.PluginContract) - dec := json.NewDecoder(file) - - err = dec.Decode(contract) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal contract: %w", err) - } - - return service.ContractToDomain(contract), nil -} - -// getInstalledPluginVersion runs the installed plugin binary with "--version" -// (or "version" as fallback) and parses the output as a semver value. -func (pc *PluginsCommand) getInstalledPluginVersion(pluginName string) (*semver.Version, error) { - pluginBinaryPath := layout.CurrentLinkPath(pc.pluginDirectory, pluginName) - cmd := exec.Command(pluginBinaryPath, "--version") - - output, err := cmd.Output() - if err != nil { - pc.logger.Warn("failed to call plugin with '--version'", slog.String("plugin", pluginName), slog.String("error", err.Error())) - - // try to call plugin with "version" command - // this is for compatibility with plugins that don't support "--version" - cmd = exec.Command(pluginBinaryPath, "version") - - output, err = cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to call plugin: %w", err) - } - } - - version, err := semver.NewVersion(strings.TrimSpace(string(output))) - if err != nil { - return nil, fmt.Errorf("failed to parse version: %w", err) - } - - return version, nil -} - -// findLatestVersion finds the latest version from a list of version strings -func (pc *PluginsCommand) findLatestVersion(versions []string) (*semver.Version, error) { - if len(versions) == 0 { - return nil, fmt.Errorf("no versions found") - } - - var latestVersion *semver.Version - - for _, version := range versions { - version, err := semver.NewVersion(version) - if err != nil { - continue - } - - if latestVersion == nil { - latestVersion = version - continue - } - - if latestVersion.LessThan(version) { - latestVersion = version - } - } - - if latestVersion == nil { - return nil, fmt.Errorf("no versions found") - } - - return latestVersion, nil -} - -// fetchLatestVersion lists tags from the registry for a plugin and returns -// the highest semver version. -func (pc *PluginsCommand) fetchLatestVersion(ctx context.Context, pluginName string) (*semver.Version, error) { - versions, err := pc.service.ListPluginTags(ctx, pluginName) - if err != nil { - pc.logger.Warn("Failed to list plugin tags", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to list plugin tags: %w", err) - } - - latestVersion, err := pc.findLatestVersion(versions) - if err != nil { - pc.logger.Warn("Failed to fetch latest version", slog.String("plugin", pluginName), slog.String("error", err.Error())) - return nil, fmt.Errorf("failed to fetch latest version: %w", err) - } - - return latestVersion, nil -} - -// FailedConstraints holds plugin requirements that were not satisfied during -// installation: a nil value means the plugin is missing entirely, a non-nil -// value carries the constraint that the currently installed version fails. -type FailedConstraints map[string]*semver.Constraints - -func (pc *PluginsCommand) validateRequirements(plugin *internal.Plugin) (FailedConstraints, error) { - pc.logger.Debug("validating plugin requirements", slog.String("plugin", plugin.Name)) - - if err := pc.validatePluginConflicts(plugin); err != nil { - return nil, fmt.Errorf("plugin conflicts: %w", err) - } - - failedConstraints, err := pc.validatePluginRequirementMandatory(plugin) - if err != nil { - return nil, fmt.Errorf("plugin requirements (mandatory): %w", err) - } - - if err := pc.validatePluginRequirementConditional(plugin); err != nil { - return nil, fmt.Errorf("plugin requirements (conditional): %w", err) - } - - // Cluster-side requirements - currently log-only, no enforcement. - // Real validation lands when cluster connectivity is added. - if err := pc.validateKubernetesRequirement(plugin); err != nil { - return nil, fmt.Errorf("kubernetes requirement: %w", err) - } - - if err := pc.validateDeckhouseRequirement(plugin); err != nil { - return nil, fmt.Errorf("deckhouse requirement: %w", err) - } - - pc.logger.Debug("validating module requirements", slog.String("plugin", plugin.Name)) - - if err := pc.validateModuleRequirement(plugin); err != nil { - return nil, fmt.Errorf("module requirements: %w", err) - } - - return failedConstraints, nil -} - -// check that installing version not make conflict with existing plugins requirements -func (pc *PluginsCommand) validatePluginConflicts(plugin *internal.Plugin) error { - contractDir, err := os.ReadDir(layout.ContractsDir(pc.pluginDirectory)) - // if no plugins installed, nothing to conflict - if err != nil && errors.Is(err, os.ErrNotExist) { - pc.logger.Debug("failed to read contract directory", slog.String("error", err.Error())) - return nil - } - - if err != nil { - return fmt.Errorf("failed to read contract directory: %w", err) - } - - for _, contractFile := range contractDir { - pluginName := strings.TrimSuffix(contractFile.Name(), layout.ContractFileExt) - - contract, err := pc.getInstalledPluginContract(pluginName) - if err != nil { - return fmt.Errorf("failed to get installed plugin contract: %w", err) - } - - err = validatePluginConflict(plugin, contract) - if err != nil { - return fmt.Errorf("validate plugin conflict: %w", err) - } - } - - return nil -} - -// validatePluginConflict checks whether installing `plugin` violates any -// constraint that the already-installed `installedPlugin` places on it. -// -// Both Mandatory and Conditional sections of installedPlugin's requirements -// are inspected - if an existing plugin requires us, we must satisfy its -// constraint regardless of whether the requirement is mandatory or conditional. -func validatePluginConflict(plugin *internal.Plugin, installedPlugin *internal.Plugin) error { - candidates := make([]internal.PluginRequirement, 0, - len(installedPlugin.Requirements.Plugins.Mandatory)+len(installedPlugin.Requirements.Plugins.Conditional)) - candidates = append(candidates, installedPlugin.Requirements.Plugins.Mandatory...) - candidates = append(candidates, installedPlugin.Requirements.Plugins.Conditional...) - - for _, requirement := range candidates { - if requirement.Name != plugin.Name { - continue - } - - constraint, err := semver.NewConstraint(requirement.Constraint) - if err != nil { - return fmt.Errorf("failed to parse constraint: %w", err) - } - // Check the NEW plugin's version against the constraint - - // not installedPlugin.Version (that was a long-standing bug). - version, err := semver.NewVersion(plugin.Version) - if err != nil { - return fmt.Errorf("failed to parse version: %w", err) - } - - if !constraint.Check(version) { - return fmt.Errorf("installing plugin %s %s conflicts with existing plugin %s which requires %s %s", - plugin.Name, - plugin.Version, - installedPlugin.Name, - plugin.Name, - constraint.String()) - } - } - - return nil -} - -// validatePluginRequirementMandatory enforces mandatory plugin requirements: -// -// For mandatory requirements: - -// - if the dependency is not installed, record a soft failure in FailedConstraints -// - if the dependency is installed but fails the constraint, record a soft failure in FailedConstraints -// - return a non-nil error only for operational failures such as install checks, -// version lookup failures, or invalid version constraints -func (pc *PluginsCommand) validatePluginRequirementMandatory(plugin *internal.Plugin) (FailedConstraints, error) { - result := make(FailedConstraints) - - for _, pluginRequirement := range plugin.Requirements.Plugins.Mandatory { - installed, err := pc.checkInstalled(pluginRequirement.Name) - if err != nil { - return nil, fmt.Errorf("failed to check if plugin is installed: %w", err) - } - - if !installed { - pc.logger.Warn("plugin requirement not installed", - slog.String("plugin", plugin.Name), - slog.String("requirement", pluginRequirement.Name)) - result[pluginRequirement.Name] = nil - - continue - } - - if pluginRequirement.Constraint == "" { - continue - } - - installedVersion, err := pc.getInstalledPluginVersion(pluginRequirement.Name) - if err != nil { - return nil, fmt.Errorf("failed to get installed version: %w", err) - } - - constraint, err := semver.NewConstraint(pluginRequirement.Constraint) - if err != nil { - return nil, fmt.Errorf("failed to parse constraint: %w", err) - } - - if !constraint.Check(installedVersion) { - pc.logger.Warn("plugin requirement not satisfied", - slog.String("plugin", plugin.Name), - slog.String("requirement", pluginRequirement.Name), - slog.String("constraint", pluginRequirement.Constraint), - slog.String("installedVersion", installedVersion.Original())) - result[pluginRequirement.Name] = constraint - } - } - - return result, nil -} - -// validatePluginRequirementConditional enforces conditional plugin requirements: -// -// For conditional requirements: -// - if the dependency is not installed, skip silently; -// - if the dependency is installed but fails the constraint, return a hard error -func (pc *PluginsCommand) validatePluginRequirementConditional(plugin *internal.Plugin) error { - for _, pluginRequirement := range plugin.Requirements.Plugins.Conditional { - installed, err := pc.checkInstalled(pluginRequirement.Name) - if err != nil { - return fmt.Errorf("failed to check if plugin is installed: %w", err) - } - - if !installed { - continue - } - - if pluginRequirement.Constraint == "" { - continue - } - - installedVersion, err := pc.getInstalledPluginVersion(pluginRequirement.Name) - if err != nil { - return fmt.Errorf("failed to get installed version: %w", err) - } - - constraint, err := semver.NewConstraint(pluginRequirement.Constraint) - if err != nil { - return fmt.Errorf("failed to parse constraint: %w", err) - } - - if !constraint.Check(installedVersion) { - return fmt.Errorf("conditional plugin requirement not satisfied: plugin %s %s installed but %s requires %s", - pluginRequirement.Name, - installedVersion.Original(), - plugin.Name, - pluginRequirement.Constraint) - } - } - - return nil -} - -// debugPrintPendingRequirement prints a human-readable warning for a declared -// requirement that d8 currently surfaces but does not enforce. -// -// Until the requirement enforcement is implemented, we print a warning to the user. -// -// Example: -// -// ! -// key1: value1 -// key2: value2 -func debugPrintPendingRequirement(title string, kv ...[2]string) { - fmt.Fprintf(os.Stderr, "! %s\n", title) - - for _, p := range kv { - fmt.Fprintf(os.Stderr, " %s: %s\n", p[0], p[1]) - } -} - -// validateKubernetesRequirement is a log-only stub (yet). -// -// For Kubernetes requirement: -// - if the constraint is empty, skip silently -// - if the constraint is not empty, print a warning -// -//nolint:unparam // stub — will return real errors once enforcement is implemented -func (pc *PluginsCommand) validateKubernetesRequirement(plugin *internal.Plugin) error { - if plugin.Requirements.Kubernetes.Constraint == "" { - return nil - } - - debugPrintPendingRequirement( - "plugin declares a Kubernetes version requirement but enforcement is not implemented yet", - [2]string{"plugin", plugin.Name}, - [2]string{"constraint", plugin.Requirements.Kubernetes.Constraint}, - ) - - return nil -} - -// validateDeckhouseRequirement is a log-only stub. See validateKubernetesRequirement -// for rationale; enforcement will land once Deckhouse version discovery is in place. -// -//nolint:unparam // stub — will return real errors once enforcement is implemented -func (pc *PluginsCommand) validateDeckhouseRequirement(plugin *internal.Plugin) error { - if plugin.Requirements.Deckhouse.Constraint == "" { - return nil - } - - debugPrintPendingRequirement( - "plugin declares a Deckhouse version requirement but enforcement is not implemented yet", - [2]string{"plugin", plugin.Name}, - [2]string{"constraint", plugin.Requirements.Deckhouse.Constraint}, - ) - - return nil -} - -// validateModuleRequirement is a log-only stub. Mandatory, Conditional and -// AnyOf sections are all surfaced so authors and operators see the declared -// expectations even though d8 does not yet inspect the cluster to verify them. -// -//nolint:unparam // stub — will return real errors once enforcement is implemented -func (pc *PluginsCommand) validateModuleRequirement(plugin *internal.Plugin) error { - mods := plugin.Requirements.Modules - if len(mods.Mandatory) == 0 && len(mods.Conditional) == 0 && len(mods.AnyOf) == 0 { - return nil - } - - for _, m := range mods.Mandatory { - debugPrintPendingRequirement( - "plugin declares a mandatory module requirement but enforcement is not implemented yet", - [2]string{"plugin", plugin.Name}, - [2]string{"module", m.Name}, - [2]string{"constraint", m.Constraint}, - ) - } - - for _, m := range mods.Conditional { - debugPrintPendingRequirement( - "plugin declares a conditional module requirement but enforcement is not implemented yet", - [2]string{"plugin", plugin.Name}, - [2]string{"module", m.Name}, - [2]string{"constraint", m.Constraint}, - ) - } - - for i, grp := range mods.AnyOf { - names := make([]string, 0, len(grp.Modules)) - for _, m := range grp.Modules { - names = append(names, m.Name) - } - - debugPrintPendingRequirement( - "plugin declares an anyOf module group but enforcement is not implemented yet", - [2]string{"plugin", plugin.Name}, - [2]string{"group_index", fmt.Sprintf("%d", i)}, - [2]string{"group_description", grp.Description}, - [2]string{"modules", strings.Join(names, ", ")}, - ) - } - - return nil -} diff --git a/internal/plugins/cmd/versions.go b/internal/plugins/cmd/versions.go new file mode 100644 index 00000000..c11ff4e1 --- /dev/null +++ b/internal/plugins/cmd/versions.go @@ -0,0 +1,141 @@ +/* +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 ( + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/plugins" +) + +// newVersionsCommand returns `d8 plugins versions <name>` - list all +// published versions of one plugin, the same verb `d8 cli versions` uses for +// the CLI itself. +func newVersionsCommand(manager *plugins.Manager) *cobra.Command { + return &cobra.Command{ + Use: "versions <plugin-name>", + 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" + + "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 <name> --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) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // 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. + names, err := manager.InstalledPluginNames() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + completions := make([]string, 0, len(names)) + + for _, name := range names { + if strings.HasPrefix(name, toComplete) { + completions = append(completions, name) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + pluginName := args[0] + if err := plugins.ValidatePluginName(pluginName); err != nil { + return err + } + + versions, err := manager.PublishedVersions(cmd.Context(), pluginName) + if err != nil { + return err + } + + if len(versions) == 0 { + return fmt.Errorf("no versions found for plugin %q", pluginName) + } + + current := manager.InstalledVersionOrNil(pluginName) + + lines, currentListed := formatPluginVersionList(versions, current) + for _, line := range lines { + fmt.Println(line) + } + + if current != nil && !currentListed { + fmt.Printf("\nInstalled version %s is not published in the registry.\n", current.Original()) + } + + return nil + }, + } +} + +// 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 +// 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) { + var ( + newer = color.New(color.FgGreen) + actual = color.New(color.FgCyan, color.Bold) + older = color.New(color.Faint) + listed bool + widest int + ) + + for _, v := range versions { + if len(v.Original()) > widest { + widest = len(v.Original()) + } + } + + lines := make([]string, 0, len(versions)) + + for _, v := range versions { + var entry string + + 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()) + default: + entry = older.Sprintf(" %-*s", widest, v.Original()) + } + + // The padding is for the trailing group word; entries without one would + // otherwise carry invisible trailing spaces. + lines = append(lines, strings.TrimRight(entry, " ")) + } + + return lines, listed +} diff --git a/internal/plugins/cmd/versions_test.go b/internal/plugins/cmd/versions_test.go new file mode 100644 index 00000000..9b5e6f44 --- /dev/null +++ b/internal/plugins/cmd/versions_test.go @@ -0,0 +1,88 @@ +/* +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/Masterminds/semver/v3" + "github.com/fatih/color" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustSemvers(t *testing.T, raw ...string) []*semver.Version { + t.Helper() + + versions := make([]*semver.Version, 0, len(raw)) + + for _, r := range raw { + v, err := semver.NewVersion(r) + require.NoError(t, err) + + versions = append(versions, v) + } + + return versions +} + +func withoutColor(t *testing.T) { + t.Helper() + + prev := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = prev }) +} + +func TestFormatPluginVersionListGroupsAroundCurrent(t *testing.T) { + withoutColor(t) + + lines, listed := formatPluginVersionList( + mustSemvers(t, "v0.1.2", "v0.0.21", "v0.0.20"), semver.MustParse("v0.0.21")) + + assert.True(t, listed) + assert.Equal(t, []string{ + " v0.1.2 newer", + "* v0.0.21 current", + " v0.0.20", + }, lines) +} + +func TestFormatPluginVersionListCurrentNotPublished(t *testing.T) { + withoutColor(t) + + lines, listed := formatPluginVersionList( + mustSemvers(t, "v0.1.2", "v0.1.1"), semver.MustParse("v0.0.21")) + + assert.False(t, listed) + assert.Equal(t, []string{ + " v0.1.2 newer", + " v0.1.1 newer", + }, lines) +} + +func TestFormatPluginVersionListNotInstalledIsPlain(t *testing.T) { + withoutColor(t) + + lines, listed := formatPluginVersionList(mustSemvers(t, "v0.1.2", "v0.0.21"), nil) + + assert.False(t, listed) + assert.Equal(t, []string{ + " v0.1.2", + " v0.0.21", + }, lines) +} diff --git a/internal/plugins/cmd/doc.go b/internal/plugins/doc.go similarity index 65% rename from internal/plugins/cmd/doc.go rename to internal/plugins/doc.go index 5ca22693..60bc4725 100644 --- a/internal/plugins/cmd/doc.go +++ b/internal/plugins/doc.go @@ -34,9 +34,9 @@ limitations under the License. // // # Where a plugin lives // -// A plugin lives in an OCI Registry as a packaged file with a "contract" -// annotation. The annotation carries a base64-encoded JSON contract. -// Plugin metadata: +// A plugin lives in the cluster's OCI registry, reached exclusively through the +// in-cluster registry-packages-proxy. The image carries the plugin binary and a +// contract.yaml file that describes the plugin: // // - name; // - version; @@ -49,12 +49,27 @@ limitations under the License. // // 1. The user invokes a command through d8. // 2. The parent CLI checks whether the plugin is installed. -// 3. If it is not, the image is pulled from the registry. +// 3. If it is not, the image is pulled through the registry-packages-proxy. // 4. The binary is unpacked. // 5. Requirements are validated. // 6. A symlink is pointed at the current major version. // 7. The plugin is exec'd with the forwarded arguments. // +// # On-disk layout +// +// Installed plugins live under the install root: /opt/deckhouse/lib/deckhouse-cli +// by default (override with --plugins-dir / DECKHOUSE_CLI_PATH; ~/.deckhouse-cli +// when the default is not writable). Concrete paths: +// +// <root>/plugins/<name>/v<major>/<name> plugin binary (one per major version) +// <root>/plugins/<name>/current symlink to the active major's binary +// <root>/plugins/<name>/install.lock install lock (one per plugin) +// <root>/cache/contracts/<name>.json cached contract +// +// Versions are kept per major; the `current` symlink selects the active one, so +// switching is an atomic repoint - the same idea selfupdate uses for d8 itself. +// Package internal/plugins/layout holds the authoritative path builders. +// // # What the plugin system is made of // // 1. Discover - learn what plugins exist and what their contracts declare. diff --git a/internal/plugins/fixtures_test.go b/internal/plugins/fixtures_test.go new file mode 100644 index 00000000..dafbd2bd --- /dev/null +++ b/internal/plugins/fixtures_test.go @@ -0,0 +1,44 @@ +/* +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 ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" +) + +func testManager() *Manager { + return &Manager{logger: dkplog.NewNop()} +} + +// installPluginFixture creates plugins/<name>/v<major>/<name> under root and the +// `current` symlink pointing at it, i.e. a minimally-installed plugin. +func installPluginFixture(t *testing.T, root, name string, major int) { + t.Helper() + + binary := layout.BinaryPath(root, name, major) + require.NoError(t, os.MkdirAll(filepath.Dir(binary), 0o755)) + require.NoError(t, os.WriteFile(binary, []byte("#!/bin/sh\n"), 0o755)) + require.NoError(t, os.Symlink(binary, layout.CurrentLinkPath(root, name))) +} diff --git a/internal/plugins/cmd/flags/doc.go b/internal/plugins/flags/doc.go similarity index 82% rename from internal/plugins/cmd/flags/doc.go rename to internal/plugins/flags/doc.go index 5e265d8c..909c01f9 100644 --- a/internal/plugins/cmd/flags/doc.go +++ b/internal/plugins/flags/doc.go @@ -15,5 +15,6 @@ limitations under the License. */ // Package flags defines the shared CLI flag set used by the d8 plugins -// management subcommands and consumed during registry client initialisation. +// 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 new file mode 100644 index 00000000..e9183825 --- /dev/null +++ b/internal/plugins/flags/flags.go @@ -0,0 +1,99 @@ +/* +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 flags + +import ( + "os" + + "github.com/spf13/pflag" + + rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" +) + +const ( + defaultDeckhousePluginsDir = "/opt/deckhouse/lib/deckhouse-cli" + + // Env* constants are the single source of truth for the environment variable + // names: functional reads and help texts must reference them, never literals. + EnvSkipClusterChecks = "D8_PLUGINS_SKIP_CLUSTER_CHECKS" + + // EnvPluginsDir overrides the plugins directory (same as --plugins-dir); + // applied at command registration in cmd/d8/root.go. + EnvPluginsDir = "DECKHOUSE_CLI_PATH" +) + +var ( + DeckhousePluginsDir = defaultDeckhousePluginsDir + + // Kubeconfig and KubeContext locate the cluster; used to reach the + // registry-packages-proxy and to enforce cluster-side plugin requirements + // (Kubernetes/Deckhouse/module versions). + Kubeconfig = defaultKubeconfigPath() + KubeContext string + + // SkipClusterChecks downgrades cluster-side requirement enforcement to a warning, + // so a plugin that declares such a requirement can be installed without reaching + // the cluster. + SkipClusterChecks = skipClusterChecksDefault() +) + +func skipClusterChecksDefault() bool { + switch os.Getenv(EnvSkipClusterChecks) { + case "1", "true", "TRUE", "True": + return true + default: + return false + } +} + +func defaultKubeconfigPath() string { + if v := os.Getenv("KUBECONFIG"); v != "" { + return v + } + + return os.ExpandEnv("$HOME/.kube/config") +} + +func AddFlags(flagSet *pflag.FlagSet) { + flagSet.StringVar( + &DeckhousePluginsDir, + "plugins-dir", + DeckhousePluginsDir, + "Path to the d8 plugins directory. Defaults to $"+EnvPluginsDir+".", + ) + flagSet.StringVarP( + &Kubeconfig, + "kubeconfig", + "k", + Kubeconfig, + "Path to the kubeconfig file. Used to reach registry-packages-proxy and to enforce cluster-side plugin requirements. Defaults to $KUBECONFIG.", + ) + flagSet.StringVar( + &KubeContext, + "context", + 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/init.go b/internal/plugins/init.go new file mode 100644 index 00000000..cf3f97d1 --- /dev/null +++ b/internal/plugins/init.go @@ -0,0 +1,50 @@ +/* +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 plugins + +import ( + "context" + "fmt" + + d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/rpp" + rppflags "github.com/deckhouse/deckhouse-cli/internal/rpp/flags" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +// InitPluginServices wires m.service to the in-cluster registry-packages-proxy, +// reaching the proxy by the user's kubeconfig identity. ctx bounds endpoint +// discovery, so a Ctrl-C during command startup is honored. The proxy is the only +// plugin source (ADR: deckhouse-cli reaches the registry exclusively through it). +func (m *Manager) InitPluginServices(ctx context.Context) error { + restConfig, kubeCl, err := utilk8s.SetupK8sClientSet(d8flags.Kubeconfig, d8flags.KubeContext) + if err != nil { + return fmt.Errorf("set up kubernetes client: %w", err) + } + + client, err := rpp.NewClusterClient( + ctx, kubeCl, restConfig, m.logger.Named("registry-packages-proxy"), + rppflags.Endpoint, rppflags.CAFile, rppflags.InsecureSkipTLSVerify, + ) + if err != nil { + return fmt.Errorf("build registry-packages-proxy client: %w", err) + } + + m.service = newRppPluginSource(client, m.logger) + + return nil +} diff --git a/internal/plugins/install.go b/internal/plugins/install.go new file mode 100644 index 00000000..c3eb7b52 --- /dev/null +++ b/internal/plugins/install.go @@ -0,0 +1,746 @@ +/* +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 plugins + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/fatih/color" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/lockfile" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" + "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +const ( + // pluginProbeTimeout bounds running a plugin binary with --version (the + // post-install smoke test, the already-installed check, and reading the installed + // version). A version probe is a sub-second call; this is only a hang guard. + pluginProbeTimeout = 10 * time.Second + + // installLockStaleAfter is how old a lock file may get before it is treated as + // orphaned (a prior install was hard-killed before its deferred release ran). + // Installs take seconds, so an hour-old lock is certainly stale. + installLockStaleAfter = 1 * time.Hour + + // stagedBinarySuffix marks the temp path a new binary is downloaded to before + // it is verified and atomically swapped in place of the live one. + stagedBinarySuffix = ".new" +) + +// pluginNameLayout matches a valid plugin name: a single lowercase OCI path +// component. Anything else cannot name a published plugin and, used unvalidated, +// would build filesystem paths outside the plugins root ("..", "a/b") or alter +// registry routes. +var pluginNameLayout = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) + +// ValidatePluginName guards every user-supplied plugin name before it reaches +// MkdirAll / RemoveAll / registry paths. +func ValidatePluginName(name string) error { + if !pluginNameLayout.MatchString(name) { + return fmt.Errorf("invalid plugin name %q", name) + } + + return nil +} + +type installOptions struct { + version string + majorVersion int + force bool +} + +type InstallOption func(*installOptions) + +func InstallWithForce() InstallOption { + return func(opts *installOptions) { + opts.force = true + } +} + +func InstallWithMajorVersion(majorVersion int) InstallOption { + return func(opts *installOptions) { + opts.majorVersion = majorVersion + } +} + +func InstallWithVersion(version string) InstallOption { + return func(opts *installOptions) { + opts.version = version + } +} + +// InstallPlugin installs or switches to a plugin version. +// Steps: select the version, validate requirements, lay out +// plugins/<name>/v<major>, swap the binary in, point `current` at it, cache +// the contract. +// Tune behaviour with the InstallWith* options (version, major, force). With no +// options it installs the newest cluster-compatible version within the installed +// major. A plugin's mandatory dependencies are always installed/upgraded first. +func (m *Manager) InstallPlugin(ctx context.Context, pluginName string, opts ...InstallOption) error { + if err := ValidatePluginName(pluginName); err != nil { + return err + } + + options := &installOptions{ + majorVersion: -1, + } + + for _, opt := range opts { + opt(options) + } + + // A major given explicitly via --use-major may move in any direction (and lets + // dependencies cross their own major too); only the implicit selection below + // gets the downgrade guard. + explicitMajor := options.majorVersion >= 0 + + if options.version != "" { + installVersion, err := semver.NewVersion(options.version) + if err != nil { + return fmt.Errorf("failed to parse version: %w", err) + } + + plan, err := m.planForExplicit(ctx, pluginName, installVersion, explicitMajor) + if err != nil { + return err + } + + return m.installPlugin(ctx, pluginName, installVersion, plan, options.force) + } + + versions, err := m.listTags(ctx, pluginName) + if err != nil { + return fmt.Errorf("failed to list plugin tags: %w", err) + } + + // Policy: an update stays within the installed major version unless the user + // explicitly crosses majors with --use-major; a fresh install has no major to + // pin and considers all majors. + if options.majorVersion < 0 { + options.majorVersion = m.inheritInstalledMajor(pluginName) + } + + if options.majorVersion >= 0 { + versions = m.filterMajorVersion(versions, options.majorVersion) + if len(versions) == 0 { + return fmt.Errorf("no versions found for major version: %d", options.majorVersion) + } + } + + // Requirements-aware selection: newest version whose cluster requirements AND + // plugin->plugin dependency chain are satisfiable, plus the plan to realize that + // chain. allowMajorCross=explicitMajor lets dependencies cross their major. + installVersion, plan, err := m.selectTopWithPlan(ctx, pluginName, versions, explicitMajor) + if err != nil { + if options.majorVersion >= 0 { + // The selection error is a HelpfulError; add the major hint as a + // solution so it survives rendering (a %w wrap would be dropped). + var he *diagnostic.HelpfulError + if errors.As(err, &he) { + he.Suggestions = append(he.Suggestions, diagnostic.Suggestion{ + Cause: fmt.Sprintf("the search was limited to major %d", options.majorVersion), + Solutions: []string{"pass --use-major to consider another major"}, + }) + + return err + } + + return fmt.Errorf("%w (search was limited to major %d; pass --use-major to consider another major)", + err, options.majorVersion) + } + + return err + } + + // Downgrade guard for the implicit path: selection may fall back to an older + // version (newer contracts unreadable, or their dependencies unresolvable) - that + // must not silently downgrade a newer installed plugin. The plan is dropped here, + // so no dependency is installed for a candidate we abandon. Explicit --version / + // --use-major bypass this by design. + if !explicitMajor && m.installedIsNewerThan(pluginName, installVersion) { + fmt.Printf("Selected %s is older than the installed version; keeping the installed version "+ + "(use --version to downgrade explicitly).\n", installVersion.Original()) + + return nil + } + + return m.installPlugin(ctx, pluginName, installVersion, plan, options.force) +} + +// planForExplicit builds the dependency plan for an exact (--version) install. +// allowMajorCross lets dependencies cross their own major to satisfy a constraint. +func (m *Manager) planForExplicit(ctx context.Context, pluginName string, version *semver.Version, allowMajorCross bool) (*resolutionPlan, error) { + contract, err := m.PluginContract(ctx, pluginName, version.Original()) + if err != nil { + return nil, fmt.Errorf("failed to get plugin contract: %w", err) + } + + plan, reason, err := m.planFor(ctx, contract, allowMajorCross) + if err != nil { + return nil, err + } + + if reason != nil { + if reason.kind.isDependency() { + return nil, &diagnostic.HelpfulError{ + Category: fmt.Sprintf("cannot install plugin %q %s: unresolved dependencies", pluginName, version.Original()), + Suggestions: []diagnostic.Suggestion{dependencySuggestion(reason)}, + } + } + + return nil, &diagnostic.HelpfulError{ + 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)}, + }}, + } + } + + return plan, nil +} + +// inheritInstalledMajor returns the major version to pin an implicit update to: +// the major of the installed plugin, or -1 (consider all majors) for a fresh +// install. The major is read from disk (the `current` symlink), not by running +// the binary, so a broken binary cannot drop the pin and let an implicit update +// cross a major. +func (m *Manager) inheritInstalledMajor(pluginName string) int { + installed, _ := m.checkInstalled(pluginName) + if !installed { + return -1 + } + + major, ok := m.installedMajorFromDisk(pluginName) + if !ok { + m.logger.Warn("could not determine the installed major version; considering all majors (pass --use-major to constrain)", + slog.String("plugin", pluginName)) + + return -1 + } + + m.logger.Debug("staying within the installed major version (use --use-major to cross majors)", + slog.String("plugin", pluginName), slog.Int("major", major)) + + return major +} + +// installedIsNewerThan reports whether the installed plugin is strictly newer +// than the selected version. An unreadable installed version (not installed, +// broken binary) reports false so install/repair proceeds. +func (m *Manager) installedIsNewerThan(pluginName string, selected *semver.Version) bool { + current, err := m.getInstalledPluginVersion(pluginName) + if err != nil { + return false + } + + return selected.LessThan(current) +} + +// pluginPaths bundles the filesystem locations an install operates on. +// Created once by preparePluginDirs and threaded through the install pipeline. +type pluginPaths struct { + pluginDir string // <plugin-dir>/plugins/<name> + versionDir string // <plugin-dir>/plugins/<name>/v<major> + binaryPath string // <plugin-dir>/plugins/<name>/v<major>/<name> + lockPath string // <plugin-dir>/plugins/<name>/install.lock - one per plugin, not per major: installs of different majors still contend on `current` and the contract cache + currentLink string // <plugin-dir>/plugins/<name>/current +} + +// installPlugin orchestrates the install pipeline. Each step delegates to a +// focused helper below. The order matters: +// - validate before switch +// - stage and smoke-test before swapping the live binary +// - cache the contract before linking +func (m *Manager) installPlugin(ctx context.Context, pluginName string, version *semver.Version, plan *resolutionPlan, force bool) error { + // Install/upgrade dependencies first (dependency-first order) so the plugin's + // requirements are satisfied before it is validated and linked. + if plan != nil && !plan.isEmpty() { + m.printPlan(pluginName, plan) + + if err := m.executePlan(ctx, plan); err != nil { + return err + } + } + + paths, err := m.preparePluginDirs(pluginName, version) + if err != nil { + return err + } + + release, err := m.acquireInstallLock(paths.lockPath) + if err != nil { + return err + } + defer release() + + // One binary probe, reused by both checks below: the probe EXECUTES the + // installed binary (--version), and the install lock guarantees the file + // cannot change between the two uses. + alreadyAtVersion := !force && m.pluginAlreadyAtVersion(ctx, paths.binaryPath, version) + + // Already current at the selected version: no switch happens, so nothing to do + // (execution is still gated by the pre-run requirement check). --force re-pulls. + if alreadyAtVersion && m.isCurrentBinary(paths) { + if plan != nil && !plan.isEmpty() { + fmt.Printf("Plugin '%s' is already at %s; installed its missing dependencies.\n", pluginName, version.Original()) + } else { + fmt.Printf("Plugin '%s' is already at %s, nothing to do (use --force to reinstall).\n", pluginName, version.Original()) + } + + return nil + } + + // We are about to (re)point `current`. Validate requirements BEFORE the switch + // (ADR: check requirements before switching) - this also covers the relink path + // below, so a switch to an already-installed version is never made blindly. + plugin, err := m.fetchAndDisplayContract(ctx, pluginName, version) + if err != nil { + return err + } + + if err := m.validateInstalledRequirements(ctx, plugin); err != nil { + return err + } + + // Selected version already on disk (a different major/version is current): + // repoint the symlink, no re-download. Requirements were validated just above. + // --force instead falls through to a full re-pull (corrupted on-disk binary, + // republished tag). + // + // Cache the contract BEFORE flipping the link. A failure in between then + // over-enforces the new contract on the old binary (fails closed), rather than + // running the new binary gated by the old contract. + if alreadyAtVersion { + if err := m.cacheContract(pluginName, plugin); err != nil { + return err + } + + if err := m.linkCurrent(paths); err != nil { + return err + } + + fmt.Printf("Switched plugin '%s' to the already-installed %s.\n", pluginName, version.Original()) + + return nil + } + + // Download to a sibling staged path and verify it BEFORE touching the live + // binary: the installed plugin keeps working (and `current` never dangles) for + // the whole download, and a failed/partial download leaves no trace behind. + stagedPath := paths.binaryPath + stagedBinarySuffix + // Clean up the staged binary on any failure; a no-op once it is renamed into place. + defer func() { _ = os.Remove(stagedPath) }() + + if err := m.downloadAndExtract(ctx, pluginName, version, stagedPath); err != nil { + return err + } + + // Reject a corrupt/incompatible binary before it replaces anything (ADR + // safe-update: smoke-check --version before the switch). + if err := m.smokeTestPlugin(ctx, pluginName, stagedPath); err != nil { + return err + } + + // Atomically replace the live binary (rename over it), so `current` never + // points at a missing file. On failure the original is untouched. + if err := os.Rename(stagedPath, paths.binaryPath); err != nil { + return fmt.Errorf("install new binary: %w", err) + } + + if err := m.cacheContract(pluginName, plugin); err != nil { + return err + } + + if err := m.linkCurrent(paths); err != nil { + return err + } + + fmt.Printf("✓ Plugin '%s' successfully installed!\n", pluginName) + + return nil +} + +// preparePluginDirs creates plugins/<name>/v<major> on disk and returns the +// paths derived from <pluginName, version> used by the rest of the pipeline. +func (m *Manager) preparePluginDirs(pluginName string, version *semver.Version) (pluginPaths, error) { + major := int(version.Major()) + paths := pluginPaths{ + pluginDir: layout.PluginDir(m.pluginDirectory, pluginName), + versionDir: layout.VersionDir(m.pluginDirectory, pluginName, major), + binaryPath: layout.BinaryPath(m.pluginDirectory, pluginName, major), + lockPath: layout.InstallLockPath(m.pluginDirectory, pluginName), + currentLink: layout.CurrentLinkPath(m.pluginDirectory, pluginName), + } + + if err := os.MkdirAll(paths.pluginDir, 0755); err != nil { + return pluginPaths{}, fmt.Errorf("failed to create plugin directory: %w", err) + } + + if err := os.MkdirAll(paths.versionDir, 0755); err != nil { + return pluginPaths{}, fmt.Errorf("failed to create plugin version directory: %w", err) + } + + return paths, nil +} + +// acquireInstallLock serializes installs of the plugin. A lock orphaned by a +// hard-killed install (older than installLockStaleAfter) is reclaimed: a Ctrl-C'd +// or crashed install leaves such orphans, so recover instead of failing forever. +// The caller must invoke the returned release func when finished (typically via +// defer). +func (m *Manager) acquireInstallLock(lockFilePath string) (func(), error) { + release, err := lockfile.Acquire(lockFilePath, installLockStaleAfter, func(age time.Duration) { + m.logger.Warn("reclaiming a stale plugin install lock", + slog.String("lock", lockFilePath), slog.Duration("age", age)) + }) + + if errors.Is(err, lockfile.ErrLocked) { + return nil, fmt.Errorf("plugin is locked by: %s", lockFilePath) + } + + if err != nil { + return nil, err + } + + return release, nil +} + +// fetchAndDisplayContract pulls the contract for <pluginName, version> from +// the registry and prints the installing-plugin banner. +func (m *Manager) fetchAndDisplayContract(ctx context.Context, pluginName string, version *semver.Version) (*internal.Plugin, error) { + tag := version.Original() + + // Cyan-bold key labels. fatih/color drops ANSI on a non-TTY and under NO_COLOR. + key := color.New(color.FgCyan, color.Bold) + + fmt.Printf("%s %s\n", key.Sprint("Installing plugin:"), pluginName) + fmt.Printf("%s %s\n", key.Sprint("Tag:"), tag) + + plugin, err := m.PluginContract(ctx, pluginName, tag) + if err != nil { + return nil, fmt.Errorf("failed to get plugin contract: %w", err) + } + + fmt.Printf("%s %s %s\n", key.Sprint("Plugin:"), printable(plugin.Name), printable(plugin.Version)) + fmt.Printf("%s %s\n", key.Sprint("Description:"), printable(plugin.Description)) + + return plugin, nil +} + +// printable strips terminal control characters from untrusted contract text, so +// a malicious image cannot smuggle ANSI escapes into the user's terminal. +func printable(s string) string { + return strings.Map(func(r rune) rune { + if (r < 32 && r != '\n' && r != '\t') || r == 127 { + return -1 + } + + return r + }, s) +} + +// validateInstalledRequirements enforces the plugin's requirements right before the +// switch. By this point the planner has already installed/upgraded the dependency +// chain, so this is the final guard: any remaining unmet requirement (e.g. a +// dependency removed by hand) fails with an actionable error. +func (m *Manager) validateInstalledRequirements(ctx context.Context, plugin *internal.Plugin) error { + m.logger.Debug("validating requirements", slog.String("plugin", plugin.Name)) + + failedConstraints, err := m.validateRequirements(ctx, plugin) + if err != nil { + return fmt.Errorf("failed to validate requirements: %w", err) + } + + if len(failedConstraints) > 0 { + return failedConstraints.helpfulError( + fmt.Sprintf("plugin %q has unsatisfied requirements", plugin.Name)) + } + + return nil +} + +// printPlan shows the dependency installs/upgrades a plugin install will perform. +func (m *Manager) printPlan(pluginName string, plan *resolutionPlan) { + fmt.Printf("Plugin '%s' requires installing/upgrading %d dependency plugin(s):\n", pluginName, len(plan.steps)) + + for _, step := range plan.steps { + fmt.Printf(" - %s %s\n", step.pluginName, step.version.Original()) + } +} + +// executePlan installs the planned dependencies in dependency-first order. Each +// dependency goes through the full atomic install pipeline with its own lock; the +// plan is already flattened, so each step installs with no further planning. +func (m *Manager) executePlan(ctx context.Context, plan *resolutionPlan) error { + for _, step := range plan.steps { + if err := m.installPlugin(ctx, step.pluginName, step.version, newResolutionPlan(), false); err != nil { + return fmt.Errorf("install dependency %s %s: %w", step.pluginName, step.version.Original(), err) + } + } + + return nil +} + +// pluginVersionProbe runs the binary at binaryPath with "--version" (falling back +// to "version") and returns its stdout. It succeeds if either invocation exits +// cleanly; the returned error on failure carries a tail of the binary's stderr so +// a smoke-test failure (missing shared lib, panic, wrong arch) is diagnosable. +func pluginVersionProbe(ctx context.Context, binaryPath string) ([]byte, error) { + if output, err := exec.CommandContext(ctx, binaryPath, "--version").Output(); err == nil { + return output, nil + } + + output, err := exec.CommandContext(ctx, binaryPath, "version").Output() + if err != nil { + return nil, fmt.Errorf("run plugin binary: %w%s", err, stderrTail(err)) + } + + return output, nil +} + +// stderrTail returns a short parenthesized tail of an *exec.ExitError's captured +// stderr (populated by Cmd.Output), or "" - turning "exit status 1" into something +// actionable. +func stderrTail(err error) string { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || len(exitErr.Stderr) == 0 { + return "" + } + + const maxLen = 200 + + msg := strings.TrimSpace(string(exitErr.Stderr)) + if len(msg) > maxLen { + msg = msg[:maxLen] + "..." + } + + return fmt.Sprintf(" (stderr: %s)", msg) +} + +// pluginBinaryVersion probes the binary and parses its reported version as semver. +func pluginBinaryVersion(ctx context.Context, binaryPath string) (*semver.Version, error) { + output, err := pluginVersionProbe(ctx, binaryPath) + if err != nil { + return nil, err + } + + version, err := semver.NewVersion(strings.TrimSpace(string(output))) + if err != nil { + return nil, fmt.Errorf("parse plugin version %q: %w", strings.TrimSpace(string(output)), err) + } + + return version, nil +} + +// installedMajorFromDisk returns the major the plugin's `current` symlink points +// at, read from the on-disk v<major> directory WITHOUT running the binary - so a +// broken/hung binary cannot lose the major pin and let an implicit update +// silently cross a major. +func (m *Manager) installedMajorFromDisk(pluginName string) (int, bool) { + target, err := os.Readlink(layout.CurrentLinkPath(m.pluginDirectory, pluginName)) + if err != nil { + return 0, false + } + + // target: <root>/plugins/<name>/v<major>/<name> - the parent dir is v<major>. + major, err := strconv.Atoi(strings.TrimPrefix(filepath.Base(filepath.Dir(target)), layout.VersionDirPrefix)) + if err != nil { + return 0, false + } + + return major, true +} + +// pluginAlreadyAtVersion reports whether the major's binary is already the selected +// version, so an install/update is a no-op (idempotency; see ADR safe update +// "skip if already installed"). +func (m *Manager) pluginAlreadyAtVersion(ctx context.Context, binaryPath string, version *semver.Version) bool { + if _, err := os.Stat(binaryPath); err != nil { + return false + } + + ctx, cancel := context.WithTimeout(ctx, pluginProbeTimeout) + defer cancel() + + installed, err := pluginBinaryVersion(ctx, binaryPath) + if err != nil { + return false + } + + return installed.Equal(version) +} + +// isCurrentBinary reports whether the `current` symlink already points at this +// major's binary, so an already-installed selected version can be distinguished +// between "nothing to do" and "needs a symlink switch". +func (m *Manager) isCurrentBinary(paths pluginPaths) bool { + target, err := os.Readlink(paths.currentLink) + if err != nil { + return false + } + + abs, err := filepath.Abs(paths.binaryPath) + if err != nil { + return false + } + + return target == abs +} + +// smokeTestPlugin verifies the freshly extracted binary runs cleanly when asked +// for its version. A corrupt or incompatible artifact (wrong arch, missing libs, +// crash) is rejected before it is linked as current. +// It requires only a clean exit, not parseable version output: a plugin that +// prints a human-readable banner is not rejected. Version parsing is reserved +// for the version-comparison paths. +func (m *Manager) smokeTestPlugin(ctx context.Context, pluginName, binaryPath string) error { + ctx, cancel := context.WithTimeout(ctx, pluginProbeTimeout) + defer cancel() + + if _, err := pluginVersionProbe(ctx, binaryPath); err != nil { + return fmt.Errorf("installed %q binary failed its smoke test: %w", pluginName, err) + } + + return nil +} + +// downloadAndExtract pulls the plugin image tag and writes the embedded +// binary to <binaryPath> (a staged sibling of the final install path). +func (m *Manager) downloadAndExtract(ctx context.Context, pluginName string, version *semver.Version, binaryPath string) error { + tag := version.Original() + + fmt.Printf("Installing to: %s\n", strings.TrimSuffix(binaryPath, stagedBinarySuffix)) + fmt.Println("Downloading and extracting plugin...") + + if err := m.service.ExtractPlugin(ctx, pluginName, tag, binaryPath); err != nil { + return fmt.Errorf("failed to extract plugin: %w", err) + } + + return nil +} + +// linkCurrent (re)points <pluginDir>/current to the freshly installed binary +// using an absolute target path. The new link is built aside and renamed over +// `current`: rename is atomic, so a concurrent plugin exec always sees either +// the old or the new target - never a missing link (a plain remove+symlink has +// exactly that window). +func (m *Manager) linkCurrent(paths pluginPaths) error { + absPath, err := filepath.Abs(paths.binaryPath) + if err != nil { + return fmt.Errorf("failed to compute absolute path: %w", err) + } + + staged := paths.currentLink + stagedBinarySuffix + _ = os.Remove(staged) + + if err := os.Symlink(absPath, staged); err != nil { + return fmt.Errorf("failed to create symlink: %w", err) + } + + if err := os.Rename(staged, paths.currentLink); err != nil { + _ = os.Remove(staged) + + return fmt.Errorf("failed to create symlink: %w", err) + } + + return nil +} + +// cacheContract writes the plugin contract JSON to +// <plugin-dir>/cache/contracts/<name>.json for later lookups by +// validatePluginConflicts and `d8 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 { + dir := layout.ContractsDir(m.pluginDirectory) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create contract directory: %w", err) + } + + var buf bytes.Buffer + + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + + if err := enc.Encode(service.DomainToContract(plugin)); err != nil { + return fmt.Errorf("failed to cache contract: %w", err) + } + + tmp, err := os.CreateTemp(dir, pluginName+layout.ContractFileExt+".tmp-*") + if err != nil { + return fmt.Errorf("failed to create temp contract file: %w", err) + } + + // Removed if the rename below does not consume it (errors / partial write). + defer func() { _ = os.Remove(tmp.Name()) }() + + if _, err := tmp.Write(buf.Bytes()); err != nil { + _ = tmp.Close() + + return fmt.Errorf("failed to write contract file: %w", err) + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close contract file: %w", err) + } + + // CreateTemp makes the file 0600; the cached contract is world-readable data. + if err := os.Chmod(tmp.Name(), 0o644); err != nil { + return fmt.Errorf("failed to chmod contract file: %w", err) + } + + if err := os.Rename(tmp.Name(), layout.ContractFile(m.pluginDirectory, pluginName)); err != nil { + return fmt.Errorf("failed to cache contract: %w", err) + } + + return nil +} + +func (m *Manager) filterMajorVersion(versions []string, majorVersion int) []string { + res := make([]string, 0, 1) + + for _, ver := range versions { + version, err := semver.NewVersion(ver) + if err != nil { + continue + } + + if version.Major() == uint64(majorVersion) { + res = append(res, ver) + } + } + + return res +} diff --git a/internal/plugins/install_test.go b/internal/plugins/install_test.go new file mode 100644 index 00000000..0946a912 --- /dev/null +++ b/internal/plugins/install_test.go @@ -0,0 +1,458 @@ +/* +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" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +// fakeInstallSource is a pluginSource whose ExtractPlugin writes a caller-supplied +// binary, so the install pipeline (smoke test, rollback) can be exercised on disk. +type fakeInstallSource struct { + contract *internal.Plugin + extract func(dest string) error + + // tags is what ListPluginTags returns; listedTags records the calls so tests + // can assert which plugins an operation actually touched. + tags []string + listedTags []string + + // contractByTag, when set, overrides contract per tag; a missing tag yields an + // error (simulating a transient registry failure for that version). + contractByTag map[string]*internal.Plugin +} + +func (f *fakeInstallSource) ListPluginTags(_ context.Context, pluginName string) ([]string, error) { + f.listedTags = append(f.listedTags, pluginName) + + return f.tags, nil +} + +func (f *fakeInstallSource) GetPluginContract(_ context.Context, _, tag string) (*internal.Plugin, error) { + if f.contractByTag != nil { + contract, ok := f.contractByTag[tag] + if !ok { + return nil, errors.New("transient contract fetch failure") + } + + return contract, nil + } + + return f.contract, nil +} + +func (f *fakeInstallSource) ExtractPlugin(_ context.Context, _, _, dest string) error { + return f.extract(dest) +} + +// writeScriptBinary writes an executable shell script at dir/name. The script +// echoes versionOutput and exits with exitCode, standing in for a plugin binary. +func writeScriptBinary(t *testing.T, dir, name, versionOutput string, exitCode int) string { + t.Helper() + + path := filepath.Join(dir, name) + script := "#!/bin/sh\necho '" + versionOutput + "'\nexit " + strconv.Itoa(exitCode) + "\n" + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) + + return path +} + +func TestPluginBinaryVersion(t *testing.T) { + dir := t.TempDir() + + ok := writeScriptBinary(t, dir, "ok", "v2.3.4", 0) + version, err := pluginBinaryVersion(context.Background(), ok) + require.NoError(t, err) + assert.Equal(t, "2.3.4", version.String()) + + bad := writeScriptBinary(t, dir, "bad", "not-a-version", 0) + _, err = pluginBinaryVersion(context.Background(), bad) + assert.Error(t, err, "non-semver output is a parse error") + + failing := writeScriptBinary(t, dir, "fail", "", 1) + _, err = pluginBinaryVersion(context.Background(), failing) + assert.Error(t, err, "a non-zero exit is a run error") +} + +func TestPluginBinaryVersionFallsBackToVersionSubcommand(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fallback") + // Fails on `--version`, succeeds on the `version` subcommand. + script := "#!/bin/sh\nif [ \"$1\" = \"version\" ]; then echo 'v3.0.0'; exit 0; fi\nexit 1\n" + require.NoError(t, os.WriteFile(path, []byte(script), 0o755)) + + version, err := pluginBinaryVersion(context.Background(), path) + require.NoError(t, err) + assert.Equal(t, "3.0.0", version.String()) +} + +func TestPluginAlreadyAtVersion(t *testing.T) { + dir := t.TempDir() + m := testManager() + + bin := writeScriptBinary(t, dir, "p", "v1.2.3", 0) + + assert.True(t, m.pluginAlreadyAtVersion(context.Background(), bin, semver.MustParse("v1.2.3")), + "same version is already installed") + assert.False(t, m.pluginAlreadyAtVersion(context.Background(), bin, semver.MustParse("v1.2.4")), + "a newer version is not yet installed") + assert.False(t, m.pluginAlreadyAtVersion(context.Background(), filepath.Join(dir, "missing"), semver.MustParse("v1.2.3")), + "no binary means not installed") + + failing := writeScriptBinary(t, dir, "broken", "", 1) + assert.False(t, m.pluginAlreadyAtVersion(context.Background(), failing, semver.MustParse("v1.2.3")), + "an unrunnable binary is treated as not-at-version (do not skip the reinstall)") +} + +func TestSmokeTestPlugin(t *testing.T) { + dir := t.TempDir() + m := testManager() + + good := writeScriptBinary(t, dir, "good", "v1.0.0", 0) + require.NoError(t, m.smokeTestPlugin(context.Background(), "p", good)) + + bad := writeScriptBinary(t, dir, "bad", "", 1) + assert.Error(t, m.smokeTestPlugin(context.Background(), "p", bad), + "a binary that cannot report its version fails the smoke test") +} + +func TestSmokeTestPluginAcceptsNonSemverOutput(t *testing.T) { + dir := t.TempDir() + m := testManager() + + // Exits 0 but prints a human-readable banner, not a bare semver. + banner := writeScriptBinary(t, dir, "banner", "myplugin version 1.2 (build abc)", 0) + + require.NoError(t, m.smokeTestPlugin(context.Background(), "p", banner), + "smoke passes on a clean exit even when output is not a parseable version") + + _, err := pluginBinaryVersion(context.Background(), banner) + assert.Error(t, err, "strict version parsing still rejects non-semver output") +} + +func TestInstalledMajorFromDisk(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + + _, ok := m.installedMajorFromDisk("p") + assert.False(t, ok, "no install yet") + + installPluginFixture(t, root, "p", 2) + + major, ok := m.installedMajorFromDisk("p") + require.True(t, ok) + assert.Equal(t, 2, major, "major read from the current symlink target, not the binary") +} + +func TestAcquireInstallLockIsExclusive(t *testing.T) { + m := testManager() + lock := filepath.Join(t.TempDir(), "p.lock") + + release, err := m.acquireInstallLock(lock) + require.NoError(t, err) + require.NotNil(t, release) + + _, err = m.acquireInstallLock(lock) + assert.Error(t, err, "a held lock blocks a second acquirer") + + release() + + release2, err := m.acquireInstallLock(lock) + require.NoError(t, err, "a released lock can be re-acquired") + release2() +} + +func TestAcquireInstallLockReclaimsStale(t *testing.T) { + m := testManager() + lock := filepath.Join(t.TempDir(), "p.lock") + + // A fresh lock blocks a second acquirer. + require.NoError(t, os.WriteFile(lock, nil, 0o644)) + _, err := m.acquireInstallLock(lock) + assert.Error(t, err, "a fresh lock blocks") + + // A lock older than installLockStaleAfter is reclaimed (orphaned by a hard kill). + old := time.Now().Add(-2 * installLockStaleAfter) + require.NoError(t, os.Chtimes(lock, old, old)) + + release, err := m.acquireInstallLock(lock) + require.NoError(t, err, "a stale lock is reclaimed") + require.NotNil(t, release) + + release() + + _, statErr := os.Stat(lock) + assert.True(t, os.IsNotExist(statErr), "release removes the lock") +} + +func TestInstallPluginSwitchesToInstalledVersionWithoutDownload(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + + extractCalled := false + m.service = &fakeInstallSource{ + contract: &internal.Plugin{Name: "p", Version: "v2.0.0"}, + extract: func(dest string) error { + extractCalled = true + + return os.WriteFile(dest, []byte("x"), 0o755) + }, + } + + // Two majors already installed; current points at v1. + v1 := layout.BinaryPath(root, "p", 1) + v2 := layout.BinaryPath(root, "p", 2) + require.NoError(t, os.MkdirAll(filepath.Dir(v1), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Dir(v2), 0o755)) + require.NoError(t, os.WriteFile(v1, []byte("#!/bin/sh\necho v1.0.0\n"), 0o755)) + require.NoError(t, os.WriteFile(v2, []byte("#!/bin/sh\necho v2.0.0\n"), 0o755)) + require.NoError(t, os.Symlink(v1, layout.CurrentLinkPath(root, "p"))) + + // Installing the already-present v2 must repoint current WITHOUT downloading. + err := m.installPlugin(context.Background(), "p", semver.MustParse("v2.0.0"), nil, false) + require.NoError(t, err) + assert.False(t, extractCalled, "switching to an installed version must not re-download") + + target, err := os.Readlink(layout.CurrentLinkPath(root, "p")) + require.NoError(t, err) + absV2, _ := filepath.Abs(v2) + assert.Equal(t, absV2, target, "current was repointed to v2") +} + +func TestInstallPluginSwitchBlockedByRequirements(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + m.service = &fakeInstallSource{ + contract: &internal.Plugin{ + Name: "p", + Version: "v2.0.0", + Requirements: internal.Requirements{Kubernetes: internal.KubernetesRequirement{Constraint: ">= 99.0"}}, + }, + extract: func(dest string) error { return os.WriteFile(dest, []byte("x"), 0o755) }, + } + + v1 := layout.BinaryPath(root, "p", 1) + v2 := layout.BinaryPath(root, "p", 2) + require.NoError(t, os.MkdirAll(filepath.Dir(v1), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Dir(v2), 0o755)) + require.NoError(t, os.WriteFile(v1, []byte("#!/bin/sh\necho v1.0.0\n"), 0o755)) + require.NoError(t, os.WriteFile(v2, []byte("#!/bin/sh\necho v2.0.0\n"), 0o755)) + require.NoError(t, os.Symlink(v1, layout.CurrentLinkPath(root, "p"))) + + // Switching to the already-installed v2 must be BLOCKED: its requirements are not + // met, and requirements are checked before the symlink switch. + err := m.installPlugin(context.Background(), "p", semver.MustParse("v2.0.0"), nil, false) + require.Error(t, err, "switch is blocked when the target version's requirements are unmet") + + target, err := os.Readlink(layout.CurrentLinkPath(root, "p")) + require.NoError(t, err) + absV1, _ := filepath.Abs(v1) + assert.Equal(t, absV1, target, "current stays at v1 - the unmet switch did not happen") +} + +func TestInstallPluginSmokeFailureRollsBack(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + m.service = &fakeInstallSource{ + contract: &internal.Plugin{Name: "p", Version: "v1.0.0"}, + extract: func(dest string) error { + // A binary that fails the smoke test (exits non-zero). + return os.WriteFile(dest, []byte("#!/bin/sh\nexit 1\n"), 0o755) + }, + } + + err := m.installPlugin(context.Background(), "p", semver.MustParse("v1.0.0"), nil, false) + require.Error(t, err, "a smoke-test failure aborts the install") + + _, statErr := os.Lstat(layout.CurrentLinkPath(root, "p")) + assert.True(t, os.IsNotExist(statErr), "current is never repointed at a broken binary") + + _, binErr := os.Stat(layout.BinaryPath(root, "p", 1)) + assert.True(t, os.IsNotExist(binErr), "the broken binary is removed on rollback (fresh install)") +} + +func TestInstallPluginDoesNotDowngradeOnContractError(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + + extractCalled := false + src := &fakeInstallSource{ + tags: []string{"v1.2.0", "v1.3.0"}, + contractByTag: map[string]*internal.Plugin{ + // v1.3.0 is deliberately absent: its contract fetch fails transiently, + // so selection falls back to v1.2.0. + "v1.2.0": {Name: "p", Version: "v1.2.0"}, + }, + extract: func(dest string) error { + extractCalled = true + + return os.WriteFile(dest, []byte("x"), 0o755) + }, + } + m.service = src + + // v1.3.0 installed and current. + v1 := layout.BinaryPath(root, "p", 1) + require.NoError(t, os.MkdirAll(filepath.Dir(v1), 0o755)) + writeScriptBinary(t, filepath.Dir(v1), "p", "v1.3.0", 0) + + absV1, err := filepath.Abs(v1) + require.NoError(t, err) + require.NoError(t, os.Symlink(absV1, layout.CurrentLinkPath(root, "p"))) + + require.NoError(t, m.InstallPlugin(context.Background(), "p"), + "a transient contract error on the newest tag is not an install failure") + + assert.False(t, extractCalled, "the older selection must not be installed over a newer installed version") + + version, err := pluginBinaryVersion(context.Background(), v1) + require.NoError(t, err) + assert.Equal(t, "1.3.0", version.String(), "the installed newer version is kept, not silently downgraded") +} + +func TestValidateInstalledRequirementsFailsOnUnsatisfiedDep(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + m.clusterStateCache = &requirements.ClusterState{} + + // dep is installed at v1.0.0; plugin p requires dep >= 2.0.0 -> unsatisfied. + depBin := layout.BinaryPath(root, "dep", 1) + require.NoError(t, os.MkdirAll(filepath.Dir(depBin), 0o755)) + writeScriptBinary(t, filepath.Dir(depBin), "dep", "v1.0.0", 0) + absDep, err := filepath.Abs(depBin) + require.NoError(t, err) + require.NoError(t, os.Symlink(absDep, layout.CurrentLinkPath(root, "dep"))) + + p := &internal.Plugin{ + Name: "p", + Version: "v1.0.0", + Requirements: internal.Requirements{ + Plugins: internal.PluginRequirementsGroup{ + Mandatory: []internal.PluginRequirement{{Name: "dep", Constraint: ">= 2.0.0"}}, + }, + }, + } + + // The final pre-switch guard fails when a dependency constraint is unmet (e.g. a + // dependency removed by hand, or one the planner could not satisfy). + err = m.validateInstalledRequirements(context.Background(), p) + require.Error(t, err, "an unsatisfied dependency constraint must fail the install") + + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he, "the failure is a HelpfulError so the CLI renders it with color") + assert.Contains(t, he.Category, "has unsatisfied requirements") + + _, ok := findSuggestion(he, "dep must satisfy >=2.0.0") + assert.True(t, ok, "the diagnostic names the dependency and its constraint") +} + +func TestValidateInstalledRequirementsNamesMissingDep(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + m.clusterStateCache = &requirements.ClusterState{} + + // A plugin needs "dep", which is not installed; the failure must name the + // dependency and point at how to install it. + p := &internal.Plugin{ + Name: "p", + Version: "v1.0.0", + Requirements: internal.Requirements{ + Plugins: internal.PluginRequirementsGroup{ + Mandatory: []internal.PluginRequirement{{Name: "dep"}}, + }, + }, + } + + err := m.validateInstalledRequirements(context.Background(), p) + require.Error(t, err) + + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he) + + 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", + "it points at how to install the dependency") +} + +func TestInstallPluginRejectsInvalidName(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + + for _, name := range []string{"../escape", "a/b", "..", "UPPER", "name?x"} { + require.Error(t, m.InstallPlugin(context.Background(), name), "name %q must be rejected", name) + } + + entries, err := os.ReadDir(m.pluginDirectory) + require.NoError(t, err) + assert.Empty(t, entries, "no directories are created for an invalid name") +} + +func TestInstallPluginDownloadFailureRestoresPreviousBinary(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + m.service = &fakeInstallSource{ + contract: &internal.Plugin{Name: "p", Version: "v1.1.0"}, + extract: func(dest string) error { + // A mid-stream failure leaves a truncated file at dest, exactly like the + // real extractor's direct write does. + _ = os.WriteFile(dest, []byte("truncated"), 0o755) + + return errors.New("connection reset") + }, + } + + // v1.0.0 installed and current. + v1 := layout.BinaryPath(root, "p", 1) + require.NoError(t, os.MkdirAll(filepath.Dir(v1), 0o755)) + writeScriptBinary(t, filepath.Dir(v1), "p", "v1.0.0", 0) + + absV1, err := filepath.Abs(v1) + require.NoError(t, err) + require.NoError(t, os.Symlink(absV1, layout.CurrentLinkPath(root, "p"))) + + err = m.installPlugin(context.Background(), "p", semver.MustParse("v1.1.0"), nil, false) + require.Error(t, err, "a failed download aborts the install") + + restored, err := os.ReadFile(v1) + require.NoError(t, err, "the installed binary is untouched after a failed download") + assert.Contains(t, string(restored), "v1.0.0", "the installed binary is the previous one, not the truncated download") +} diff --git a/internal/plugins/cmd/layout/layout.go b/internal/plugins/layout/layout.go similarity index 59% rename from internal/plugins/cmd/layout/layout.go rename to internal/plugins/layout/layout.go index a4cdca92..d281e1c2 100644 --- a/internal/plugins/cmd/layout/layout.go +++ b/internal/plugins/layout/layout.go @@ -21,12 +21,11 @@ limitations under the License. // The install root is the on-disk directory under which the subsystem keeps // everything it owns - installed plugin binaries (<root>/plugins/...) and // cached contracts (<root>/cache/contracts/...). It is supplied by the -// caller (typically pc.pluginDirectory, sourced from --plugins-dir, with +// caller (typically the plugins.Manager directory, sourced from --plugins-dir, with // default /opt/deckhouse/lib/deckhouse-cli, or ~/.deckhouse-cli as fallback). // -// Callers should use the builder functions (PluginDir, BinaryPath, ...) -// for full paths. The raw segment constants are exposed for the rare cases -// where only a single directory or extension name is needed. +// Callers should use the builder functions (PluginDir, BinaryPath, ...) for +// full paths; the directory-name segments are package-private. package layout import ( @@ -37,55 +36,50 @@ import ( ) const ( - PluginsDirName = "plugins" - CacheDirName = "cache" - ContractsDirName = "contracts" - CurrentLinkName = "current" - LockFileSuffix = ".lock" + pluginsDirName = "plugins" + cacheDirName = "cache" + contractsDirName = "contracts" + currentLinkName = "current" + lockFileSuffix = ".lock" ContractFileExt = ".json" - HomeFallbackDir = ".deckhouse-cli" + homeFallbackDir = ".deckhouse-cli" VersionDirPrefix = "v" ) // PluginsRoot returns <installRoot>/plugins. func PluginsRoot(installRoot string) string { - return path.Join(installRoot, PluginsDirName) + return path.Join(installRoot, pluginsDirName) } // PluginDir returns <installRoot>/plugins/<pluginName>. func PluginDir(installRoot, pluginName string) string { - return path.Join(installRoot, PluginsDirName, pluginName) + return path.Join(installRoot, pluginsDirName, pluginName) } // VersionDir returns <installRoot>/plugins/<pluginName>/v<majorVersion>. func VersionDir(installRoot, pluginName string, majorVersion int) string { - return path.Join(installRoot, PluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion)) + return path.Join(installRoot, pluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion)) } // BinaryPath returns <installRoot>/plugins/<pluginName>/v<majorVersion>/<pluginName>. func BinaryPath(installRoot, pluginName string, majorVersion int) string { - return path.Join(installRoot, PluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion), pluginName) -} - -// LockPath returns <installRoot>/plugins/<pluginName>/v<majorVersion>/<pluginName>.lock. -func LockPath(installRoot, pluginName string, majorVersion int) string { - return path.Join(installRoot, PluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion), pluginName+LockFileSuffix) + return path.Join(installRoot, pluginsDirName, pluginName, VersionDirPrefix+strconv.Itoa(majorVersion), pluginName) } // CurrentLinkPath returns <installRoot>/plugins/<pluginName>/current - the symlink to the // currently active binary version. func CurrentLinkPath(installRoot, pluginName string) string { - return path.Join(installRoot, PluginsDirName, pluginName, CurrentLinkName) + return path.Join(installRoot, pluginsDirName, pluginName, currentLinkName) } // ContractsDir returns <installRoot>/cache/contracts. func ContractsDir(installRoot string) string { - return path.Join(installRoot, CacheDirName, ContractsDirName) + return path.Join(installRoot, cacheDirName, contractsDirName) } // ContractFile returns <installRoot>/cache/contracts/<pluginName>.json. func ContractFile(installRoot, pluginName string) string { - return path.Join(installRoot, CacheDirName, ContractsDirName, pluginName+ContractFileExt) + return path.Join(installRoot, cacheDirName, contractsDirName, pluginName+ContractFileExt) } // HomeFallbackPath returns ~/.deckhouse-cli - the fallback install root used @@ -96,5 +90,35 @@ func HomeFallbackPath() (string, error) { return "", fmt.Errorf("failed to determine user home directory: %w", err) } - return path.Join(home, HomeFallbackDir), nil + return path.Join(home, homeFallbackDir), nil +} + +// InstallLockPath returns <installRoot>/plugins/<pluginName>/install.lock - one +// lock per plugin (not per major): installs of different majors still contend +// on the shared `current` symlink and contract cache, so they must serialize. +func InstallLockPath(installRoot, pluginName string) string { + return path.Join(installRoot, pluginsDirName, pluginName, "install"+lockFileSuffix) +} + +// RootHasInstall reports whether <root>/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<major> dir from a failed install does not +// count as an install. The cache dir is a sibling, never miscounted. +func RootHasInstall(root string) bool { + entries, err := os.ReadDir(PluginsRoot(root)) + if err != nil { + return false + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + if _, err := os.Lstat(CurrentLinkPath(root, entry.Name())); err == nil { + return true + } + } + + return false } diff --git a/internal/plugins/list.go b/internal/plugins/list.go new file mode 100644 index 00000000..18273dd3 --- /dev/null +++ b/internal/plugins/list.go @@ -0,0 +1,91 @@ +/* +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 plugins + +import ( + "fmt" + "log/slog" + "os" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" +) + +// PluginInfo is one installed plugin's name, version and description for display. +type PluginInfo struct { + Name string + Version string + 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 plugins versions <name>`. +func (m *Manager) List() []PluginInfo { + installed, err := m.fetchInstalledPlugins() + if err != nil { + m.logger.Warn("Failed to fetch installed plugins", slog.String("error", err.Error())) + + return []PluginInfo{} + } + + return installed +} + +// fetchInstalledPlugins reads the installed plugins from the plugins root on disk. +func (m *Manager) fetchInstalledPlugins() ([]PluginInfo, error) { + plugins, err := os.ReadDir(layout.PluginsRoot(m.pluginDirectory)) + if err != nil { + return nil, fmt.Errorf("failed to read plugins directory: %w", err) + } + + res := make([]PluginInfo, 0, len(plugins)) + + for _, plugin := range plugins { + version, err := m.getInstalledPluginVersion(plugin.Name()) + if err != nil { + res = append(res, PluginInfo{ + Name: plugin.Name(), + Version: "ERROR", + Description: err.Error(), + }) + + continue + } + + contract, err := m.InstalledPluginContract(plugin.Name()) + if err != nil { + res = append(res, PluginInfo{ + Name: plugin.Name(), + Version: version.Original(), + Description: "failed to get description", + }) + + continue + } + + displayInfo := PluginInfo{ + Name: plugin.Name(), + Version: version.Original(), + Description: contract.Description, + } + + res = append(res, displayInfo) + } + + return res, nil +} diff --git a/internal/plugins/planner.go b/internal/plugins/planner.go new file mode 100644 index 00000000..4c81bdd0 --- /dev/null +++ b/internal/plugins/planner.go @@ -0,0 +1,535 @@ +/* +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" + "fmt" + "maps" + "os" + "slices" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/internal/rpp" +) + +// maxResolveDepth bounds the dependency recursion. The visited-set already stops +// cycles; this is a backstop against a pathological/malicious contract chain. +const maxResolveDepth = 16 + +// planStep is one install action: lay down pluginName at version. +type planStep struct { + pluginName string + version *semver.Version +} + +// resolutionPlan is a dependency-first, deduplicated set of installs needed to +// satisfy a plugin's plugin->plugin requirements. steps is ordered so every +// dependency precedes its dependents; byName dedups and records the decided +// version per plugin. +type resolutionPlan struct { + steps []planStep + byName map[string]*semver.Version +} + +func newResolutionPlan() *resolutionPlan { + return &resolutionPlan{byName: make(map[string]*semver.Version)} +} + +func (p *resolutionPlan) isEmpty() bool { + return len(p.steps) == 0 +} + +// add records a decided install. Callers append a dependency only after its own +// sub-steps, so steps stays dependency-first. +func (p *resolutionPlan) add(name string, version *semver.Version) { + p.steps = append(p.steps, planStep{pluginName: name, version: version}) + p.byName[name] = version +} + +// mergeNewSteps folds a sub-plan's new steps and decisions into dst. src is seeded +// from dst.byName, so its steps are exactly the newly added ones. +func mergeNewSteps(dst, src *resolutionPlan) { + dst.steps = append(dst.steps, src.steps...) + maps.Copy(dst.byName, src.byName) +} + +// reasonKind classifies why a candidate was rejected, so the terminal error can +// group dependency problems under one "unresolved dependencies" message and keep +// non-dependency failures (cluster, conflict) out of it. +type reasonKind int + +const ( + reasonOther reasonKind = iota + reasonDepNotPublished + reasonDepNoVersion + reasonDepCycle + reasonClusterIncompatible + reasonContractUnavailable + reasonReverseConflict +) + +func (k reasonKind) isDependency() bool { + switch k { + case reasonDepNotPublished, reasonDepNoVersion, reasonDepCycle: + return true + default: + return false + } +} + +// unsatisfiableReason explains why a candidate cannot be resolved. It is a soft +// "skip this candidate" signal, distinct from an operational error (which must +// hard-stop and is returned as a plain error). +type unsatisfiableReason struct { + kind reasonKind + pluginName string + constraint string + detail string + // path is the dependency chain from the requested plugin down to pluginName. + // Renders as "via a -> b -> c". Empty or single-element for a directly + // requested plugin. + path []string +} + +func (r *unsatisfiableReason) summary() string { + msg := r.detail + if r.pluginName != "" { + if len(r.path) > 1 { + msg = fmt.Sprintf("dependency %q (via %s): %s", r.pluginName, strings.Join(r.path, " -> "), r.detail) + } else { + msg = fmt.Sprintf("dependency %q: %s", r.pluginName, r.detail) + } + } + + if r.constraint != "" { + msg += fmt.Sprintf(" (needs %s)", r.constraint) + } + + return msg +} + +// planFor computes the dependency plan needed to install plugin into the current +// on-disk state. It is read-only: it consults the registry (cached), the installed +// contracts on disk and cluster state, but writes nothing. allowMajorCross lets an +// installed dependency be upgraded across its own major to satisfy a constraint +// (the cascade enabled by --use-major); otherwise a dependency stays within its +// installed major. +// +// Returns: +// - (plan, nil, nil) when the chain is resolvable, +// - (nil, reason, nil) when plugin cannot be satisfied (caller skips the candidate), +// - (nil, nil, err) only on an operational failure that must hard-stop. +func (m *Manager) planFor(ctx context.Context, plugin *internal.Plugin, allowMajorCross bool) (*resolutionPlan, *unsatisfiableReason, error) { + plan := newResolutionPlan() + visited := map[string]bool{plugin.Name: true} + + reason, err := m.resolveInto(ctx, plugin, plan, visited, allowMajorCross, 0, []string{plugin.Name}) + if err != nil { + return nil, nil, err + } + + if reason != nil { + return nil, reason, nil + } + + return plan, nil, nil +} + +// resolveInto walks plugin's plugin-requirements, growing plan in place. visited is +// the set of plugin names currently on the recursion stack (the cycle guard). +func (m *Manager) resolveInto( + ctx context.Context, + plugin *internal.Plugin, + plan *resolutionPlan, + visited map[string]bool, + allowMajorCross bool, + depth int, + path []string, +) (*unsatisfiableReason, error) { + if depth > maxResolveDepth { + return nil, fmt.Errorf("plugin dependency chain for %q exceeds the maximum depth of %d", plugin.Name, maxResolveDepth) + } + + // Reverse conflicts: an already-installed plugin may constrain plugin to a + // version it does not satisfy. Not resolvable by installing deps - skip candidate. + reason, err := m.reverseConflictReason(plugin) + if err != nil { + return nil, err + } + + if reason != nil { + return reason, nil + } + + // Conditional deps: enforced only when the dependency is installed or planned. + for _, req := range plugin.Requirements.Plugins.Conditional { + reason, err := m.conditionalReason(req, plan) + if err != nil { + return nil, err + } + + if reason != nil { + return reason, nil + } + } + + // Mandatory deps: must be satisfiable (installed/planned at a good version, or + // installable/upgradable). + for _, req := range plugin.Requirements.Plugins.Mandatory { + reason, err := m.resolveMandatoryDep(ctx, req, plan, visited, allowMajorCross, depth, path) + if err != nil { + return nil, err + } + + if reason != nil { + return reason, nil + } + } + + return nil, nil +} + +// reverseConflictReason reports the first already-installed plugin whose constraint +// on plugin is violated by plugin's candidate version. +func (m *Manager) reverseConflictReason(plugin *internal.Plugin) (*unsatisfiableReason, error) { + contractDir, err := os.ReadDir(layout.ContractsDir(m.pluginDirectory)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + return nil, fmt.Errorf("read contract directory: %w", err) + } + + for _, file := range contractDir { + name := strings.TrimSuffix(file.Name(), layout.ContractFileExt) + + installed, err := m.InstalledPluginContract(name) + if err != nil { + return nil, fmt.Errorf("read installed contract %q: %w", name, err) + } + + if err := validatePluginConflict(plugin, installed); err != nil { + return &unsatisfiableReason{kind: reasonReverseConflict, pluginName: name, detail: "reverse conflict: " + err.Error()}, nil + } + } + + return nil, nil +} + +// conditionalReason enforces a conditional dependency: it matters only when the +// dependency is installed or already planned. An installed/planned but +// out-of-constraint conditional dependency makes the candidate unsatisfiable (a +// conditional dependency is not auto-upgraded). +func (m *Manager) conditionalReason(req internal.PluginRequirement, plan *resolutionPlan) (*unsatisfiableReason, error) { + version, present, err := m.effectiveVersion(req.Name, plan) + if err != nil { + return nil, err + } + + if !present || req.Constraint == "" { + return nil, nil + } + + constraint, err := semver.NewConstraint(req.Constraint) + if err != nil { + return nil, fmt.Errorf("parse constraint %q for plugin %q: %w", req.Constraint, req.Name, err) + } + + if !constraint.Check(version) { + return &unsatisfiableReason{ + pluginName: req.Name, + constraint: req.Constraint, + detail: fmt.Sprintf("conditional dependency installed at %s does not satisfy", version.Original()), + }, nil + } + + return nil, nil +} + +// resolveMandatoryDep ensures one mandatory dependency is satisfied, planning an +// install/upgrade when needed. +func (m *Manager) resolveMandatoryDep( + ctx context.Context, + req internal.PluginRequirement, + plan *resolutionPlan, + visited map[string]bool, + allowMajorCross bool, + depth int, + path []string, +) (*unsatisfiableReason, error) { + if visited[req.Name] { + return &unsatisfiableReason{kind: reasonDepCycle, pluginName: req.Name, path: append(slices.Clone(path), req.Name), detail: "dependency cycle"}, nil + } + + var constraint *semver.Constraints + + if req.Constraint != "" { + parsed, err := semver.NewConstraint(req.Constraint) + if err != nil { + return nil, fmt.Errorf("parse constraint %q for plugin %q: %w", req.Constraint, req.Name, err) + } + + constraint = parsed + } + + version, present, err := m.effectiveVersion(req.Name, plan) + if err != nil { + return nil, err + } + + if present { + if constraint == nil || constraint.Check(version) { + // Satisfied. An installed dependency's own chain was validated at its + // install time; a planned one was resolved when it was planned. + return nil, nil + } + + // A planned version cannot be changed without conflicting another dependent. + if _, planned := plan.byName[req.Name]; planned { + return &unsatisfiableReason{ + pluginName: req.Name, + constraint: req.Constraint, + detail: fmt.Sprintf("already planned at %s", version.Original()), + }, nil + } + // Installed but out of constraint: fall through to upgrade it. + } + + reason, err := m.selectDepVersion(ctx, req.Name, constraint, plan, visited, allowMajorCross, depth, path) + if err != nil { + return nil, err + } + + return reason, nil +} + +// effectiveVersion returns the version a dependency would have given the current +// plan and disk: a planned version wins, else the installed version. present is +// false when the dependency is neither planned nor installed. +func (m *Manager) effectiveVersion(name string, plan *resolutionPlan) (*semver.Version, bool, error) { + if version, ok := plan.byName[name]; ok { + return version, true, nil + } + + installed, err := m.checkInstalled(name) + if err != nil { + return nil, false, fmt.Errorf("check whether %q is installed: %w", name, err) + } + + if !installed { + return nil, false, nil + } + + version, err := m.getInstalledPluginVersion(name) + if err != nil { + return nil, false, fmt.Errorf("read installed version of %q: %w", name, err) + } + + return version, true, nil +} + +// selectDepVersion picks (and plans) a version for a dependency that must be +// installed or upgraded: the newest tag that satisfies constraint, is +// cluster-compatible, and whose own chain resolves. An installed dependency is +// bounded to its current major (unless allowMajorCross) and never downgraded. +func (m *Manager) selectDepVersion( + ctx context.Context, + depName string, + constraint *semver.Constraints, + plan *resolutionPlan, + visited map[string]bool, + allowMajorCross bool, + depth int, + path []string, +) (*unsatisfiableReason, error) { + depPath := append(slices.Clone(path), depName) + + tags, err := m.listTags(ctx, depName) + if err != nil { + // An unpublished mandatory dependency makes this candidate unsatisfiable + // (skip to an older version), not an operational failure. Other errors + // (auth, proxy, transport) still hard-stop. + if errors.Is(err, rpp.ErrNotFound) { + return &unsatisfiableReason{ + kind: reasonDepNotPublished, + pluginName: depName, + path: depPath, + detail: fmt.Sprintf("not published as deckhouse-cli/plugins/%s", depName), + }, nil + } + + return nil, fmt.Errorf("list tags for %q: %w", depName, err) + } + + majorBound := -1 + + var floor *semver.Version + + installed, err := m.checkInstalled(depName) + if err != nil { + return nil, fmt.Errorf("check whether %q is installed: %w", depName, err) + } + + if installed { + current, err := m.getInstalledPluginVersion(depName) + if err != nil { + return nil, fmt.Errorf("read installed version of %q: %w", depName, err) + } + + floor = current + if !allowMajorCross { + majorBound = int(current.Major()) + } + } + + candidates := filterDepTags(tags, majorBound, floor) + + // Mark the dependency active on the recursion stack while its subtree resolves. + visited[depName] = true + defer delete(visited, depName) + + deepCheck := func(ctx context.Context, contract *internal.Plugin) (bool, *unsatisfiableReason, error) { + sub := newResolutionPlan() + maps.Copy(sub.byName, plan.byName) + + reason, err := m.resolveInto(ctx, contract, sub, visited, allowMajorCross, depth+1, depPath) + if err != nil { + return false, nil, err + } + + if reason != nil { + return false, reason, nil + } + + mergeNewSteps(plan, sub) + + return true, nil, nil + } + + version, rejected, err := m.selectCompatible(ctx, depName, candidates, constraint, deepCheck) + if err != nil { + return nil, err + } + + if version == nil { + // A single version blocked by its own (deeper) dependency: surface that leaf + // reason directly so the chain names the real culprit, not just this dep. + if len(rejected) == 1 && rejected[0].reason != nil && rejected[0].reason.kind.isDependency() { + return rejected[0].reason, nil + } + + return &unsatisfiableReason{ + kind: reasonDepNoVersion, + pluginName: depName, + path: depPath, + constraint: constraintString(constraint), + detail: "no compatible version", + }, nil + } + + plan.add(depName, version) + + return nil, nil +} + +// filterDepTags keeps tags that parse as semver, optionally restricting to +// majorBound (>= 0) and dropping anything below floor (never downgrade an +// installed dependency). +func filterDepTags(tags []string, majorBound int, floor *semver.Version) []string { + out := make([]string, 0, len(tags)) + + for _, tag := range tags { + version, err := semver.NewVersion(tag) + if err != nil { + continue + } + + if majorBound >= 0 && int(version.Major()) != majorBound { + continue + } + + if floor != nil && version.LessThan(floor) { + continue + } + + out = append(out, tag) + } + + return out +} + +func constraintString(constraint *semver.Constraints) string { + if constraint == nil { + return "" + } + + return constraint.String() +} + +// selectTopWithPlan returns the newest version of pluginName whose cluster +// requirements AND plugin->plugin chain are resolvable, together with the plan to +// realize that chain. +func (m *Manager) selectTopWithPlan( + ctx context.Context, + pluginName string, + versions []string, + allowMajorCross bool, +) (*semver.Version, *resolutionPlan, error) { + plan := newResolutionPlan() + + deepCheck := func(ctx context.Context, contract *internal.Plugin) (bool, *unsatisfiableReason, error) { + candidatePlan, reason, err := m.planFor(ctx, contract, allowMajorCross) + if err != nil { + return false, nil, err + } + + if reason != nil { + return false, reason, nil + } + + plan = candidatePlan + + return true, nil, nil + } + + version, rejected, err := m.selectCompatible(ctx, pluginName, versions, nil, deepCheck) + if err != nil { + return nil, nil, err + } + + if version == nil { + return nil, nil, noCompatibleError(pluginName, rejected) + } + + if len(rejected) > 0 { + skipped := make([]string, len(rejected)) + for i, rc := range rejected { + skipped[i] = fmt.Sprintf("%s (%s)", rc.version, rc.reason.summary()) + } + + fmt.Printf("Selected %s (newer version(s) skipped: %s)\n", version.Original(), strings.Join(skipped, "; ")) + } + + return version, plan, nil +} diff --git a/internal/plugins/planner_test.go b/internal/plugins/planner_test.go new file mode 100644 index 00000000..fe3c8832 --- /dev/null +++ b/internal/plugins/planner_test.go @@ -0,0 +1,486 @@ +/* +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" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" + "github.com/deckhouse/deckhouse-cli/internal/rpp" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +// multiPluginSource is a pluginSource serving several plugins, for planner and +// resolution tests. ExtractPlugin writes a script binary that echoes the tag, so +// getInstalledPluginVersion reports the installed version correctly. +type multiPluginSource struct { + tags map[string][]string + contracts map[string]map[string]*internal.Plugin + // unpublished names return an rpp.ErrNotFound from ListPluginTags, emulating a + // dependency that is not published in the registry (a 404 from the proxy). + unpublished map[string]bool + // tagErrors returns a specific error for a name, for non-404 failures + // (auth, proxy, transport) that must hard-stop rather than skip. + tagErrors map[string]error +} + +func (s *multiPluginSource) ListPluginTags(_ context.Context, name string) ([]string, error) { + if err, ok := s.tagErrors[name]; ok { + return nil, err + } + + if s.unpublished[name] { + return nil, fmt.Errorf("%s: %w", name, rpp.ErrNotFound) + } + + tags, ok := s.tags[name] + if !ok { + return nil, fmt.Errorf("no such plugin %q", name) + } + + return tags, nil +} + +func (s *multiPluginSource) GetPluginContract(_ context.Context, name, tag string) (*internal.Plugin, error) { + byTag, ok := s.contracts[name] + if !ok { + return nil, fmt.Errorf("no such plugin %q", name) + } + + contract, ok := byTag[tag] + if !ok { + return nil, fmt.Errorf("no contract for %s@%s", name, tag) + } + + return contract, nil +} + +func (s *multiPluginSource) ExtractPlugin(_ context.Context, _, tag, dest string) error { + return os.WriteFile(dest, []byte("#!/bin/sh\necho '"+tag+"'\n"), 0o755) +} + +// requires builds a contract for name@version that mandatorily depends on dep at +// constraint. +func requires(name, version, dep, constraint string) *internal.Plugin { + return &internal.Plugin{ + Name: name, + Version: version, + Requirements: internal.Requirements{ + Plugins: internal.PluginRequirementsGroup{ + Mandatory: []internal.PluginRequirement{{Name: dep, Constraint: constraint}}, + }, + }, + } +} + +func plannerManager(t *testing.T, src *multiPluginSource) *Manager { + t.Helper() + + m := testManager() + m.pluginDirectory = t.TempDir() + m.service = src + // A non-nil snapshot so cluster checks (when a contract declares them) read it + // instead of dialing a real API server. + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + + return m +} + +// installVersionFixture installs name at version (major derived from version) with +// a version-reporting binary and the current symlink. +func installVersionFixture(t *testing.T, root, name, version string) { + t.Helper() + + major := int(semver.MustParse(version).Major()) + dir := filepath.Dir(layout.BinaryPath(root, name, major)) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + bin := writeScriptBinary(t, dir, name, version, 0) + abs, err := filepath.Abs(bin) + require.NoError(t, err) + require.NoError(t, os.Symlink(abs, layout.CurrentLinkPath(root, name))) +} + +func planStepVersions(plan *resolutionPlan) map[string]string { + out := make(map[string]string, len(plan.steps)) + for _, step := range plan.steps { + out[step.pluginName] = step.version.Original() + } + + return out +} + +func TestPlannerInstallsMissingMandatoryDep(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"foo": {"v1.0.0", "v1.3.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "foo": {"v1.0.0": {Name: "foo", Version: "v1.0.0"}, "v1.3.0": {Name: "foo", Version: "v1.3.0"}}, + }, + } + m := plannerManager(t, src) + + top := requires("p", "v1.0.0", "foo", ">= 1.0.0") + + plan, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err) + require.Nil(t, reason, "the missing dependency is installable") + assert.Equal(t, "v1.3.0", planStepVersions(plan)["foo"], "newest satisfying version is planned") +} + +func TestPlannerUnpublishedDepIsReasonWithChain(t *testing.T) { + src := &multiPluginSource{ + unpublished: map[string]bool{"foo": true}, + contracts: map[string]map[string]*internal.Plugin{}, + } + m := plannerManager(t, src) + + top := requires("p", "v1.0.0", "foo", "") + + plan, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err, "an unpublished dependency is not an operational error") + require.Nil(t, plan) + require.NotNil(t, reason, "the candidate is unsatisfiable, not a hard stop") + assert.Equal(t, `dependency "foo" (via p -> foo): not published as deckhouse-cli/plugins/foo`, reason.summary()) +} + +func TestPlannerSkipsDepVersionWithUnpublishedSubdep(t *testing.T) { + // dk v2 needs the unpublished "x"; dk v1 has no deps. Resolution falls back to v1. + src := &multiPluginSource{ + tags: map[string][]string{"dk": {"v2.0.0", "v1.0.0"}}, + unpublished: map[string]bool{"x": true}, + contracts: map[string]map[string]*internal.Plugin{ + "dk": { + "v2.0.0": requires("dk", "v2.0.0", "x", ""), + "v1.0.0": {Name: "dk", Version: "v1.0.0"}, + }, + }, + } + m := plannerManager(t, src) + + top := requires("p", "v1.0.0", "dk", "") + + plan, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err) + require.Nil(t, reason, "dk v1 satisfies the requirement") + assert.Equal(t, "v1.0.0", planStepVersions(plan)["dk"], "skips dk v2 whose subdep is unpublished") +} + +func TestPlannerDeepUnpublishedDepNamesTheChain(t *testing.T) { + // p -> dk (only v2) -> x (unpublished). The failure must name x and the full chain. + src := &multiPluginSource{ + tags: map[string][]string{"dk": {"v2.0.0"}}, + unpublished: map[string]bool{"x": true}, + contracts: map[string]map[string]*internal.Plugin{ + "dk": {"v2.0.0": requires("dk", "v2.0.0", "x", "")}, + }, + } + m := plannerManager(t, src) + + top := requires("p", "v1.0.0", "dk", "") + + _, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err) + require.NotNil(t, reason) + assert.Contains(t, reason.summary(), "not published as deckhouse-cli/plugins/x") + assert.Contains(t, reason.summary(), "via p -> dk -> x") +} + +func TestPlannerNonNotFoundDepErrorHardStops(t *testing.T) { + // A non-404 dependency error (auth, proxy, transport) must hard-stop the whole + // install, not skip to an older version, and must preserve the sentinel. + src := &multiPluginSource{ + tagErrors: map[string]error{"dk": fmt.Errorf("dk: %w", rpp.ErrUnauthorized)}, + contracts: map[string]map[string]*internal.Plugin{}, + } + m := plannerManager(t, src) + + top := requires("p", "v1.0.0", "dk", "") + + plan, reason, err := m.planFor(context.Background(), top, false) + require.Error(t, err) + require.Nil(t, plan) + require.Nil(t, reason, "an operational error is not a skippable reason") + assert.ErrorIs(t, err, rpp.ErrUnauthorized, "the sentinel is preserved for errdetect") +} + +func TestSelectTopWithPlanUnpublishedDepIsHelpful(t *testing.T) { + // When the only version is rejected for a missing dependency, the install + // failure is a structured HelpfulError that names the dependency. + src := &multiPluginSource{ + tags: map[string][]string{"package": {"v0.0.21"}}, + unpublished: map[string]bool{"delivery-kit": true}, + contracts: map[string]map[string]*internal.Plugin{ + "package": {"v0.0.21": requires("package", "v0.0.21", "delivery-kit", "")}, + }, + } + m := plannerManager(t, src) + + _, _, err := m.selectTopWithPlan(context.Background(), "package", []string{"v0.0.21"}, false) + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he, "an unresolved-dependency failure is a HelpfulError") + assert.Equal(t, `cannot install plugin "package": unresolved dependencies`, he.Category) + + out := he.Format() + assert.Contains(t, out, `required plugin "delivery-kit" is not published`) + assert.Contains(t, out, "publish it under deckhouse-cli/plugins/delivery-kit") + assert.NotContains(t, out, "(needed by:", "a direct dependency needs no chain") +} + +func TestSelectTopWithPlanTransitiveDepNamesChain(t *testing.T) { + // package -> delivery-kit -> x (x unpublished): the message names x and the chain. + src := &multiPluginSource{ + tags: map[string][]string{"package": {"v0.0.21"}, "delivery-kit": {"v1.0.0"}}, + unpublished: map[string]bool{"x": true}, + contracts: map[string]map[string]*internal.Plugin{ + "package": {"v0.0.21": requires("package", "v0.0.21", "delivery-kit", "")}, + "delivery-kit": {"v1.0.0": requires("delivery-kit", "v1.0.0", "x", "")}, + }, + } + m := plannerManager(t, src) + + _, _, err := m.selectTopWithPlan(context.Background(), "package", []string{"v0.0.21"}, false) + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he) + + out := he.Format() + assert.Contains(t, out, `required plugin "x" is not published`) + assert.Contains(t, out, "(needed by: package -> delivery-kit -> x)") +} + +func TestSelectTopDeduplicatesDependencySuggestions(t *testing.T) { + // Two versions blocked by the same missing dependency collapse to one suggestion. + src := &multiPluginSource{ + tags: map[string][]string{"package": {"v0.0.21", "v0.0.20"}}, + unpublished: map[string]bool{"delivery-kit": true}, + contracts: map[string]map[string]*internal.Plugin{ + "package": { + "v0.0.21": requires("package", "v0.0.21", "delivery-kit", ""), + "v0.0.20": requires("package", "v0.0.20", "delivery-kit", ""), + }, + }, + } + m := plannerManager(t, src) + + _, _, err := m.selectTopWithPlan(context.Background(), "package", []string{"v0.0.21", "v0.0.20"}, false) + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he) + assert.Len(t, he.Suggestions, 1, "both versions blocked by the same dependency -> one suggestion") +} + +func TestPlanForExplicitUnresolvedDepIsHelpful(t *testing.T) { + src := &multiPluginSource{ + unpublished: map[string]bool{"delivery-kit": true}, + contracts: map[string]map[string]*internal.Plugin{ + "package": {"v0.0.21": requires("package", "v0.0.21", "delivery-kit", "")}, + }, + } + m := plannerManager(t, src) + + _, err := m.planForExplicit(context.Background(), "package", semver.MustParse("v0.0.21"), false) + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he) + assert.Contains(t, he.Category, "unresolved dependencies") + assert.Contains(t, he.Format(), `required plugin "delivery-kit" is not published`) +} + +func TestPlannerUpgradesInstalledDepWithinMajor(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"foo": {"v1.0.0", "v1.2.0", "v1.5.0", "v2.0.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "foo": { + "v1.0.0": {Name: "foo", Version: "v1.0.0"}, + "v1.2.0": {Name: "foo", Version: "v1.2.0"}, + "v1.5.0": {Name: "foo", Version: "v1.5.0"}, + "v2.0.0": {Name: "foo", Version: "v2.0.0"}, + }, + }, + } + m := plannerManager(t, src) + installVersionFixture(t, m.pluginDirectory, "foo", "v1.0.0") + + top := requires("p", "v1.0.0", "foo", ">= 1.2.0") + + plan, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err) + require.Nil(t, reason) + assert.Equal(t, "v1.5.0", planStepVersions(plan)["foo"], + "newest in the installed major (not v2.0.0) is chosen") +} + +func TestPlannerDepMajorCrossNeedsCascade(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"foo": {"v1.0.0", "v2.0.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "foo": {"v1.0.0": {Name: "foo", Version: "v1.0.0"}, "v2.0.0": {Name: "foo", Version: "v2.0.0"}}, + }, + } + m := plannerManager(t, src) + installVersionFixture(t, m.pluginDirectory, "foo", "v1.0.0") + + top := requires("p", "v1.0.0", "foo", ">= 2.0.0") + + // Without cascade the installed dep is bound to its major -> unsatisfiable. + _, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err) + require.NotNil(t, reason, "dep cannot reach >= 2.0.0 within major 1") + + // With cascade (allowMajorCross) it may cross to v2.0.0. + plan, reason, err := m.planFor(context.Background(), top, true) + require.NoError(t, err) + require.Nil(t, reason) + assert.Equal(t, "v2.0.0", planStepVersions(plan)["foo"]) +} + +func TestPlannerSkipsClusterIncompatibleDepVersion(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"foo": {"v1.0.0", "v2.0.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "foo": { + "v1.0.0": {Name: "foo", Version: "v1.0.0"}, + "v2.0.0": { + Name: "foo", Version: "v2.0.0", + Requirements: internal.Requirements{Kubernetes: internal.KubernetesRequirement{Constraint: ">= 99.0"}}, + }, + }, + }, + } + m := plannerManager(t, src) + + top := requires("p", "v1.0.0", "foo", ">= 1.0.0") + + plan, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err) + require.Nil(t, reason) + assert.Equal(t, "v1.0.0", planStepVersions(plan)["foo"], + "the cluster-incompatible newest dep version is skipped for an older one") +} + +func TestPlannerCycleIsUnsatisfiable(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"a": {"v1.0.0"}, "b": {"v1.0.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "a": {"v1.0.0": requires("a", "v1.0.0", "b", ">= 1.0.0")}, + "b": {"v1.0.0": requires("b", "v1.0.0", "a", ">= 1.0.0")}, + }, + } + m := plannerManager(t, src) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, reason, err := m.planFor(ctx, src.contracts["a"]["v1.0.0"], false) + require.NoError(t, err) + require.NotNil(t, reason, "a self-referential dependency chain is unsatisfiable, not an infinite loop") + assert.Contains(t, reason.summary(), "dependency cycle") + assert.Contains(t, reason.summary(), "via a -> b -> a", "the cycle is shown as a chain") +} + +func TestPlannerConditionalDepWrongVersionUnsatisfiable(t *testing.T) { + src := &multiPluginSource{tags: map[string][]string{}, contracts: map[string]map[string]*internal.Plugin{}} + m := plannerManager(t, src) + installVersionFixture(t, m.pluginDirectory, "foo", "v1.0.0") + + top := &internal.Plugin{ + Name: "p", + Version: "v1.0.0", + Requirements: internal.Requirements{ + Plugins: internal.PluginRequirementsGroup{ + Conditional: []internal.PluginRequirement{{Name: "foo", Constraint: ">= 2.0.0"}}, + }, + }, + } + + _, reason, err := m.planFor(context.Background(), top, false) + require.NoError(t, err) + require.NotNil(t, reason, "an installed conditional dep at the wrong version is not auto-upgraded") +} + +func TestPlannerDependencyFirstOrdering(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"a": {"v1.0.0"}, "b": {"v1.0.0"}, "c": {"v1.0.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "a": {"v1.0.0": requires("a", "v1.0.0", "b", ">= 1.0.0")}, + "b": {"v1.0.0": requires("b", "v1.0.0", "c", ">= 1.0.0")}, + "c": {"v1.0.0": {Name: "c", Version: "v1.0.0"}}, + }, + } + m := plannerManager(t, src) + + plan, reason, err := m.planFor(context.Background(), src.contracts["a"]["v1.0.0"], false) + require.NoError(t, err) + require.Nil(t, reason) + + order := make([]string, 0, len(plan.steps)) + for _, step := range plan.steps { + order = append(order, step.pluginName) + } + + assert.Equal(t, []string{"c", "b"}, order, "dependencies precede dependents") +} + +func TestPlannerDryRunWritesNothing(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"foo": {"v1.0.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "foo": {"v1.0.0": {Name: "foo", Version: "v1.0.0"}}, + }, + } + m := plannerManager(t, src) + + before, err := os.ReadDir(m.pluginDirectory) + require.NoError(t, err) + + _, _, err = m.planFor(context.Background(), requires("p", "v1.0.0", "foo", ">= 1.0.0"), false) + require.NoError(t, err) + + after, err := os.ReadDir(m.pluginDirectory) + require.NoError(t, err) + assert.Equal(t, len(before), len(after), "planning is read-only: it installs nothing") +} + +func TestInstallPluginResolvesDepByDefault(t *testing.T) { + src := &multiPluginSource{ + tags: map[string][]string{"p": {"v1.0.0"}, "foo": {"v1.0.0"}}, + contracts: map[string]map[string]*internal.Plugin{ + "p": {"v1.0.0": requires("p", "v1.0.0", "foo", ">= 1.0.0")}, + "foo": {"v1.0.0": {Name: "foo", Version: "v1.0.0"}}, + }, + } + m := plannerManager(t, src) + + require.NoError(t, m.InstallPlugin(context.Background(), "p"), + "a missing mandatory dependency is installed automatically") + + installed, err := m.checkInstalled("foo") + require.NoError(t, err) + assert.True(t, installed, "the dependency was pulled in") + + installed, err = m.checkInstalled("p") + require.NoError(t, err) + assert.True(t, installed, "the plugin itself is installed after its deps") +} diff --git a/internal/plugins/plugins.go b/internal/plugins/plugins.go new file mode 100644 index 00000000..34c06984 --- /dev/null +++ b/internal/plugins/plugins.go @@ -0,0 +1,94 @@ +/* +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 plugins + +import ( + "errors" + "fmt" + "log/slog" + "os" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" +) + +// Manager is the plugin machinery shared by every `d8 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 { + service pluginSource + pluginDirectory string + + // clusterStateCache memoizes the cluster snapshot used to enforce cluster-side + // requirements, so a single command run inspects the cluster at most once. + clusterStateCache *requirements.ClusterState + + // contractCache memoizes plugin contracts by name@tag so requirements-aware + // version selection does not re-pull the same image within a command run. + // Not safe for concurrent use - Manager is per-invocation, run sequentially. + contractCache map[string]*internal.Plugin + + // tagsCache memoizes a plugin's published tags by name. The dependency planner + // probes the same dep across several candidate paths, so this keeps the tag + // listing to one registry call per plugin within a command run. + tagsCache map[string][]string + + logger *dkplog.Logger +} + +func NewManager(logger *dkplog.Logger) *Manager { + return &Manager{ + pluginDirectory: flags.DeckhousePluginsDir, + logger: logger, + } +} + +// SetDirectory retargets the manager at another install root. The command layer +// calls it after flag parsing, so --plugins-dir overrides the directory captured +// at construction time. +func (m *Manager) SetDirectory(dir string) { + m.pluginDirectory = dir +} + +// EnsureInstallRoot creates <pluginDirectory>/plugins; on permission denied +// falls back to ~/.deckhouse-cli, updates m.pluginDirectory, and retries. +func (m *Manager) EnsureInstallRoot() error { + err := os.MkdirAll(layout.PluginsRoot(m.pluginDirectory), 0755) + if err == nil { + return nil + } + + if !errors.Is(err, os.ErrPermission) { + return err + } + + m.logger.Debug("use homedir instead of default d8 plugins path in '/opt/deckhouse/lib/deckhouse-cli'", + slog.String("was", m.pluginDirectory), dkplog.Err(err)) + + fallback, ferr := layout.HomeFallbackPath() + if ferr != nil { + return fmt.Errorf("home fallback: %w", ferr) + } + + m.pluginDirectory = fallback + + return os.MkdirAll(layout.PluginsRoot(m.pluginDirectory), 0755) +} diff --git a/internal/plugins/remove.go b/internal/plugins/remove.go new file mode 100644 index 00000000..e6b2d7a9 --- /dev/null +++ b/internal/plugins/remove.go @@ -0,0 +1,98 @@ +/* +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 plugins + +import ( + "errors" + "fmt" + "os" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" +) + +// Remove deletes an installed plugin from disk: its install directory and the +// cached contract. It is idempotent (removing a plugin that is not installed is a +// no-op) and holds the plugin's install lock so it cannot race a concurrent +// install of the same plugin. +func (m *Manager) Remove(pluginName string) error { + if err := ValidatePluginName(pluginName); err != nil { + return err + } + + pluginDir := layout.PluginDir(m.pluginDirectory, pluginName) + + // Nothing installed: stay idempotent and skip locking - the lock file lives + // inside pluginDir, so there is nothing to serialize against and no parent to + // create it under. + if _, err := os.Stat(pluginDir); errors.Is(err, os.ErrNotExist) { + fmt.Printf("Plugin '%s' is not installed.\n", pluginName) + + return nil + } + + return m.removeLocked(pluginName, pluginDir) +} + +// RemoveAll deletes every plugin found under the plugins root, each under its own +// install lock. +func (m *Manager) RemoveAll() error { + plugins, err := os.ReadDir(layout.PluginsRoot(m.pluginDirectory)) + if err != nil { + return fmt.Errorf("failed to read plugins directory: %w", err) + } + + fmt.Println("Found", len(plugins), "plugins to remove:") + + for _, plugin := range plugins { + if !plugin.IsDir() { + continue + } + + if err := m.removeLocked(plugin.Name(), layout.PluginDir(m.pluginDirectory, plugin.Name())); err != nil { + return err + } + } + + return nil +} + +// removeLocked deletes one plugin's install directory and cached contract while +// holding the plugin's install lock. The same lock guards installs, so a remove +// can no longer delete a directory out from under a concurrent install (which +// would corrupt it); a concurrent install instead fails fast with the lock error. +func (m *Manager) removeLocked(pluginName, pluginDir string) error { + release, err := m.acquireInstallLock(layout.InstallLockPath(m.pluginDirectory, pluginName)) + if err != nil { + return err + } + + defer release() + + fmt.Printf("Removing plugin from: %s\n", pluginDir) + + if err := os.RemoveAll(pluginDir); err != nil { + return fmt.Errorf("failed to remove plugin directory: %w", err) + } + + fmt.Println("Cleaning up plugin files...") + + _ = os.Remove(layout.ContractFile(m.pluginDirectory, pluginName)) + + fmt.Printf("✓ Plugin '%s' successfully removed!\n", pluginName) + + return nil +} diff --git a/internal/plugins/remove_test.go b/internal/plugins/remove_test.go new file mode 100644 index 00000000..545e6553 --- /dev/null +++ b/internal/plugins/remove_test.go @@ -0,0 +1,62 @@ +/* +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 ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" +) + +func TestRemoveIsIdempotentWhenNotInstalled(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + + require.NoError(t, m.Remove("ghost"), "removing a plugin that is not installed is a no-op") +} + +func TestRemoveRejectsInvalidName(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + + require.Error(t, m.Remove("../escape")) +} + +// TestRemoveBlockedByHeldInstallLock proves the fix: a remove cannot delete a +// plugin while an install holds its lock - it fails fast and leaves the files in +// place instead of wiping the directory out from under the install. +func TestRemoveBlockedByHeldInstallLock(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + + bin := layout.BinaryPath(root, "p", 1) + require.NoError(t, os.MkdirAll(filepath.Dir(bin), 0o755)) + require.NoError(t, os.WriteFile(bin, []byte("x"), 0o755)) + + // A held (fresh, non-stale) install lock, as a concurrent install would create. + require.NoError(t, os.WriteFile(layout.InstallLockPath(root, "p"), nil, 0o644)) + + require.Error(t, m.Remove("p"), "remove must not proceed while the install lock is held") + + _, err := os.Stat(bin) + require.NoError(t, err, "the binary is left in place when the lock is held") +} diff --git a/internal/plugins/requirements/checks.go b/internal/plugins/requirements/checks.go new file mode 100644 index 00000000..a1ff44ab --- /dev/null +++ b/internal/plugins/requirements/checks.go @@ -0,0 +1,305 @@ +/* +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 requirements + +import ( + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/Masterminds/semver/v3" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" +) + +// Checker runs the cluster-side requirement checks. It carries only the logger +// used by the skip-with-a-warning paths (unknown Deckhouse version, module +// without a version). +type Checker struct { + logger *dkplog.Logger +} + +func NewChecker(logger *dkplog.Logger) *Checker { + return &Checker{logger: logger} +} + +// Check is one named cluster-side validator. The ordered list returned by +// Checker.Checks is the single source of truth shared by enforcement +// (install/run gating) and version selection in internal/plugins, so a new +// check is added in one place and cannot drift between the two. +type Check struct { + Name string + Run func(*internal.Plugin, *ClusterState) error +} + +func (c *Checker) Checks() []Check { + return []Check{ + {"kubernetes requirement", c.validateKubernetesRequirement}, + {"deckhouse requirement", c.validateDeckhouseRequirement}, + {"module requirements", c.validateModuleRequirement}, + } +} + +// unmetRequirementError marks a requirement the cluster genuinely does not satisfy, +// as opposed to an operational error (e.g. a malformed constraint in the contract). +// Version selection treats "unmet" as "try an older version" but must propagate +// operational errors instead of silently masking a broken contract. +type unmetRequirementError struct{ msg string } + +func (e unmetRequirementError) Error() string { return e.msg } + +func unmetf(format string, args ...any) error { + return unmetRequirementError{msg: fmt.Sprintf(format, args...)} +} + +// IsUnmet reports whether err marks a genuinely unmet requirement (as opposed +// to an operational failure that must not be masked). +func IsUnmet(err error) bool { + var unmet unmetRequirementError + + return errors.As(err, &unmet) +} + +// HasClusterRequirements reports whether the plugin declares any requirement that +// needs cluster state to verify (Kubernetes / Deckhouse / modules). +func HasClusterRequirements(plugin *internal.Plugin) bool { + requirements := plugin.Requirements + + return requirements.Kubernetes.Constraint != "" || + requirements.Deckhouse.Constraint != "" || + len(requirements.Modules.Mandatory) > 0 || + len(requirements.Modules.Conditional) > 0 || + len(requirements.Modules.AnyOf) > 0 +} + +// normalizedForConstraint prepares a version for constraint matching. +// Build metadata is always dropped. The pre-release segment depends on its kind: +// - genuine RC (rc/alpha/beta/etc.): kept, so boundary constraints treat an RC as below its GA; +// - CI/build markers ("v1.77.0-main+abc", "v1.28.3-eks-1-30"): stripped, so a plain floor like ">= 1.0" matches them. +// +// Trade-off: for genuine RCs, ">= 1.30" excludes 1.30.0-rc.1. +func normalizedForConstraint(v *semver.Version) *semver.Version { + pre := v.Prerelease() + if pre != "" && IsGenuinePrerelease(pre) { + return semver.New(v.Major(), v.Minor(), v.Patch(), pre, "") + } + + return semver.New(v.Major(), v.Minor(), v.Patch(), "", "") +} + +// IsGenuinePrerelease reports whether a pre-release segment denotes a real +// pre-release (rc/alpha/beta/preview/snapshot) rather than a CI/build marker. +// Version selection uses the same notion to keep pre-releases out of the +// default pick. +func IsGenuinePrerelease(pre string) bool { + first := strings.ToLower(strings.SplitN(pre, ".", 2)[0]) + + for _, marker := range []string{"alpha", "beta", "rc", "pre", "preview", "snapshot"} { + if strings.HasPrefix(first, marker) { + return true + } + } + + return false +} + +// validateKubernetesRequirement fails if the cluster Kubernetes version does not +// satisfy the plugin's constraint, or cannot be determined. +func (c *Checker) validateKubernetesRequirement(plugin *internal.Plugin, state *ClusterState) error { + if plugin.Requirements.Kubernetes.Constraint == "" { + return nil + } + + if state.Kubernetes == nil { + return fmt.Errorf("plugin %s requires Kubernetes %s, but the cluster Kubernetes version could not be determined", + plugin.Name, plugin.Requirements.Kubernetes.Constraint) + } + + constraint, err := semver.NewConstraint(plugin.Requirements.Kubernetes.Constraint) + if err != nil { + return fmt.Errorf("parse kubernetes constraint %q: %w", plugin.Requirements.Kubernetes.Constraint, err) + } + + if !constraint.Check(normalizedForConstraint(state.Kubernetes)) { + return unmetf("plugin %s requires Kubernetes %s, but the cluster runs %s", + plugin.Name, plugin.Requirements.Kubernetes.Constraint, state.Kubernetes.Original()) + } + + return nil +} + +// validateDeckhouseRequirement fails if the cluster Deckhouse version does not +// satisfy the plugin's constraint. A non-release cluster version (e.g. "dev", +// recorded as nil) is skipped with a warning rather than blocking the install. +func (c *Checker) validateDeckhouseRequirement(plugin *internal.Plugin, state *ClusterState) error { + if plugin.Requirements.Deckhouse.Constraint == "" { + return nil + } + + if state.Deckhouse == nil { + c.logger.Warn("skipping Deckhouse version requirement: cluster version is not a release semver", + slog.String("plugin", plugin.Name), + slog.String("constraint", plugin.Requirements.Deckhouse.Constraint)) + + return nil + } + + constraint, err := semver.NewConstraint(plugin.Requirements.Deckhouse.Constraint) + if err != nil { + return fmt.Errorf("parse deckhouse constraint %q: %w", plugin.Requirements.Deckhouse.Constraint, err) + } + + if !constraint.Check(normalizedForConstraint(state.Deckhouse)) { + return unmetf("plugin %s requires Deckhouse %s, but the cluster runs %s", + plugin.Name, plugin.Requirements.Deckhouse.Constraint, state.Deckhouse.Original()) + } + + return nil +} + +// validateModuleRequirement enforces module requirements against the cluster: +// - Mandatory: the module must be enabled and satisfy its version constraint; +// - Conditional: checked only when the module is enabled; +// - AnyOf: at least one module in each group must be enabled and satisfy its constraint. +func (c *Checker) validateModuleRequirement(plugin *internal.Plugin, state *ClusterState) error { + for _, requirement := range plugin.Requirements.Modules.Mandatory { + module, enabled := enabledModule(state, requirement.Name) + if !enabled { + return unmetf("plugin %s requires module %q to be enabled, but it is not", plugin.Name, requirement.Name) + } + + if err := c.checkModuleConstraint(plugin.Name, requirement, module); err != nil { + return err + } + } + + for _, requirement := range plugin.Requirements.Modules.Conditional { + module, enabled := enabledModule(state, requirement.Name) + if !enabled { + continue + } + + if err := c.checkModuleConstraint(plugin.Name, requirement, module); err != nil { + return err + } + } + + for index, group := range plugin.Requirements.Modules.AnyOf { + if err := c.checkAnyOfModules(plugin.Name, index, group, state); err != nil { + return err + } + } + + return nil +} + +// enabledModule returns the module's state and whether it is present and enabled. +func enabledModule(state *ClusterState, name string) (ModuleState, bool) { + module, present := state.Modules[name] + + return module, present && module.Enabled +} + +// evaluateModuleVersion checks an enabled module's version against the requirement. +// It returns (satisfied, versionKnown, err): +// - err is non-nil only for an operational failure (a malformed constraint string); +// - versionKnown is false when the module reports no version; +// - satisfied is meaningful only when versionKnown is true (or the constraint is empty). +func evaluateModuleVersion(requirement internal.ModuleRequirement, module ModuleState) (bool, bool, error) { + if requirement.Constraint == "" { + return true, true, nil + } + + constraint, err := semver.NewConstraint(requirement.Constraint) + if err != nil { + return false, false, fmt.Errorf("parse module %q constraint %q: %w", requirement.Name, requirement.Constraint, err) + } + + if module.Version == nil { + return false, false, nil + } + + return constraint.Check(normalizedForConstraint(module.Version)), true, nil +} + +// checkModuleConstraint verifies a mandatory/conditional module's version. A module +// that reports no version is skipped with a warning (its presence/enabled state is +// already enforced by the caller) rather than failing the install. +func (c *Checker) checkModuleConstraint(pluginName string, requirement internal.ModuleRequirement, module ModuleState) error { + satisfied, versionKnown, err := evaluateModuleVersion(requirement, module) + if err != nil { + return err + } + + if !versionKnown { + c.logger.Warn("skipping module version check: module reports no version", + slog.String("plugin", pluginName), + slog.String("module", requirement.Name), + slog.String("constraint", requirement.Constraint)) + + return nil + } + + if !satisfied { + return unmetf("plugin %s requires module %q %s, but the cluster has %s", + pluginName, requirement.Name, requirement.Constraint, module.Version.Original()) + } + + return nil +} + +// checkAnyOfModules passes if at least one module in the group is enabled and +// satisfies its constraint; otherwise it returns a descriptive error. +// An enabled-but-unversioned module does NOT satisfy a versioned alternative here +// (unlike the mandatory/conditional paths): another candidate may be verifiable. +// A malformed constraint is operational and propagates, not swallowed as "none satisfied". +func (c *Checker) checkAnyOfModules(pluginName string, index int, group internal.AnyOfGroup, state *ClusterState) error { + if len(group.Modules) == 0 { + return nil + } + + names := make([]string, 0, len(group.Modules)) + + for _, requirement := range group.Modules { + names = append(names, requirement.Name) + + module, enabled := enabledModule(state, requirement.Name) + if !enabled { + continue + } + + satisfied, versionKnown, err := evaluateModuleVersion(requirement, module) + if err != nil { + return err + } + + if versionKnown && satisfied { + return nil + } + } + + description := group.Description + if description == "" { + description = fmt.Sprintf("group %d", index) + } + + return unmetf("plugin %s requires at least one of [%s] (%s), but none is satisfied", + pluginName, strings.Join(names, ", "), description) +} diff --git a/internal/plugins/requirements/checks_test.go b/internal/plugins/requirements/checks_test.go new file mode 100644 index 00000000..2e8973b7 --- /dev/null +++ b/internal/plugins/requirements/checks_test.go @@ -0,0 +1,222 @@ +/* +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 requirements + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" +) + +func testChecker() *Checker { + return NewChecker(dkplog.NewNop()) +} + +func enabled(version string) ModuleState { + m := ModuleState{Enabled: true} + if version != "" { + m.Version = semver.MustParse(version) + } + + return m +} + +func TestNormalizedForConstraint(t *testing.T) { + // CI / build markers are stripped to the release version. + assert.Equal(t, "1.77.0", normalizedForConstraint(semver.MustParse("v1.77.0-main+abc")).String()) + assert.Equal(t, "1.28.3", normalizedForConstraint(semver.MustParse("v1.28.3-eks-1-30")).String()) + // Genuine pre-releases are kept (only build metadata is dropped). + assert.Equal(t, "1.30.0-rc.1", normalizedForConstraint(semver.MustParse("v1.30.0-rc.1+build")).String()) + assert.Equal(t, "1.30.0-alpha.2", normalizedForConstraint(semver.MustParse("v1.30.0-alpha.2")).String()) +} + +func TestHasClusterRequirements(t *testing.T) { + assert.False(t, HasClusterRequirements(&internal.Plugin{})) + assert.True(t, HasClusterRequirements(&internal.Plugin{ + Requirements: internal.Requirements{Kubernetes: internal.KubernetesRequirement{Constraint: ">= 1.27"}}, + })) + assert.True(t, HasClusterRequirements(&internal.Plugin{ + Requirements: internal.Requirements{Modules: internal.ModuleRequirementsGroup{ + Mandatory: []internal.ModuleRequirement{{Name: "x"}}, + }}, + })) +} + +func TestIsUnmet(t *testing.T) { + assert.True(t, IsUnmet(unmetf("requirement not met"))) + assert.False(t, IsUnmet(assert.AnError), "an operational error is not an unmet requirement") +} + +func TestValidateKubernetesRequirement(t *testing.T) { + c := testChecker() + state := &ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + + require.NoError(t, c.validateKubernetesRequirement(&internal.Plugin{}, state), "empty constraint passes") + require.NoError(t, c.validateKubernetesRequirement(reqK8s(">= 1.27"), state), "satisfied") + require.Error(t, c.validateKubernetesRequirement(reqK8s(">= 1.30"), state), "violated") +} + +func TestValidateKubernetesRequirementUnknownVersion(t *testing.T) { + c := testChecker() + state := &ClusterState{} // Kubernetes == nil (unparseable cluster version) + + require.Error(t, c.validateKubernetesRequirement(reqK8s(">= 1.27"), state), "declared requirement cannot be verified") + require.NoError(t, c.validateKubernetesRequirement(&internal.Plugin{}, state), "no constraint → no error") +} + +func TestValidateDeckhouseRequirement(t *testing.T) { + c := testChecker() + state := &ClusterState{Deckhouse: semver.MustParse("v1.65.3")} + + require.NoError(t, c.validateDeckhouseRequirement(reqDeckhouse(">= 1.60"), state), "satisfied") + require.Error(t, c.validateDeckhouseRequirement(reqDeckhouse(">= 1.70"), state), "violated") + + // dev cluster (nil version) is skipped, not blocked + dev := &ClusterState{} + require.NoError(t, c.validateDeckhouseRequirement(reqDeckhouse(">= 1.70"), dev), "dev cluster skips") +} + +func TestValidateModuleRequirementMandatory(t *testing.T) { + c := testChecker() + + state := func(mods map[string]ModuleState) *ClusterState { return &ClusterState{Modules: mods} } + + // enabled + version satisfies + require.NoError(t, c.validateModuleRequirement( + reqModules(mandatory("stronghold", ">= 1.0")), + state(map[string]ModuleState{"stronghold": enabled("v1.2.0")}))) + + // absent → error + require.Error(t, c.validateModuleRequirement( + reqModules(mandatory("stronghold", "")), + state(map[string]ModuleState{}))) + + // present but disabled → error + require.Error(t, c.validateModuleRequirement( + reqModules(mandatory("stronghold", "")), + state(map[string]ModuleState{"stronghold": {Enabled: false}}))) + + // enabled but version mismatch → error + require.Error(t, c.validateModuleRequirement( + reqModules(mandatory("stronghold", ">= 2.0")), + state(map[string]ModuleState{"stronghold": enabled("v1.2.0")}))) + + // enabled, version unknown → presence satisfied, version skipped + require.NoError(t, c.validateModuleRequirement( + reqModules(mandatory("stronghold", ">= 2.0")), + state(map[string]ModuleState{"stronghold": {Enabled: true}}))) + + // dev module version (pre-release) still satisfies a plain constraint via coreVersion + require.NoError(t, c.validateModuleRequirement( + reqModules(mandatory("stronghold", ">= 1.0.0")), + state(map[string]ModuleState{"stronghold": enabled("v1.77.0-main+abc")}))) +} + +func TestValidateModuleRequirementConditional(t *testing.T) { + c := testChecker() + reqs := reqModules(internal.ModuleRequirementsGroup{ + Conditional: []internal.ModuleRequirement{{Name: "stronghold", Constraint: ">= 2.0"}}, + }) + + // not enabled → skipped + require.NoError(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{}})) + + // enabled but fails → error + require.Error(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{"stronghold": enabled("v1.0.0")}})) +} + +func TestValidateModuleRequirementAnyOf(t *testing.T) { + c := testChecker() + reqs := reqModules(internal.ModuleRequirementsGroup{ + AnyOf: []internal.AnyOfGroup{{ + Description: "ingress", + Modules: []internal.ModuleRequirement{ + {Name: "ingress-nginx", Constraint: ">= 1.0"}, + {Name: "ingress-alb", Constraint: ">= 1.0"}, + }, + }}, + }) + + // one satisfied → ok + require.NoError(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{ + "ingress-alb": enabled("v1.5.0"), + }})) + + // none enabled → error + require.Error(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{}})) + + // enabled but all fail constraint → error + require.Error(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{ + "ingress-nginx": enabled("v0.9.0"), + }})) +} + +func TestValidateModuleRequirementAnyOfUnversionedNotSatisfied(t *testing.T) { + c := testChecker() + reqs := reqModules(internal.ModuleRequirementsGroup{ + AnyOf: []internal.AnyOfGroup{{Modules: []internal.ModuleRequirement{{Name: "m", Constraint: ">= 1.0"}}}}, + }) + + // enabled but no version → does NOT satisfy a versioned anyOf alternative + require.Error(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{ + "m": {Enabled: true}, + }})) +} + +func TestValidateModuleRequirementMalformedConstraintPropagates(t *testing.T) { + c := testChecker() + reqs := reqModules(internal.ModuleRequirementsGroup{ + AnyOf: []internal.AnyOfGroup{{Modules: []internal.ModuleRequirement{{Name: "m", Constraint: "abc"}}}}, + }) + + // a malformed constraint is operational - it propagates, not swallowed as "none satisfied" + err := c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{ + "m": enabled("v1.0.0"), + }}) + require.Error(t, err) + assert.False(t, IsUnmet(err), "operational errors are not reported as unmet requirements") +} + +// --- helpers to build plugins with specific requirements --- + +func reqK8s(constraint string) *internal.Plugin { + return &internal.Plugin{Name: "p", Requirements: internal.Requirements{ + Kubernetes: internal.KubernetesRequirement{Constraint: constraint}, + }} +} + +func reqDeckhouse(constraint string) *internal.Plugin { + return &internal.Plugin{Name: "p", Requirements: internal.Requirements{ + Deckhouse: internal.DeckhouseRequirement{Constraint: constraint}, + }} +} + +func reqModules(group internal.ModuleRequirementsGroup) *internal.Plugin { + return &internal.Plugin{Name: "p", Requirements: internal.Requirements{Modules: group}} +} + +func mandatory(name, constraint string) internal.ModuleRequirementsGroup { + return internal.ModuleRequirementsGroup{ + Mandatory: []internal.ModuleRequirement{{Name: name, Constraint: constraint}}, + } +} diff --git a/internal/plugins/requirements/clusterstate.go b/internal/plugins/requirements/clusterstate.go new file mode 100644 index 00000000..95c840fa --- /dev/null +++ b/internal/plugins/requirements/clusterstate.go @@ -0,0 +1,200 @@ +/* +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 requirements + +import ( + "context" + "fmt" + "log/slog" + + "github.com/Masterminds/semver/v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + modulecond "github.com/deckhouse/deckhouse-cli/internal/status/tools/constants" +) + +const ( + deckhouseNamespace = "d8-system" + deckhouseDeploymentName = "deckhouse" + deckhouseVersionAnnotation = "core.deckhouse.io/version" + + conditionStatusTrue = "True" +) + +// moduleGVR identifies the Deckhouse Module custom resource. +var moduleGVR = schema.GroupVersionResource{Group: "deckhouse.io", Version: "v1alpha1", Resource: "modules"} + +// ModuleState is the cluster-side fact about a single Deckhouse module. +type ModuleState struct { + Enabled bool + // Version is the installed module version, or nil when the module reports none + // or a non-parseable value (some modules omit it). + Version *semver.Version +} + +// ClusterState is a one-shot snapshot of the cluster facts needed to enforce a +// plugin's requirements. The caller decides when to (re)build it - typically +// lazily and once per command run, only when a plugin actually declares a +// cluster-side requirement. +type ClusterState struct { + // Kubernetes is the API server version, or nil if the cluster returned a version + // string that is not parseable as semver (a declared k8s requirement then fails). + Kubernetes *semver.Version + // Deckhouse is the platform version, or nil for a non-release build (e.g. "dev") + // or an absent annotation: Deckhouse version requirements are then skipped with + // a warning. A failure to READ the deployment is a hard error, not nil. + Deckhouse *semver.Version + // Modules maps module name -> its state; absence from the map means "not in cluster". + Modules map[string]ModuleState +} + +// LoadClusterState builds the snapshot from the cluster. A failure is fatal for +// the caller: if a plugin declares a requirement we cannot verify, it must not +// be installed blindly. +func LoadClusterState(ctx context.Context, kubeCl kubernetes.Interface, dynamicCl dynamic.Interface, logger *dkplog.Logger) (*ClusterState, error) { + kubeVersion, err := clusterKubernetesVersion(kubeCl, logger) + if err != nil { + return nil, err + } + + deckhouseVersion, err := clusterDeckhouseVersion(ctx, kubeCl, logger) + if err != nil { + return nil, err + } + + modules, err := clusterModules(ctx, dynamicCl) + if err != nil { + return nil, err + } + + return &ClusterState{ + Kubernetes: kubeVersion, + Deckhouse: deckhouseVersion, + Modules: modules, + }, nil +} + +// clusterKubernetesVersion returns the API server version. +// A failure to REACH the API server is a hard error: the whole snapshot is unusable. +// An unparseable version string yields nil, so a module/Deckhouse-only plugin is not +// blocked; a declared Kubernetes requirement then fails later in its validator. +func clusterKubernetesVersion(kubeCl kubernetes.Interface, logger *dkplog.Logger) (*semver.Version, error) { + info, err := kubeCl.Discovery().ServerVersion() + if err != nil { + return nil, fmt.Errorf("get kubernetes version: %w", err) + } + + version, err := semver.NewVersion(info.GitVersion) + if err != nil { + logger.Warn("could not parse cluster Kubernetes version", slog.String("version", info.GitVersion)) + + return nil, nil + } + + return version, nil +} + +// clusterDeckhouseVersion reads the platform version from the deckhouse deployment +// annotation. A read failure (missing deployment, RBAC denied, transient API error) +// is a hard error - we must not silently skip a declared requirement. An absent or +// non-release-semver annotation (e.g. "dev") returns nil to skip with a warning. +func clusterDeckhouseVersion(ctx context.Context, kubeCl kubernetes.Interface, logger *dkplog.Logger) (*semver.Version, error) { + deployment, err := kubeCl.AppsV1().Deployments(deckhouseNamespace).Get(ctx, deckhouseDeploymentName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("read deckhouse deployment to determine version: %w", err) + } + + raw, found := deployment.Annotations[deckhouseVersionAnnotation] + if !found || raw == "" { + logger.Debug("deckhouse version annotation absent; its requirements will be skipped") + + return nil, nil + } + + version, err := semver.NewVersion(raw) + if err != nil { + logger.Debug("deckhouse version is not a release semver; its requirements will be skipped", slog.String("version", raw)) + + return nil, nil + } + + return version, nil +} + +func clusterModules(ctx context.Context, dynamicCl dynamic.Interface) (map[string]ModuleState, error) { + list, err := dynamicCl.Resource(moduleGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list deckhouse modules: %w", err) + } + + modules := make(map[string]ModuleState, len(list.Items)) + + for i := range list.Items { + item := list.Items[i].Object + modules[list.Items[i].GetName()] = ModuleState{ + Enabled: moduleEnabled(item), + Version: moduleVersion(item), + } + } + + return modules, nil +} + +// moduleEnabled reports whether a module is on. It mirrors the repo convention +// (internal/status/objects/cni_modules): enabled when either the module-config or +// the module-manager condition is True. The OR avoids reporting an enabled module +// as off mid-reconcile, when only one condition has flipped. +func moduleEnabled(obj map[string]any) bool { + conditions, found, err := unstructured.NestedSlice(obj, "status", "conditions") + if !found || err != nil { + return false + } + + for _, raw := range conditions { + condition, ok := raw.(map[string]any) + if !ok || condition["status"] != conditionStatusTrue { + continue + } + + switch condition["type"] { + case modulecond.ModuleConditionEnabledByModuleManager, modulecond.ModuleConditionEnabledByModuleConfig: + return true + } + } + + return false +} + +func moduleVersion(obj map[string]any) *semver.Version { + raw, found, err := unstructured.NestedString(obj, "properties", "version") + if !found || err != nil || raw == "" { + return nil + } + + version, err := semver.NewVersion(raw) + if err != nil { + return nil + } + + return version +} diff --git a/internal/plugins/requirements/clusterstate_test.go b/internal/plugins/requirements/clusterstate_test.go new file mode 100644 index 00000000..c75f6161 --- /dev/null +++ b/internal/plugins/requirements/clusterstate_test.go @@ -0,0 +1,150 @@ +/* +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 requirements + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + apiversion "k8s.io/apimachinery/pkg/version" + fakediscovery "k8s.io/client-go/discovery/fake" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8sfake "k8s.io/client-go/kubernetes/fake" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" +) + +func deckhouseDeployment(version string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: deckhouseDeploymentName, + Namespace: deckhouseNamespace, + Annotations: map[string]string{deckhouseVersionAnnotation: version}, + }, + } +} + +func moduleObject(name, version string, enabled bool) *unstructured.Unstructured { + status := "False" + if enabled { + status = conditionStatusTrue + } + + return moduleObjectCond(name, version, "EnabledByModuleManager", status) +} + +func moduleObjectCond(name, version, condType, condStatus string) *unstructured.Unstructured { + properties := map[string]any{} + if version != "" { + properties["version"] = version + } + + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "Module", + "metadata": map[string]any{"name": name}, + "properties": properties, + "status": map[string]any{ + "conditions": []any{ + map[string]any{"type": condType, "status": condStatus}, + }, + }, + }} +} + +func fakeDynamic(objs ...runtime.Object) *dynamicfake.FakeDynamicClient { + return dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + runtime.NewScheme(), + map[schema.GroupVersionResource]string{moduleGVR: "ModuleList"}, + objs..., + ) +} + +func fakeKube(version string, objs ...runtime.Object) *k8sfake.Clientset { + kube := k8sfake.NewSimpleClientset(objs...) + kube.Discovery().(*fakediscovery.FakeDiscovery).FakedServerVersion = &apiversion.Info{GitVersion: version} + + return kube +} + +func TestLoadClusterState(t *testing.T) { + kube := fakeKube("v1.28.3", deckhouseDeployment("v1.65.3")) + dyn := fakeDynamic( + moduleObject("stronghold", "v1.2.0", true), + moduleObject("disabled-mod", "v1.0.0", false), + moduleObject("no-version", "", true), + moduleObjectCond("config-only", "v1.0.0", "EnabledByModuleConfig", "True"), + ) + + state, err := LoadClusterState(context.Background(), kube, dyn, dkplog.NewNop()) + require.NoError(t, err) + + require.NotNil(t, state.Kubernetes) + assert.Equal(t, "1.28.3", state.Kubernetes.String()) + + require.NotNil(t, state.Deckhouse) + assert.Equal(t, "1.65.3", state.Deckhouse.String()) + + assert.True(t, state.Modules["stronghold"].Enabled) + require.NotNil(t, state.Modules["stronghold"].Version) + assert.Equal(t, "1.2.0", state.Modules["stronghold"].Version.String()) + + assert.False(t, state.Modules["disabled-mod"].Enabled) + + assert.True(t, state.Modules["no-version"].Enabled) + assert.Nil(t, state.Modules["no-version"].Version, "absent version is nil, not an error") + + assert.True(t, state.Modules["config-only"].Enabled, "EnabledByModuleConfig alone counts as enabled") + + _, present := state.Modules["does-not-exist"] + assert.False(t, present) +} + +func TestLoadClusterStateDevDeckhouseIsNil(t *testing.T) { + kube := fakeKube("v1.28.3", deckhouseDeployment("dev")) + dyn := fakeDynamic() + + state, err := LoadClusterState(context.Background(), kube, dyn, dkplog.NewNop()) + require.NoError(t, err) + assert.Nil(t, state.Deckhouse, "non-release deckhouse version is recorded as nil") +} + +func TestLoadClusterStateDeckhouseReadErrorIsHard(t *testing.T) { + // No deckhouse deployment object → Get returns NotFound. Inability to READ the + // version must be a hard error (not silently skipped like the dev case). + kube := fakeKube("v1.28.3") + + _, err := LoadClusterState(context.Background(), kube, fakeDynamic(), dkplog.NewNop()) + require.Error(t, err) +} + +func TestLoadClusterStateUnparseableKubernetesVersion(t *testing.T) { + // A version string the cluster returns but semver cannot parse → nil (not fatal), + // so a module/Deckhouse-only plugin is not blocked. + kube := fakeKube("v1.28+", deckhouseDeployment("dev")) + + state, err := LoadClusterState(context.Background(), kube, fakeDynamic(), dkplog.NewNop()) + require.NoError(t, err) + assert.Nil(t, state.Kubernetes) +} diff --git a/internal/plugins/requirements/doc.go b/internal/plugins/requirements/doc.go new file mode 100644 index 00000000..c2cef8b9 --- /dev/null +++ b/internal/plugins/requirements/doc.go @@ -0,0 +1,35 @@ +/* +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 requirements answers one question: does THIS cluster satisfy a +// plugin's cluster-side requirements (Kubernetes, Deckhouse and module +// versions)? +// +// It has two halves: +// +// - ClusterState / LoadClusterState - a one-shot snapshot of the cluster +// facts the checks need; +// - Checker.Checks - the ordered, named validators that enforce a plugin +// contract against a snapshot. +// +// A check failure is either a genuinely unmet requirement (IsUnmet reports +// true; version selection may then try an older version) or an operational +// error (malformed constraint, unreadable cluster fact) that must propagate. +// +// The Manager-side concerns stay in internal/plugins: building the Kubernetes +// clients from flags, caching the snapshot per command run, the +// --skip-cluster-checks escape hatch and its error wording. +package requirements diff --git a/internal/plugins/rpp_source.go b/internal/plugins/rpp_source.go new file mode 100644 index 00000000..b18ff98a --- /dev/null +++ b/internal/plugins/rpp_source.go @@ -0,0 +1,149 @@ +/* +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" + "fmt" + "log/slog" + + "sigs.k8s.io/yaml" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/rpp" + "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +const ( + // pluginBinaryEntryName is the file inside a plugin image that holds the + // executable. + pluginBinaryEntryName = "plugin" + + // pluginContractEntryName is the file inside a plugin image that holds the + // contract. The proxy serves the image as a tar (not via OCI annotations), so + // the contract travels as a file. Confirmed against the plugin build CI + // (d8-package-plugin: a scratch image with /plugin and /contract.yaml). A + // missing contract is still tolerated for older / contract-less images. + pluginContractEntryName = "contract.yaml" + + // maxContractBytes caps the contract read so a malformed image cannot exhaust + // memory; real contracts are a few KiB. + maxContractBytes = 1 << 20 +) + +// rppPluginSource adapts the registry-packages-proxy client to pluginSource. +type rppPluginSource struct { + client *rpp.Client + logger *dkplog.Logger +} + +func newRppPluginSource(client *rpp.Client, logger *dkplog.Logger) *rppPluginSource { + return &rppPluginSource{client: client, logger: logger} +} + +var _ pluginSource = (*rppPluginSource)(nil) + +func (s *rppPluginSource) ListPluginTags(ctx context.Context, pluginName string) ([]string, error) { + ref, err := rpp.PluginImage(pluginName) + if err != nil { + return nil, err + } + + return s.client.ListTags(ctx, ref) +} + +func (s *rppPluginSource) GetPluginContract(ctx context.Context, pluginName, tag string) (*internal.Plugin, error) { + ref, err := rpp.PluginImage(pluginName) + if err != nil { + return nil, err + } + + // The proxy serves no manifest/annotations, so reading the contract needs a + // full image pull; an install therefore pulls the image twice (here and in + // ExtractPlugin). Plugin images are small, so this is acceptable for now. + body, err := s.client.PullImage(ctx, ref, tag) + if err != nil { + return nil, err + } + + defer func() { _ = body.Close() }() + + raw, found, err := rpp.ReadFile(body, pluginContractEntryName, maxContractBytes) + if err != nil { + return nil, fmt.Errorf("read contract for plugin %q: %w", pluginName, err) + } + + if !found { + // Plugin images do not carry a contract yet, so surface just name+version: + // install can proceed and the cluster/plugin requirement checks have nothing + // to enforce. + s.logger.Debug("plugin image has no contract file", slog.String("plugin", pluginName), slog.String("tag", tag)) + + return &internal.Plugin{Name: pluginName, Version: tag}, nil + } + + return contractFromBytes(raw, pluginName, tag) +} + +func (s *rppPluginSource) ExtractPlugin(ctx context.Context, pluginName, tag, destination string) error { + ref, err := rpp.PluginImage(pluginName) + if err != nil { + return err + } + + body, err := s.client.PullImage(ctx, ref, tag) + if err != nil { + return err + } + + defer func() { _ = body.Close() }() + + if err := rpp.ExtractFileToPath(body, pluginBinaryEntryName, destination, rpp.ExecutableMode, rpp.DefaultBinaryByteLimit); err != nil { + return fmt.Errorf("extract %q binary: %w", pluginName, err) + } + + return nil +} + +// contractFromBytes decodes a contract file, backfilling identity from the request +// so a present-but-degenerate contract still yields a usable name/version. +func contractFromBytes(raw []byte, pluginName, tag string) (*internal.Plugin, error) { + // The contract file is YAML (contract.yaml); the shared decoder expects JSON. + jsonRaw, err := yaml.YAMLToJSON(raw) + if err != nil { + return nil, fmt.Errorf("decode contract for plugin %q: %w", pluginName, err) + } + + var dto service.PluginContract + if err := service.UnmarshalContract(jsonRaw, &dto); err != nil { + return nil, fmt.Errorf("decode contract for plugin %q: %w", pluginName, err) + } + + plugin := service.ContractToDomain(&dto) + + if plugin.Name == "" { + plugin.Name = pluginName + } + + if plugin.Version == "" { + plugin.Version = tag + } + + return plugin, nil +} diff --git a/internal/plugins/rpp_source_test.go b/internal/plugins/rpp_source_test.go new file mode 100644 index 00000000..8ec0a874 --- /dev/null +++ b/internal/plugins/rpp_source_test.go @@ -0,0 +1,130 @@ +/* +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 ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" +) + +type tarFile struct { + content string + mode int64 +} + +func gzipTar(t *testing.T, files map[string]tarFile) []byte { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, f := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Mode: f.mode, + Size: int64(len(f.content)), + Typeflag: tar.TypeReg, + })) + _, err := tw.Write([]byte(f.content)) + require.NoError(t, err) + } + + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + return buf.Bytes() +} + +func newTestRppSource(t *testing.T, body []byte) *rppPluginSource { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/tags/v1.0.0", r.URL.Path) + + w.Header().Set("Content-Type", "application/x-gzip") + _, _ = w.Write(body) + })) + t.Cleanup(srv.Close) + + client := rpp.NewWithHTTPClient(srv.URL, srv.Client(), dkplog.NewNop()) + + return newRppPluginSource(client, dkplog.NewNop()) +} + +func TestRppSourceExtractPlugin(t *testing.T) { + // A non-executable mode in the image must still yield an executable binary. + src := newTestRppSource(t, gzipTar(t, map[string]tarFile{"plugin": {content: "BINARY", mode: 0o644}})) + dest := filepath.Join(t.TempDir(), "stronghold") + + require.NoError(t, src.ExtractPlugin(context.Background(), "stronghold", "v1.0.0", dest)) + + got, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "BINARY", string(got)) + + info, err := os.Stat(dest) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&0o100, "extracted binary must be executable") +} + +func TestRppSourceGetPluginContractBackfillsIdentity(t *testing.T) { + src := newTestRppSource(t, gzipTar(t, map[string]tarFile{ + pluginContractEntryName: {content: `{}`, mode: 0o644}, + })) + + plugin, err := src.GetPluginContract(context.Background(), "stronghold", "v1.0.0") + require.NoError(t, err) + assert.Equal(t, "stronghold", plugin.Name) + assert.Equal(t, "v1.0.0", plugin.Version) +} + +func TestRppSourceGetPluginContractTolerantWhenAbsent(t *testing.T) { + src := newTestRppSource(t, gzipTar(t, map[string]tarFile{"plugin": {content: "BINARY", mode: 0o755}})) + + plugin, err := src.GetPluginContract(context.Background(), "stronghold", "v1.0.0") + require.NoError(t, err) + assert.Equal(t, "stronghold", plugin.Name) + assert.Equal(t, "v1.0.0", plugin.Version) +} + +func TestRppSourceGetPluginContractParsesFile(t *testing.T) { + // The real contract ships as YAML (contract.yaml), as produced by the plugin CI. + contract := "name: stronghold\nversion: v1.0.0\ndescription: d\n" + src := newTestRppSource(t, gzipTar(t, map[string]tarFile{ + pluginContractEntryName: {content: contract, mode: 0o644}, + })) + + plugin, err := src.GetPluginContract(context.Background(), "stronghold", "v1.0.0") + require.NoError(t, err) + assert.Equal(t, "stronghold", plugin.Name) + assert.Equal(t, "d", plugin.Description) +} diff --git a/internal/plugins/run.go b/internal/plugins/run.go new file mode 100644 index 00000000..45c84508 --- /dev/null +++ b/internal/plugins/run.go @@ -0,0 +1,248 @@ +/* +Copyright 2024 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" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal" + d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" +) + +// pluginStopGracePeriod is how long a plugin gets to exit after SIGTERM +// (forwarded on d8's own cancellation) before it is killed. +const pluginStopGracePeriod = 10 * time.Second + +// Contract env-var names of the d8<->plugin protocol. Declared once so the +// injection switch (pluginRunEnv) and the help text (ProvidesEnv callers in +// the command layer) cannot drift. +const ( + envKubeconfig = "KUBECONFIG" + envPluginsCaller = "PLUGINS_CALLER" + envModuleConfigInfo = "MODULE_CONFIG_INFO" +) + +// ProvidesEnv reports whether d8 actually injects a value for a contract-requested +// env var (vs leaving it to pass through from the inherited environment). It is the +// single source of truth for both injection and help. MODULE_CONFIG_INFO is not yet +// provided (it needs a defined module mapping in the contract). +func ProvidesEnv(name string) bool { + return name == envKubeconfig || name == envPluginsCaller +} + +// RunInstalled ensures the plugin is installed, enforces its contract +// requirements, then execs its binary with args. stdin/stdout/stderr are inherited; +// the contract's requested env vars are injected. +func (m *Manager) RunInstalled(ctx context.Context, pluginName string, args []string) error { + installed, err := m.checkInstalled(pluginName) + if err != nil { + return fmt.Errorf("check installed: %w", err) + } + + if !installed { + // The plugin source is needed only to install; initialize it lazily so an + // already-installed plugin is exec'd without any registry/cluster round-trip. + if err := m.InitPluginServices(ctx); err != nil { + return fmt.Errorf("init plugin services: %w", err) + } + + fmt.Println("Not installed, installing...") + + if err := m.InstallPlugin(ctx, pluginName); err != nil { + return fmt.Errorf("install: %w", err) + } + + fmt.Println("Installed successfully") + } + + // The cached contract drives the pre-run requirement gate and env injection. A + // genuinely absent contract is best-effort (run ungated); a present-but-corrupt + // one is a hard error - failing open there would silently disable the gate. + contract, err := m.InstalledPluginContract(pluginName) + + switch { + case err == nil: + case errors.Is(err, os.ErrNotExist): + m.logger.Debug("no cached contract for plugin; running without requirement check or contract env", + slog.String("plugin", pluginName)) + + contract = nil + default: + return fmt.Errorf("read %q contract (reinstall with 'd8 plugins install %s --force'): %w", + pluginName, pluginName, err) + } + + // Requirement gate (ADR: check before run). Skipped for purely local args + // (help/version/completion) so a plugin's own help/version stays readable offline + // even when it declares cluster requirements. + if contract != nil && !isLocalPluginInvocation(args) { + if err := m.ensurePluginRequirements(ctx, contract); err != nil { + return err + } + } + + absPath, err := filepath.Abs(layout.CurrentLinkPath(m.pluginDirectory, pluginName)) + if err != nil { + return fmt.Errorf("absolute path: %w", err) + } + + m.logger.Debug("Executing plugin", slog.String("plugin", pluginName), slog.Any("args", args)) + cmd := exec.CommandContext(ctx, absPath, args...) + + cmd.Env = m.pluginRunEnv(contract) + cmd.Stdin = os.Stdin + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + + // On ctx cancellation (d8's SIGINT/SIGTERM handling) forward SIGTERM and give + // the plugin a grace period. The CommandContext default is an immediate SIGKILL + // that would deny the plugin any cleanup. The terminal's own Ctrl-C reaches the + // child anyway via the process group. + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + cmd.WaitDelay = pluginStopGracePeriod + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() > 0 { + // The plugin already reported its failure on the inherited stderr; do + // not wrap it in another error line - propagate its exact exit code. + os.Exit(exitErr.ExitCode()) + } + + return fmt.Errorf("plugin run: %w", err) + } + + return nil +} + +// ensurePluginRequirements enforces the plugin's contract requirements before +// launch (ADR: check before run, not only at install). It is a hard gate, like +// install. The run is blocked when: +// - a Kubernetes/Deckhouse/module/plugin requirement is unsatisfied, or +// - the cluster is unreachable and the plugin declares cluster requirements. +// +// The wrapper forwards flags to the plugin, so the override here is the env var +// D8_PLUGINS_SKIP_CLUSTER_CHECKS=1 (surfaced in the cluster-unreachable error). +func (m *Manager) ensurePluginRequirements(ctx context.Context, contract *internal.Plugin) error { + failed, err := m.validateRequirements(ctx, contract) + if err != nil { + return err + } + + if len(failed) == 0 { + return nil + } + + return failed.helpfulError(fmt.Sprintf("plugin %q requirements not satisfied", contract.Name)) +} + +// isLocalPluginInvocation reports whether the forwarded args are a purely local +// query (help/version/completion) that needs no cluster, so the requirement gate is +// skipped and a plugin's own help/version stays available offline. +func isLocalPluginInvocation(args []string) bool { + if len(args) == 0 { + return false + } + + switch args[0] { + case "--help", "-h", "--version", "-v", "help", "completion", + cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: + return true + } + + // `--help/-h` after a subcommand (e.g. `d8 system status --help`) is still a + // help query; past a literal `--` it is plugin payload, not a flag. + for _, arg := range args { + if arg == "--" { + break + } + + if arg == "--help" || arg == "-h" { + return true + } + } + + return false +} + +// pluginRunEnv returns the environment for the plugin process: the inherited +// environment plus the variables the contract asks d8 to provide. KUBECONFIG (the +// path d8 itself uses) and PLUGINS_CALLER (d8's own executable path) are injected; +// MODULE_CONFIG_INFO is not yet supported (it needs a defined module mapping in the +// contract). Unrecognized requested vars are left to pass through from the parent. +func (m *Manager) pluginRunEnv(contract *internal.Plugin) []string { + env := os.Environ() + if contract == nil { + return env + } + + for _, want := range contract.Env { + switch want.Name { + case envKubeconfig: + env = append(env, envKubeconfig+"="+d8flags.Kubeconfig) + case envPluginsCaller: + exe, err := os.Executable() + if err != nil { + m.logger.Debug("cannot resolve PLUGINS_CALLER", slog.String("plugin", contract.Name), slog.String("error", err.Error())) + + continue + } + + env = append(env, envPluginsCaller+"="+exe) + case envModuleConfigInfo: + // Declared in the protocol but not provided yet (needs a module mapping in + // the contract); passes through from the inherited environment if set. + m.logger.Debug("contract env var deferred, passed through if set", + slog.String("plugin", contract.Name), slog.String("env", want.Name)) + default: + // Unrecognized requested var, not part of the d8<->plugin protocol; passes + // through from the inherited environment if set. The help marks non-injected + // vars so a contract author is not misled. + m.logger.Debug("contract env var not provided by d8; passed through if set", + slog.String("plugin", contract.Name), slog.String("env", want.Name)) + } + } + + return env +} + +func (m *Manager) checkInstalled(commandName string) (bool, error) { + absPath, err := filepath.Abs(layout.CurrentLinkPath(m.pluginDirectory, commandName)) + if err != nil { + return false, fmt.Errorf("failed to compute absolute path: %w", err) + } + + _, err = os.Stat(absPath) + if os.IsNotExist(err) { + return false, nil + } + + if err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/plugins/run_test.go b/internal/plugins/run_test.go new file mode 100644 index 00000000..237eea17 --- /dev/null +++ b/internal/plugins/run_test.go @@ -0,0 +1,157 @@ +/* +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" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +func envValue(env []string, key string) (string, bool) { + for _, kv := range env { + if name, val, ok := strings.Cut(kv, "="); ok && name == key { + return val, true + } + } + + return "", false +} + +func TestPluginRunEnvInjectsRequestedVars(t *testing.T) { + m := testManager() + contract := &internal.Plugin{ + Name: "p", + Env: []internal.EnvVar{ + {Name: "KUBECONFIG"}, + {Name: "PLUGINS_CALLER"}, + {Name: "MODULE_CONFIG_INFO"}, // deferred - must not crash, not injected + }, + } + + env := m.pluginRunEnv(contract) + + kubeconfig, ok := envValue(env, "KUBECONFIG") + assert.True(t, ok, "KUBECONFIG is injected when requested") + assert.NotEmpty(t, kubeconfig) + + caller, ok := envValue(env, "PLUGINS_CALLER") + assert.True(t, ok, "PLUGINS_CALLER is injected when requested") + assert.NotEmpty(t, caller, "PLUGINS_CALLER points at the d8 executable") + + _, ok = envValue(env, "MODULE_CONFIG_INFO") + assert.False(t, ok, "MODULE_CONFIG_INFO is deferred, not injected") +} + +func TestPluginRunEnvNilContractIsInherited(t *testing.T) { + m := testManager() + env := m.pluginRunEnv(nil) + assert.NotEmpty(t, env, "a nil contract yields the inherited environment") +} + +func TestPluginRunEnvOnlyRequestedVars(t *testing.T) { + m := testManager() + // A contract that requests nothing must not inject PLUGINS_CALLER/KUBECONFIG. + env := m.pluginRunEnv(&internal.Plugin{Name: "p"}) + _, ok := envValue(env, "PLUGINS_CALLER") + assert.False(t, ok, "PLUGINS_CALLER is injected only when the contract requests it") +} + +func TestIsLocalPluginInvocation(t *testing.T) { + for _, args := range [][]string{ + {"--help"}, {"-h"}, {"--version"}, {"-v"}, {"help"}, {"completion", "bash"}, {"__complete"}, + // `--help` after a subcommand is still a help query. + {"server", "--help"}, {"status", "-h"}, + } { + assert.True(t, isLocalPluginInvocation(args), "%v is local", args) + } + + for _, args := range [][]string{ + {}, {"secret", "put"}, + // past a literal `--` a help token is plugin payload, not a flag. + {"run", "--", "--help"}, + } { + assert.False(t, isLocalPluginInvocation(args), "%v needs the gate", args) + } +} + +func TestEnsurePluginRequirementsNoRequirements(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + + // A contract with no cluster/plugin requirements passes without cluster access. + require.NoError(t, m.ensurePluginRequirements(context.Background(), &internal.Plugin{Name: "p"})) +} + +func TestEnsurePluginRequirementsBlocksOnViolation(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + + contract := &internal.Plugin{ + Name: "p", + Requirements: internal.Requirements{Kubernetes: internal.KubernetesRequirement{Constraint: ">= 1.30"}}, + } + + err := m.ensurePluginRequirements(context.Background(), contract) + require.Error(t, err, "an unsatisfied Kubernetes requirement blocks the run") + assert.Contains(t, err.Error(), "1.30") +} + +func TestEnsurePluginRequirementsReportsMissingDependency(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + + contract := &internal.Plugin{ + Name: "p", + Requirements: internal.Requirements{ + Plugins: internal.PluginRequirementsGroup{ + Mandatory: []internal.PluginRequirement{{Name: "delivery", Constraint: ">= 1.0.0"}}, + }, + }, + } + + err := m.ensurePluginRequirements(context.Background(), contract) + require.Error(t, err, "a missing mandatory plugin dependency blocks the run") + + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he) + + _, ok := findSuggestion(he, "delivery is not installed") + assert.True(t, ok, "the run-time gate names the missing dependency") +} + +func TestEnsurePluginRequirementsPassesWhenSatisfied(t *testing.T) { + m := testManager() + m.pluginDirectory = t.TempDir() + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.31.0")} + + contract := &internal.Plugin{ + Name: "p", + Requirements: internal.Requirements{Kubernetes: internal.KubernetesRequirement{Constraint: ">= 1.30"}}, + } + + assert.NoError(t, m.ensurePluginRequirements(context.Background(), contract)) +} diff --git a/internal/plugins/select.go b/internal/plugins/select.go new file mode 100644 index 00000000..6bc50b5d --- /dev/null +++ b/internal/plugins/select.go @@ -0,0 +1,339 @@ +/* +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 plugins + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/deckhouse/deckhouse-cli/internal" + d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +// PluginContract fetches a plugin contract, memoizing it per name@tag for the +// duration of the command. Requirements-aware selection probes several versions' +// contracts, and the chosen one is fetched again by the install pipeline; the +// cache keeps that to a single pull per version. +func (m *Manager) PluginContract(ctx context.Context, pluginName, tag string) (*internal.Plugin, error) { + key := pluginName + "@" + tag + + if cached, ok := m.contractCache[key]; ok { + return cached, nil + } + + contract, err := m.service.GetPluginContract(ctx, pluginName, tag) + if err != nil { + return nil, err + } + + if m.contractCache == nil { + m.contractCache = make(map[string]*internal.Plugin) + } + + m.contractCache[key] = contract + + return contract, nil +} + +// listTags returns a plugin's published tags, memoized per name for the command +// run. The dependency planner probes the same dep across several candidate paths, +// so this keeps the listing to one registry call per plugin. +func (m *Manager) listTags(ctx context.Context, pluginName string) ([]string, error) { + if tags, ok := m.tagsCache[pluginName]; ok { + return tags, nil + } + + tags, err := m.service.ListPluginTags(ctx, pluginName) + if err != nil { + return nil, err + } + + if m.tagsCache == nil { + m.tagsCache = make(map[string][]string) + } + + m.tagsCache[pluginName] = tags + + return tags, nil +} + +// deepCheckFunc decides whether a cluster-compatible candidate is acceptable. It +// returns ok=false with a structured reason to skip to an older version, or err +// for an operational failure that must stop selection (never masked as "try older"). +type deepCheckFunc func(ctx context.Context, contract *internal.Plugin) (ok bool, reason *unsatisfiableReason, err error) + +// rejectedCandidate is a version skipped during selection, kept with its +// structured reason so the terminal error can group dependency problems. +type rejectedCandidate struct { + version string + reason *unsatisfiableReason +} + +// selectCompatible walks stable versions newest->oldest and returns the first that +// (a) matches constraint (nil = any), (b) is cluster-compatible, and (c) passes +// deepCheck (nil = skip). It returns: +// - (version, rejected, nil) on success - rejected lists the newer versions skipped, +// - (nil, rejected, nil) when no candidate qualifies (the caller reports it), +// - (nil, nil, err) only on an operational failure (cluster/contract hard error). +// +// A too-new release needing a newer cluster is skipped for an older, working one. +// Genuine pre-releases (rc/alpha/beta) are excluded (install them via --version). +// Scope of the built-in checks is cluster-only (Kubernetes/Deckhouse/modules); +// plugin->plugin dependency resolvability is layered in via deepCheck (the planner). +func (m *Manager) selectCompatible( + ctx context.Context, + pluginName string, + tags []string, + constraint *semver.Constraints, + deepCheck deepCheckFunc, +) (*semver.Version, []rejectedCandidate, error) { + candidates := stableVersions(sortedSemverDesc(tags)) + + rejected := make([]rejectedCandidate, 0, len(candidates)) + + for _, version := range candidates { + if constraint != nil && !constraint.Check(version) { + continue + } + + contract, err := m.PluginContract(ctx, pluginName, version.Original()) + if err != nil { + // GetPluginContract has no typed not-found, so a transient registry error + // is indistinguishable from "no contract"; both demote to an older version. + m.logger.Warn("skipping version: contract unavailable", + slog.String("plugin", pluginName), slog.String("version", version.Original()), slog.String("error", err.Error())) + rejected = append(rejected, rejectedCandidate{version.Original(), &unsatisfiableReason{kind: reasonContractUnavailable, detail: "contract unavailable"}}) + + continue + } + + compatible, reason, err := m.clusterCompatible(ctx, contract) + if err != nil { + // Cluster unreachable or a broken contract (operational) - hard stop, same + // as enforcement; do not silently mask it by trying an older version. + return nil, nil, err + } + + if !compatible { + rejected = append(rejected, rejectedCandidate{version.Original(), &unsatisfiableReason{kind: reasonClusterIncompatible, detail: reason}}) + + continue + } + + if deepCheck != nil { + ok, reason, err := deepCheck(ctx, contract) + if err != nil { + return nil, nil, err + } + + if !ok { + rejected = append(rejected, rejectedCandidate{version.Original(), reason}) + + continue + } + } + + return version, rejected, nil + } + + return nil, rejected, nil +} + +// noCompatibleError builds the terminal "nothing usable" error as a HelpfulError. +// When every rejection is dependency-related it leads with "unresolved +// dependencies" and one suggestion per missing dependency; otherwise it falls back +// to a per-version listing. The top-level handler renders it. +func noCompatibleError(pluginName string, rejected []rejectedCandidate) error { + if len(rejected) == 0 { + return &diagnostic.HelpfulError{ + 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 <version>", pluginName)}, + }}, + } + } + + if allDependencyReasons(rejected) { + return &diagnostic.HelpfulError{ + Category: fmt.Sprintf("cannot install plugin %q: unresolved dependencies", pluginName), + Suggestions: dependencySuggestions(rejected), + } + } + + suggestions := make([]diagnostic.Suggestion, 0, len(rejected)+1) + for _, rc := range rejected { + suggestions = append(suggestions, diagnostic.Suggestion{Cause: fmt.Sprintf("%s: %s", rc.version, rc.reason.summary())}) + } + + 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 <version>", pluginName), + }, + }) + + return &diagnostic.HelpfulError{ + Category: fmt.Sprintf("cannot install plugin %q: no installable version", pluginName), + Suggestions: suggestions, + } +} + +// allDependencyReasons reports whether every rejection is an unresolved-dependency +// problem (so the message can lead with "unresolved dependencies"). +func allDependencyReasons(rejected []rejectedCandidate) bool { + for _, rc := range rejected { + if rc.reason == nil || !rc.reason.kind.isDependency() { + return false + } + } + + return true +} + +// dependencySuggestions builds one suggestion per distinct unresolved dependency +// (deduped by kind+name, since several rejected versions often share one). +func dependencySuggestions(rejected []rejectedCandidate) []diagnostic.Suggestion { + seen := make(map[string]bool, len(rejected)) + suggestions := make([]diagnostic.Suggestion, 0, len(rejected)) + + for _, rc := range rejected { + key := fmt.Sprintf("%d:%s", rc.reason.kind, rc.reason.pluginName) + if seen[key] { + continue + } + + seen[key] = true + + suggestions = append(suggestions, dependencySuggestion(rc.reason)) + } + + return suggestions +} + +// dependencySuggestion renders one missing dependency as a cause + fix. +func dependencySuggestion(r *unsatisfiableReason) diagnostic.Suggestion { + dep := r.pluginName + + switch r.kind { + case reasonDepNotPublished: + return diagnostic.Suggestion{ + Cause: withChain(fmt.Sprintf("required plugin %q is not published", dep), r.path), + Solutions: []string{fmt.Sprintf("publish it under deckhouse-cli/plugins/%s", dep)}, + } + case reasonDepNoVersion: + cause := fmt.Sprintf("no compatible version of required plugin %q", dep) + if r.constraint != "" { + cause = fmt.Sprintf("no version of required plugin %q satisfies %s", dep, r.constraint) + } + + return diagnostic.Suggestion{ + Cause: withChain(cause, r.path), + Solutions: []string{fmt.Sprintf("publish a matching version of %q", dep)}, + } + case reasonDepCycle: + return diagnostic.Suggestion{ + Cause: fmt.Sprintf("dependency cycle: %s", strings.Join(r.path, " -> ")), + Solutions: []string{"break the cycle in the plugins' contracts"}, + } + default: + return diagnostic.Suggestion{Cause: r.summary()} + } +} + +// withChain appends "(needed by: a -> b -> c)" for a transitive dependency (the +// path is deeper than the direct request); a direct dependency needs no chain. +func withChain(cause string, path []string) string { + if len(path) > 2 { + return cause + fmt.Sprintf("\n (needed by: %s)", strings.Join(path, " -> ")) + } + + return cause +} + +// clusterCompatible reports whether the plugin's cluster-side requirements are +// met, reusing the enforcement validators (clusterChecks) read-only. A genuine +// unmet requirement yields (false, reason, nil). +// The err return covers cases where compatibility cannot be determined (cluster +// unreachable) or an operational contract error (e.g. a malformed constraint). +// These must NOT be masked as merely "incompatible" and trigger a downgrade. +func (m *Manager) clusterCompatible(ctx context.Context, plugin *internal.Plugin) (bool, string, error) { + if !requirements.HasClusterRequirements(plugin) || d8flags.SkipClusterChecks { + return true, "", nil + } + + state, err := m.clusterState(ctx) + if err != nil { + return false, "", fmt.Errorf("cannot reach the cluster to select a compatible version "+ + "(use --skip-cluster-checks to pick the latest regardless): %w", err) + } + + for _, check := range m.clusterChecks() { + if err := check.Run(plugin, state); err != nil { + if requirements.IsUnmet(err) { + return false, err.Error(), nil + } + + return false, "", fmt.Errorf("%s: %w", check.Name, err) + } + } + + return true, "", nil +} + +// stableVersions drops genuine pre-releases (rc/alpha/beta), keeping CI/build +// markers like "v1.77.0-main"; the default pick should not land on a pre-release. +func stableVersions(versions []*semver.Version) []*semver.Version { + stable := make([]*semver.Version, 0, len(versions)) + + for _, version := range versions { + if version.Prerelease() != "" && requirements.IsGenuinePrerelease(version.Prerelease()) { + continue + } + + stable = append(stable, version) + } + + return stable +} + +// sortedSemverDesc parses tags as semver, drops the unparseable ones, and returns +// them sorted newest first. +func sortedSemverDesc(tags []string) []*semver.Version { + versions := make([]*semver.Version, 0, len(tags)) + + for _, tag := range tags { + version, err := semver.NewVersion(tag) + if err != nil { + continue + } + + versions = append(versions, version) + } + + sort.Slice(versions, func(i, j int) bool { return versions[i].GreaterThan(versions[j]) }) + + return versions +} diff --git a/internal/plugins/select_test.go b/internal/plugins/select_test.go new file mode 100644 index 00000000..bf85a9ef --- /dev/null +++ b/internal/plugins/select_test.go @@ -0,0 +1,200 @@ +/* +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 plugins + +import ( + "context" + "fmt" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal" + d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" +) + +// fakeSelectSource is a pluginSource returning fixed tags and per-tag contracts, +// counting GetPluginContract calls so cache behaviour can be asserted. +type fakeSelectSource struct { + tags []string + contracts map[string]*internal.Plugin + contractCalls map[string]int +} + +func (f *fakeSelectSource) ListPluginTags(context.Context, string) ([]string, error) { + return f.tags, nil +} + +func (f *fakeSelectSource) GetPluginContract(_ context.Context, _, tag string) (*internal.Plugin, error) { + if f.contractCalls != nil { + f.contractCalls[tag]++ + } + + contract, ok := f.contracts[tag] + if !ok { + return nil, fmt.Errorf("no contract for %s", tag) + } + + return contract, nil +} + +func (f *fakeSelectSource) ExtractPlugin(context.Context, string, string, string) error { + return nil +} + +func k8sContract(version, constraint string) *internal.Plugin { + return &internal.Plugin{ + Name: "p", + Version: version, + Requirements: internal.Requirements{Kubernetes: internal.KubernetesRequirement{Constraint: constraint}}, + } +} + +// selectLatest exercises the production cluster-only descent: newest stable, +// cluster-compatible version, or an error when none qualifies. +func selectLatest(t *testing.T, m *Manager, tags []string) (*semver.Version, error) { + t.Helper() + + version, rejected, err := m.selectCompatible(context.Background(), "p", tags, nil, nil) + if err != nil { + return nil, err + } + + if version == nil { + return nil, noCompatibleError("p", rejected) + } + + return version, nil +} + +func TestSortedSemverDesc(t *testing.T) { + got := sortedSemverDesc([]string{"v1.0.0", "v2.0.0", "latest", "v1.5.0"}) + + require.Len(t, got, 3, "non-semver tags are dropped") + assert.Equal(t, "2.0.0", got[0].String()) + assert.Equal(t, "1.5.0", got[1].String()) + assert.Equal(t, "1.0.0", got[2].String()) +} + +func TestSelectLatestCompatiblePicksNewestCompatible(t *testing.T) { + tags := []string{"v1.0.0", "v1.1.0", "v1.2.0"} + m := testManager() + m.service = &fakeSelectSource{tags: tags, contracts: map[string]*internal.Plugin{ + "v1.2.0": k8sContract("v1.2.0", ">= 99.0"), // needs a newer cluster - skipped + "v1.1.0": k8sContract("v1.1.0", ">= 1.20"), // compatible + "v1.0.0": k8sContract("v1.0.0", ""), + }} + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + + got, err := selectLatest(t, m, tags) + require.NoError(t, err) + assert.Equal(t, "v1.1.0", got.Original(), "skips the too-new v1.2.0, picks newest compatible") +} + +func TestSelectLatestCompatibleNewestWhenAllCompatible(t *testing.T) { + tags := []string{"v1.0.0", "v1.2.0", "v1.1.0"} + m := testManager() + m.service = &fakeSelectSource{tags: tags, contracts: map[string]*internal.Plugin{ + "v1.2.0": k8sContract("v1.2.0", ">= 1.20"), + "v1.1.0": k8sContract("v1.1.0", ">= 1.20"), + "v1.0.0": k8sContract("v1.0.0", ">= 1.20"), + }} + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + + got, err := selectLatest(t, m, tags) + require.NoError(t, err) + assert.Equal(t, "v1.2.0", got.Original()) +} + +func TestSelectLatestCompatibleNoneCompatible(t *testing.T) { + tags := []string{"v1.0.0", "v1.1.0"} + m := testManager() + m.service = &fakeSelectSource{tags: tags, contracts: map[string]*internal.Plugin{ + "v1.1.0": k8sContract("v1.1.0", ">= 99.0"), + "v1.0.0": k8sContract("v1.0.0", ">= 99.0"), + }} + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + + _, err := selectLatest(t, m, tags) + require.Error(t, err) +} + +func TestSelectLatestCompatibleSkipChecksPicksNewest(t *testing.T) { + prev := d8flags.SkipClusterChecks + t.Cleanup(func() { d8flags.SkipClusterChecks = prev }) + d8flags.SkipClusterChecks = true + + tags := []string{"v1.0.0", "v2.0.0"} + m := testManager() + m.service = &fakeSelectSource{tags: tags, contracts: map[string]*internal.Plugin{ + "v2.0.0": k8sContract("v2.0.0", ">= 99.0"), // would be incompatible if checked + "v1.0.0": k8sContract("v1.0.0", ""), + }} + // No clusterStateCache and no cluster: skip must avoid consulting it at all. + + got, err := selectLatest(t, m, tags) + require.NoError(t, err) + assert.Equal(t, "v2.0.0", got.Original()) +} + +func TestSelectLatestCompatibleExcludesPrereleases(t *testing.T) { + // The newest tag is a genuine pre-release; the default pick must skip it. + tags := []string{"v1.0.0", "v2.0.0-rc.1"} + m := testManager() + m.service = &fakeSelectSource{tags: tags, contracts: map[string]*internal.Plugin{ + "v2.0.0-rc.1": {Name: "p", Version: "v2.0.0-rc.1"}, + "v1.0.0": {Name: "p", Version: "v1.0.0"}, + }} + + got, err := selectLatest(t, m, tags) + require.NoError(t, err) + assert.Equal(t, "v1.0.0", got.Original(), "pre-release excluded from default pick") +} + +func TestSelectLatestCompatibleMalformedContractHardStops(t *testing.T) { + // A malformed constraint in the NEWEST contract is operational: selection must + // hard-stop, not silently downgrade to an older version. + tags := []string{"v1.0.0", "v2.0.0"} + m := testManager() + m.service = &fakeSelectSource{tags: tags, contracts: map[string]*internal.Plugin{ + "v2.0.0": k8sContract("v2.0.0", "garbage-constraint"), + "v1.0.0": k8sContract("v1.0.0", ">= 1.0"), + }} + m.clusterStateCache = &requirements.ClusterState{Kubernetes: semver.MustParse("v1.28.3")} + + _, err := selectLatest(t, m, tags) + require.Error(t, err, "operational error must not be masked as incompatibility") +} + +func TestPluginContractCachesPerTag(t *testing.T) { + calls := map[string]int{} + m := testManager() + m.service = &fakeSelectSource{ + contracts: map[string]*internal.Plugin{"v1.0.0": {Name: "p", Version: "v1.0.0"}}, + contractCalls: calls, + } + + for range 3 { + _, err := m.PluginContract(context.Background(), "p", "v1.0.0") + require.NoError(t, err) + } + + assert.Equal(t, 1, calls["v1.0.0"], "contract fetched once, then served from cache") +} diff --git a/internal/plugins/source.go b/internal/plugins/source.go new file mode 100644 index 00000000..613e7a5d --- /dev/null +++ b/internal/plugins/source.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 plugins + +import ( + "context" + + "github.com/deckhouse/deckhouse-cli/internal" +) + +// 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. +type pluginSource interface { + 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 +} diff --git a/internal/plugins/update.go b/internal/plugins/update.go new file mode 100644 index 00000000..11840734 --- /dev/null +++ b/internal/plugins/update.go @@ -0,0 +1,118 @@ +/* +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 plugins + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +// 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 { + plugins, err := m.InstalledPluginNames() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to read plugins directory: %w", err) + } + + // 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. + if len(plugins) == 0 && m.switchToFallbackRoot() { + if plugins, err = m.InstalledPluginNames(); err != nil { + return fmt.Errorf("failed to read plugins directory: %w", err) + } + } + + // Keep going on a per-plugin failure so one plugin (e.g. one gated by an + // unreachable cluster requirement) does not block updating the rest; + // report the failures together at the end. + var failed []string + + for _, plugin := range plugins { + if err := m.InstallPlugin(ctx, plugin); 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 + if errors.As(err, &he) { + fmt.Printf("✗ %s:\n%s", plugin, he.Format()) + } else { + fmt.Printf("✗ %s: %v\n", plugin, err) + } + + failed = append(failed, plugin) + } + } + + if len(failed) > 0 { + return fmt.Errorf("failed to update %d plugin(s): %s", len(failed), strings.Join(failed, ", ")) + } + + return nil +} + +// switchToFallbackRoot retargets m to the home fallback root (~/.deckhouse-cli) +// when it differs from the configured root and actually holds an install. +// Reports whether the switch happened. +func (m *Manager) switchToFallbackRoot() bool { + fallback, err := layout.HomeFallbackPath() + if err != nil { + return false + } + + if fallback == m.pluginDirectory || !layout.RootHasInstall(fallback) { + return false + } + + m.pluginDirectory = fallback + + return true +} + +// InstalledPluginNames returns the plugins that are actually installed under the +// plugins root - a directory with a `current` symlink. A leftover directory from a +// 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 +} diff --git a/internal/plugins/update_test.go b/internal/plugins/update_test.go new file mode 100644 index 00000000..8cdd8bd5 --- /dev/null +++ b/internal/plugins/update_test.go @@ -0,0 +1,93 @@ +/* +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" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" +) + +func TestUpdateAllSkipsGhostDirsWithoutInstall(t *testing.T) { + // Isolate the home fallback so a real ~/.deckhouse-cli does not leak in. + t.Setenv("HOME", t.TempDir()) + + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + + src := &fakeInstallSource{ + contract: &internal.Plugin{Name: "ghost", Version: "v1.0.0"}, + extract: func(dest string) error { return os.WriteFile(dest, []byte("x"), 0o755) }, + } + m.service = src + + // A ghost dir left by a failed install: a version dir exists but there is no + // `current` symlink. It is NOT an installed plugin and must not become a fresh + // install of something the user never had. + require.NoError(t, os.MkdirAll(layout.VersionDir(root, "ghost", 1), 0o755)) + + require.NoError(t, m.UpdateAll(context.Background()), "a ghost dir is skipped, not treated as an update failure") + assert.Empty(t, src.listedTags, "no install was attempted for a plugin that is not installed") +} + +func TestInstalledPluginNames(t *testing.T) { + root := t.TempDir() + m := testManager() + m.pluginDirectory = root + + installPluginFixture(t, root, "real", 1) + require.NoError(t, os.MkdirAll(layout.VersionDir(root, "ghost", 1), 0o755)) + + names, err := m.InstalledPluginNames() + require.NoError(t, err) + assert.Equal(t, []string{"real"}, names, "only plugins with a current symlink count as installed") +} + +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 + // still find (and update) the fallback install. + t.Setenv("HOME", t.TempDir()) + + fallback, err := layout.HomeFallbackPath() + require.NoError(t, err) + installPluginFixture(t, fallback, "homeplugin", 1) + + m := testManager() + m.pluginDirectory = t.TempDir() + + src := &fakeInstallSource{ + contract: &internal.Plugin{Name: "homeplugin", Version: "v1.0.0"}, + tags: []string{"v1.0.0"}, + contractByTag: map[string]*internal.Plugin{ + "v1.0.0": {Name: "homeplugin", Version: "v1.0.0"}, + }, + extract: func(dest string) error { return os.WriteFile(dest, []byte("#!/bin/sh\necho v1.0.0\n"), 0o755) }, + } + m.service = src + + require.NoError(t, m.UpdateAll(context.Background())) + assert.Equal(t, []string{"homeplugin"}, src.listedTags, "the fallback install is an update target") + assert.Equal(t, fallback, m.pluginDirectory, "the run switched to the fallback root") +} diff --git a/internal/plugins/validators.go b/internal/plugins/validators.go new file mode 100644 index 00000000..df77ad00 --- /dev/null +++ b/internal/plugins/validators.go @@ -0,0 +1,398 @@ +/* +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 plugins + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "sort" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + + "github.com/deckhouse/deckhouse-cli/internal" + d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/internal/plugins/layout" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" + "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// clusterProbeTimeout bounds the cluster snapshot. Discovery().ServerVersion() +// has no context variant, so the bound is applied via rest.Config.Timeout. +const clusterProbeTimeout = 30 * time.Second + +// InstalledPluginContract reads the cached contract from +// <plugin-dir>/cache/contracts/<plugin>.json and converts it to a domain object. +func (m *Manager) InstalledPluginContract(pluginName string) (*internal.Plugin, error) { + contractFile := layout.ContractFile(m.pluginDirectory, pluginName) + + file, err := os.Open(contractFile) + if err != nil { + return nil, fmt.Errorf("failed to read contract file: %w", err) + } + defer file.Close() + + contract := new(service.PluginContract) + dec := json.NewDecoder(file) + + err = dec.Decode(contract) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal contract: %w", err) + } + + return service.ContractToDomain(contract), nil +} + +// getInstalledPluginVersion runs the installed plugin's current binary and parses +// its reported version. It delegates to pluginBinaryVersion so the probe logic and +// its timeout are shared with the install path (no duplicate, no unbounded exec). +func (m *Manager) getInstalledPluginVersion(pluginName string) (*semver.Version, error) { + ctx, cancel := context.WithTimeout(context.Background(), pluginProbeTimeout) + defer cancel() + + return pluginBinaryVersion(ctx, layout.CurrentLinkPath(m.pluginDirectory, pluginName)) +} + +// LatestVersion lists tags from the registry for a plugin and returns the +// highest STABLE semver version - the same notion of "latest" that install +// selection uses (select.go), so `plugins list`/`contract` never advertise a +// pre-release that a default install would not pick. +func (m *Manager) LatestVersion(ctx context.Context, pluginName string) (*semver.Version, error) { + versions, err := m.listTags(ctx, pluginName) + if err != nil { + return nil, fmt.Errorf("failed to list plugin tags: %w", err) + } + + candidates := stableVersions(sortedSemverDesc(versions)) + if len(candidates) == 0 { + return nil, fmt.Errorf("no stable versions found for plugin %q", pluginName) + } + + return candidates[0], nil +} + +// failedConstraints holds plugin requirements that were not satisfied during +// installation: a nil value means the plugin is missing entirely, a non-nil +// value carries the constraint that the currently installed version fails. +type failedConstraints map[string]*semver.Constraints + +// helpfulError turns unsatisfied requirements into a HelpfulError. The top-level +// handler (cmd/d8/root.go) renders it with semantic color: one "cause -> fix" +// pair per dependency. +// +// nil value = dependency missing; non-nil = installed but fails the constraint. +func (fc failedConstraints) helpfulError(category string) *diagnostic.HelpfulError { + names := make([]string, 0, len(fc)) + for name := range fc { + names = append(names, name) + } + + sort.Strings(names) + + suggestions := make([]diagnostic.Suggestion, 0, len(names)) + + for _, name := range names { + // Defense-in-depth: requirement names come from contract.yaml. + safe := printable(name) + + 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)}, + }) + } 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 <version>", safe)}, + }) + } + } + + return &diagnostic.HelpfulError{Category: category, Suggestions: suggestions} +} + +func (m *Manager) validateRequirements(ctx context.Context, plugin *internal.Plugin) (failedConstraints, error) { + m.logger.Debug("validating plugin requirements", slog.String("plugin", plugin.Name)) + + if err := m.validatePluginConflicts(plugin); err != nil { + return nil, fmt.Errorf("plugin conflicts: %w", err) + } + + failedConstraints, err := m.validatePluginRequirementMandatory(plugin) + if err != nil { + return nil, fmt.Errorf("plugin requirements (mandatory): %w", err) + } + + if err := m.validatePluginRequirementConditional(plugin); err != nil { + return nil, fmt.Errorf("plugin requirements (conditional): %w", err) + } + + if err := m.validateClusterRequirements(ctx, plugin); err != nil { + return nil, err + } + + return failedConstraints, nil +} + +// validatePluginConflicts checks that installing the plugin does not violate any +// constraint placed on it by already-installed plugins. +func (m *Manager) validatePluginConflicts(plugin *internal.Plugin) error { + contractDir, err := os.ReadDir(layout.ContractsDir(m.pluginDirectory)) + if err != nil && errors.Is(err, os.ErrNotExist) { + // No contracts dir yet: no installed plugins, nothing to conflict with. + m.logger.Debug("no installed plugins, skipping conflict check") + return nil + } + + if err != nil { + return fmt.Errorf("failed to read contract directory: %w", err) + } + + for _, contractFile := range contractDir { + pluginName := strings.TrimSuffix(contractFile.Name(), layout.ContractFileExt) + + contract, err := m.InstalledPluginContract(pluginName) + if err != nil { + return fmt.Errorf("failed to get installed plugin contract: %w", err) + } + + err = validatePluginConflict(plugin, contract) + if err != nil { + return fmt.Errorf("validate plugin conflict: %w", err) + } + } + + return nil +} + +// validatePluginConflict checks whether installing `plugin` violates any +// constraint that the already-installed `installedPlugin` places on it. +// +// Both Mandatory and Conditional sections of installedPlugin's requirements +// are inspected - if an existing plugin requires us, we must satisfy its +// constraint regardless of whether the requirement is mandatory or conditional. +func validatePluginConflict(plugin *internal.Plugin, installedPlugin *internal.Plugin) error { + candidates := make([]internal.PluginRequirement, 0, + len(installedPlugin.Requirements.Plugins.Mandatory)+len(installedPlugin.Requirements.Plugins.Conditional)) + candidates = append(candidates, installedPlugin.Requirements.Plugins.Mandatory...) + candidates = append(candidates, installedPlugin.Requirements.Plugins.Conditional...) + + for _, requirement := range candidates { + if requirement.Name != plugin.Name { + continue + } + + constraint, err := semver.NewConstraint(requirement.Constraint) + if err != nil { + return fmt.Errorf("failed to parse constraint: %w", err) + } + // Check the NEW plugin's version against the constraint, not the installed + // plugin's version. + version, err := semver.NewVersion(plugin.Version) + if err != nil { + return fmt.Errorf("failed to parse version: %w", err) + } + + if !constraint.Check(version) { + return fmt.Errorf("installing plugin %s %s conflicts with existing plugin %s which requires %s %s", + plugin.Name, + plugin.Version, + installedPlugin.Name, + plugin.Name, + constraint.String()) + } + } + + return nil +} + +// validatePluginRequirementMandatory enforces mandatory plugin requirements: +// - if the dependency is not installed, record a soft failure in failedConstraints; +// - if the dependency is installed but fails the constraint, record a soft failure; +// - return a non-nil error only for operational failures (install check, version +// lookup, invalid constraint). +func (m *Manager) validatePluginRequirementMandatory(plugin *internal.Plugin) (failedConstraints, error) { + result := make(failedConstraints) + + for _, pluginRequirement := range plugin.Requirements.Plugins.Mandatory { + installed, err := m.checkInstalled(pluginRequirement.Name) + if err != nil { + return nil, fmt.Errorf("failed to check if plugin is installed: %w", err) + } + + if !installed { + m.logger.Debug("plugin requirement not installed", + slog.String("plugin", plugin.Name), + slog.String("requirement", pluginRequirement.Name)) + result[pluginRequirement.Name] = nil + + continue + } + + if pluginRequirement.Constraint == "" { + continue + } + + installedVersion, err := m.getInstalledPluginVersion(pluginRequirement.Name) + if err != nil { + return nil, fmt.Errorf("failed to get installed version: %w", err) + } + + constraint, err := semver.NewConstraint(pluginRequirement.Constraint) + if err != nil { + return nil, fmt.Errorf("failed to parse constraint: %w", err) + } + + if !constraint.Check(installedVersion) { + m.logger.Debug("plugin requirement not satisfied", + slog.String("plugin", plugin.Name), + slog.String("requirement", pluginRequirement.Name), + slog.String("constraint", pluginRequirement.Constraint), + slog.String("installed_version", installedVersion.Original())) + result[pluginRequirement.Name] = constraint + } + } + + return result, nil +} + +// validatePluginRequirementConditional enforces conditional plugin requirements: +// - if the dependency is not installed, skip silently; +// - if the dependency is installed but fails the constraint, return a hard error +func (m *Manager) validatePluginRequirementConditional(plugin *internal.Plugin) error { + for _, pluginRequirement := range plugin.Requirements.Plugins.Conditional { + installed, err := m.checkInstalled(pluginRequirement.Name) + if err != nil { + return fmt.Errorf("failed to check if plugin is installed: %w", err) + } + + if !installed { + continue + } + + if pluginRequirement.Constraint == "" { + continue + } + + installedVersion, err := m.getInstalledPluginVersion(pluginRequirement.Name) + if err != nil { + return fmt.Errorf("failed to get installed version: %w", err) + } + + constraint, err := semver.NewConstraint(pluginRequirement.Constraint) + if err != nil { + return fmt.Errorf("failed to parse constraint: %w", err) + } + + if !constraint.Check(installedVersion) { + return fmt.Errorf("conditional plugin requirement not satisfied: plugin %s %s installed but %s requires %s", + pluginRequirement.Name, + installedVersion.Original(), + plugin.Name, + pluginRequirement.Constraint) + } + } + + return nil +} + +// clusterState builds (and caches) the snapshot used by the cluster-side checks, +// using the kubeconfig identity from the plugin flags. A failure here is fatal for +// the caller: if a plugin declares a requirement we cannot verify, we must not +// install it blindly. +func (m *Manager) clusterState(ctx context.Context) (*requirements.ClusterState, error) { + if m.clusterStateCache != nil { + return m.clusterStateCache, nil + } + + restConfig, _, err := utilk8s.SetupK8sClientSet(d8flags.Kubeconfig, d8flags.KubeContext) + if err != nil { + return nil, fmt.Errorf("set up kubernetes client: %w", err) + } + + // Build both clients from a timeout-bounded config so the un-cancellable version + // probe cannot hang indefinitely on an unreachable API server. + restConfig.Timeout = clusterProbeTimeout + + kubeCl, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("set up kubernetes client: %w", err) + } + + dynamicCl, err := dynamic.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("set up dynamic client: %w", err) + } + + state, err := requirements.LoadClusterState(ctx, kubeCl, dynamicCl, m.logger) + if err != nil { + return nil, err + } + + m.clusterStateCache = state + + return state, nil +} + +// clusterChecks returns the cluster-side check list bound to m's logger. The +// ordered list is the single source of truth shared by enforcement +// (validateClusterRequirements) and selection (clusterCompatible). +func (m *Manager) clusterChecks() []requirements.Check { + return requirements.NewChecker(m.logger).Checks() +} + +// validateClusterRequirements enforces the cluster-side requirements (Kubernetes, +// Deckhouse and module versions). It inspects the cluster only when the plugin +// actually declares such a requirement, so plugins without them install without a +// cluster connection. A snapshot that cannot be built is a hard error: a declared +// requirement we cannot verify must not be silently ignored. +func (m *Manager) validateClusterRequirements(ctx context.Context, plugin *internal.Plugin) error { + if !requirements.HasClusterRequirements(plugin) { + return nil + } + + if d8flags.SkipClusterChecks { + m.logger.Warn("skipping cluster-side requirement checks (--skip-cluster-checks set)", + slog.String("plugin", plugin.Name)) + + return nil + } + + 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", + plugin.Name, err) + } + + for _, check := range m.clusterChecks() { + if err := check.Run(plugin, state); err != nil { + return fmt.Errorf("%s: %w", check.Name, err) + } + } + + return nil +} diff --git a/internal/plugins/cmd/validators_test.go b/internal/plugins/validators_test.go similarity index 62% rename from internal/plugins/cmd/validators_test.go rename to internal/plugins/validators_test.go index 323d2295..9f10074e 100644 --- a/internal/plugins/cmd/validators_test.go +++ b/internal/plugins/validators_test.go @@ -17,11 +17,68 @@ limitations under the License. package plugins import ( + "context" + "strings" "testing" + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/deckhouse/deckhouse-cli/internal" + d8flags "github.com/deckhouse/deckhouse-cli/internal/plugins/flags" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" ) +// findSuggestion returns the suggestion whose Cause matches exactly. Shared by the +// install- and run-time tests that assert on the requirement diagnostic. +func findSuggestion(he *diagnostic.HelpfulError, cause string) (diagnostic.Suggestion, bool) { + for _, s := range he.Suggestions { + if s.Cause == cause { + return s, true + } + } + + return diagnostic.Suggestion{}, false +} + +func TestFailedConstraintsHelpfulError(t *testing.T) { + wrongVersion, err := semver.NewConstraint(">=2.0.0") + require.NoError(t, err) + + fc := failedConstraints{ + "zeta": nil, // missing entirely + "alpha": wrongVersion, // installed but incompatible + } + + he := fc.helpfulError("header text") + assert.Equal(t, "header text", he.Category) + require.Len(t, he.Suggestions, 2) + + // Suggestions are sorted by dependency name: alpha before zeta. + assert.Equal(t, "alpha must satisfy >=2.0.0", he.Suggestions[0].Cause) + assert.Contains(t, strings.Join(he.Suggestions[0].Solutions, " "), "--version", + "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", + "a missing dep points at how to install it") +} + +func TestSkipClusterChecksDowngradesEnforcement(t *testing.T) { + prev := d8flags.SkipClusterChecks + t.Cleanup(func() { d8flags.SkipClusterChecks = prev }) + d8flags.SkipClusterChecks = true + + plugin := &internal.Plugin{Name: "p", Requirements: internal.Requirements{ + Kubernetes: internal.KubernetesRequirement{Constraint: ">= 99.0"}, + }} + + // With the escape hatch set, a plugin with a cluster requirement is not blocked + // and no cluster is contacted (validateClusterRequirements returns before clusterState). + require.NoError(t, testManager().validateClusterRequirements(context.Background(), plugin)) +} + // TestValidatePluginConflict_BugRegression covers the historical bug where the // reverse conflict check compared the *installed* plugin's version against its // own constraint on the new plugin (a tautology). With the fix the constraint diff --git a/internal/plugins/versions.go b/internal/plugins/versions.go new file mode 100644 index 00000000..2dfa2f4f --- /dev/null +++ b/internal/plugins/versions.go @@ -0,0 +1,51 @@ +/* +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" + "fmt" + + "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) { + 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 +} + +// InstalledVersionOrNil returns the active installed version of the +// plugin, or nil when the plugin is not installed or its version cannot be +// probed - best-effort: a version listing then simply carries no "current" marker. +func (m *Manager) InstalledVersionOrNil(pluginName string) *semver.Version { + if installed, _ := m.checkInstalled(pluginName); !installed { + return nil + } + + current, err := m.getInstalledPluginVersion(pluginName) + if err != nil { + return nil + } + + return current +} diff --git a/internal/rpp/client.go b/internal/rpp/client.go new file mode 100644 index 00000000..1a444202 --- /dev/null +++ b/internal/rpp/client.go @@ -0,0 +1,294 @@ +/* +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 rpp + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" + "unicode" + + "k8s.io/client-go/rest" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" +) + +const ( + headerAccept = "Accept" + mediaTypeJSON = "application/json" + + loggerName = "rpp" + + // maxTagsResponseBytes caps the tags JSON read so a misbehaving endpoint cannot + // make the client buffer an unbounded response; real tag lists are a few KiB. + maxTagsResponseBytes int64 = 4 << 20 + + // maxBodySnippetBytes bounds how much of an error response body is echoed + // into the error message. + maxBodySnippetBytes = 256 +) + +// options holds the TLS settings used to build the proxy HTTP transport. +type options struct { + caFile string + caData []byte + insecure bool +} + +// Option configures the proxy client transport. +type Option func(*options) + +// WithCAFile verifies the proxy TLS certificate against the CA bundle in the +// given PEM file, in addition to the system roots. Mutually exclusive with +// WithCAData and WithInsecureSkipTLSVerify. +func WithCAFile(path string) Option { + return func(o *options) { + o.caFile = path + } +} + +// WithCAData is WithCAFile with the PEM bytes supplied directly. Mutually +// exclusive with WithCAFile and WithInsecureSkipTLSVerify. +func WithCAData(pem []byte) Option { + return func(o *options) { + o.caData = pem + } +} + +// WithInsecureSkipTLSVerify disables proxy TLS verification. Intended for +// debugging only. Mutually exclusive with WithCAFile and WithCAData. +func WithInsecureSkipTLSVerify() Option { + return func(o *options) { + o.insecure = true + } +} + +// validate rejects contradictory TLS options instead of silently resolving them. +func (o options) validate() error { + if o.insecure && (o.caFile != "" || len(o.caData) > 0) { + return fmt.Errorf("%w: insecure TLS verification and a CA bundle are mutually exclusive", ErrUnsupportedConfig) + } + + if o.caFile != "" && len(o.caData) > 0 { + return fmt.Errorf("%w: WithCAFile and WithCAData are mutually exclusive", ErrUnsupportedConfig) + } + + return nil +} + +// Client talks to the registry-packages-proxy CLI routes, authenticating with +// the caller's kubeconfig identity. +type Client struct { + baseURL string + http *http.Client + logger *dkplog.Logger +} + +// New builds a Client whose requests carry the kubeconfig identity from +// restConfig. baseURL is the proxy endpoint root, for example +// "https://10.0.0.1:4219". +func New(baseURL string, restConfig *rest.Config, logger *dkplog.Logger, opts ...Option) (*Client, error) { + var o options + for _, opt := range opts { + opt(&o) + } + + if err := o.validate(); err != nil { + return nil, err + } + + if err := validateBaseURL(baseURL); err != nil { + return nil, err + } + + if o.caFile != "" { + pem, err := os.ReadFile(o.caFile) + if err != nil { + return nil, fmt.Errorf("read RPP CA file %q: %w", o.caFile, err) + } + + o.caData = pem + } + + httpClient, err := buildHTTPClient(restConfig, o) + if err != nil { + return nil, fmt.Errorf("build RPP HTTP client: %w", err) + } + + return newClient(baseURL, httpClient, logger), nil +} + +// NewWithHTTPClient builds a Client around a pre-built HTTP client. It is used in +// tests and by callers that construct the transport themselves. +func NewWithHTTPClient(baseURL string, httpClient *http.Client, logger *dkplog.Logger) *Client { + return newClient(baseURL, httpClient, logger) +} + +func newClient(baseURL string, httpClient *http.Client, logger *dkplog.Logger) *Client { + // Refuse redirects: the transport stamps the kubeconfig credential on every + // hop, so a redirect would replay it to whatever host the response names. + // A 3xx then surfaces as an unexpected-status error. + // The caller's client is copied, not mutated. + guarded := *httpClient + guarded.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + http: &guarded, + logger: logger.Named(loggerName), + } +} + +// validateBaseURL ensures the explicit endpoint is a usable https URL, so a +// misconfigured --rpp-endpoint fails with a clear message instead of an opaque +// transport error on the first request. +func validateBaseURL(raw string) error { + if raw == "" { + return fmt.Errorf("%w: endpoint is empty", ErrInvalidEndpoint) + } + + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidEndpoint, err) + } + + if parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("%w: %q must be an https URL with a host", ErrInvalidEndpoint, raw) + } + + return nil +} + +// ListTags returns the available tags (versions) of the image. +func (c *Client) ListTags(ctx context.Context, ref ImageRef) ([]string, error) { + c.logger.Debug("listing tags", slog.String("image", ref.String())) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+ref.tagsPath(), nil) + if err != nil { + return nil, fmt.Errorf("build list-tags request: %w", err) + } + + req.Header.Set(headerAccept, mediaTypeJSON) + + resp, err := c.do(req) + if err != nil { + return nil, err + } + + defer func() { _ = resp.Body.Close() }() + + var body tagListResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, maxTagsResponseBytes)).Decode(&body); err != nil { + return nil, fmt.Errorf("decode tags response for %q: %w", ref.String(), err) + } + + return body.Tags, nil +} + +// PullImage downloads the image tag as a gzipped tar stream (the binary, and the +// contract when present, are files inside it). The caller owns the returned +// reader and must close it. +// +// No integrity check: the proxy exposes only a manifest digest, not a hash of +// the gzip-tar body. Trust rests on the TLS-authenticated proxy channel. +// The caller may want to cap the read with an io.LimitReader. +func (c *Client) PullImage(ctx context.Context, ref ImageRef, tag string) (io.ReadCloser, error) { + if err := validateTag(tag); err != nil { + return nil, err + } + + c.logger.Debug("pulling image", slog.String("image", ref.String()), slog.String("tag", tag)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+ref.tagPath(tag), nil) + if err != nil { + return nil, fmt.Errorf("build pull request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, err + } + + return resp.Body, nil +} + +// do executes the request and, on a non-2xx status, closes the body and maps the +// status to a sentinel error. On success the response is returned with its body +// still open for the caller to consume. +func (c *Client) do(req *http.Request) (*http.Response, error) { + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", req.Method, req.URL.Path, err) + } + + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return resp, nil + } + + defer func() { _ = resp.Body.Close() }() + + return nil, statusError(req, resp) +} + +func statusError(req *http.Request, resp *http.Response) error { + switch resp.StatusCode { + case http.StatusNotFound: + return fmt.Errorf("%s %s: %w", req.Method, req.URL.Path, ErrNotFound) + case http.StatusUnauthorized: + return fmt.Errorf("%s %s: %w", req.Method, req.URL.Path, ErrUnauthorized) + case http.StatusForbidden: + return fmt.Errorf("%s %s: %w", req.Method, req.URL.Path, ErrForbidden) + } + + if resp.StatusCode >= http.StatusInternalServerError { + return fmt.Errorf("%s %s: %w (status %d)%s", req.Method, req.URL.Path, ErrUpstream, resp.StatusCode, bodySnippet(resp.Body)) + } + + return fmt.Errorf("%s %s: unexpected status %d%s", req.Method, req.URL.Path, resp.StatusCode, bodySnippet(resp.Body)) +} + +// bodySnippet returns a short printable fragment of an error response body - the +// proxy and its intermediaries put the actual reason there (e.g. kube-rbac-proxy +// authorization denials), which would otherwise be discarded. +func bodySnippet(r io.Reader) string { + raw, _ := io.ReadAll(io.LimitReader(r, maxBodySnippetBytes)) + + msg := strings.TrimSpace(string(raw)) + if msg == "" { + return "" + } + + // Flatten control characters (newlines of an HTML error page, ANSI noise) so + // the snippet stays a single readable error-message line. + msg = strings.Map(func(c rune) rune { + if unicode.IsControl(c) { + return ' ' + } + + return c + }, msg) + + return ": " + msg +} diff --git a/internal/rpp/client_construction_test.go b/internal/rpp/client_construction_test.go new file mode 100644 index 00000000..6450306c --- /dev/null +++ b/internal/rpp/client_construction_test.go @@ -0,0 +1,81 @@ +/* +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 rpp + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" +) + +func TestNewValidatesEndpoint(t *testing.T) { + tests := []struct { + name string + baseURL string + wantErr error + }{ + {name: "valid https", baseURL: "https://master:4219", wantErr: nil}, + {name: "empty", baseURL: "", wantErr: ErrInvalidEndpoint}, + {name: "missing scheme", baseURL: "master:4219", wantErr: ErrInvalidEndpoint}, + {name: "plain http", baseURL: "http://master:4219", wantErr: ErrInvalidEndpoint}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := New(tt.baseURL, &rest.Config{}, dkplog.NewNop()) + if tt.wantErr == nil { + require.NoError(t, err) + return + } + + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestNewRejectsContradictoryTLSOptions(t *testing.T) { + const endpoint = "https://master:4219" + + t.Run("insecure with CA data", func(t *testing.T) { + _, err := New(endpoint, &rest.Config{}, dkplog.NewNop(), + WithInsecureSkipTLSVerify(), WithCAData([]byte("pem"))) + require.ErrorIs(t, err, ErrUnsupportedConfig) + }) + + t.Run("CA file and CA data together", func(t *testing.T) { + _, err := New(endpoint, &rest.Config{}, dkplog.NewNop(), + WithCAFile("/tmp/ca.pem"), WithCAData([]byte("pem"))) + require.ErrorIs(t, err, ErrUnsupportedConfig) + }) +} + +func TestNewRejectsUnparseableCAData(t *testing.T) { + _, err := New("https://master:4219", &rest.Config{}, dkplog.NewNop(), + WithCAData([]byte("not a pem certificate"))) + require.ErrorIs(t, err, ErrInvalidCA) +} + +func TestNewRejectsCustomTransport(t *testing.T) { + cfg := &rest.Config{Transport: http.DefaultTransport} + + _, err := New("https://master:4219", cfg, dkplog.NewNop(), WithCAData([]byte("pem"))) + require.ErrorIs(t, err, ErrUnsupportedConfig) +} diff --git a/internal/rpp/client_http_test.go b/internal/rpp/client_http_test.go new file mode 100644 index 00000000..8f1c5521 --- /dev/null +++ b/internal/rpp/client_http_test.go @@ -0,0 +1,176 @@ +/* +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 rpp + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" +) + +func testClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + return NewWithHTTPClient(srv.URL, srv.Client(), dkplog.NewNop()) +} + +func TestClientListTags(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/images/deckhouse-cli/tags", r.URL.Path) + assert.Equal(t, mediaTypeJSON, r.Header.Get(headerAccept)) + + w.Header().Set("Content-Type", mediaTypeJSON) + _, _ = io.WriteString(w, `{"name":"deckhouse-cli","tags":["v0.13.0","v0.13.1"]}`) + }) + + tags, err := client.ListTags(context.Background(), CLIImage()) + require.NoError(t, err) + assert.Equal(t, []string{"v0.13.0", "v0.13.1"}, tags) +} + +func TestClientListTagsForPlugin(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/tags", r.URL.Path) + _, _ = io.WriteString(w, `{"name":"deckhouse-cli/plugins/stronghold","tags":["v1.0.0"]}`) + }) + + ref, err := PluginImage("stronghold") + require.NoError(t, err) + + tags, err := client.ListTags(context.Background(), ref) + require.NoError(t, err) + assert.Equal(t, []string{"v1.0.0"}, tags) +} + +func TestClientPullImage(t *testing.T) { + const payload = "fake-tar-gz-bytes" + + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/v1/images/deckhouse-cli/tags/v0.13.1", r.URL.Path) + + w.Header().Set("Content-Type", "application/x-gzip") + _, _ = io.WriteString(w, payload) + }) + + body, err := client.PullImage(context.Background(), CLIImage(), "v0.13.1") + require.NoError(t, err) + defer func() { _ = body.Close() }() + + got, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, payload, string(got)) +} + +func TestClientStatusErrorMapping(t *testing.T) { + tests := []struct { + name string + status int + wantErr error + }{ + {name: "not found", status: http.StatusNotFound, wantErr: ErrNotFound}, + {name: "unauthorized", status: http.StatusUnauthorized, wantErr: ErrUnauthorized}, + {name: "forbidden", status: http.StatusForbidden, wantErr: ErrForbidden}, + {name: "bad gateway", status: http.StatusBadGateway, wantErr: ErrUpstream}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + }) + + _, err := client.ListTags(context.Background(), CLIImage()) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +// authInjectingTransport mimics client-go's bearer round tripper: it stamps the +// credential on EVERY request that passes through the transport - redirect hops +// included, which is exactly why the client must not follow redirects. +type authInjectingTransport struct{ base http.RoundTripper } + +func (t authInjectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer secret") + + return t.base.RoundTrip(req) +} + +func TestClientRefusesRedirectsSoCredentialsCannotLeak(t *testing.T) { + var leaked bool + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + leaked = true + } + + _, _ = io.WriteString(w, `{"name":"deckhouse-cli","tags":["v1.0.0"]}`) + })) + t.Cleanup(target.Close) + + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound) + })) + t.Cleanup(redirector.Close) + + httpClient := &http.Client{Transport: authInjectingTransport{base: http.DefaultTransport}} + client := NewWithHTTPClient(redirector.URL, httpClient, dkplog.NewNop()) + + _, err := client.ListTags(context.Background(), CLIImage()) + require.Error(t, err, "a redirect from the proxy is a protocol violation, not something to follow") + assert.Contains(t, err.Error(), "302") + assert.False(t, leaked, "the kubeconfig bearer must never be replayed to a redirect target") +} + +func TestClientInvalidTagDoesNotCallServer(t *testing.T) { + client := testClient(t, func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("server must not be called for an invalid tag") + }) + + _, err := client.PullImage(context.Background(), CLIImage(), "with/slash") + require.ErrorIs(t, err, ErrInvalidImage) +} + +func TestClientListTagsRejectsOversizedResponse(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", mediaTypeJSON) + _, _ = io.WriteString(w, `{"name":"deckhouse-cli","tags":[`) + + filler := `"` + strings.Repeat("x", 1024) + `",` + for written := int64(0); written <= maxTagsResponseBytes; written += int64(len(filler)) { + _, _ = io.WriteString(w, filler) + } + + _, _ = io.WriteString(w, `"end"]}`) + }) + + _, err := client.ListTags(context.Background(), CLIImage()) + require.Error(t, err, "a response beyond the cap is a decode error, not an unbounded buffer") +} diff --git a/internal/rpp/connect.go b/internal/rpp/connect.go new file mode 100644 index 00000000..34d25b7e --- /dev/null +++ b/internal/rpp/connect.go @@ -0,0 +1,73 @@ +/* +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 rpp + +import ( + "context" + "log/slog" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" +) + +// NewClusterClient builds a Client for the proxy reachable from the given cluster +// connection. The endpoint is used as-is when set, otherwise discovered (Ingress +// preferred, pod IPs as fallback; see chooseDiscoveredEndpoint). +// caFile / insecure select TLS verification (mutually exclusive; New reports the +// contradiction). +func NewClusterClient( + ctx context.Context, + kube kubernetes.Interface, + restConfig *rest.Config, + logger *dkplog.Logger, + endpoint, caFile string, + insecure bool, +) (*Client, error) { + if endpoint == "" { + discovered, source, err := chooseDiscoveredEndpoint(ctx, kube) + if err != nil { + return nil, err + } + + logger.Debug("discovered registry-packages-proxy endpoint", + slog.String("endpoint", discovered), slog.String("discovered_via", source)) + + if source == "pod" { + // The pod fallback is a master-node IP: unreachable from outside the + // cluster network, and its certificate carries no IP SANs - say so + // before the connection fails with an opaque TLS/timeout error. + logger.Debug("no registry-packages-proxy Ingress found; the pod endpoint is reachable " + + "only from the cluster network and needs --rpp-insecure-skip-tls-verify (or pass --rpp-endpoint)") + } + + endpoint = discovered + } + + var opts []Option + + if insecure { + opts = append(opts, WithInsecureSkipTLSVerify()) + } + + if caFile != "" { + opts = append(opts, WithCAFile(caFile)) + } + + return New(endpoint, restConfig, logger, opts...) +} diff --git a/internal/rpp/doc.go b/internal/rpp/doc.go new file mode 100644 index 00000000..65053234 --- /dev/null +++ b/internal/rpp/doc.go @@ -0,0 +1,25 @@ +/* +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 rpp is a client for the Deckhouse registry-packages-proxy (RPP) CLI +// routes: GET /v1/images/<image>/tags and /v1/images/<image>/tags/<tag>. +// +// It lets deckhouse-cli list available versions of itself and its plugins and +// download their images. All traffic goes to the in-cluster proxy and is +// authenticated with the caller's kubeconfig identity; no separate registry +// credentials are needed, because the proxy fetches from the backing registry +// on the CLI's behalf. +package rpp diff --git a/internal/rpp/dto.go b/internal/rpp/dto.go new file mode 100644 index 00000000..1c322418 --- /dev/null +++ b/internal/rpp/dto.go @@ -0,0 +1,24 @@ +/* +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 rpp + +// tagListResponse is the JSON body of GET /v1/images/<image>/tags as served by +// the proxy cliHandler. +type tagListResponse struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} diff --git a/internal/rpp/endpoint.go b/internal/rpp/endpoint.go new file mode 100644 index 00000000..f9c6aab8 --- /dev/null +++ b/internal/rpp/endpoint.go @@ -0,0 +1,151 @@ +/* +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 rpp + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strconv" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const ( + // proxyNamespace is where registry-packages-proxy and its objects live. + proxyNamespace = "d8-cloud-instance-manager" + + // proxyPodSelector selects the registry-packages-proxy pods. + proxyPodSelector = "app=registry-packages-proxy" + + // proxyIngressName is the Ingress the registry-packages-proxy module creates for + // the public /v1/images route (host registry-packages-proxy.<publicDomain>). + proxyIngressName = "registry-packages-proxy" + + // proxyPort is the kube-rbac-proxy port that fronts the proxy on every master node. + proxyPort = 4219 + + // proxyScheme is the endpoint scheme (kube-rbac-proxy serves HTTPS). + proxyScheme = "https" +) + +// errIngressUnusable marks the Ingress lookup as "the API answered, but the +// Ingress is absent or has no host" - the only case where the in-cluster pod +// fallback is worth trying. A transport/TLS/auth failure reaching the API is NOT +// this: it would fail pod listing identically, so it is surfaced as-is. +var errIngressUnusable = errors.New("registry-packages-proxy ingress unusable") + +// chooseDiscoveredEndpoint resolves the proxy endpoint when none was given +// explicitly. It PREFERS the public Ingress (a valid TLS certificate, reachable +// from a workstation) and falls back to in-cluster pod IPs (which need +// --rpp-insecure-skip-tls-verify and cluster-network reachability). The second +// return value names the source ("ingress" / "pod") for logging. +// +// Only an unusable Ingress (see errIngressUnusable) triggers the pod fallback. +// Any other error is an API-leg failure, surfaced as ErrEndpointDiscovery. +func chooseDiscoveredEndpoint(ctx context.Context, kube kubernetes.Interface) (string, string, error) { + endpoint, err := discoverIngressEndpoint(ctx, kube) + if err == nil { + return endpoint, "ingress", nil + } + + if !errors.Is(err, errIngressUnusable) { + return "", "", fmt.Errorf("%w: %w", ErrEndpointDiscovery, err) + } + + endpoint, err = discoverEndpoint(ctx, kube) + if err != nil { + return "", "", fmt.Errorf("%w: %w", ErrEndpointDiscovery, err) + } + + return endpoint, "pod", nil +} + +// discoverIngressEndpoint returns the public proxy endpoint (https://<host>) taken +// from the registry-packages-proxy Ingress. This path has a valid TLS certificate +// and is reachable from outside the cluster - the right default for a workstation. +// +// An absent Ingress or one with no host yields errIngressUnusable, signalling the +// caller to try the in-cluster pod fallback. Any other error is returned raw so +// the caller can surface the API-leg failure instead of falling back. +func discoverIngressEndpoint(ctx context.Context, kube kubernetes.Interface) (string, error) { + ingress, err := kube.NetworkingV1().Ingresses(proxyNamespace).Get(ctx, proxyIngressName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return "", fmt.Errorf("%w: ingress %q not found", errIngressUnusable, proxyIngressName) + } + + return "", fmt.Errorf("get registry-packages-proxy ingress: %w", err) + } + + for _, rule := range ingress.Spec.Rules { + if rule.Host != "" { + return (&url.URL{Scheme: proxyScheme, Host: rule.Host}).String(), nil + } + } + + return "", fmt.Errorf("%w: ingress %q has no host", errIngressUnusable, proxyIngressName) +} + +// discoverEndpoint returns a proxy endpoint base URL from the first serving +// registry-packages-proxy pod (running, ready, with an IP), joined to the proxy +// port. Terminating and not-yet-ready pods are skipped. No failover: one serving +// pod is enough. +// +// This is a master-node pod IP, reachable from inside the cluster network. A +// workstation outside the cluster usually cannot reach it and should pass an +// explicit endpoint (for example the public Ingress) instead. +func discoverEndpoint(ctx context.Context, kube kubernetes.Interface) (string, error) { + pods, err := kube.CoreV1().Pods(proxyNamespace).List(ctx, metav1.ListOptions{LabelSelector: proxyPodSelector}) + if err != nil { + return "", fmt.Errorf("list registry-packages-proxy pods: %w", err) + } + + for i := range pods.Items { + pod := &pods.Items[i] + if !podIsServing(pod) { + continue + } + + base := url.URL{Scheme: proxyScheme, Host: net.JoinHostPort(pod.Status.PodIP, strconv.Itoa(proxyPort))} + + return base.String(), nil + } + + return "", fmt.Errorf("no ready registry-packages-proxy pods found in namespace %q", proxyNamespace) +} + +// podIsServing reports whether the pod is a usable proxy endpoint: running, not +// terminating, with an assigned IP and a Ready condition that is true. +func podIsServing(pod *corev1.Pod) bool { + if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil || pod.Status.PodIP == "" { + return false + } + + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + return condition.Status == corev1.ConditionTrue + } + } + + return false +} diff --git a/internal/rpp/endpoint_test.go b/internal/rpp/endpoint_test.go new file mode 100644 index 00000000..5b02829b --- /dev/null +++ b/internal/rpp/endpoint_test.go @@ -0,0 +1,157 @@ +/* +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 rpp + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" +) + +func proxyIngress(host string) *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: proxyIngressName, Namespace: proxyNamespace}, + Spec: networkingv1.IngressSpec{Rules: []networkingv1.IngressRule{{Host: host}}}, + } +} + +func proxyPod(name, podIP string, phase corev1.PodPhase, ready bool, terminating bool) *corev1.Pod { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: proxyNamespace, + Labels: map[string]string{"app": "registry-packages-proxy"}, + }, + Status: corev1.PodStatus{ + Phase: phase, + PodIP: podIP, + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: conditionStatus(ready)}, + }, + }, + } + + if terminating { + pod.DeletionTimestamp = &metav1.Time{} + } + + return pod +} + +func conditionStatus(ready bool) corev1.ConditionStatus { + if ready { + return corev1.ConditionTrue + } + + return corev1.ConditionFalse +} + +func TestDiscoverEndpoint(t *testing.T) { + kube := fake.NewSimpleClientset( + proxyPod("not-ready", "10.0.0.2", corev1.PodRunning, false, false), + proxyPod("terminating", "10.0.0.3", corev1.PodRunning, true, true), + proxyPod("pending", "10.0.0.4", corev1.PodPending, false, false), + proxyPod("no-ip", "", corev1.PodRunning, true, false), + proxyPod("serving", "10.0.0.1", corev1.PodRunning, true, false), + ) + + endpoint, err := discoverEndpoint(context.Background(), kube) + require.NoError(t, err) + assert.Equal(t, "https://10.0.0.1:4219", endpoint) +} + +func TestDiscoverEndpointNoneServing(t *testing.T) { + kube := fake.NewSimpleClientset( + proxyPod("not-ready", "10.0.0.2", corev1.PodRunning, false, false), + ) + + _, err := discoverEndpoint(context.Background(), kube) + require.Error(t, err) +} + +func TestDiscoverIngressEndpoint(t *testing.T) { + kube := fake.NewSimpleClientset(proxyIngress("registry-packages-proxy.example.com")) + + endpoint, err := discoverIngressEndpoint(context.Background(), kube) + require.NoError(t, err) + assert.Equal(t, "https://registry-packages-proxy.example.com", endpoint) +} + +func TestDiscoverIngressEndpointAbsent(t *testing.T) { + _, err := discoverIngressEndpoint(context.Background(), fake.NewSimpleClientset()) + require.Error(t, err) + assert.ErrorIs(t, err, errIngressUnusable, "an absent Ingress signals the pod fallback") +} + +func TestDiscoverIngressEndpointNoHost(t *testing.T) { + kube := fake.NewSimpleClientset(proxyIngress("")) + + _, err := discoverIngressEndpoint(context.Background(), kube) + require.Error(t, err) + assert.ErrorIs(t, err, errIngressUnusable, "an Ingress with no host signals the pod fallback") +} + +func TestChooseDiscoveredEndpointPrefersIngress(t *testing.T) { + kube := fake.NewSimpleClientset( + proxyIngress("registry-packages-proxy.example.com"), + proxyPod("serving", "10.0.0.1", corev1.PodRunning, true, false), + ) + + endpoint, source, err := chooseDiscoveredEndpoint(context.Background(), kube) + require.NoError(t, err) + assert.Equal(t, "https://registry-packages-proxy.example.com", endpoint) + assert.Equal(t, "ingress", source) +} + +func TestChooseDiscoveredEndpointFallsBackToPods(t *testing.T) { + // No Ingress -> fall back to in-cluster pod IPs. + kube := fake.NewSimpleClientset( + proxyPod("serving", "10.0.0.1", corev1.PodRunning, true, false), + ) + + endpoint, source, err := chooseDiscoveredEndpoint(context.Background(), kube) + require.NoError(t, err) + assert.Equal(t, "https://10.0.0.1:4219", endpoint) + assert.Equal(t, "pod", source) +} + +func TestChooseDiscoveredEndpointSurfacesAPIFailure(t *testing.T) { + // A transport/TLS failure reaching the API (not an absent Ingress) is surfaced + // as ErrEndpointDiscovery, not masked by falling back to pod listing - even + // when a serving pod exists. + kube := fake.NewSimpleClientset( + proxyPod("serving", "10.0.0.1", corev1.PodRunning, true, false), + ) + kube.PrependReactor("get", "ingresses", func(clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("tls: failed to verify certificate") + }) + + _, _, err := chooseDiscoveredEndpoint(context.Background(), kube) + require.Error(t, err) + assert.ErrorIs(t, err, ErrEndpointDiscovery) + assert.NotErrorIs(t, err, errIngressUnusable) + assert.Contains(t, err.Error(), "tls: failed to verify certificate") +} diff --git a/internal/rpp/errors.go b/internal/rpp/errors.go new file mode 100644 index 00000000..4ab3fe45 --- /dev/null +++ b/internal/rpp/errors.go @@ -0,0 +1,67 @@ +/* +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 rpp + +import "errors" + +// Sentinel errors let callers branch on the outcome via errors.Is instead of +// inspecting HTTP status codes themselves. +var ( + // ErrInvalidImage means the requested image or tag is malformed or outside + // the proxy allow-list (e.g. a plugin name containing a slash). + ErrInvalidImage = errors.New("invalid image reference") + + // ErrInvalidEndpoint means the proxy endpoint URL is empty or not an https URL with a host. + ErrInvalidEndpoint = errors.New("invalid proxy endpoint") + + // ErrEndpointDiscovery means the proxy endpoint could not be discovered through + // the Kubernetes API (the kubeconfig 'server:'). Causes: + // - the API was unreachable + // - its certificate was invalid + // - the identity was rejected + // - no usable proxy was found + // This is the kube-API leg, not the proxy itself - bypass it with an explicit + // endpoint (--rpp-endpoint / D8_RPP_ENDPOINT). + ErrEndpointDiscovery = errors.New("registry-packages-proxy endpoint discovery failed") + + // ErrInvalidCA means the supplied CA bundle contained no usable certificates. + ErrInvalidCA = errors.New("invalid CA bundle") + + // ErrUnsupportedConfig means the requested client configuration is + // contradictory or unsupported (e.g. insecure TLS together with a CA bundle, + // or a rest.Config carrying a custom transport that would bypass CA verification). + ErrUnsupportedConfig = errors.New("unsupported client configuration") + + // ErrNotFound means the proxy has no such image or tag (HTTP 404). + ErrNotFound = errors.New("image or tag not found") + + // ErrUnauthorized means authentication failed: the kubeconfig credentials + // were missing, invalid or expired (HTTP 401). + ErrUnauthorized = errors.New("unauthorized") + + // ErrForbidden means the caller is authenticated but not allowed to download. + // In practice the cli-download ClusterRole is not bound to the subject (HTTP 403). + ErrForbidden = errors.New("forbidden") + + // ErrUpstream means the proxy failed while talking to the backing registry + // (HTTP 5xx). + ErrUpstream = errors.New("registry proxy upstream error") + + // ErrFileNotFound means a requested entry was absent from the downloaded image + // archive. + ErrFileNotFound = errors.New("file not found in image") +) diff --git a/internal/rpp/extract.go b/internal/rpp/extract.go new file mode 100644 index 00000000..d7a098ef --- /dev/null +++ b/internal/rpp/extract.go @@ -0,0 +1,164 @@ +/* +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 rpp + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path" + "strings" +) + +// The proxy serves images as a gzipped tar of the (flattened) filesystem. The +// helpers below pull a single named file out of that stream by its base name, so +// callers do not duplicate gzip/tar plumbing. +// +// Safe against path traversal: +// - destination is caller-supplied +// - the archive name is used only for a base-name match +// - non-regular entries (symlinks, dirs) are skipped + +const ( + // ExecutableMode is the mode forced on extracted binaries so they are always + // runnable regardless of the mode recorded in the image. + ExecutableMode os.FileMode = 0o755 + + // DefaultBinaryByteLimit caps an extracted binary. 512 MiB is far above the d8 + // binary (~130 MiB) and any plugin; shared so callers do not redefine it. + DefaultBinaryByteLimit int64 = 512 << 20 + + // maxArchiveBytes bounds the TOTAL decompressed bytes read while walking the + // archive, so a decompression bomb placed in entries before the target cannot + // exhaust resources. Generous (1 GiB); the per-entry caps are the tighter guard. + maxArchiveBytes int64 = 1 << 30 +) + +// ExtractFileToPath finds the entry whose base name is entryName and writes it to +// destination with mode. The mode is forced (the archive's recorded mode is +// ignored) because these artifacts are executables that must be runnable. The +// copy is capped at maxBytes to guard against a decompression bomb. Returns +// ErrFileNotFound if no such entry exists. +func ExtractFileToPath(r io.Reader, entryName, destination string, mode os.FileMode, maxBytes int64) error { + found, err := withTarGzEntry(r, entryName, func(tr *tar.Reader, _ *tar.Header) error { + return writeCapped(destination, tr, mode, maxBytes) + }) + if err != nil { + return err + } + + if !found { + return fmt.Errorf("%w: %q", ErrFileNotFound, entryName) + } + + return nil +} + +// ReadFile returns the bytes of the entry whose base name is entryName, capped at +// maxBytes. found is false (with a nil error) when no such entry exists. +func ReadFile(r io.Reader, entryName string, maxBytes int64) ([]byte, bool, error) { + var data []byte + + found, err := withTarGzEntry(r, entryName, func(tr *tar.Reader, _ *tar.Header) error { + // +1 so an entry of exactly maxBytes is not mistaken for an overflow. + bytes, err := io.ReadAll(io.LimitReader(tr, maxBytes+1)) + if err != nil { + return fmt.Errorf("read %q: %w", entryName, err) + } + + if int64(len(bytes)) > maxBytes { + return fmt.Errorf("%q exceeds the %d-byte limit", entryName, maxBytes) + } + + data = bytes + + return nil + }) + + return data, found, err +} + +// withTarGzEntry walks the gzipped tar, and on the first regular file whose base +// name equals entryName invokes fn with the reader positioned at that entry. It +// reports whether the entry was found. +func withTarGzEntry(r io.Reader, entryName string, fn func(tr *tar.Reader, header *tar.Header) error) (bool, error) { + gz, err := gzip.NewReader(r) + if err != nil { + return false, fmt.Errorf("open gzip stream: %w", err) + } + + defer func() { _ = gz.Close() }() + + // Bound the total decompressed bytes across the whole walk (not just the + // matched entry) so a bomb in a preceding entry cannot exhaust resources. + reader := tar.NewReader(io.LimitReader(gz, maxArchiveBytes+1)) + + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + return false, nil + } + + if err != nil { + return false, fmt.Errorf("read tar: %w", err) + } + + if header.Typeflag != tar.TypeReg || tarBaseName(header.Name) != entryName { + continue + } + + if err := fn(reader, header); err != nil { + return true, err + } + + return true, nil + } +} + +func writeCapped(destination string, r io.Reader, mode os.FileMode, maxBytes int64) error { + out, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("create %q: %w", destination, err) + } + + defer func() { _ = out.Close() }() + + written, err := io.Copy(out, io.LimitReader(r, maxBytes+1)) + if err != nil { + return fmt.Errorf("write %q: %w", destination, err) + } + + if written > maxBytes { + return fmt.Errorf("%q exceeds the %d-byte limit", destination, maxBytes) + } + + // OpenFile honors the umask, so force the exact mode for executables. + if err := os.Chmod(destination, mode); err != nil { + return fmt.Errorf("chmod %q: %w", destination, err) + } + + return nil +} + +// tarBaseName normalizes a tar entry name to its base file name so that "plugin", +// "./plugin" and "dir/plugin" all match by their final segment. +func tarBaseName(name string) string { + return path.Base(strings.TrimPrefix(name, "./")) +} diff --git a/internal/rpp/extract_test.go b/internal/rpp/extract_test.go new file mode 100644 index 00000000..4856b6dc --- /dev/null +++ b/internal/rpp/extract_test.go @@ -0,0 +1,103 @@ +/* +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 rpp + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testMaxBytes = 1 << 20 + +func gzipTar(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, content := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + })) + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + } + + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + return buf.Bytes() +} + +func TestExtractFileToPathForcesMode(t *testing.T) { + archive := gzipTar(t, map[string]string{"./d8": "BINARY", "README": "ignored"}) + dest := filepath.Join(t.TempDir(), "d8") + + // Source mode is 0o644; the forced mode must win so the binary is executable. + require.NoError(t, ExtractFileToPath(bytes.NewReader(archive), "d8", dest, 0o755, testMaxBytes)) + + got, err := os.ReadFile(dest) + require.NoError(t, err) + assert.Equal(t, "BINARY", string(got)) + + info, err := os.Stat(dest) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) +} + +func TestExtractFileToPathNotFound(t *testing.T) { + archive := gzipTar(t, map[string]string{"README": "x"}) + + err := ExtractFileToPath(bytes.NewReader(archive), "d8", filepath.Join(t.TempDir(), "d8"), 0o755, testMaxBytes) + require.ErrorIs(t, err, ErrFileNotFound) +} + +func TestExtractFileToPathTooBig(t *testing.T) { + archive := gzipTar(t, map[string]string{"d8": "way-too-large"}) + + err := ExtractFileToPath(bytes.NewReader(archive), "d8", filepath.Join(t.TempDir(), "d8"), 0o755, 4) + require.Error(t, err) +} + +func TestReadFilePresent(t *testing.T) { + archive := gzipTar(t, map[string]string{"contract.json": `{"name":"x"}`}) + + data, found, err := ReadFile(bytes.NewReader(archive), "contract.json", testMaxBytes) + require.NoError(t, err) + require.True(t, found) + assert.JSONEq(t, `{"name":"x"}`, string(data)) +} + +func TestReadFileAbsent(t *testing.T) { + archive := gzipTar(t, map[string]string{"d8": "BINARY"}) + + data, found, err := ReadFile(bytes.NewReader(archive), "contract.json", testMaxBytes) + require.NoError(t, err) + assert.False(t, found) + assert.Nil(t, data) +} diff --git a/internal/rpp/flags/flags.go b/internal/rpp/flags/flags.go new file mode 100644 index 00000000..aed697fa --- /dev/null +++ b/internal/rpp/flags/flags.go @@ -0,0 +1,66 @@ +/* +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 flags declares the CLI flags and environment defaults for reaching the +// registry-packages-proxy. +package flags + +import ( + "os" + + "github.com/spf13/pflag" +) + +const ( + EnvEndpoint = "D8_RPP_ENDPOINT" + EnvCAFile = "D8_RPP_CA_FILE" +) + +// CLI parameters for the registry-packages-proxy connection. +var ( + // Endpoint is the proxy base URL (e.g. https://<master-ip>:4219, or the + // public Ingress). Empty means discover it from the cluster. + Endpoint = os.Getenv(EnvEndpoint) + + // CAFile points to a PEM CA bundle used to verify the proxy TLS certificate + // in addition to the system roots. + CAFile = os.Getenv(EnvCAFile) + + // InsecureSkipTLSVerify disables proxy TLS verification (debugging only). + InsecureSkipTLSVerify bool +) + +// AddFlags registers the registry-packages-proxy connection flags on flagSet. +func AddFlags(flagSet *pflag.FlagSet) { + flagSet.StringVar( + &Endpoint, + "rpp-endpoint", + Endpoint, + "registry-packages-proxy base URL (e.g. https://master:4219). Discovered from the cluster when empty. Defaults to $"+EnvEndpoint+".", + ) + flagSet.StringVar( + &CAFile, + "rpp-ca-file", + CAFile, + "Path to a PEM CA bundle used to verify the registry-packages-proxy TLS certificate. Defaults to $"+EnvCAFile+".", + ) + flagSet.BoolVar( + &InsecureSkipTLSVerify, + "rpp-insecure-skip-tls-verify", + false, + "Skip registry-packages-proxy TLS verification. For debugging only.", + ) +} diff --git a/internal/rpp/image.go b/internal/rpp/image.go new file mode 100644 index 00000000..0606b7ee --- /dev/null +++ b/internal/rpp/image.go @@ -0,0 +1,109 @@ +/* +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 rpp + +import ( + "fmt" + "net/url" + "regexp" +) + +const ( + // cliImageName is the registry image of the deckhouse-cli binary itself. + cliImageName = "deckhouse-cli" + + // pluginsSegment namespaces plugin images under the CLI image path + // (deckhouse-cli/plugins/<plugin>). The proxy allow-list permits exactly one + // plugin name segment after it. + pluginsSegment = "plugins" + + // imagesPathPrefix is the proxy route prefix for CLI image operations. + imagesPathPrefix = "/v1/images/" + + // tagsPathSegment is the route segment that lists or addresses tags. + tagsPathSegment = "tags" +) + +// pluginNamePattern is the OCI repository path-component grammar (lowercase +// alphanumerics joined by single ./_/- separators). The proxy allow-list +// addresses a plugin as exactly one such component; anything else cannot name a +// published plugin and would only smuggle URL metacharacters into the route. +var pluginNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) + +// tagPattern is the OCI tag grammar: up to 128 chars of [A-Za-z0-9._-], not +// starting with a separator. A string outside it (e.g. with ?, # or a leading +// dot) cannot be a real registry tag and must not reach the request URL. +var tagPattern = regexp.MustCompile(`^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$`) + +// ImageRef identifies an image the proxy is allowed to serve over /v1/images: +// either the deckhouse-cli binary or a single plugin. Construct it through +// CLIImage or PluginImage so the value always stays within the allow-list. +type ImageRef struct { + path string +} + +// CLIImage refers to the deckhouse-cli binary image. +func CLIImage() ImageRef { + return ImageRef{path: cliImageName} +} + +// PluginImage refers to a plugin image (deckhouse-cli/plugins/<name>). name must +// be a single OCI path component, matching the proxy allow-list. +func PluginImage(name string) (ImageRef, error) { + if name == "" { + return ImageRef{}, fmt.Errorf("%w: plugin name is empty", ErrInvalidImage) + } + + if !pluginNamePattern.MatchString(name) { + return ImageRef{}, fmt.Errorf("%w: plugin name %q is not a valid image path component", ErrInvalidImage, name) + } + + return ImageRef{path: cliImageName + "/" + pluginsSegment + "/" + name}, nil +} + +// String returns the image path as used in proxy URLs, e.g. "deckhouse-cli" or +// "deckhouse-cli/plugins/stronghold". +func (r ImageRef) String() string { + return r.path +} + +// tagsPath is the route that lists the image tags. +func (r ImageRef) tagsPath() string { + return imagesPathPrefix + r.path + "/" + tagsPathSegment +} + +// tagPath is the route that addresses a single tag of the image. The tag is +// path-escaped as defense in depth; after validateTag this is a no-op, but it +// keeps URL metacharacters out of the route even if validation ever loosens. +func (r ImageRef) tagPath(tag string) string { + return r.tagsPath() + "/" + url.PathEscape(tag) +} + +// validateTag rejects strings that cannot be a registry tag, so the proxy route +// (anchored on the final /tags/<tag> segment) cannot be altered by a crafted +// --version value (slashes, ?, #, leading dots). +func validateTag(tag string) error { + if tag == "" { + return fmt.Errorf("%w: tag is empty", ErrInvalidImage) + } + + if !tagPattern.MatchString(tag) { + return fmt.Errorf("%w: %q is not a valid image tag", ErrInvalidImage, tag) + } + + return nil +} diff --git a/internal/rpp/image_test.go b/internal/rpp/image_test.go new file mode 100644 index 00000000..85688cb6 --- /dev/null +++ b/internal/rpp/image_test.go @@ -0,0 +1,88 @@ +/* +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 rpp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCLIImage(t *testing.T) { + ref := CLIImage() + + assert.Equal(t, "deckhouse-cli", ref.String()) + assert.Equal(t, "/v1/images/deckhouse-cli/tags", ref.tagsPath()) + assert.Equal(t, "/v1/images/deckhouse-cli/tags/v1.2.3", ref.tagPath("v1.2.3")) +} + +func TestPluginImage(t *testing.T) { + t.Run("valid name", func(t *testing.T) { + ref, err := PluginImage("stronghold") + require.NoError(t, err) + + assert.Equal(t, "deckhouse-cli/plugins/stronghold", ref.String()) + assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/tags", ref.tagsPath()) + assert.Equal(t, "/v1/images/deckhouse-cli/plugins/stronghold/tags/v2.0.0", ref.tagPath("v2.0.0")) + }) + + t.Run("empty name is rejected", func(t *testing.T) { + _, err := PluginImage("") + require.ErrorIs(t, err, ErrInvalidImage) + }) + + t.Run("name with slash is rejected", func(t *testing.T) { + _, err := PluginImage("plugins/extra") + require.ErrorIs(t, err, ErrInvalidImage) + }) + + t.Run("names outside the OCI component grammar are rejected", func(t *testing.T) { + for _, name := range []string{"..", "name?x=y", "name#f", "Name", "na me", "-lead", "trail-"} { + _, err := PluginImage(name) + assert.ErrorIs(t, err, ErrInvalidImage, "name %q must be rejected", name) + } + }) + + t.Run("separator-joined names are accepted", func(t *testing.T) { + for _, name := range []string{"delivery-kit", "my_plugin", "v1.plugin"} { + _, err := PluginImage(name) + assert.NoError(t, err, "name %q is a valid OCI component", name) + } + }) +} + +func TestValidateTag(t *testing.T) { + t.Run("valid tag", func(t *testing.T) { + require.NoError(t, validateTag("v1.2.3")) + }) + + t.Run("empty tag is rejected", func(t *testing.T) { + require.ErrorIs(t, validateTag(""), ErrInvalidImage) + }) + + t.Run("tag with slash is rejected", func(t *testing.T) { + require.ErrorIs(t, validateTag("v1/2"), ErrInvalidImage) + }) + + t.Run("tags outside the OCI grammar are rejected", func(t *testing.T) { + // URL metacharacters and a leading separator would alter the request route. + for _, tag := range []string{"v1?x=y", "v1#frag", "..", ".hidden", "v 1", "v1.2.3+meta"} { + assert.ErrorIs(t, validateTag(tag), ErrInvalidImage, "tag %q must be rejected", tag) + } + }) +} diff --git a/internal/rpp/platform.go b/internal/rpp/platform.go new file mode 100644 index 00000000..27ef360e --- /dev/null +++ b/internal/rpp/platform.go @@ -0,0 +1,26 @@ +/* +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 rpp + +import "runtime" + +// PlatformSuffix is the "-<os>-<arch>" tag suffix of the current platform +// (e.g. "-linux-amd64"). Matches the publishing convention: one single-platform +// image per tag (e.g. "<tag>-darwin-arm64"). +func PlatformSuffix() string { + return "-" + runtime.GOOS + "-" + runtime.GOARCH +} diff --git a/internal/rpp/transport.go b/internal/rpp/transport.go new file mode 100644 index 00000000..3fd86619 --- /dev/null +++ b/internal/rpp/transport.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 rpp + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "time" + + "k8s.io/client-go/rest" +) + +const ( + // tlsHandshakeTimeout / responseHeaderTimeout bound connection setup and the + // wait for response headers, so a wedged proxy cannot hang a command forever. + // Body streaming is intentionally unbounded - image downloads may legitimately + // take minutes. + tlsHandshakeTimeout = 10 * time.Second + responseHeaderTimeout = 30 * time.Second +) + +// buildHTTPClient builds an HTTP client that carries the kubeconfig identity +// (bearer token, client certificate or exec credentials) from restConfig, but +// verifies TLS against the proxy endpoint instead of the Kubernetes API server. +// +// The kubeconfig's API-server CA is dropped on purpose: the proxy endpoint +// (master hostPort or public Ingress) is served by a different certificate. +// Server trust is established from opts - a custom CA bundle, or the system +// roots by default, or skipped entirely when insecure. +// +// Notes: +// - restConfig is copied; the caller's config is never mutated. +// - The kubeconfig Proxy setting is kept, so RPP traffic uses the same HTTP +// proxy (if any) as API-server traffic. +// - The identity is attached to every request, so use the client only against +// the proxy endpoint. +func buildHTTPClient(restConfig *rest.Config, opts options) (*http.Client, error) { + cfg := rest.CopyConfig(restConfig) + + // CA injection (below) overrides the root CAs of the *http.Transport that + // client-go builds from cfg.TLS. A caller-supplied base transport would defeat + // that and silently skip verification, so reject it rather than fail open. + if cfg.Transport != nil { + return nil, fmt.Errorf("%w: rest.Config carries a custom Transport", ErrUnsupportedConfig) + } + + // Reset server verification to a known baseline (system roots, verified), then + // apply the requested mode. Identity fields (token/cert/exec) are left intact. + cfg.TLSClientConfig.CAData = nil + cfg.TLSClientConfig.CAFile = "" + cfg.TLSClientConfig.Insecure = false + + var pool *x509.CertPool + + switch { + case opts.insecure: + cfg.TLSClientConfig.Insecure = true + case len(opts.caData) > 0: + var err error + + pool, err = certPoolWith(opts.caData) + if err != nil { + return nil, err + } + } + + cfg.WrapTransport = withTunedTransport(cfg.WrapTransport, pool) + + return rest.HTTPClientFor(cfg) +} + +// certPoolWith returns the system certificate pool extended with the given PEM +// CA. A failure to load the system pool is fatal rather than silently narrowing +// trust to the supplied CA alone (which would contradict the documented "in +// addition to the system roots" behavior). +func certPoolWith(caPEM []byte) (*x509.CertPool, error) { + pool, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("load system certificate pool: %w", err) + } + + if pool == nil { + pool = x509.NewCertPool() + } + + if !pool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("%w: no certificates parsed from CA data", ErrInvalidCA) + } + + return pool, nil +} + +// withTunedTransport returns a transport wrapper that, innermost to outermost: +// - clones the base *http.Transport +// - bounds connection setup (TLS handshake, response-header wait) +// - overrides the root CAs when pool is non-nil +// - applies any pre-existing wrapper (e.g. an OIDC auth-provider) on top +// +// Tuning must hit the raw base transport, so it runs innermost. If the base is +// not an *http.Transport the request fails closed, rather than proceeding without +// the intended trust and bounds. +func withTunedTransport(prev func(http.RoundTripper) http.RoundTripper, pool *x509.CertPool) func(http.RoundTripper) http.RoundTripper { + return func(rt http.RoundTripper) http.RoundTripper { + base, ok := rt.(*http.Transport) + if !ok { + return errorRoundTripper{err: fmt.Errorf("%w: base transport is %T, want *http.Transport", ErrUnsupportedConfig, rt)} + } + + cloned := base.Clone() + cloned.TLSHandshakeTimeout = tlsHandshakeTimeout + cloned.ResponseHeaderTimeout = responseHeaderTimeout + + if pool != nil { + if cloned.TLSClientConfig == nil { + cloned.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + + cloned.TLSClientConfig.RootCAs = pool + } + + var wrapped http.RoundTripper = cloned + if prev != nil { + wrapped = prev(wrapped) + } + + return wrapped + } +} + +// errorRoundTripper fails every request with a fixed error. It lets the CA +// wrapper fail closed, so a trust override never degrades into an unverified +// connection. +type errorRoundTripper struct { + err error +} + +func (e errorRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, e.err +} diff --git a/internal/rpp/transport_test.go b/internal/rpp/transport_test.go new file mode 100644 index 00000000..e9a5620a --- /dev/null +++ b/internal/rpp/transport_test.go @@ -0,0 +1,87 @@ +/* +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 rpp + +import ( + "context" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" +) + +// TestNewForwardsKubeconfigIdentityAndTrustsCA verifies the end-to-end transport: +// the bearer token from the rest.Config reaches the proxy over TLS that is +// verified against the explicitly supplied CA (the test server's own cert). +func TestNewForwardsKubeconfigIdentityAndTrustsCA(t *testing.T) { + const token = "kubeconfig-bearer-token" + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer "+token, r.Header.Get("Authorization")) + + _, _ = io.WriteString(w, `{"name":"deckhouse-cli","tags":["v0.13.1"]}`) + })) + t.Cleanup(srv.Close) + + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + restConfig := &rest.Config{BearerToken: token} + + client, err := New(srv.URL, restConfig, dkplog.NewNop(), WithCAData(caPEM)) + require.NoError(t, err) + + tags, err := client.ListTags(context.Background(), CLIImage()) + require.NoError(t, err) + assert.Equal(t, []string{"v0.13.1"}, tags) +} + +// TestNewInsecureSkipsVerification verifies that WithInsecureSkipTLSVerify lets +// the client reach a TLS server whose certificate is not otherwise trusted. +func TestNewInsecureSkipsVerification(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"name":"deckhouse-cli","tags":["v0.13.1"]}`) + })) + t.Cleanup(srv.Close) + + client, err := New(srv.URL, &rest.Config{}, dkplog.NewNop(), WithInsecureSkipTLSVerify()) + require.NoError(t, err) + + tags, err := client.ListTags(context.Background(), CLIImage()) + require.NoError(t, err) + assert.Equal(t, []string{"v0.13.1"}, tags) +} + +// TestNewRejectsUntrustedCA confirms that without a matching CA (and without +// insecure) the proxy certificate is rejected. +func TestNewRejectsUntrustedCA(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{}`) + })) + t.Cleanup(srv.Close) + + client, err := New(srv.URL, &rest.Config{}, dkplog.NewNop()) + require.NoError(t, err) + + _, err = client.ListTags(context.Background(), CLIImage()) + require.Error(t, err) +} diff --git a/internal/selfupdate/README.md b/internal/selfupdate/README.md new file mode 100644 index 00000000..1e6ed046 --- /dev/null +++ b/internal/selfupdate/README.md @@ -0,0 +1,148 @@ +# d8 self-update (`d8 cli`) + +The `internal/selfupdate` package updates the `d8` binary itself through the +cluster. + +## Why + +- `d8` is a single binary; before this, updating meant "download and replace by hand". +- The cluster itself knows which CLI versions are published for it - its registry + is the source of updates. +- Access to updates is controlled by the user's ordinary RBAC permissions + (kubeconfig), with no registry credentials handed out. + +## Commands + +| 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 <version>` | 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` | + +## The version store and the `current` symlink (`store.go`) + +Versions live in a per-user store with the same versions-directory-plus-symlink +layout the plugin installer uses: + +``` +/opt/deckhouse/bin/d8 -> ~/.deckhouse-cli/cli/current -> versions/v0.13.1/d8 + (PATH entry, (stable symlink, (the store) + migrated once) atomic repoint) +``` + +- **Switching = repointing `current`** (atomic: staged symlink + rename). The + PATH binary is never rewritten after migration, so switching needs no + elevated privileges and copies no files. +- **The store is addressed by its own well-known paths, never through + `os.Executable()`**: on Linux `/proc/self/exe` resolves to the symlink + *target*, so "replace whatever the executable resolves to" would overwrite a + stored version in place. The two-level layout (PATH -> `current` -> version) + is what makes the symlink scheme safe - the updater always knows where the + switchable link lives. +- **Migration**: when the running binary is a plain file outside the store (the + pre-store layout, or a binary dropped in by external tooling), the first + update/use seeds the store with it (under its own version, when semver), + backs it up as `<exe>.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 <version>` 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 + smoke-tested **while staged** (`.staged` suffix), so a corrupt artifact never + becomes a visible entry. Foreign content in the store directory is ignored. + Dev builds (non-semver versions) are never archived - `use` cannot address them. +- 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 <TAB>` shell-completes from the store (newest-first, prefix + filtered). Completion never touches the network - the offline-switchable + versions are exactly the ones worth suggesting. + +## What RPP is and how this package talks to it + +RPP (**registry-packages-proxy**) is the in-cluster HTTP proxy in front of the +platform's container registry (module `registry-packages-proxy`, ns +`d8-cloud-instance-manager`): + +- **Why it exists**: users do not have (and should not have) registry + credentials. The proxy serves artifacts based on the **kubeconfig identity** - + the same token the user already presents to kube-apiserver. +- **Transport**: kube-rbac-proxy on `:4219` of the master nodes; the public + Ingress `registry-packages-proxy.<publicDomain>` (valid TLS, the default path). +- **Per-request authorization**: the Bearer token from kubeconfig -> + TokenReview ("who") + SubjectAccessReview ("is it allowed"). Access is granted + by the ClusterRole `d8:registry-packages-proxy:cli-download`; it is NOT bound + to anyone by default - the cluster administrator decides who may download the CLI. +- **API used by this package** (the HTTP client lives in `internal/rpp`): + - `GET /v1/images/deckhouse-cli/tags` -> `{"name": ..., "tags": [...]}` - the version list; + - `GET /v1/images/deckhouse-cli/tags/<tag>` -> gzip-tar of the image contents + (containing the `d8` file). +- **Endpoint** is discovered automatically (Ingress -> pod-IP fallback) or set + explicitly (`--rpp-endpoint` / `D8_RPP_ENDPOINT`). + +## How an update works (`Updater.Apply` -> `SwitchTo`) + +1. A lock in the store (`install.lock`, `internal/lockfile`) - two switches + cannot run in parallel; a lock orphaned by a kill is reclaimed. +2. A plain-file install is seeded into the store first (best-effort, semver + versions only), so the displaced version stays switchable. +3. The requested version is installed into the store unless already present: + download to `versions/<tag>/d8.staged`, smoke test (`--version` must exit + cleanly - a corrupt artifact or one built for another platform is rejected + while staged), atomic rename to its final name. +4. Pre-existing store entries are smoke-tested again before they become active. +5. `current` is atomically repointed at the version. +6. A plain-file install is migrated: the original binary becomes `<exe>.old` + 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 <previous>` - the previous version remains installed +(the command prints it). The `.old` file exists only as the migration backup. + +Version selection: + +- by default - the highest **stable** semver tag; +- pre-releases (`rc`/`alpha`/`beta`) are installed only explicitly via + `--version` (which also allows a downgrade). + +Platform tags (`rpp_source.go`): + +- releases may be published per platform, one single-platform image per tag + (`v1.2.3-linux-amd64` - the same convention the plugin CI uses); +- `ListTags` reports this platform's tags as their bare version (so the Updater + selects them) and passes other platforms' tags through raw - their suffix + parses as a semver pre-release and is never auto-selected; +- `ExtractBinary` downloads `<tag>-<os>-<arch>` first and falls back to the bare + `<tag>` (legacy / platform-neutral publishing) on 404. + +## Switches + +| Need | How | +|---|---| +| explicit RPP endpoint | `--rpp-endpoint` / `D8_RPP_ENDPOINT` | +| custom CA / skip TLS verification | `--rpp-ca-file` / `--rpp-insecure-skip-tls-verify` | +| identity | `-k/--kubeconfig`, `--context` | + +## Boundaries and deliberate decisions + +- Windows is not supported: a running `.exe` cannot be replaced. +- The client does not follow redirects: the Bearer token must never travel to a + foreign host. + +## Package map + +| 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/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 new file mode 100644 index 00000000..24bbadbb --- /dev/null +++ b/internal/selfupdate/cmd/command.go @@ -0,0 +1,200 @@ +/* +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/cmd/errdetect/diagnose.go b/internal/selfupdate/cmd/errdetect/diagnose.go new file mode 100644 index 00000000..45f819cb --- /dev/null +++ b/internal/selfupdate/cmd/errdetect/diagnose.go @@ -0,0 +1,71 @@ +/* +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 errdetect maps registry-packages-proxy failures from `d8 cli` to +// HelpfulErrors with CLI-specific guidance. +package errdetect + +import ( + "errors" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +// Diagnose returns a HelpfulError for a recognized proxy failure, or nil for a nil, +// unrecognized, or already-diagnosed error - so the caller keeps the original. +func Diagnose(err error) *diagnostic.HelpfulError { + var he *diagnostic.HelpfulError + if err == nil || errors.As(err, &he) { + return nil + } + + switch { + case errors.Is(err, rpp.ErrUnauthorized): + return help(err, "registry-packages-proxy: unauthorized (401)", + "no accepted Bearer token (a client-certificate kubeconfig is not enough)", + "use a kubeconfig with an OIDC token (Kubeconfig Generator or 'd8 login')") + case errors.Is(err, rpp.ErrForbidden): + return help(err, "registry-packages-proxy: forbidden (403)", + "the identity may not download the CLI", + "bind the ClusterRole 'd8:registry-packages-proxy:cli-download' to the user/group", + "authorization is cached ~5 min - after binding, retry with a fresh token") + 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'") + case errors.Is(err, rpp.ErrUpstream): + return help(err, "registry-packages-proxy: 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") + case errors.Is(err, rpp.ErrEndpointDiscovery): + return help(err, "registry-packages-proxy: endpoint discovery via the Kubernetes API failed", + "discovery reaches the proxy through your kubeconfig's API server; that server was unreachable or presented an invalid certificate", + "this is the Kubernetes API endpoint (kubeconfig 'server:'), not the proxy; confirm it is reachable with a valid TLS certificate for that host", + "skip discovery: pass --rpp-endpoint https://registry-packages-proxy.<publicDomain> (or set D8_RPP_ENDPOINT)", + "on a master node, point the kubeconfig at the local API (https://127.0.0.1:6445, CA /etc/kubernetes/pki/ca.crt) with an OIDC token") + default: + return nil + } +} + +func help(err error, category, cause string, solutions ...string) *diagnostic.HelpfulError { + return &diagnostic.HelpfulError{ + Category: category, + OriginalErr: err, + Suggestions: []diagnostic.Suggestion{{Cause: cause, Solutions: solutions}}, + } +} diff --git a/internal/selfupdate/cmd/errdetect/diagnose_test.go b/internal/selfupdate/cmd/errdetect/diagnose_test.go new file mode 100644 index 00000000..80a315d7 --- /dev/null +++ b/internal/selfupdate/cmd/errdetect/diagnose_test.go @@ -0,0 +1,62 @@ +/* +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 errdetect + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +func TestDiagnose(t *testing.T) { + cases := []struct { + name string + sentinel error + wantCat string + wantSol string + }{ + {"401", rpp.ErrUnauthorized, "unauthorized (401)", "OIDC"}, + {"403", rpp.ErrForbidden, "forbidden (403)", "cli-download"}, + {"404", rpp.ErrNotFound, "version not found (404)", "d8 cli versions"}, + {"5xx", rpp.ErrUpstream, "upstream error (5xx)", "registry-packages-proxy pods"}, + {"discovery", rpp.ErrEndpointDiscovery, "endpoint discovery via the Kubernetes API failed", "--rpp-endpoint"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + he := Diagnose(fmt.Errorf("GET /v1/images/deckhouse-cli/tags: %w", tc.sentinel)) + require.NotNil(t, he) + assert.Contains(t, he.Category, tc.wantCat) + require.Len(t, he.Suggestions, 1) + assert.Contains(t, strings.Join(he.Suggestions[0].Solutions, " "), tc.wantSol) + assert.ErrorIs(t, he, tc.sentinel, "the original cause is preserved") + }) + } +} + +func TestDiagnoseReturnsNil(t *testing.T) { + assert.Nil(t, Diagnose(nil)) + assert.Nil(t, Diagnose(errors.New("some other failure")), "an unrecognized error is left alone") + assert.Nil(t, Diagnose(&diagnostic.HelpfulError{Category: "preexisting"}), "an already-diagnosed error is left alone") +} diff --git a/internal/selfupdate/cmd/list.go b/internal/selfupdate/cmd/list.go new file mode 100644 index 00000000..00bd95b6 --- /dev/null +++ b/internal/selfupdate/cmd/list.go @@ -0,0 +1,180 @@ +/* +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 + +import ( + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/fatih/color" + "github.com/spf13/cobra" + + 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 { + return &cobra.Command{ + Use: "versions", + Aliases: []string{"list"}, + Short: "List deckhouse-cli versions available in the cluster registry", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + updater, err := newUpdater(cmd.Context(), cmd, logger) + if err != nil { + return err + } + + versions, err := updater.Versions(cmd.Context()) + if err != nil { + return err + } + + if len(versions) == 0 { + return fmt.Errorf("no deckhouse-cli versions found in the registry") + } + + store, storeErr := selfupdate.NewStore() + if storeErr != nil { + // Listing still works; entries just lose their "installed" marker. + logger.Debug("version store unavailable", dkplog.Err(storeErr)) + } + + 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 + } + } + + lines, currentListed := formatVersionList(versions, current, installed) + for _, line := range lines { + fmt.Println(line) + } + + if extra := storedOnly(installed, versions); len(extra) > 0 { + fmt.Println("\nInstalled locally (switch with 'd8 cli use'), not published in the registry:") + + for _, v := range extra { + fmt.Printf(" %s\n", v.Original()) + } + } + + if !currentListed { + fmt.Printf("\nCurrent version %s is not published in the registry.\n", current) + } + + return nil + }, + } +} + +// 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` +// 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) { + var ( + newer = color.New(color.FgGreen) + actual = color.New(color.FgCyan, color.Bold) + older = color.New(color.Faint) + listed bool + widest int + ) + + for _, v := range versions { + if len(v.Original()) > widest { + widest = len(v.Original()) + } + } + + inStore := func(v *semver.Version) bool { + for _, s := range installed { + if s.Equal(v) { + return true + } + } + + return false + } + + currentVersion, err := semver.NewVersion(current) + + lines := make([]string, 0, len(versions)) + + for _, v := range versions { + marker := "" + if inStore(v) { + marker = " installed" + } + + var entry string + + switch { + case err != nil: + // Dev build - no reference point, no grouping. + entry = fmt.Sprintf(" %-*s%s", widest, v.Original(), marker) + case v.Equal(currentVersion): + listed = true + entry = actual.Sprintf("* %-*s current%s", widest, v.Original(), marker) + case v.GreaterThan(currentVersion): + entry = newer.Sprintf(" %-*s newer%s", widest, v.Original(), marker) + default: + entry = older.Sprintf(" %-*s%s", widest, v.Original(), marker) + } + + // The padding is for the trailing group word; entries without one would + // otherwise carry invisible trailing spaces. + lines = append(lines, strings.TrimRight(entry, " ")) + } + + return lines, listed +} + +// storedOnly returns stored versions absent from the published list (the registry +// was re-pointed or pruned); they remain switchable via `d8 cli use`. +func storedOnly(installed, published []*semver.Version) []*semver.Version { + extra := make([]*semver.Version, 0, len(installed)) + + for _, s := range installed { + found := false + + for _, p := range published { + if p.Equal(s) { + found = true + + break + } + } + + if !found { + extra = append(extra, s) + } + } + + return extra +} diff --git a/internal/selfupdate/cmd/list_test.go b/internal/selfupdate/cmd/list_test.go new file mode 100644 index 00000000..de184ad0 --- /dev/null +++ b/internal/selfupdate/cmd/list_test.go @@ -0,0 +1,112 @@ +/* +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 + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/fatih/color" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustVersions(t *testing.T, raw ...string) []*semver.Version { + t.Helper() + + versions := make([]*semver.Version, 0, len(raw)) + + for _, r := range raw { + v, err := semver.NewVersion(r) + require.NoError(t, err) + + versions = append(versions, v) + } + + return versions +} + +func withoutColor(t *testing.T) { + t.Helper() + + prev := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = prev }) +} + +func TestFormatVersionListGroupsAroundCurrent(t *testing.T) { + withoutColor(t) + + lines, listed := formatVersionList( + mustVersions(t, "v0.14.1", "v0.14.0", "v0.13.1", "v0.13.0"), "v0.13.1", nil) + + assert.True(t, listed) + assert.Equal(t, []string{ + " v0.14.1 newer", + " v0.14.0 newer", + "* v0.13.1 current", + " v0.13.0", + }, lines) +} + +func TestFormatVersionListCurrentNotPublished(t *testing.T) { + withoutColor(t) + + lines, listed := formatVersionList(mustVersions(t, "v0.14.1", "v0.14.0"), "v0.13.5", nil) + + assert.False(t, listed) + assert.Equal(t, []string{ + " v0.14.1 newer", + " v0.14.0 newer", + }, lines) +} + +func TestFormatVersionListDevBuildIsPlain(t *testing.T) { + withoutColor(t) + + lines, listed := formatVersionList(mustVersions(t, "v0.14.1", "v0.13.0"), "local-dev", nil) + + assert.False(t, listed) + assert.Equal(t, []string{ + " v0.14.1", + " v0.13.0", + }, lines) +} + +func TestFormatVersionListMarksInstalled(t *testing.T) { + withoutColor(t) + + lines, listed := formatVersionList( + mustVersions(t, "v0.14.1", "v0.13.1", "v0.13.0"), "v0.13.1", + mustVersions(t, "v0.14.1", "v0.13.0")) + + assert.True(t, listed) + assert.Equal(t, []string{ + " v0.14.1 newer installed", + "* v0.13.1 current", + " v0.13.0 installed", + }, lines) +} + +func TestStoredOnly(t *testing.T) { + extra := storedOnly( + mustVersions(t, "v0.14.1", "v0.10.0"), + mustVersions(t, "v0.14.1", "v0.13.0")) + + require.Len(t, extra, 1) + assert.Equal(t, "v0.10.0", extra[0].Original()) +} diff --git a/internal/selfupdate/cmd/use.go b/internal/selfupdate/cmd/use.go new file mode 100644 index 00000000..db6d68c0 --- /dev/null +++ b/internal/selfupdate/cmd/use.go @@ -0,0 +1,169 @@ +/* +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 + +import ( + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/selfupdate" + "github.com/deckhouse/deckhouse-cli/internal/version" +) + +// newUseCommand returns `d8 cli use <version>` - 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 { + return &cobra.Command{ + Use: "use <version>", + Short: "Switch to a specific deckhouse-cli version (no download when it is installed locally)", + Long: "Switch the d8 binary to the given version.\n\n" + + "Versions live in the local store (~/.deckhouse-cli/cli/versions) and the active one is\n" + + "selected by the store's 'current' symlink - the same layout plugins use. Switching to an\n" + + "installed version repoints that symlink: instant, offline, no elevated privileges.\n" + + "A version missing from the store is downloaded through the registry-packages-proxy first\n" + + "(kubeconfig required) and stays installed afterwards.\n\n" + + "The first switch migrates a plain-file install to the symlink layout; the original binary\n" + + "is kept with a \".old\" suffix.", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeStoredVersions, + RunE: func(cmd *cobra.Command, args []string) error { + requested, err := semver.NewVersion(args[0]) + if err != nil { + return fmt.Errorf("invalid version %q: %w", args[0], err) + } + + exePath, err := selfupdate.CurrentExecutable() + if err != nil { + return err + } + + store, err := selfupdate.NewStore() + if err != nil { + return fmt.Errorf("version store unavailable: %w", err) + } + + // The `current` link is authoritative only when this very invocation runs + // through the store; a foreign binary (e.g. a copied-off d8) must not + // trust a link it is not part of. + if store.Contains(exePath) { + if tag := store.CurrentTag(); tag != "" { + if cur, err := semver.NewVersion(tag); err == nil && cur.Equal(requested) { + fmt.Printf("deckhouse-cli is already at %s.\n", verCur.Sprint(tag)) + + return nil + } + } + } + + // Installed locally - pure symlink switch, no network, no kubeconfig. + if stored := store.Resolve(requested); stored != "" { + res, err := selfupdate.SwitchTo(cmd.Context(), exePath, stored, store, logger, nil) + if err != nil { + return err + } + + fmt.Printf("%s Switched deckhouse-cli to %s (installed locally).\n", okMark.Sprint("✓"), verNew.Sprint(stored)) + printSwitchNotes(res) + + return nil + } + + // The requested version may be the running binary itself (a plain-file + // install not seeded into the store yet): migration alone satisfies it - + // SwitchTo archives the running binary under its version, no download. + if cur, err := semver.NewVersion(version.Version); err == nil && cur.Equal(requested) { + res, err := selfupdate.SwitchTo(cmd.Context(), exePath, version.Version, store, logger, nil) + if err != nil { + return err + } + + fmt.Printf("%s Switched deckhouse-cli to %s (taken from the running binary).\n", okMark.Sprint("✓"), verNew.Sprint(version.Version)) + printSwitchNotes(res) + + return nil + } + + updater, err := newUpdater(cmd.Context(), cmd, logger) + if err != nil { + return err + } + + fmt.Printf("Version %s is not installed locally, downloading...\n", verNew.Sprint(requested.Original())) + + res, err := updater.Apply(cmd.Context(), requested.Original()) + if err != nil { + return err + } + + fmt.Printf("%s Switched deckhouse-cli to %s.\n", okMark.Sprint("✓"), verNew.Sprint(requested.Original())) + printSwitchNotes(res) + + return nil + }, + } +} + +// printSwitchNotes tells the user what the switch left behind and how to undo it. +func printSwitchNotes(res selfupdate.SwitchResult) { + if res.Migrated { + fmt.Printf("The d8 binary in PATH is now a symlink into the version store; the previous binary is kept with a %q suffix.\n", selfupdate.OldSuffix) + } + + if res.PrevTag != "" { + fmt.Printf("Previous version %s remains installed - switch back with 'd8 cli use %s'.\n", verOld.Sprint(res.PrevTag), res.PrevTag) + } +} + +// completeStoredVersions offers the locally installed versions for `d8 cli use +// <TAB>`. 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. +func completeStoredVersions(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + store, err := selfupdate.NewStore() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return storedVersionCompletions(store, toComplete), cobra.ShellCompDirectiveNoFileComp +} + +// storedVersionCompletions renders the store content as shell completions, +// newest-first, filtered by the typed prefix. +func storedVersionCompletions(store *selfupdate.Store, toComplete string) []string { + versions := store.List() + completions := make([]string, 0, len(versions)) + + for _, v := range versions { + if !strings.HasPrefix(v.Original(), toComplete) { + continue + } + + completions = append(completions, v.Original()+"\tinstalled locally, switches offline") + } + + return completions +} diff --git a/internal/selfupdate/cmd/use_test.go b/internal/selfupdate/cmd/use_test.go new file mode 100644 index 00000000..685e746f --- /dev/null +++ b/internal/selfupdate/cmd/use_test.go @@ -0,0 +1,56 @@ +/* +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 + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/selfupdate" +) + +// TestStoredVersionCompletions checks the `d8 cli use <TAB>` source: stored +// versions newest-first, filtered by the typed prefix, with a description. +func TestStoredVersionCompletions(t *testing.T) { + dir := t.TempDir() + store := selfupdate.NewStoreAt(filepath.Join(dir, "cli")) + + // Store entries are smoke-tested with --version, so the payload is a script. + src := filepath.Join(dir, "binary") + require.NoError(t, os.WriteFile(src, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + + for _, tag := range []string{"v0.13.0", "v0.14.0", "v1.0.0"} { + require.NoError(t, store.Archive(context.Background(), src, tag)) + } + + all := storedVersionCompletions(store, "") + assert.Equal(t, []string{ + "v1.0.0\tinstalled locally, switches offline", + "v0.14.0\tinstalled locally, switches offline", + "v0.13.0\tinstalled locally, switches offline", + }, all) + + filtered := storedVersionCompletions(store, "v0.1") + assert.Len(t, filtered, 2, "completions must honor the typed prefix") + + assert.Empty(t, storedVersionCompletions(nil, ""), "a nil store completes to nothing") +} diff --git a/internal/selfupdate/doc.go b/internal/selfupdate/doc.go new file mode 100644 index 00000000..f3062fd1 --- /dev/null +++ b/internal/selfupdate/doc.go @@ -0,0 +1,41 @@ +/* +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 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: +// +// - check - is a newer version available +// - versions - list published versions (alias: list) +// - update - install a version and switch to it +// - use - switch to a version (instant if already installed) +// +// How it works, in short: +// +// - Versions are kept in a per-user store (~/.deckhouse-cli/cli/versions/<tag>/d8); +// the active one is selected by the `current` symlink, so switching is an +// atomic repoint - no file copying, no sudo. The PATH entry (e.g. +// /opt/deckhouse/bin/d8) is a one-time-created symlink to that `current`, with +// the original binary kept as <exe>.old. Full layout: store.go. +// - 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 +// 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. +package selfupdate diff --git a/internal/selfupdate/fixtures_test.go b/internal/selfupdate/fixtures_test.go new file mode 100644 index 00000000..1f160917 --- /dev/null +++ b/internal/selfupdate/fixtures_test.go @@ -0,0 +1,50 @@ +/* +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 selfupdate + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "testing" + + "github.com/stretchr/testify/require" +) + +// gzipTarWithD8 builds a gzipped tar holding a single "d8" file with the given +// content - the image shape the registry-packages-proxy serves for the CLI. +func gzipTarWithD8(t *testing.T, content string) []byte { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "d8", + Mode: 0o644, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + })) + + _, err := tw.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + return buf.Bytes() +} diff --git a/internal/selfupdate/rpp_source.go b/internal/selfupdate/rpp_source.go new file mode 100644 index 00000000..3d108ee0 --- /dev/null +++ b/internal/selfupdate/rpp_source.go @@ -0,0 +1,97 @@ +/* +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 selfupdate + +import ( + "context" + "errors" + "strings" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" +) + +// cliBinaryEntryName is the file inside the deckhouse-cli image that holds the d8 +// executable (verified against the published image). +const cliBinaryEntryName = "d8" + +// rppSource extracts deckhouse-cli releases through the registry-packages-proxy. +// +// Tags may be published per platform ("v1.2.3-linux-amd64", one single-platform +// image per tag - the same convention the plugin CI uses). The source hides that +// from the Updater: ListTags reports such tags as their bare version, and +// ExtractBinary resolves the bare version back to the platform tag, falling back +// to the bare tag itself for platform-neutral/legacy publishing. +type rppSource struct { + client *rpp.Client +} + +var _ Source = (*rppSource)(nil) + +// NewRPPSource builds the proxy-backed release source. +func NewRPPSource(client *rpp.Client) Source { + return &rppSource{client: client} +} + +// ListTags returns the available release tags normalized for the current +// platform: "v1.2.3-<os>-<arch>" of THIS platform becomes "v1.2.3", tags of other +// platforms pass through raw (their suffix parses as a semver pre-release, so the +// Updater never auto-selects them). +func (s *rppSource) ListTags(ctx context.Context) ([]string, error) { + tags, err := s.client.ListTags(ctx, rpp.CLIImage()) + if err != nil { + return nil, err + } + + suffix := rpp.PlatformSuffix() + seen := make(map[string]struct{}, len(tags)) + normalized := make([]string, 0, len(tags)) + + for _, tag := range tags { + tag = strings.TrimSuffix(tag, suffix) + if _, ok := seen[tag]; ok { + continue + } + + seen[tag] = struct{}{} + normalized = append(normalized, tag) + } + + return normalized, nil +} + +// ExtractBinary downloads the d8 binary for tag, preferring the per-platform tag +// ("<tag>-<os>-<arch>") and falling back to the bare tag when the platform tag is +// not published. +func (s *rppSource) ExtractBinary(ctx context.Context, tag, destination string) error { + err := s.extract(ctx, tag+rpp.PlatformSuffix(), destination) + if errors.Is(err, rpp.ErrNotFound) { + return s.extract(ctx, tag, destination) + } + + return err +} + +func (s *rppSource) extract(ctx context.Context, tag, destination string) error { + body, err := s.client.PullImage(ctx, rpp.CLIImage(), tag) + if err != nil { + return err + } + + defer func() { _ = body.Close() }() + + return rpp.ExtractFileToPath(body, cliBinaryEntryName, destination, rpp.ExecutableMode, rpp.DefaultBinaryByteLimit) +} diff --git a/internal/selfupdate/rpp_source_test.go b/internal/selfupdate/rpp_source_test.go new file mode 100644 index 00000000..792db097 --- /dev/null +++ b/internal/selfupdate/rpp_source_test.go @@ -0,0 +1,176 @@ +/* +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 selfupdate + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/rpp" +) + +// newTestRPPSource serves the given handler over TLS and returns a source wired to it. +func newTestRPPSource(t *testing.T, handler http.HandlerFunc) Source { + t.Helper() + + srv := httptest.NewTLSServer(handler) + t.Cleanup(srv.Close) + + client, err := rpp.New( + srv.URL, + &rest.Config{Host: srv.URL, BearerToken: "test-token"}, + dkplog.NewNop(), + rpp.WithInsecureSkipTLSVerify(), + ) + require.NoError(t, err) + + return NewRPPSource(client) +} + +func TestVersionsSortsNewestFirstAndSkipsGarbage(t *testing.T) { + source := newTestRPPSource(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "deckhouse-cli", + "tags": []string{"v0.13.0", "not-a-version", "v0.14.0", "v0.13.1"}, + }) + }) + + versions, err := NewUpdater(source, nil, dkplog.NewNop()).Versions(context.Background()) + require.NoError(t, err) + + got := make([]string, 0, len(versions)) + for _, v := range versions { + got = append(got, v.Original()) + } + + assert.Equal(t, []string{"v0.14.0", "v0.13.1", "v0.13.0"}, got) +} + +// TestRPPSourceListTagsNormalizesPlatformTags checks that this platform's +// "-<os>-<arch>" suffix is stripped (so the Updater can select the version), +// while foreign-platform and bare tags pass through untouched. +func TestRPPSourceListTagsNormalizesPlatformTags(t *testing.T) { + source := newTestRPPSource(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/images/deckhouse-cli/tags", r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "deckhouse-cli", + "tags": []string{ + "v0.13.1", // legacy bare tag + "v0.14.0" + rpp.PlatformSuffix(), // ours -> normalized + "v0.14.0-windows-amd64", // foreign -> raw passthrough + "v0.14.1" + rpp.PlatformSuffix(), // ours -> normalized + "v0.14.1", // bare duplicate of ours -> deduped + }, + }) + }) + + tags, err := source.ListTags(context.Background()) + require.NoError(t, err) + assert.ElementsMatch(t, + []string{"v0.13.1", "v0.14.0", "v0.14.0-windows-amd64", "v0.14.1"}, + tags, + ) + + // End to end through the Updater: the platform tag must win the selection, + // and the foreign platform tag must never be picked (parses as pre-release). + updater := NewUpdater(source, nil, dkplog.NewNop()) + latest, newer, err := updater.LatestVersion(context.Background(), "v0.13.1") + require.NoError(t, err) + assert.True(t, newer) + assert.Equal(t, "v0.14.1", latest) +} + +// TestRPPSourceExtractPrefersPlatformTag checks that the bare version selected by +// the Updater is resolved back to this platform's tag on download. +func TestRPPSourceExtractPrefersPlatformTag(t *testing.T) { + tarball := gzipTarWithD8(t, "PLATFORM-BINARY") + + var requested []string + + source := newTestRPPSource(t, func(w http.ResponseWriter, r *http.Request) { + requested = append(requested, r.URL.Path) + require.Equal(t, "/v1/images/deckhouse-cli/tags/v0.14.0"+rpp.PlatformSuffix(), r.URL.Path) + _, _ = w.Write(tarball) + }) + + destination := filepath.Join(t.TempDir(), "d8.new") + require.NoError(t, source.ExtractBinary(context.Background(), "v0.14.0", destination)) + + got, err := os.ReadFile(destination) + require.NoError(t, err) + assert.Equal(t, "PLATFORM-BINARY", string(got)) + assert.Len(t, requested, 1, "the platform tag must be fetched directly, no extra round-trips") +} + +// TestRPPSourceExtractFallsBackToBareTag checks legacy/platform-neutral publishing: +// when the platform tag is absent (404), the bare tag is downloaded instead. +func TestRPPSourceExtractFallsBackToBareTag(t *testing.T) { + tarball := gzipTarWithD8(t, "BARE-BINARY") + + var requested []string + + source := newTestRPPSource(t, func(w http.ResponseWriter, r *http.Request) { + requested = append(requested, r.URL.Path) + + if r.URL.Path == "/v1/images/deckhouse-cli/tags/v0.13.1" { + _, _ = w.Write(tarball) + + return + } + + http.Error(w, "not found", http.StatusNotFound) + }) + + destination := filepath.Join(t.TempDir(), "d8.new") + require.NoError(t, source.ExtractBinary(context.Background(), "v0.13.1", destination)) + + got, err := os.ReadFile(destination) + require.NoError(t, err) + assert.Equal(t, "BARE-BINARY", string(got)) + assert.Equal(t, []string{ + "/v1/images/deckhouse-cli/tags/v0.13.1" + rpp.PlatformSuffix(), + "/v1/images/deckhouse-cli/tags/v0.13.1", + }, requested, "platform tag tried first, bare tag second") +} + +// TestRPPSourceExtractPropagatesNonNotFoundErrors checks that the fallback fires +// only on 404: a 403 on the platform tag must surface as-is, not mask itself with +// a second request. +func TestRPPSourceExtractPropagatesNonNotFoundErrors(t *testing.T) { + var requests int + + source := newTestRPPSource(t, func(w http.ResponseWriter, _ *http.Request) { + requests++ + + http.Error(w, "forbidden", http.StatusForbidden) + }) + + err := source.ExtractBinary(context.Background(), "v0.14.0", filepath.Join(t.TempDir(), "d8.new")) + require.ErrorIs(t, err, rpp.ErrForbidden) + assert.Equal(t, 1, requests) +} diff --git a/internal/selfupdate/source.go b/internal/selfupdate/source.go new file mode 100644 index 00000000..fe522dd7 --- /dev/null +++ b/internal/selfupdate/source.go @@ -0,0 +1,25 @@ +/* +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 selfupdate + +import "context" + +// Source lists available deckhouse-cli versions and extracts the binary for a tag. +type Source interface { + ListTags(ctx context.Context) ([]string, error) + ExtractBinary(ctx context.Context, tag, destination string) error +} diff --git a/internal/selfupdate/store.go b/internal/selfupdate/store.go new file mode 100644 index 00000000..4752a1b2 --- /dev/null +++ b/internal/selfupdate/store.go @@ -0,0 +1,302 @@ +/* +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 selfupdate + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" +) + +const ( + // storeBinaryName is the file every stored version keeps its binary under: + // <root>/versions/<version>/d8. + storeBinaryName = "d8" + + // storeVersionsDirName holds one directory per installed version. + storeVersionsDirName = "versions" + + // storeCurrentLinkName is the stable symlink the PATH entry points at; it is + // repointed atomically to switch versions - the same `current` idea the + // plugin layout uses. + storeCurrentLinkName = "current" + + // storeStagedSuffix marks an entry that is still being written, so a + // half-written binary is never visible under its final name. + storeStagedSuffix = ".staged" + + // storeLockName serializes store mutations (installs, switches, migration). + storeLockName = "install.lock" +) + +// Store is the local store of installed d8 versions plus the `current` symlink +// that selects the active one - the same versions-directory-plus-symlink layout +// the plugin installer uses: +// +// <root>/current -> versions/<version>/d8 (atomic repoint on switch) +// <root>/versions/<version>/d8 +// +// The PATH entry (e.g. /opt/deckhouse/bin/d8) is a one-time-created symlink to +// <root>/current, so switching never touches root-owned directories. +// +// Addressed by its own well-known paths, never via os.Executable(). +// On Linux /proc/self/exe resolves to the symlink TARGET: "replace what +// the executable resolves to" would overwrite a stored version in place +// instead of repointing the link. +// +// A nil *Store is a valid no-op store (all read methods are nil-safe), so +// callers degrade gracefully when the home directory cannot be resolved. +type Store struct { + root string +} + +// NewStore returns the per-user version store at ~/.deckhouse-cli/cli (next to +// the plugins home-fallback layout). +func NewStore() (*Store, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("locate home directory for the version store: %w", err) + } + + return &Store{root: filepath.Join(home, ".deckhouse-cli", "cli")}, nil +} + +// NewStoreAt returns a store rooted at an explicit directory (tests, tooling). +func NewStoreAt(root string) *Store { + return &Store{root: root} +} + +// binaryPath returns the path the binary for tag is stored under (whether or +// not it exists). +func (s *Store) binaryPath(tag string) string { + return filepath.Join(s.root, storeVersionsDirName, tag, storeBinaryName) +} + +// currentLinkPath returns the stable `current` symlink the PATH entry points at. +func (s *Store) currentLinkPath() string { + return filepath.Join(s.root, storeCurrentLinkName) +} + +// lockPath returns the lock file serializing store mutations. +func (s *Store) lockPath() string { + return filepath.Join(s.root, storeLockName) +} + +// has reports whether tag's binary is present in the store. +func (s *Store) has(tag string) bool { + if s == nil { + return false + } + + info, err := os.Stat(s.binaryPath(tag)) + + return err == nil && info.Mode().IsRegular() +} + +// Resolve returns the stored tag matching the requested version (semver +// comparison, so "0.13.1" finds an entry stored as "v0.13.1"), or "" when the +// version is not stored. +func (s *Store) Resolve(requested *semver.Version) string { + for _, v := range s.List() { + if v.Equal(requested) { + return v.Original() + } + } + + return "" +} + +// List returns the stored versions newest-first. Foreign entries (non-semver +// directory names, entries without a binary) are skipped rather than reported: +// the store is best-effort by design. +func (s *Store) List() []*semver.Version { + if s == nil { + return nil + } + + entries, err := os.ReadDir(filepath.Join(s.root, storeVersionsDirName)) + if err != nil { + return nil + } + + versions := make([]*semver.Version, 0, len(entries)) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + v, err := semver.NewVersion(entry.Name()) + if err != nil || !s.has(entry.Name()) { + continue + } + + versions = append(versions, v) + } + + sort.Sort(sort.Reverse(semver.Collection(versions))) + + return versions +} + +// CurrentTag returns the version the `current` symlink points at, or "" when +// the link is absent or points outside the expected versions layout. +func (s *Store) CurrentTag() string { + if s == nil { + return "" + } + + target, err := os.Readlink(s.currentLinkPath()) + if err != nil { + return "" + } + + // The link target is versions/<tag>/d8 (relative or absolute) - the tag is + // the parent directory's name. + tag := filepath.Base(filepath.Dir(target)) + if _, err := semver.NewVersion(tag); err != nil { + return "" + } + + return tag +} + +// switchCurrent atomically repoints the `current` symlink at tag's binary. The +// target is relative (versions/<tag>/d8), so the layout survives a moved home. +func (s *Store) switchCurrent(tag string) error { + if !s.has(tag) { + return fmt.Errorf("version %s is not in the store", tag) + } + + staged := s.currentLinkPath() + storeStagedSuffix + _ = os.Remove(staged) + + target := filepath.Join(storeVersionsDirName, tag, storeBinaryName) + if err := os.Symlink(target, staged); err != nil { + return fmt.Errorf("stage current symlink: %w", err) + } + + if err := os.Rename(staged, s.currentLinkPath()); err != nil { + _ = os.Remove(staged) + + return fmt.Errorf("switch current symlink: %w", err) + } + + return nil +} + +// Contains reports whether path (already symlink-resolved) lies inside the +// store - i.e. the running binary is store-managed. +func (s *Store) Contains(path string) bool { + if s == nil { + return false + } + + root, err := filepath.EvalSymlinks(s.root) + if err != nil { + return false + } + + return strings.HasPrefix(path, root+string(filepath.Separator)) +} + +// install materializes tag in the store via fetch (fetch writes the binary +// to the path it is given). The staged file is smoke-tested BEFORE the entry +// becomes visible: a corrupt artifact never lands under its final name, where +// `list` would mark it installed and completion would suggest it. +// An existing entry is kept as is: a published version is immutable. +func (s *Store) install(ctx context.Context, tag string, fetch func(dst string) error) error { + if s == nil { + return fmt.Errorf("version store is unavailable") + } + + if _, err := semver.NewVersion(tag); err != nil { + return fmt.Errorf("tag %q is not a semver version: %w", tag, err) + } + + if s.has(tag) { + return nil + } + + binPath := s.binaryPath(tag) + if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil { + return fmt.Errorf("create version store entry: %w", err) + } + + staged := binPath + storeStagedSuffix + + defer func() { _ = os.Remove(staged) }() + + if err := fetch(staged); err != nil { + return err + } + + if err := os.Chmod(staged, 0o755); err != nil { + return fmt.Errorf("mark staged binary executable: %w", err) + } + + if err := smokeTest(ctx, staged); err != nil { + return err + } + + if err := os.Rename(staged, binPath); err != nil { + return fmt.Errorf("finalize version store entry: %w", err) + } + + return nil +} + +// Archive copies the binary at srcPath into the store under tag (used to seed +// the store with the running binary during migration). Same immutability and +// semver rules as Install. +func (s *Store) Archive(ctx context.Context, srcPath, tag string) error { + if s == nil { + return nil + } + + return s.install(ctx, tag, func(dst string) error { + return copyFile(srcPath, dst) + }) +} + +// copyFile copies src to dst as an executable file (0755). +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + return err + } + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + + return err + } + + return out.Close() +} diff --git a/internal/selfupdate/store_test.go b/internal/selfupdate/store_test.go new file mode 100644 index 00000000..6fbbe4f9 --- /dev/null +++ b/internal/selfupdate/store_test.go @@ -0,0 +1,170 @@ +/* +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 selfupdate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeTestBinary creates a runnable script (store entries are smoke-tested +// with --version, so plain text payloads would be rejected). +func writeTestBinary(t *testing.T, dir, name, marker string) string { + t.Helper() + + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(testScript(marker)), 0o755)) + + return path +} + +func testScript(marker string) string { + return "#!/bin/sh\n# " + marker + "\nexit 0\n" +} + +func TestStoreArchiveAndResolve(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + src := writeTestBinary(t, dir, "binary", "PAYLOAD") + require.NoError(t, store.Archive(context.Background(), src, "v1.2.3")) + require.True(t, store.has("v1.2.3")) + + info, err := os.Stat(store.binaryPath("v1.2.3")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm(), "stored binary must be executable") + + // Resolve matches by semver value, not by string: "1.2.3" finds "v1.2.3". + assert.Equal(t, "v1.2.3", store.Resolve(semver.MustParse("1.2.3"))) + assert.Empty(t, store.Resolve(semver.MustParse("9.9.9"))) +} + +func TestStoreArchiveKeepsExistingEntry(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + first := writeTestBinary(t, dir, "first", "FIRST") + require.NoError(t, store.Archive(context.Background(), first, "v1.0.0")) + + second := writeTestBinary(t, dir, "second", "SECOND") + require.NoError(t, store.Archive(context.Background(), second, "v1.0.0")) + + got, err := os.ReadFile(store.binaryPath("v1.0.0")) + require.NoError(t, err) + assert.Equal(t, testScript("FIRST"), string(got), "a published version is immutable - the existing entry wins") +} + +func TestStoreArchiveRejectsNonSemver(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + src := writeTestBinary(t, dir, "binary", "PAYLOAD") + require.Error(t, store.Archive(context.Background(), src, "local-dev")) +} + +func TestStoreInstallRejectsCorruptBinary(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + err := store.install(context.Background(), "v1.0.0", func(dst string) error { + return os.WriteFile(dst, []byte("not a program"), 0o755) + }) + require.Error(t, err, "a binary failing the smoke test must not be installed") + assert.False(t, store.has("v1.0.0")) +} + +func TestStoreListSkipsGarbageAndSortsNewestFirst(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + src := writeTestBinary(t, dir, "binary", "PAYLOAD") + for _, tag := range []string{"v0.1.0", "v0.2.0"} { + require.NoError(t, store.Archive(context.Background(), src, tag)) + } + + // Junk the store must tolerate: a non-semver dir, a version dir without a + // binary, a stray file. + versionsDir := filepath.Join(store.root, storeVersionsDirName) + require.NoError(t, os.MkdirAll(filepath.Join(versionsDir, "not-a-version"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(versionsDir, "v9.9.9"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(versionsDir, "stray"), nil, 0o644)) + + got := make([]string, 0, 2) + for _, v := range store.List() { + got = append(got, v.Original()) + } + + assert.Equal(t, []string{"v0.2.0", "v0.1.0"}, got) +} + +func TestStoreSwitchCurrentAndCurrentTag(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + assert.Empty(t, store.CurrentTag(), "no link yet - no current tag") + require.Error(t, store.switchCurrent("v1.0.0"), "cannot switch to a version that is not installed") + + src := writeTestBinary(t, dir, "binary", "PAYLOAD") + for _, tag := range []string{"v1.0.0", "v2.0.0"} { + require.NoError(t, store.Archive(context.Background(), src, tag)) + } + + require.NoError(t, store.switchCurrent("v1.0.0")) + assert.Equal(t, "v1.0.0", store.CurrentTag()) + + // Repointing is an atomic replace; reading through the link follows the chain. + require.NoError(t, store.switchCurrent("v2.0.0")) + assert.Equal(t, "v2.0.0", store.CurrentTag()) + + got, err := os.ReadFile(store.currentLinkPath()) + require.NoError(t, err) + assert.Equal(t, testScript("PAYLOAD"), string(got)) +} + +func TestStoreContains(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + src := writeTestBinary(t, dir, "binary", "PAYLOAD") + require.NoError(t, store.Archive(context.Background(), src, "v1.0.0")) + + inStore, err := filepath.EvalSymlinks(store.binaryPath("v1.0.0")) + require.NoError(t, err) + assert.True(t, store.Contains(inStore)) + + outside, err := filepath.EvalSymlinks(src) + require.NoError(t, err) + assert.False(t, store.Contains(outside)) +} + +func TestNilStoreIsNoop(t *testing.T) { + var store *Store + + assert.False(t, store.has("v1.0.0")) + assert.Nil(t, store.List()) + assert.Empty(t, store.Resolve(semver.MustParse("1.0.0"))) + assert.Empty(t, store.CurrentTag()) + assert.False(t, store.Contains("/usr/local/bin/d8")) + assert.NoError(t, store.Archive(context.Background(), "/dev/null", "v1.0.0")) + assert.Error(t, store.install(context.Background(), "v1.0.0", nil)) +} diff --git a/internal/selfupdate/switch_test.go b/internal/selfupdate/switch_test.go new file mode 100644 index 00000000..ec129c38 --- /dev/null +++ b/internal/selfupdate/switch_test.go @@ -0,0 +1,102 @@ +/* +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 selfupdate + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/version" +) + +// TestSwitchToStoredVersionRepointsSymlink exercises the store-hit half of +// `d8 cli 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() + store := &Store{root: filepath.Join(dir, "cli")} + + for _, marker := range []string{"v1", "v2"} { + src := writeTestBinary(t, dir, marker, marker) + require.NoError(t, store.Archive(context.Background(), src, marker+".0.0")) + } + + require.NoError(t, store.switchCurrent("v1.0.0")) + + // A managed install: the running binary resolves into the store. + exePath, err := filepath.EvalSymlinks(store.binaryPath("v1.0.0")) + require.NoError(t, err) + + res, err := SwitchTo(context.Background(), exePath, "v2.0.0", store, dkplog.NewNop(), nil) + require.NoError(t, err) + + assert.False(t, res.Migrated, "a managed install must not be migrated again") + assert.Equal(t, "v1.0.0", res.PrevTag) + assert.Equal(t, "v2.0.0", store.CurrentTag()) + + assert.True(t, store.has("v1.0.0"), "the displaced version stays installed") + + got, err := os.ReadFile(store.currentLinkPath()) + require.NoError(t, err) + assert.Equal(t, testScript("v2"), string(got)) +} + +// TestSwitchToMissingVersionWithoutStageFails covers `use` of a version that is +// neither stored nor downloadable (stage == nil): a clear error, no changes. +func TestSwitchToMissingVersionWithoutStageFails(t *testing.T) { + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + exePath := writeTestBinary(t, dir, "d8", "RUNNING") + + _, err := SwitchTo(context.Background(), exePath, "v9.9.9", store, dkplog.NewNop(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "not in the local store") +} + +// TestSwitchToSeedsRunningVersion verifies migration retention: a plain-file +// install with a semver version is archived under its own tag, so the displaced +// version remains switchable (and `use <running version>` needs no download). +func TestSwitchToSeedsRunningVersion(t *testing.T) { + prev := version.Version + version.Version = "v0.9.0" + t.Cleanup(func() { version.Version = prev }) + + dir := t.TempDir() + store := &Store{root: filepath.Join(dir, "cli")} + + exePath := writeTestBinary(t, dir, "d8", "RUNNING") + + // Switching to the running binary's own version: seeded by retention, no stage. + res, err := SwitchTo(context.Background(), exePath, "v0.9.0", store, dkplog.NewNop(), nil) + require.NoError(t, err) + + assert.True(t, res.Migrated) + assert.True(t, store.has("v0.9.0"), "the running binary must be seeded into the store") + assert.Equal(t, "v0.9.0", store.CurrentTag()) + + got, err := os.ReadFile(exePath) // PATH symlink -> current -> stored binary + require.NoError(t, err) + assert.Equal(t, testScript("RUNNING"), string(got)) +} diff --git a/internal/selfupdate/update.go b/internal/selfupdate/update.go new file mode 100644 index 00000000..ed3e7f5a --- /dev/null +++ b/internal/selfupdate/update.go @@ -0,0 +1,400 @@ +/* +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 selfupdate + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal/lockfile" + "github.com/deckhouse/deckhouse-cli/internal/version" + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +const ( + // OldSuffix marks the backup of a plain-file install made when it is migrated + // to the store-managed (symlink) layout. + OldSuffix = ".old" + + // smokeTestTimeout bounds the new binary's --version probe so a hung or + // malformed binary fails the update instead of stalling it. + smokeTestTimeout = 30 * time.Second + + // lockStaleAfter is how old the update lock may get before it is treated as + // orphaned (a prior update was hard-killed before its deferred release ran). + // An update takes seconds, so an hour-old lock is certainly stale. Mirrors the + // plugin installer's reclaim so the two locks behave identically. + lockStaleAfter = 1 * time.Hour +) + +// Updater checks for and installs newer deckhouse-cli releases from a Source. +type Updater struct { + source Source + store *Store + logger *dkplog.Logger +} + +// NewUpdater builds an Updater over the given Source. The store is where +// versions are installed and switched; without it (nil) updates cannot proceed. +func NewUpdater(source Source, store *Store, logger *dkplog.Logger) *Updater { + return &Updater{source: source, store: store, logger: logger} +} + +// Versions returns the published release versions sorted newest-first. Tags that +// are not valid semver are skipped (foreign platforms' suffixed tags survive the +// source normalization as raw strings, but they parse fine and are kept - they +// carry their suffix as a pre-release marker). +func (u *Updater) Versions(ctx context.Context) ([]*semver.Version, error) { + tags, err := u.source.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("list deckhouse-cli versions: %w", err) + } + + versions := make([]*semver.Version, 0, len(tags)) + + for _, tag := range tags { + version, err := semver.NewVersion(tag) + if err != nil { + continue + } + + versions = append(versions, version) + } + + sort.Sort(sort.Reverse(semver.Collection(versions))) + + return versions, nil +} + +// LatestVersion returns the highest available STABLE semver tag and whether it is +// newer than current (pre-releases are ignored - install them explicitly via +// --version). A non-semver current (e.g. a "dev" build) is treated as older than +// any real release, so an update is always offered. +func (u *Updater) LatestVersion(ctx context.Context, current string) (string, bool, error) { + tags, err := u.source.ListTags(ctx) + if err != nil { + return "", false, fmt.Errorf("list deckhouse-cli versions: %w", err) + } + + latest := maxSemver(tags) + if latest == nil { + return "", false, errors.New("no released deckhouse-cli versions found") + } + + currentVersion, err := semver.NewVersion(current) + if err != nil { + return latest.Original(), true, nil + } + + return latest.Original(), currentVersion.LessThan(latest), nil +} + +// SwitchResult describes what a switch did, so commands can tell the user what +// was left behind and how to undo it. +type SwitchResult struct { + // PrevTag is the version `current` pointed at before the switch ("" when the + // install was not store-managed yet). + PrevTag string + // Migrated reports that the PATH entry was converted from a plain file to a + // symlink into the store (the original is backed up with OldSuffix). + Migrated bool +} + +// Apply downloads tag into the version store (unless already present) and makes +// it the active version by repointing the store's `current` symlink. A plain-file +// install is migrated to the symlink layout on the way (backup kept as <exe>.old). +func (u *Updater) Apply(ctx context.Context, tag string) (SwitchResult, error) { + exePath, err := CurrentExecutable() + if err != nil { + return SwitchResult{}, err + } + + return u.applyTo(ctx, exePath, tag) +} + +// applyTo performs the switch against an explicit executable path. It is +// separated from Apply so the logic can be tested without touching the running +// test binary. +func (u *Updater) applyTo(ctx context.Context, exePath, tag string) (SwitchResult, error) { + return SwitchTo(ctx, exePath, tag, u.store, u.logger, func(dst string) error { + if err := u.source.ExtractBinary(ctx, tag, dst); err != nil { + return fmt.Errorf("download new binary: %w", err) + } + + return nil + }) +} + +// CurrentExecutable resolves the running binary to a real file path (symlinks +// evaluated). For a store-managed install this lands inside the store (the +// `current` chain resolved), which is exactly how SwitchTo detects the mode. +func CurrentExecutable() (string, error) { + exePath, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate current executable: %w", err) + } + + exePath, err = filepath.EvalSymlinks(exePath) + if err != nil { + return "", fmt.Errorf("resolve executable path: %w", err) + } + + return exePath, nil +} + +// SwitchTo makes tag the active d8 version: ensures it is in the store (fetching +// via stage when missing; stage == nil demands a store hit), smoke-tests it and +// atomically repoints the store's `current` symlink. The PATH binary is never +// rewritten - it is a symlink into the store, so the switch happens entirely in +// the user's home, with no elevated privileges and no file copies. +// +// exePath is the resolved path of the running binary. When it lies outside the +// store (a plain-file install), the switch MIGRATES it: the running binary is +// seeded into the store under its own version, and the PATH file is backed up +// as <exe>.old and replaced with a symlink to the store's `current`. The same +// path heals an install whose symlink was overwritten by external tooling. +func SwitchTo(ctx context.Context, exePath, tag string, store *Store, logger *dkplog.Logger, stage func(dst string) error) (SwitchResult, error) { + if runtime.GOOS == "windows" { + // Windows cannot replace a running .exe, and the image entry is d8, not + // d8.exe. RPP is in-cluster (Linux/macOS), so this stays unsupported. + return SwitchResult{}, errors.New("self-update is not supported on Windows; download the new d8 binary manually") + } + + if store == nil { + return SwitchResult{}, errors.New("version store is unavailable (home directory cannot be resolved)") + } + + if err := os.MkdirAll(store.root, 0o755); err != nil { + return SwitchResult{}, fmt.Errorf("create version store: %w", err) + } + + release, err := acquireLock(store.lockPath(), logger) + if err != nil { + return SwitchResult{}, err + } + + defer release() + + managed := store.Contains(exePath) + + // A plain-file install is about to become store-managed: seed the store with + // the running binary first, so the displaced version stays switchable (and a + // `use` of the running version needs no download at all). Best-effort - a dev + // build (non-semver) cannot be addressed by `use` and is covered by the + // <exe>.old backup below anyway. + if !managed { + retain(ctx, store, exePath, version.Version, logger) + } + + if !store.has(tag) { + if stage == nil { + return SwitchResult{}, fmt.Errorf("version %s is not in the local store", tag) + } + + if err := store.install(ctx, tag, stage); err != nil { + return SwitchResult{}, err + } + } + + // Fresh installs were smoke-tested while staged; pre-existing entries are + // re-checked before they become the active binary. + if err := smokeTest(ctx, store.binaryPath(tag)); err != nil { + return SwitchResult{}, err + } + + result := SwitchResult{PrevTag: store.CurrentTag()} + + if err := store.switchCurrent(tag); err != nil { + return SwitchResult{}, err + } + + if !managed { + if err := migratePathEntry(exePath, store); err != nil { + restorePreviousCurrent(store, result.PrevTag, logger) + + return SwitchResult{}, err + } + + result.Migrated = true + } + + logger.Debug("switched deckhouse-cli version", + slog.String("tag", tag), slog.String("previous", result.PrevTag), slog.Bool("migrated", result.Migrated)) + + return result, nil +} + +// migratePathEntry converts a plain-file install into a symlink onto the store's +// `current`: the original binary is kept as <exe>.old, and a failed link +// creation rolls the backup straight back. +func migratePathEntry(exePath string, store *Store) error { + oldPath := exePath + OldSuffix + + if err := os.Rename(exePath, oldPath); err != nil { + return withPrivilegeHint(exePath, fmt.Errorf("back up current binary: %w", err)) + } + + if err := os.Symlink(store.currentLinkPath(), exePath); err != nil { + if restoreErr := os.Rename(oldPath, exePath); restoreErr != nil { + return fmt.Errorf("replacing the binary with a symlink failed (%w); restoring the previous binary also failed (%v) - restore it manually from %s", + err, restoreErr, oldPath) + } + + return withPrivilegeHint(exePath, fmt.Errorf("replace binary with a symlink: %w", err)) + } + + return nil +} + +// restorePreviousCurrent undoes switchCurrent after the PATH migration failed, so a +// failed switch does not leave `current` pointing at a version the PATH binary was +// never repointed to. Best-effort: the store lives in the user's home. +func restorePreviousCurrent(store *Store, prevTag string, logger *dkplog.Logger) { + var err error + if prevTag == "" { + err = os.Remove(store.currentLinkPath()) + } else { + err = store.switchCurrent(prevTag) + } + + if err != nil && !errors.Is(err, os.ErrNotExist) { + logger.Debug("could not restore previous current after failed migration", + slog.String("previous", prevTag), dkplog.Err(err)) + } +} + +// retain seeds the store with binPath under tag, best-effort: a failure (notably +// a non-semver tag - a dev build cannot be addressed by `use` anyway) must never +// fail the switch itself. +func retain(ctx context.Context, store *Store, binPath, tag string, logger *dkplog.Logger) { + if err := store.Archive(ctx, binPath, tag); err != nil { + logger.Debug("binary not retained in the version store", + slog.String("tag", tag), dkplog.Err(err)) + } +} + +// withPrivilegeHint augments a permission error with a hint, since the install +// directory (e.g. /opt/deckhouse/bin) is usually root-owned. +func withPrivilegeHint(exePath string, err error) error { + if !errors.Is(err, os.ErrPermission) { + return err + } + + dir := filepath.Dir(exePath) + + // HelpfulError so the top-level handler (cmd/d8/root.go) renders it with color + // and the actionable solutions, instead of the bare error prefix. + return &diagnostic.HelpfulError{ + Category: fmt.Sprintf("updating d8 needs write access to %s", dir), + OriginalErr: err, + Suggestions: []diagnostic.Suggestion{ + { + Cause: fmt.Sprintf("%s is not writable by the current user", dir), + Solutions: []string{ + "re-run the command with sudo", + "or install d8 in a user-writable directory (for example ~/.local/bin) and retry", + }, + }, + }, + } +} + +// smokeTest runs the freshly downloaded binary with --version so a corrupt or +// incompatible artifact is rejected before it replaces the working CLI. The +// binary's output is included in the error, turning a bare "exit status 1" into +// something diagnosable (missing shared lib, wrong arch, panic). +func smokeTest(ctx context.Context, binaryPath string) error { + ctx, cancel := context.WithTimeout(ctx, smokeTestTimeout) + defer cancel() + + if out, err := exec.CommandContext(ctx, binaryPath, "--version").CombinedOutput(); err != nil { + return fmt.Errorf("new binary failed its --version smoke test: %w%s", err, outputTail(out)) + } + + return nil +} + +// outputTail returns a short parenthesized tail of a failed probe's output, or "". +func outputTail(out []byte) string { + msg := strings.TrimSpace(string(out)) + if msg == "" { + return "" + } + + const maxLen = 200 + if len(msg) > maxLen { + msg = msg[:maxLen] + "..." + } + + return fmt.Sprintf(" (output: %s)", msg) +} + +// acquireLock serializes self-updates. A lock orphaned by a hard-killed update +// (older than lockStaleAfter) is reclaimed, so a SIGKILLed update does not block +// every future one. +func acquireLock(lockPath string, logger *dkplog.Logger) (func(), error) { + release, err := lockfile.Acquire(lockPath, lockStaleAfter, func(age time.Duration) { + logger.Warn("reclaiming a stale self-update lock", + slog.String("lock", lockPath), slog.Duration("age", age)) + }) + + if errors.Is(err, lockfile.ErrLocked) { + return nil, fmt.Errorf("an update is already in progress (lock file %s exists)", lockPath) + } + + if err != nil { + return nil, err + } + + return release, nil +} + +func maxSemver(tags []string) *semver.Version { + var latest *semver.Version + + for _, tag := range tags { + version, err := semver.NewVersion(tag) + if err != nil { + continue + } + + // The default update tracks stable releases only; pre-releases (rc/alpha/beta) + // are installable explicitly via --version. + if version.Prerelease() != "" { + continue + } + + if latest == nil || latest.LessThan(version) { + latest = version + } + } + + return latest +} diff --git a/internal/selfupdate/update_test.go b/internal/selfupdate/update_test.go new file mode 100644 index 00000000..69ff5d69 --- /dev/null +++ b/internal/selfupdate/update_test.go @@ -0,0 +1,227 @@ +/* +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 selfupdate + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" +) + +// fakeSource serves fixed tags and writes binaryContent as the extracted binary. +type fakeSource struct { + tags []string + binaryContent string + err error +} + +func (s fakeSource) ListTags(context.Context) ([]string, error) { + return s.tags, s.err +} + +func (s fakeSource) ExtractBinary(_ context.Context, _, destination string) error { + return os.WriteFile(destination, []byte(s.binaryContent), 0o755) +} + +func TestLatestVersion(t *testing.T) { + src := fakeSource{tags: []string{"v0.13.0", "v0.13.1", "latest"}} + updater := NewUpdater(src, nil, dkplog.NewNop()) + + tests := []struct { + name string + current string + wantTag string + wantNewer bool + }{ + {name: "older current", current: "v0.13.0", wantTag: "v0.13.1", wantNewer: true}, + {name: "current is latest", current: "v0.13.1", wantTag: "v0.13.1", wantNewer: false}, + {name: "non-semver dev build", current: "dev", wantTag: "v0.13.1", wantNewer: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + latest, newer, err := updater.LatestVersion(context.Background(), tt.current) + require.NoError(t, err) + assert.Equal(t, tt.wantTag, latest) + assert.Equal(t, tt.wantNewer, newer) + }) + } +} + +func TestLatestVersionIgnoresPrereleases(t *testing.T) { + updater := NewUpdater(fakeSource{tags: []string{"v0.13.1", "v0.14.0-rc1"}}, nil, dkplog.NewNop()) + + latest, newer, err := updater.LatestVersion(context.Background(), "v0.13.1") + require.NoError(t, err) + assert.Equal(t, "v0.13.1", latest, "a pre-release must not be offered as the latest stable") + assert.False(t, newer) +} + +func TestLatestVersionNoReleases(t *testing.T) { + updater := NewUpdater(fakeSource{tags: []string{"latest", "main"}}, nil, dkplog.NewNop()) + + _, _, err := updater.LatestVersion(context.Background(), "v1.0.0") + require.Error(t, err) +} + +func TestApplyMigratesPlainInstallAndKeepsBackup(t *testing.T) { + dir := t.TempDir() + exePath := filepath.Join(dir, "d8") + require.NoError(t, os.WriteFile(exePath, []byte("OLD"), 0o755)) + + // The "new binary" must run for the --version smoke test, so it is a tiny script. + newBinary := "#!/bin/sh\nexit 0\n" + store := &Store{root: filepath.Join(dir, "store")} + updater := NewUpdater(fakeSource{tags: []string{"v1.0.0"}, binaryContent: newBinary}, store, dkplog.NewNop()) + + res, err := updater.applyTo(context.Background(), exePath, "v1.0.0") + require.NoError(t, err) + assert.True(t, res.Migrated, "a plain-file install must be migrated to the symlink layout") + + info, err := os.Lstat(exePath) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink, "PATH entry must become a symlink") + + got, err := os.ReadFile(exePath) // follows the symlink chain into the store + require.NoError(t, err) + assert.Equal(t, newBinary, string(got)) + + assert.Equal(t, "v1.0.0", store.CurrentTag()) + assert.True(t, store.has("v1.0.0")) + + backup, err := os.ReadFile(exePath + OldSuffix) + require.NoError(t, err) + assert.Equal(t, "OLD", string(backup)) + + _, err = os.Stat(store.binaryPath("v1.0.0") + storeStagedSuffix) + assert.True(t, os.IsNotExist(err), "staged store entry must be cleaned up") + + _, err = os.Stat(store.lockPath()) + assert.True(t, os.IsNotExist(err), "lock file must be released") +} + +func TestAcquireLockReclaimsStale(t *testing.T) { + lock := filepath.Join(t.TempDir(), "d8.lock") + + // A fresh lock blocks a second acquirer. + require.NoError(t, os.WriteFile(lock, nil, 0o644)) + _, err := acquireLock(lock, dkplog.NewNop()) + assert.Error(t, err, "a fresh lock blocks") + + // A lock older than lockStaleAfter is reclaimed (orphaned by a hard kill), + // otherwise a SIGKILLed update would block every future update forever. + old := time.Now().Add(-2 * lockStaleAfter) + require.NoError(t, os.Chtimes(lock, old, old)) + + release, err := acquireLock(lock, dkplog.NewNop()) + require.NoError(t, err, "a stale lock is reclaimed") + require.NotNil(t, release) + + release() + + _, statErr := os.Stat(lock) + assert.True(t, os.IsNotExist(statErr), "release removes the lock") +} + +func TestApplyMigrationPermissionErrorGetsPrivilegeHint(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permissions") + } + + dir := t.TempDir() + exeDir := filepath.Join(dir, "bin") + require.NoError(t, os.MkdirAll(exeDir, 0o755)) + exePath := filepath.Join(exeDir, "d8") + require.NoError(t, os.WriteFile(exePath, []byte("OLD"), 0o755)) + + // The store lives in the (writable) home, but migrating the PATH entry needs + // write access to its directory - a read-only one must produce a sudo hint. + require.NoError(t, os.Chmod(exeDir, 0o555)) + t.Cleanup(func() { _ = os.Chmod(exeDir, 0o755) }) + + store := &Store{root: filepath.Join(dir, "store")} + updater := NewUpdater(fakeSource{tags: []string{"v1.0.0"}, binaryContent: "#!/bin/sh\nexit 0\n"}, store, dkplog.NewNop()) + + _, err := updater.applyTo(context.Background(), exePath, "v1.0.0") + require.Error(t, err) + + var he *diagnostic.HelpfulError + require.ErrorAs(t, err, &he, "a permission failure is a HelpfulError so the CLI colors it") + require.Len(t, he.Suggestions, 1) + assert.Contains(t, strings.Join(he.Suggestions[0].Solutions, " "), "sudo", + "the diagnostic points the user at sudo") + + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, "OLD", string(got), "original binary must be untouched on failure") +} + +func TestApplyMigrationFailureRollsBackCurrent(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permissions") + } + + dir := t.TempDir() + exeDir := filepath.Join(dir, "bin") + require.NoError(t, os.MkdirAll(exeDir, 0o755)) + exePath := filepath.Join(exeDir, "d8") + require.NoError(t, os.WriteFile(exePath, []byte("OLD"), 0o755)) + + // Read-only PATH dir: migratePathEntry's rename fails after current was switched. + require.NoError(t, os.Chmod(exeDir, 0o555)) + t.Cleanup(func() { _ = os.Chmod(exeDir, 0o755) }) + + store := &Store{root: filepath.Join(dir, "store")} + updater := NewUpdater(fakeSource{tags: []string{"v1.0.0"}, binaryContent: "#!/bin/sh\nexit 0\n"}, store, dkplog.NewNop()) + + _, err := updater.applyTo(context.Background(), exePath, "v1.0.0") + require.Error(t, err) + + assert.Empty(t, store.CurrentTag(), + "a failed migration must roll current back (fresh install: cleared), not leave it on the new tag") +} + +func TestApplyRejectsBinaryThatFailsSmokeTest(t *testing.T) { + dir := t.TempDir() + exePath := filepath.Join(dir, "d8") + require.NoError(t, os.WriteFile(exePath, []byte("OLD"), 0o755)) + + // A non-executable payload fails the --version smoke test; the original must + // stay, and the corrupt artifact must not become a visible store entry. + store := &Store{root: filepath.Join(dir, "store")} + updater := NewUpdater(fakeSource{tags: []string{"v1.0.0"}, binaryContent: "not a program"}, store, dkplog.NewNop()) + + _, err := updater.applyTo(context.Background(), exePath, "v1.0.0") + require.Error(t, err) + + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, "OLD", string(got), "original binary must be untouched on failure") + + assert.False(t, store.has("v1.0.0"), "a corrupt artifact must not land in the store") + assert.Empty(t, store.CurrentTag(), "current must not be switched on failure") +} diff --git a/internal/tools/sigmigrate/sigmigrate.go b/internal/tools/sigmigrate/sigmigrate.go index 7dc542c7..a39a5e30 100644 --- a/internal/tools/sigmigrate/sigmigrate.go +++ b/internal/tools/sigmigrate/sigmigrate.go @@ -347,8 +347,10 @@ func SigMigrate(cmd *cobra.Command, _ []string) error { if runState.traceFile != nil { traceWriteMu.Lock() + _ = runState.traceFile.Sync() _ = runState.traceFile.Close() + traceWriteMu.Unlock() } @@ -618,11 +620,13 @@ func collectAllObjects(discoveryClient discovery.DiscoveryInterface, dynamicClie if logLevel != "TRACE" { progressMu.Lock() + if shouldEmitProgress(current, totalResources, &lastProgressPercent, &lastProgressPrintedAt) { progress := int((current * 100) / totalResources) greenProgress := color.New(color.FgGreen).SprintFunc() fmt.Printf("\rCalculating: [%s] Processed Resource: %s ", greenProgress(fmt.Sprintf("%d%%", progress)), info.gvr.Resource) } + progressMu.Unlock() } @@ -630,6 +634,7 @@ func collectAllObjects(discoveryClient discovery.DiscoveryInterface, dynamicClie } objectsMu.Lock() + for _, item := range list.Items { namespace := item.GetNamespace() if namespace == "" { @@ -639,17 +644,20 @@ func collectAllObjects(discoveryClient discovery.DiscoveryInterface, dynamicClie name := item.GetName() upsertCollectedObject(objects, namespace, name, info.gvr, preferredByGroup) } + objectsMu.Unlock() current := atomic.AddInt64(&processed, 1) if logLevel != "TRACE" { progressMu.Lock() + if shouldEmitProgress(current, totalResources, &lastProgressPercent, &lastProgressPrintedAt) { progress := int((current * 100) / totalResources) greenProgress := color.New(color.FgGreen).SprintFunc() fmt.Printf("\rCalculating: [%s] Processed Resource: %s ", greenProgress(fmt.Sprintf("%d%%", progress)), info.gvr.Resource) } + progressMu.Unlock() } } @@ -730,9 +738,11 @@ func annotateObjects( if logLevel != "TRACE" { progressMu.Lock() + if shouldEmitProgress(current, total, &lastProgressPercent, &lastProgressPrintedAt) { printAnnotationProgress(current, total, obj) } + progressMu.Unlock() } @@ -753,9 +763,11 @@ func annotateObjects( if logLevel != "TRACE" { progressMu.Lock() + if shouldEmitProgress(current, total, &lastProgressPercent, &lastProgressPrintedAt) { printAnnotationProgress(current, total, obj) } + progressMu.Unlock() } } @@ -984,7 +996,9 @@ func processObjectAnnotation( tracef("method not supported after switch account for %s/%s/%s: %s", obj.Kind, obj.Namespace, obj.Name, formatServerErrorDetails(err)) unsupportedMu.Lock() + unsupportedTypes[obj.Kind] = true + unsupportedMu.Unlock() color.Yellow("\nAdding %s to unsupported annotation types due to MethodNotSupported (after trying switch account).\n", obj.Kind) recordSkippedObject(obj, "MethodNotSupported", fmt.Sprintf("After switching to account %s: %v", switchAccount, err)) @@ -1017,7 +1031,9 @@ func processObjectAnnotation( tracef("method not supported for %s/%s/%s: %s", obj.Kind, obj.Namespace, obj.Name, formatServerErrorDetails(err)) unsupportedMu.Lock() + unsupportedTypes[obj.Kind] = true + unsupportedMu.Unlock() color.Yellow("\nAdding %s to unsupported annotation types due to MethodNotSupported.\n", obj.Kind) recordSkippedObject(obj, "MethodNotSupported", fmt.Sprintf("Error: %v", err)) diff --git a/pkg/registry/service/contract.go b/pkg/registry/service/contract.go new file mode 100644 index 00000000..169337f1 --- /dev/null +++ b/pkg/registry/service/contract.go @@ -0,0 +1,193 @@ +/* +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 service + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/deckhouse/deckhouse-cli/internal" +) + +// UnmarshalContract decodes raw contract JSON into dst. +// It rewrites encoding/json's default errors as user-actionable messages. +// Wording stays identical across sources: a cache file or a +// registry-packages-proxy tar entry. +func UnmarshalContract(raw []byte, dst *PluginContract) error { + err := json.Unmarshal(raw, dst) + if err == nil { + return nil + } + + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + if (typeErr.Field == "requirements.modules" || typeErr.Field == "requirements.plugins") && typeErr.Value == "array" { + return fmt.Errorf("invalid contract: field %q must be an object with mandatory/conditional sections, got a JSON array", typeErr.Field) + } + + if typeErr.Field != "" { + return fmt.Errorf("invalid contract: field %q has wrong JSON type (got %s)", typeErr.Field, typeErr.Value) + } + + return fmt.Errorf("invalid contract: wrong JSON type (got %s)", typeErr.Value) + } + + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + return fmt.Errorf("invalid contract: malformed JSON at byte offset %d", syntaxErr.Offset) + } + + return fmt.Errorf("invalid contract: %w", err) +} + +// ContractToDomain converts PluginContract DTO to Plugin domain entity. +func ContractToDomain(contract *PluginContract) *internal.Plugin { + plugin := &internal.Plugin{ + Name: contract.Name, + Version: contract.Version, + Description: contract.Description, + Env: make([]internal.EnvVar, 0, len(contract.Env)), + Flags: make([]internal.Flag, 0, len(contract.Flags)), + } + + for _, envDTO := range contract.Env { + plugin.Env = append(plugin.Env, internal.EnvVar{Name: envDTO.Name}) + } + + for _, flagDTO := range contract.Flags { + plugin.Flags = append(plugin.Flags, internal.Flag{Name: flagDTO.Name}) + } + + plugin.Requirements = internal.Requirements{ + Kubernetes: internal.KubernetesRequirement{Constraint: contract.Requirements.Kubernetes.Constraint}, + Deckhouse: internal.DeckhouseRequirement{Constraint: contract.Requirements.Deckhouse.Constraint}, + Modules: moduleGroupToDomain(contract.Requirements.Modules), + Plugins: pluginGroupToDomain(contract.Requirements.Plugins), + } + + return plugin +} + +// DomainToContract converts Plugin domain entity to PluginContract DTO. +func DomainToContract(plugin *internal.Plugin) *PluginContract { + contract := &PluginContract{ + Name: plugin.Name, + Version: plugin.Version, + Description: plugin.Description, + Env: make([]EnvVarDTO, 0, len(plugin.Env)), + Flags: make([]FlagDTO, 0, len(plugin.Flags)), + Requirements: RequirementsDTO{ + Kubernetes: KubernetesRequirementDTO{Constraint: plugin.Requirements.Kubernetes.Constraint}, + Deckhouse: DeckhouseRequirementDTO{Constraint: plugin.Requirements.Deckhouse.Constraint}, + Modules: moduleGroupToDTO(plugin.Requirements.Modules), + Plugins: pluginGroupToDTO(plugin.Requirements.Plugins), + }, + } + + for _, env := range plugin.Env { + contract.Env = append(contract.Env, EnvVarDTO{Name: env.Name}) + } + + for _, flag := range plugin.Flags { + contract.Flags = append(contract.Flags, FlagDTO{Name: flag.Name}) + } + + return contract +} + +func pluginGroupToDomain(g PluginRequirementsGroupDTO) internal.PluginRequirementsGroup { + return internal.PluginRequirementsGroup{ + Mandatory: pluginReqsToDomain(g.Mandatory), + Conditional: pluginReqsToDomain(g.Conditional), + } +} + +func pluginGroupToDTO(g internal.PluginRequirementsGroup) PluginRequirementsGroupDTO { + return PluginRequirementsGroupDTO{ + Mandatory: pluginReqsToDTO(g.Mandatory), + Conditional: pluginReqsToDTO(g.Conditional), + } +} + +func pluginReqsToDomain(reqs []PluginRequirementDTO) []internal.PluginRequirement { + out := make([]internal.PluginRequirement, 0, len(reqs)) + for _, r := range reqs { + out = append(out, internal.PluginRequirement{Name: r.Name, Constraint: r.Constraint}) + } + + return out +} + +func pluginReqsToDTO(reqs []internal.PluginRequirement) []PluginRequirementDTO { + out := make([]PluginRequirementDTO, 0, len(reqs)) + for _, r := range reqs { + out = append(out, PluginRequirementDTO{Name: r.Name, Constraint: r.Constraint}) + } + + return out +} + +func moduleGroupToDomain(g ModuleRequirementsGroupDTO) internal.ModuleRequirementsGroup { + anyOf := make([]internal.AnyOfGroup, 0, len(g.AnyOf)) + for _, grp := range g.AnyOf { + anyOf = append(anyOf, internal.AnyOfGroup{ + Description: grp.Description, + Modules: moduleReqsToDomain(grp.Modules), + }) + } + + return internal.ModuleRequirementsGroup{ + Mandatory: moduleReqsToDomain(g.Mandatory), + Conditional: moduleReqsToDomain(g.Conditional), + AnyOf: anyOf, + } +} + +func moduleGroupToDTO(g internal.ModuleRequirementsGroup) ModuleRequirementsGroupDTO { + anyOf := make([]AnyOfGroupDTO, 0, len(g.AnyOf)) + for _, grp := range g.AnyOf { + anyOf = append(anyOf, AnyOfGroupDTO{ + Description: grp.Description, + Modules: moduleReqsToDTO(grp.Modules), + }) + } + + return ModuleRequirementsGroupDTO{ + Mandatory: moduleReqsToDTO(g.Mandatory), + Conditional: moduleReqsToDTO(g.Conditional), + AnyOf: anyOf, + } +} + +func moduleReqsToDomain(reqs []ModuleRequirementDTO) []internal.ModuleRequirement { + out := make([]internal.ModuleRequirement, 0, len(reqs)) + for _, r := range reqs { + out = append(out, internal.ModuleRequirement{Name: r.Name, Constraint: r.Constraint}) + } + + return out +} + +func moduleReqsToDTO(reqs []internal.ModuleRequirement) []ModuleRequirementDTO { + out := make([]ModuleRequirementDTO, 0, len(reqs)) + for _, r := range reqs { + out = append(out, ModuleRequirementDTO{Name: r.Name, Constraint: r.Constraint}) + } + + return out +} diff --git a/pkg/registry/service/dto_test.go b/pkg/registry/service/dto_test.go index cac8acef..3180839e 100644 --- a/pkg/registry/service/dto_test.go +++ b/pkg/registry/service/dto_test.go @@ -74,18 +74,18 @@ func TestPluginContract_FlatArrayRejected(t *testing.T) { } } -// TestUnmarshalContract_FriendlyArrayMessage: when a flat-array contract -// hits the production unmarshal path (unmarshalContract, used by both the -// OCI-annotation and file-load code paths), the error must be a -// user-actionable "invalid contract" message naming the offending field -// and the expected shape, NOT the raw encoding/json reflect-soup. +// A flat-array contract must produce a user-actionable error. +// The message names the offending field and the expected shape, +// not the raw encoding/json type error. +// UnmarshalContract is the shared path for OCI annotations, +// file loads, and registry-packages-proxy. func TestUnmarshalContract_FriendlyArrayMessage(t *testing.T) { in := []byte(`{ "name":"x","version":"v1.0.0", "requirements":{"modules":[{"name":"m","constraint":">=1.0.0"}]} }`) var c PluginContract - err := unmarshalContract(in, &c) + err := UnmarshalContract(in, &c) if err == nil { t.Fatal("expected error, got nil") } diff --git a/pkg/registry/service/plugin_service.go b/pkg/registry/service/plugin_service.go deleted file mode 100644 index 99bea688..00000000 --- a/pkg/registry/service/plugin_service.go +++ /dev/null @@ -1,425 +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 service - -import ( - "archive/tar" - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "os" - "runtime" - - v1 "github.com/google/go-containerregistry/pkg/v1" - - "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/deckhouse/pkg/registry" - regclient "github.com/deckhouse/deckhouse/pkg/registry/client" - - "github.com/deckhouse/deckhouse-cli/internal" -) - -const ( - PluginContractAnnotation = "contract" -) - -// PluginService provides high-level operations for plugin management -type PluginService struct { - client registry.Client - log *log.Logger -} - -// NewPluginService creates a new plugin service -func NewPluginService(client registry.Client, logger *log.Logger) *PluginService { - return &PluginService{ - client: client, - log: logger, - } -} - -// GetPluginContract reads the plugin contract from image metadata annotation -func (s *PluginService) GetPluginContract(ctx context.Context, pluginName, tag string) (*internal.Plugin, error) { - // Create a scoped client for this specific plugin - // The base client already has the path like "deckhouse/ee/plugins" - // We just need to add the plugin name - s.log.Debug("Getting plugin contract", slog.String("plugin", pluginName), slog.String("tag", tag)) - - pluginClient := s.client.WithSegment(pluginName) - - digestManifestResult, err := pluginClient.GetManifest(ctx, tag) - if err != nil { - return nil, fmt.Errorf("failed to get manifest: %w", err) - } - - var manifest registry.Manifest - - // multiarch manifests are just list of manifests, we need to get the image manifest - if digestManifestResult.GetMediaType().IsIndex() { - indexManifest, err := digestManifestResult.GetIndexManifest() - if err != nil { - return nil, fmt.Errorf("failed to get index manifest: %w", err) - } - - if len(indexManifest.GetManifests()) == 0 { - return nil, fmt.Errorf("no manifests found in index manifest") - } - - // hardcoded first manifest (all contracts must be the same for all manifests) - digest := indexManifest.GetManifests()[0].GetDigest() - if digest.String() == "" { - return nil, fmt.Errorf("no digest found in manifest") - } - - digestManifestResult, err = pluginClient.GetManifest(ctx, "@"+digest.String()) - if err != nil { - return nil, fmt.Errorf("failed to get manifest: %w", err) - } - - manifest, err = digestManifestResult.GetManifest() - if err != nil { - return nil, fmt.Errorf("failed to get manifest: %w", err) - } - } else { - manifest, err = digestManifestResult.GetManifest() - if err != nil { - return nil, fmt.Errorf("failed to get manifest: %w", err) - } - } - - if manifest.GetAnnotations() == nil { - return &internal.Plugin{ - Name: pluginName, - Version: tag, - }, nil - } - - annotations := manifest.GetAnnotations() - - contractB64, ok := annotations[PluginContractAnnotation] - if !ok || contractB64 == "" { - return nil, fmt.Errorf("contract annotation not found in image metadata") - } - - s.log.Debug("Contract base64 retrieved successfully", slog.String("contractb64", contractB64)) - - contractRaw, err := base64.StdEncoding.DecodeString(contractB64) - if err != nil { - return nil, fmt.Errorf("failed to decode contract: %w", err) - } - - s.log.Debug("Contract raw retrieved successfully", slog.String("contractraw", string(contractRaw))) - - contract := new(PluginContract) - if err := unmarshalContract(contractRaw, contract); err != nil { - return nil, err - } - - s.log.Debug("Plugin contract parsed successfully", slog.String("plugin", pluginName), slog.String("tag", tag), slog.String("name", contract.Name), slog.String("version", contract.Version)) - - // Convert to domain entity - return ContractToDomain(contract), nil -} - -// unmarshalContract decodes raw JSON into a PluginContract and rewrites -// encoding/json's verbose default errors as user-actionable messages. -// Used by every caller that turns a contract blob into a domain object so -// the wording stays identical whether the source is an OCI annotation or -// a local file. -func unmarshalContract(raw []byte, dst *PluginContract) error { - err := json.Unmarshal(raw, dst) - if err == nil { - return nil - } - - var typeErr *json.UnmarshalTypeError - if errors.As(err, &typeErr) { - if (typeErr.Field == "requirements.modules" || typeErr.Field == "requirements.plugins") && typeErr.Value == "array" { - return fmt.Errorf("invalid contract: field %q must be an object with mandatory/conditional sections, got a JSON array", typeErr.Field) - } - - if typeErr.Field != "" { - return fmt.Errorf("invalid contract: field %q has wrong JSON type (got %s)", typeErr.Field, typeErr.Value) - } - - return fmt.Errorf("invalid contract: wrong JSON type (got %s)", typeErr.Value) - } - - var syntaxErr *json.SyntaxError - if errors.As(err, &syntaxErr) { - return fmt.Errorf("invalid contract: malformed JSON at byte offset %d", syntaxErr.Offset) - } - - return fmt.Errorf("invalid contract: %w", err) -} - -// GetPluginContractFromFile reads the plugin contract from a file -func GetPluginContractFromFile(contractFilePath string) (*internal.Plugin, error) { - contractBytes, err := os.ReadFile(contractFilePath) - if err != nil { - return nil, fmt.Errorf("failed to read contract file: %w", err) - } - - contract := new(PluginContract) - if err := unmarshalContract(contractBytes, contract); err != nil { - return nil, err - } - - return ContractToDomain(contract), nil -} - -// ExtractPlugin downloads the plugin image and extracts it to the specified location -func (s *PluginService) ExtractPlugin(ctx context.Context, pluginName, tag, destination string) error { - // Create a scoped client for this specific plugin - s.log.Debug("Extracting plugin", slog.String("plugin", pluginName), slog.String("tag", tag), slog.String("destination", destination)) - - pluginClient := s.client.WithSegment(pluginName) - - platform := &v1.Platform{Architecture: runtime.GOARCH, OS: runtime.GOOS} - - img, err := pluginClient.GetImage(ctx, tag, regclient.WithPlatform{Platform: platform}) - if err != nil { - return fmt.Errorf("failed to get image: %w", err) - } - - err = s.extractPluginFromTar(img.Extract(), destination, pluginName) - if err != nil { - return fmt.Errorf("failed to extract tar: %w", err) - } - - return nil -} - -// extractPluginFromTar extracts the plugin binary from a tar archive to the destination directory -func (s *PluginService) extractPluginFromTar(r io.Reader, destination, pluginName string) error { - s.log.Debug("Starting plugin extraction from tar archive", - slog.String("destination", destination), - slog.String("plugin", pluginName)) - - tr := tar.NewReader(r) - - for { - header, err := tr.Next() - if err == io.EOF { - break // End of archive - } - - if err != nil { - return fmt.Errorf("failed to read tar header: %w", err) - } - - // only extract regular files named "plugin" - if header.Name == "plugin" { - outFile, err := os.OpenFile(destination, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) - if err != nil { - return fmt.Errorf("failed to create plugin file %s: %w", destination, err) - } - defer outFile.Close() - - if _, err := io.Copy(outFile, tr); err != nil { - return fmt.Errorf("failed to write plugin content to %s: %w", destination, err) - } - - break // plugin found and extracted, no need to continue - } - } - - s.log.Debug("Plugin extraction completed successfully", - slog.String("destination", destination), - slog.String("plugin", pluginName)) - - return nil -} - -// ContractToDomain converts PluginContract DTO to Plugin domain entity -func ContractToDomain(contract *PluginContract) *internal.Plugin { - // Note: This is a pure conversion function, no logging needed as it's called from GetPluginContract - plugin := &internal.Plugin{ - Name: contract.Name, - Version: contract.Version, - Description: contract.Description, - Env: make([]internal.EnvVar, 0, len(contract.Env)), - Flags: make([]internal.Flag, 0, len(contract.Flags)), - } - - for _, envDTO := range contract.Env { - plugin.Env = append(plugin.Env, internal.EnvVar{Name: envDTO.Name}) - } - - for _, flagDTO := range contract.Flags { - plugin.Flags = append(plugin.Flags, internal.Flag{Name: flagDTO.Name}) - } - - plugin.Requirements = internal.Requirements{ - Kubernetes: internal.KubernetesRequirement{Constraint: contract.Requirements.Kubernetes.Constraint}, - Deckhouse: internal.DeckhouseRequirement{Constraint: contract.Requirements.Deckhouse.Constraint}, - Modules: moduleGroupToDomain(contract.Requirements.Modules), - Plugins: pluginGroupToDomain(contract.Requirements.Plugins), - } - - return plugin -} - -// DomainToContract converts Plugin domain entity to PluginContract DTO -func DomainToContract(plugin *internal.Plugin) *PluginContract { - contract := &PluginContract{ - Name: plugin.Name, - Version: plugin.Version, - Description: plugin.Description, - Env: make([]EnvVarDTO, 0, len(plugin.Env)), - Flags: make([]FlagDTO, 0, len(plugin.Flags)), - Requirements: RequirementsDTO{ - Kubernetes: KubernetesRequirementDTO{Constraint: plugin.Requirements.Kubernetes.Constraint}, - Deckhouse: DeckhouseRequirementDTO{Constraint: plugin.Requirements.Deckhouse.Constraint}, - Modules: moduleGroupToDTO(plugin.Requirements.Modules), - Plugins: pluginGroupToDTO(plugin.Requirements.Plugins), - }, - } - - for _, env := range plugin.Env { - contract.Env = append(contract.Env, EnvVarDTO{Name: env.Name}) - } - - for _, flag := range plugin.Flags { - contract.Flags = append(contract.Flags, FlagDTO{Name: flag.Name}) - } - - return contract -} - -func pluginGroupToDomain(g PluginRequirementsGroupDTO) internal.PluginRequirementsGroup { - return internal.PluginRequirementsGroup{ - Mandatory: pluginReqsToDomain(g.Mandatory), - Conditional: pluginReqsToDomain(g.Conditional), - } -} - -func pluginGroupToDTO(g internal.PluginRequirementsGroup) PluginRequirementsGroupDTO { - return PluginRequirementsGroupDTO{ - Mandatory: pluginReqsToDTO(g.Mandatory), - Conditional: pluginReqsToDTO(g.Conditional), - } -} - -func pluginReqsToDomain(reqs []PluginRequirementDTO) []internal.PluginRequirement { - out := make([]internal.PluginRequirement, 0, len(reqs)) - for _, r := range reqs { - out = append(out, internal.PluginRequirement{Name: r.Name, Constraint: r.Constraint}) - } - - return out -} - -func pluginReqsToDTO(reqs []internal.PluginRequirement) []PluginRequirementDTO { - out := make([]PluginRequirementDTO, 0, len(reqs)) - for _, r := range reqs { - out = append(out, PluginRequirementDTO{Name: r.Name, Constraint: r.Constraint}) - } - - return out -} - -func moduleGroupToDomain(g ModuleRequirementsGroupDTO) internal.ModuleRequirementsGroup { - anyOf := make([]internal.AnyOfGroup, 0, len(g.AnyOf)) - for _, grp := range g.AnyOf { - anyOf = append(anyOf, internal.AnyOfGroup{ - Description: grp.Description, - Modules: moduleReqsToDomain(grp.Modules), - }) - } - - return internal.ModuleRequirementsGroup{ - Mandatory: moduleReqsToDomain(g.Mandatory), - Conditional: moduleReqsToDomain(g.Conditional), - AnyOf: anyOf, - } -} - -func moduleGroupToDTO(g internal.ModuleRequirementsGroup) ModuleRequirementsGroupDTO { - anyOf := make([]AnyOfGroupDTO, 0, len(g.AnyOf)) - for _, grp := range g.AnyOf { - anyOf = append(anyOf, AnyOfGroupDTO{ - Description: grp.Description, - Modules: moduleReqsToDTO(grp.Modules), - }) - } - - return ModuleRequirementsGroupDTO{ - Mandatory: moduleReqsToDTO(g.Mandatory), - Conditional: moduleReqsToDTO(g.Conditional), - AnyOf: anyOf, - } -} - -func moduleReqsToDomain(reqs []ModuleRequirementDTO) []internal.ModuleRequirement { - out := make([]internal.ModuleRequirement, 0, len(reqs)) - for _, r := range reqs { - out = append(out, internal.ModuleRequirement{Name: r.Name, Constraint: r.Constraint}) - } - - return out -} - -func moduleReqsToDTO(reqs []internal.ModuleRequirement) []ModuleRequirementDTO { - out := make([]ModuleRequirementDTO, 0, len(reqs)) - for _, r := range reqs { - out = append(out, ModuleRequirementDTO{Name: r.Name, Constraint: r.Constraint}) - } - - return out -} - -// ListPlugins lists all available plugin names from the registry -// Note: This requires the registry to support the catalog API and grant access to it. -// If the registry doesn't allow catalog access, this will return an error. -func (s *PluginService) ListPlugins(ctx context.Context) ([]string, error) { - s.log.Debug("Listing all plugins") - - // The client is already scoped to "deckhouse/ee/modules" - // ListRepositories will return the plugin names directly (tags under that path) - pluginNames, err := s.client.ListTags(ctx) - if err != nil { - s.log.Warn("Failed to list repositories from registry. The registry may not allow catalog access or you may need special permissions.", - slog.String("error", err.Error())) - - return nil, fmt.Errorf("failed to list repositories (registry may not allow catalog access): %w", err) - } - - s.log.Debug("Plugins listed successfully", slog.Int("count", len(pluginNames))) - - return pluginNames, nil -} - -// ListPluginTags lists all available tags for a specific plugin -func (s *PluginService) ListPluginTags(ctx context.Context, pluginName string) ([]string, error) { - // Create a scoped client for this specific plugin - s.log.Debug("Listing plugin tags", slog.String("plugin", pluginName)) - - pluginClient := s.client.WithSegment(pluginName) - - tags, err := pluginClient.ListTags(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list tags for plugin %s: %w", pluginName, err) - } - - s.log.Debug("Plugin tags listed successfully", slog.String("plugin", pluginName), slog.Int("count", len(tags))) - - return tags, nil -} diff --git a/pkg/registry/service/plugin_service_test.go b/pkg/registry/service/plugin_service_test.go deleted file mode 100644 index ad3fbc00..00000000 --- a/pkg/registry/service/plugin_service_test.go +++ /dev/null @@ -1,859 +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 service_test - -import ( - "archive/tar" - "bytes" - "context" - "encoding/base64" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/gojuno/minimock/v3" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/types" - - "github.com/deckhouse/deckhouse/pkg/log" - - "github.com/deckhouse/deckhouse-cli/pkg/mock" - "github.com/deckhouse/deckhouse-cli/pkg/registry/service" - registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" - "github.com/deckhouse/deckhouse/pkg/registry" - "github.com/deckhouse/deckhouse/pkg/registry/client" -) - -// DescriptorStub implements registry.Descriptor -type DescriptorStub struct { - MediaType string - Size int64 - Digest string -} - -func (d *DescriptorStub) GetAnnotations() map[string]string { - return map[string]string{} -} - -func (d *DescriptorStub) GetArtifactType() string { - return "" -} - -func (d *DescriptorStub) GetData() []byte { - return nil -} - -func (d *DescriptorStub) GetDigest() v1.Hash { - h, _ := v1.NewHash(d.Digest) - return h -} - -func (d *DescriptorStub) GetMediaType() types.MediaType { - return types.MediaType(d.MediaType) -} - -func (d *DescriptorStub) GetSize() int64 { - return d.Size -} - -func (d *DescriptorStub) GetPlatform() *v1.Platform { - return nil -} - -func (d *DescriptorStub) GetURLs() []string { - return nil -} - -// ManifestStub implements registry.Manifest -type ManifestStub struct { - data []byte -} - -func (m *ManifestStub) GetAnnotations() map[string]string { - var manifest map[string]interface{} - err := json.Unmarshal(m.data, &manifest) - if err != nil { - return nil - } - if annotations, ok := manifest["annotations"].(map[string]interface{}); ok { - result := make(map[string]string) - for k, v := range annotations { - if s, ok := v.(string); ok { - result[k] = s - } - } - return result - } - return nil -} - -func (m *ManifestStub) GetConfig() registry.Descriptor { - return &DescriptorStub{ - MediaType: "application/vnd.docker.container.image.v1+json", - Size: 1469, - Digest: "sha256:b5d2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7", - } -} - -func (m *ManifestStub) GetLayers() []registry.Descriptor { - return []registry.Descriptor{ - &DescriptorStub{ - MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", - Size: 32654, - Digest: "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f", - }, - } -} - -func (m *ManifestStub) GetMediaType() types.MediaType { - return "application/vnd.docker.distribution.manifest.v2+json" -} - -func (m *ManifestStub) GetSchemaVersion() int64 { - return 2 -} - -func (m *ManifestStub) GetSubject() registry.Descriptor { - return nil -} - -// ManifestResultStub implements registry.ManifestResult -type ManifestResultStub struct { - data []byte -} - -func (m *ManifestResultStub) RawManifest() []byte { - return m.data -} - -func (m *ManifestResultStub) GetDescriptor() registry.Descriptor { - return &DescriptorStub{ - MediaType: "application/vnd.docker.distribution.manifest.v2+json", - Size: int64(len(m.data)), - Digest: "sha256:stub", - } -} - -func (m *ManifestResultStub) GetIndexManifest() (registry.IndexManifest, error) { - return nil, nil -} - -func (m *ManifestResultStub) GetMediaType() types.MediaType { - return "application/vnd.docker.distribution.manifest.v2+json" -} - -func (m *ManifestResultStub) GetManifest() (registry.Manifest, error) { - return &ManifestStub{data: m.data}, nil -} - -func TestGetPluginContract_Success(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - contractJSON := `{"name": "test-plugin", "version": "v1.0.0", "description": "A test plugin", "env": [{"name": "TEST_ENV"}], "flags": [{"name": "--test-flag"}], "requirements": {"kubernetes": {"constraint": ">= 1.26"}, "modules": {"mandatory": [{"name": "test-module", "constraint": ">= 1.0.0"}]}}}` - contractB64 := base64.StdEncoding.EncodeToString([]byte(contractJSON)) - manifestJSON := `{"annotations": {"` + service.PluginContractAnnotation + `": "` + contractB64 + `"}}` - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetManifestMock. - Expect(context.Background(), "v1.0.0"). - Return(&ManifestResultStub{data: []byte(manifestJSON)}, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - plugin, err := service.GetPluginContract(context.Background(), "test-plugin", "v1.0.0") - - // Assert - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if plugin == nil { - t.Fatal("Expected plugin to be non-nil") - } - - if plugin.Name != "test-plugin" { - t.Errorf("Expected name 'test-plugin', got '%s'", plugin.Name) - } - - if plugin.Version != "v1.0.0" { - t.Errorf("Expected version 'v1.0.0', got '%s'", plugin.Version) - } - - if plugin.Description != "A test plugin" { - t.Errorf("Expected description 'A test plugin', got '%s'", plugin.Description) - } - - if len(plugin.Env) != 1 || plugin.Env[0].Name != "TEST_ENV" { - t.Errorf("Expected 1 env var 'TEST_ENV', got: %+v", plugin.Env) - } - - if len(plugin.Flags) != 1 || plugin.Flags[0].Name != "--test-flag" { - t.Errorf("Expected 1 flag '--test-flag', got: %+v", plugin.Flags) - } - - if plugin.Requirements.Kubernetes.Constraint != ">= 1.26" { - t.Errorf("Expected kubernetes constraint '>= 1.26', got '%s'", plugin.Requirements.Kubernetes.Constraint) - } - - if len(plugin.Requirements.Modules.Mandatory) != 1 { - t.Fatalf("Expected 1 mandatory module requirement, got %d", len(plugin.Requirements.Modules.Mandatory)) - } - - if plugin.Requirements.Modules.Mandatory[0].Name != "test-module" { - t.Errorf("Expected module name 'test-module', got '%s'", plugin.Requirements.Modules.Mandatory[0].Name) - } - - if plugin.Requirements.Modules.Mandatory[0].Constraint != ">= 1.0.0" { - t.Errorf("Expected module constraint '>= 1.0.0', got '%s'", plugin.Requirements.Modules.Mandatory[0].Constraint) - } -} - -func TestGetPluginContract_MinimalContract(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - contractJSON := `{"name": "minimal-plugin", "version": "v1.0.0", "description": "Minimal plugin"}` - contractB64 := base64.StdEncoding.EncodeToString([]byte(contractJSON)) - manifestJSON := `{"annotations": {"` + service.PluginContractAnnotation + `": "` + contractB64 + `"}}` - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetManifestMock. - Expect(context.Background(), "v1.0.0"). - Return(&ManifestResultStub{data: []byte(manifestJSON)}, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("minimal-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - plugin, err := service.GetPluginContract(context.Background(), "minimal-plugin", "v1.0.0") - - // Assert - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if plugin == nil { - t.Fatal("Expected plugin to be non-nil") - } - - if plugin.Name != "minimal-plugin" { - t.Errorf("Expected name 'minimal-plugin', got '%s'", plugin.Name) - } - - if len(plugin.Env) != 0 { - t.Errorf("Expected 0 env vars, got %d", len(plugin.Env)) - } - - if len(plugin.Flags) != 0 { - t.Errorf("Expected 0 flags, got %d", len(plugin.Flags)) - } -} - -func TestGetPluginContract_LabelNotFound(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - manifestJSON := `{"annotations": {}}` // no contract annotation - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetManifestMock. - Expect(context.Background(), "v1.0.0"). - Return(&ManifestResultStub{data: []byte(manifestJSON)}, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - plugin, err := service.GetPluginContract(context.Background(), "test-plugin", "v1.0.0") - - // Assert - if err == nil { - t.Fatal("Expected error, got nil") - } - - if plugin != nil { - t.Errorf("Expected plugin to be nil, got: %+v", plugin) - } - - expectedError := "contract annotation not found in image metadata" - if err.Error() != expectedError { - t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) - } -} - -func TestGetPluginContract_GetLabelError(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - expectedErr := errors.New("registry connection failed") - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetManifestMock. - Expect(context.Background(), "v1.0.0"). - Return(nil, expectedErr) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - plugin, err := service.GetPluginContract(context.Background(), "test-plugin", "v1.0.0") - - // Assert - if err == nil { - t.Fatal("Expected error, got nil") - } - - if plugin != nil { - t.Errorf("Expected plugin to be nil, got: %+v", plugin) - } - - if !errors.Is(err, expectedErr) { - t.Errorf("Expected error to wrap registry error, got: %v", err) - } -} - -func TestGetPluginContract_InvalidJSON(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - invalidContractJSON := `{invalid json` - contractB64 := base64.StdEncoding.EncodeToString([]byte(invalidContractJSON)) - manifestJSON := `{"annotations": {"` + service.PluginContractAnnotation + `": "` + contractB64 + `"}}` - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetManifestMock. - Expect(context.Background(), "v1.0.0"). - Return(&ManifestResultStub{data: []byte(manifestJSON)}, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - plugin, err := service.GetPluginContract(context.Background(), "test-plugin", "v1.0.0") - - // Assert - if err == nil { - t.Fatal("Expected error, got nil") - } - - if plugin != nil { - t.Errorf("Expected plugin to be nil, got: %+v", plugin) - } -} - -func TestGetPluginContract_EmptyJSON(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - emptyContractJSON := `{}` - contractB64 := base64.StdEncoding.EncodeToString([]byte(emptyContractJSON)) - manifestJSON := `{"annotations": {"` + service.PluginContractAnnotation + `": "` + contractB64 + `"}}` - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetManifestMock. - Expect(context.Background(), "v1.0.0"). - Return(&ManifestResultStub{data: []byte(manifestJSON)}, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - plugin, err := service.GetPluginContract(context.Background(), "test-plugin", "v1.0.0") - - // Assert - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - if plugin.Name != "" { - t.Errorf("Expected empty name, got '%s'", plugin.Name) - } -} - -func TestExtractPlugin_Success(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - tmpDir := t.TempDir() - - // Create a tar archive in memory - var tarBuf bytes.Buffer - tw := tar.NewWriter(&tarBuf) - - // Add a directory - err := tw.WriteHeader(&tar.Header{ - Name: "bin/", - Mode: 0755, - Typeflag: tar.TypeDir, - }) - if err != nil { - t.Fatalf("Failed to write tar directory header: %v", err) - } - - // Add a file - fileContent := []byte("#!/bin/bash\necho 'test plugin'\n") - err = tw.WriteHeader(&tar.Header{ - Name: "plugin", - Mode: 0755, - Size: int64(len(fileContent)), - Typeflag: tar.TypeReg, - }) - if err != nil { - t.Fatalf("Failed to write tar file header: %v", err) - } - - _, err = tw.Write(fileContent) - if err != nil { - t.Fatalf("Failed to write tar file content: %v", err) - } - - tw.Close() - - mockImage := mock.NewRegistryImageMock(mc) - mockImage.ExtractMock.Return(io.NopCloser(&tarBuf)) - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetImageMock. - Expect(context.Background(), "v1.0.0", client.WithPlatform{Platform: &v1.Platform{Architecture: runtime.GOARCH, OS: runtime.GOOS}}). - Return(mockImage, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - err = service.ExtractPlugin(context.Background(), "test-plugin", "v1.0.0", tmpDir+"/test-plugin") - - // Assert - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - // Verify directory was not created - dirPath := filepath.Join(tmpDir, "bin") - if _, err := os.Stat(dirPath); !os.IsNotExist(err) { - t.Errorf("Expected directory '%s' to not exist", dirPath) - } - - // Verify file was created - filePath := filepath.Join(tmpDir, "test-plugin") - if _, err := os.Stat(filePath); os.IsNotExist(err) { - t.Errorf("Expected file '%s' to exist", filePath) - } - - // Verify file content - content, err := os.ReadFile(filePath) - if err != nil { - t.Fatalf("Failed to read extracted file: %v", err) - } - - if !bytes.Equal(content, fileContent) { - t.Errorf("Expected file content '%s', got '%s'", fileContent, content) - } - - // Verify file permissions - info, err := os.Stat(filePath) - if err != nil { - t.Fatalf("Failed to stat file: %v", err) - } - - expectedMode := os.FileMode(0755) - if info.Mode().Perm() != expectedMode { - t.Errorf("Expected file mode %v, got %v", expectedMode, info.Mode().Perm()) - } -} - -func TestExtractPlugin_MultipleLayersSuccess(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - tmpDir := t.TempDir() - - // Create a tar with multiple files, but only "plugin" should be extracted - var combinedTar bytes.Buffer - tw := tar.NewWriter(&combinedTar) - - // Add a non-plugin file (should be ignored) - ignoredContent := []byte("ignored") - tw.WriteHeader(&tar.Header{ - Name: "file1.txt", - Mode: 0644, - Size: int64(len(ignoredContent)), - Typeflag: tar.TypeReg, - }) - tw.Write(ignoredContent) - - // Add the plugin file (should be extracted) - pluginContent := []byte("plugin content") - tw.WriteHeader(&tar.Header{ - Name: "plugin", - Mode: 0755, - Size: int64(len(pluginContent)), - Typeflag: tar.TypeReg, - }) - tw.Write(pluginContent) - - tw.Close() - - mockImage := mock.NewRegistryImageMock(mc) - mockImage.ExtractMock.Return(io.NopCloser(&combinedTar)) - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetImageMock. - Expect(context.Background(), "v1.0.0", client.WithPlatform{Platform: &v1.Platform{Architecture: runtime.GOARCH, OS: runtime.GOOS}}). - Return(mockImage, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - err := service.ExtractPlugin(context.Background(), "test-plugin", "v1.0.0", tmpDir+"/test-plugin") - - // Assert - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - // Verify only the plugin file was extracted (renamed to test-plugin) - pluginPath := filepath.Join(tmpDir, "test-plugin") - ignoredPath := filepath.Join(tmpDir, "file1.txt") - - // Plugin file should exist - if _, err := os.Stat(pluginPath); os.IsNotExist(err) { - t.Errorf("Expected plugin file '%s' to exist", pluginPath) - } - - // Ignored file should not exist - if _, err := os.Stat(ignoredPath); !os.IsNotExist(err) { - t.Errorf("Expected ignored file '%s' to not exist", ignoredPath) - } - - // Verify plugin file content - content, err := os.ReadFile(pluginPath) - if err != nil { - t.Fatalf("Failed to read plugin file: %v", err) - } - if string(content) != "plugin content" { - t.Errorf("Expected plugin content 'plugin content', got '%s'", content) - } -} - -func TestExtractPlugin_ExtractImageLayersError(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - tmpDir := t.TempDir() - expectedErr := errors.New("failed to get image") - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetImageMock. - Expect(context.Background(), "v1.0.0", client.WithPlatform{Platform: &v1.Platform{Architecture: runtime.GOARCH, OS: runtime.GOOS}}). - Return(nil, expectedErr) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - err := service.ExtractPlugin(context.Background(), "test-plugin", "v1.0.0", tmpDir+"/test-plugin") - - // Assert - if err == nil { - t.Fatal("Expected error, got nil") - } - - if !errors.Is(err, expectedErr) { - t.Errorf("Expected error to wrap registry error, got: %v", err) - } -} - -func TestExtractPlugin_PathTraversalAttempt(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - tmpDir := t.TempDir() - - // Create a tar archive with path traversal attempt - var tarBuf bytes.Buffer - tw := tar.NewWriter(&tarBuf) - - fileContent := []byte("malicious content") - err := tw.WriteHeader(&tar.Header{ - Name: "../../../etc/passwd", - Mode: 0644, - Size: int64(len(fileContent)), - Typeflag: tar.TypeReg, - }) - if err != nil { - t.Fatalf("Failed to write tar header: %v", err) - } - - _, err = tw.Write(fileContent) - if err != nil { - t.Fatalf("Failed to write tar content: %v", err) - } - - tw.Close() - - mockImage := mock.NewRegistryImageMock(mc) - mockImage.ExtractMock.Return(io.NopCloser(&tarBuf)) - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetImageMock. - Expect(context.Background(), "v1.0.0", client.WithPlatform{Platform: &v1.Platform{Architecture: runtime.GOARCH, OS: runtime.GOOS}}). - Return(mockImage, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - err = service.ExtractPlugin(context.Background(), "test-plugin", "v1.0.0", tmpDir) - - // Assert - // Current implementation does not prevent path traversal, so no error is expected - // This test documents the current behavior but should be updated when path traversal protection is added - if err != nil { - t.Fatalf("Expected no error (current implementation doesn't check path traversal), got: %v", err) - } -} - -func TestExtractPlugin_CreateDestinationError(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - // Use a path that can be created - validDir := "/tmp/deckhouse-test-destination" - - // Create a simple tar - var tarBuf bytes.Buffer - tw := tar.NewWriter(&tarBuf) - tw.WriteHeader(&tar.Header{ - Name: "file.txt", - Mode: 0644, - Size: 0, - Typeflag: tar.TypeReg, - }) - tw.Close() - - mockImage := mock.NewRegistryImageMock(mc) - mockImage.ExtractMock.Return(io.NopCloser(&tarBuf)) - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetImageMock.Return(mockImage, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock.Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - err := service.ExtractPlugin(context.Background(), "test-plugin", "v1.0.0", validDir+"/test-plugin") - - // Assert - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } -} - -func TestExtractPlugin_EmptyRepository(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - tmpDir := t.TempDir() - - // Empty tar (no files) - var tarBuf bytes.Buffer - tw := tar.NewWriter(&tarBuf) - tw.Close() - - mockImage := mock.NewRegistryImageMock(mc) - mockImage.ExtractMock.Return(io.NopCloser(&tarBuf)) - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetImageMock. - Expect(context.Background(), "v1.0.0", client.WithPlatform{Platform: &v1.Platform{Architecture: runtime.GOARCH, OS: runtime.GOOS}}). - Return(mockImage, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - err := service.ExtractPlugin(context.Background(), "test-plugin", "v1.0.0", tmpDir+"/test-plugin") - - // Assert - if err != nil { - t.Fatalf("Expected no error for empty tar, got: %v", err) - } - - // Verify destination directory was created but is empty - entries, err := os.ReadDir(tmpDir) - if err != nil { - t.Fatalf("Failed to read destination directory: %v", err) - } - - if len(entries) != 0 { - t.Errorf("Expected destination directory to be empty, got %d entries", len(entries)) - } -} - -func TestExtractPlugin_NestedDirectories(t *testing.T) { - // Arrange - mc := minimock.NewController(t) - - tmpDir := t.TempDir() - - // Create a tar archive with nested directories and a plugin file - var tarBuf bytes.Buffer - tw := tar.NewWriter(&tarBuf) - - // Add nested directories (ignored by current implementation) - tw.WriteHeader(&tar.Header{ - Name: "a/", - Mode: 0755, - Typeflag: tar.TypeDir, - }) - - tw.WriteHeader(&tar.Header{ - Name: "a/b/", - Mode: 0755, - Typeflag: tar.TypeDir, - }) - - tw.WriteHeader(&tar.Header{ - Name: "a/b/c/", - Mode: 0755, - Typeflag: tar.TypeDir, - }) - - // Add a plugin file (this will be extracted, but directories are ignored) - fileContent := []byte("nested file") - tw.WriteHeader(&tar.Header{ - Name: "plugin", - Mode: 0644, - Size: int64(len(fileContent)), - Typeflag: tar.TypeReg, - }) - tw.Write(fileContent) - - tw.Close() - - mockImage := mock.NewRegistryImageMock(mc) - mockImage.ExtractMock.Return(io.NopCloser(&tarBuf)) - - mockScopedClient := mock.NewRegistryClientMock(mc) - mockScopedClient.GetImageMock. - Expect(context.Background(), "v1.0.0", client.WithPlatform{Platform: &v1.Platform{Architecture: runtime.GOARCH, OS: runtime.GOOS}}). - Return(mockImage, nil) - - mockClient := mock.NewRegistryClientMock(mc) - mockClient.WithSegmentMock. - Expect("test-plugin"). - Return(mockScopedClient) - - logger := log.NewNop() - service := registryservice.NewPluginService(mockClient, logger) - - // Act - err := service.ExtractPlugin(context.Background(), "test-plugin", "v1.0.0", tmpDir+"/test-plugin") - - // Assert - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - // Verify plugin file was extracted (directories are ignored by current implementation) - pluginPath := filepath.Join(tmpDir, "test-plugin") - if _, err := os.Stat(pluginPath); os.IsNotExist(err) { - t.Errorf("Expected plugin file '%s' to exist", pluginPath) - } - - // Verify directories were not created - dirPath := filepath.Join(tmpDir, "a", "b", "c") - if _, err := os.Stat(dirPath); !os.IsNotExist(err) { - t.Errorf("Expected directory '%s' to not exist", dirPath) - } - - // Verify content - content, err := os.ReadFile(pluginPath) - if err != nil { - t.Fatalf("Failed to read plugin file: %v", err) - } - if string(content) != "nested file" { - t.Errorf("Expected content 'nested file', got '%s'", content) - } -} diff --git a/pkg/registry/service/service.go b/pkg/registry/service/service.go index e145d639..9f4fd9d9 100644 --- a/pkg/registry/service/service.go +++ b/pkg/registry/service/service.go @@ -28,7 +28,6 @@ import ( const ( moduleSegment = "modules" packageSegment = "packages" - pluginSegment = "plugins" securitySegment = "security" securityServiceName = "security" @@ -44,7 +43,6 @@ type Service struct { modulesService *ModulesService packagesService *PackagesService - pluginService *PluginService deckhouseService *DeckhouseService security *SecurityServices installer *InstallerServices @@ -76,7 +74,6 @@ func NewService(c client.Client, edition pkg.Edition, logger *log.Logger) *Servi s.security = NewSecurityServices(securityServiceName, base.WithSegment(securitySegment), logger.Named("security")) // services that are not scoped by edition - s.pluginService = NewPluginService(c.WithSegment(pluginSegment), logger.Named("plugins")) s.installer = NewInstallerServices(installerServiceName, c.WithSegment("installer"), logger.Named("installer")) return s @@ -107,11 +104,6 @@ func (s *Service) PackageService() *PackagesService { return s.packagesService } -// PluginService returns the plugin service -func (s *Service) PluginService() *PluginService { - return s.pluginService -} - // DeckhouseService returns the deckhouse service func (s *Service) DeckhouseService() *DeckhouseService { return s.deckhouseService diff --git a/pkg/registry/service/service_test.go b/pkg/registry/service/service_test.go index b48adbe6..309212d6 100644 --- a/pkg/registry/service/service_test.go +++ b/pkg/registry/service/service_test.go @@ -169,14 +169,12 @@ func TestService_SubServiceScoping(t *testing.T) { "DeckhouseService must be scoped to <root>/<edition>") }) - t.Run("plugin service is NOT edition-scoped", func(t *testing.T) { - // Plugins live at <root>/plugins regardless of edition. We assert it via - // the absence of the edition segment in the plugin service root. - // PluginService does not expose GetRoot, but its scope is reflected by - // Service.GetRoot — Service.GetRoot stays at the bare root so that the - // installer and plugin tree references stay correct. + t.Run("installer is NOT edition-scoped", func(t *testing.T) { + // The installer lives at <root>/installer regardless of edition, so + // Service.GetRoot must stay at the bare root (no edition segment) for the + // installer tree references to stay correct. assert.Equal(t, host, svc.GetRoot(), - "Service.GetRoot must remain non-edition-scoped — plugins and installer rely on it") + "Service.GetRoot must remain non-edition-scoped - the installer relies on it") }) t.Run("no edition keeps both roots equal across all sub-services", func(t *testing.T) {