From 6b663bc587f7c58a735928eb060d9faaeae42de6 Mon Sep 17 00:00:00 2001 From: Pavel Okhlopkov Date: Tue, 7 Apr 2026 13:45:16 +0300 Subject: [PATCH 1/6] add dryrun Signed-off-by: Pavel Okhlopkov --- .gitignore | 1 + docs/mirror-pull-dry-run.md | 252 +++++++++++++++ internal/mirror/cmd/pull/flags/flags.go | 8 + internal/mirror/cmd/pull/pull.go | 6 + internal/mirror/cmd/pull/pull_dryrun_test.go | 286 ++++++++++++++++++ .../mirror/cmd/pull/pull_realregistry_test.go | 96 ++++++ internal/mirror/installer/installer.go | 10 + .../mirror/installer/installer_dryrun_test.go | 106 +++++++ internal/mirror/modules/modules.go | 46 ++- .../mirror/modules/modules_dryrun_test.go | 99 ++++++ internal/mirror/platform/platform.go | 39 ++- .../mirror/platform/platform_dryrun_test.go | 111 +++++++ internal/mirror/pull.go | 6 + internal/mirror/security/security.go | 12 + .../mirror/security/security_dryrun_test.go | 103 +++++++ 15 files changed, 1167 insertions(+), 14 deletions(-) create mode 100644 docs/mirror-pull-dry-run.md create mode 100644 internal/mirror/cmd/pull/pull_dryrun_test.go create mode 100644 internal/mirror/cmd/pull/pull_realregistry_test.go create mode 100644 internal/mirror/installer/installer_dryrun_test.go create mode 100644 internal/mirror/modules/modules_dryrun_test.go create mode 100644 internal/mirror/platform/platform_dryrun_test.go create mode 100644 internal/mirror/security/security_dryrun_test.go diff --git a/.gitignore b/.gitignore index 30a43a92..4afb34cc 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ bin/ d8 dist/ build/ +tmp/ # Entrypoint for the application !/cmd/d8 \ No newline at end of file diff --git a/docs/mirror-pull-dry-run.md b/docs/mirror-pull-dry-run.md new file mode 100644 index 00000000..724d2095 --- /dev/null +++ b/docs/mirror-pull-dry-run.md @@ -0,0 +1,252 @@ +# `d8 mirror pull --dry-run` Guide + +## What dry-run does + +`--dry-run` runs the full planning pipeline of `d8 mirror pull` — version resolution, +release-channel discovery, module filtering, installer tag lookup — then **prints the +complete list of images that would be downloaded** and exits without writing any bundle +output to the bundle directory. + +The key distinction from a no-op: + +| Step | Normal pull | Dry-run | +|------|-------------|---------| +| Validate registry access | yes | yes | +| Resolve versions / channels | yes | yes | +| Pull installer to tmpDir | yes | **yes** (needed to read `images_digests.json`) | +| Pull release-channel metadata | yes | yes | +| Download platform/module/security blobs | yes | **no** | +| Write `platform.tar`, `security.tar`, module tarballs | yes | **no** | +| Write `deckhousereleases.yaml` | yes | **no** | +| Compute GOST digests | yes | **no** | + +Installer OCI layouts land in `--tmp-dir` (or `/.tmp`) so the tool can +extract the built-in image digest list from `deckhouse/candi/images_digests.json`. +The **bundle directory** (first positional argument) remains empty. + +--- + +## CLI usage + +```bash +d8 mirror pull --dry-run [flags] +``` + +All normal `pull` flags are accepted. Dry-run respects every filter and skip flag +(`--deckhouse-tag`, `--since-version`, `--no-platform`, `--no-modules`, etc.). + +### Minimal example — exact tag, platform only + +```bash +d8 mirror pull --dry-run /tmp/bundle \ + --source registry.deckhouse.io/deckhouse/fe \ + --license \ + --deckhouse-tag v1.69.0 \ + --no-modules \ + --no-security-db +``` + +Expected output (abbreviated): + +``` +INFO Skipped releases lookup as tag "v1.69.0" is specifically requested with --deckhouse-tag +INFO Deckhouse releases to pull: [1.69.0] +INFO ╔ Pull release channels and installers +INFO ║ [1 / 1] Pulling registry.deckhouse.io/deckhouse/install:v1.69.0 +INFO ╚ Pull release channels and installers succeeded in … +INFO Extracting images digests from Deckhouse installer v1.69.0 +INFO Deckhouse digests found: 319 +INFO Found 320 images +INFO [dry-run] Platform images that would be pulled: +INFO registry.deckhouse.io/deckhouse/fe@sha256:… +INFO registry.deckhouse.io/deckhouse/fe/release-channel:v1.69.0 +INFO registry.deckhouse.io/deckhouse/fe/install:v1.69.0 + … +INFO [dry-run] Done. No images were downloaded. +``` + +### All components + +```bash +d8 mirror pull --dry-run /tmp/bundle \ + --source registry.deckhouse.io/deckhouse/fe \ + --license \ + --deckhouse-tag v1.69.0 +``` + +This resolves platform images, installer, security databases, and all modules. +No blobs are downloaded; the bundle directory stays empty. + +### Since a minimum version (channel-based pull) + +```bash +d8 mirror pull --dry-run /tmp/bundle \ + --source registry.deckhouse.io/deckhouse/fe \ + --license \ + --since-version 1.68.0 +``` + +### Module whitelist + +```bash +d8 mirror pull --dry-run /tmp/bundle \ + --source registry.deckhouse.io/deckhouse/fe \ + --license \ + --deckhouse-tag v1.69.0 \ + --include-module stronghold \ + --include-module commander-agent +``` + +--- + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Planning succeeded (or was cancelled by the user) | +| non-0 | Registry unreachable, invalid flags, or licence denied | + +--- + +## Testing dry-run + +### Unit tests (offline, stub registry) + +Unit tests live alongside the packages they cover and run with `go test` — no network +access, no credentials needed. The stub registry is activated via: + +``` +STUB_REGISTRY_CLIENT=true +``` + +The stub seeds versions `v1.68.0`–`v1.72.10`, channels `alpha`/`beta`/`early-access`/ +`stable`/`rock-solid`, and several module tags. + +#### Run all dry-run unit tests + +```bash +go test ./internal/mirror/... -run 'TestDryRun' -v -timeout 120s +``` + +#### Specific packages + +```bash +# Pull-command level (flag registration, no bundle output, exit 0) +go test ./internal/mirror/cmd/pull/ -run 'TestDryRun' -v + +# Platform service level (installer pulled to tmpDir, bundle stays empty) +go test ./internal/mirror/platform/ -run 'TestDryRun' -v +``` + +#### What the tests assert + +| Test | What it checks | +|------|----------------| +| `TestDryRunFlagRegistered` | `--dry-run` cobra flag exists and defaults to `false` | +| `TestDryRunNoBundleOutput` | bundleDir has no `.tar`/`.chunk`/`.gostsum` after full run | +| `TestDryRunNoBundleWithNoPlatform` | same, with `--no-platform --no-security-db` | +| `TestDryRunWithDeckhouseTag` | specific `--deckhouse-tag` works in dry-run | +| `TestDryRunExitsZeroOnSuccess` | `Execute()` returns `nil` | +| `TestDryRun_NoBundleFilesWritten` | platform service: bundleDir empty | +| `TestDryRun_InstallerPulledToTmpDir` | platform service: `/platform/install/` exists | + +### Integration smoke test (real registry) + +`TestDryRunRealRegistry` in +`internal/mirror/cmd/pull/pull_realregistry_test.go` +is skipped automatically unless both environment variables are set: + +| Variable | Value | +|----------|-------| +| `D8_TEST_REGISTRY` | `registry.deckhouse.io/deckhouse/fe` | +| `D8_TEST_LICENSE_TOKEN` | your Deckhouse license key | + +```bash +D8_TEST_REGISTRY=registry.deckhouse.io/deckhouse/fe \ +D8_TEST_LICENSE_TOKEN= \ + go test ./internal/mirror/cmd/pull/ \ + -run TestDryRunRealRegistry \ + -v -timeout 300s +``` + +The test asserts: +1. `Execute()` returns `nil` +2. The bundle directory is **empty** — no `.tar` output +3. The tmp directory is **non-empty** — OCI layouts were written (installer pull), proving + `images_digests.json` extraction was attempted + +Sample passing output: + +``` +=== RUN TestDryRunRealRegistry +… +INFO Deckhouse digests found: 319 +INFO Found 320 images +INFO [dry-run] Platform images that would be pulled: +INFO registry.deckhouse.io/deckhouse/fe@sha256:e927fc9… + … 320 lines … + pull_realregistry_test.go:94: tmpDir files written during dry-run: 51 + pull_realregistry_test.go:95: bundleDir entries (must be 0): 0 +--- PASS: TestDryRunRealRegistry (79.99s) +``` + +--- + +## How dry-run works internally + +``` +Puller.Execute() + └─ mirror.NewPullService(… DryRun: pullflags.DryRun …) + └─ PullService.Pull() + ├─ platform.Service.PullPlatform() [DryRun=true] + │ ├─ validatePlatformAccess() ← real network call + │ ├─ findTagsToMirror() ← real network call + │ ├─ downloadList.FillDeckhouseImages() ← in-memory + │ └─ pullDeckhousePlatform() + │ ├─ pullDeckhouseReleaseChannels() ← writes to tmpDir + │ ├─ pullInstallers() ← writes to tmpDir ← KEY STEP + │ ├─ pullStandaloneInstallers() ← writes to tmpDir + │ │ (pullDeckhouseImages SKIPPED in dry-run) + │ ├─ ExtractImageDigestsFromInstaller ← reads images_digests.json + │ └─ [dry-run guard] print plan → return nil + │ (no platform.tar written) + ├─ installer.Service.PullInstaller() [DryRun=true] + │ ├─ validateInstallerAccess() + │ ├─ findTagsToMirror() + │ ├─ downloadList.FillInstallerImages() + │ └─ [dry-run guard] print plan → return nil + ├─ security.Service.PullSecurity() [DryRun=true] + │ ├─ validateSecurityAccess() + │ ├─ downloadList.FillSecurityImages() + │ └─ [dry-run guard] print plan → return nil + └─ modules.Service.PullModules() [DryRun=true] + ├─ discover modules (ListRepositories) + ├─ per module: extractVersionsFromReleaseChannels() + └─ [dry-run guard] print plan → return nil + + After Pull() returns: + if DryRun → print "[dry-run] Done." → return nil + else → computeGOSTDigests, finalCleanup +``` + +The installer image **is** pulled to `tmpDir` in dry-run mode because +`images_digests.json` inside it is the only source for the ~300 component image +digest references that the platform bundle would contain. Without this step, +dry-run could only report the 5–10 top-level image tags, missing the vast majority +of what a real pull actually downloads. + +--- + +## Temporary files + +| Location | Created in dry-run? | Description | +|----------|---------------------|-------------| +| `/platform/install/` | **yes** | Installer OCI layout | +| `/platform/install-standalone/` | **yes** | Standalone installer OCI layout | +| `/platform/release/` | **yes** | Release-channel metadata OCI layout | +| `/platform.tar` | no | Not created | +| `/security.tar` | no | Not created | +| `/modules-*.tar` | no | Not created | +| `/deckhousereleases.yaml` | no | Not created | + +`tmpDir` is cleaned up by a subsequent normal pull or can be removed manually. diff --git a/internal/mirror/cmd/pull/flags/flags.go b/internal/mirror/cmd/pull/flags/flags.go index 0c46acb9..afc3bc3f 100644 --- a/internal/mirror/cmd/pull/flags/flags.go +++ b/internal/mirror/cmd/pull/flags/flags.go @@ -69,6 +69,8 @@ var ( OnlyExtraImages bool SkipVexImages bool + DryRun bool + MirrorTimeout time.Duration = -1 ) @@ -217,6 +219,12 @@ module-name@=v1.3.0+stable → exact tag match: include only v1.3.0 and and publ false, "Do not pull VEX images.", ) + flagSet.BoolVar( + &DryRun, + "dry-run", + false, + "Print what would be pulled without downloading any images. Useful for fast validation of flags and filters.", + ) flagSet.BoolVar( &TLSSkipVerify, "tls-skip-verify", diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index 8866c970..a02544eb 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -283,6 +283,7 @@ func (p *Puller) Execute(ctx context.Context) error { BundleDir: pullflags.ImagesBundlePath, BundleChunkSize: pullflags.ImagesBundleChunkSizeGB * 1000 * 1000 * 1000, Timeout: pullflags.MirrorTimeout, + DryRun: pullflags.DryRun, }, logger.Named("pull"), p.logger, @@ -298,6 +299,11 @@ func (p *Puller) Execute(ctx context.Context) error { return fmt.Errorf("pull from registry: %w", err) } + if pullflags.DryRun { + p.logger.InfoLn("[dry-run] Done. No images were downloaded.") + return nil + } + if err := p.computeGOSTDigests(); err != nil { return err } diff --git a/internal/mirror/cmd/pull/pull_dryrun_test.go b/internal/mirror/cmd/pull/pull_dryrun_test.go new file mode 100644 index 00000000..c0045921 --- /dev/null +++ b/internal/mirror/cmd/pull/pull_dryrun_test.go @@ -0,0 +1,286 @@ +/* +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 + +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" +) + +// saveFlagsAndRestore captures the current state of all relevant pull flags and +// returns a restore function that must be deferred by the caller. +func saveFlagsAndRestore(t *testing.T) func() { + t.Helper() + + saved := struct { + TempDir string + ImagesBundlePath string + SourceRegistryRepo string + NoPlatform bool + NoSecurityDB bool + NoModules bool + NoInstaller bool + DoGOSTDigest bool + DryRun bool + NoPullResume bool + DeckhouseTag string + InstallerTag string + ModulesWhitelist []string + ModulesBlacklist []string + SourceRegistryLogin string + SourceRegistryPassword string + DeckhouseLicenseToken string + OnlyExtraImages bool + SkipVexImages bool + IgnoreSuspend bool + ImagesBundleChunkSizeGB int64 + }{ + TempDir: pullflags.TempDir, + ImagesBundlePath: pullflags.ImagesBundlePath, + SourceRegistryRepo: pullflags.SourceRegistryRepo, + NoPlatform: pullflags.NoPlatform, + NoSecurityDB: pullflags.NoSecurityDB, + NoModules: pullflags.NoModules, + NoInstaller: pullflags.NoInstaller, + DoGOSTDigest: pullflags.DoGOSTDigest, + DryRun: pullflags.DryRun, + NoPullResume: pullflags.NoPullResume, + DeckhouseTag: pullflags.DeckhouseTag, + InstallerTag: pullflags.InstallerTag, + ModulesWhitelist: pullflags.ModulesWhitelist, + ModulesBlacklist: pullflags.ModulesBlacklist, + SourceRegistryLogin: pullflags.SourceRegistryLogin, + SourceRegistryPassword: pullflags.SourceRegistryPassword, + DeckhouseLicenseToken: pullflags.DeckhouseLicenseToken, + OnlyExtraImages: pullflags.OnlyExtraImages, + SkipVexImages: pullflags.SkipVexImages, + IgnoreSuspend: pullflags.IgnoreSuspend, + ImagesBundleChunkSizeGB: pullflags.ImagesBundleChunkSizeGB, + } + + return func() { + pullflags.TempDir = saved.TempDir + pullflags.ImagesBundlePath = saved.ImagesBundlePath + pullflags.SourceRegistryRepo = saved.SourceRegistryRepo + pullflags.NoPlatform = saved.NoPlatform + pullflags.NoSecurityDB = saved.NoSecurityDB + pullflags.NoModules = saved.NoModules + pullflags.NoInstaller = saved.NoInstaller + pullflags.DoGOSTDigest = saved.DoGOSTDigest + pullflags.DryRun = saved.DryRun + pullflags.NoPullResume = saved.NoPullResume + pullflags.DeckhouseTag = saved.DeckhouseTag + pullflags.InstallerTag = saved.InstallerTag + pullflags.ModulesWhitelist = saved.ModulesWhitelist + pullflags.ModulesBlacklist = saved.ModulesBlacklist + pullflags.SourceRegistryLogin = saved.SourceRegistryLogin + pullflags.SourceRegistryPassword = saved.SourceRegistryPassword + pullflags.DeckhouseLicenseToken = saved.DeckhouseLicenseToken + pullflags.OnlyExtraImages = saved.OnlyExtraImages + pullflags.SkipVexImages = saved.SkipVexImages + pullflags.IgnoreSuspend = saved.IgnoreSuspend + pullflags.ImagesBundleChunkSizeGB = saved.ImagesBundleChunkSizeGB + } +} + +// TestDryRunFlagRegistered verifies --dry-run is properly registered as a cobra flag. +func TestDryRunFlagRegistered(t *testing.T) { + cmd := NewCommand() + flag := cmd.Flags().Lookup("dry-run") + require.NotNil(t, flag, "--dry-run flag must be registered on pull command") + assert.Equal(t, "false", flag.DefValue) + assert.NotEmpty(t, flag.Usage) +} + +// TestDryRunNoBundleOutput verifies that no files are written to the bundle dir in dry-run mode. +func TestDryRunNoBundleOutput(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.InstallerTag = "latest" + pullflags.NoPlatform = false + pullflags.NoSecurityDB = false + pullflags.NoModules = false + pullflags.NoInstaller = 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) + + // No .tar or .chunk files must be written in bundle dir + 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()) + assert.NotEqual(t, ".gostsum", ext, "dry-run must not write .gostsum files, found: %s", e.Name()) + } +} + +// TestDryRunNoBundleWithNoPlatform verifies dry-run with --no-platform does not write files. +func TestDryRunNoBundleWithNoPlatform(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.InstallerTag = "latest" + pullflags.NoPlatform = true + pullflags.NoSecurityDB = true + pullflags.NoModules = false + pullflags.NoInstaller = true + 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) + assert.NotEqual(t, ".chunk", ext) + } +} + +// TestDryRunWithDeckhouseTag verifies dry-run works with a specific --deckhouse-tag. +func TestDryRunWithDeckhouseTag(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.72.10" + pullflags.InstallerTag = "v1.72.10" + pullflags.NoPlatform = false + pullflags.NoSecurityDB = true + pullflags.NoModules = true + pullflags.NoInstaller = false + pullflags.DryRun = true + pullflags.DoGOSTDigest = false + pullflags.NoPullResume = true + pullflags.SkipVexImages = true + + ctx := context.Background() + cmd.SetContext(ctx) + + puller := NewPuller(cmd) + err := puller.Execute(ctx) + require.NoError(t, err) + + // Bundle dir must remain empty + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + for _, e := range entries { + ext := filepath.Ext(e.Name()) + assert.NotEqual(t, ".tar", ext) + } +} + +// TestDryRunExitsZeroOnSuccess verifies Execute returns nil in dry-run mode. +func TestDryRunExitsZeroOnSuccess(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.InstallerTag = "latest" + pullflags.NoPlatform = false + pullflags.NoSecurityDB = false + pullflags.NoModules = false + pullflags.NoInstaller = 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) + assert.NoError(t, err, "dry-run must exit with code 0 on success") +} + +// TestDryRunFlagInOptions verifies DryRun field is included in PullServiceOptions literal. +func TestDryRunFlagInOptions(t *testing.T) { + defer saveFlagsAndRestore(t)() + + pullflags.DryRun = true + assert.True(t, pullflags.DryRun) + + pullflags.DryRun = false + assert.False(t, pullflags.DryRun) +} diff --git a/internal/mirror/cmd/pull/pull_realregistry_test.go b/internal/mirror/cmd/pull/pull_realregistry_test.go new file mode 100644 index 00000000..2c712506 --- /dev/null +++ b/internal/mirror/cmd/pull/pull_realregistry_test.go @@ -0,0 +1,96 @@ +/* +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 + +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" +) + +// TestDryRunRealRegistry is an integration smoke-test that hits a real Deckhouse +// registry. It is skipped automatically unless D8_TEST_REGISTRY and +// D8_TEST_LICENSE_TOKEN environment variables are set. +// +// Example: +// +// D8_TEST_REGISTRY=registry.deckhouse.io/deckhouse/fe \ +// D8_TEST_LICENSE_TOKEN= \ +// go test ./internal/mirror/cmd/pull/ -run TestDryRunRealRegistry -v -timeout 300s +func TestDryRunRealRegistry(t *testing.T) { + registry := os.Getenv("D8_TEST_REGISTRY") + licenseToken := os.Getenv("D8_TEST_LICENSE_TOKEN") + if registry == "" || licenseToken == "" { + t.Skip("skipping real-registry test: D8_TEST_REGISTRY and D8_TEST_LICENSE_TOKEN must be set") + } + + bundleDir := t.TempDir() + tmpDir := t.TempDir() + + cmd := NewCommand() + defer saveFlagsAndRestore(t)() + + pullflags.ImagesBundlePath = bundleDir + pullflags.TempDir = tmpDir + pullflags.SourceRegistryRepo = registry + pullflags.DeckhouseLicenseToken = licenseToken + pullflags.DeckhouseTag = "v1.69.0" + pullflags.InstallerTag = "v1.69.0" + pullflags.NoPlatform = false + pullflags.NoSecurityDB = true + pullflags.NoModules = true + pullflags.NoInstaller = false + pullflags.DryRun = true + pullflags.DoGOSTDigest = false + pullflags.NoPullResume = true + pullflags.SkipVexImages = true + + ctx := context.Background() + cmd.SetContext(ctx) + + puller := NewPuller(cmd) + err := puller.Execute(ctx) + require.NoError(t, err, "dry-run against real registry must succeed") + + // bundleDir must have NO .tar / .chunk output files + 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 to bundle dir, found: %s", e.Name()) + assert.NotEqual(t, ".chunk", ext, "dry-run must not write .chunk to bundle dir, found: %s", e.Name()) + } + + // tmpDir MUST have content: installer OCI layouts land here + var tmpFiles []string + _ = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + tmpFiles = append(tmpFiles, path) + } + return nil + }) + assert.NotEmpty(t, tmpFiles, "dry-run must write installer OCI layouts to tmpDir so images_digests.json can be read") + + t.Logf("tmpDir files written during dry-run: %d", len(tmpFiles)) + t.Logf("bundleDir entries (must be 0): %d", len(entries)) +} diff --git a/internal/mirror/installer/installer.go b/internal/mirror/installer/installer.go index 66db34c7..116e40ab 100644 --- a/internal/mirror/installer/installer.go +++ b/internal/mirror/installer/installer.go @@ -49,6 +49,8 @@ type Options struct { BundleChunkSize int64 // Timeout is the timeout for the installer access check Timeout time.Duration + // DryRun prints the pull plan without downloading any image blobs + DryRun bool } type Service struct { // registryService handles Deckhouse installer registry operations @@ -115,6 +117,14 @@ func (svc *Service) PullInstaller(ctx context.Context) error { svc.downloadList.FillInstallerImages(tagsToMirror) + if svc.options.DryRun { + svc.userLogger.InfoLn("[dry-run] Installer images that would be pulled:") + for ref := range svc.downloadList.Installer { + svc.userLogger.InfoLn(" " + ref) + } + return nil + } + err = svc.pullInstaller(ctx) if err != nil { return fmt.Errorf("pull installer: %w", err) diff --git a/internal/mirror/installer/installer_dryrun_test.go b/internal/mirror/installer/installer_dryrun_test.go new file mode 100644 index 00000000..b7cb3807 --- /dev/null +++ b/internal/mirror/installer/installer_dryrun_test.go @@ -0,0 +1,106 @@ +/* +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 installer + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + "github.com/deckhouse/deckhouse-cli/pkg/stub" +) + +// TestDryRun_NoBundleFilesWritten verifies that PullInstaller in dry-run mode +// does not write any tar bundles to the bundle directory. +func TestDryRun_NoBundleFilesWritten(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + TargetTag: "v1.69.0", + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullInstaller(context.Background()) + require.NoError(t, err) + + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write any files to the bundle directory; found: %v", entries) +} + +// TestDryRun_WorkingDirHasLayouts verifies that PullInstaller in dry-run mode +// creates the installer OCI layout directory under the working directory but +// does not pack anything into bundles. +func TestDryRun_WorkingDirHasLayouts(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + TargetTag: "v1.69.0", + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullInstaller(context.Background()) + require.NoError(t, err) + + // Installer OCI layout should be created under workingDir/installer + installerDir := filepath.Join(workingDir, "installer") + _, statErr := os.Stat(installerDir) + assert.NoError(t, statErr, "installer OCI layout should be created in working dir during dry-run; dir: %s", installerDir) + + // Bundle dir must remain empty + bundleEntries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, bundleEntries, "dry-run must not write any bundle files; found: %v", bundleEntries) +} diff --git a/internal/mirror/modules/modules.go b/internal/mirror/modules/modules.go index 6b52d954..b6d129fc 100644 --- a/internal/mirror/modules/modules.go +++ b/internal/mirror/modules/modules.go @@ -74,6 +74,8 @@ type Options struct { BundleChunkSize int64 // Timeout is the timeout for the modules access check Timeout time.Duration + // DryRun prints the pull plan without downloading any image blobs + DryRun bool } type Service struct { @@ -242,6 +244,11 @@ func (svc *Service) pullModules(ctx context.Context) error { return err } + // Skip OCI layout post-processing in dry-run (layouts are empty) + if svc.options.DryRun { + return nil + } + err = logger.Process("Processing modules image indexes", func() error { for _, l := range svc.layout.AsList() { err = layouts.SortIndexManifests(l) @@ -289,20 +296,22 @@ func (svc *Service) pullSingleModule(ctx context.Context, module moduleData) err downloadList.ModuleReleaseChannels[svc.rootURL+"/modules/"+module.name+"/release:"+channel] = nil } - // Pull release channels first to get version information - config := puller.PullConfig{ - Name: module.name + " release channels", - ImageSet: downloadList.ModuleReleaseChannels, - Layout: svc.layout.Module(module.name).ModulesReleaseChannels, - AllowMissingTags: true, - GetterService: svc.modulesService.Module(module.name).ReleaseChannels(), - } + if !svc.options.DryRun { + // Pull release channels first to get version information + config := puller.PullConfig{ + Name: module.name + " release channels", + ImageSet: downloadList.ModuleReleaseChannels, + Layout: svc.layout.Module(module.name).ModulesReleaseChannels, + AllowMissingTags: true, + GetterService: svc.modulesService.Module(module.name).ReleaseChannels(), + } - if err := svc.pullerService.PullImages(ctx, config); err != nil { - return fmt.Errorf("pull release channels: %w", err) + if err := svc.pullerService.PullImages(ctx, config); err != nil { + return fmt.Errorf("pull release channels: %w", err) + } } - // Extract versions from pulled release channels + // Extract versions from release channels (calls registry directly, works in dry-run) moduleVersions = svc.extractVersionsFromReleaseChannels(ctx, module.name) } @@ -321,6 +330,21 @@ func (svc *Service) pullSingleModule(ctx context.Context, module moduleData) err // Deduplicate versions moduleVersions = deduplicateStrings(moduleVersions) + // In dry-run mode: print what would be pulled and return without downloading blobs + if svc.options.DryRun { + svc.userLogger.InfoLn("[dry-run] Module '" + module.name + "' images that would be pulled:") + for ref := range downloadList.ModuleReleaseChannels { + svc.userLogger.InfoLn(" " + ref) + } + for _, version := range moduleVersions { + svc.userLogger.InfoLn(" " + svc.rootURL + "/modules/" + module.name + ":" + version) + } + if len(moduleVersions) > 0 { + svc.userLogger.InfoLn(" (extra images discovery requires a real pull)") + } + return nil + } + // Skip main module images if only pulling extra images if !svc.options.OnlyExtraImages { // Fill module images for each version diff --git a/internal/mirror/modules/modules_dryrun_test.go b/internal/mirror/modules/modules_dryrun_test.go new file mode 100644 index 00000000..df69bea0 --- /dev/null +++ b/internal/mirror/modules/modules_dryrun_test.go @@ -0,0 +1,99 @@ +/* +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 modules + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + "github.com/deckhouse/deckhouse-cli/pkg/stub" +) + +// TestDryRun_NoBundleFilesWritten verifies that PullModules in dry-run mode does +// not write any tar bundles to the bundle directory. +func TestDryRun_NoBundleFilesWritten(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullModules(context.Background()) + require.NoError(t, err) + + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write any files to the bundle directory; found: %v", entries) +} + +// TestDryRun_WorkingDirHasLayouts verifies that PullModules in dry-run mode +// creates OCI layout directories in the working directory (needed for module +// discovery) but does not pack anything into bundles. +func TestDryRun_WorkingDirHasLayouts(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullModules(context.Background()) + require.NoError(t, err) + + // Working dir should have something (module layout dirs) or at least not crash + // Bundle dir must remain empty + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write any bundle files; found: %v", entries) +} diff --git a/internal/mirror/platform/platform.go b/internal/mirror/platform/platform.go index 40e45e41..ab479a09 100644 --- a/internal/mirror/platform/platform.go +++ b/internal/mirror/platform/platform.go @@ -66,6 +66,8 @@ type Options struct { SkipVexImages bool // Timeout is the timeout for the platform access check Timeout time.Duration + // DryRun prints the pull plan without downloading any image blobs + DryRun bool } type Service struct { @@ -546,8 +548,12 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st return fmt.Errorf("pull standalone installers: %w", err) } - if err := svc.pullDeckhouseImages(ctx); err != nil { - return fmt.Errorf("pull deckhouse images: %w", err) + // In dry-run mode skip pulling main release blobs; we only need the installer + // image (already pulled above) to extract image_digest.json. + if !svc.options.DryRun { + if err := svc.pullDeckhouseImages(ctx); err != nil { + return fmt.Errorf("pull deckhouse images: %w", err) + } } return nil @@ -557,7 +563,8 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st } // We should not generate deckhousereleases.yaml manifest for tag-based pulls - if svc.options.TargetTag == "" { + // and never write to bundleDir in dry-run mode. + if svc.options.TargetTag == "" && !svc.options.DryRun { if err = svc.generateDeckhouseReleaseManifests(tagsToMirror); err != nil { logger.WarnLn(err.Error()) } @@ -580,6 +587,13 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st digests, err := svc.ExtractImageDigestsFromDeckhouseInstallerNew(tag, prevDigests) if err != nil { + if svc.options.DryRun { + // In dry-run mode the installer image may not contain a real images_digests.json + // (e.g. when using a stub registry). Warn and continue — the caller will still + // see the version/channel image refs that were resolved above. + svc.userLogger.Warnf("[dry-run] Could not extract images from installer %q: %v", tag, err) + continue + } return fmt.Errorf("extract images digests: %w", err) } @@ -588,6 +602,25 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st logger.Infof("Found %d images", len(svc.downloadList.Deckhouse)) + // In dry-run mode: print the complete image list (including any extras extracted + // from images_digests.json) and return without writing any bundle output. + if svc.options.DryRun { + svc.userLogger.InfoLn("[dry-run] Platform images that would be pulled:") + for ref := range svc.downloadList.Deckhouse { + svc.userLogger.InfoLn(" " + ref) + } + for ref := range svc.downloadList.DeckhouseReleaseChannel { + svc.userLogger.InfoLn(" " + ref) + } + for ref := range svc.downloadList.DeckhouseInstall { + svc.userLogger.InfoLn(" " + ref) + } + for ref := range svc.downloadList.DeckhouseInstallStandalone { + svc.userLogger.InfoLn(" " + ref) + } + return nil + } + if err = logger.Process("Pull Deckhouse images", func() error { if err := svc.pullDeckhouseImages(ctx); err != nil { return fmt.Errorf("pull deckhouse images: %w", err) diff --git a/internal/mirror/platform/platform_dryrun_test.go b/internal/mirror/platform/platform_dryrun_test.go new file mode 100644 index 00000000..2afef4d4 --- /dev/null +++ b/internal/mirror/platform/platform_dryrun_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 platform + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + "github.com/deckhouse/deckhouse-cli/pkg/stub" +) + +// TestDryRun_NoBundleFilesWritten verifies that PullPlatform in dry-run mode does +// not write any files to the bundle directory. Temporary OCI layout data may only +// land under the working/tmp directory. +func TestDryRun_NoBundleFilesWritten(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() // must stay empty after dry-run + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + TargetTag: "v1.69.0", + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullPlatform(context.Background()) + require.NoError(t, err) + + // bundleDir must contain nothing after dry-run + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write any files to the bundle directory; found: %v", entries) +} + +// TestDryRun_InstallerPulledToTmpDir verifies that in dry-run mode the installer +// image IS pulled into the working (tmp) directory so that images_digests.json can +// be read from it. This produces the complete list of images that would be +// downloaded in a real run. +func TestDryRun_InstallerPulledToTmpDir(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + TargetTag: "v1.69.0", + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullPlatform(context.Background()) + require.NoError(t, err) + + // The installer OCI layout directory must exist under workingDir, proving + // that pullInstallers was executed (so images_digests.json extraction was + // attempted). + installerLayoutDir := filepath.Join(workingDir, "platform", "install") + _, statErr := os.Stat(installerLayoutDir) + assert.NoError(t, statErr, "installer OCI layout must be created in tmpDir during dry-run; dir: %s", installerLayoutDir) + + // bundleDir must remain empty – no platform.tar, no deckhousereleases.yaml + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write any files to the bundle directory; found: %v", entries) +} diff --git a/internal/mirror/pull.go b/internal/mirror/pull.go index f48930f7..1798eed8 100644 --- a/internal/mirror/pull.go +++ b/internal/mirror/pull.go @@ -57,6 +57,8 @@ type PullServiceOptions struct { SkipVexImages bool // Timeout is the timeout for the pull operation Timeout time.Duration + // DryRun prints the pull plan without downloading any image blobs + DryRun bool } type PullService struct { @@ -103,6 +105,7 @@ func NewPullService( IgnoreSuspend: options.IgnoreSuspend, SkipVexImages: options.SkipVexImages, Timeout: options.Timeout, + DryRun: options.DryRun, }, logger, userLogger, @@ -114,6 +117,7 @@ func NewPullService( BundleDir: options.BundleDir, BundleChunkSize: options.BundleChunkSize, Timeout: options.Timeout, + DryRun: options.DryRun, }, logger, userLogger, @@ -128,6 +132,7 @@ func NewPullService( BundleDir: options.BundleDir, BundleChunkSize: options.BundleChunkSize, Timeout: options.Timeout, + DryRun: options.DryRun, }, logger, userLogger, @@ -140,6 +145,7 @@ func NewPullService( BundleDir: options.BundleDir, BundleChunkSize: options.BundleChunkSize, Timeout: options.Timeout, + DryRun: options.DryRun, }, logger, userLogger, diff --git a/internal/mirror/security/security.go b/internal/mirror/security/security.go index 0cf93316..f12a1b52 100644 --- a/internal/mirror/security/security.go +++ b/internal/mirror/security/security.go @@ -45,6 +45,8 @@ type Options struct { BundleChunkSize int64 // Timeout is the timeout for the security access check Timeout time.Duration + // DryRun prints the pull plan without downloading any image blobs + DryRun bool } type Service struct { @@ -140,6 +142,16 @@ func (svc *Service) pullSecurityDatabases(ctx context.Context) error { // Fill download list with security images svc.downloadList.FillSecurityImages() + if svc.options.DryRun { + svc.userLogger.InfoLn("[dry-run] Security database images that would be pulled:") + for _, imageSet := range svc.downloadList.Security { + for ref := range imageSet { + svc.userLogger.InfoLn(" " + ref) + } + } + return nil + } + err := logger.Process("Pull Security Databases", func() error { for securityName, imageSet := range svc.downloadList.Security { config := puller.PullConfig{ diff --git a/internal/mirror/security/security_dryrun_test.go b/internal/mirror/security/security_dryrun_test.go new file mode 100644 index 00000000..0057acd2 --- /dev/null +++ b/internal/mirror/security/security_dryrun_test.go @@ -0,0 +1,103 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package security + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + dkplog "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/deckhouse-cli/pkg" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" + registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + "github.com/deckhouse/deckhouse-cli/pkg/stub" +) + +// TestDryRun_NoBundleFilesWritten verifies that PullSecurity in dry-run mode does +// not write any tar bundles to the bundle directory. +func TestDryRun_NoBundleFilesWritten(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullSecurity(context.Background()) + require.NoError(t, err) + + entries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, entries, "dry-run must not write any files to the bundle directory; found: %v", entries) +} + +// TestDryRun_WorkingDirHasLayouts verifies that PullSecurity in dry-run mode +// creates OCI layout directories under the working directory but does not +// pack anything into bundles. +func TestDryRun_WorkingDirHasLayouts(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := stub.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + err := svc.PullSecurity(context.Background()) + require.NoError(t, err) + + // Security OCI layouts should be created under workingDir + entries, err := os.ReadDir(workingDir) + require.NoError(t, err) + assert.NotEmpty(t, entries, "security OCI layouts should be created in working dir during dry-run") + + // Bundle dir must remain empty + bundleEntries, err := os.ReadDir(bundleDir) + require.NoError(t, err) + assert.Empty(t, bundleEntries, "dry-run must not write any bundle files; found: %v", bundleEntries) +} From 1435d48d725daaa1fd0925ad9e8cbbf71e9cf8c7 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 8 Apr 2026 11:52:03 +0300 Subject: [PATCH 2/6] Optimize dry-run to stream installer metadata instead of full OCI pull - Stream images_digests.json directly from remote registry using ExtractFileFromImage (layer-by-layer), avoiding full installer image download to disk - Skip OCI layout creation, release channel pulls, and standalone installer pulls in dry-run mode - none are needed for image list resolution - Separate dry-run logic into dedicated path (pullDeckhousePlatformDryRun) keeping the normal pull path free of conditional checks - Remove implicit VEX scanning from dry-run - (~319 unnecessary network calls) Signed-off-by: Roman Berezkin --- internal/mirror/platform/platform.go | 153 ++++++++++++++++++++------- 1 file changed, 115 insertions(+), 38 deletions(-) diff --git a/internal/mirror/platform/platform.go b/internal/mirror/platform/platform.go index ab479a09..0a4bdb26 100644 --- a/internal/mirror/platform/platform.go +++ b/internal/mirror/platform/platform.go @@ -40,6 +40,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/mirror/chunked" "github.com/deckhouse/deckhouse-cli/internal/mirror/puller" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/images" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/layouts" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" "github.com/deckhouse/deckhouse-cli/pkg/registry/image" @@ -104,10 +105,15 @@ func NewService( tmpDir := filepath.Join(workingDir, "platform") - layout, err := createOCIImageLayoutsForPlatform(tmpDir) - if err != nil { - //TODO: handle error - userLogger.Warnf("Create OCI Image Layouts: %v", err) + // layout is nil in dry-run mode; pullDeckhousePlatformDryRun does not use it. + var layout *ImageLayouts + if !options.DryRun { + var err error + layout, err = createOCIImageLayoutsForPlatform(tmpDir) + if err != nil { + //TODO: handle error + userLogger.Warnf("Create OCI Image Layouts: %v", err) + } } rootURL := registryService.GetRoot() @@ -533,6 +539,10 @@ func (svc *Service) getReleaseChannelVersionFromRegistry(ctx context.Context, re } func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []string) error { + if svc.options.DryRun { + return svc.pullDeckhousePlatformDryRun(ctx, tagsToMirror) + } + logger := svc.userLogger err := logger.Process("Pull release channels and installers", func() error { @@ -548,12 +558,8 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st return fmt.Errorf("pull standalone installers: %w", err) } - // In dry-run mode skip pulling main release blobs; we only need the installer - // image (already pulled above) to extract image_digest.json. - if !svc.options.DryRun { - if err := svc.pullDeckhouseImages(ctx); err != nil { - return fmt.Errorf("pull deckhouse images: %w", err) - } + if err := svc.pullDeckhouseImages(ctx); err != nil { + return fmt.Errorf("pull deckhouse images: %w", err) } return nil @@ -563,8 +569,7 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st } // We should not generate deckhousereleases.yaml manifest for tag-based pulls - // and never write to bundleDir in dry-run mode. - if svc.options.TargetTag == "" && !svc.options.DryRun { + if svc.options.TargetTag == "" { if err = svc.generateDeckhouseReleaseManifests(tagsToMirror); err != nil { logger.WarnLn(err.Error()) } @@ -587,13 +592,6 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st digests, err := svc.ExtractImageDigestsFromDeckhouseInstallerNew(tag, prevDigests) if err != nil { - if svc.options.DryRun { - // In dry-run mode the installer image may not contain a real images_digests.json - // (e.g. when using a stub registry). Warn and continue — the caller will still - // see the version/channel image refs that were resolved above. - svc.userLogger.Warnf("[dry-run] Could not extract images from installer %q: %v", tag, err) - continue - } return fmt.Errorf("extract images digests: %w", err) } @@ -602,25 +600,6 @@ func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []st logger.Infof("Found %d images", len(svc.downloadList.Deckhouse)) - // In dry-run mode: print the complete image list (including any extras extracted - // from images_digests.json) and return without writing any bundle output. - if svc.options.DryRun { - svc.userLogger.InfoLn("[dry-run] Platform images that would be pulled:") - for ref := range svc.downloadList.Deckhouse { - svc.userLogger.InfoLn(" " + ref) - } - for ref := range svc.downloadList.DeckhouseReleaseChannel { - svc.userLogger.InfoLn(" " + ref) - } - for ref := range svc.downloadList.DeckhouseInstall { - svc.userLogger.InfoLn(" " + ref) - } - for ref := range svc.downloadList.DeckhouseInstallStandalone { - svc.userLogger.InfoLn(" " + ref) - } - return nil - } - if err = logger.Process("Pull Deckhouse images", func() error { if err := svc.pullDeckhouseImages(ctx); err != nil { return fmt.Errorf("pull deckhouse images: %w", err) @@ -747,6 +726,104 @@ func (svc *Service) pullDeckhouseImages(ctx context.Context) error { return svc.pullerService.PullImages(ctx, config) } +// pullDeckhousePlatformDryRun resolves and prints platform images without pulling +// any blobs to disk. It streams images_digests.json directly from the remote +// installer image using ExtractFileFromImage (layer-by-layer, no OCI layout needed). +func (svc *Service) pullDeckhousePlatformDryRun(ctx context.Context, tagsToMirror []string) error { + logger := svc.userLogger + + logger.Infof("Searching for Deckhouse built-in modules digests") + + var prevDigests = make(map[string]struct{}) + for _, tag := range tagsToMirror { + logger.Infof("[dry-run] Streaming installer metadata for %s from registry", tag) + + digests, err := svc.extractImageDigestsFromRemote(ctx, tag, prevDigests) + if err != nil { + logger.Warnf("[dry-run] Could not extract images from installer %q: %v", tag, err) + continue + } + + maps.Copy(svc.downloadList.Deckhouse, digests) + } + + logger.Infof("Found %d images", len(svc.downloadList.Deckhouse)) + + svc.userLogger.InfoLn("[dry-run] Platform images that would be pulled:") + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.Deckhouse)) { + svc.userLogger.InfoLn(" " + ref) + } + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseReleaseChannel)) { + svc.userLogger.InfoLn(" " + ref) + } + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseInstall)) { + svc.userLogger.InfoLn(" " + ref) + } + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseInstallStandalone)) { + svc.userLogger.InfoLn(" " + ref) + } + return nil +} + +// extractImageDigestsFromRemote streams images_digests.json (or images_tags.json) +// directly from the remote installer image without saving the image to disk. +// Uses ExtractFileFromImage which downloads only the layer containing the target file. +func (svc *Service) extractImageDigestsFromRemote( + ctx context.Context, + tag string, + prevDigests map[string]struct{}, +) (map[string]*puller.ImageMeta, error) { + img, err := svc.deckhouseService.Installer().GetImage(ctx, tag) + if err != nil { + return nil, fmt.Errorf("get remote installer image %q: %w", tag, err) + } + + rootURL := svc.deckhouseService.GetRoot() + result := make(map[string]*puller.ImageMeta) + + // Try images_tags.json first (preferred) + tagsFile, err := images.ExtractFileFromImage(img, imagesTagsFile) + if err == nil && tagsFile.Len() > 0 { + var tags map[string]map[string]string + if err := json.NewDecoder(tagsFile).Decode(&tags); err != nil { + return nil, fmt.Errorf("decode %s: %w", imagesTagsFile, err) + } + for _, nameTagTuple := range tags { + for _, imageID := range nameTagTuple { + ref := rootURL + ":" + imageID + if _, ok := prevDigests[ref]; !ok { + prevDigests[ref] = struct{}{} + result[ref] = nil + } + } + } + svc.userLogger.Infof("Deckhouse digests found: %d", len(result)) + return result, nil + } + + // Fallback: images_digests.json + digestsFile, err := images.ExtractFileFromImage(img, imagesDigestsFile) + if err != nil { + return nil, fmt.Errorf("extract %s from installer %q: %w", imagesDigestsFile, tag, err) + } + var digests map[string]map[string]string + if err := json.NewDecoder(digestsFile).Decode(&digests); err != nil { + return nil, fmt.Errorf("decode %s: %w", imagesDigestsFile, err) + } + for _, nameDigestTuple := range digests { + for _, imageID := range nameDigestTuple { + ref := rootURL + "@" + imageID + if _, ok := prevDigests[ref]; !ok { + prevDigests[ref] = struct{}{} + result[ref] = nil + } + } + } + + svc.userLogger.Infof("Deckhouse digests found: %d", len(result)) + return result, nil +} + func (svc *Service) generateDeckhouseReleaseManifests( tagsToMirror []string, ) error { From 9bf4cbe3bbf3b1939a9bb078415980095c6e7df3 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 8 Apr 2026 11:53:16 +0300 Subject: [PATCH 3/6] Extract dry-run platform logic into dedicated file - Dry-run methods (pullDeckhousePlatformDryRun, extractImageDigestsFromRemote) live in platform_dryrun.go for clear separation from the normal pull path - Grouped dry-run output with per-category headers and sorted image refs Signed-off-by: Roman Berezkin --- internal/mirror/platform/platform.go | 99 -------------- internal/mirror/platform/platform_dryrun.go | 139 ++++++++++++++++++++ 2 files changed, 139 insertions(+), 99 deletions(-) create mode 100644 internal/mirror/platform/platform_dryrun.go diff --git a/internal/mirror/platform/platform.go b/internal/mirror/platform/platform.go index 0a4bdb26..1a6147f9 100644 --- a/internal/mirror/platform/platform.go +++ b/internal/mirror/platform/platform.go @@ -40,7 +40,6 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/mirror/chunked" "github.com/deckhouse/deckhouse-cli/internal/mirror/puller" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" - "github.com/deckhouse/deckhouse-cli/pkg/libmirror/images" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/layouts" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" "github.com/deckhouse/deckhouse-cli/pkg/registry/image" @@ -726,104 +725,6 @@ func (svc *Service) pullDeckhouseImages(ctx context.Context) error { return svc.pullerService.PullImages(ctx, config) } -// pullDeckhousePlatformDryRun resolves and prints platform images without pulling -// any blobs to disk. It streams images_digests.json directly from the remote -// installer image using ExtractFileFromImage (layer-by-layer, no OCI layout needed). -func (svc *Service) pullDeckhousePlatformDryRun(ctx context.Context, tagsToMirror []string) error { - logger := svc.userLogger - - logger.Infof("Searching for Deckhouse built-in modules digests") - - var prevDigests = make(map[string]struct{}) - for _, tag := range tagsToMirror { - logger.Infof("[dry-run] Streaming installer metadata for %s from registry", tag) - - digests, err := svc.extractImageDigestsFromRemote(ctx, tag, prevDigests) - if err != nil { - logger.Warnf("[dry-run] Could not extract images from installer %q: %v", tag, err) - continue - } - - maps.Copy(svc.downloadList.Deckhouse, digests) - } - - logger.Infof("Found %d images", len(svc.downloadList.Deckhouse)) - - svc.userLogger.InfoLn("[dry-run] Platform images that would be pulled:") - for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.Deckhouse)) { - svc.userLogger.InfoLn(" " + ref) - } - for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseReleaseChannel)) { - svc.userLogger.InfoLn(" " + ref) - } - for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseInstall)) { - svc.userLogger.InfoLn(" " + ref) - } - for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseInstallStandalone)) { - svc.userLogger.InfoLn(" " + ref) - } - return nil -} - -// extractImageDigestsFromRemote streams images_digests.json (or images_tags.json) -// directly from the remote installer image without saving the image to disk. -// Uses ExtractFileFromImage which downloads only the layer containing the target file. -func (svc *Service) extractImageDigestsFromRemote( - ctx context.Context, - tag string, - prevDigests map[string]struct{}, -) (map[string]*puller.ImageMeta, error) { - img, err := svc.deckhouseService.Installer().GetImage(ctx, tag) - if err != nil { - return nil, fmt.Errorf("get remote installer image %q: %w", tag, err) - } - - rootURL := svc.deckhouseService.GetRoot() - result := make(map[string]*puller.ImageMeta) - - // Try images_tags.json first (preferred) - tagsFile, err := images.ExtractFileFromImage(img, imagesTagsFile) - if err == nil && tagsFile.Len() > 0 { - var tags map[string]map[string]string - if err := json.NewDecoder(tagsFile).Decode(&tags); err != nil { - return nil, fmt.Errorf("decode %s: %w", imagesTagsFile, err) - } - for _, nameTagTuple := range tags { - for _, imageID := range nameTagTuple { - ref := rootURL + ":" + imageID - if _, ok := prevDigests[ref]; !ok { - prevDigests[ref] = struct{}{} - result[ref] = nil - } - } - } - svc.userLogger.Infof("Deckhouse digests found: %d", len(result)) - return result, nil - } - - // Fallback: images_digests.json - digestsFile, err := images.ExtractFileFromImage(img, imagesDigestsFile) - if err != nil { - return nil, fmt.Errorf("extract %s from installer %q: %w", imagesDigestsFile, tag, err) - } - var digests map[string]map[string]string - if err := json.NewDecoder(digestsFile).Decode(&digests); err != nil { - return nil, fmt.Errorf("decode %s: %w", imagesDigestsFile, err) - } - for _, nameDigestTuple := range digests { - for _, imageID := range nameDigestTuple { - ref := rootURL + "@" + imageID - if _, ok := prevDigests[ref]; !ok { - prevDigests[ref] = struct{}{} - result[ref] = nil - } - } - } - - svc.userLogger.Infof("Deckhouse digests found: %d", len(result)) - return result, nil -} - func (svc *Service) generateDeckhouseReleaseManifests( tagsToMirror []string, ) error { diff --git a/internal/mirror/platform/platform_dryrun.go b/internal/mirror/platform/platform_dryrun.go new file mode 100644 index 00000000..c54ef61a --- /dev/null +++ b/internal/mirror/platform/platform_dryrun.go @@ -0,0 +1,139 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package platform + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + + "github.com/deckhouse/deckhouse-cli/internal/mirror/puller" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/images" +) + +// pullDeckhousePlatformDryRun resolves and prints platform images without pulling +// any blobs to disk. It streams images_digests.json directly from the remote +// installer image using ExtractFileFromImage (layer-by-layer, no OCI layout needed). +func (svc *Service) pullDeckhousePlatformDryRun(ctx context.Context, tagsToMirror []string) error { + logger := svc.userLogger + + logger.Infof("Searching for Deckhouse built-in modules digests") + + var prevDigests = make(map[string]struct{}) + for _, tag := range tagsToMirror { + logger.Infof("[dry-run] Streaming installer metadata for %s from registry", tag) + + digests, err := svc.extractImageDigestsFromRemote(ctx, tag, prevDigests) + if err != nil { + logger.Warnf("[dry-run] Could not extract images from installer %q: %v", tag, err) + continue + } + + maps.Copy(svc.downloadList.Deckhouse, digests) + } + + totalImages := len(svc.downloadList.Deckhouse) + + len(svc.downloadList.DeckhouseReleaseChannel) + + len(svc.downloadList.DeckhouseInstall) + + len(svc.downloadList.DeckhouseInstallStandalone) + + svc.userLogger.InfoLn("[dry-run] Platform images that would be pulled:") + + svc.userLogger.Infof(" Deckhouse components: %d images", len(svc.downloadList.Deckhouse)) + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.Deckhouse)) { + svc.userLogger.InfoLn(" " + ref) + } + + svc.userLogger.Infof(" Release channels: %d", len(svc.downloadList.DeckhouseReleaseChannel)) + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseReleaseChannel)) { + svc.userLogger.InfoLn(" " + ref) + } + + svc.userLogger.Infof(" Installer: %d", len(svc.downloadList.DeckhouseInstall)) + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseInstall)) { + svc.userLogger.InfoLn(" " + ref) + } + + svc.userLogger.Infof(" Standalone installer: %d", len(svc.downloadList.DeckhouseInstallStandalone)) + for _, ref := range slices.Sorted(maps.Keys(svc.downloadList.DeckhouseInstallStandalone)) { + svc.userLogger.InfoLn(" " + ref) + } + + svc.userLogger.Infof(" Total: %d platform images", totalImages) + return nil +} + +// extractImageDigestsFromRemote streams images_digests.json (or images_tags.json) +// directly from the remote installer image without saving the image to disk. +// Uses ExtractFileFromImage which downloads only the layer containing the target file. +func (svc *Service) extractImageDigestsFromRemote( + ctx context.Context, + tag string, + prevDigests map[string]struct{}, +) (map[string]*puller.ImageMeta, error) { + img, err := svc.deckhouseService.Installer().GetImage(ctx, tag) + if err != nil { + return nil, fmt.Errorf("get remote installer image %q: %w", tag, err) + } + + rootURL := svc.deckhouseService.GetRoot() + result := make(map[string]*puller.ImageMeta) + + // Try images_tags.json first (preferred) + tagsFile, err := images.ExtractFileFromImage(img, imagesTagsFile) + if err == nil && tagsFile.Len() > 0 { + var tags map[string]map[string]string + if err := json.NewDecoder(tagsFile).Decode(&tags); err != nil { + return nil, fmt.Errorf("decode %s: %w", imagesTagsFile, err) + } + for _, nameTagTuple := range tags { + for _, imageID := range nameTagTuple { + ref := rootURL + ":" + imageID + if _, ok := prevDigests[ref]; !ok { + prevDigests[ref] = struct{}{} + result[ref] = nil + } + } + } + svc.userLogger.Infof("Deckhouse digests found: %d", len(result)) + return result, nil + } + + // Fallback: images_digests.json + digestsFile, err := images.ExtractFileFromImage(img, imagesDigestsFile) + if err != nil { + return nil, fmt.Errorf("extract %s from installer %q: %w", imagesDigestsFile, tag, err) + } + var digests map[string]map[string]string + if err := json.NewDecoder(digestsFile).Decode(&digests); err != nil { + return nil, fmt.Errorf("decode %s: %w", imagesDigestsFile, err) + } + for _, nameDigestTuple := range digests { + for _, imageID := range nameDigestTuple { + ref := rootURL + "@" + imageID + if _, ok := prevDigests[ref]; !ok { + prevDigests[ref] = struct{}{} + result[ref] = nil + } + } + } + + svc.userLogger.Infof("Deckhouse digests found: %d", len(result)) + return result, nil +} From dc0da5d7747f2d1c5b803b0ee8ea5718f2f2facc Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 8 Apr 2026 11:56:27 +0300 Subject: [PATCH 4/6] Remove no-op test Signed-off-by: Roman Berezkin --- internal/mirror/cmd/pull/pull_dryrun_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/internal/mirror/cmd/pull/pull_dryrun_test.go b/internal/mirror/cmd/pull/pull_dryrun_test.go index c0045921..19fda85d 100644 --- a/internal/mirror/cmd/pull/pull_dryrun_test.go +++ b/internal/mirror/cmd/pull/pull_dryrun_test.go @@ -273,14 +273,3 @@ func TestDryRunExitsZeroOnSuccess(t *testing.T) { err := puller.Execute(ctx) assert.NoError(t, err, "dry-run must exit with code 0 on success") } - -// TestDryRunFlagInOptions verifies DryRun field is included in PullServiceOptions literal. -func TestDryRunFlagInOptions(t *testing.T) { - defer saveFlagsAndRestore(t)() - - pullflags.DryRun = true - assert.True(t, pullflags.DryRun) - - pullflags.DryRun = false - assert.False(t, pullflags.DryRun) -} From d542a4e8fb3a7c5a9e344be0d9a9eb94690b7ca8 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 8 Apr 2026 11:57:53 +0300 Subject: [PATCH 5/6] Better explanation in comment Signed-off-by: Roman Berezkin --- internal/mirror/modules/modules.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mirror/modules/modules.go b/internal/mirror/modules/modules.go index b6d129fc..cd4e9b35 100644 --- a/internal/mirror/modules/modules.go +++ b/internal/mirror/modules/modules.go @@ -311,7 +311,9 @@ func (svc *Service) pullSingleModule(ctx context.Context, module moduleData) err } } - // Extract versions from release channels (calls registry directly, works in dry-run) + // Extract versions from release channels. + // Does not depend on PullImages above - calls GetImage() directly + // against the remote registry, not from the local OCI layout. moduleVersions = svc.extractVersionsFromReleaseChannels(ctx, module.name) } From 234a84ccfe2a096a88c5303368bf68988ed84f96 Mon Sep 17 00:00:00 2001 From: Roman Berezkin Date: Wed, 8 Apr 2026 14:03:22 +0300 Subject: [PATCH 6/6] Small test fix after optimization Signed-off-by: Roman Berezkin --- internal/mirror/platform/platform_dryrun_test.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/mirror/platform/platform_dryrun_test.go b/internal/mirror/platform/platform_dryrun_test.go index 2afef4d4..40bc7a30 100644 --- a/internal/mirror/platform/platform_dryrun_test.go +++ b/internal/mirror/platform/platform_dryrun_test.go @@ -97,14 +97,13 @@ func TestDryRun_InstallerPulledToTmpDir(t *testing.T) { err := svc.PullPlatform(context.Background()) require.NoError(t, err) - // The installer OCI layout directory must exist under workingDir, proving - // that pullInstallers was executed (so images_digests.json extraction was - // attempted). + // In optimized dry-run, no OCI layout is created - images_digests.json is + // streamed directly from the remote registry via ExtractFileFromImage. installerLayoutDir := filepath.Join(workingDir, "platform", "install") _, statErr := os.Stat(installerLayoutDir) - assert.NoError(t, statErr, "installer OCI layout must be created in tmpDir during dry-run; dir: %s", installerLayoutDir) + assert.ErrorIs(t, statErr, os.ErrNotExist, "installer OCI layout must NOT be created in dry-run; dir: %s", installerLayoutDir) - // bundleDir must remain empty – no platform.tar, no deckhousereleases.yaml + // bundleDir must remain empty entries, err := os.ReadDir(bundleDir) require.NoError(t, err) assert.Empty(t, entries, "dry-run must not write any files to the bundle directory; found: %v", entries)