diff --git a/docs/plugins.md b/docs/plugins.md index bc6f38645..c22b0b2a0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -111,6 +111,21 @@ modules) are only *verified* - d8 never changes the cluster for you. cluster-side checks to a warning - useful when the cluster is unreachable or air-gapped. Plugin-to-plugin requirements are still enforced. +## Air-gapped installs (via d8 mirror) + +`d8 mirror pull` mirrors plugins into the images bundle automatically: every +plugin whose contract names a mirrored module is selected (per bundled module +version, newest compatible), along with its mandatory plugin dependencies; +`--include-plugin [@constraint]` adds more. After `d8 mirror push`, the +plugins live at `/deckhouse-cli/plugins/` - exactly where the +in-cluster registry-packages-proxy looks - so `d8 plugins install ` +works in the air-gapped cluster with no extra setup. See +`internal/mirror/README.MD` (Plugin Mirroring) for selection details. + +Note: the proxy serves plugins by exact name; listing the catalog through it +is not supported. To see what a registry offers, use +`crane ls /deckhouse-cli/plugins` (or `d8 cr ls`). + ## Flags and environment variables | Flag | Env | Purpose | diff --git a/internal/layout.go b/internal/layout.go index 767e17b73..d5ba5521a 100644 --- a/internal/layout.go +++ b/internal/layout.go @@ -42,6 +42,10 @@ import "path" // /packages/: - Package main image // /packages//version: - Package version channel metadata // /packages//extra/: - Package extra images +// +// Deckhouse CLI plugins (at the bare root, outside the edition segment, like installer): +// +// /deckhouse-cli/plugins/: - Plugin image (multi-platform OCI index) const ( InstallSegment = "install" InstallStandaloneSegment = "install-standalone" @@ -57,6 +61,9 @@ const ( InstallerSegment = "installer" + D8CLISegment = "deckhouse-cli" + D8PluginsSegment = "plugins" + SecuritySegment = "security" SecurityTrivyDBSegment = "trivy-db" diff --git a/internal/mirror/PROXY-REGISTRY.md b/internal/mirror/PROXY-REGISTRY.md index 161436847..96c9611e8 100644 --- a/internal/mirror/PROXY-REGISTRY.md +++ b/internal/mirror/PROXY-REGISTRY.md @@ -24,6 +24,8 @@ Caching/proxy registries usually refuse the catalog API outright — they only s | Pull from a caching/proxy registry that has already cached the desired versions | `--proxy-registry` + `--include-platform` + `--include-module` | | Pull from a registry that supports the catalog API but you still want range-based filtering | omit `--proxy-registry`, use `--include-platform` alone | +**d8 CLI plugins:** automatic plugin selection needs the plugins catalog, which a proxy registry does not serve, so it is skipped in this mode. To mirror plugins, pin them exactly: `--include-plugin @=vX.Y.Z` (exact pins address manifests by tag, no listing involved; the CLI validates this up front). + --- ## End-to-end flow diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index 38bc90101..3815b05f3 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -74,6 +74,14 @@ Packages are mirrored with the same `name[@version-constraint]` dialect as modul | `--include-package` | Whitelist specific packages. Use one flag per package. Disables `--exclude-package` | | `--exclude-package` | Blacklist specific packages. Format: `package-name[@version]`. Use one flag per package. Overridden by `--include-package` | +#### Plugin Selection + +d8 CLI plugins that the mirrored modules need are selected **automatically** (see [Plugin Mirroring](#plugin-mirroring)); the flag adds more on top. + +| Flag | Description | +|------|-------------| +| `--include-plugin` | Mirror a specific plugin in addition to the automatic selection. Format: `plugin-name[@constraint]`, same dialect as `--include-module`. Use one flag per plugin | + #### Component Selection | Flag | Description | @@ -386,6 +394,37 @@ If the source registry has no `packages` repository (some public/CE registries), --- +### Plugin Mirroring + +d8 CLI plugins are standalone binaries the CLI installs through the in-cluster registry-packages-proxy. A plugin declares its requirements (Deckhouse modules, other plugins, platform versions) in a contract - a base64-JSON annotation on its image manifest. The plugins phase runs **last**, after modules and packages, because it resolves against what the earlier phases actually put into the bundle. + +Selection principle: **nothing extra**. A plugin enters the bundle only when: + +- a mirrored module needs it - the plugin's contract names that module in its `mandatory` or `anyOf` requirements. For **each bundled version** of the module, the newest plugin version whose contract the bundle satisfies is picked (so a bundle carrying module v1.0.0 and v1.5.0 may get two plugin versions, deduplicated); +- it is a mandatory plugin dependency of another selected plugin (resolved recursively; a version already picked is shared when it satisfies the constraint); +- the user names it with `--include-plugin` (additive; an unmet explicit include fails the pull, unlike the automatic selection which skips with a reason in the summary). + +Registry layout (at the **bare root**, outside the edition segment - like the installer): + +| Path | Contents | +|------|----------| +| `/deckhouse-cli/plugins` | Plugin catalog; its tags are plugin names | +| `/deckhouse-cli/plugins/:` | One plugin version - a multi-platform OCI index (linux/darwin/windows) | + +**Bundle output:** one `plugin-.tar` per plugin. Multi-platform indexes are stored whole, so every platform binary and the contract annotation reach the target registry exactly as published. + +What is checked at mirror time vs install time: + +- Mirror verifies that each pulled plugin version's module requirements are satisfiable by **at least one bundled version** of each required module, and its `deckhouse` constraint by a bundled platform version. +- `kubernetes` and `noneOf` requirements are cluster-side: `d8 plugins install` enforces them on the target cluster as usual. +- Conditional requirements never gate mirroring; conflicts show up as warnings. + +After `d8 mirror push`, `d8 plugins install ` works in the air-gapped cluster through the registry-packages-proxy. Note the proxy serves plugins **by exact name** - listing the plugin catalog through it is not supported (a registry-packages-proxy limitation, not a bundle one; `crane ls /deckhouse-cli/plugins` shows the names). + +If the source registry has no `deckhouse-cli/plugins` catalog, the automatic selection is skipped quietly; explicit `--include-plugin` entries still resolve against their own repositories. + +--- + ### Security Databases The security phase mirrors four Trivy databases into `security.tar`, each pinned at a fixed schema tag under `//security/`: @@ -430,6 +469,7 @@ The pull command creates a bundle with the following structure: ├── module-.tar # One archive per module (if not --no-modules) ├── package-.tar # One archive per package (if not --no-packages) ├── package-versions.tar # Package release-metadata catalog (always produced) +├── plugin-.tar # One archive per d8 CLI plugin the bundle needs (see Plugin Mirroring) └── .tar.NNNN.chunk # Chunk parts, when --images-bundle-chunk-size is set (NNNN = 0000, 0001, …) ``` diff --git a/internal/mirror/cmd/pull/flags/flags.go b/internal/mirror/cmd/pull/flags/flags.go index 094bef896..8cb8c78b4 100644 --- a/internal/mirror/cmd/pull/flags/flags.go +++ b/internal/mirror/cmd/pull/flags/flags.go @@ -61,6 +61,8 @@ var ( PackagesWhitelist []string PackagesBlacklist []string + PluginsWhitelist []string + SourceRegistryRepo = EnterpriseEditionRepo // Fallback to EE if nothing was given as source. SourceRegistryLogin string SourceRegistryPassword string @@ -213,6 +215,14 @@ Packages live under the packages/ registry segment, with release metadata under nil, `Blacklist specific packages from downloading. Format is "package-name[@constraint]", the same dialect as --include-module, quoting included. Use one flag per each package. Overridden by use of --include-package.`, ) + flagSet.StringArrayVar( + &PluginsWhitelist, + "include-plugin", + nil, + `Mirror a specific d8 CLI plugin in addition to the automatic selection. Format is "plugin-name[@constraint]", the same dialect as --include-module, quoting included. Use one flag per each plugin. + +Plugins live under the deckhouse-cli/plugins registry segment. Without this flag, plugins required by the mirrored modules (and their plugin dependencies) are selected automatically.`, + ) flagSet.Int64VarP( &ImagesBundleChunkSizeGB, "images-bundle-chunk-size", diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index a645d6000..de4ddb9b5 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -412,6 +412,12 @@ func (p *Puller) buildPullService() (*mirror.PullService, error) { return nil, err } + // Create plugin filter from CLI flags + pluginFilter, err := p.createPluginFilter() + if err != nil { + return nil, err + } + svc := mirror.NewPullService( registryservice.NewService(c, edition, logger, registryservice.WithModulesPathSuffix(p.params.ModulesPathSuffix), @@ -431,6 +437,8 @@ func (p *Puller) buildPullService() (*mirror.PullService, error) { PlatformConstraint: pullflags.PlatformConstraint, ModuleFilter: filter, PackageFilter: packageFilter, + PluginFilter: pluginFilter, + PluginBuiltins: pluginBuiltinCommands, BundleDir: pullflags.ImagesBundlePath, BundleChunkSize: pullflags.ImagesBundleChunkSizeGB * 1000 * 1000 * 1000, Timeout: pullflags.MirrorTimeout, @@ -517,6 +525,34 @@ func (p *Puller) createModuleFilter() (*modules.Filter, error) { return filter, nil } +// pluginBuiltinCommands are built-in d8 commands that satisfy a same-named +// plugin dependency by presence - such dependencies are never mirrored. The +// list matches what root.go passes to pluginscmd.NewCommand; kept as literals +// because importing the command layer would drag werf into this package. +var pluginBuiltinCommands = []string{"delivery-kit", "package"} + +// createPluginFilter builds the whitelist filter from --include-plugin +// entries. Plugins have no blacklist: the automatic selection is already +// minimal (only plugins the mirrored modules need), so the only knob is +// adding more. Returns nil when the flag is unused, which the plugins +// service reads as "auto-selection only". +func (p *Puller) createPluginFilter() (*modules.Filter, error) { + if pullflags.PluginsWhitelist == nil { + return nil, nil + } + + filter, err := modules.NewFilter(pullflags.PluginsWhitelist, modules.FilterTypeWhitelist) + if err != nil { + if diag := errdetect.DiagnoseConstraintParseError(err, "include-plugin", pullflags.PluginsWhitelist...); diag != nil { + return nil, diag + } + + return nil, fmt.Errorf("Prepare plugin filter: %w", err) + } + + return filter, nil +} + // createPackageFilter creates the appropriate package filter based on whitelist/blacklist. // Packages reuse the modules filter because selection logic (names + semver // constraints) is identical. diff --git a/internal/mirror/cmd/pull/pull_plugins_stub_test.go b/internal/mirror/cmd/pull/pull_plugins_stub_test.go new file mode 100644 index 000000000..f4807eb43 --- /dev/null +++ b/internal/mirror/cmd/pull/pull_plugins_stub_test.go @@ -0,0 +1,117 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pull + +// Command-level plugin tests: the real `d8 mirror pull` path through +// Puller.Execute against the in-memory registry stub, which carries the +// cert-manager module and the cert-manager-tool plugin whose contract +// requires it. + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pullflags "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd/pull/flags" +) + +// TestPullerExecute_PluginsTarInBundle runs a real (non-dry-run) pull of +// modules and plugins: the module-driven plugin selection must produce a +// plugin tar next to the module tar in the bundle dir. +func TestPullerExecute_PluginsTarInBundle(t *testing.T) { + t.Setenv("STUB_REGISTRY_CLIENT", "true") + + bundleDir := t.TempDir() + tmpDir := t.TempDir() + + // NewCommand calls AddFlags which resets all flag vars to defaults; set flags after. + cmd := NewCommand() + defer saveFlagsAndRestore(t)() + + pullflags.ImagesBundlePath = bundleDir + pullflags.TempDir = tmpDir + pullflags.SourceRegistryRepo = "registry.deckhouse.ru/deckhouse/ee" + pullflags.DeckhouseTag = "v1.69.0" + pullflags.NoPlatform = true + pullflags.NoSecurityDB = true + pullflags.NoInstaller = true + pullflags.NoModules = false + pullflags.DryRun = false + pullflags.DoGOSTDigest = false + pullflags.NoPullResume = true + pullflags.SkipVexImages = true + pullflags.ModulesWhitelist = nil + pullflags.ModulesBlacklist = nil + + ctx := context.Background() + cmd.SetContext(ctx) + + puller := NewPuller(cmd) + err := puller.Execute(ctx) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(bundleDir, "module-cert-manager.tar")) + assert.FileExists(t, filepath.Join(bundleDir, "plugin-cert-manager-tool.tar"), + "the plugin selected for the mirrored module must land in the bundle") +} + +// TestPullerExecute_DryRun_PluginsNoFiles: the same pull in dry-run mode +// resolves the plugin but writes nothing. +func TestPullerExecute_DryRun_PluginsNoFiles(t *testing.T) { + t.Setenv("STUB_REGISTRY_CLIENT", "true") + + bundleDir := t.TempDir() + tmpDir := t.TempDir() + + cmd := NewCommand() + defer saveFlagsAndRestore(t)() + + pullflags.ImagesBundlePath = bundleDir + pullflags.TempDir = tmpDir + pullflags.SourceRegistryRepo = "registry.deckhouse.ru/deckhouse/ee" + pullflags.DeckhouseTag = "v1.69.0" + pullflags.NoPlatform = true + pullflags.NoSecurityDB = true + pullflags.NoInstaller = true + pullflags.NoModules = false + pullflags.DryRun = true + pullflags.DoGOSTDigest = false + pullflags.NoPullResume = true + pullflags.SkipVexImages = true + pullflags.ModulesWhitelist = nil + pullflags.ModulesBlacklist = nil + + ctx := context.Background() + cmd.SetContext(ctx) + + puller := NewPuller(cmd) + err := puller.Execute(ctx) + require.NoError(t, err) + + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + + for _, e := range entries { + ext := filepath.Ext(e.Name()) + assert.NotEqual(t, ".tar", ext, "dry-run must not write .tar files, found: %s", e.Name()) + assert.NotEqual(t, ".chunk", ext, "dry-run must not write .chunk files, found: %s", e.Name()) + } +} diff --git a/internal/mirror/cmd/pull/summary.go b/internal/mirror/cmd/pull/summary.go index 5c1a8b016..3cf2ac3da 100644 --- a/internal/mirror/cmd/pull/summary.go +++ b/internal/mirror/cmd/pull/summary.go @@ -84,6 +84,11 @@ var ( // ║ csi-nfs (3 VEX) [v0.6.2, v0.6.1] // ║ Packages: 1 // ║ deckhouse [v1.69.1] +// ║ Plugins: 2 · 1 for modules · 1 dependency +// ║ console +// ║ console-ctl [v0.4.1] +// ║ └ db-connector [v0.9.1] (dependency) +// ║ skipped: backup-tool - for module console v1.40.0: requires module "console" >=2.0.0 // ║ // ║ Bundle artifacts (3 files) // ║ platform.tar 2.9 GiB @@ -117,6 +122,7 @@ func renderPullSummary(s *mirror.PullSummary, verbose bool) string { writeSecurity(&b, s.Security) writeModules(&b, s.Modules, verbose) writePackages(&b, s.Packages, verbose) + writePlugins(&b, s.Plugins, verbose) if !s.DryRun && len(s.Bundle.Files) > 0 { b.WriteString(bar() + "\n") @@ -364,6 +370,253 @@ func writePackages(b *strings.Builder, p mirror.PackagesStats, verbose bool) { } } +// writePlugins renders the plugins line, the module-grouped provenance tree +// (verbose only), and skipped plugins with reasons (always - losing a plugin +// in an air-gapped bundle is an operational surprise). e.g.: +// +// ║ Plugins: 2 · 1 for modules · 1 dependency +// ║ postgresql +// ║ postgresql-mgr [v1.2.0, v1.1.0] +// ║ └ db-connector [v0.9.1] (dependency) +// ║ skipped: backup-tool - requires module "postgresql" >=3.0.0 +func writePlugins(b *strings.Builder, p mirror.PluginsStats, verbose bool) { + label := cLabel(padLabel("Plugins")) + + if p.Skipped { + fmt.Fprintf(b, "%s %s %s\n", bar(), label, cDim("skipped")) + return + } + + if !p.Attempted { + fmt.Fprintf(b, "%s %s %s\n", bar(), label, cWarn("not pulled")) + return + } + + // Aggregate: count plus the provenance breakdown (why plugins are here). + parts := []string{cCount(fmt.Sprint(len(p.Plugins)))} + + counts := countPluginProvenance(p.Plugins) + if counts.forModules > 0 { + parts = append(parts, cDim(fmt.Sprintf("%d for modules", counts.forModules))) + } + + if counts.dependencies > 0 { + word := "dependencies" + if counts.dependencies == 1 { + word = "dependency" + } + + parts = append(parts, cDim(fmt.Sprintf("%d %s", counts.dependencies, word))) + } + + if counts.explicit > 0 { + parts = append(parts, cDim(fmt.Sprintf("%d explicit", counts.explicit))) + } + + fmt.Fprintf(b, "%s %s %s\n", bar(), label, strings.Join(parts, " "+cDim("·")+" ")) + + if verbose { + writePluginsTree(b, p.Plugins) + } + + for _, skip := range p.SkippedPlugins { + fmt.Fprintf(b, "%s %s\n", bar(), cWarn("skipped: "+skip.Name+" - "+skip.Reason)) + } +} + +// pluginProvenanceCounts is the per-category tally of the aggregate line. +type pluginProvenanceCounts struct { + forModules int + dependencies int + explicit int +} + +// countPluginProvenance counts each plugin once by its strongest provenance: +// serving a mirrored module beats an explicit include, which beats being +// someone's dependency. +func countPluginProvenance(plugins []mirror.PluginStat) pluginProvenanceCounts { + var counts pluginProvenanceCounts + + for _, plugin := range plugins { + provenance := pluginProvenance(plugin) + + switch { + case len(provenance.modules) > 0: + counts.forModules++ + case provenance.explicit: + counts.explicit++ + default: + counts.dependencies++ + } + } + + return counts +} + +// pluginProvenanceInfo is one plugin's provenance aggregated across its +// versions: the sorted modules it serves, the plugins depending on it, and +// whether it was explicitly included. +type pluginProvenanceInfo struct { + modules []string + dependents []string + explicit bool +} + +func pluginProvenance(plugin mirror.PluginStat) pluginProvenanceInfo { + var info pluginProvenanceInfo + + seenModules := make(map[string]struct{}) + seenDependents := make(map[string]struct{}) + + for _, version := range plugin.Versions { + for _, reason := range version.Reasons { + switch reason.Kind { + case "module": + if _, ok := seenModules[reason.Subject]; !ok { + seenModules[reason.Subject] = struct{}{} + info.modules = append(info.modules, reason.Subject) + } + case "dependency": + // Subject is "@"; group by the plugin name. + name, _, _ := strings.Cut(reason.Subject, "@") + if _, ok := seenDependents[name]; !ok { + seenDependents[name] = struct{}{} + info.dependents = append(info.dependents, name) + } + case "explicit": + info.explicit = true + } + } + } + + sort.Strings(info.modules) + sort.Strings(info.dependents) + + return info +} + +// pluginTreeNode is one rendered plugin with its dependency children. +type pluginTreeNode struct { + stat mirror.PluginStat + note string + children []*pluginTreeNode +} + +// writePluginsTree renders plugins grouped by the module they serve, with +// dependency plugins nested under their dependents and explicitly included +// plugins in their own group. e.g.: +// +// ║ postgresql +// ║ postgresql-mgr [v1.1.0, v1.2.0] +// ║ └ db-connector [v0.9.1] (dependency) +// ║ explicit +// ║ velero-helper [v0.3.0] +func writePluginsTree(b *strings.Builder, plugins []mirror.PluginStat) { + nodes := make(map[string]*pluginTreeNode, len(plugins)) + for _, plugin := range plugins { + nodes[plugin.Name] = &pluginTreeNode{stat: plugin} + } + + // Group roots by the module they serve (first module alphabetically; the + // rest become an "(also for ...)" note) or under "explicit". Dependency-only + // plugins nest under their first dependent. + groups := make(map[string][]*pluginTreeNode) + + var groupNames []string + + addToGroup := func(group string, node *pluginTreeNode) { + if _, ok := groups[group]; !ok { + groupNames = append(groupNames, group) + } + + groups[group] = append(groups[group], node) + } + + for _, plugin := range plugins { + node := nodes[plugin.Name] + provenance := pluginProvenance(plugin) + + switch { + case len(provenance.modules) > 0: + if len(provenance.modules) > 1 { + node.note = cDim("(also for " + strings.Join(provenance.modules[1:], ", ") + ")") + } + + addToGroup(provenance.modules[0], node) + case provenance.explicit: + addToGroup("explicit", node) + case len(provenance.dependents) > 0: + node.note = cDim("(dependency)") + if parent, ok := nodes[provenance.dependents[0]]; ok { + parent.children = append(parent.children, node) + continue + } + + addToGroup("dependencies", node) + default: + addToGroup("other", node) + } + } + + // Module groups sort alphabetically; the pseudo-groups (explicit, + // dependency orphans) always render after them. + pseudo := map[string]int{"dependencies": 1, "explicit": 2, "other": 3} + + sort.Slice(groupNames, func(i, j int) bool { + pi, pj := pseudo[groupNames[i]], pseudo[groupNames[j]] + if pi != pj { + return pi < pj + } + + return groupNames[i] < groupNames[j] + }) + + for _, group := range groupNames { + fmt.Fprintf(b, "%s %s\n", bar(), group) + + for _, node := range groups[group] { + writePluginNode(b, node, 0) + } + } +} + +// writePluginNode renders one plugin line and recurses into its dependency +// children, indenting each level under its parent. +func writePluginNode(b *strings.Builder, node *pluginTreeNode, depth int) { + name := node.stat.Name + if depth > 0 { + name = strings.Repeat(" ", depth-1) + "└ " + name + } + + versions := make([]string, 0, len(node.stat.Versions)) + for _, v := range node.stat.Versions { + versions = append(versions, v.Version) + } + + line := name + + suffix := "" + if len(versions) > 0 { + suffix = " " + cVersion("["+strings.Join(sortVersions(versions), ", ")+"]") + } + + if node.note != "" { + suffix += " " + node.note + } + + if suffix != "" { + line = fmt.Sprintf("%-*s%s", nameWidth, name, suffix) + } + + fmt.Fprintf(b, "%s %s\n", bar(), line) + + sort.Slice(node.children, func(i, j int) bool { return node.children[i].stat.Name < node.children[j].stat.Name }) + + for _, child := range node.children { + writePluginNode(b, child, depth+1) + } +} + // writeBundle renders the on-disk bundle artifact block (real pull only). // e.g.: // diff --git a/internal/mirror/cmd/pull/summary_test.go b/internal/mirror/cmd/pull/summary_test.go index d166e44fa..434993fce 100644 --- a/internal/mirror/cmd/pull/summary_test.go +++ b/internal/mirror/cmd/pull/summary_test.go @@ -408,17 +408,18 @@ func TestRenderPullSummary(t *testing.T) { skippedCount: -1, }, { - name: "everything skipped renders five skipped lines and no body", + name: "everything skipped renders six skipped lines and no body", summary: &mirror.PullSummary{ Platform: mirror.ComponentStats{Skipped: true}, Installer: mirror.ComponentStats{Skipped: true}, Security: mirror.SecurityStats{Skipped: true}, Modules: mirror.ModulesStats{Skipped: true}, Packages: mirror.PackagesStats{Skipped: true}, + Plugins: mirror.PluginsStats{Skipped: true}, }, - contains: []string{"Platform:", "Installer:", "Security:", "Modules:", "Packages:"}, + contains: []string{"Platform:", "Installer:", "Security:", "Modules:", "Packages:", "Plugins:"}, notContains: []string{"Bundle artifacts", "VEX", "not pulled"}, - skippedCount: 5, + skippedCount: 6, }, { name: "moved modules path with modules pulled is warned about", @@ -494,6 +495,111 @@ func TestRenderPullSummary(t *testing.T) { // enabled and fully suppressed otherwise - the contract that keeps escape codes // out of pipes, files, and captured logs (fatih/color flips color.NoColor from // the stdout TTY check and NO_COLOR, which the logger writes to). +// TestRenderPullSummary_Plugins pins the plugins section: the aggregate line +// with the provenance breakdown, the module-grouped tree in verbose mode, the +// dependency nesting, and skipped plugins visible without verbose. +func TestRenderPullSummary_Plugins(t *testing.T) { + color.NoColor = true + + pluginsStats := mirror.PluginsStats{ + Attempted: true, + Plugins: []mirror.PluginStat{ + { + Name: "db-connector", + Images: 1, + Versions: []mirror.PluginVersionStat{{ + Version: "v0.9.1", + Reasons: []mirror.PluginReason{{Kind: "dependency", Subject: "postgresql-mgr@v1.2.0", Constraint: ">=0.9.0"}}, + }}, + }, + { + Name: "postgresql-mgr", + Images: 2, + Versions: []mirror.PluginVersionStat{ + {Version: "v1.2.0", Reasons: []mirror.PluginReason{{Kind: "module", Subject: "postgresql", Constraint: ">=1.5.0"}}}, + {Version: "v1.1.0", Reasons: []mirror.PluginReason{{Kind: "module", Subject: "postgresql", Constraint: ">=1.0.0 <1.5.0"}}}, + }, + }, + { + Name: "velero-helper", + Images: 1, + Versions: []mirror.PluginVersionStat{{ + Version: "v0.3.0", + Reasons: []mirror.PluginReason{{Kind: "explicit", Subject: "--include-plugin velero-helper"}}, + }}, + }, + }, + SkippedPlugins: []mirror.SkippedPluginStat{ + {Name: "backup-tool", Reason: `for module postgresql v1.0.0: requires module "postgresql" >=3.0.0`}, + }, + TotalImages: 4, + } + + base := func() *mirror.PullSummary { + return &mirror.PullSummary{ + Elapsed: time.Minute, + Platform: mirror.ComponentStats{Attempted: true}, + Security: mirror.SecurityStats{Attempted: true, Available: true}, + Modules: mirror.ModulesStats{Attempted: true}, + Packages: mirror.PackagesStats{Attempted: true}, + Plugins: pluginsStats, + } + } + + t.Run("aggregate line with provenance breakdown", func(t *testing.T) { + out := renderPullSummary(base(), false) + + require.Contains(t, out, "Plugins:") + require.Contains(t, out, "1 for modules") + require.Contains(t, out, "1 dependency") + require.Contains(t, out, "1 explicit") + }) + + t.Run("skips are visible without verbose", func(t *testing.T) { + out := renderPullSummary(base(), false) + + require.Contains(t, out, "skipped: backup-tool") + require.Contains(t, out, `requires module "postgresql" >=3.0.0`) + require.NotContains(t, out, "└", "the tree is verbose-only") + }) + + t.Run("verbose renders the module-grouped tree", func(t *testing.T) { + out := renderPullSummary(base(), true) + + require.Contains(t, out, "postgresql\n", "the module group header must be present") + require.Contains(t, out, "postgresql-mgr") + require.Contains(t, out, "[v1.2.0, v1.1.0]", "versions are sorted newest-first, like modules") + require.Contains(t, out, "└ db-connector") + require.Contains(t, out, "(dependency)") + require.Contains(t, out, "explicit\n", "explicit-only plugins get their own group") + require.Contains(t, out, "velero-helper") + }) + + t.Run("phase skipped renders skipped", func(t *testing.T) { + s := base() + s.Plugins = mirror.PluginsStats{Skipped: true} + + out := renderPullSummary(s, false) + require.Regexp(t, `Plugins:\s+skipped`, out) + }) + + t.Run("phase never ran renders not pulled", func(t *testing.T) { + s := base() + s.Plugins = mirror.PluginsStats{} + + out := renderPullSummary(s, false) + require.Regexp(t, `Plugins:\s+not pulled`, out) + }) + + t.Run("zero plugins render a bare count", func(t *testing.T) { + s := base() + s.Plugins = mirror.PluginsStats{Attempted: true} + + out := renderPullSummary(s, false) + require.Regexp(t, `Plugins:\s+0`, out) + }) +} + func TestRenderPullSummary_ColorGating(t *testing.T) { orig := color.NoColor defer func() { color.NoColor = orig }() diff --git a/internal/mirror/cmd/pull/validation.go b/internal/mirror/cmd/pull/validation.go index 5b8d44735..a7ef68842 100644 --- a/internal/mirror/cmd/pull/validation.go +++ b/internal/mirror/cmd/pull/validation.go @@ -246,6 +246,15 @@ func validateProxyRegistryFlag() error { } } + // A proxy registry serves no plugins catalog, so version ranges cannot be + // resolved; only exact pins address manifests directly by tag. + for _, entry := range pullflags.PluginsWhitelist { + name, constraint, hasConstraint := strings.Cut(strings.TrimSpace(entry), "@") + if !hasConstraint || !strings.HasPrefix(strings.TrimSpace(constraint), "=") { + return fmt.Errorf("--proxy-registry requires every --include-plugin entry to pin an exact version (e.g. %q): the registry serves no catalog to resolve version ranges against", strings.TrimSpace(name)+"@=v1.0.0") + } + } + return nil } diff --git a/internal/mirror/cmd/pull/validation_test.go b/internal/mirror/cmd/pull/validation_test.go index 5e2f1765e..d6ed7e444 100644 --- a/internal/mirror/cmd/pull/validation_test.go +++ b/internal/mirror/cmd/pull/validation_test.go @@ -659,6 +659,60 @@ func TestValidationValidateProxyRegistryFlag(t *testing.T) { } } +// TestValidateProxyRegistryFlag_PluginPins: with --proxy-registry every +// --include-plugin entry must pin an exact version - the registry serves no +// catalog, so version ranges have nothing to resolve against. +func TestValidateProxyRegistryFlag_PluginPins(t *testing.T) { + tests := []struct { + name string + pluginsWhitelist []string + expectError bool + }{ + {name: "no plugin includes", pluginsWhitelist: nil, expectError: false}, + {name: "exact pin passes", pluginsWhitelist: []string{"stronghold@=v1.2.3"}, expectError: false}, + {name: "bare name rejected", pluginsWhitelist: []string{"stronghold"}, expectError: true}, + {name: "semver range rejected", pluginsWhitelist: []string{"stronghold@^1.0.0"}, expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originals := struct { + proxyRegistry bool + platformConstraintString string + noModules bool + pluginsWhitelist []string + }{ + proxyRegistry: pullflags.ProxyRegistry, + platformConstraintString: pullflags.PlatformConstraintString, + noModules: pullflags.NoModules, + pluginsWhitelist: pullflags.PluginsWhitelist, + } + defer func() { + pullflags.ProxyRegistry = originals.proxyRegistry + pullflags.PlatformConstraintString = originals.platformConstraintString + pullflags.NoModules = originals.noModules + pullflags.PluginsWhitelist = originals.pluginsWhitelist + }() + + // A valid proxy-registry context, so only the plugin rule fires. + pullflags.ProxyRegistry = true + pullflags.PlatformConstraintString = "^1.64.0" + pullflags.NoModules = true + pullflags.PluginsWhitelist = tt.pluginsWhitelist + + err := validateProxyRegistryFlag() + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), "include-plugin") + assert.Contains(t, err.Error(), "exact version") + } else { + assert.NoError(t, err) + } + }) + } +} + func TestValidationResolveModuleFlags(t *testing.T) { tests := []struct { name string diff --git a/internal/mirror/cmd/push/summary.go b/internal/mirror/cmd/push/summary.go index 8be716b2e..79dacce82 100644 --- a/internal/mirror/cmd/push/summary.go +++ b/internal/mirror/cmd/push/summary.go @@ -42,6 +42,7 @@ import ( // ║ Security: 4 databases // ║ Modules: 12 // ║ Packages: 3 +// ║ Plugins: 2 // ║ // ║ Elapsed: 2m4s // ╚═══════════════════════════════════════════════════════ @@ -62,6 +63,7 @@ func renderPushSummary(s *mirror.PushSummary) string { writePushSecurity(&b, s.SecurityDatabases) writePushCount(&b, "Modules", s.Modules) writePushCount(&b, "Packages", s.Packages) + writePushCount(&b, "Plugins", s.Plugins) b.WriteString(summaryui.Bar() + "\n") diff --git a/internal/mirror/cmd/push/summary_test.go b/internal/mirror/cmd/push/summary_test.go index 966134acf..6ee661ae6 100644 --- a/internal/mirror/cmd/push/summary_test.go +++ b/internal/mirror/cmd/push/summary_test.go @@ -46,6 +46,7 @@ func TestRenderPushSummary(t *testing.T) { SecurityDatabases: 4, Modules: 12, Packages: 3, + Plugins: 2, Elapsed: 2*time.Minute + 4*time.Second, }, contains: []string{ @@ -54,6 +55,7 @@ func TestRenderPushSummary(t *testing.T) { "Security:", "4 databases", "Modules:", "12", "Packages:", "3", + "Plugins:", "2", "Elapsed: 2m4s", }, notContains: []string{"Warning", "default:", "failed", "cancelled", "not present"}, diff --git a/internal/mirror/plugins/catalog.go b/internal/mirror/plugins/catalog.go new file mode 100644 index 000000000..819a014aa --- /dev/null +++ b/internal/mirror/plugins/catalog.go @@ -0,0 +1,211 @@ +/* +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" + "encoding/base64" + "errors" + "fmt" + "sort" + + "github.com/Masterminds/semver/v3" + "sigs.k8s.io/yaml" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// Catalog is the resolver's read model of the plugins registry catalog. +// Results are memoized for the lifetime of the catalog (one pull), so however +// many resolution paths probe the same plugin, the registry is asked once. +type Catalog interface { + // PluginNames lists the plugin names published in the catalog. + PluginNames(ctx context.Context) ([]string, error) + // PluginVersions lists a plugin's published stable versions, newest + // first. Non-semver tags (werf build junk) and genuine pre-releases are + // dropped - the same notion of "stable" that plugin install uses. + PluginVersions(ctx context.Context, name string) ([]*semver.Version, error) + // Contract returns the decoded contract of one plugin version. An image + // without a contract annotation yields a degenerate {Name, Version} + // contract, not an error. + Contract(ctx context.Context, name string, version *semver.Version) (*internal.Plugin, error) +} + +// ErrInvalidContract marks a deterministic content problem of a published +// contract (broken base64, malformed JSON, failed domain validation). The +// resolver skips such versions and tries older ones; transport errors never +// carry this sentinel and fail the pull instead. +var ErrInvalidContract = errors.New("invalid plugin contract") + +// registryCatalog implements Catalog over the plugins registry service. +type registryCatalog struct { + service *registryservice.PluginsService + + versionsByName map[pluginName][]*semver.Version + // contractsByRef is keyed by "@". + contractsByRef map[string]*internal.Plugin + + logger *dkplog.Logger +} + +// NewCatalog creates a memoizing catalog over the plugins registry service. +func NewCatalog(service *registryservice.PluginsService, logger *dkplog.Logger) Catalog { + return ®istryCatalog{ + service: service, + + versionsByName: make(map[pluginName][]*semver.Version), + contractsByRef: make(map[string]*internal.Plugin), + + logger: logger, + } +} + +func (c *registryCatalog) PluginNames(ctx context.Context) ([]string, error) { + return c.service.ListTags(ctx) +} + +func (c *registryCatalog) PluginVersions(ctx context.Context, name string) ([]*semver.Version, error) { + if versions, ok := c.versionsByName[name]; ok { + return versions, nil + } + + tags, err := c.service.Plugin(name).ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("list versions of plugin %q: %w", name, err) + } + + versions := stableVersions(sortedSemverDesc(tags)) + c.versionsByName[name] = versions + + return versions, nil +} + +func (c *registryCatalog) Contract(ctx context.Context, name string, version *semver.Version) (*internal.Plugin, error) { + tag := version.Original() + + ref := name + "@" + tag + if contract, ok := c.contractsByRef[ref]; ok { + return contract, nil + } + + encoded, err := c.service.Plugin(name).ContractAnnotation(ctx, tag) + if err != nil { + return nil, fmt.Errorf("get contract of plugin %q %s: %w", name, tag, err) + } + + contract, err := decodeContract(encoded, name, tag) + if err != nil { + return nil, err + } + + c.contractsByRef[ref] = contract + + return contract, nil +} + +// decodeContract turns the base64 contract annotation into a domain Plugin. +// An empty annotation is a contract-less image: only name and version are +// known, there is nothing to enforce. Same decode chain as the plugin install +// sources (internal/plugins rpp_source/source_legacy). +func decodeContract(encoded, name, tag string) (*internal.Plugin, error) { + if encoded == "" { + return &internal.Plugin{Name: name, Version: tag}, nil + } + + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, invalidContract(name, tag, err) + } + + // The decoded annotation is JSON; YAMLToJSON is a tolerant pass-through + // (valid JSON is valid YAML) and the shared decoder expects JSON. + jsonRaw, err := yaml.YAMLToJSON(raw) + if err != nil { + return nil, invalidContract(name, tag, err) + } + + var dto registryservice.PluginContract + if err := registryservice.UnmarshalContract(jsonRaw, &dto); err != nil { + return nil, invalidContract(name, tag, err) + } + + plugin, err := registryservice.ContractToDomain(&dto) + if err != nil { + return nil, invalidContract(name, tag, err) + } + + if plugin.Name == "" { + plugin.Name = name + } + + if plugin.Version == "" { + plugin.Version = tag + } + + return plugin, nil +} + +// invalidContract wraps a deterministic contract-content error with the +// ErrInvalidContract sentinel. The original error is flattened into text: the +// resolver classifies by the sentinel alone and never unwraps further. +func invalidContract(name, tag string, err error) error { + return fmt.Errorf("%w: plugin %q %s: %s", ErrInvalidContract, name, tag, err) +} + +// stableVersions and sortedSemverDesc are ports of the same helpers in +// internal/plugins/select.go, so mirror keeps install's notion of a stable +// published version. Kept as copies: importing the plugin manager package +// from mirror would cross subsystem boundaries for two small functions. + +// stableVersions drops genuine pre-releases (rc/alpha/beta), keeping CI/build +// markers like "v1.77.0-main". +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/mirror/plugins/catalog_test.go b/internal/mirror/plugins/catalog_test.go new file mode 100644 index 000000000..0260fa0c7 --- /dev/null +++ b/internal/mirror/plugins/catalog_test.go @@ -0,0 +1,253 @@ +/* +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" + "encoding/base64" + "log/slog" + "testing" + + "github.com/Masterminds/semver/v3" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" + + "github.com/deckhouse/deckhouse-cli/pkg" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +const testHost = "fake.registry" + +const pluginsCatalogRepo = "deckhouse-cli/plugins" + +// newTestCatalog builds a Catalog over a fake registry, wired through the +// real registry service so the deckhouse-cli/plugins scoping is exercised too. +func newTestCatalog(t *testing.T, reg *upfake.Registry) Catalog { + t.Helper() + + stub := pkgclient.Adapt(upfake.NewClient(reg)) + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + regSvc := registryservice.NewService(stub, pkg.NoEdition, logger) + + return NewCatalog(regSvc.PluginService(), logger) +} + +// addPluginVersion publishes one plugin version image into the fake registry: +// tagged in the plugin repo and, when contractJSON is non-empty, annotated +// with the base64 contract. The catalog name index gets the plugin name tag. +func addPluginVersion(t *testing.T, reg *upfake.Registry, name, tag, contractJSON string) { + t.Helper() + + img := upfake.NewImageBuilder().WithFile("plugin", "binary-"+name+"-"+tag).MustBuild() + + if contractJSON != "" { + encoded := base64.StdEncoding.EncodeToString([]byte(contractJSON)) + annotated, ok := mutate.Annotations(img, map[string]string{"contract": encoded}).(v1.Image) + require.True(t, ok, "mutate.Annotations on an image must return an image") + img = annotated + } + + reg.MustAddImage(pluginsCatalogRepo+"/"+name, tag, img) + reg.MustAddImage(pluginsCatalogRepo, name, upfake.NewImageBuilder().WithFile("name", name).MustBuild()) +} + +// addRawContract publishes a plugin version whose contract annotation is the +// given raw string (not JSON-encoded here), for malformed-contract cases. +func addRawContract(t *testing.T, reg *upfake.Registry, name, tag, rawAnnotation string) { + t.Helper() + + img := upfake.NewImageBuilder().WithFile("plugin", "binary").MustBuild() + annotated, ok := mutate.Annotations(img, map[string]string{"contract": rawAnnotation}).(v1.Image) + require.True(t, ok) + + reg.MustAddImage(pluginsCatalogRepo+"/"+name, tag, annotated) +} + +func TestCatalog_PluginNames(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "postgresql-mgr", "v1.0.0", "") + addPluginVersion(t, reg, "db-connector", "v0.9.0", "") + + catalog := newTestCatalog(t, reg) + + names, err := catalog.PluginNames(context.Background()) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"postgresql-mgr", "db-connector"}, names, + "plugin names must come from the catalog directory-as-tags index") +} + +// TestCatalog_PluginVersions_FiltersAndSorts pins the stable-version notion: +// werf build junk (non-semver tags) is dropped, genuine pre-releases are +// dropped, "-main" CI markers stay, and the result is newest first. +func TestCatalog_PluginVersions_FiltersAndSorts(t *testing.T) { + reg := upfake.NewRegistry(testHost) + for _, tag := range []string{ + "v1.0.0", "v1.1.0", "v1.2.0-main", + "v2.0.0-rc.1", // genuine pre-release: dropped + "main-linux-amd64", // werf junk: dropped + "meta-89584285_abc", // werf junk: dropped + } { + addPluginVersion(t, reg, "postgresql-mgr", tag, "") + } + + catalog := newTestCatalog(t, reg) + + versions, err := catalog.PluginVersions(context.Background(), "postgresql-mgr") + require.NoError(t, err) + + got := make([]string, 0, len(versions)) + for _, v := range versions { + got = append(got, v.Original()) + } + + assert.Equal(t, []string{"v1.2.0-main", "v1.1.0", "v1.0.0"}, got) +} + +// TestCatalog_PluginVersions_Memoized: the second call must not see registry +// changes - the version list is fetched once per pull. +func TestCatalog_PluginVersions_Memoized(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "postgresql-mgr", "v1.0.0", "") + + catalog := newTestCatalog(t, reg) + + first, err := catalog.PluginVersions(context.Background(), "postgresql-mgr") + require.NoError(t, err) + require.Len(t, first, 1) + + addPluginVersion(t, reg, "postgresql-mgr", "v9.9.9", "") + + second, err := catalog.PluginVersions(context.Background(), "postgresql-mgr") + require.NoError(t, err) + assert.Len(t, second, 1, "memoized version list must not refresh mid-pull") +} + +func TestCatalog_Contract_FullV2Schema(t *testing.T) { + const contractJSON = `{ + "name": "postgresql-mgr", + "version": "v1.2.3", + "description": "manage postgresql", + "requirements": { + "deckhouse": {"constraint": ">=1.70"}, + "modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.0.0"}]}, + "plugins": {"mandatory": [{"name": "db-connector", "constraint": ">=0.9.0"}]} + } + }` + + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "postgresql-mgr", "v1.2.3", contractJSON) + + catalog := newTestCatalog(t, reg) + + contract, err := catalog.Contract(context.Background(), "postgresql-mgr", semver.MustParse("v1.2.3")) + require.NoError(t, err) + + assert.Equal(t, "postgresql-mgr", contract.Name) + assert.Equal(t, "v1.2.3", contract.Version) + assert.Equal(t, ">=1.70", contract.Requirements.Deckhouse.Constraint) + + require.Len(t, contract.Requirements.Modules.Mandatory, 1) + assert.Equal(t, "postgresql", contract.Requirements.Modules.Mandatory[0].Name) + assert.Equal(t, ">=1.0.0", contract.Requirements.Modules.Mandatory[0].Constraint) + + require.Len(t, contract.Requirements.Plugins.Mandatory, 1) + assert.Equal(t, "db-connector", contract.Requirements.Plugins.Mandatory[0].Name) +} + +// TestCatalog_Contract_NoAnnotation: a contract-less image is not an error - +// only name and version are known, there is nothing to enforce. +func TestCatalog_Contract_NoAnnotation(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "plain", "v1.0.0", "") + + catalog := newTestCatalog(t, reg) + + contract, err := catalog.Contract(context.Background(), "plain", semver.MustParse("v1.0.0")) + require.NoError(t, err) + + assert.Equal(t, "plain", contract.Name) + assert.Equal(t, "v1.0.0", contract.Version) + assert.Empty(t, contract.Requirements.Modules.Mandatory) +} + +// TestCatalog_Contract_ContentErrors: every deterministic content problem +// must carry the ErrInvalidContract sentinel, so the resolver can skip the +// version instead of failing the pull. +func TestCatalog_Contract_ContentErrors(t *testing.T) { + b64 := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + + cases := []struct { + name string + annotation string + }{ + {"broken base64", "%%%not-base64%%%"}, + {"malformed JSON", b64("{{{")}, + {"v1 array schema rejected", b64(`{"name":"x","version":"v1.0.0","requirements":{"modules":[{"name":"m"}]}}`)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addRawContract(t, reg, "broken", "v1.0.0", tc.annotation) + + catalog := newTestCatalog(t, reg) + + _, err := catalog.Contract(context.Background(), "broken", semver.MustParse("v1.0.0")) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidContract) + }) + } +} + +// TestCatalog_Contract_TransportErrorIsNotContentError: a missing tag must +// come back as a plain error WITHOUT the ErrInvalidContract sentinel - the +// resolver treats it as operational, not as a broken published version. +func TestCatalog_Contract_TransportErrorIsNotContentError(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "present", "v1.0.0", "") + + catalog := newTestCatalog(t, reg) + + _, err := catalog.Contract(context.Background(), "present", semver.MustParse("v9.9.9")) + require.Error(t, err) + assert.NotErrorIs(t, err, ErrInvalidContract) +} + +// TestCatalog_Contract_Memoized: re-publishing a tag mid-pull must not change +// the already-fetched contract. +func TestCatalog_Contract_Memoized(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "postgresql-mgr", "v1.0.0", `{"name":"postgresql-mgr","version":"v1.0.0","description":"first"}`) + + catalog := newTestCatalog(t, reg) + + first, err := catalog.Contract(context.Background(), "postgresql-mgr", semver.MustParse("v1.0.0")) + require.NoError(t, err) + require.Equal(t, "first", first.Description) + + addPluginVersion(t, reg, "postgresql-mgr", "v1.0.0", `{"name":"postgresql-mgr","version":"v1.0.0","description":"second"}`) + + second, err := catalog.Contract(context.Background(), "postgresql-mgr", semver.MustParse("v1.0.0")) + require.NoError(t, err) + assert.Equal(t, "first", second.Description, "memoized contract must not refresh mid-pull") +} diff --git a/internal/mirror/plugins/doc.go b/internal/mirror/plugins/doc.go new file mode 100644 index 000000000..e94cbd25b --- /dev/null +++ b/internal/mirror/plugins/doc.go @@ -0,0 +1,34 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package plugins mirrors d8 CLI plugins into the images bundle. +// +// Plugins live in a single registry catalog OUTSIDE the edition segment (same +// asymmetry as the installer): +// +// /deckhouse-cli/plugins - catalog; its tags are plugin names +// /deckhouse-cli/plugins/: - one plugin version (multi-platform OCI index) +// +// A plugin declares its requirements (Deckhouse modules, other plugins, +// platform versions) in a contract: a base64-JSON annotation on the image +// manifest. Reading a contract is a single manifest fetch, so deciding WHAT +// to mirror needs no layer downloads. +// +// Selection principle: nothing extra. A plugin enters the bundle only when a +// mirrored module needs it (its contract names that module), when another +// selected plugin requires it, or when the user asks for it explicitly with +// --include-plugin. +package plugins diff --git a/internal/mirror/plugins/plugins.go b/internal/mirror/plugins/plugins.go new file mode 100644 index 000000000..efc39afb6 --- /dev/null +++ b/internal/mirror/plugins/plugins.go @@ -0,0 +1,361 @@ +/* +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" + "io" + "path/filepath" + "time" + + "github.com/Masterminds/semver/v3" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/types" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + dkpreg "github.com/deckhouse/deckhouse/pkg/registry" + + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" + "github.com/deckhouse/deckhouse-cli/internal/mirror/pack" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/retry" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/retry/task" + regimage "github.com/deckhouse/deckhouse-cli/pkg/registry/image" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +const ( + // pluginsDirName is the working-dir subdirectory holding per-plugin OCI + // layouts during a pull. + pluginsDirName = "plugins" + + pullRetryAttempts = 5 + pullRetryDelay = 10 * time.Second +) + +// Options contains configuration options for the plugins service. +type Options struct { + // Filter carries --include-plugin expressions (whitelist, additive to the + // module-driven auto-selection). May be nil. + Filter *modules.Filter + // Builtins are d8 built-in command names that satisfy a same-named plugin + // dependency by presence (never pulled). + Builtins map[string]struct{} + // BundleDir is the directory to store the bundle. + BundleDir string + // BundleChunkSize is the max size of bundle chunks in bytes (0 = no chunking). + BundleChunkSize int64 + // DryRun prints the pull plan without downloading any image blobs. + DryRun bool + // ProxyRegistry means the registry serves no catalog: auto-selection is + // impossible, only explicit exact pins are resolved. + ProxyRegistry bool +} + +// PullInput is the cross-phase handoff: what the earlier pull phases put into +// the bundle. Built by the pull orchestrator, never by this package. +type PullInput struct { + // Modules are the mirrored modules with their bundled versions. + Modules []ModuleInBundle + // PlatformVersions are the mirrored Deckhouse platform versions. + PlatformVersions []*semver.Version +} + +// Service is the plugins phase of mirror pull: resolve which plugin versions +// the bundle needs, pull them (multi-platform indexes whole), pack one +// plugin-.tar per plugin. +type Service struct { + workingDir string + + // pluginsService handles plugin registry operations. + pluginsService *registryservice.PluginsService + // resolver picks the plugin versions to mirror. + resolver Resolver + // layouts holds per-plugin OCI layouts, created lazily. + layouts map[pluginName]*regimage.ImageLayout + + options *Options + + // stats accumulates pull accounting for the summary. + stats *pluginsPullStats + + logger *dkplog.Logger + userLogger *log.SLogger +} + +// NewService creates the plugins phase service. +func NewService( + registryService *registryservice.Service, + workingDir string, + options *Options, + logger *dkplog.Logger, + userLogger *log.SLogger, +) *Service { + pluginsService := registryService.PluginService() + + return &Service{ + workingDir: workingDir, + + pluginsService: pluginsService, + resolver: NewResolver(NewCatalog(pluginsService, logger), logger), + layouts: make(map[pluginName]*regimage.ImageLayout), + + options: options, + + stats: newPluginsPullStats(), + + logger: logger, + userLogger: userLogger, + } +} + +// PullPlugins mirrors the plugins the bundle needs: plugins whose contracts +// name the mirrored modules (per bundled module version), their mandatory +// plugin dependencies, and explicit --include-plugin entries. +func (svc *Service) PullPlugins(ctx context.Context, in PullInput) error { + svc.stats.attempted = true + + modulesIn := in.Modules + if svc.options.ProxyRegistry { + // A proxy registry serves no catalog, so auto-selection cannot + // enumerate plugins. Explicit exact pins still work: they address + // manifests by tag. + if len(modulesIn) > 0 { + svc.userLogger.WarnLn("Plugin auto-selection is not available with --proxy-registry; use --include-plugin @= to mirror plugins.") + } + + modulesIn = nil + } + + resolution, err := svc.resolver.Resolve(ctx, ResolveInput{ + Modules: modulesIn, + PlatformVersions: in.PlatformVersions, + Filter: svc.options.Filter, + Builtins: svc.options.Builtins, + }) + if err != nil { + return err + } + + svc.stats.recordResolution(resolution) + + for _, warning := range resolution.Warnings { + svc.userLogger.WarnLn(warning) + } + + for _, skip := range resolution.Skipped { + svc.userLogger.Warnf("Skipping plugin %s: %s", skip.Name, skip.Reason) + } + + if len(resolution.Plugins) == 0 { + svc.userLogger.InfoLn("No plugins to mirror") + + return nil + } + + if svc.options.DryRun { + svc.printDryRunPlan(resolution) + + return nil + } + + if err := svc.pullPlugins(ctx, resolution); err != nil { + return err + } + + // Image counts must be captured before packing: bundle.Pack deletes the + // layout files as it tars them. + svc.stats.captureImages(svc.layouts) + + return svc.packPlugins(ctx, resolution) +} + +func (svc *Service) pullPlugins(ctx context.Context, resolution *Resolution) error { + total := 0 + for _, plugin := range resolution.Plugins { + total += len(plugin.Versions) + } + + current := 0 + + for _, plugin := range resolution.Plugins { + for _, sv := range plugin.Versions { + current++ + + tag := sv.Version.Original() + ref := svc.pluginRef(plugin.Name, tag) + + err := retry.RunTask( + ctx, + svc.userLogger, + fmt.Sprintf("[%d / %d] Pulling %s", current, total, ref), + task.WithConstantRetries(pullRetryAttempts, pullRetryDelay, func(ctx context.Context) error { + return svc.pullVersion(ctx, plugin.Name, tag) + })) + if err != nil { + return fmt.Errorf("pull plugin %s@%s: %w", plugin.Name, tag, err) + } + } + } + + return nil +} + +// pullVersion pulls one plugin version into the plugin's OCI layout. A +// multi-platform index is stored whole: children are fetched by digest, so +// their bytes (and the contract annotation) stay exactly as published. +func (svc *Service) pullVersion(ctx context.Context, name pluginName, tag versionTag) error { + pluginSvc := svc.pluginsService.Plugin(name) + + layout, err := svc.layoutFor(name) + if err != nil { + return err + } + + result, err := pluginSvc.GetManifest(ctx, tag) + if err != nil { + return fmt.Errorf("get manifest: %w", err) + } + + if !result.GetMediaType().IsIndex() { + img, err := pluginSvc.GetImage(ctx, tag) + if err != nil { + return fmt.Errorf("get image: %w", err) + } + + return layout.AddImage(img, tag) + } + + indexManifest, err := result.GetIndexManifest() + if err != nil { + return fmt.Errorf("read index manifest: %w", err) + } + + idx, err := rebuildIndex(ctx, pluginSvc, indexManifest, result.GetMediaType()) + if err != nil { + return err + } + + return layout.AddIndex(idx, tag, svc.pluginRef(name, tag)) +} + +// rebuildIndex reassembles a multi-platform index from its children. Children +// are fetched by digest (byte-exact); only the top-level index manifest is +// re-marshaled locally, with its media type and annotations carried over. +func rebuildIndex(ctx context.Context, pluginSvc *registryservice.PluginService, indexManifest dkpreg.IndexManifest, mediaType types.MediaType) (v1.ImageIndex, error) { + children := indexManifest.GetManifests() + + adds := make([]mutate.IndexAddendum, 0, len(children)) + + for _, child := range children { + img, err := pluginSvc.GetImage(ctx, "@"+child.GetDigest().String()) + if err != nil { + return nil, fmt.Errorf("get platform image %s: %w", child.GetDigest(), err) + } + + adds = append(adds, mutate.IndexAddendum{ + Add: img, + Descriptor: v1.Descriptor{ + MediaType: child.GetMediaType(), + URLs: child.GetURLs(), + Annotations: child.GetAnnotations(), + Platform: child.GetPlatform(), + }, + }) + } + + idx := mutate.AppendManifests(empty.Index, adds...) + + if annotations := indexManifest.GetAnnotations(); len(annotations) > 0 { + annotated, ok := mutate.Annotations(idx, annotations).(v1.ImageIndex) + if !ok { + return nil, fmt.Errorf("annotate rebuilt index: unexpected mutate result type") + } + + idx = annotated + } + + return mutate.IndexMediaType(idx, mediaType), nil +} + +func (svc *Service) packPlugins(ctx context.Context, resolution *Resolution) error { + for _, plugin := range resolution.Plugins { + // Honor cancellation between plugins so a Ctrl+C during the pack + // phase doesn't keep producing more tars. + if err := ctx.Err(); err != nil { + return err + } + + if _, ok := svc.layouts[plugin.Name]; !ok { + continue + } + + pkgName := "plugin-" + plugin.Name + ".tar" + + if err := svc.userLogger.Process(fmt.Sprintf("Pack %s", pkgName), func() error { + // The tar prefix places the layout at deckhouse-cli/plugins/ + // inside the bundle - the path mirror push uploads verbatim and + // the registry-packages-proxy expects on the target side. + pluginDir := filepath.Join(svc.workingDir, pluginsDirName, plugin.Name) + tarPrefix := filepath.Join("deckhouse-cli", "plugins", plugin.Name) + + return pack.Bundle(ctx, svc.options.BundleDir, pkgName, svc.options.BundleChunkSize, func(w io.Writer) error { + return bundle.PackWithPrefix(ctx, pluginDir, tarPrefix, w) + }) + }); err != nil { + return err + } + } + + return nil +} + +// printDryRunPlan prints the refs that would be pulled, without downloading. +func (svc *Service) printDryRunPlan(resolution *Resolution) { + svc.userLogger.InfoLn("[dry-run] Plugins that would be pulled:") + + for _, plugin := range resolution.Plugins { + for _, sv := range plugin.Versions { + svc.userLogger.InfoLn(" " + svc.pluginRef(plugin.Name, sv.Version.Original())) + } + } +} + +func (svc *Service) layoutFor(name pluginName) (*regimage.ImageLayout, error) { + if layout, ok := svc.layouts[name]; ok { + return layout, nil + } + + layout, err := regimage.NewImageLayout(filepath.Join(svc.workingDir, pluginsDirName, name)) + if err != nil { + return nil, fmt.Errorf("create layout for plugin %s: %w", name, err) + } + + svc.layouts[name] = layout + + return layout, nil +} + +// pluginRef is the full registry reference of one plugin version, e.g. +// "registry.deckhouse.io/deckhouse/deckhouse-cli/plugins/foo:v1.2.3". +func (svc *Service) pluginRef(name pluginName, tag versionTag) string { + return svc.pluginsService.Plugin(name).GetRoot() + ":" + tag +} diff --git a/internal/mirror/plugins/pull_plugins_test.go b/internal/mirror/plugins/pull_plugins_test.go new file mode 100644 index 000000000..84b06eaab --- /dev/null +++ b/internal/mirror/plugins/pull_plugins_test.go @@ -0,0 +1,321 @@ +/* +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" + "context" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + ggcrregistry "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + regclient "github.com/deckhouse/deckhouse/pkg/registry/client" + upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" + + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// newPhaseService wires the plugins phase over a fake registry with quiet +// loggers, a fresh working dir, and the given options. +func newPhaseService(t *testing.T, reg *upfake.Registry, options *Options) *Service { + t.Helper() + + stub := pkgclient.Adapt(upfake.NewClient(reg)) + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + regSvc := registryservice.NewService(stub, pkg.NoEdition, logger) + + return NewService(regSvc, t.TempDir(), options, logger, log.NewSLogger(slog.LevelWarn)) +} + +// readBundleTar returns all entry names of a bundle tar and the content of +// its single OCI index.json. +func readBundleTar(t *testing.T, path string) ([]string, []byte) { + t.Helper() + + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + + var names []string + + var indexJSON []byte + + tr := tar.NewReader(f) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + + require.NoError(t, err) + names = append(names, header.Name) + + if strings.HasSuffix(header.Name, "/index.json") { + indexJSON, err = io.ReadAll(tr) + require.NoError(t, err) + } + } + + return names, indexJSON +} + +// untarTo extracts a bundle tar into destDir. +func untarTo(t *testing.T, tarPath, destDir string) { + t.Helper() + + f, err := os.Open(tarPath) + require.NoError(t, err) + defer f.Close() + + tr := tar.NewReader(f) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + + require.NoError(t, err) + + target := filepath.Join(destDir, header.Name) + + switch header.Typeflag { + case tar.TypeDir: + require.NoError(t, os.MkdirAll(target, 0o755)) + case tar.TypeReg: + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + + out, err := os.Create(target) + require.NoError(t, err) + _, err = io.Copy(out, tr) //nolint:gosec // test fixture tars only + require.NoError(t, err) + require.NoError(t, out.Close()) + } + } +} + +const mgrContractV110 = `{ + "name": "postgresql-mgr", "version": "v1.1.0", + "requirements": {"modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.0.0 <1.5.0"}]}} +}` + +const mgrContractV120 = `{ + "name": "postgresql-mgr", "version": "v1.2.0", + "requirements": {"modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.5.0"}]}} +}` + +// TestPullPlugins_EndToEnd runs the whole phase against a fake registry: the +// per-module-version selection picks two plugin versions, both land in one +// plugin-.tar under the deckhouse-cli/plugins/ prefix, and the +// stats carry versions with provenance. +func TestPullPlugins_EndToEnd(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "postgresql-mgr", "v1.1.0", mgrContractV110) + addPluginVersion(t, reg, "postgresql-mgr", "v1.2.0", mgrContractV120) + + bundleDir := t.TempDir() + svc := newPhaseService(t, reg, &Options{BundleDir: bundleDir}) + + err := svc.PullPlugins(context.Background(), PullInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.0.0", "v1.5.0", "v1.10.0")}, + }) + require.NoError(t, err) + + tarPath := filepath.Join(bundleDir, "plugin-postgresql-mgr.tar") + require.FileExists(t, tarPath) + + entries, indexJSON := readBundleTar(t, tarPath) + require.NotEmpty(t, entries) + + for _, entry := range entries { + assert.Truef(t, strings.HasPrefix(entry, "deckhouse-cli/plugins/postgresql-mgr/"), + "every tar entry must carry the registry prefix, got %q", entry) + } + + var index struct { + Manifests []struct { + Annotations map[string]string `json:"annotations"` + } `json:"manifests"` + } + require.NoError(t, json.Unmarshal(indexJSON, &index)) + + tags := make([]string, 0, len(index.Manifests)) + for _, m := range index.Manifests { + tags = append(tags, m.Annotations["io.deckhouse.image.short_tag"]) + } + + assert.ElementsMatch(t, []string{"v1.1.0", "v1.2.0"}, tags, + "the bundle must hold exactly the versions the resolver picked") + + stats := svc.Stats() + assert.True(t, stats.Attempted) + require.Len(t, stats.Plugins, 1) + assert.Equal(t, "postgresql-mgr", stats.Plugins[0].Name) + assert.Equal(t, 2, stats.Plugins[0].Images) + assert.Equal(t, 2, stats.TotalImages) + require.Len(t, stats.Plugins[0].Versions, 2) + assert.NotEmpty(t, stats.Plugins[0].Versions[0].Reasons, "provenance must reach the stats") +} + +// TestPullPlugins_NothingToMirror: a registry with no relevant plugins yields +// no error, no tars, and attempted-but-empty stats. +func TestPullPlugins_NothingToMirror(t *testing.T) { + reg := upfake.NewRegistry(testHost) + + bundleDir := t.TempDir() + svc := newPhaseService(t, reg, &Options{BundleDir: bundleDir}) + + err := svc.PullPlugins(context.Background(), PullInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + require.NoError(t, err) + + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "no bundle files must be written when nothing is mirrored") + + stats := svc.Stats() + assert.True(t, stats.Attempted) + assert.Empty(t, stats.Plugins) +} + +// TestPullPlugins_DryRun: the resolution runs in full (versions in stats) but +// neither layouts nor bundle tars are written. +func TestPullPlugins_DryRun(t *testing.T) { + reg := upfake.NewRegistry(testHost) + addPluginVersion(t, reg, "postgresql-mgr", "v1.2.0", mgrContractV120) + + bundleDir := t.TempDir() + svc := newPhaseService(t, reg, &Options{BundleDir: bundleDir, DryRun: true}) + + err := svc.PullPlugins(context.Background(), PullInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + require.NoError(t, err) + + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write bundle files") + + stats := svc.Stats() + require.Len(t, stats.Plugins, 1) + assert.Equal(t, []PluginVersionStat{{ + Version: "v1.2.0", + Reasons: []Reason{{Kind: ReasonModule, Subject: "postgresql", Constraint: ">=1.5.0"}}, + }}, stats.Plugins[0].Versions) + assert.Zero(t, stats.TotalImages, "dry-run pulls no images") +} + +// TestPullPlugins_MultiPlatformIndexPreserved is the end-to-end proof of the +// multi-platform keystone: a plugin published as a two-platform index with a +// contract annotation goes through resolve -> pull -> pack and comes out of +// the bundle tar as the same index - same digest, both platforms, contract +// annotation intact. Runs against ggcr's in-memory registry with the real +// client (the fake cannot store indexes). +func TestPullPlugins_MultiPlatformIndexPreserved(t *testing.T) { + srv := httptest.NewServer(ggcrregistry.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + contractJSON := `{ + "name": "pg-tool", "version": "v1.0.0", + "requirements": {"modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.0.0"}]}} + }` + encodedContract := base64.StdEncoding.EncodeToString([]byte(contractJSON)) + + linuxImg := upfake.NewImageBuilder().WithFile("plugin", "linux-bin").MustBuild() + darwinImg := upfake.NewImageBuilder().WithFile("plugin", "darwin-bin").MustBuild() + + idx := mutate.AppendManifests(empty.Index, + mutate.IndexAddendum{Add: linuxImg, Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}}}, + mutate.IndexAddendum{Add: darwinImg, Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "darwin", Architecture: "arm64"}}}, + ) + annotated, ok := mutate.Annotations(idx, map[string]string{"contract": encodedContract}).(v1.ImageIndex) + require.True(t, ok) + + pluginRef, err := name.ParseReference(host+"/deckhouse-cli/plugins/pg-tool:v1.0.0", name.Insecure) + require.NoError(t, err) + require.NoError(t, remote.WriteIndex(pluginRef, annotated)) + + // The catalog's directory-as-tags name index. + nameRef, err := name.ParseReference(host+"/deckhouse-cli/plugins:pg-tool", name.Insecure) + require.NoError(t, err) + require.NoError(t, remote.Write(nameRef, upfake.NewImageBuilder().WithFile("name", "pg-tool").MustBuild())) + + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + regSvc := registryservice.NewService(pkgclient.NewFromOptions(host, regclient.WithInsecure(true)), pkg.NoEdition, logger) + + bundleDir := t.TempDir() + svc := NewService(regSvc, t.TempDir(), &Options{BundleDir: bundleDir}, logger, log.NewSLogger(slog.LevelWarn)) + + err = svc.PullPlugins(context.Background(), PullInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + require.NoError(t, err) + + tarPath := filepath.Join(bundleDir, "plugin-pg-tool.tar") + require.FileExists(t, tarPath) + + extracted := t.TempDir() + untarTo(t, tarPath, extracted) + + layoutPath := layout.Path(filepath.Join(extracted, "deckhouse-cli", "plugins", "pg-tool")) + topIndex, err := layoutPath.ImageIndex() + require.NoError(t, err) + topManifest, err := topIndex.IndexManifest() + require.NoError(t, err) + + require.Len(t, topManifest.Manifests, 1) + desc := topManifest.Manifests[0] + assert.True(t, desc.MediaType.IsIndex(), "the plugin version must stay an index in the bundle") + assert.Equal(t, "v1.0.0", desc.Annotations["io.deckhouse.image.short_tag"]) + + nested, err := topIndex.ImageIndex(desc.Digest) + require.NoError(t, err) + nestedManifest, err := nested.IndexManifest() + require.NoError(t, err) + + require.Len(t, nestedManifest.Manifests, 2, "both platform children must survive the round-trip") + platforms := []string{ + nestedManifest.Manifests[0].Platform.String(), + nestedManifest.Manifests[1].Platform.String(), + } + assert.ElementsMatch(t, []string{"linux/amd64", "darwin/arm64"}, platforms) + assert.Equal(t, encodedContract, nestedManifest.Annotations["contract"], + "the contract annotation must survive into the bundle") +} diff --git a/internal/mirror/plugins/resolver.go b/internal/mirror/plugins/resolver.go new file mode 100644 index 000000000..dbbc6615f --- /dev/null +++ b/internal/mirror/plugins/resolver.go @@ -0,0 +1,964 @@ +/* +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" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + dkpclient "github.com/deckhouse/deckhouse/pkg/registry/client" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/mirror/errmatch" + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" + "github.com/deckhouse/deckhouse-cli/internal/plugins/requirements" +) + +// maxResolveDepth caps plugin dependency recursion, same as the install-time +// planner: deeper chains are a contract-authoring error, not a real graph. +const maxResolveDepth = 16 + +// resolver implements Resolver: for every bundled version of every mirrored +// module it picks the newest plugin version the bundle satisfies, resolves +// transitive mandatory plugin dependencies, and applies --include-plugin on +// top. Content problems (no compatible version, broken published contract) +// skip a plugin with a recorded reason; transport errors fail the whole +// resolution. An explicitly included plugin that cannot be resolved is an +// error, not a skip. +type resolver struct { + catalog Catalog + logger *dkplog.Logger +} + +// NewResolver creates a resolver over the given catalog. +func NewResolver(catalog Catalog, logger *dkplog.Logger) Resolver { + return &resolver{catalog: catalog, logger: logger} +} + +func (r *resolver) Resolve(ctx context.Context, in ResolveInput) (*Resolution, error) { + st := &resolveState{ + catalog: r.catalog, + in: in, + bundle: make(map[moduleName][]*semver.Version, len(in.Modules)), + selected: make(selectionSet), + warnings: newWarningLog(), + logger: r.logger, + } + + for _, module := range in.Modules { + st.bundle[module.Name] = module.Versions + } + + if len(st.bundle) > 0 { + if err := st.resolveAuto(ctx); err != nil { + return nil, err + } + } + + if err := st.resolveExplicit(ctx); err != nil { + return nil, err + } + + return st.result(), nil +} + +// resolveState is the working state of one Resolve call. +type resolveState struct { + catalog Catalog + in ResolveInput + // bundle maps a mirrored module to its bundled versions. + bundle map[moduleName][]*semver.Version + + selected selectionSet + skipped []SkippedPlugin + warnings *warningLog + + logger *dkplog.Logger +} + +// selectionSet accumulates the resolution outcome: which versions of which +// plugins enter the bundle, with provenance on every version. +type selectionSet map[pluginName]map[versionTag]*SelectedVersion + +// commit adds a plugin version to the set; when the version is already +// selected, only the provenance edge is appended. +func (s selectionSet) commit(name pluginName, version *semver.Version, contract *internal.Plugin, reason Reason) { + byVersion, ok := s[name] + if !ok { + byVersion = make(map[versionTag]*SelectedVersion) + s[name] = byVersion + } + + tag := version.Original() + + sv, ok := byVersion[tag] + if !ok { + sv = &SelectedVersion{Version: version, Contract: contract} + byVersion[tag] = sv + } + + addReason(sv, reason) +} + +// at returns the selection stored under exact (name, tag), nil if absent. +func (s selectionSet) at(name pluginName, tag versionTag) *SelectedVersion { + return s[name][tag] +} + +// version is at() keyed by a parsed version. +func (s selectionSet) version(name pluginName, version *semver.Version) *SelectedVersion { + return s.at(name, version.Original()) +} + +// warningLog collects user-facing warnings in emission order, deduplicated. +type warningLog struct { + seen map[string]struct{} + messages []string +} + +func newWarningLog() *warningLog { + return &warningLog{seen: make(map[string]struct{})} +} + +func (w *warningLog) add(msg string) { + if _, dup := w.seen[msg]; dup { + return + } + + w.seen[msg] = struct{}{} + w.messages = append(w.messages, msg) +} + +// resolveAuto selects plugins for the bundle's modules: every plugin in the +// catalog whose contract names a mirrored module is paired with each bundled +// version of that module. +func (st *resolveState) resolveAuto(ctx context.Context) error { + names, err := st.catalog.PluginNames(ctx) + if err != nil { + if isNotPublished(err) { + // The source registry has no plugins catalog (older registries, + // self-hosted mirrors). Nothing to auto-select; explicit includes + // still resolve against their own repositories. + st.logger.Debug("The registry has no plugins catalog, skipping plugin auto-selection") + + return nil + } + + return fmt.Errorf("list plugins catalog: %w", err) + } + + sort.Strings(names) + + for _, name := range names { + if err := st.resolveAutoPlugin(ctx, name); err != nil { + return err + } + } + + return nil +} + +func (st *resolveState) resolveAutoPlugin(ctx context.Context, name string) error { + versions, err := st.catalog.PluginVersions(ctx, name) + if err != nil { + if isNotPublished(err) { + // A name tag in the catalog index without a version repo behind it. + st.logger.Debug(fmt.Sprintf("Plugin %q is in the catalog index but has no published versions", name)) + + return nil + } + + return err + } + + if len(versions) == 0 { + return nil + } + + // Relevance is judged by the newest readable contract: it is the plugin's + // current self-description. Older contracts may reference abandoned + // module integrations and must not resurrect them. + top, err := st.newestReadableContract(ctx, name, versions) + if err != nil { + return err + } + + if top == nil { + // Every published version carries a broken contract - relevance + // cannot be judged. Surface the broken publication to the operator. + st.skipped = append(st.skipped, SkippedPlugin{Name: name, Reason: "no readable contract in any published version"}) + + return nil + } + + triggers := triggeringModules(top, st.bundle) + if len(triggers) == 0 { + return nil + } + + var failures []string + + for _, module := range triggers { + for _, moduleVersion := range st.bundle[module] { + failure, err := st.selectForModuleVersion(ctx, name, versions, module, moduleVersion) + if err != nil { + return err + } + + if failure != "" { + failures = append(failures, fmt.Sprintf("for module %s %s: %s", module, moduleVersion.Original(), failure)) + } + } + } + + if len(failures) == 0 { + return nil + } + + detail := strings.Join(failures, "; ") + + // A plugin that still got some version into the bundle is not skipped: + // the leftover per-module-version failures become advisories. + if len(st.selected[name]) > 0 { + st.warnings.add(fmt.Sprintf("plugin %s: %s", name, detail)) + + return nil + } + + st.skipped = append(st.skipped, SkippedPlugin{Name: name, Reason: detail}) + + return nil +} + +// newestReadableContract walks versions newest first and returns the first +// contract that decodes. Versions with broken published contracts are passed +// over; nil means no version has a readable contract. +func (st *resolveState) newestReadableContract(ctx context.Context, name string, versions []*semver.Version) (*internal.Plugin, error) { + for _, version := range versions { + contract, err := st.catalog.Contract(ctx, name, version) + if err != nil { + if errors.Is(err, ErrInvalidContract) { + continue + } + + return nil, err + } + + return contract, nil + } + + return nil, nil +} + +// selectForModuleVersion picks the newest plugin version compatible with one +// bundled module version and commits it (with its dependency closure). An +// empty failure return means a version was selected; a non-empty one carries +// the newest candidate's rejection reason. +func (st *resolveState) selectForModuleVersion(ctx context.Context, name string, versions []*semver.Version, module string, moduleVersion *semver.Version) (string, error) { + var firstReject string + + for _, candidate := range versions { + contract, err := st.catalog.Contract(ctx, name, candidate) + if err != nil { + if errors.Is(err, ErrInvalidContract) { + noteReject(&firstReject, fmt.Sprintf("%s: broken published contract", candidate.Original())) + + continue + } + + return "", err + } + + why := st.pairingGate(contract, module, moduleVersion) + if why != "" { + noteReject(&firstReject, fmt.Sprintf("%s: %s", candidate.Original(), why)) + + continue + } + + _, declaredConstraint := moduleConstraint(contract, module) + reason := Reason{Kind: ReasonModule, Subject: module, Constraint: declaredConstraint} + + // The same version may win for several module versions: the first win + // already resolved its dependencies, later wins only add provenance. + if sv := st.selected.version(name, candidate); sv != nil { + addReason(sv, reason) + + return "", nil + } + + delta := &selectionDelta{} + + why, err = st.resolveDeps(ctx, contract, delta, map[pluginName]bool{name: true}, []string{name + "@" + candidate.Original()}, 0, true) + if err != nil { + return "", err + } + + if why != "" { + noteReject(&firstReject, fmt.Sprintf("%s: %s", candidate.Original(), why)) + + continue + } + + st.selected.commit(name, candidate, contract, reason) + st.applyDelta(delta) + + return "", nil + } + + if firstReject == "" { + firstReject = "no published versions" + } + + return firstReject, nil +} + +// resolveExplicit applies --include-plugin entries on top of the module-driven +// selection. Failures here are the user's explicit request going unmet, so +// they are errors, not skips. +func (st *resolveState) resolveExplicit(ctx context.Context) error { + // Only a whitelist filter carries --include-plugin names; anything else + // (including the empty filter, which is a blacklist) has none. + if st.in.Filter == nil || !st.in.Filter.IsWhitelist() || st.in.Filter.Len() == 0 { + return nil + } + + for _, name := range st.in.Filter.ModuleNames() { + constraint, _ := st.in.Filter.GetConstraint(name) + + if err := st.resolveExplicitPlugin(ctx, name, constraint); err != nil { + return err + } + } + + return nil +} + +func (st *resolveState) resolveExplicitPlugin(ctx context.Context, name string, constraint modules.VersionConstraint) error { + subject := "--include-plugin " + name + + // Exact tag pins bypass the stable-version list, so pre-releases stay + // reachable - the mirror analog of `d8 plugins install --version`. + exacts := modules.ExactConstraintsOf(constraint) + for _, exact := range exacts { + if err := st.selectExplicitExact(ctx, name, exact.Tag(), subject); err != nil { + return err + } + } + + // Nothing ranged left when the pin was exact-only. + ranges := modules.SemverConstraintsOf(constraint) + if constraint != nil && len(ranges) == 0 { + return nil + } + + versions, err := st.catalog.PluginVersions(ctx, name) + if err != nil { + if isNotPublished(err) { + return fmt.Errorf("--include-plugin %s: plugin is not published under deckhouse-cli/plugins", name) + } + + return err + } + + if constraint == nil { + return st.selectExplicitRanged(ctx, name, versions, nil, subject) + } + + // Every semver range the user declared must be met on its own: repeated + // --include-plugin entries OR-combine into disjoint ranges, and one + // picked version must not silently swallow another range. + for _, sub := range ranges { + if err := st.selectExplicitRanged(ctx, name, versions, sub, subject); err != nil { + return err + } + } + + return nil +} + +// selectExplicitRanged picks the newest version matching one semver range (or +// any version when sub is nil) whose contract is readable and dependencies +// resolve. Bundle-gate violations warn: the user's explicit choice wins, and +// modules are never auto-added for a plugin. +func (st *resolveState) selectExplicitRanged(ctx context.Context, name pluginName, versions []*semver.Version, sub *modules.SemanticVersionConstraint, subject string) error { + var rejections []string + + for _, candidate := range versions { + if sub != nil && !sub.Match(candidate) { + continue + } + + contract, err := st.catalog.Contract(ctx, name, candidate) + if err != nil { + if errors.Is(err, ErrInvalidContract) { + rejections = append(rejections, fmt.Sprintf("%s: broken published contract", candidate.Original())) + + continue + } + + return err + } + + delta := &selectionDelta{} + + why, err := st.resolveDeps(ctx, contract, delta, map[pluginName]bool{name: true}, []string{name + "@" + candidate.Original()}, 0, false) + if err != nil { + return err + } + + if why != "" { + rejections = append(rejections, fmt.Sprintf("%s: %s", candidate.Original(), why)) + + continue + } + + st.selected.commit(name, candidate, contract, Reason{Kind: ReasonExplicit, Subject: subject}) + st.applyDelta(delta) + + if gateWarn := st.bundleGate(contract, ""); gateWarn != "" { + st.warnings.add(fmt.Sprintf("plugin %s@%s (explicitly included): %s; the target cluster must provide it", name, candidate.Original(), gateWarn)) + } + + return nil + } + + if len(rejections) == 0 { + return fmt.Errorf("--include-plugin %s: no published version matches the requested constraint; pin an exact version with %s@=vX.Y.Z if it is a pre-release", name, name) + } + + return fmt.Errorf("--include-plugin %s: no suitable version: %s", name, strings.Join(rejections, "; ")) +} + +func (st *resolveState) selectExplicitExact(ctx context.Context, name, tag, subject string) error { + version, err := semver.NewVersion(tag) + if err != nil { + return fmt.Errorf("--include-plugin %s: %q is not a semver version tag", name, tag) + } + + contract, err := st.catalog.Contract(ctx, name, version) + if err != nil { + if isNotPublished(err) { + return fmt.Errorf("--include-plugin %s: version %s is not published", name, tag) + } + + // Shipping an exactly pinned image whose requirements cannot be read + // would break air-gapped installs silently, so this is fatal. + return fmt.Errorf("--include-plugin %s: %w", name, err) + } + + delta := &selectionDelta{} + + why, err := st.resolveDeps(ctx, contract, delta, map[pluginName]bool{name: true}, []string{name + "@" + tag}, 0, false) + if err != nil { + return err + } + + if why != "" { + return fmt.Errorf("--include-plugin %s@=%s: unresolved dependencies: %s", name, tag, why) + } + + st.selected.commit(name, version, contract, Reason{Kind: ReasonExplicit, Subject: subject, Constraint: "=" + tag}) + st.applyDelta(delta) + + if gateWarn := st.bundleGate(contract, ""); gateWarn != "" { + st.warnings.add(fmt.Sprintf("plugin %s@%s (explicitly included): %s; the target cluster must provide it", name, tag, gateWarn)) + } + + return nil +} + +// resolveDeps resolves the contract's mandatory plugin dependencies into +// delta, recursively. A non-empty reject reason means the candidate that +// declared these dependencies must be passed over; an error is operational +// and fails the resolution. enforceGate tells whether a dependency failing +// the bundle gate rejects it (auto-selected chains) or only warns (chains of +// an explicitly included plugin - the user's choice wins there). +func (st *resolveState) resolveDeps(ctx context.Context, contract *internal.Plugin, delta *selectionDelta, visited map[pluginName]bool, path []string, depth int, enforceGate bool) (string, error) { + if depth > maxResolveDepth { + return fmt.Sprintf("dependency chain deeper than %d: %s", maxResolveDepth, strings.Join(path, " -> ")), nil + } + + for _, req := range contract.Requirements.Plugins.Mandatory { + if _, builtin := st.in.Builtins[req.Name]; builtin { + // Built-in d8 commands satisfy a same-named dependency by + // presence; there is nothing to pull. + continue + } + + if visited[req.Name] { + return fmt.Sprintf("dependency cycle: %s -> %s", strings.Join(path, " -> "), req.Name), nil + } + + var constraint *semver.Constraints + + if req.Constraint != "" { + parsed, err := semver.NewConstraint(req.Constraint) + if err != nil { + return fmt.Sprintf("invalid constraint %q for dependency %q", req.Constraint, req.Name), nil + } + + constraint = parsed + } + + reason := Reason{Kind: ReasonDependency, Subject: path[len(path)-1], Constraint: req.Constraint} + + // Union-reuse: a version already picked for the bundle that satisfies + // this constraint is shared instead of adding another one. + if reused := st.findSatisfying(delta, req.Name, constraint); reused != "" { + delta.reasons = append(delta.reasons, deltaReason{name: req.Name, version: reused, reason: reason}) + + continue + } + + reject, err := st.resolveDepFresh(ctx, req, constraint, reason, delta, visited, path, depth, enforceGate) + if reject != "" || err != nil { + return reject, err + } + } + + return "", nil +} + +// resolveDepFresh picks the newest version of one dependency that satisfies +// the constraint, passes the bundle gate, and resolves its own dependencies. +func (st *resolveState) resolveDepFresh(ctx context.Context, req internal.PluginRequirement, constraint *semver.Constraints, reason Reason, delta *selectionDelta, visited map[pluginName]bool, path []string, depth int, enforceGate bool) (string, error) { + versions, err := st.catalog.PluginVersions(ctx, req.Name) + if err != nil { + if isNotPublished(err) { + return fmt.Sprintf("dependency %q is not published in the plugins catalog", req.Name), nil + } + + return "", err + } + + var firstReject string + + for _, version := range versions { + if constraint != nil && !constraint.Check(version) { + continue + } + + contract, err := st.catalog.Contract(ctx, req.Name, version) + if err != nil { + if errors.Is(err, ErrInvalidContract) { + noteReject(&firstReject, fmt.Sprintf("%s: broken published contract", version.Original())) + + continue + } + + return "", err + } + + if why := st.bundleGate(contract, ""); why != "" { + if enforceGate { + noteReject(&firstReject, fmt.Sprintf("%s: %s", version.Original(), why)) + + continue + } + + st.warnings.add(fmt.Sprintf("dependency %s@%s of an explicitly included plugin: %s; the target cluster must provide it", req.Name, version.Original(), why)) + } + + childPath := append(append(make([]string, 0, len(path)+1), path...), req.Name+"@"+version.Original()) + + mark := delta.checkpoint() + visited[req.Name] = true + + why, err := st.resolveDeps(ctx, contract, delta, visited, childPath, depth+1, enforceGate) + + delete(visited, req.Name) + + if err != nil { + return "", err + } + + if why != "" { + delta.rollback(mark) + noteReject(&firstReject, fmt.Sprintf("%s: %s", version.Original(), why)) + + continue + } + + delta.adds = append(delta.adds, deltaAdd{name: req.Name, version: version, contract: contract, reason: reason}) + + return "", nil + } + + detail := "no published version satisfies it" + if firstReject != "" { + detail = firstReject + } + + return fmt.Sprintf("dependency %q (constraint %q): %s", req.Name, req.Constraint, detail), nil +} + +// pairingGate checks one candidate contract against one bundled module +// version: the contract must integrate the module, the module version must +// satisfy the declared constraint, and the rest of the contract must be +// satisfiable by the bundle. Empty return means the gate passes. +func (st *resolveState) pairingGate(contract *internal.Plugin, module string, moduleVersion *semver.Version) string { + declared, constraintStr := moduleConstraint(contract, module) + if !declared { + return fmt.Sprintf("does not integrate module %q", module) + } + + if constraintStr != "" { + constraint, err := semver.NewConstraint(constraintStr) + if err != nil { + return fmt.Sprintf("invalid constraint %q for module %q", constraintStr, module) + } + + // Same normalization as install-time: CI/build markers on the module + // version are stripped, so ">=1.0" matches "v1.2.3-dev". + if !constraint.Check(requirements.NormalizedForConstraint(moduleVersion)) { + return fmt.Sprintf("requires module %q %s", module, constraintStr) + } + } + + return st.bundleGate(contract, module) +} + +// bundleGate checks the bundle-verifiable requirements of a contract, apart +// from the paired module (already checked by pairingGate; empty when there is +// no pairing). Kubernetes and noneOf requirements are cluster-side and are +// left to install-time validation. Empty return means the gate passes. +func (st *resolveState) bundleGate(contract *internal.Plugin, pairedModule string) string { + for _, req := range contract.Requirements.Modules.Mandatory { + if req.Name == pairedModule { + continue + } + + versions, inBundle := st.bundle[req.Name] + if !inBundle || len(versions) == 0 { + return fmt.Sprintf("requires module %q which is not in the bundle", req.Name) + } + + if ok := anySatisfies(versions, req.Constraint); !ok { + return fmt.Sprintf("requires module %q %s, bundle has %s", req.Name, req.Constraint, formatVersions(versions)) + } + } + + // anyOf groups are checked in full even when they contain the paired + // module: the pairing may have matched a weaker constraint from the + // mandatory bucket, while the group's own member constraint is stricter. + // When the pairing did satisfy the group's member, anyOfSatisfied is true + // anyway (the paired version is one of the bundled ones). + for _, group := range contract.Requirements.Modules.AnyOf { + if !st.anyOfSatisfied(group) { + return fmt.Sprintf("no module of group %q is in the bundle at a satisfying version", group.Name) + } + } + + if constraint := contract.Requirements.Deckhouse.Constraint; constraint != "" && len(st.in.PlatformVersions) > 0 { + if ok := anySatisfies(st.in.PlatformVersions, constraint); !ok { + return fmt.Sprintf("requires Deckhouse %q, bundle platform versions: %s", constraint, formatVersions(st.in.PlatformVersions)) + } + } + + return "" +} + +func (st *resolveState) anyOfSatisfied(group internal.ModuleGroup) bool { + for _, member := range group.Modules { + versions, inBundle := st.bundle[member.Name] + if !inBundle || len(versions) == 0 { + continue + } + + if anySatisfies(versions, member.Constraint) { + return true + } + } + + return false +} + +// selectionDelta accumulates tentative dependency picks while one candidate +// version is evaluated. It is committed only when the whole candidate +// resolves; checkpoints let a failed dependency branch roll back its own +// additions without touching sibling branches. +type selectionDelta struct { + adds []deltaAdd + reasons []deltaReason +} + +type deltaAdd struct { + name pluginName + version *semver.Version + contract *internal.Plugin + reason Reason +} + +type deltaReason struct { + name pluginName + version versionTag + reason Reason +} + +type deltaMark struct { + adds int + reasons int +} + +func (d *selectionDelta) checkpoint() deltaMark { + return deltaMark{adds: len(d.adds), reasons: len(d.reasons)} +} + +func (d *selectionDelta) rollback(m deltaMark) { + d.adds = d.adds[:m.adds] + d.reasons = d.reasons[:m.reasons] +} + +func (st *resolveState) applyDelta(delta *selectionDelta) { + for _, add := range delta.adds { + st.selected.commit(add.name, add.version, add.contract, add.reason) + } + + for _, dr := range delta.reasons { + if sv := st.selected.at(dr.name, dr.version); sv != nil { + addReason(sv, dr.reason) + } + } +} + +// findSatisfying returns the newest already-picked version of the plugin +// (committed or pending in delta) that satisfies the constraint, "" if none. +func (st *resolveState) findSatisfying(delta *selectionDelta, name pluginName, constraint *semver.Constraints) versionTag { + var best *semver.Version + + consider := func(v *semver.Version) { + if constraint != nil && !constraint.Check(v) { + return + } + + if best == nil || v.GreaterThan(best) { + best = v + } + } + + for _, sv := range st.selected[name] { + consider(sv.Version) + } + + for _, add := range delta.adds { + if add.name == name { + consider(add.version) + } + } + + if best == nil { + return "" + } + + return best.Original() +} + +// result assembles the final Resolution: plugins sorted by name, versions +// newest first, plus co-installation advisories over the selected set. +func (st *resolveState) result() *Resolution { + res := &Resolution{Skipped: st.skipped} + + names := make([]string, 0, len(st.selected)) + for name := range st.selected { + names = append(names, name) + } + + sort.Strings(names) + + for _, name := range names { + byVersion := st.selected[name] + + versions := make([]SelectedVersion, 0, len(byVersion)) + for _, sv := range byVersion { + versions = append(versions, *sv) + } + + sort.Slice(versions, func(i, j int) bool { return versions[i].Version.GreaterThan(versions[j].Version) }) + + res.Plugins = append(res.Plugins, PluginToMirror{Name: name, Versions: versions}) + } + + for _, plugin := range res.Plugins { + for _, sv := range plugin.Versions { + st.advisories(plugin.Name, sv) + } + } + + res.Warnings = st.warnings.messages + + return res +} + +// advisories emits non-blocking warnings about conditional requirements the +// bundle cannot satisfy. Conditional requirements never gate mirroring (the +// module or plugin may simply not be enabled on the target cluster), but the +// operator should know about the co-installation hazard. +func (st *resolveState) advisories(name string, sv SelectedVersion) { + for _, req := range sv.Contract.Requirements.Modules.Conditional { + versions, inBundle := st.bundle[req.Name] + if !inBundle || len(versions) == 0 || req.Constraint == "" { + continue + } + + if !anySatisfies(versions, req.Constraint) { + st.warnings.add(fmt.Sprintf("plugin %s@%s: module %q is in the bundle, but no bundled version satisfies its conditional constraint %q", + name, sv.Version.Original(), req.Name, req.Constraint)) + } + } + + for _, req := range sv.Contract.Requirements.Plugins.Conditional { + byVersion := st.selected[req.Name] + if len(byVersion) == 0 || req.Constraint == "" { + continue + } + + satisfied := false + + for _, other := range byVersion { + if anySatisfies([]*semver.Version{other.Version}, req.Constraint) { + satisfied = true + + break + } + } + + if !satisfied { + st.warnings.add(fmt.Sprintf("plugins %s@%s and %s cannot be installed together: no bundled version of %q satisfies the conditional constraint %q", + name, sv.Version.Original(), req.Name, req.Name, req.Constraint)) + } + } +} + +// triggeringModules returns the mirrored modules the contract names in its +// mandatory or anyOf requirements - the modules this plugin exists for. +// Conditional mentions do not trigger: install never auto-installs on a +// conditional, so mirror does not auto-pull on one either. +func triggeringModules(contract *internal.Plugin, bundle map[string][]*semver.Version) []string { + seen := make(map[string]struct{}) + out := make([]string, 0, 1) + + add := func(name string) { + if len(bundle[name]) == 0 { + return + } + + if _, dup := seen[name]; dup { + return + } + + seen[name] = struct{}{} + out = append(out, name) + } + + for _, req := range contract.Requirements.Modules.Mandatory { + add(req.Name) + } + + for _, group := range contract.Requirements.Modules.AnyOf { + for _, member := range group.Modules { + add(member.Name) + } + } + + sort.Strings(out) + + return out +} + +// moduleConstraint reports whether the contract declares the module (in +// mandatory or anyOf) and the constraint it declares for it. +func moduleConstraint(contract *internal.Plugin, module string) (bool, string) { + for _, req := range contract.Requirements.Modules.Mandatory { + if req.Name == module { + return true, req.Constraint + } + } + + for _, group := range contract.Requirements.Modules.AnyOf { + for _, member := range group.Modules { + if member.Name == module { + return true, member.Constraint + } + } + } + + return false, "" +} + +func addReason(sv *SelectedVersion, reason Reason) { + for _, existing := range sv.Reasons { + if existing == reason { + return + } + } + + sv.Reasons = append(sv.Reasons, reason) +} + +// noteReject keeps the first (newest candidate's) rejection reason - the most +// useful one for the user, since newer versions are preferred. +func noteReject(dst *string, reason string) { + if *dst == "" { + *dst = reason + } +} + +// anySatisfies reports whether at least one version satisfies the constraint. +// An empty constraint is satisfied by anything; an unparseable one by nothing. +// Versions are normalized the same way install-time checks normalize them, so +// CI/build markers (e.g. "-dev") do not fail plain constraints. +func anySatisfies(versions []*semver.Version, constraintStr string) bool { + if constraintStr == "" { + return true + } + + constraint, err := semver.NewConstraint(constraintStr) + if err != nil { + return false + } + + for _, version := range versions { + if constraint.Check(requirements.NormalizedForConstraint(version)) { + return true + } + } + + return false +} + +func formatVersions(versions []*semver.Version) string { + parts := make([]string, 0, len(versions)) + for _, version := range versions { + parts = append(parts, version.Original()) + } + + return strings.Join(parts, ", ") +} + +// isNotPublished tells a missing repository or tag apart from other registry +// errors: absence is content information ("not published"), everything else +// is operational. +func isNotPublished(err error) bool { + return errors.Is(err, dkpclient.ErrImageNotFound) || errmatch.IsRepoNotFound(err) || errmatch.IsImageNotFound(err) +} diff --git a/internal/mirror/plugins/resolver_test.go b/internal/mirror/plugins/resolver_test.go new file mode 100644 index 000000000..832491163 --- /dev/null +++ b/internal/mirror/plugins/resolver_test.go @@ -0,0 +1,788 @@ +/* +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" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse/pkg/log" + dkpclient "github.com/deckhouse/deckhouse/pkg/registry/client" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" +) + +// catalogStub is an in-memory Catalog for resolver tests. The invariant of a +// real catalog holds: every listed tag has a contract behind it. +type catalogStub struct { + names []string + namesErr error + tags map[string][]string + contracts map[string]*internal.Plugin // "name@tag" + invalid map[string]bool // "name@tag" -> broken published contract + transport map[string]bool // "name@tag" -> transport error on contract fetch + namesCalls int +} + +func newStub() *catalogStub { + return &catalogStub{ + tags: make(map[string][]string), + contracts: make(map[string]*internal.Plugin), + invalid: make(map[string]bool), + transport: make(map[string]bool), + } +} + +func (s *catalogStub) add(name, tag string, contract *internal.Plugin) *catalogStub { + s.registerName(name) + s.tags[name] = append(s.tags[name], tag) + s.contracts[name+"@"+tag] = contract + + return s +} + +func (s *catalogStub) markInvalid(name, tag string) *catalogStub { + s.registerName(name) + s.tags[name] = append(s.tags[name], tag) + s.invalid[name+"@"+tag] = true + + return s +} + +func (s *catalogStub) failTransport(name, tag string) *catalogStub { + s.registerName(name) + s.tags[name] = append(s.tags[name], tag) + s.transport[name+"@"+tag] = true + + return s +} + +func (s *catalogStub) registerName(name string) { + for _, existing := range s.names { + if existing == name { + return + } + } + + s.names = append(s.names, name) +} + +func (s *catalogStub) PluginNames(_ context.Context) ([]string, error) { + s.namesCalls++ + + if s.namesErr != nil { + return nil, s.namesErr + } + + return s.names, nil +} + +func (s *catalogStub) PluginVersions(_ context.Context, name string) ([]*semver.Version, error) { + tags, ok := s.tags[name] + if !ok { + return nil, fmt.Errorf("list versions of plugin %q: %w", name, dkpclient.ErrImageNotFound) + } + + return stableVersions(sortedSemverDesc(tags)), nil +} + +func (s *catalogStub) Contract(_ context.Context, name string, version *semver.Version) (*internal.Plugin, error) { + ref := name + "@" + version.Original() + + if s.transport[ref] { + return nil, fmt.Errorf("get contract of plugin %q %s: connection reset", name, version.Original()) + } + + if s.invalid[ref] { + return nil, invalidContract(name, version.Original(), fmt.Errorf("boom")) + } + + contract, ok := s.contracts[ref] + if !ok { + return nil, fmt.Errorf("get contract of plugin %q %s: %w", name, version.Original(), dkpclient.ErrImageNotFound) + } + + return contract, nil +} + +// ---- contract builders ---- + +func plug(name, version string) *internal.Plugin { + return &internal.Plugin{Name: name, Version: version} +} + +func needsModule(p *internal.Plugin, module, constraint string) *internal.Plugin { + p.Requirements.Modules.Mandatory = append(p.Requirements.Modules.Mandatory, + internal.ModuleRequirement{Name: module, Constraint: constraint}) + + return p +} + +func condModule(p *internal.Plugin, module, constraint string) *internal.Plugin { + p.Requirements.Modules.Conditional = append(p.Requirements.Modules.Conditional, + internal.ModuleRequirement{Name: module, Constraint: constraint}) + + return p +} + +func needsAnyOf(p *internal.Plugin, group string, members ...internal.ModuleRequirement) *internal.Plugin { + p.Requirements.Modules.AnyOf = append(p.Requirements.Modules.AnyOf, + internal.ModuleGroup{Name: group, Modules: members}) + + return p +} + +func needsPlugin(p *internal.Plugin, name, constraint string) *internal.Plugin { + p.Requirements.Plugins.Mandatory = append(p.Requirements.Plugins.Mandatory, + internal.PluginRequirement{Name: name, Constraint: constraint}) + + return p +} + +func needsDeckhouse(p *internal.Plugin, constraint string) *internal.Plugin { + p.Requirements.Deckhouse.Constraint = constraint + + return p +} + +// ---- input/output helpers ---- + +func mod(name string, versions ...string) ModuleInBundle { + return ModuleInBundle{Name: name, Versions: semvers(versions...)} +} + +func semvers(versions ...string) []*semver.Version { + out := make([]*semver.Version, 0, len(versions)) + for _, v := range versions { + out = append(out, semver.MustParse(v)) + } + + return out +} + +func mustFilter(t *testing.T, expressions ...string) *modules.Filter { + t.Helper() + + filter, err := modules.NewFilter(expressions, modules.FilterTypeWhitelist) + require.NoError(t, err) + + return filter +} + +func resolve(t *testing.T, stub *catalogStub, in ResolveInput) *Resolution { + t.Helper() + + res, err := NewResolver(stub, log.NewNop()).Resolve(context.Background(), in) + require.NoError(t, err) + + return res +} + +func selectedVersions(res *Resolution, name string) []string { + for _, p := range res.Plugins { + if p.Name != name { + continue + } + + out := make([]string, 0, len(p.Versions)) + for _, sv := range p.Versions { + out = append(out, sv.Version.Original()) + } + + return out + } + + return nil +} + +func selectedVersion(t *testing.T, res *Resolution, name, version string) SelectedVersion { + t.Helper() + + for _, p := range res.Plugins { + if p.Name != name { + continue + } + + for _, sv := range p.Versions { + if sv.Version.Original() == version { + return sv + } + } + } + + t.Fatalf("plugin %s@%s is not selected; resolution: %+v", name, version, res.Plugins) + + return SelectedVersion{} +} + +// ---- tests ---- + +// TestResolve_PerModuleVersionSelection is the requirement's core example: +// for EACH bundled module version the newest compatible plugin version is +// picked, and picks are deduplicated. Module postgresql {1.0.0, 1.5.0, +// 1.10.0}: plugin v1.1.0 covers 1.0.0, v1.2.0 covers both 1.5.0 and 1.10.0 - +// the bundle gets exactly {v1.1.0, v1.2.0}. +func TestResolve_PerModuleVersionSelection(t *testing.T) { + stub := newStub(). + add("postgresql-mgr", "v1.1.0", needsModule(plug("postgresql-mgr", "v1.1.0"), "postgresql", ">=1.0.0 <1.5.0")). + add("postgresql-mgr", "v1.2.0", needsModule(plug("postgresql-mgr", "v1.2.0"), "postgresql", ">=1.5.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.0.0", "v1.5.0", "v1.10.0")}, + }) + + assert.Equal(t, []string{"v1.2.0", "v1.1.0"}, selectedVersions(res, "postgresql-mgr"), + "each bundled module version gets its newest compatible plugin version, deduplicated") + assert.Empty(t, res.Skipped) + + sv := selectedVersion(t, res, "postgresql-mgr", "v1.2.0") + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonModule, Subject: "postgresql", Constraint: ">=1.5.0"}) +} + +// TestResolve_TransitiveDependency: a selected plugin drags its mandatory +// plugin dependency into the bundle, newest satisfying version. +func TestResolve_TransitiveDependency(t *testing.T) { + stub := newStub(). + add("postgresql-mgr", "v1.2.0", + needsPlugin(needsModule(plug("postgresql-mgr", "v1.2.0"), "postgresql", ">=1.0.0"), "db-connector", ">=0.9.0")). + add("db-connector", "v0.8.0", plug("db-connector", "v0.8.0")). + add("db-connector", "v0.9.1", plug("db-connector", "v0.9.1")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "postgresql-mgr")) + assert.Equal(t, []string{"v0.9.1"}, selectedVersions(res, "db-connector")) + + dep := selectedVersion(t, res, "db-connector", "v0.9.1") + assert.Contains(t, dep.Reasons, Reason{Kind: ReasonDependency, Subject: "postgresql-mgr@v1.2.0", Constraint: ">=0.9.0"}) +} + +// TestResolve_DependencyUnionReuse: two dependents with overlapping +// constraints share one version of the dependency instead of pulling two. +func TestResolve_DependencyUnionReuse(t *testing.T) { + stub := newStub(). + add("alpha", "v1.0.0", needsPlugin(needsModule(plug("alpha", "v1.0.0"), "m1", ""), "shared", ">=1.0.0")). + add("beta", "v1.0.0", needsPlugin(needsModule(plug("beta", "v1.0.0"), "m1", ""), "shared", ">=1.2.0")). + add("shared", "v1.3.0", plug("shared", "v1.3.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + }) + + assert.Equal(t, []string{"v1.3.0"}, selectedVersions(res, "shared"), + "one shared version must satisfy both dependents") + + sv := selectedVersion(t, res, "shared", "v1.3.0") + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonDependency, Subject: "alpha@v1.0.0", Constraint: ">=1.0.0"}) + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonDependency, Subject: "beta@v1.0.0", Constraint: ">=1.2.0"}) +} + +// TestResolve_DisjointDependencyConstraints: dependents whose constraints +// cannot be satisfied by one version get two versions of the dependency - +// each dependent stays installable on the air-gapped side. +func TestResolve_DisjointDependencyConstraints(t *testing.T) { + stub := newStub(). + add("alpha", "v1.0.0", needsPlugin(needsModule(plug("alpha", "v1.0.0"), "m1", ""), "shared", "<=1.0.0")). + add("beta", "v1.0.0", needsPlugin(needsModule(plug("beta", "v1.0.0"), "m1", ""), "shared", ">=2.0.0")). + add("shared", "v1.0.0", plug("shared", "v1.0.0")). + add("shared", "v2.1.0", plug("shared", "v2.1.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + }) + + assert.Equal(t, []string{"v2.1.0", "v1.0.0"}, selectedVersions(res, "shared"), + "disjoint constraints require two bundled versions of the dependency") +} + +// TestResolve_BuiltinDependency: a dependency on a built-in d8 command is +// satisfied by presence - nothing is pulled and the catalog is not asked. The +// stub has no "package" entry, so any lookup would fail the dependent. +func TestResolve_BuiltinDependency(t *testing.T) { + stub := newStub(). + add("packer", "v1.0.0", needsPlugin(needsModule(plug("packer", "v1.0.0"), "m1", ""), "package", "")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + Builtins: map[string]struct{}{"package": {}}, + }) + + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "packer")) + assert.Nil(t, selectedVersions(res, "package"), "built-ins are never pulled") + assert.Empty(t, res.Skipped) +} + +// TestResolve_DependencyCycleSkips: a cycle in mandatory dependencies rejects +// the candidate; with no other candidates the plugin is skipped with the +// cycle spelled out. +func TestResolve_DependencyCycleSkips(t *testing.T) { + stub := newStub(). + add("alpha", "v1.0.0", needsPlugin(needsModule(plug("alpha", "v1.0.0"), "m1", ""), "beta", ">=1.0.0")). + add("beta", "v1.0.0", needsPlugin(plug("beta", "v1.0.0"), "alpha", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + }) + + assert.Nil(t, selectedVersions(res, "alpha")) + require.Len(t, res.Skipped, 1) + assert.Equal(t, "alpha", res.Skipped[0].Name) + assert.Contains(t, res.Skipped[0].Reason, "cycle") +} + +// TestResolve_DependencyNotPublishedSkips: an auto-selected plugin whose +// dependency is missing from the catalog is skipped with the reason recorded, +// and the pull goes on. +func TestResolve_DependencyNotPublishedSkips(t *testing.T) { + stub := newStub(). + add("alpha", "v1.0.0", needsPlugin(needsModule(plug("alpha", "v1.0.0"), "m1", ""), "ghost", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + }) + + assert.Empty(t, res.Plugins) + require.Len(t, res.Skipped, 1) + assert.Contains(t, res.Skipped[0].Reason, `"ghost" is not published`) +} + +// TestResolve_UnrelatedPluginsNotSelected: a plugin with no module +// requirements and a plugin with only a conditional module mention are not +// auto-selected - nothing extra enters the bundle. +func TestResolve_UnrelatedPluginsNotSelected(t *testing.T) { + stub := newStub(). + add("standalone", "v1.0.0", plug("standalone", "v1.0.0")). + add("condonly", "v1.0.0", condModule(plug("condonly", "v1.0.0"), "m1", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + }) + + assert.Empty(t, res.Plugins, "standalone and conditional-only plugins must not be auto-selected") + assert.Empty(t, res.Skipped) +} + +// TestResolve_AnyOfTriggers: a mirrored module that is a member of an anyOf +// group triggers selection, with the member's constraint on the reason edge. +func TestResolve_AnyOfTriggers(t *testing.T) { + stub := newStub(). + add("cni-tool", "v1.0.0", needsAnyOf(plug("cni-tool", "v1.0.0"), "cni", + internal.ModuleRequirement{Name: "cni-flannel", Constraint: ">=1.0.0"}, + internal.ModuleRequirement{Name: "cni-cilium", Constraint: ">=1.0.0"}, + )) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("cni-cilium", "v1.2.0")}, + }) + + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "cni-tool")) + + sv := selectedVersion(t, res, "cni-tool", "v1.0.0") + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonModule, Subject: "cni-cilium", Constraint: ">=1.0.0"}) +} + +// TestResolve_NoCompatibleVersionSkips: every candidate requires a newer +// module than the bundle has - the plugin is skipped and the reason quotes +// the constraint, so the operator knows what to bump. +func TestResolve_NoCompatibleVersionSkips(t *testing.T) { + stub := newStub(). + add("backup-tool", "v2.0.0", needsModule(plug("backup-tool", "v2.0.0"), "postgresql", ">=3.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.10.0")}, + }) + + assert.Empty(t, res.Plugins) + require.Len(t, res.Skipped, 1) + assert.Equal(t, "backup-tool", res.Skipped[0].Name) + assert.Contains(t, res.Skipped[0].Reason, ">=3.0.0") + assert.Contains(t, res.Skipped[0].Reason, "for module postgresql v1.10.0") +} + +// TestResolve_BrokenNewestContractFallsBack: a broken published contract on +// the newest version must not hide the plugin - triage and selection fall +// back to the next readable version. +func TestResolve_BrokenNewestContractFallsBack(t *testing.T) { + stub := newStub(). + markInvalid("postgresql-mgr", "v1.3.0"). + add("postgresql-mgr", "v1.2.0", needsModule(plug("postgresql-mgr", "v1.2.0"), "postgresql", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "postgresql-mgr")) +} + +// TestResolve_DeckhouseConstraintGates: a candidate demanding a newer +// Deckhouse than the bundle carries is passed over for an older candidate. +func TestResolve_DeckhouseConstraintGates(t *testing.T) { + stub := newStub(). + add("postgresql-mgr", "v1.1.0", needsModule(plug("postgresql-mgr", "v1.1.0"), "postgresql", ">=1.0.0")). + add("postgresql-mgr", "v1.2.0", + needsDeckhouse(needsModule(plug("postgresql-mgr", "v1.2.0"), "postgresql", ">=1.0.0"), ">=1.80.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + PlatformVersions: semvers("v1.71.3"), + }) + + assert.Equal(t, []string{"v1.1.0"}, selectedVersions(res, "postgresql-mgr"), + "the deckhouse constraint must demote to an older compatible version") +} + +// TestResolve_ExplicitInclude: --include-plugin pulls a plugin unrelated to +// the bundle's modules; an unmet module requirement warns but does not block +// (the user's explicit choice wins, modules are never auto-added). +func TestResolve_ExplicitInclude(t *testing.T) { + stub := newStub(). + add("velero-helper", "v0.3.0", needsModule(plug("velero-helper", "v0.3.0"), "velero", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Filter: mustFilter(t, "velero-helper"), + }) + + assert.Equal(t, []string{"v0.3.0"}, selectedVersions(res, "velero-helper")) + + sv := selectedVersion(t, res, "velero-helper", "v0.3.0") + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonExplicit, Subject: "--include-plugin velero-helper"}) + + require.Len(t, res.Warnings, 1) + assert.Contains(t, res.Warnings[0], `requires module "velero"`) + assert.Contains(t, res.Warnings[0], "explicitly included") +} + +// TestResolve_ExplicitIncludeNotPublished: an explicit request that cannot be +// met is an error, never a silent skip. +func TestResolve_ExplicitIncludeNotPublished(t *testing.T) { + _, err := NewResolver(newStub(), log.NewNop()).Resolve(context.Background(), ResolveInput{ + Filter: mustFilter(t, "ghost"), + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "ghost") + assert.Contains(t, err.Error(), "not published") +} + +// TestResolve_ExplicitExactPinReachesPrerelease: an exact pin bypasses the +// stable-version list, so pre-releases stay reachable - the mirror analog of +// `d8 plugins install --version`. +func TestResolve_ExplicitExactPinReachesPrerelease(t *testing.T) { + stub := newStub(). + add("experimental", "v2.0.0-rc.1", plug("experimental", "v2.0.0-rc.1")) + + res := resolve(t, stub, ResolveInput{ + Filter: mustFilter(t, "experimental@=v2.0.0-rc.1"), + }) + + assert.Equal(t, []string{"v2.0.0-rc.1"}, selectedVersions(res, "experimental")) + + sv := selectedVersion(t, res, "experimental", "v2.0.0-rc.1") + assert.Contains(t, sv.Reasons, Reason{Kind: ReasonExplicit, Subject: "--include-plugin experimental", Constraint: "=v2.0.0-rc.1"}) +} + +// TestResolve_EmptyInput: no modules and no explicit includes - the resolver +// does nothing and does not even list the catalog. +func TestResolve_EmptyInput(t *testing.T) { + stub := newStub().add("anything", "v1.0.0", plug("anything", "v1.0.0")) + + res := resolve(t, stub, ResolveInput{}) + + assert.Empty(t, res.Plugins) + assert.Empty(t, res.Skipped) + assert.Zero(t, stub.namesCalls, "with nothing to resolve the catalog must not be listed") +} + +// TestResolve_NewestContractDecidesRelevance: relevance is judged by the +// newest readable contract only. A plugin whose CURRENT version dropped the +// module integration is not resurrected by its older contracts. +func TestResolve_NewestContractDecidesRelevance(t *testing.T) { + stub := newStub(). + add("postgresql-mgr", "v2.0.0", plug("postgresql-mgr", "v2.0.0")). // integration dropped + add("postgresql-mgr", "v1.2.0", needsModule(plug("postgresql-mgr", "v1.2.0"), "postgresql", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Empty(t, res.Plugins, "an abandoned integration must not be resurrected from old contracts") + assert.Empty(t, res.Skipped) +} + +// TestResolve_ExplicitRangedConstraint: a semver range on --include-plugin +// picks the newest version inside the range, not the newest overall. +func TestResolve_ExplicitRangedConstraint(t *testing.T) { + stub := newStub(). + add("foo", "v2.0.0", plug("foo", "v2.0.0")). + add("foo", "v1.5.0", plug("foo", "v1.5.0")). + add("foo", "v1.4.0", plug("foo", "v1.4.0")) + + res := resolve(t, stub, ResolveInput{ + Filter: mustFilter(t, "foo@^1.2"), + }) + + assert.Equal(t, []string{"v1.5.0"}, selectedVersions(res, "foo")) +} + +// TestResolve_ExplicitMultiHonorsEveryRange is the regression for the +// exact-pin shadowing bug: combining an exact pin with a semver range must +// satisfy BOTH - the exact pick must not swallow the ranged one. +func TestResolve_ExplicitMultiHonorsEveryRange(t *testing.T) { + stub := newStub(). + add("foo", "v3.0.0", plug("foo", "v3.0.0")). + add("foo", "v1.5.0", plug("foo", "v1.5.0")) + + res := resolve(t, stub, ResolveInput{ + Filter: mustFilter(t, "foo@=v3.0.0", "foo@^1.0"), + }) + + assert.Equal(t, []string{"v3.0.0", "v1.5.0"}, selectedVersions(res, "foo"), + "the exact pin and the semver range must each get their version") +} + +// TestResolve_SecondMandatoryModuleGates: a candidate needing another module +// the bundle lacks is passed over for an older self-sufficient version. +func TestResolve_SecondMandatoryModuleGates(t *testing.T) { + stub := newStub(). + add("postgresql-mgr", "v1.1.0", needsModule(plug("postgresql-mgr", "v1.1.0"), "postgresql", ">=1.0.0")). + add("postgresql-mgr", "v1.2.0", + needsModule(needsModule(plug("postgresql-mgr", "v1.2.0"), "postgresql", ">=1.0.0"), "redis", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Equal(t, []string{"v1.1.0"}, selectedVersions(res, "postgresql-mgr")) +} + +// TestResolve_AnyOfGroupGatesBesidePairing is the regression for the anyOf +// hole: pairing on a weak mandatory constraint must not silence a stricter +// anyOf group containing the same module. +func TestResolve_AnyOfGroupGatesBesidePairing(t *testing.T) { + contract := needsModule(plug("pg-tool", "v1.0.0"), "postgresql", ">=1.0.0") + contract = needsAnyOf(contract, "storage", + internal.ModuleRequirement{Name: "postgresql", Constraint: ">=2.0.0"}, + internal.ModuleRequirement{Name: "ceph", Constraint: ">=1.0.0"}, + ) + + stub := newStub().add("pg-tool", "v1.0.0", contract) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Empty(t, res.Plugins, "the unsatisfiable anyOf group must gate the candidate") + require.Len(t, res.Skipped, 1) + assert.Contains(t, res.Skipped[0].Reason, `"storage"`) +} + +// TestResolve_PartialFailureWarnsNotSkips is the regression for the +// both-selected-and-skipped bug: a plugin that covered some module versions +// is selected, and the uncovered ones become a warning, not a skip. +func TestResolve_PartialFailureWarnsNotSkips(t *testing.T) { + stub := newStub(). + add("postgresql-mgr", "v1.1.0", needsModule(plug("postgresql-mgr", "v1.1.0"), "postgresql", ">=2.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.0.0", "v2.0.0")}, + }) + + assert.Equal(t, []string{"v1.1.0"}, selectedVersions(res, "postgresql-mgr")) + assert.Empty(t, res.Skipped, "a partially covered plugin is not skipped") + require.Len(t, res.Warnings, 1) + assert.Contains(t, res.Warnings[0], "for module postgresql v1.0.0") +} + +// TestResolve_CIMarkerModuleVersionSatisfies is the regression for the +// normalization divergence: a module pinned to a CI-marked version (-dev) +// must satisfy a plain floor constraint, same as install-time checks do. +func TestResolve_CIMarkerModuleVersionSatisfies(t *testing.T) { + stub := newStub(). + add("postgresql-mgr", "v1.2.0", needsModule(plug("postgresql-mgr", "v1.2.0"), "postgresql", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.2.3-dev")}, + }) + + assert.Equal(t, []string{"v1.2.0"}, selectedVersions(res, "postgresql-mgr")) +} + +// TestResolve_TransportErrorFailsResolution: a registry error that is neither +// not-found nor a broken contract must fail the whole resolution - it is +// never laundered into "no compatible version". +func TestResolve_TransportErrorFailsResolution(t *testing.T) { + stub := newStub(). + failTransport("postgresql-mgr", "v1.2.0") + + _, err := NewResolver(stub, log.NewNop()).Resolve(context.Background(), ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "connection reset") +} + +// TestResolve_DepthCapRejects: a dependency chain deeper than the cap rejects +// the root candidate instead of recursing forever. +func TestResolve_DepthCapRejects(t *testing.T) { + stub := newStub(). + add("p0", "v1.0.0", needsPlugin(needsModule(plug("p0", "v1.0.0"), "m1", ""), "p1", "")) + + for i := 1; i <= 20; i++ { + contract := plug(fmt.Sprintf("p%d", i), "v1.0.0") + if i < 20 { + contract = needsPlugin(contract, fmt.Sprintf("p%d", i+1), "") + } + + stub.add(fmt.Sprintf("p%d", i), "v1.0.0", contract) + } + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + }) + + assert.Nil(t, selectedVersions(res, "p0")) + require.Len(t, res.Skipped, 1) + assert.Contains(t, res.Skipped[0].Reason, "deeper than") +} + +// TestResolve_KubernetesAndNoneOfIgnored: kubernetes and noneOf requirements +// are cluster-side; mirror must not gate on them. +func TestResolve_KubernetesAndNoneOfIgnored(t *testing.T) { + contract := needsModule(plug("pg-tool", "v1.0.0"), "postgresql", ">=1.0.0") + contract.Requirements.Kubernetes.Constraint = ">=1.99.0" + contract.Requirements.Modules.NoneOf = []internal.ModuleGroup{{ + Name: "legacy", + Modules: []internal.ModuleRequirement{{Name: "postgresql"}}, + }} + + stub := newStub().add("pg-tool", "v1.0.0", contract) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "pg-tool")) +} + +// TestResolve_ExplicitDependencyGateRelaxed: dependencies of an explicitly +// included plugin are pulled even when the bundle lacks their modules - with +// a warning, matching the plugin's own relaxed gate. A plugins-only bundle +// stays possible. +func TestResolve_ExplicitDependencyGateRelaxed(t *testing.T) { + stub := newStub(). + add("foo", "v1.0.0", needsPlugin(plug("foo", "v1.0.0"), "bar", ">=1.0.0")). + add("bar", "v1.0.0", needsModule(plug("bar", "v1.0.0"), "m1", ">=1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Filter: mustFilter(t, "foo"), + }) + + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "foo")) + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "bar")) + require.NotEmpty(t, res.Warnings) + assert.Contains(t, res.Warnings[0], `requires module "m1"`) +} + +// TestResolve_AllContractsBrokenSkips: a plugin whose every published +// contract is broken is surfaced in Skipped, not silently dropped. +func TestResolve_AllContractsBrokenSkips(t *testing.T) { + stub := newStub(). + markInvalid("broken", "v1.0.0"). + markInvalid("broken", "v1.1.0") + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("m1", "v1.0.0")}, + }) + + assert.Empty(t, res.Plugins) + require.Len(t, res.Skipped, 1) + assert.Equal(t, "broken", res.Skipped[0].Name) + assert.Contains(t, res.Skipped[0].Reason, "no readable contract") +} + +// TestResolve_RollbackDiscardsPartialDeps: when a candidate resolves part of +// its dependencies and then fails, the fallback to an older candidate must +// not leak the partial picks into the result. +func TestResolve_RollbackDiscardsPartialDeps(t *testing.T) { + v2 := needsModule(plug("pg-tool", "v2.0.0"), "postgresql", ">=1.0.0") + v2 = needsPlugin(v2, "dep-a", "") // resolvable + v2 = needsPlugin(v2, "ghost", "") // not published -> candidate fails + + stub := newStub(). + add("pg-tool", "v2.0.0", v2). + add("pg-tool", "v1.0.0", needsModule(plug("pg-tool", "v1.0.0"), "postgresql", ">=1.0.0")). + add("dep-a", "v1.0.0", plug("dep-a", "v1.0.0")) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + }) + + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "pg-tool"), + "the failed v2.0.0 candidate must fall back to v1.0.0") + assert.Nil(t, selectedVersions(res, "dep-a"), + "partially resolved deps of the failed candidate must not leak into the result") +} + +// TestResolve_MissingCatalogSkipsAuto: a registry without a plugins catalog +// yields an empty auto-selection, not an error - and explicit includes still +// resolve against their own repositories. +func TestResolve_MissingCatalogSkipsAuto(t *testing.T) { + stub := newStub(). + add("velero-helper", "v0.3.0", plug("velero-helper", "v0.3.0")) + stub.namesErr = fmt.Errorf("list plugins catalog: %w", dkpclient.ErrImageNotFound) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{mod("postgresql", "v1.5.0")}, + Filter: mustFilter(t, "velero-helper"), + }) + + assert.Equal(t, []string{"v0.3.0"}, selectedVersions(res, "velero-helper"), + "explicit includes must survive a missing catalog") + assert.Len(t, res.Plugins, 1, "nothing must be auto-selected without a catalog") +} + +// TestResolve_ConditionalModuleAdvisory: a conditional module constraint the +// bundle cannot satisfy warns about the co-installation hazard but does not +// gate the selection. +func TestResolve_ConditionalModuleAdvisory(t *testing.T) { + contract := needsModule(plug("pg-tool", "v1.0.0"), "postgresql", ">=1.0.0") + contract = condModule(contract, "observability", ">=5.0.0") + + stub := newStub().add("pg-tool", "v1.0.0", contract) + + res := resolve(t, stub, ResolveInput{ + Modules: []ModuleInBundle{ + mod("postgresql", "v1.5.0"), + mod("observability", "v1.0.0"), + }, + }) + + assert.Equal(t, []string{"v1.0.0"}, selectedVersions(res, "pg-tool"), + "a conditional constraint never gates selection") + require.Len(t, res.Warnings, 1) + assert.Contains(t, res.Warnings[0], "conditional") + assert.Contains(t, res.Warnings[0], "observability") +} diff --git a/internal/mirror/plugins/stats.go b/internal/mirror/plugins/stats.go new file mode 100644 index 000000000..08e952975 --- /dev/null +++ b/internal/mirror/plugins/stats.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 plugins + +import ( + "github.com/google/go-containerregistry/pkg/v1/layout" + + regimage "github.com/deckhouse/deckhouse-cli/pkg/registry/image" +) + +// PluginVersionStat is one pulled plugin version with its provenance, ready +// for the summary's per-module grouping. +type PluginVersionStat struct { + Version string + Reasons []Reason +} + +// PluginStat is one plugin's contribution to the pull. +type PluginStat struct { + Name string + Images int + // Versions are the pulled versions, newest first. Filled at resolution + // time, so available in dry-run too. + Versions []PluginVersionStat +} + +// PluginsStats is the plugins phase's accounting, mapped into the top-level +// summary by the pull orchestrator. +type PluginsStats struct { + Attempted bool + Plugins []PluginStat + Skipped []SkippedPlugin + Warnings []string + TotalImages int +} + +// pluginsPullStats is the internal accumulator behind Stats. The resolution +// is recorded up front (dry-run friendly); image counts are captured before +// packing deletes the layouts (see bundle.Pack). +type pluginsPullStats struct { + attempted bool + resolution *Resolution + imagesByPlugin map[pluginName]int +} + +func newPluginsPullStats() *pluginsPullStats { + return &pluginsPullStats{ + imagesByPlugin: make(map[pluginName]int), + } +} + +func (s *pluginsPullStats) recordResolution(resolution *Resolution) { + s.resolution = resolution +} + +// captureImages records per-plugin manifest counts from the OCI layouts. It +// must run before packing deletes the layout files. +func (s *pluginsPullStats) captureImages(layouts map[pluginName]*regimage.ImageLayout) { + for name, pluginLayout := range layouts { + s.imagesByPlugin[name] = regimage.CountManifests([]layout.Path{pluginLayout.Path()}) + } +} + +// Stats returns accounting for the plugins phase. +func (svc *Service) Stats() PluginsStats { + stats := PluginsStats{Attempted: svc.stats.attempted} + + resolution := svc.stats.resolution + if resolution == nil { + return stats + } + + for _, plugin := range resolution.Plugins { + versions := make([]PluginVersionStat, 0, len(plugin.Versions)) + for _, sv := range plugin.Versions { + versions = append(versions, PluginVersionStat{Version: sv.Version.Original(), Reasons: sv.Reasons}) + } + + images := svc.stats.imagesByPlugin[plugin.Name] + + stats.Plugins = append(stats.Plugins, PluginStat{Name: plugin.Name, Images: images, Versions: versions}) + stats.TotalImages += images + } + + stats.Skipped = resolution.Skipped + stats.Warnings = resolution.Warnings + + return stats +} diff --git a/internal/mirror/plugins/types.go b/internal/mirror/plugins/types.go new file mode 100644 index 000000000..96df9570d --- /dev/null +++ b/internal/mirror/plugins/types.go @@ -0,0 +1,145 @@ +/* +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/Masterminds/semver/v3" + + "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" +) + +// Key vocabulary of the package's maps. Aliases (not defined types) keep +// plain strings interoperable without conversions - the same convention as +// moduleName in the modules package. +type ( + // pluginName is a plugin's registry name, e.g. "postgresql-mgr". + pluginName = string + // moduleName is a Deckhouse module name, e.g. "postgresql". + moduleName = string + // versionTag is a version tag as published in the registry, e.g. + // "v1.2.3" (semver.Version.Original of a parsed tag). + versionTag = string +) + +// ModuleInBundle is one mirrored module with the exact versions selected into +// the bundle. The pull orchestrator builds this list from the modules phase +// stats and hands it to the plugins phase. +type ModuleInBundle struct { + Name string + Versions []*semver.Version +} + +// ResolveInput is everything the resolver knows about the bundle being built. +type ResolveInput struct { + // Modules are the modules mirrored into this bundle with their versions. + // Empty when modules were not mirrored: nothing is auto-selected then and + // only explicit --include-plugin entries are resolved. + Modules []ModuleInBundle + // PlatformVersions are the mirrored Deckhouse platform versions. Empty + // when the platform phase was skipped; the contract's deckhouse + // constraint is then not checked. + PlatformVersions []*semver.Version + // Filter carries --include-plugin expressions. Explicit picks are + // additive: they are pulled on top of the module-driven selection. + Filter *modules.Filter + // Builtins are d8 built-in command names (e.g. delivery-kit, package) + // that satisfy a same-named plugin dependency by presence. They are + // never pulled. + Builtins map[string]struct{} +} + +// ReasonKind classifies why a plugin version is in the bundle. +type ReasonKind int + +const ( + // ReasonModule marks a plugin required by a mirrored module. + // Reason.Subject is the module name. + ReasonModule ReasonKind = iota + // ReasonDependency marks a mandatory dependency of another selected + // plugin. Reason.Subject is "@". + ReasonDependency + // ReasonExplicit marks a plugin named by --include-plugin. + // Reason.Subject is the flag expression. + ReasonExplicit +) + +// String returns the stable lowercase label of the kind, used by the pull +// summary ("module", "dependency", "explicit"). +func (k ReasonKind) String() string { + switch k { + case ReasonModule: + return "module" + case ReasonDependency: + return "dependency" + case ReasonExplicit: + return "explicit" + default: + return "unknown" + } +} + +// Reason is one provenance edge of a selected plugin version: who needed it +// and under which constraint. The summary renders these edges as the +// per-module plugin tree. +type Reason struct { + Kind ReasonKind + Subject string + // Constraint is the requirement constraint that created the edge, + // empty when none was declared. + Constraint string +} + +// SelectedVersion is one plugin version to mirror, with its provenance. +type SelectedVersion struct { + Version *semver.Version + Contract *internal.Plugin + Reasons []Reason +} + +// PluginToMirror is the resolver's verdict for one plugin. +type PluginToMirror struct { + Name string + // Versions to pull, newest first. Several versions appear when different + // bundled module versions need different plugin versions. + Versions []SelectedVersion +} + +// SkippedPlugin is a plugin the resolver considered and dropped, with the +// reason spelled out for the summary (e.g. `requires module "postgresql" +// >=2.0.0, bundle has v1.4.1`). +type SkippedPlugin struct { + Name string + Reason string +} + +// Resolution is the resolver's full output. +type Resolution struct { + // Plugins to mirror, sorted by name for deterministic output. + Plugins []PluginToMirror + Skipped []SkippedPlugin + // Warnings are advisories that do not block the pull (e.g. an explicitly + // included plugin whose required module is not in the bundle). + Warnings []string +} + +// Resolver picks which plugin versions belong to the bundle. +type Resolver interface { + Resolve(ctx context.Context, in ResolveInput) (*Resolution, error) +} diff --git a/internal/mirror/pull.go b/internal/mirror/pull.go index db99ce8e5..c2331e504 100644 --- a/internal/mirror/pull.go +++ b/internal/mirror/pull.go @@ -21,12 +21,15 @@ import ( "fmt" "time" + "github.com/Masterminds/semver/v3" + dkplog "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse-cli/internal/mirror/installer" "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" "github.com/deckhouse/deckhouse-cli/internal/mirror/packages" "github.com/deckhouse/deckhouse-cli/internal/mirror/platform" + "github.com/deckhouse/deckhouse-cli/internal/mirror/plugins" "github.com/deckhouse/deckhouse-cli/internal/mirror/security" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" @@ -60,6 +63,12 @@ type PullServiceOptions struct { // PackageFilter is the filter for package selection (whitelist/blacklist). // Packages reuse the modules filter because selection logic is identical. PackageFilter *modules.Filter + // PluginFilter carries --include-plugin entries (whitelist, additive to + // the module-driven plugin auto-selection). May be nil. + PluginFilter *modules.Filter + // PluginBuiltins are d8 built-in command names that satisfy a same-named + // plugin dependency by presence (never pulled). + PluginBuiltins []string // BundleDir is the directory to store the bundle BundleDir string // BundleChunkSize is the max size of bundle chunks in bytes (0 = no chunking) @@ -86,6 +95,7 @@ type PullService struct { modulesService *modules.Service packagesService *packages.Service installerService *installer.Service + pluginsService *plugins.Service options *PullServiceOptions @@ -174,6 +184,20 @@ func NewPullService( logger, userLogger, ), + pluginsService: plugins.NewService( + registryService, + tmpDir, + &plugins.Options{ + Filter: options.PluginFilter, + Builtins: builtinsSet(options.PluginBuiltins), + BundleDir: options.BundleDir, + BundleChunkSize: options.BundleChunkSize, + DryRun: options.DryRun, + ProxyRegistry: options.ProxyRegistry, + }, + logger, + userLogger, + ), installerService: installer.NewService( registryService, tmpDir, @@ -270,9 +294,76 @@ func (svc *PullService) Pull(ctx context.Context) (*PullSummary, error) { return summary, fmt.Errorf("pull package release images: %w", err) } + // Plugins resolve against what the earlier phases put into the bundle + // (module and platform versions), so this phase runs last. + if svc.options.OnlyExtraImages { + summary.Plugins.Skipped = true + } else { + if err := svc.pluginsService.PullPlugins(ctx, svc.pluginsInput()); err != nil { + return summary, fmt.Errorf("pull plugins: %w", err) + } + + summary.Plugins = toPluginsStats(svc.pluginsService.Stats()) + } + return summary, nil } +// pluginsInput assembles the plugins phase input from what the earlier phases +// actually selected: module versions from the modules stats, platform +// versions from the platform stats. Both are recorded at resolution time, so +// the handoff works in dry-run too. +func (svc *PullService) pluginsInput() plugins.PullInput { + return buildPluginsInput(svc.modulesService.Stats(), svc.platformService.Stats().Versions) +} + +func buildPluginsInput(modulesStats modules.ModulesStats, platformVersions []string) plugins.PullInput { + in := plugins.PullInput{} + + for _, module := range modulesStats.Modules { + versions := parseSemvers(module.Versions) + if len(versions) == 0 { + continue + } + + in.Modules = append(in.Modules, plugins.ModuleInBundle{Name: module.Name, Versions: versions}) + } + + in.PlatformVersions = parseSemvers(platformVersions) + + return in +} + +// parseSemvers parses version tags, dropping unparseable ones (e.g. channel +// aliases) - plugin contracts constrain semver versions only. +func parseSemvers(raw []string) []*semver.Version { + versions := make([]*semver.Version, 0, len(raw)) + + for _, tag := range raw { + version, err := semver.NewVersion(tag) + if err != nil { + continue + } + + versions = append(versions, version) + } + + return versions +} + +func builtinsSet(names []string) map[string]struct{} { + if len(names) == 0 { + return nil + } + + set := make(map[string]struct{}, len(names)) + for _, name := range names { + set[name] = struct{}{} + } + + return set +} + // The mapper functions below copy each service's package-local stat struct into // the corresponding summary type. The structs are duplicated to keep the // service packages decoupled from package mirror, which imports them (so the @@ -302,6 +393,35 @@ func toModulesStats(s modules.ModulesStats) ModulesStats { } } +func toPluginsStats(s plugins.PluginsStats) PluginsStats { + stats := PluginsStats{ + Attempted: s.Attempted, + Warnings: s.Warnings, + TotalImages: s.TotalImages, + } + + for _, p := range s.Plugins { + versions := make([]PluginVersionStat, 0, len(p.Versions)) + + for _, v := range p.Versions { + reasons := make([]PluginReason, 0, len(v.Reasons)) + for _, r := range v.Reasons { + reasons = append(reasons, PluginReason{Kind: r.Kind.String(), Subject: r.Subject, Constraint: r.Constraint}) + } + + versions = append(versions, PluginVersionStat{Version: v.Version, Reasons: reasons}) + } + + stats.Plugins = append(stats.Plugins, PluginStat{Name: p.Name, Images: p.Images, Versions: versions}) + } + + for _, skip := range s.Skipped { + stats.SkippedPlugins = append(stats.SkippedPlugins, SkippedPluginStat{Name: skip.Name, Reason: skip.Reason}) + } + + return stats +} + func toPackagesStats(s packages.PackagesStats) PackagesStats { pkgs := make([]PackageStat, 0, len(s.Packages)) for _, p := range s.Packages { diff --git a/internal/mirror/pull_plugins_e2e_test.go b/internal/mirror/pull_plugins_e2e_test.go new file mode 100644 index 000000000..172726f87 --- /dev/null +++ b/internal/mirror/pull_plugins_e2e_test.go @@ -0,0 +1,637 @@ +// Copyright 2026 Flant JSC +// SPDX-License-Identifier: Apache-2.0 + +package mirror + +// End-to-end tests for the plugins leg of the pull pipeline. Unlike the unit +// suites in internal/mirror/plugins, these run the whole PullService.Pull: +// the modules phase discovers module versions from release channels, records +// them in its stats, and the plugins phase resolves the catalog against those +// stats. The tests pin the cross-phase handoff and the produced artifacts +// (bundle tars, stats, summary provenance), not the resolver internals - +// those are covered by internal/mirror/plugins/resolver_test.go. + +import ( + "archive/tar" + "context" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" + + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" +) + +// pluginsCatalogRepo is where PluginsService looks for the catalog, relative +// to the registry root (plugins are not edition-scoped). +const pluginsCatalogRepo = "deckhouse-cli/plugins" + +// e2eBuiltins mirrors pluginBuiltinCommands from the cmd layer: built-in d8 +// commands that satisfy a same-named plugin dependency without being pulled. +var e2eBuiltins = []string{"delivery-kit", "package"} + +// --------------------------------------------------------------------------- +// Contracts +// --------------------------------------------------------------------------- + +// The two postgresql-mgr versions split the module version range: v1.1.0 +// serves postgresql <1.10.0, v1.2.0 serves >=1.10.0. +const mgrContractForOldPostgres = `{ + "name": "postgresql-mgr", "version": "v1.1.0", + "requirements": {"modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.0.0 <1.10.0"}]}} +}` + +const mgrContractForNewPostgres = `{ + "name": "postgresql-mgr", "version": "v1.2.0", + "requirements": {"modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.10.0"}]}} +}` + +// pg-mgr depends on another catalog plugin and on a built-in d8 command. +const pgMgrContractWithDeps = `{ + "name": "pg-mgr", "version": "v1.2.0", + "requirements": { + "modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.0.0"}]}, + "plugins": {"mandatory": [{"name": "pg-backup", "constraint": ">=1.0.0"}, {"name": "delivery-kit"}]} + } +}` + +const pgBackupContract = `{"name": "pg-backup", "version": "v1.0.5"}` + +// cond-tool only conditionally mentions postgresql: a conditional mention +// must never trigger auto-selection. +const condToolContract = `{ + "name": "cond-tool", "version": "v1.0.0", + "requirements": {"modules": {"conditional": [{"name": "postgresql", "constraint": ">=1.0.0"}]}} +}` + +// old-mgr is triggered by postgresql but no version pairs with v1.5.0. +const oldMgrContract = `{ + "name": "old-mgr", "version": "v0.9.0", + "requirements": {"modules": {"mandatory": [{"name": "postgresql", "constraint": "<1.0.0"}]}} +}` + +const standaloneToolStableContract = `{"name": "standalone-tool", "version": "v1.0.0"}` + +const standaloneToolRCContract = `{"name": "standalone-tool", "version": "v2.0.0-rc.1"}` + +// dh-tool is triggered by postgresql but also constrains the platform version. +const dhToolContract = `{ + "name": "dh-tool", "version": "v1.0.0", + "requirements": { + "modules": {"mandatory": [{"name": "postgresql", "constraint": ">=1.0.0"}]}, + "deckhouse": {"constraint": ">=1.70.0"} + } +}` + +// --------------------------------------------------------------------------- +// Tests: module versions reach the plugin resolver through Pull +// --------------------------------------------------------------------------- + +// TestPullE2E_ModuleVersionsReachPluginResolver pins the modules->plugins +// handoff: the module versions discovered from release channels by the +// modules phase must reach the plugin resolver, which pairs each bundled +// module version with a compatible plugin version. Both selected versions +// must land in one plugin tar with module provenance in the summary. +func TestPullE2E_ModuleVersionsReachPluginResolver(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0", "alpha": "v1.10.0"}) + addPluginVersion(reg, "postgresql-mgr", "v1.1.0", mgrContractForOldPostgres) + addPluginVersion(reg, "postgresql-mgr", "v1.2.0", mgrContractForNewPostgres) + + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + require.NotNil(t, summary) + + // The modules phase recorded both channel versions. + require.Len(t, summary.Modules.Modules, 1) + assert.Equal(t, "postgresql", summary.Modules.Modules[0].Name) + assert.ElementsMatch(t, []string{"v1.5.0", "v1.10.0"}, summary.Modules.Modules[0].Versions) + + // The plugins phase paired each bundled module version with a plugin version. + assert.True(t, summary.Plugins.Attempted) + assert.False(t, summary.Plugins.Skipped) + require.Len(t, summary.Plugins.Plugins, 1) + + plugin := summary.Plugins.Plugins[0] + assert.Equal(t, "postgresql-mgr", plugin.Name) + assert.Equal(t, 2, plugin.Images) + assert.Equal(t, 2, summary.Plugins.TotalImages) + + require.Len(t, plugin.Versions, 2, "one plugin version per bundled module version") + assert.Equal(t, "v1.2.0", plugin.Versions[0].Version, "versions are newest first") + assert.Equal(t, []PluginReason{{Kind: "module", Subject: "postgresql", Constraint: ">=1.10.0"}}, + plugin.Versions[0].Reasons) + assert.Equal(t, "v1.1.0", plugin.Versions[1].Version) + assert.Equal(t, []PluginReason{{Kind: "module", Subject: "postgresql", Constraint: ">=1.0.0 <1.10.0"}}, + plugin.Versions[1].Reasons) + + // Artifacts: the module tar and one plugin tar holding exactly the + // selected versions. + assert.FileExists(t, filepath.Join(bundleDir, "module-postgresql.tar")) + + pluginTar := filepath.Join(bundleDir, "plugin-postgresql-mgr.tar") + require.FileExists(t, pluginTar) + assert.ElementsMatch(t, []string{"v1.1.0", "v1.2.0"}, pluginTarShortTags(t, pluginTar), + "the bundle must hold exactly the versions the resolver picked") +} + +// TestPullE2E_DependencyChainAndBuiltin verifies that PluginBuiltins wiring +// reaches the resolver through PullServiceOptions: a mandatory plugin +// dependency is pulled with dependency provenance, while a same-named +// built-in command dependency is satisfied by presence and produces no tar. +func TestPullE2E_DependencyChainAndBuiltin(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0"}) + addPluginVersion(reg, "pg-mgr", "v1.2.0", pgMgrContractWithDeps) + addPluginVersion(reg, "pg-backup", "v1.0.5", pgBackupContract) + + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + + require.Len(t, summary.Plugins.Plugins, 2, "the triggered plugin and its dependency") + + backup, mgr := summary.Plugins.Plugins[0], summary.Plugins.Plugins[1] + assert.Equal(t, "pg-backup", backup.Name, "plugins are sorted by name") + assert.Equal(t, "pg-mgr", mgr.Name) + + require.Len(t, mgr.Versions, 1) + assert.Equal(t, "v1.2.0", mgr.Versions[0].Version) + assert.Equal(t, []PluginReason{{Kind: "module", Subject: "postgresql", Constraint: ">=1.0.0"}}, + mgr.Versions[0].Reasons) + + require.Len(t, backup.Versions, 1) + assert.Equal(t, "v1.0.5", backup.Versions[0].Version) + assert.Equal(t, []PluginReason{{Kind: "dependency", Subject: "pg-mgr@v1.2.0", Constraint: ">=1.0.0"}}, + backup.Versions[0].Reasons) + + assert.Equal(t, 2, summary.Plugins.TotalImages) + + assert.FileExists(t, filepath.Join(bundleDir, "plugin-pg-mgr.tar")) + assert.FileExists(t, filepath.Join(bundleDir, "plugin-pg-backup.tar")) + assert.NoFileExists(t, filepath.Join(bundleDir, "plugin-delivery-kit.tar"), + "a built-in command dependency must not be pulled") +} + +// TestPullE2E_NothingExtraAndSkipReporting pins the "nothing extra" +// principle at the pipeline boundary: a conditional-only mention does not +// select a plugin, and a triggered plugin with no compatible version is +// reported in the summary as skipped, with no tar written. +func TestPullE2E_NothingExtraAndSkipReporting(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0"}) + addPluginVersion(reg, "cond-tool", "v1.0.0", condToolContract) + addPluginVersion(reg, "old-mgr", "v0.9.0", oldMgrContract) + + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + + assert.True(t, summary.Plugins.Attempted) + assert.Empty(t, summary.Plugins.Plugins, "neither plugin qualifies for the bundle") + + // old-mgr was triggered by postgresql but has no compatible version: the + // summary must spell out why it is missing from an air-gapped bundle. + require.Len(t, summary.Plugins.SkippedPlugins, 1) + assert.Equal(t, "old-mgr", summary.Plugins.SkippedPlugins[0].Name) + assert.NotEmpty(t, summary.Plugins.SkippedPlugins[0].Reason) + + // cond-tool is simply irrelevant: not selected, not reported as skipped. + for _, skip := range summary.Plugins.SkippedPlugins { + assert.NotEqual(t, "cond-tool", skip.Name, "a conditional mention must not put the plugin in play") + } + + pluginTars, err := filepath.Glob(filepath.Join(bundleDir, "plugin-*.tar")) + require.NoError(t, err) + assert.Empty(t, pluginTars, "no plugin tars when nothing is selected") +} + +// --------------------------------------------------------------------------- +// Tests: phase interactions and options wiring +// --------------------------------------------------------------------------- + +// TestPullE2E_ExplicitIncludePlugin verifies the PluginFilter wiring: an +// exact-pin --include-plugin entry selects the pinned pre-release (which +// auto-selection can never reach) without any module trigger. +func TestPullE2E_ExplicitIncludePlugin(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addPluginVersion(reg, "standalone-tool", "v1.0.0", standaloneToolStableContract) + addPluginVersion(reg, "standalone-tool", "v2.0.0-rc.1", standaloneToolRCContract) + + filter, err := modules.NewFilter([]string{"standalone-tool@=v2.0.0-rc.1"}, modules.FilterTypeWhitelist) + require.NoError(t, err) + + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipModules: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginFilter: filter, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + + require.Len(t, summary.Plugins.Plugins, 1) + plugin := summary.Plugins.Plugins[0] + assert.Equal(t, "standalone-tool", plugin.Name) + + require.Len(t, plugin.Versions, 1, "only the pinned version, not the newest stable") + assert.Equal(t, "v2.0.0-rc.1", plugin.Versions[0].Version) + assert.Equal(t, []PluginReason{{Kind: "explicit", Subject: "--include-plugin standalone-tool", Constraint: "=v2.0.0-rc.1"}}, + plugin.Versions[0].Reasons) + + pluginTar := filepath.Join(bundleDir, "plugin-standalone-tool.tar") + require.FileExists(t, pluginTar) + assert.ElementsMatch(t, []string{"v2.0.0-rc.1"}, pluginTarShortTags(t, pluginTar)) +} + +// TestPullE2E_SkipModules_NoPluginAutoSelection: with the modules phase +// skipped there are no modules in the bundle, so a relevant catalog plugin +// must not be selected - nothing extra. +func TestPullE2E_SkipModules_NoPluginAutoSelection(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0"}) + addPluginVersion(reg, "postgresql-mgr", "v1.1.0", mgrContractForOldPostgres) + + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipModules: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + + assert.True(t, summary.Plugins.Attempted) + assert.Empty(t, summary.Plugins.Plugins) + assert.Empty(t, summary.Plugins.SkippedPlugins) + + pluginTars, err := filepath.Glob(filepath.Join(bundleDir, "plugin-*.tar")) + require.NoError(t, err) + assert.Empty(t, pluginTars) +} + +// TestPullE2E_DeckhouseConstraint verifies the platform->plugins handoff: a +// contract's deckhouse constraint is enforced against the platform versions +// in the bundle, and not enforced when the platform phase is skipped. +func TestPullE2E_DeckhouseConstraint(t *testing.T) { + buildRegistry := func() *upfake.Registry { + reg := upfake.NewRegistry(pullStubRootURL) + addPlatform(reg, "v1.69.0") + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0"}) + addPluginVersion(reg, "dh-tool", "v1.0.0", dhToolContract) + + return reg + } + + t.Run("enforced against bundled platform version", func(t *testing.T) { + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(buildRegistry())), "v1.69.0", &PullServiceOptions{ + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + + assert.Empty(t, summary.Plugins.Plugins, "dh-tool requires deckhouse >=1.70.0, bundle has v1.69.0") + require.Len(t, summary.Plugins.SkippedPlugins, 1) + assert.Equal(t, "dh-tool", summary.Plugins.SkippedPlugins[0].Name) + assert.NotEmpty(t, summary.Plugins.SkippedPlugins[0].Reason) + }) + + t.Run("not enforced without platform in bundle", func(t *testing.T) { + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(buildRegistry())), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + + require.Len(t, summary.Plugins.Plugins, 1, + "without platform versions the deckhouse constraint is not enforced at mirror time") + assert.Equal(t, "dh-tool", summary.Plugins.Plugins[0].Name) + }) +} + +// TestPullE2E_DryRun_ResolutionParityNoFiles: dry-run resolves plugins with +// the same versions and provenance as a real pull, but writes nothing. +func TestPullE2E_DryRun_ResolutionParityNoFiles(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0", "alpha": "v1.10.0"}) + addPluginVersion(reg, "postgresql-mgr", "v1.1.0", mgrContractForOldPostgres) + addPluginVersion(reg, "postgresql-mgr", "v1.2.0", mgrContractForNewPostgres) + + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + DryRun: true, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + assert.True(t, summary.DryRun) + + // Same resolution as the real pull in TestPullE2E_ModuleVersionsReachPluginResolver. + require.Len(t, summary.Plugins.Plugins, 1) + plugin := summary.Plugins.Plugins[0] + assert.Equal(t, "postgresql-mgr", plugin.Name) + require.Len(t, plugin.Versions, 2) + assert.Equal(t, "v1.2.0", plugin.Versions[0].Version) + assert.Equal(t, "v1.1.0", plugin.Versions[1].Version) + assert.NotEmpty(t, plugin.Versions[0].Reasons) + + assert.Zero(t, plugin.Images, "dry-run pulls no images") + assert.Zero(t, summary.Plugins.TotalImages) + + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write bundle files") +} + +// TestPullE2E_OnlyExtraImages_PluginsSkipped: --only-extra-images skips the +// plugins phase entirely and the summary reports it as skipped. +func TestPullE2E_OnlyExtraImages_PluginsSkipped(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0"}) + addPluginVersion(reg, "postgresql-mgr", "v1.1.0", mgrContractForOldPostgres) + + bundleDir := t.TempDir() + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + OnlyExtraImages: true, + PluginBuiltins: e2eBuiltins, + }) + + summary, err := svc.Pull(context.Background()) + require.NoError(t, err) + + assert.True(t, summary.Plugins.Skipped) + assert.False(t, summary.Plugins.Attempted) + + pluginTars, err := filepath.Glob(filepath.Join(bundleDir, "plugin-*.tar")) + require.NoError(t, err) + assert.Empty(t, pluginTars) +} + +// --------------------------------------------------------------------------- +// Tests: pull -> push roundtrip +// --------------------------------------------------------------------------- + +// TestPullE2E_RoundTrip_PullThenPushPlugins carries plugins through the whole +// mirror path: pull from the source registry, pack into bundle tars, push the +// tars into a target registry. The plugin repository must land verbatim at +// deckhouse-cli/plugins with its discovery tag, and --modules-path-suffix +// must move modules only. +func TestPullE2E_RoundTrip_PullThenPushPlugins(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + addModule(reg, "postgresql", map[string]string{"stable": "v1.5.0", "alpha": "v1.10.0"}) + addPluginVersion(reg, "postgresql-mgr", "v1.1.0", mgrContractForOldPostgres) + addPluginVersion(reg, "postgresql-mgr", "v1.2.0", mgrContractForNewPostgres) + + bundleDir := t.TempDir() + pullSvc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "", &PullServiceOptions{ + SkipPlatform: true, + SkipSecurity: true, + SkipInstaller: true, + SkipVexImages: true, + BundleDir: bundleDir, + PluginBuiltins: e2eBuiltins, + }) + + ctx := context.Background() + + _, err := pullSvc.Pull(ctx) + require.NoError(t, err) + + tars, err := filepath.Glob(filepath.Join(bundleDir, "*.tar")) + require.NoError(t, err) + require.NotEmpty(t, tars) + + destReg := upfake.NewRegistry("registry.example.com/deckhouse/ee") + destClient := pkgclient.Adapt(upfake.NewClient(destReg)) + + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + pushSvc := NewPushService(destClient, &PushServiceOptions{ + Packages: tars, + WorkingDir: t.TempDir(), + // A moved modules path must not touch plugins. + ModulesPathSuffix: "/my/mods", + }, logger, userLogger) + + pushSummary, err := pushSvc.Push(ctx) + require.NoError(t, err) + + assert.Equal(t, 1, pushSummary.Plugins, "one plugin repository pushed") + assert.Equal(t, 1, pushSummary.Modules) + + // Both resolved plugin versions land verbatim at deckhouse-cli/plugins. + pluginClient := destClient.WithSegment("deckhouse-cli", "plugins", "postgresql-mgr") + assert.NoError(t, pluginClient.CheckImageExists(ctx, "v1.1.0")) + assert.NoError(t, pluginClient.CheckImageExists(ctx, "v1.2.0")) + + // The discovery tag makes the plugin visible to catalog listing. + catalogTags, err := destClient.WithSegment("deckhouse-cli", "plugins").ListTags(ctx) + require.NoError(t, err) + assert.Contains(t, catalogTags, "postgresql-mgr") + + // The module moved with the suffix; the plugin stayed put. + movedModule := destClient.WithSegment("my", "mods", "postgresql") + assert.NoError(t, movedModule.CheckImageExists(ctx, "v1.5.0")) + assert.NoError(t, movedModule.CheckImageExists(ctx, "v1.10.0")) + + defaultModule := destClient.WithSegment("modules", "postgresql") + assert.Error(t, defaultModule.CheckImageExists(ctx, "v1.5.0"), + "a moved modules path must hold nothing at the default location") +} + +// --------------------------------------------------------------------------- +// Registry fixture builders +// --------------------------------------------------------------------------- + +// addPlatform publishes the minimal platform refs a --deckhouse-tag pinned +// pull needs: the root, install and install-standalone images for one +// version. Channel discovery is short-circuited by the pinned tag, so no +// release-channel refs are required. +func addPlatform(reg *upfake.Registry, version string) { + img := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"`+version+`"}`). + WithFile("deckhouse/candi/images_digests.json", `{}`). + MustBuild() + + for _, repo := range []string{"", "install", "install-standalone"} { + reg.MustAddImage(repo, version, img) + } +} + +// versionImage builds a minimal module image the modules phase can read: +// version.json plus the OCI version label. +func versionImage(version string) v1.Image { + return upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"`+version+`"}`). + WithLabel("org.opencontainers.image.version", version). + MustBuild() +} + +// addModule populates the registry with one module's worth of refs, the same +// shape the modules phase discovers against a real registry: +// +// modules: - modules-list entry +// modules/: - one image per distinct channel version +// modules//release: - the given release channels +// modules//release: - version-tagged release images +// +// channels maps a release-channel tag to the version it points at; the +// module's bundled versions are exactly the distinct channel versions. +// Channels absent from the map are tolerated by the modules phase. +func addModule(reg *upfake.Registry, name string, channels map[string]string) { + versions := make(map[string]struct{}, len(channels)) + + for channel, version := range channels { + reg.MustAddImage("modules/"+name+"/release", channel, versionImage(version)) + versions[version] = struct{}{} + } + + for version := range versions { + reg.MustAddImage("modules/"+name, version, versionImage(version)) + reg.MustAddImage("modules/"+name+"/release", version, versionImage(version)) + } + + reg.MustAddImage("modules", name, upfake.NewImageBuilder().WithFile("name", name).MustBuild()) +} + +// addPluginVersion publishes one plugin version with the given contract JSON +// (base64-encoded into the "contract" annotation) plus the catalog's +// directory-as-tags name entry. Takes no *testing.T so registry builders +// without one (fullStub) can use it; the type assertion cannot fail for a +// v1.Image input. +func addPluginVersion(reg *upfake.Registry, name, tag, contractJSON string) { + img := upfake.NewImageBuilder().WithFile("plugin", "binary-"+name+"-"+tag).MustBuild() + + encoded := base64.StdEncoding.EncodeToString([]byte(contractJSON)) + annotated := mutate.Annotations(img, map[string]string{"contract": encoded}).(v1.Image) + + reg.MustAddImage(pluginsCatalogRepo+"/"+name, tag, annotated) + reg.MustAddImage(pluginsCatalogRepo, name, upfake.NewImageBuilder().WithFile("name", name).MustBuild()) +} + +// --------------------------------------------------------------------------- +// Assertion helpers +// --------------------------------------------------------------------------- + +// pluginTarShortTags reads a plugin bundle tar and returns the +// io.deckhouse.image.short_tag annotations of its OCI index - the version +// tags that actually made it into the bundle. +func pluginTarShortTags(t *testing.T, tarPath string) []string { + t.Helper() + + f, err := os.Open(tarPath) + require.NoError(t, err) + defer f.Close() + + var indexJSON []byte + + tr := tar.NewReader(f) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + + require.NoError(t, err) + + if strings.HasSuffix(header.Name, "/index.json") { + indexJSON, err = io.ReadAll(tr) + require.NoError(t, err) + } + } + + require.NotEmpty(t, indexJSON, "bundle tar must contain an OCI index.json") + + var index struct { + Manifests []struct { + Annotations map[string]string `json:"annotations"` + } `json:"manifests"` + } + require.NoError(t, json.Unmarshal(indexJSON, &index)) + + tags := make([]string, 0, len(index.Manifests)) + for _, m := range index.Manifests { + tags = append(tags, m.Annotations["io.deckhouse.image.short_tag"]) + } + + return tags +} diff --git a/internal/mirror/pull_plugins_wiring_test.go b/internal/mirror/pull_plugins_wiring_test.go new file mode 100644 index 000000000..216f72736 --- /dev/null +++ b/internal/mirror/pull_plugins_wiring_test.go @@ -0,0 +1,111 @@ +/* +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 mirror + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" + "github.com/deckhouse/deckhouse-cli/internal/mirror/plugins" +) + +// TestBuildPluginsInput pins the modules->plugins handoff: module versions +// from the modules stats become typed semver versions, unparseable tags and +// version-less modules are dropped, platform versions ride along. +func TestBuildPluginsInput(t *testing.T) { + stats := modules.ModulesStats{ + Attempted: true, + Modules: []modules.ModuleStat{ + {Name: "postgresql", Versions: []string{"v1.0.0", "v1.5.0", "not-a-version"}}, + {Name: "broken-only", Versions: []string{"alpha-junk"}}, + {Name: "no-versions"}, + }, + } + + in := buildPluginsInput(stats, []string{"v1.71.3", "stable"}) + + require.Len(t, in.Modules, 1, "modules without a single parseable version must be dropped") + assert.Equal(t, "postgresql", in.Modules[0].Name) + + versions := make([]string, 0, len(in.Modules[0].Versions)) + for _, v := range in.Modules[0].Versions { + versions = append(versions, v.Original()) + } + + assert.Equal(t, []string{"v1.0.0", "v1.5.0"}, versions) + + require.Len(t, in.PlatformVersions, 1, "channel aliases must be dropped from platform versions") + assert.Equal(t, "v1.71.3", in.PlatformVersions[0].Original()) +} + +// TestToPluginsStats pins the phase-stats -> summary mapping, including the +// reason-kind labels the renderer keys on. +func TestToPluginsStats(t *testing.T) { + stats := toPluginsStats(plugins.PluginsStats{ + Attempted: true, + Plugins: []plugins.PluginStat{{ + Name: "postgresql-mgr", + Images: 2, + Versions: []plugins.PluginVersionStat{{ + Version: "v1.2.0", + Reasons: []plugins.Reason{ + {Kind: plugins.ReasonModule, Subject: "postgresql", Constraint: ">=1.5.0"}, + {Kind: plugins.ReasonExplicit, Subject: "--include-plugin postgresql-mgr"}, + }, + }}, + }, { + Name: "db-connector", + Images: 1, + Versions: []plugins.PluginVersionStat{{ + Version: "v0.9.1", + Reasons: []plugins.Reason{{Kind: plugins.ReasonDependency, Subject: "postgresql-mgr@v1.2.0", Constraint: ">=0.9.0"}}, + }}, + }}, + Skipped: []plugins.SkippedPlugin{{Name: "backup-tool", Reason: "requires postgresql >=3.0.0"}}, + Warnings: []string{"some advisory"}, + TotalImages: 3, + }) + + assert.True(t, stats.Attempted) + assert.Equal(t, 3, stats.TotalImages) + assert.Equal(t, []string{"some advisory"}, stats.Warnings) + + require.Len(t, stats.Plugins, 2) + require.Len(t, stats.Plugins[0].Versions, 1) + assert.Equal(t, []PluginReason{ + {Kind: "module", Subject: "postgresql", Constraint: ">=1.5.0"}, + {Kind: "explicit", Subject: "--include-plugin postgresql-mgr"}, + }, stats.Plugins[0].Versions[0].Reasons) + assert.Equal(t, "dependency", stats.Plugins[1].Versions[0].Reasons[0].Kind) + + require.Len(t, stats.SkippedPlugins, 1) + assert.Equal(t, SkippedPluginStat{Name: "backup-tool", Reason: "requires postgresql >=3.0.0"}, stats.SkippedPlugins[0]) +} + +// TestParseSemvers: unparseable tags are dropped, originals preserved. +func TestParseSemvers(t *testing.T) { + versions := parseSemvers([]string{"v1.2.3", "junk", "v2.0.0-main"}) + + require.Len(t, versions, 2) + assert.Equal(t, "v1.2.3", versions[0].Original()) + assert.Equal(t, "v2.0.0-main", versions[1].Original()) + assert.IsType(t, &semver.Version{}, versions[0]) +} diff --git a/internal/mirror/pull_test.go b/internal/mirror/pull_test.go index ac076e4db..15f27394f 100644 --- a/internal/mirror/pull_test.go +++ b/internal/mirror/pull_test.go @@ -54,11 +54,12 @@ func newPullService( ) } -// fullStub returns a stub that has data in all four service areas: +// fullStub returns a stub that has data in all five service areas: // - platform (root, release-channel, install, install-standalone) // - installer ("installer" repo at root, tag "latest") // - security ("security/trivy-db" repo, tag "2") -// - modules ("modules" repo with two module names as tags) +// - modules (pullable cert-manager, name-tag-only ingress-nginx) +// - plugins (cert-manager-tool, auto-selected for cert-manager) func fullStub() localreg.Client { reg := upfake.NewRegistry(pullStubRootURL) @@ -108,14 +109,25 @@ func fullStub() localreg.Client { trivyImg := upfake.NewImageBuilder().MustBuild() reg.MustAddImage("security/trivy-db", "2", trivyImg) - // ---- modules: two module names as tags ---- - modImg := upfake.NewImageBuilder().MustBuild() - reg.MustAddImage("modules", "cert-manager", modImg) - reg.MustAddImage("modules", "ingress-nginx", modImg) + // ---- modules: cert-manager is fully pullable (one version via its + // stable channel); ingress-nginx is a bare name tag with no versions, + // pinning that zero-version modules are dropped from the plugins handoff ---- + addModule(reg, "cert-manager", map[string]string{"stable": "v0.5.0"}) + reg.MustAddImage("modules", "ingress-nginx", upfake.NewImageBuilder().MustBuild()) + + // ---- plugins: cert-manager-tool rides along with the cert-manager module ---- + addPluginVersion(reg, "cert-manager-tool", "v1.0.0", certManagerToolContract) return pkgclient.Adapt(upfake.NewClient(reg)) } +// certManagerToolContract auto-selects the fullStub plugin whenever the +// cert-manager module is mirrored. +const certManagerToolContract = `{ + "name": "cert-manager-tool", "version": "v1.0.0", + "requirements": {"modules": {"mandatory": [{"name": "cert-manager", "constraint": ">=0.1.0"}]}} +}` + // --------------------------------------------------------------------------- // Error path tests // --------------------------------------------------------------------------- @@ -413,7 +425,11 @@ func TestPull_SecurityGracefulSkip(t *testing.T) { // TestPull_ModulesGracefulSkip verifies that when no modules exist in // the registry Pull still succeeds (PullModules logs a warning and returns nil). func TestPull_ModulesGracefulSkip(t *testing.T) { - svc := newPullService(t, localfake.NewRegistryClientStub(), "v1.69.0", &PullServiceOptions{ + // An empty registry: the canned stub cannot be used here because it + // carries a modules catalog. + emptyStub := pkgclient.Adapt(upfake.NewClient(upfake.NewRegistry(pullStubRootURL))) + + svc := newPullService(t, emptyStub, "v1.69.0", &PullServiceOptions{ SkipPlatform: true, SkipSecurity: true, SkipInstaller: true, @@ -494,13 +510,27 @@ func TestPull_FullStub_SummaryPopulated(t *testing.T) { assert.Equal(t, 4, summary.Security.AvailableDatabases) assert.Greater(t, summary.Security.Databases, 0) - // Modules: the stub exposes two module names but no pullable version or - // release-channel images for them, so the phase runs (Attempted) yet pulls - // nothing - zero-image modules are correctly omitted from the breakdown. - // (Real module counting is exercised end-to-end against a live registry; the - // stub only carries module names, not their contents.) + // Modules: cert-manager is pullable (one version via its stable channel); + // ingress-nginx exposes no versions and is omitted from the breakdown. assert.True(t, summary.Modules.Attempted) assert.False(t, summary.Modules.Skipped) + require.Len(t, summary.Modules.Modules, 1) + assert.Equal(t, "cert-manager", summary.Modules.Modules[0].Name) + assert.Equal(t, []string{"v0.5.0"}, summary.Modules.Modules[0].Versions) + assert.Greater(t, summary.Modules.TotalImages, 0, + "module image count must survive packing") + + // Plugins: cert-manager-tool is auto-selected because the cert-manager + // module is in the bundle. + assert.True(t, summary.Plugins.Attempted) + assert.False(t, summary.Plugins.Skipped) + require.Len(t, summary.Plugins.Plugins, 1) + assert.Equal(t, "cert-manager-tool", summary.Plugins.Plugins[0].Name) + require.Len(t, summary.Plugins.Plugins[0].Versions, 1) + assert.Equal(t, "v1.0.0", summary.Plugins.Plugins[0].Versions[0].Version) + assert.Equal(t, []PluginReason{{Kind: "module", Subject: "cert-manager", Constraint: ">=0.1.0"}}, + summary.Plugins.Plugins[0].Versions[0].Reasons) + assert.Equal(t, 1, summary.Plugins.TotalImages) } // TestPull_FullStub_FullDiscovery verifies full-discovery mode (empty diff --git a/internal/mirror/push.go b/internal/mirror/push.go index cd6c4ae9a..29638a997 100644 --- a/internal/mirror/push.go +++ b/internal/mirror/push.go @@ -83,11 +83,16 @@ type PushServiceOptions struct { // │ ├── index.json // │ ├── release/ // │ └── / -// └── packages/ # Packages -// └── / -// ├── index.json -// ├── version/ -// └── / +// ├── packages/ # Packages +// │ └── / +// │ ├── index.json +// │ ├── version/ +// │ └── / +// └── deckhouse-cli/ # d8 CLI plugins +// └── plugins/ +// └── / +// ├── index.json +// └── blobs/ type PushService struct { client client.Client options *PushServiceOptions @@ -166,6 +171,13 @@ func (svc *PushService) Push(ctx context.Context) (*PushSummary, error) { return summary, err } + // Create plugins index (deckhouse-cli/plugins: tags for discovery) + if err := svc.userLogger.Process("Create plugins index", func() error { + return svc.createPluginsIndex(ctx, dirPath, summary) + }); err != nil { + return summary, err + } + return summary, nil } @@ -373,8 +385,8 @@ func (svc *PushService) pushSingleLayout(ctx context.Context, rootDir, layoutDir } // recordPushedComponent tallies a pushed layout into the summary by its bundle -// segment. Modules and packages are counted from their index step, so their -// layouts are ignored here. +// segment. Modules, packages, and plugins are counted from their index steps, +// so their layouts are ignored here. func recordPushedComponent(summary *PushSummary, segment string) { first, _, _ := strings.Cut(segment, "/") @@ -385,6 +397,8 @@ func recordPushedComponent(summary *PushSummary, segment string) { summary.InstallerPushed = true case internal.SecuritySegment: summary.SecurityDatabases++ + case internal.D8CLISegment: + // Plugins are counted by createPluginsIndex. } } @@ -531,3 +545,62 @@ func (svc *PushService) createPackagesIndex(ctx context.Context, rootDir string, return nil } + +// createPluginsIndex creates the CLI plugins index in the registry: a small +// random image per plugin with tag = plugin name on the deckhouse-cli/plugins +// path. The same directory-as-tags convention modules and packages use; the +// registry-bundle server synthesizes an identical index for bundle-served +// registries, so both air-gapped delivery shapes look the same. +func (svc *PushService) createPluginsIndex(ctx context.Context, rootDir string, summary *PushSummary) error { + pluginsDir := filepath.Join(rootDir, internal.D8CLISegment, internal.D8PluginsSegment) + + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if os.IsNotExist(err) { + svc.userLogger.InfoLn("No plugins directory found, skipping plugins index") + return nil + } + + return fmt.Errorf("read plugins directory %q: %w", pluginsDir, err) + } + + var pluginNames []string + + for _, entry := range entries { + if entry.IsDir() { + pluginNames = append(pluginNames, entry.Name()) + } + } + + if len(pluginNames) == 0 { + svc.userLogger.InfoLn("No plugins found, skipping plugins index") + return nil + } + + slices.Sort(pluginNames) + summary.Plugins = len(pluginNames) + svc.userLogger.Infof("Creating plugins index with %d plugins", len(pluginNames)) + + pluginsClient := svc.client.WithSegment(internal.D8CLISegment, internal.D8PluginsSegment) + + for _, pluginName := range pluginNames { + if err := ctx.Err(); err != nil { + return err + } + + svc.userLogger.Infof("Creating index tag: %s:%s", pluginsClient.GetRegistry(), pluginName) + + img, err := random.Image(32, 1) + if err != nil { + return fmt.Errorf("create random image for plugin discovery tag %s: %w", pluginName, err) + } + + if err := pluginsClient.PushImage(ctx, pluginName, img); err != nil { + return fmt.Errorf("push plugin index tag %s to registry %s: %w", pluginName, pluginsClient.GetRegistry(), err) + } + } + + svc.userLogger.Infof("Plugins index created successfully") + + return nil +} diff --git a/internal/mirror/push_test.go b/internal/mirror/push_test.go index 443897316..6f8ee4b8e 100644 --- a/internal/mirror/push_test.go +++ b/internal/mirror/push_test.go @@ -99,6 +99,52 @@ func buildLayoutBundle(t *testing.T, dir, tarName, prefix, shortTag string) stri return tarPath } +// TestPushService_PluginsLayout verifies the plugin leg of push: a bundle tar +// prefixed deckhouse-cli/plugins/ lands verbatim at that registry path, +// the discovery tag appears on the plugins index, the summary counts it, and +// --modules-path-suffix never moves plugin paths. +func TestPushService_PluginsLayout(t *testing.T) { + const ( + repoHost = "registry.example.com/deckhouse/ee" + pluginName = "postgresql-mgr" + pluginTag = "v1.2.0" + ) + + bundleDir := t.TempDir() + pluginPkg := buildLayoutBundle(t, bundleDir, "plugin-"+pluginName+".tar", + path.Join("deckhouse-cli", "plugins", pluginName), pluginTag) + + reg := upfake.NewRegistry(repoHost) + destClient := pkgclient.Adapt(upfake.NewClient(reg)) + + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := NewPushService(destClient, &PushServiceOptions{ + Packages: []string{pluginPkg}, + WorkingDir: t.TempDir(), + // A moved modules path must not touch plugins. + ModulesPathSuffix: "/my/mods", + }, logger, userLogger) + + summary, err := svc.Push(context.Background()) + require.NoError(t, err, "push must succeed") + + assert.Equal(t, 1, summary.Plugins, "one plugin repository pushed") + assert.False(t, summary.PlatformPushed, "a plugin layout must not be classified as platform") + + ctx := context.Background() + + pluginClient := destClient.WithSegment("deckhouse-cli", "plugins", pluginName) + assert.NoErrorf(t, pluginClient.CheckImageExists(ctx, pluginTag), + "plugin image must exist at %s:%s", pluginClient.GetRegistry(), pluginTag) + + indexClient := destClient.WithSegment("deckhouse-cli", "plugins") + tags, err := indexClient.ListTags(ctx) + require.NoError(t, err) + assert.Contains(t, tags, pluginName, "discovery tag must exist on the plugins index path") +} + // TestPushService_ModulesPathSuffix verifies that --modules-path-suffix moves // both module images and their discovery index tag, while non-module layouts // stay put. The default (empty / "/modules") keeps the historical layout. diff --git a/internal/mirror/pusher/pusher.go b/internal/mirror/pusher/pusher.go index dd1a1afb1..5109c9229 100644 --- a/internal/mirror/pusher/pusher.go +++ b/internal/mirror/pusher/pusher.go @@ -71,7 +71,8 @@ func (s *Service) PackageExists(bundleDir, pkgName string) bool { return false } -// PushLayout pushes all images from an OCI layout to the registry +// PushLayout pushes all images from an OCI layout to the registry. +// A descriptor holding a nested index (multi-platform image) is pushed whole. func (s *Service) PushLayout(ctx context.Context, layoutPath layout.Path, client client.Client) error { index, err := layoutPath.ImageIndex() if err != nil { @@ -96,12 +97,6 @@ func (s *Service) PushLayout(ctx context.Context, layoutPath layout.Path, client for i, manifest := range manifests { tag := manifest.Annotations[regimage.AnnotationImageShortTag] - - img, err := index.Image(manifest.Digest) - if err != nil { - return fmt.Errorf("read image %s from layout %s: %w", tag, layoutPath, err) - } - imageReferenceString := fmt.Sprintf("%s:%s", client.GetRegistry(), tag) err = retry.RunTask( @@ -109,20 +104,38 @@ func (s *Service) PushLayout(ctx context.Context, layoutPath layout.Path, client s.userLogger, fmt.Sprintf("[%d / %d] Pushing %s", i+1, len(manifests), imageReferenceString), task.WithConstantRetries(pushRetryAttempts, pushRetryDelay, func(ctx context.Context) error { - if err := client.PushImage(ctx, tag, img); err != nil { - return fmt.Errorf("write %s:%s to registry: %w", client.GetRegistry(), tag, err) - } - - return nil + return pushManifest(ctx, index, manifest, client, tag) })) if err != nil { - return fmt.Errorf("push image %s: %w", tag, err) + return fmt.Errorf("push %s: %w", imageReferenceString, err) } } return nil } +// pushManifest reads one descriptor's content from the layout and pushes it +// under tag. A nested index (multi-platform image, e.g. a CLI plugin) is +// pushed whole, so its platform children and annotations survive; index.Image +// would refuse an index media type. +func pushManifest(ctx context.Context, index v1.ImageIndex, desc v1.Descriptor, dest client.Client, tag string) error { + if desc.MediaType.IsIndex() { + idx, err := index.ImageIndex(desc.Digest) + if err != nil { + return fmt.Errorf("read image index from layout: %w", err) + } + + return dest.PushIndex(ctx, tag, idx) + } + + img, err := index.Image(desc.Digest) + if err != nil { + return fmt.Errorf("read image from layout: %w", err) + } + + return dest.PushImage(ctx, tag, img) +} + // dedupManifestsByShortTag filters and deduplicates manifests for pushing. // // Descriptors without the io.deckhouse.image.short_tag annotation are skipped. diff --git a/internal/mirror/pusher/pusher_test.go b/internal/mirror/pusher/pusher_test.go index f80990c11..ec584c3dc 100644 --- a/internal/mirror/pusher/pusher_test.go +++ b/internal/mirror/pusher/pusher_test.go @@ -19,15 +19,24 @@ package pusher import ( "context" "log/slog" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "github.com/google/go-containerregistry/pkg/name" + ggcrregistry "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" dkplog "github.com/deckhouse/deckhouse/pkg/log" + regclient "github.com/deckhouse/deckhouse/pkg/registry/client" upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" @@ -275,3 +284,77 @@ func TestPushLayout_MultipleImages(t *testing.T) { assert.NoErrorf(t, err, "tag %q must exist in destination after PushLayout", tag) } } + +// TestPushLayout_PushesNestedIndexWhole is the multi-platform round-trip: a +// layout descriptor holding an image index must arrive in the registry as the +// same index - same digest (byte-exact), both platform children, and the +// contract annotation in place. The upstream fake registry stubs PushIndex, so +// this test runs against ggcr's in-memory registry over HTTP with the real +// client. +func TestPushLayout_PushesNestedIndexWhole(t *testing.T) { + srv := httptest.NewServer(ggcrregistry.New()) + t.Cleanup(srv.Close) + host := strings.TrimPrefix(srv.URL, "http://") + + const ( + tag = "v1.0.0" + contract = "ZmFrZS1jb250cmFjdA==" + ) + + linuxImg := upfake.NewImageBuilder().WithFile("plugin", "linux-bin").MustBuild() + darwinImg := upfake.NewImageBuilder().WithFile("plugin", "darwin-bin").MustBuild() + + idx := mutate.AppendManifests(empty.Index, + mutate.IndexAddendum{ + Add: linuxImg, + Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}}, + }, + mutate.IndexAddendum{ + Add: darwinImg, + Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "darwin", Architecture: "arm64"}}, + }, + ) + annotated, ok := mutate.Annotations(idx, map[string]string{"contract": contract}).(v1.ImageIndex) + require.True(t, ok, "mutate.Annotations on an index must return an index") + + idxDigest, err := annotated.Digest() + require.NoError(t, err) + + imgLayout, err := regimage.NewImageLayout(t.TempDir()) + require.NoError(t, err) + repo := host + "/deckhouse-cli/plugins/foo" + require.NoError(t, imgLayout.AddIndex(annotated, tag, repo+":"+tag)) + + destClient := pkgclient.NewFromOptions(repo, regclient.WithInsecure(true)) + + svc := newTestService(t) + require.NoError(t, svc.PushLayout(context.Background(), imgLayout.Path(), destClient)) + + ref, err := name.ParseReference(repo+":"+tag, name.Insecure) + require.NoError(t, err) + desc, err := remote.Get(ref) + require.NoError(t, err, "the pushed tag must be reachable in the registry") + + require.True(t, desc.MediaType.IsIndex(), "the tag must resolve to an index, not a flattened image") + assert.Equal(t, idxDigest, desc.Digest, "the index must survive push byte-exact") + + pushedIdx, err := desc.ImageIndex() + require.NoError(t, err) + pushedManifest, err := pushedIdx.IndexManifest() + require.NoError(t, err) + + require.Len(t, pushedManifest.Manifests, 2, "both platform children must be pushed") + platforms := []string{ + pushedManifest.Manifests[0].Platform.String(), + pushedManifest.Manifests[1].Platform.String(), + } + assert.ElementsMatch(t, []string{"linux/amd64", "darwin/arm64"}, platforms) + assert.Equal(t, contract, pushedManifest.Annotations["contract"], + "the contract annotation must survive the push") + + // Children must be complete (all blobs uploaded), not bare descriptors. + child, err := pushedIdx.Image(pushedManifest.Manifests[0].Digest) + require.NoError(t, err) + _, err = child.RawConfigFile() + assert.NoError(t, err, "child image blobs must be present in the registry") +} diff --git a/internal/mirror/summary.go b/internal/mirror/summary.go index 3136355a6..0f51d529a 100644 --- a/internal/mirror/summary.go +++ b/internal/mirror/summary.go @@ -109,6 +109,52 @@ type PackagesStats struct { TotalVEX int } +// PluginReason is one provenance edge of a pulled plugin version: why it is +// in the bundle. Kind is "module" (Subject = module name), "dependency" +// (Subject = "@"), or "explicit" (Subject = the flag). +type PluginReason struct { + Kind string + Subject string + Constraint string +} + +// PluginVersionStat is one pulled plugin version with its provenance. +type PluginVersionStat struct { + Version string + Reasons []PluginReason +} + +// PluginStat is one plugin's contribution to the pull. +type PluginStat struct { + Name string + Images int + // Versions are the pulled versions, newest first. Available in dry-run too. + Versions []PluginVersionStat +} + +// SkippedPluginStat is a plugin the resolver considered and dropped, with the +// reason spelled out. +type SkippedPluginStat struct { + Name string + Reason string +} + +// PluginsStats aggregates the plugins phase accounting. +type PluginsStats struct { + // Skipped is true when the phase did not run (--only-extra-images). + Skipped bool + // Attempted is true when the plugins phase ran. + Attempted bool + // Plugins holds the per-plugin breakdown, sorted by name. + Plugins []PluginStat + // SkippedPlugins are plugins the resolver dropped, with reasons. + SkippedPlugins []SkippedPluginStat + // Warnings are resolver advisories worth showing in the summary. + Warnings []string + // TotalImages is the sum of images across all plugins. + TotalImages int +} + // BundleFile is one logical bundle artifact (platform.tar, installer.tar, // security.tar, module-.tar), possibly spread over .NNNN.chunk files. type BundleFile struct { @@ -153,6 +199,7 @@ type PullSummary struct { Security SecurityStats Modules ModulesStats Packages PackagesStats + Plugins PluginsStats // Bundle is populated by the CLI from the bundle directory (real pull only). Bundle BundleStats @@ -184,6 +231,9 @@ type PushSummary struct { SecurityDatabases int // Modules is the number of module repositories pushed. Modules int + // Plugins is the number of CLI plugin repositories pushed, counted from + // the plugins index step. + Plugins int // Packages is the number of package repositories pushed. Packages int } diff --git a/internal/plugins/README.md b/internal/plugins/README.md index 4a791065e..05f89a954 100644 --- a/internal/plugins/README.md +++ b/internal/plugins/README.md @@ -155,6 +155,15 @@ A failure at any step leaves the previous version installed and working. | 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` | +## Air-gapped delivery + +`d8 mirror pull` mirrors plugins into the images bundle automatically (plugins +whose contracts name the mirrored modules, plus their mandatory plugin +dependencies; `--include-plugin` adds more). After `d8 mirror push` the target +registry holds them at `deckhouse-cli/plugins/`, where the proxy serves +them - install/update work as usual. See `internal/mirror/README.MD` +(Plugin Mirroring). + ## Boundaries and deliberate decisions - Listing the full plugin catalog over RPP is not supported (the proxy has no diff --git a/internal/plugins/requirements/checks.go b/internal/plugins/requirements/checks.go index ad6e58d03..5a363a4e2 100644 --- a/internal/plugins/requirements/checks.go +++ b/internal/plugins/requirements/checks.go @@ -90,13 +90,13 @@ func HasClusterRequirements(plugin *internal.Plugin) bool { len(requirements.Modules.NoneOf) > 0 } -// normalizedForConstraint prepares a version for constraint matching. +// 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 { +func NormalizedForConstraint(v *semver.Version) *semver.Version { pre := v.Prerelease() if pre != "" && IsGenuinePrerelease(pre) { return semver.New(v.Major(), v.Minor(), v.Patch(), pre, "") @@ -138,7 +138,7 @@ func (c *Checker) validateKubernetesRequirement(plugin *internal.Plugin, state * return fmt.Errorf("parse kubernetes constraint %q: %w", plugin.Requirements.Kubernetes.Constraint, err) } - if !constraint.Check(normalizedForConstraint(state.Kubernetes)) { + 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()) } @@ -167,7 +167,7 @@ func (c *Checker) validateDeckhouseRequirement(plugin *internal.Plugin, state *C return fmt.Errorf("parse deckhouse constraint %q: %w", plugin.Requirements.Deckhouse.Constraint, err) } - if !constraint.Check(normalizedForConstraint(state.Deckhouse)) { + 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()) } @@ -244,7 +244,7 @@ func evaluateModuleVersion(requirement internal.ModuleRequirement, module Module return false, false, nil } - return constraint.Check(normalizedForConstraint(module.Version)), true, nil + return constraint.Check(NormalizedForConstraint(module.Version)), true, nil } // checkModuleConstraint verifies a mandatory/conditional module's version. A module diff --git a/internal/plugins/requirements/checks_test.go b/internal/plugins/requirements/checks_test.go index 284b21dd9..001f6153a 100644 --- a/internal/plugins/requirements/checks_test.go +++ b/internal/plugins/requirements/checks_test.go @@ -43,11 +43,11 @@ func enabled(version string) ModuleState { 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()) + 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()) + 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) { diff --git a/pkg/fake/deckhouse_stub.go b/pkg/fake/deckhouse_stub.go index ba0144203..d1cd5946b 100644 --- a/pkg/fake/deckhouse_stub.go +++ b/pkg/fake/deckhouse_stub.go @@ -17,9 +17,11 @@ limitations under the License. package fake import ( + "encoding/base64" "fmt" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" localreg "github.com/deckhouse/deckhouse/pkg/registry" upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" @@ -50,6 +52,16 @@ const changelogYAML = `candi: // imagesDigestsJSON is the sample images-tags file embedded in stub version images. const imagesDigestsJSON = `{}` +// stubModuleVersion is the version the stub module's stable channel points at. +const stubModuleVersion = "v0.5.0" + +// stubPluginContract makes the stub plugin auto-selected whenever the +// cert-manager module is mirrored. +const stubPluginContract = `{ + "name": "cert-manager-tool", "version": "v1.0.0", + "requirements": {"modules": {"mandatory": [{"name": "cert-manager", "constraint": ">=0.1.0"}]}} +}` + // NewRegistryClientStub creates a [localreg.Client] pre-populated with // Deckhouse-shaped registry data that mirrors the structure expected by the // platform test suite. @@ -65,6 +77,13 @@ const imagesDigestsJSON = `{}` // version (e.g. alpha → v1.72.10). // // - "install" and "install-standalone" repositories: same tags as root. +// +// - "modules" catalog with the cert-manager module: one version +// (stubModuleVersion) reachable via its stable release channel. +// +// - "deckhouse-cli/plugins" catalog with the cert-manager-tool plugin +// (v1.0.0), whose contract requires the cert-manager module - so a pull +// that mirrors the module auto-selects the plugin. func NewRegistryClientStub() localreg.Client { reg := upfake.NewRegistry(defaultSource) @@ -118,6 +137,23 @@ func NewRegistryClientStub() localreg.Client { reg.MustAddImage(si.segment, si.tag, securityImage()) } + // ---- modules ---- + // cert-manager carries one pullable version via its stable channel, so + // command-level tests exercise the modules phase and the module-driven + // plugin selection. + reg.MustAddImage("modules", "cert-manager", moduleImage()) + reg.MustAddImage("modules/cert-manager", stubModuleVersion, moduleImage()) + reg.MustAddImage("modules/cert-manager/release", "stable", moduleImage()) + reg.MustAddImage("modules/cert-manager/release", stubModuleVersion, moduleImage()) + + // ---- plugins catalog ---- + // The catalog name index is directory-as-tags: a tag per plugin name on + // the catalog repo, next to the per-plugin version repos. + reg.MustAddImage("deckhouse-cli/plugins", "cert-manager-tool", + upfake.NewImageBuilder().WithFile("name", "cert-manager-tool").MustBuild()) + reg.MustAddImage("deckhouse-cli/plugins/cert-manager-tool", "v1.0.0", + pluginImage("cert-manager-tool", "v1.0.0", stubPluginContract)) + return pkgclient.Adapt(upfake.NewClient(reg)) } @@ -146,3 +182,23 @@ func releaseChannelImage(version string) v1.Image { func securityImage() v1.Image { return upfake.NewImageBuilder().MustBuild() } + +// moduleImage creates a stub v1.Image for the cert-manager module repos: +// version.json (read during module version discovery) plus the OCI version +// label, both carrying stubModuleVersion. +func moduleImage() v1.Image { + return upfake.NewImageBuilder(). + WithFile("version.json", fmt.Sprintf(`{"version":%q}`, stubModuleVersion)). + WithLabel("org.opencontainers.image.version", stubModuleVersion). + MustBuild() +} + +// pluginImage creates a stub v1.Image for a plugin version: the contract JSON +// is base64-encoded into the "contract" annotation the plugins catalog reads. +func pluginImage(name, tag, contractJSON string) v1.Image { + img := upfake.NewImageBuilder().WithFile("plugin", "binary-"+name+"-"+tag).MustBuild() + + encoded := base64.StdEncoding.EncodeToString([]byte(contractJSON)) + + return mutate.Annotations(img, map[string]string{"contract": encoded}).(v1.Image) +} diff --git a/pkg/fake/deckhouse_stub_test.go b/pkg/fake/deckhouse_stub_test.go index 10f741ee8..83c92a768 100644 --- a/pkg/fake/deckhouse_stub_test.go +++ b/pkg/fake/deckhouse_stub_test.go @@ -165,3 +165,42 @@ func TestNewRegistryClientStub_GetRegistry(t *testing.T) { // registry.deckhouse.ru/deckhouse/fe assert.Equal(t, "registry.deckhouse.ru/deckhouse/fe", client.GetRegistry()) } + +// TestNewRegistryClientStub_Modules verifies the modules catalog: the +// cert-manager module with a version reachable via its stable channel. +func TestNewRegistryClientStub_Modules(t *testing.T) { + client := fake.NewRegistryClientStub() + ctx := context.Background() + + names, err := client.WithSegment("modules").ListTags(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"cert-manager"}, names) + + releaseTags, err := client.WithSegment("modules", "cert-manager", "release").ListTags(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"stable", "v0.5.0"}, releaseTags) + + err = client.WithSegment("modules", "cert-manager").CheckImageExists(ctx, "v0.5.0") + assert.NoError(t, err) +} + +// TestNewRegistryClientStub_PluginsCatalog verifies the plugins catalog: the +// name index tag and the plugin version image carrying a contract annotation. +func TestNewRegistryClientStub_PluginsCatalog(t *testing.T) { + client := fake.NewRegistryClientStub() + ctx := context.Background() + + names, err := client.WithSegment("deckhouse-cli", "plugins").ListTags(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"cert-manager-tool"}, names) + + pluginClient := client.WithSegment("deckhouse-cli", "plugins", "cert-manager-tool") + require.NoError(t, pluginClient.CheckImageExists(ctx, "v1.0.0")) + + manifestResult, err := pluginClient.GetManifest(ctx, "v1.0.0") + require.NoError(t, err) + manifest, err := manifestResult.GetManifest() + require.NoError(t, err) + assert.NotEmpty(t, manifest.GetAnnotations()["contract"], + "the plugin version must carry the base64 contract annotation") +} diff --git a/pkg/registry/image/layout.go b/pkg/registry/image/layout.go index a85505066..3e2e69b67 100644 --- a/pkg/registry/image/layout.go +++ b/pkg/registry/image/layout.go @@ -146,6 +146,52 @@ func (l *ImageLayout) AddImage(img pkg.RegistryImage, tag string) error { return nil } +// AddIndex stores idx in the layout under tag. The index goes in whole: +// per-platform children, their descriptors, and index annotations stay as +// published. Use it for multi-platform images (e.g. CLI plugins), where +// flattening to a single platform would lose the rest. tagReference is the +// full registry reference for the descriptor annotations, same as AddImage +// records. +// +// Idempotent for the (tag, digest) pair, same guard order as AddImage: +// metaByTag is written only after AppendIndex succeeds, so a failed write is +// retried, not skipped. +func (l *ImageLayout) AddIndex(idx v1.ImageIndex, tag, tagReference string) error { + digest, err := idx.Digest() + if err != nil { + return fmt.Errorf("get index digest: %w", err) + } + + if existing, ok := l.metaByTag[tag]; ok { + if existingDigest := existing.GetDigest(); existingDigest != nil && *existingDigest == digest { + return nil + } + } + + err = l.wrapped.AppendIndex(idx, + layout.WithAnnotations(map[string]string{ + AnnotationImageReferenceName: tagReference, + AnnotationImageShortTag: tag, + }), + ) + if err != nil { + return fmt.Errorf("append index: %w", err) + } + + meta := &ImageMeta{ + TagReference: tagReference, + Digest: &digest, + } + if strings.Contains(tagReference, ":") { + repo, _ := SplitImageRefByRepoAndTag(tagReference) + meta.DigestReference = repo + "@" + digest.String() + } + + l.metaByTag[tag] = meta + + return nil +} + func (l *ImageLayout) GetImage(tag string) (pkg.RegistryImage, error) { index, err := l.wrapped.ImageIndex() if err != nil { diff --git a/pkg/registry/image/layout_test.go b/pkg/registry/image/layout_test.go index 1a90338d7..68d88ebe0 100644 --- a/pkg/registry/image/layout_test.go +++ b/pkg/registry/image/layout_test.go @@ -21,7 +21,9 @@ import ( "testing" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" "github.com/google/go-containerregistry/pkg/v1/layout" + "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -136,6 +138,100 @@ func TestAddImage_NewDescriptorForSameTagDifferentDigest(t *testing.T) { "in-memory metadata for the tag must reflect the latest AddImage call") } +// buildMultiPlatformIndex builds a two-platform OCI index with a top-level +// annotation - the shape CLI plugin images are published in. +func buildMultiPlatformIndex(t *testing.T, contract string) v1.ImageIndex { + t.Helper() + + linuxImg := upfake.NewImageBuilder().WithFile("plugin", "linux-bin").MustBuild() + darwinImg := upfake.NewImageBuilder().WithFile("plugin", "darwin-bin").MustBuild() + + idx := mutate.AppendManifests(empty.Index, + mutate.IndexAddendum{ + Add: linuxImg, + Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}}, + }, + mutate.IndexAddendum{ + Add: darwinImg, + Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "darwin", Architecture: "arm64"}}, + }, + ) + + withAnnotations := mutate.Annotations(idx, map[string]string{"contract": contract}) + + annotated, ok := withAnnotations.(v1.ImageIndex) + require.True(t, ok, "mutate.Annotations on an index must return an index") + + return annotated +} + +// TestAddIndex_PreservesIndexStructure checks that the index lands as one +// descriptor, the children keep their platforms, the contract annotation +// survives byte-exact, and the ref/short_tag annotations for the pusher are +// set. +func TestAddIndex_PreservesIndexStructure(t *testing.T) { + l, err := regimage.NewImageLayout(t.TempDir()) + require.NoError(t, err) + + idx := buildMultiPlatformIndex(t, "ZmFrZS1jb250cmFjdA==") + idxDigest, err := idx.Digest() + require.NoError(t, err) + + const tagRef = "example.io/deckhouse-cli/plugins/foo:v1.0.0" + require.NoError(t, l.AddIndex(idx, "v1.0.0", tagRef)) + + require.Equal(t, 1, indexDescriptorCount(t, l), + "a single AddIndex call must produce exactly one descriptor") + + topIndex, err := l.Path().ImageIndex() + require.NoError(t, err) + topManifest, err := topIndex.IndexManifest() + require.NoError(t, err) + + desc := topManifest.Manifests[0] + assert.True(t, desc.MediaType.IsIndex(), "the descriptor must keep the index media type") + assert.Equal(t, idxDigest, desc.Digest, "the index digest must be preserved") + assert.Equal(t, tagRef, desc.Annotations[regimage.AnnotationImageReferenceName]) + assert.Equal(t, "v1.0.0", desc.Annotations[regimage.AnnotationImageShortTag]) + + nested, err := topIndex.ImageIndex(desc.Digest) + require.NoError(t, err, "the nested index must be readable from the layout") + nestedManifest, err := nested.IndexManifest() + require.NoError(t, err) + + require.Len(t, nestedManifest.Manifests, 2, "both platform children must survive") + platforms := []string{ + nestedManifest.Manifests[0].Platform.String(), + nestedManifest.Manifests[1].Platform.String(), + } + assert.ElementsMatch(t, []string{"linux/amd64", "darwin/arm64"}, platforms) + assert.Equal(t, "ZmFrZS1jb250cmFjdA==", nestedManifest.Annotations["contract"], + "the top-level contract annotation must survive intact") + + meta, err := l.GetMeta("v1.0.0") + require.NoError(t, err) + require.NotNil(t, meta.GetDigest()) + assert.Equal(t, idxDigest.String(), meta.GetDigest().String()) + assert.Equal(t, "example.io/deckhouse-cli/plugins/foo@"+idxDigest.String(), meta.GetDigestReference()) +} + +// TestAddIndex_IdempotentForSameTagAndDigest: same guard as AddImage - a +// repeated call with the same (tag, digest) must not append a duplicate +// descriptor, or retried pulls would inflate the layout and the push. +func TestAddIndex_IdempotentForSameTagAndDigest(t *testing.T) { + l, err := regimage.NewImageLayout(t.TempDir()) + require.NoError(t, err) + + idx := buildMultiPlatformIndex(t, "ZmFrZS1jb250cmFjdA==") + + require.NoError(t, l.AddIndex(idx, "v1.0.0", "example.io/repo:v1.0.0")) + require.NoError(t, l.AddIndex(idx, "v1.0.0", "example.io/repo:v1.0.0"), + "second AddIndex with same tag+digest must be a no-op, not an error") + + assert.Equal(t, 1, indexDescriptorCount(t, l), + "AddIndex must not append a second descriptor for the same (tag, digest)") +} + // TestCountManifestsMatching verifies that CountManifestsMatching counts only // the descriptors whose short-tag annotation satisfies the predicate - the // mechanism the summary uses to separate VEX attestations (".att" tags) from diff --git a/pkg/registry/service/plugin_service.go b/pkg/registry/service/plugin_service.go new file mode 100644 index 000000000..105b86a38 --- /dev/null +++ b/pkg/registry/service/plugin_service.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 service + +import ( + "context" + "fmt" + "log/slog" + + "github.com/deckhouse/deckhouse/pkg/log" + client "github.com/deckhouse/deckhouse/pkg/registry" +) + +// CLI plugins are standalone d8 subcommand binaries published as OCI images +// under the deckhouse-cli/plugins catalog at the bare registry root, outside +// the edition segment (like the installer). The catalog is a directory-as-tags +// index: ListTags on deckhouse-cli/plugins returns plugin names, ListTags on +// deckhouse-cli/plugins/ returns its published versions. +const ( + deckhouseCLISegment = "deckhouse-cli" + pluginsSegment = "plugins" + + pluginsServiceName = "plugins" + pluginServiceName = "plugin" + + // pluginContractAnnotation carries the plugin contract as base64 JSON on + // the image manifest (or on the index / its children for multi-platform + // plugins). Same convention as the internal/plugins sources. + pluginContractAnnotation = "contract" +) + +// PluginsService is scoped to the plugins catalog +// (/deckhouse-cli/plugins). ListTags on it enumerates plugin names. +type PluginsService struct { + client client.Client + + *BasicService + + services map[string]*PluginService + + logger *log.Logger +} + +// NewPluginsService creates a new plugins catalog service. +func NewPluginsService(client client.Client, logger *log.Logger) *PluginsService { + return &PluginsService{ + client: client, + + BasicService: NewBasicService(pluginsServiceName, client, logger), + services: make(map[string]*PluginService), + + logger: logger, + } +} + +// Plugin returns the service scoped to one plugin repository +// (deckhouse-cli/plugins/). +func (s *PluginsService) Plugin(pluginName string) *PluginService { + if s.services == nil { + s.services = make(map[string]*PluginService) + } + + if _, exists := s.services[pluginName]; !exists { + s.services[pluginName] = NewPluginService(s.client.WithSegment(pluginName), s.logger) + } + + return s.services[pluginName] +} + +// GetRoot returns the full registry path of the plugins catalog. +func (s *PluginsService) GetRoot() string { + return s.client.GetRegistry() +} + +// PluginService provides operations for a single plugin repository. ListTags +// returns the plugin's published versions. +type PluginService struct { + client client.Client + + *BasicService + + logger *log.Logger +} + +// NewPluginService creates a service for a single plugin repository. +func NewPluginService(client client.Client, logger *log.Logger) *PluginService { + return &PluginService{ + client: client, + + BasicService: NewBasicService(pluginServiceName, client, logger), + + logger: logger, + } +} + +// GetRoot returns the full registry path of the plugin repository. +func (s *PluginService) GetRoot() string { + return s.client.GetRegistry() +} + +// GetManifest returns the raw manifest structure for tag. A multi-platform +// plugin resolves to an index whose children carry per-platform manifests. +func (s *PluginService) GetManifest(ctx context.Context, tag string) (client.ManifestResult, error) { + logger := s.logger.With(slog.String("service", pluginServiceName), slog.String("tag", tag)) + + logger.Debug("Getting manifest") + + result, err := s.client.GetManifest(ctx, tag) + if err != nil { + return nil, fmt.Errorf("failed to get manifest: %w", err) + } + + logger.Debug("Manifest retrieved successfully") + + return result, nil +} + +// ContractAnnotation returns the base64-encoded plugin contract for tag. The +// contract may sit on the index or on its (identical) child manifests: the +// index is read first, and the first child is followed only when the index +// carries none. An empty string means the image ships no contract. +func (s *PluginService) ContractAnnotation(ctx context.Context, tag string) (string, error) { + result, err := s.GetManifest(ctx, tag) + if err != nil { + return "", err + } + + if !result.GetMediaType().IsIndex() { + man, err := result.GetManifest() + if err != nil { + return "", fmt.Errorf("read manifest: %w", err) + } + + return man.GetAnnotations()[pluginContractAnnotation], nil + } + + index, err := result.GetIndexManifest() + if err != nil { + return "", fmt.Errorf("read index manifest: %w", err) + } + + if encoded := index.GetAnnotations()[pluginContractAnnotation]; encoded != "" { + return encoded, nil + } + + children := index.GetManifests() + if len(children) == 0 { + return "", nil + } + + child, err := s.GetManifest(ctx, "@"+children[0].GetDigest().String()) + if err != nil { + return "", fmt.Errorf("get first child manifest: %w", err) + } + + childManifest, err := child.GetManifest() + if err != nil { + return "", fmt.Errorf("read child manifest: %w", err) + } + + return childManifest.GetAnnotations()[pluginContractAnnotation], nil +} diff --git a/pkg/registry/service/plugin_service_test.go b/pkg/registry/service/plugin_service_test.go new file mode 100644 index 000000000..6c549f6dc --- /dev/null +++ b/pkg/registry/service/plugin_service_test.go @@ -0,0 +1,243 @@ +/* +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 service_test + +import ( + "context" + "fmt" + "strings" + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/deckhouse/pkg/log" + dkpreg "github.com/deckhouse/deckhouse/pkg/registry" + + "github.com/deckhouse/deckhouse-cli/pkg" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" +) + +// TestPluginsService_Scoping pins the registry paths of the plugins catalog: +// it lives at /deckhouse-cli/plugins OUTSIDE the edition segment (same +// asymmetry as the installer), and each plugin repository hangs directly under +// it. A regression in either direction silently routes plugin pulls to a path +// the registry-packages-proxy and registry-bundle never serve. +func TestPluginsService_Scoping(t *testing.T) { + logger := log.NewNop() + + const host = "registry.deckhouse.ru/deckhouse" + + t.Run("plugins catalog is NOT edition-scoped", func(t *testing.T) { + svc := registryservice.NewService(pkgclient.NewFromOptions(host), pkg.FEEdition, logger) + + assert.Equal(t, host+"/deckhouse-cli/plugins", svc.PluginService().GetRoot(), + "plugins catalog must live at the bare root, never under the edition segment") + assert.Equal(t, host+"/deckhouse-cli/plugins/stronghold", svc.PluginService().Plugin("stronghold").GetRoot(), + "a plugin repository must hang directly under the plugins catalog") + }) + + t.Run("no edition yields the same paths", func(t *testing.T) { + svc := registryservice.NewService(pkgclient.NewFromOptions(host), pkg.NoEdition, logger) + + assert.Equal(t, host+"/deckhouse-cli/plugins", svc.PluginService().GetRoot()) + }) + + t.Run("plugin sub-services are cached", func(t *testing.T) { + svc := registryservice.NewService(pkgclient.NewFromOptions(host), pkg.NoEdition, logger) + + first := svc.PluginService().Plugin("stronghold") + second := svc.PluginService().Plugin("stronghold") + assert.Same(t, first, second, "Plugin must return the same instance for the same name") + }) +} + +// TestPluginService_ContractAnnotation covers the contract resolution order: +// single manifest -> its annotation; index -> index annotation first, first +// child only as a fallback; no contract anywhere -> empty string, no error. +func TestPluginService_ContractAnnotation(t *testing.T) { + logger := log.NewNop() + + t.Run("reads the annotation from the index without fetching a child", func(t *testing.T) { + client := &fakeManifestClient{byTag: map[string]dkpreg.ManifestResult{ + "v1.0.0": fakeManifestResult{ + mediaType: types.OCIImageIndex, + index: fakeIndexManifest{ + annotations: map[string]string{"contract": "index-contract"}, + manifests: []dkpreg.Descriptor{fakeDescriptor{digest: v1.Hash{Algorithm: "sha256", Hex: strings.Repeat("a", 64)}}}, + }, + }, + }} + + svc := registryservice.NewPluginService(client, logger) + + got, err := svc.ContractAnnotation(context.Background(), "v1.0.0") + require.NoError(t, err) + assert.Equal(t, "index-contract", got) + assert.Equal(t, []string{"v1.0.0"}, client.gotTags, + "the child manifest must not be fetched when the index carries the contract") + }) + + t.Run("falls back to the first child when the index has no contract", func(t *testing.T) { + childDigest := v1.Hash{Algorithm: "sha256", Hex: strings.Repeat("b", 64)} + client := &fakeManifestClient{byTag: map[string]dkpreg.ManifestResult{ + "v1.0.0": fakeManifestResult{ + mediaType: types.OCIImageIndex, + index: fakeIndexManifest{ + manifests: []dkpreg.Descriptor{fakeDescriptor{digest: childDigest}}, + }, + }, + "@" + childDigest.String(): fakeManifestResult{ + mediaType: types.OCIManifestSchema1, + manifest: fakeManifest{annotations: map[string]string{"contract": "child-contract"}}, + }, + }} + + svc := registryservice.NewPluginService(client, logger) + + got, err := svc.ContractAnnotation(context.Background(), "v1.0.0") + require.NoError(t, err) + assert.Equal(t, "child-contract", got) + assert.Equal(t, []string{"v1.0.0", "@" + childDigest.String()}, client.gotTags) + }) + + t.Run("reads the annotation from a single (non-index) manifest", func(t *testing.T) { + client := &fakeManifestClient{byTag: map[string]dkpreg.ManifestResult{ + "v1.0.0": fakeManifestResult{ + mediaType: types.OCIManifestSchema1, + manifest: fakeManifest{annotations: map[string]string{"contract": "single-contract"}}, + }, + }} + + svc := registryservice.NewPluginService(client, logger) + + got, err := svc.ContractAnnotation(context.Background(), "v1.0.0") + require.NoError(t, err) + assert.Equal(t, "single-contract", got) + }) + + t.Run("contract-less image yields empty string without error", func(t *testing.T) { + childDigest := v1.Hash{Algorithm: "sha256", Hex: strings.Repeat("c", 64)} + client := &fakeManifestClient{byTag: map[string]dkpreg.ManifestResult{ + "v1.0.0": fakeManifestResult{ + mediaType: types.OCIImageIndex, + index: fakeIndexManifest{ + manifests: []dkpreg.Descriptor{fakeDescriptor{digest: childDigest}}, + }, + }, + "@" + childDigest.String(): fakeManifestResult{ + mediaType: types.OCIManifestSchema1, + manifest: fakeManifest{}, + }, + }} + + svc := registryservice.NewPluginService(client, logger) + + got, err := svc.ContractAnnotation(context.Background(), "v1.0.0") + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("index without children yields empty string without error", func(t *testing.T) { + client := &fakeManifestClient{byTag: map[string]dkpreg.ManifestResult{ + "v1.0.0": fakeManifestResult{ + mediaType: types.OCIImageIndex, + index: fakeIndexManifest{}, + }, + }} + + svc := registryservice.NewPluginService(client, logger) + + got, err := svc.ContractAnnotation(context.Background(), "v1.0.0") + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("manifest fetch error is returned", func(t *testing.T) { + client := &fakeManifestClient{} + + svc := registryservice.NewPluginService(client, logger) + + _, err := svc.ContractAnnotation(context.Background(), "v9.9.9") + require.Error(t, err) + }) +} + +// fakeManifestClient serves a preset ManifestResult per reference and records +// the references it was asked for, so tests can assert whether a child +// manifest was fetched. Only GetManifest is implemented; the embedded +// interface panics on any other call. +type fakeManifestClient struct { + dkpreg.Client + + byTag map[string]dkpreg.ManifestResult + gotTags []string +} + +func (c *fakeManifestClient) GetManifest(_ context.Context, tag string) (dkpreg.ManifestResult, error) { + c.gotTags = append(c.gotTags, tag) + + res, ok := c.byTag[tag] + if !ok { + return nil, fmt.Errorf("no manifest for %q", tag) + } + + return res, nil +} + +type fakeManifestResult struct { + dkpreg.ManifestResult + + mediaType types.MediaType + manifest dkpreg.Manifest + index dkpreg.IndexManifest +} + +func (r fakeManifestResult) GetMediaType() types.MediaType { return r.mediaType } +func (r fakeManifestResult) GetManifest() (dkpreg.Manifest, error) { return r.manifest, nil } +func (r fakeManifestResult) GetIndexManifest() (dkpreg.IndexManifest, error) { + return r.index, nil +} + +type fakeManifest struct { + dkpreg.Manifest + + annotations map[string]string +} + +func (m fakeManifest) GetAnnotations() map[string]string { return m.annotations } + +type fakeIndexManifest struct { + dkpreg.IndexManifest + + annotations map[string]string + manifests []dkpreg.Descriptor +} + +func (i fakeIndexManifest) GetAnnotations() map[string]string { return i.annotations } +func (i fakeIndexManifest) GetManifests() []dkpreg.Descriptor { return i.manifests } + +type fakeDescriptor struct { + dkpreg.Descriptor + + digest v1.Hash +} + +func (d fakeDescriptor) GetDigest() v1.Hash { return d.digest } diff --git a/pkg/registry/service/service.go b/pkg/registry/service/service.go index 949fc9e59..89a7909c7 100644 --- a/pkg/registry/service/service.go +++ b/pkg/registry/service/service.go @@ -47,6 +47,7 @@ type Service struct { deckhouseService *DeckhouseService security *SecurityServices installer *InstallerServices + plugins *PluginsService // modulesPath is the registry path where modules live, relative to the // edition root. Defaults to "modules"; empty means the edition root. May @@ -111,6 +112,7 @@ func NewService(c client.Client, edition pkg.Edition, logger *log.Logger, opts . // services that are not scoped by edition s.installer = NewInstallerServices(installerServiceName, c.WithSegment("installer"), logger.Named("installer")) + s.plugins = NewPluginsService(c.WithSegment(deckhouseCLISegment, pluginsSegment), logger.Named("plugins")) return s } @@ -161,6 +163,12 @@ func (s *Service) InstallerService() *InstallerServices { return s.installer } +// PluginService returns the CLI plugins catalog service. It is scoped to +// /deckhouse-cli/plugins, outside the edition segment. +func (s *Service) PluginService() *PluginsService { + return s.plugins +} + // GetEditionFromRegistryPath cuts the edition from the registry path // returns the path without the edition and the edition // this is needed because of the different paths for the installer images in the registry