diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index b4306fc7..b0181bdd 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -52,8 +52,9 @@ d8 mirror pull [flags] | Flag | Description | |------|-------------| -| `--since-version` | Minimal Deckhouse release to pull. Ignored if above current Rock Solid release. Conflicts with `--deckhouse-tag` | -| `--deckhouse-tag` | Specific Deckhouse build tag to pull. Conflicts with `--since-version`. If the registry contains a release channel image for the specified tag, all release channels in the bundle will point to it | +| `--since-version` | Minimal Deckhouse release to pull (lower bound, inclusive). Ignored if above current Rock Solid release. Conflicts with `--deckhouse-tag` and `--include-platform` | +| `--include-platform` | Select platform releases by semver constraint (e.g. `">=1.64 <=1.68"`). Uses the same constraint dialect as `--include-module`. Conflicts with `--deckhouse-tag` and `--since-version`. See [Platform Version Filtering](#platform-version-filtering) | +| `--deckhouse-tag` | Specific Deckhouse build tag to pull. Conflicts with `--since-version` and `--include-platform`. If the registry contains a release channel image for the specified tag, all release channels in the bundle will point to it | #### Module Filtering @@ -91,6 +92,53 @@ d8 mirror pull [flags] | `--insecure` | Interact with registries over HTTP | | `--tmp-dir` | Path to temporary directory for processing. Ensure sufficient disk space for the entire bundle | +### Platform Version Filtering + +The `--include-platform` flag accepts a semver constraint expression to pull only a specific window of platform releases. It uses exactly the same constraint dialect as the version part of `--include-module`. + +#### When to use + +| Goal | Recommended flag | +|------|-----------------| +| Pull all releases from a given minimum upward | `--since-version 1.64.0` | +| Pull a bounded range (e.g. for an incremental update) | `--include-platform ">=1.64 <=1.68"` | +| Pull every release of a single minor | `--include-platform "~1.65.0"` | +| Pin a single exact release (same as `--deckhouse-tag`) | `--include-platform "=v1.65.3"` | + +#### Constraint syntax + +The same rules apply as for `--include-module` version constraints (see [Module Filtering](#module-filtering)): + +- **Latest-patch-per-minor collapsing** — for semver ranges, only the highest patch in each `(major, minor)` bucket is pulled. Inclusive boundary operators (`>=`, `<=`) always preserve the named version as an anchor even when a newer patch exists. +- **Channel filtering** — release channels whose current snapshot points outside the constraint window are dropped from the bundle so no entry references an image that was not downloaded. +- **Exact-tag form (`=`)** — operationally identical to `--deckhouse-tag`: a single tag is pulled and all default release channels in the bundle are pointed at it. + +#### Examples + +```bash +# Pull releases v1.64.x through v1.68.x (incremental update scenario) +d8 mirror pull /tmp/d8-bundle \ + --license $LICENSE_TOKEN \ + --include-platform ">=1.64 <=1.68" + +# Pull only the latest patch of v1.65.x +d8 mirror pull /tmp/d8-bundle \ + --license $LICENSE_TOKEN \ + --include-platform "~1.65.0" + +# Pull every minor from v1.65 onward that the registry exposes +d8 mirror pull /tmp/d8-bundle \ + --license $LICENSE_TOKEN \ + --include-platform "^1.65.0" + +# Pin an exact release (all channels point to v1.65.3) +d8 mirror pull /tmp/d8-bundle \ + --license $LICENSE_TOKEN \ + --include-platform "=v1.65.3" +``` + +--- + ### Module Filtering The `--include-module` and `--exclude-module` flags support version constraints for fine-grained control over which module versions to include. @@ -166,6 +214,11 @@ module-name[@version-constraint] # Download all modules d8 mirror pull /tmp/d8-bundle --license $LICENSE_TOKEN +# Download a bounded range of platform releases (e.g. v1.64.x – v1.68.x) +d8 mirror pull /tmp/d8-bundle \ + --license $LICENSE_TOKEN \ + --include-platform ">=1.64 <=1.68" + # Download only specific modules d8 mirror pull /tmp/d8-bundle \ --license $LICENSE_TOKEN \ @@ -177,6 +230,12 @@ d8 mirror pull /tmp/d8-bundle \ --license $LICENSE_TOKEN \ --include-module neuvector@^1.2.0 +# Combine platform range with a module filter +d8 mirror pull /tmp/d8-bundle \ + --license $LICENSE_TOKEN \ + --include-platform ">=1.64 <=1.68" \ + --include-module neuvector + # Exclude specific modules d8 mirror pull /tmp/d8-bundle \ --license $LICENSE_TOKEN \ @@ -301,12 +360,20 @@ The same environment variables used by `d8 mirror pull` are also supported: 1. **On a machine with internet access**, download the bundle: ```bash + # Full pull from a minimum version d8 mirror pull /tmp/deckhouse-bundle \ --license $DECKHOUSE_LICENSE_TOKEN \ --since-version 1.59.0 \ --include-module prometheus \ --include-module ingress-nginx \ --images-bundle-chunk-size 10 + + # Or pull only a specific version window for an incremental update + d8 mirror pull /tmp/deckhouse-bundle \ + --license $DECKHOUSE_LICENSE_TOKEN \ + --include-platform ">=1.64 <=1.68" \ + --include-module prometheus \ + --include-module ingress-nginx ``` 2. **Transfer the bundle** to the air-gapped environment (USB drive, secure file transfer, etc.) @@ -394,10 +461,11 @@ d8 mirror pull /tmp/bundle --license $LICENSE_TOKEN ### Version Management -1. **Use `--since-version`:** To pull only newer releases, reducing bundle size -2. **Specific Versions:** Use `--deckhouse-tag` for controlled, predictable deployments -3. **Module Versioning:** Leverage semver constraints for flexible module version management -4. **Release Channels:** Understand that pulling includes all configured release channels (alpha, beta, early-access, stable, rock-solid) +1. **Use `--since-version`:** To pull all releases from a given minimum upward, reducing bundle size relative to a full pull +2. **Use `--include-platform`:** To pull a bounded window of releases (e.g. `">=1.64 <=1.68"`) — useful for staged incremental updates where you want to move up a few minors at a time without pulling the entire channel history +3. **Specific Versions:** Use `--deckhouse-tag` (or `--include-platform "=vX.Y.Z"`) for fully controlled, single-release deployments +4. **Module Versioning:** Leverage semver constraints in `--include-module` for flexible module version management; `--include-platform` speaks the same dialect +5. **Release Channels:** Pulling respects all configured release channels (alpha, beta, early-access, stable, rock-solid); channels whose snapshot falls outside an `--include-platform` constraint are automatically excluded from the bundle ### Bundle Management @@ -418,11 +486,19 @@ The mirror system handles three primary component types: 2. **Modules:** Optional Deckhouse modules with versioned releases 3. **Security Databases:** Vulnerability scanning databases and related data +### Platform Release Discovery + +By default, the platform service discovers releases between the current `rock-solid` channel version (lower bound) and the current `alpha` channel version (upper bound), keeping the highest patch per `(major, minor)`. + +`--since-version` raises the lower bound above `rock-solid` when the user wants to skip older minors. + +`--include-platform` replaces this window with a user-supplied semver constraint, applying the same latest-patch-per-minor and inclusive-anchor rules as module version filtering. Channel snapshots outside the constraint are pruned so the bundle stays internally consistent. + ### Module Filtering Logic - **Whitelist Mode:** Only specified modules are included - **Blacklist Mode:** All modules except specified ones are included (default) -- **Version Constraints:** Semver-based filtering for granular control +- **Version Constraints:** Semver-based filtering for granular control (same dialect as `--include-platform`) - **Release Channels:** Each module can have multiple release channel versions ### Storage Layout diff --git a/internal/mirror/cmd/pull/flags/flags.go b/internal/mirror/cmd/pull/flags/flags.go index d4cdeb7d..7959523e 100644 --- a/internal/mirror/cmd/pull/flags/flags.go +++ b/internal/mirror/cmd/pull/flags/flags.go @@ -23,6 +23,8 @@ import ( "github.com/Masterminds/semver/v3" "github.com/spf13/pflag" + + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" ) const ( @@ -46,6 +48,9 @@ var ( SinceVersionString string SinceVersion *semver.Version + PlatformConstraintString string + PlatformConstraint modules.VersionConstraint + DeckhouseTag string InstallerTag string @@ -106,11 +111,36 @@ func AddFlags(flagSet *pflag.FlagSet) { "", "Minimal Deckhouse release to pull. Ignored if above current Rock Solid release. Conflicts with --deckhouse-tag.", ) + flagSet.StringVar( + &PlatformConstraintString, + "include-platform", + "", + `Select platform releases to download by a semver constraint expression, using the same dialect as --include-module's version part. +Conflicts with --since-version and --deckhouse-tag. + +Semver constraints (caret, tilde, range) keep only the highest patch in each (major, minor) series, mirroring the release-discovery rules used for full pulls. +Versions explicitly named with an inclusive boundary operator (>= or <=) are always preserved — that boundary is part of the user's request and must round-trip even when a newer patch exists in the same minor. +Use the exact-tag form (=) when you need to pin a specific tag, optionally propagating it to a release channel via the +channel suffix. + +Examples (available platform versions: v1.63.x, v1.64.x, v1.65.x, v1.66.x, v1.67.x, v1.68.x, v1.69.x, v1.70.x, v1.71.x): + +--include-platform ">=1.64 <=1.68" → bounded range: latest patch per minor in v1.64..v1.68, anchors v1.64.0 and v1.68.0 always preserved if present in the registry. + +--include-platform "~1.65.0" → semver ~ constraint (>=1.65.0 <1.66.0): latest v1.65.x patch only. + +--include-platform "^1.65.0" → semver ^ constraint (>=1.65.0 <2.0.0): latest patch per minor starting at v1.65.x. + +--include-platform "1.65.0" → implicit caret (^1.65.0): same as above; shorthand kept for parity with --include-module. + +--include-platform "=v1.65.3" → exact-tag pin: only v1.65.3 is pulled and propagated to all default release channels, just like --deckhouse-tag. + +--include-platform "=v1.65.3+stable" → exact-tag pin with channel suffix: only v1.65.3 is pulled (channel propagation matches --deckhouse-tag).`, + ) flagSet.StringVar( &DeckhouseTag, "deckhouse-tag", "", - "Specific Deckhouse build tag to pull. Conflicts with --since-version. If registry contains release channel image for specified tag, all release channels in the bundle will be pointed to it.", + "Specific Deckhouse build tag to pull. Conflicts with --since-version and --include-platform. If registry contains release channel image for specified tag, all release channels in the bundle will be pointed to it.", ) flagSet.StringVar( &InstallerTag, diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index 714d88ee..2e77c1aa 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -99,6 +99,8 @@ func NewCommand() *cobra.Command { pullflags.AddFlags(pullCmd.Flags()) pullCmd.MarkFlagsMutuallyExclusive("include-module", "exclude-module") + pullCmd.MarkFlagsMutuallyExclusive("include-platform", "deckhouse-tag") + pullCmd.MarkFlagsMutuallyExclusive("include-platform", "since-version") pullflags.ParseEnvironmentVariables() return pullCmd @@ -281,19 +283,20 @@ func (p *Puller) Execute(ctx context.Context) error { pullflags.TempDir, pullflags.DeckhouseTag, &mirror.PullServiceOptions{ - SkipPlatform: pullflags.NoPlatform, - SkipSecurity: pullflags.NoSecurityDB, - SkipModules: pullflags.NoModules, - SkipVexImages: pullflags.SkipVexImages, - SkipInstaller: pullflags.NoInstaller, - InstallerTag: pullflags.InstallerTag, - OnlyExtraImages: pullflags.OnlyExtraImages, - IgnoreSuspend: pullflags.IgnoreSuspend, - ModuleFilter: filter, - BundleDir: pullflags.ImagesBundlePath, - BundleChunkSize: pullflags.ImagesBundleChunkSizeGB * 1000 * 1000 * 1000, - Timeout: pullflags.MirrorTimeout, - DryRun: pullflags.DryRun, + SkipPlatform: pullflags.NoPlatform, + SkipSecurity: pullflags.NoSecurityDB, + SkipModules: pullflags.NoModules, + SkipVexImages: pullflags.SkipVexImages, + SkipInstaller: pullflags.NoInstaller, + InstallerTag: pullflags.InstallerTag, + OnlyExtraImages: pullflags.OnlyExtraImages, + IgnoreSuspend: pullflags.IgnoreSuspend, + PlatformConstraint: pullflags.PlatformConstraint, + ModuleFilter: filter, + BundleDir: pullflags.ImagesBundlePath, + BundleChunkSize: pullflags.ImagesBundleChunkSizeGB * 1000 * 1000 * 1000, + Timeout: pullflags.MirrorTimeout, + DryRun: pullflags.DryRun, }, logger.Named("pull"), p.logger, diff --git a/internal/mirror/cmd/pull/validation.go b/internal/mirror/cmd/pull/validation.go index c3add54d..9ee3e3b1 100644 --- a/internal/mirror/cmd/pull/validation.go +++ b/internal/mirror/cmd/pull/validation.go @@ -28,6 +28,7 @@ import ( "github.com/spf13/cobra" pullflags "github.com/deckhouse/deckhouse-cli/internal/mirror/cmd/pull/flags" + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" ) func parseAndValidateParameters(_ *cobra.Command, args []string) error { @@ -117,6 +118,12 @@ func parseAndValidateVersionFlags() error { if pullflags.SinceVersionString != "" && pullflags.DeckhouseTag != "" { return errors.New("Using both --deckhouse-tag and --since-version at the same time is ambiguous") } + if pullflags.PlatformConstraintString != "" && pullflags.DeckhouseTag != "" { + return errors.New("Using both --deckhouse-tag and --include-platform at the same time is ambiguous") + } + if pullflags.PlatformConstraintString != "" && pullflags.SinceVersionString != "" { + return errors.New("Using both --since-version and --include-platform at the same time is ambiguous: --include-platform already expresses a lower bound (e.g. \">=1.64\")") + } var err error if pullflags.SinceVersionString != "" { @@ -126,6 +133,13 @@ func parseAndValidateVersionFlags() error { } } + if pullflags.PlatformConstraintString != "" { + pullflags.PlatformConstraint, err = modules.ParseVersionConstraint(pullflags.PlatformConstraintString) + if err != nil { + return fmt.Errorf("Parse --include-platform constraint: %w", err) + } + } + return nil } diff --git a/internal/mirror/cmd/pull/validation_test.go b/internal/mirror/cmd/pull/validation_test.go index f039c86d..7833826b 100644 --- a/internal/mirror/cmd/pull/validation_test.go +++ b/internal/mirror/cmd/pull/validation_test.go @@ -331,11 +331,12 @@ func TestValidationValidateImagesBundlePathArg(t *testing.T) { func TestValidationParseAndValidateVersionFlags(t *testing.T) { tests := []struct { - name string - sinceVersionString string - deckhouseTag string - expectError bool - errorMsg string + name string + sinceVersionString string + platformConstraintString string + deckhouseTag string + expectError bool + errorMsg string }{ { name: "no version flags", @@ -392,6 +393,46 @@ func TestValidationParseAndValidateVersionFlags(t *testing.T) { deckhouseTag: "", expectError: true, }, + { + name: "valid include-platform range", + platformConstraintString: ">=1.64 <=1.68", + expectError: false, + }, + { + name: "valid include-platform caret shorthand", + platformConstraintString: "1.65.0", + expectError: false, + }, + { + name: "valid include-platform exact tag", + platformConstraintString: "=v1.65.3", + expectError: false, + }, + { + name: "valid include-platform exact tag with channel suffix", + platformConstraintString: "=v1.65.3+stable", + expectError: false, + }, + { + name: "include-platform conflicts with deckhouse-tag", + platformConstraintString: ">=1.64 <=1.68", + deckhouseTag: "v1.65.0", + expectError: true, + errorMsg: "ambiguous", + }, + { + name: "include-platform conflicts with since-version", + platformConstraintString: ">=1.64 <=1.68", + sinceVersionString: "1.64.0", + expectError: true, + errorMsg: "ambiguous", + }, + { + name: "invalid include-platform constraint", + platformConstraintString: "not-a-constraint", + expectError: true, + errorMsg: "Parse --include-platform constraint", + }, } for _, tt := range tests { @@ -399,16 +440,22 @@ func TestValidationParseAndValidateVersionFlags(t *testing.T) { originalSinceVersionString := pullflags.SinceVersionString originalDeckhouseTag := pullflags.DeckhouseTag originalSinceVersion := pullflags.SinceVersion + originalPlatformConstraintString := pullflags.PlatformConstraintString + originalPlatformConstraint := pullflags.PlatformConstraint defer func() { pullflags.SinceVersionString = originalSinceVersionString pullflags.DeckhouseTag = originalDeckhouseTag pullflags.SinceVersion = originalSinceVersion + pullflags.PlatformConstraintString = originalPlatformConstraintString + pullflags.PlatformConstraint = originalPlatformConstraint }() pullflags.SinceVersionString = tt.sinceVersionString pullflags.DeckhouseTag = tt.deckhouseTag pullflags.SinceVersion = nil + pullflags.PlatformConstraintString = tt.platformConstraintString + pullflags.PlatformConstraint = nil err := parseAndValidateVersionFlags() @@ -423,6 +470,9 @@ func TestValidationParseAndValidateVersionFlags(t *testing.T) { assert.NotNil(t, pullflags.SinceVersion) assert.Equal(t, tt.sinceVersionString, pullflags.SinceVersion.String()) } + if tt.platformConstraintString != "" { + assert.NotNil(t, pullflags.PlatformConstraint, "constraint must be parsed") + } } }) } diff --git a/internal/mirror/modules/filter.go b/internal/mirror/modules/filter.go index 41137254..e5950cc4 100644 --- a/internal/mirror/modules/filter.go +++ b/internal/mirror/modules/filter.go @@ -103,6 +103,23 @@ func (f *Filter) GetConstraint(moduleName string) (VersionConstraint, bool) { return constraint, found } +// ParseVersionConstraint turns a user-supplied constraint string into a +// VersionConstraint. The syntax mirrors the `module-name@` body +// accepted by --include-module so any consumer (modules filter, platform +// --include-platform, future call sites) speaks the same dialect: +// +// - "=v1.2.3" → exact tag (no channel propagation) +// - "=v1.2.3+stable" → exact tag pinned to the named release channel +// - ">=1.2.0 <=1.3.0" → semver range with inclusive anchors +// - "^1.2.0", "~1.2.0" → semver shorthand +// - "1.2.0" → implicit caret (^1.2.0), kept for backward compat +// +// An empty or whitespace-only input is rejected so callers see a clear error +// instead of silently producing a no-op constraint. +func ParseVersionConstraint(v string) (VersionConstraint, error) { + return parseVersionConstraint(v) +} + func parseVersionConstraint(v string) (VersionConstraint, error) { v = strings.TrimSpace(v) if v == "" { diff --git a/internal/mirror/platform/platform.go b/internal/mirror/platform/platform.go index 7da1b59c..d3d25e19 100644 --- a/internal/mirror/platform/platform.go +++ b/internal/mirror/platform/platform.go @@ -36,6 +36,7 @@ import ( "github.com/deckhouse/deckhouse/pkg/registry/client" "github.com/deckhouse/deckhouse-cli/internal" + "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" "github.com/deckhouse/deckhouse-cli/internal/mirror/pack" "github.com/deckhouse/deckhouse-cli/internal/mirror/puller" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" @@ -49,6 +50,17 @@ import ( type Options struct { // SinceVersion specifies the minimum version to start mirroring from (optional) SinceVersion *semver.Version + // IncludeConstraint narrows platform release discovery to a user-supplied + // semver constraint expression, mirroring the per-module constraint syntax + // accepted by --include-module. When set it replaces the default discovery + // window (rock-solid..alpha) — only registry tags matching the constraint + // are pulled, latest-patch-per-(major, minor) collapsing applies and + // release channels pointing outside the constraint are filtered out. + // + // IncludeConstraint is mutually exclusive with TargetTag: pinning one tag + // and selecting a range simultaneously is contradictory and the CLI + // rejects that combination before reaching this layer. + IncludeConstraint modules.VersionConstraint // TargetTag specifies a specific tag to mirror instead of determining versions automatically // it can be: // semver f.e. vX.Y.Z @@ -137,6 +149,18 @@ func NewService( // It validates access to the registry, determines which versions to mirror, // and prepares the image layouts for mirroring func (svc *Service) PullPlatform(ctx context.Context) error { + // An exact-tag --include-platform constraint (`=v1.65.0` / + // `=v1.65.0+stable`) is operationally identical to --deckhouse-tag: a + // single tag is pulled and downstream code propagates channel aliases + // from it. Synthesize TargetTag here so every later code path + // (validatePlatformAccess, findTagsToMirror, pullDeckhousePlatform's + // release-channel propagation) sees a uniform input. The CLI guarantees + // IncludeConstraint and TargetTag are not both set so this assignment + // cannot silently override a user-supplied --deckhouse-tag. + if exact, ok := svc.options.IncludeConstraint.(*modules.ExactTagConstraint); ok { + svc.options.TargetTag = exact.Tag() + } + err := svc.validatePlatformAccess(ctx) if err != nil { return fmt.Errorf("validate platform access: %w", err) @@ -287,13 +311,26 @@ func (svc *Service) versionsToMirror(ctx context.Context, tagsToMirror []string) }, nil } + // When --include-platform supplies a semver range, prune channel snapshots + // that point outside it BEFORE expanding the discovery window so the + // expansion only inherits anchors the user can actually be served from + // the registry. Exact-tag constraints take the synthesized-TargetTag + // path in PullPlatform and never reach this branch. + semverConstraint, hasSemverConstraint := svc.options.IncludeConstraint.(*modules.SemanticVersionConstraint) + if hasSemverConstraint { + versions = filterVersionsByConstraint(versions, semverConstraint) + matchedChannels = filterChannelsByConstraint(matchedChannels, channelVersions, semverConstraint) + } + // For full discovery mode, expand version range expandedVersions, err := svc.expandVersionRange(ctx, channelVersions, versions) if err != nil { return nil, err } - // Filter out channels that are below the minimum version (SinceVersion/rock-solid) + // Filter out channels that are below the minimum version (SinceVersion/rock-solid). + // When IncludeConstraint is active the channel set was already constrained above; + // keep the lower-bound filter so SinceVersion still works in combination. minVersion := svc.determineMinimumVersion(channelVersions) filteredChannels := make([]string, 0, len(matchedChannels)) for _, ch := range matchedChannels { @@ -436,40 +473,165 @@ func (svc *Service) tagMatchesChannel(tag, channelName string, channelVersion *s // expandVersionRange expands the version range for full discovery mode func (svc *Service) expandVersionRange(ctx context.Context, channelVersions channelVersions, baseVersions []*semver.Version) ([]*semver.Version, error) { - minVersion := svc.determineMinimumVersion(channelVersions) - maxVersion := channelVersions[internal.AlphaChannel] + semverConstraint, hasSemverConstraint := svc.options.IncludeConstraint.(*modules.SemanticVersionConstraint) + + if !hasSemverConstraint { + minVersion := svc.determineMinimumVersion(channelVersions) + maxVersion := channelVersions[internal.AlphaChannel] + + if maxVersion == nil { + // No alpha channel - return base versions only + return baseVersions, nil + } - if maxVersion == nil { - // No alpha channel - return base versions only - return baseVersions, nil + svc.userLogger.Debugf("listing deckhouse releases") + + // Fetch all available tags + allTags, err := svc.deckhouseService.ReleaseChannels().ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("get tags from Deckhouse registry: %w", err) + } + + // Filter and get latest patches + filteredVersions := filterVersionsBetween(minVersion, maxVersion, allTags) + latestPatches := filterOnlyLatestPatches(filteredVersions) + + // Filter base channel versions by minVersion as well + filteredBase := baseVersions + if minVersion != nil { + nb := make([]*semver.Version, 0, len(baseVersions)) + for _, v := range baseVersions { + if v == nil || v.LessThan(minVersion) { + continue + } + nb = append(nb, v) + } + filteredBase = nb + } + + return append(filteredBase, latestPatches...), nil } - svc.userLogger.Debugf("listing deckhouse releases") + // --include-platform semver path: the constraint owns both the lower and + // upper bounds, so we ignore rock-solid/alpha endpoints entirely. We still + // honour --since-version (when above the constraint's lower bound) to + // preserve the existing knob without surprising the user. + svc.userLogger.Debugf("listing deckhouse releases for --include-platform") - // Fetch all available tags allTags, err := svc.deckhouseService.ReleaseChannels().ListTags(ctx) if err != nil { return nil, fmt.Errorf("get tags from Deckhouse registry: %w", err) } - // Filter and get latest patches - filteredVersions := filterVersionsBetween(minVersion, maxVersion, allTags) - latestPatches := filterOnlyLatestPatches(filteredVersions) - - // Filter base channel versions by minVersion as well - filteredBase := baseVersions - if minVersion != nil { - nb := make([]*semver.Version, 0, len(baseVersions)) - for _, v := range baseVersions { - if v == nil || v.LessThan(minVersion) { + matched := parseTagsMatchingConstraint(allTags, semverConstraint) + if since := svc.options.SinceVersion; since != nil { + nb := make([]*semver.Version, 0, len(matched)) + for _, v := range matched { + if v.LessThan(since) { continue } nb = append(nb, v) } - filteredBase = nb + matched = nb + } + + selected := filterOnlyLatestPatches(matched) + selected = restoreInclusiveAnchors(selected, matched, semverConstraint.Anchors()) + + return append(baseVersions, selected...), nil +} + +// parseTagsMatchingConstraint walks the registry's tag list, drops anything +// that is not a valid semver, and keeps only versions that satisfy the +// constraint. Returning *semver.Version (rather than tag strings) lets the +// caller share filterOnlyLatestPatches / restoreInclusiveAnchors with the +// channel-version path. +func parseTagsMatchingConstraint(tags []string, constraint *modules.SemanticVersionConstraint) []*semver.Version { + matched := make([]*semver.Version, 0, len(tags)) + for _, tag := range tags { + v, err := semver.NewVersion(tag) + if err != nil { + continue + } + if constraint.Match(v) { + matched = append(matched, v) + } } + return matched +} + +// filterVersionsByConstraint retains only versions matching the constraint. +// It is the counterpart of parseTagsMatchingConstraint but operates on +// already-parsed semver values (channel snapshots in versionsToMirror). +func filterVersionsByConstraint(versions []*semver.Version, constraint *modules.SemanticVersionConstraint) []*semver.Version { + out := make([]*semver.Version, 0, len(versions)) + for _, v := range versions { + if v == nil { + continue + } + if constraint.Match(v) { + out = append(out, v) + } + } + return out +} + +// filterChannelsByConstraint drops release channels whose current snapshot +// version falls outside the user-supplied constraint. Channels that resolve +// to a missing version (defensive — fetchReleaseChannelVersions normally +// guarantees this) are kept so we don't silently delete metadata for sources +// we cannot evaluate against the constraint. +func filterChannelsByConstraint(channels []string, channelVersions channelVersions, constraint *modules.SemanticVersionConstraint) []string { + out := make([]string, 0, len(channels)) + for _, ch := range channels { + v, ok := channelVersions[ch] + if !ok || v == nil { + out = append(out, ch) + continue + } + if constraint.Match(v) { + out = append(out, ch) + } + } + return out +} - return append(filteredBase, latestPatches...), nil +// restoreInclusiveAnchors re-adds anchor versions (the literals named with +// `>=` / `<=` in the user's constraint) that filterOnlyLatestPatches would +// otherwise drop in favour of a higher patch in the same (major, minor). +// An anchor is only restored when the registry actually exposes it via +// `available` so we never enqueue a tag the source does not have. +func restoreInclusiveAnchors(selected, available []*semver.Version, anchors []*semver.Version) []*semver.Version { + if len(anchors) == 0 { + return selected + } + + availableByKey := make(map[string]*semver.Version, len(available)) + for _, v := range available { + if v == nil { + continue + } + availableByKey[v.String()] = v + } + + selectedKeys := make(map[string]struct{}, len(selected)) + for _, v := range selected { + selectedKeys[v.String()] = struct{}{} + } + + for _, anchor := range anchors { + key := anchor.String() + if _, already := selectedKeys[key]; already { + continue + } + registryVersion, isAvailable := availableByKey[key] + if !isAvailable { + continue + } + selected = append(selected, registryVersion) + selectedKeys[key] = struct{}{} + } + return selected } // determineMinimumVersion determines the minimum version for mirroring based on configuration diff --git a/internal/mirror/platform/pull_platform_test.go b/internal/mirror/platform/pull_platform_test.go index 836bfa0f..b55b2351 100644 --- a/internal/mirror/platform/pull_platform_test.go +++ b/internal/mirror/platform/pull_platform_test.go @@ -19,6 +19,7 @@ import ( dkplog "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/deckhouse-cli/internal" +"github.com/deckhouse/deckhouse-cli/internal/mirror/modules" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/bundle" mlayouts "github.com/deckhouse/deckhouse-cli/pkg/libmirror/layouts" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" @@ -459,6 +460,141 @@ func TestPullPlatform_DryRun_SinceVersion_FiltersOlderChannels(t *testing.T) { } } +// ---- --include-platform: semver constraint ---- + +// includePlatformConstraint is a tiny test helper that constructs the same +// VersionConstraint the CLI would build out of --include-platform. +func includePlatformConstraint(t *testing.T, expr string) modules.VersionConstraint { + t.Helper() + c, err := modules.ParseVersionConstraint(expr) + require.NoError(t, err, "constraint %q must parse", expr) + return c +} + +// TestPullPlatform_DryRun_IncludePlatform_RangeFiltersChannelsAndVersions +// covers the user-facing scenario from the original feature request: pull +// an inclusive [1.69, 1.71] window of platform releases (the equivalent of +// "1.64..1.68" against the stub registry whose newest tag is v1.72.10). +// Channel snapshots above the upper bound (alpha → v1.72.10) and below the +// lower bound (rock-solid → v1.68.0) must be excluded; everything in between +// must be present, plus the latest patch per minor inside the range. +func TestPullPlatform_DryRun_IncludePlatform_RangeFiltersChannelsAndVersions(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService( + localfake.NewRegistryClientStub(), + &Options{IncludeConstraint: includePlatformConstraint(t, ">=1.69 <=1.71")}, + logger, + userLogger, + ) + require.NoError(t, svc.PullPlatform(context.Background())) + + rootURL := stubRootURL + for _, ver := range []string{"v1.71.0", "v1.70.0", "v1.69.0"} { + assert.Contains(t, svc.downloadList.Deckhouse, rootURL+":"+ver, + "version %s is within the include-platform window and must be included", ver) + } + for _, ver := range []string{"v1.72.10", "v1.68.0"} { + assert.NotContains(t, svc.downloadList.Deckhouse, rootURL+":"+ver, + "version %s is outside the include-platform window and must be excluded", ver) + } + for _, ch := range []string{"beta", "early-access", "stable"} { + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + rootURL+"/release-channel:"+ch, + "channel %s points inside the include-platform window and must survive", ch) + } + for _, ch := range []string{"alpha", "rock-solid"} { + assert.NotContains(t, svc.downloadList.DeckhouseReleaseChannel, + rootURL+"/release-channel:"+ch, + "channel %s points outside the include-platform window and must be dropped", ch) + } +} + +// TestPullPlatform_DryRun_IncludePlatform_TildeKeepsSingleMinor exercises the +// tilde shorthand (~X.Y.Z → >=X.Y.Z =` literals will surface as a clear failure. +func TestPullPlatform_DryRun_IncludePlatform_InclusiveAnchorPreserved(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService( + localfake.NewRegistryClientStub(), + &Options{IncludeConstraint: includePlatformConstraint(t, ">=1.69.0 <=1.70.0")}, + logger, + userLogger, + ) + require.NoError(t, svc.PullPlatform(context.Background())) + + rootURL := stubRootURL + for _, ver := range []string{"v1.69.0", "v1.70.0"} { + assert.Contains(t, svc.downloadList.Deckhouse, rootURL+":"+ver, + "anchor version %s must be preserved by include-platform", ver) + } + for _, ver := range []string{"v1.72.10", "v1.71.0", "v1.68.0"} { + assert.NotContains(t, svc.downloadList.Deckhouse, rootURL+":"+ver, + "version %s is outside the anchored window", ver) + } +} + +// TestPullPlatform_DryRun_IncludePlatform_ExactTagBehavesLikeTargetTag pins +// down the contract that =vX.Y.Z is operationally identical to +// --deckhouse-tag=vX.Y.Z. The exact-tag synthesis happens in PullPlatform +// before validation, so the test asserts both the version download list +// (only the pinned tag is present) and the channel propagation block (every +// default channel points at the pinned tag, mirroring --deckhouse-tag). +func TestPullPlatform_DryRun_IncludePlatform_ExactTagBehavesLikeTargetTag(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService( + localfake.NewRegistryClientStub(), + &Options{IncludeConstraint: includePlatformConstraint(t, "=v1.69.0")}, + logger, + userLogger, + ) + require.NoError(t, svc.PullPlatform(context.Background())) + + rootURL := stubRootURL + assert.Contains(t, svc.downloadList.Deckhouse, rootURL+":v1.69.0") + for _, ver := range []string{"v1.72.10", "v1.71.0", "v1.70.0", "v1.68.0"} { + assert.NotContains(t, svc.downloadList.Deckhouse, rootURL+":"+ver, + "only the exactly-pinned tag must be enqueued for download") + } + // Synthesized TargetTag flows through findTagsToMirror which matches the + // pinned tag against channel snapshots; stable points at v1.69.0 in the + // stub so it must show up in the release-channel layout. + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + rootURL+"/release-channel:stable", + "=v1.69.0 must propagate to stable because v1.69.0 is the stable channel version in the stub") +} + func TestPullPlatform_DryRun_SinceVersion_EqualToAlpha_OnlyAlpha(t *testing.T) { // SinceVersion=1.72.10 means only the alpha version (== newest) survives. logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) diff --git a/internal/mirror/pull.go b/internal/mirror/pull.go index 1798eed8..663d46f8 100644 --- a/internal/mirror/pull.go +++ b/internal/mirror/pull.go @@ -47,6 +47,11 @@ type PullServiceOptions struct { OnlyExtraImages bool // IgnoreSuspend allows mirroring even if release channels are suspended IgnoreSuspend bool + // PlatformConstraint selects platform releases by semver constraint + // (--include-platform). When non-nil it replaces the default + // rock-solid..alpha discovery window for the platform service. Exact-tag + // constraints are routed through TargetTag inside platform.PullPlatform. + PlatformConstraint modules.VersionConstraint // ModuleFilter is the filter for module selection (whitelist/blacklist) ModuleFilter *modules.Filter // BundleDir is the directory to store the bundle @@ -99,13 +104,14 @@ func NewPullService( registryService, tmpDir, &platform.Options{ - TargetTag: targetTag, - BundleDir: options.BundleDir, - BundleChunkSize: options.BundleChunkSize, - IgnoreSuspend: options.IgnoreSuspend, - SkipVexImages: options.SkipVexImages, - Timeout: options.Timeout, - DryRun: options.DryRun, + TargetTag: targetTag, + IncludeConstraint: options.PlatformConstraint, + BundleDir: options.BundleDir, + BundleChunkSize: options.BundleChunkSize, + IgnoreSuspend: options.IgnoreSuspend, + SkipVexImages: options.SkipVexImages, + Timeout: options.Timeout, + DryRun: options.DryRun, }, logger, userLogger,