Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>[@constraint]` adds more. After `d8 mirror push`, the
plugins live at `<target>/deckhouse-cli/plugins/<name>` - exactly where the
in-cluster registry-packages-proxy looks - so `d8 plugins install <name>`
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 <target>/deckhouse-cli/plugins` (or `d8 cr ls`).

## Flags and environment variables

| Flag | Env | Purpose |
Expand Down
7 changes: 7 additions & 0 deletions internal/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import "path"
// <root>/packages/<package-name>:<version> - Package main image
// <root>/packages/<package-name>/version:<channel> - Package version channel metadata
// <root>/packages/<package-name>/extra/<extra-name>:<version> - Package extra images
//
// Deckhouse CLI plugins (at the bare root, outside the edition segment, like installer):
//
// <root>/deckhouse-cli/plugins/<plugin-name>:<version> - Plugin image (multi-platform OCI index)
const (
InstallSegment = "install"
InstallStandaloneSegment = "install-standalone"
Expand All @@ -57,6 +61,9 @@ const (

InstallerSegment = "installer"

D8CLISegment = "deckhouse-cli"
D8PluginsSegment = "plugins"

SecuritySegment = "security"

SecurityTrivyDBSegment = "trivy-db"
Expand Down
2 changes: 2 additions & 0 deletions internal/mirror/PROXY-REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>@=vX.Y.Z` (exact pins address manifests by tag, no listing involved; the CLI validates this up front).

---

## End-to-end flow
Expand Down
40 changes: 40 additions & 0 deletions internal/mirror/README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
|------|----------|
| `<root>/deckhouse-cli/plugins` | Plugin catalog; its tags are plugin names |
| `<root>/deckhouse-cli/plugins/<name>:<vX.Y.Z>` | One plugin version - a multi-platform OCI index (linux/darwin/windows) |

**Bundle output:** one `plugin-<name>.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 <name>` 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 <target>/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 `<root>/<edition>/security/`:
Expand Down Expand Up @@ -430,6 +469,7 @@ The pull command creates a bundle with the following structure:
├── module-<name>.tar # One archive per module (if not --no-modules)
├── package-<name>.tar # One archive per package (if not --no-packages)
├── package-versions.tar # Package release-metadata catalog (always produced)
├── plugin-<name>.tar # One archive per d8 CLI plugin the bundle needs (see Plugin Mirroring)
└── <name>.tar.NNNN.chunk # Chunk parts, when --images-bundle-chunk-size is set (NNNN = 0000, 0001, …)
```

Expand Down
10 changes: 10 additions & 0 deletions internal/mirror/cmd/pull/flags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions internal/mirror/cmd/pull/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions internal/mirror/cmd/pull/pull_plugins_stub_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading