diff --git a/internal/mirror/README.MD b/internal/mirror/README.MD index cef1567d..b4306fc7 100644 --- a/internal/mirror/README.MD +++ b/internal/mirror/README.MD @@ -114,7 +114,9 @@ module-name[@version-constraint] --include-module mymodule@^1.3.0 # equivalent ``` - Example: For available versions `v1.0.0, v1.1.0, v1.2.0, v1.3.0, v1.3.3, v1.4.1` - - Constraint `^1.3.0` includes: `v1.3.0, v1.3.3, v1.4.1` + - Constraint `^1.3.0` includes: `v1.3.3, v1.4.1` + - Only the highest patch in each `(major, minor)` series is kept, matching the platform release discovery rule. Use the exact-tag form (`=`) to pin a specific older patch. + - Versions currently pinned by release channels (alpha, beta, early-access, stable, rock-solid, lts) are pulled in addition, regardless of the patch filter. - Also pulls current versions from all release channels 3. **Semver tilde constraint (~)** - Patch-level changes only @@ -122,13 +124,20 @@ module-name[@version-constraint] --include-module mymodule@~1.3.0 ``` - Example: For available versions `v1.0.0, v1.1.0, v1.2.0, v1.3.0, v1.3.3, v1.4.1` - - Constraint `~1.3.0` (equivalent to `>=1.3.0 <1.4.0`) includes: `v1.3.0, v1.3.3` + - Constraint `~1.3.0` (equivalent to `>=1.3.0 <1.4.0`) includes: `v1.3.3` (latest patch of 1.3.x) - Also pulls current versions from all release channels 4. **Semver range constraint** - Explicit version range ```bash --include-module mymodule@">=1.1.0 <1.3.0" ``` + - Example: For available versions `v1.0.0, v1.1.0, v1.1.2, v1.2.0, v1.2.4, v1.3.0` + - Constraint `>=1.1.0 <1.3.0` includes: `v1.1.0, v1.1.2, v1.2.4` + - The latest patch in each `(major, minor)` is kept; **inclusive boundary operators (`>=`, `<=`) always preserve the named version**, even when a newer patch exists in the same minor. `>=1.1.0` literally means "v1.1.0 OR newer" — the equality is part of the operator. + - Strict bounds (`>`, `<`) do NOT preserve the named version; they exclude it by definition. + - Caret (`^`) and tilde (`~`) are syntactic shorthand for a range: their lower bounds are NOT anchors and the latest-patch-per-minor filter applies in full. + - Anchors that are not present in the registry are silently skipped — this command never invents a tag. + - Also pulls current versions from all release channels 5. **Exact tag match (=)** - Single specific version ```bash diff --git a/internal/mirror/cmd/pull/flags/flags.go b/internal/mirror/cmd/pull/flags/flags.go index afc3bc3f..d4cdeb7d 100644 --- a/internal/mirror/cmd/pull/flags/flags.go +++ b/internal/mirror/cmd/pull/flags/flags.go @@ -125,12 +125,20 @@ func AddFlags(flagSet *pflag.FlagSet) { nil, `Whitelist specific modules for downloading. Use one flag per each module. Disables blacklisting by --exclude-module." +Semver constraints (caret, tilde, range) keep only the highest patch in each (major, minor) series, mirroring how platform releases are discovered. +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 a specific older patch unconditionally. + Example: -Available versions for : v1.0.0, v1.1.0, v1.2.0, v1.3.0, v1.3.3, v1.4.1 +Available versions for : v1.0.0, v1.1.0, v1.2.0, v1.3.0, v1.3.3, v1.4.0, v1.4.1 + +module-name@1.3.0 → semver ^ constraint (^1.3.0): keep latest patch per minor — includes v1.3.3 (1.3.x) and v1.4.1 (1.4.x). Versions currently pinned by release channels are pulled in addition. + +module-name@~1.3.0 → semver ~ constraint (>=1.3.0 <1.4.0): keep latest patch per minor in range — includes v1.3.3. Versions currently pinned by release channels are pulled in addition. -module-name@1.3.0 → semver ^ constraint (^1.3.0): include v1.3.0, v1.3.3, v1.4.1. In addition pulls current versions from release channels +module-name@>=1.3.0 → range constraint with explicit >= anchor: keep latest patch per minor AND the named anchor v1.3.0 — includes v1.3.0 (anchor), v1.3.3 (1.3.x latest), v1.4.1 (1.4.x latest). -module-name@~1.3.0 → semver ~ constraint (>=1.3.0 <1.4.0): include only v1.3.0, v1.3.3. In addition pulls current versions from release channels +module-name@>=1.3.0 <=1.4.0 → both anchors honoured: includes v1.3.0, v1.3.3, v1.4.0; v1.4.1 is excluded by the upper bound. module-name@=v1.3.0 → exact tag match: include only v1.3.0 and publish it to all release channels (alpha, beta, early-access, stable, rock-solid). diff --git a/internal/mirror/modules/constraints.go b/internal/mirror/modules/constraints.go index bc539373..114a9bdf 100644 --- a/internal/mirror/modules/constraints.go +++ b/internal/mirror/modules/constraints.go @@ -18,6 +18,7 @@ package modules import ( "fmt" + "regexp" "github.com/Masterminds/semver/v3" ) @@ -30,14 +31,87 @@ type VersionConstraint interface { type SemanticVersionConstraint struct { constraint *semver.Constraints + // anchors are versions explicitly named with an inclusive boundary + // operator (>=, <=) in the user's constraint string. These versions + // must round-trip through the latest-patch-per-minor filter — the user + // has named them by hand, so dropping them in favor of a newer patch + // in the same (major, minor) bucket would silently override an + // explicit user choice. + // + // Only `>=`, `<=` (and their reversed forms `=>`, `=<`) contribute + // anchors. Caret (`^`), tilde (`~`), and implicit constraints do not: + // those operators are shorthand for a range and the patch filter is + // free to collapse same-minor patches inside them. + anchors []*semver.Version } +// anchorOpRegex captures version literals that follow an inclusive boundary +// operator. Exclusive bounds (>, <) and shorthand operators (^, ~) are +// intentionally excluded — see the anchors field doc above for the rationale. +// +// The version capture is deliberately permissive (any non-space, non-comma +// run); the result is re-validated through semver.NewVersion before being +// stored. +var anchorOpRegex = regexp.MustCompile(`(?:>=|<=|=>|=<)\s*([^\s,]+)`) + func NewSemanticVersionConstraint(c string) (*SemanticVersionConstraint, error) { constraint, err := semver.NewConstraint(c) if err != nil { return nil, fmt.Errorf("invalid semantic version constraint %q: %w", c, err) } - return &SemanticVersionConstraint{constraint: constraint}, nil + + anchors, err := extractInclusiveAnchors(c) + if err != nil { + return nil, fmt.Errorf("invalid semantic version constraint %q: %w", c, err) + } + + return &SemanticVersionConstraint{ + constraint: constraint, + anchors: anchors, + }, nil +} + +// Anchors returns the versions explicitly named with an inclusive boundary +// operator (>=, <=). Callers must re-check membership against the constraint +// itself (Match) before consuming an anchor: an anchor that fails Match means +// the user wrote a contradictory constraint (e.g. `>=2.0.0 <1.0.0`) and we +// will not silently widen the range. +func (s *SemanticVersionConstraint) Anchors() []*semver.Version { + return s.anchors +} + +// extractInclusiveAnchors finds every >=X / <=X literal in the constraint +// string and parses X with semver.NewVersion. Duplicates are removed. +// The returned slice is nil when no inclusive boundary literals are present. +func extractInclusiveAnchors(constraintStr string) ([]*semver.Version, error) { + matches := anchorOpRegex.FindAllStringSubmatch(constraintStr, -1) + if len(matches) == 0 { + return nil, nil + } + + seen := make(map[string]struct{}, len(matches)) + out := make([]*semver.Version, 0, len(matches)) + for _, m := range matches { + if len(m) < 2 { + continue + } + raw := m[1] + if _, dup := seen[raw]; dup { + continue + } + seen[raw] = struct{}{} + + v, err := semver.NewVersion(raw) + if err != nil { + // The constraint already passed semver.NewConstraint, so this + // only fires on programming errors in the regex (we'd extract + // something that the constraint parser had accepted but the + // version parser hadn't). Surface it as a real error. + return nil, fmt.Errorf("anchor %q not a valid semver: %w", raw, err) + } + out = append(out, v) + } + return out, nil } func (s *SemanticVersionConstraint) HasChannelAlias() bool { diff --git a/internal/mirror/modules/filter.go b/internal/mirror/modules/filter.go index b66fdf52..41137254 100644 --- a/internal/mirror/modules/filter.go +++ b/internal/mirror/modules/filter.go @@ -20,6 +20,8 @@ import ( "fmt" "strings" + "github.com/Masterminds/semver/v3" + "github.com/deckhouse/deckhouse-cli/internal" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/operations/params" ) @@ -155,6 +157,27 @@ func (f *Filter) ShouldMirrorReleaseChannels(moduleName string) bool { // VersionsToMirror resolves module constraints from --include-module into concrete tags to pull. // Returns nil when no explicit version tags should be added for this module. +// +// For semver constraints (caret, tilde, ranges) only the highest patch in each +// (major, minor) bucket that satisfies the constraint is returned. This mirrors +// the platform-level discovery rule (filterOnlyLatestPatches in +// internal/mirror/platform/platform.go) and avoids pulling N redundant patches +// per minor when the user wires a single module pin like `module@v1.6.0`. +// +// Anchor exception: versions named with an inclusive boundary operator (`>=` +// or `<=`) are always restored to the result if they exist in the registry. +// `>=1.40.0` literally encodes "v1.40.0 OR newer" — the user named v1.40.0 +// by hand and we MUST honour that even when a newer patch (v1.40.1) exists +// in the same minor. Caret (`^`) and tilde (`~`) are syntactic shorthand +// for a range; their lower bounds are NOT anchors. +// +// Exact-tag constraints (`module@=vX.Y.Z`) bypass this filter — when the user +// asks for a specific tag they get exactly that tag. +// +// Channel snapshot versions (alpha/beta/early-access/stable/rock-solid) are +// merged into the pull list outside this method, so an older patch that a +// channel still points at remains reachable through the channel snapshot even +// when filterOnlyLatestPatches drops it from the constraint set. func (f *Filter) VersionsToMirror(mod *Module) []string { constraint, hasConstraint := f.modules[mod.Name] if !hasConstraint { @@ -169,17 +192,98 @@ func (f *Filter) VersionsToMirror(mod *Module) []string { return []string{exact.Tag()} } - semver, isSemver := constraint.(*SemanticVersionConstraint) + semverConstraint, isSemver := constraint.(*SemanticVersionConstraint) if !isSemver { return nil } - var tags []string + matched := make([]*semver.Version, 0) for _, v := range mod.Versions() { - if semver.Match(v) { - tags = append(tags, "v"+v.String()) + if semverConstraint.Match(v) { + matched = append(matched, v) } } + selected := filterOnlyLatestPatches(matched) + selected = restoreInclusiveAnchors(selected, matched, semverConstraint.Anchors()) + + tags := make([]string, 0, len(selected)) + for _, v := range selected { + tags = append(tags, "v"+v.String()) + } return tags } + +// restoreInclusiveAnchors re-introduces any anchor versions (named via >=/<= +// in the user's constraint string) that were dropped by filterOnlyLatestPatches. +// +// An anchor is only restored when it is present in `available` (i.e. the +// constraint actually matched it against the registry's tag list). This guards +// against two failure modes: +// - silently widening the constraint by appending versions the user +// contradicted in another sub-constraint; +// - emitting tags the registry doesn't have. +// +// The function preserves the order of `selected` and appends restored anchors +// at the end. Duplicates are de-duplicated by version equality. +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 +} + +// filterOnlyLatestPatches keeps a single highest-patch version for every +// (major, minor) bucket. It is the modules-package counterpart of the same +// helper used by platform release discovery, kept private to avoid an +// import cycle and to make it easy for both packages to evolve independently. +func filterOnlyLatestPatches(versions []*semver.Version) []*semver.Version { + type majorMinor struct { + major uint64 + minor uint64 + } + + latest := make(map[majorMinor]*semver.Version, len(versions)) + for _, v := range versions { + if v == nil { + continue + } + key := majorMinor{major: v.Major(), minor: v.Minor()} + current, ok := latest[key] + if !ok || v.GreaterThan(current) { + latest[key] = v + } + } + + result := make([]*semver.Version, 0, len(latest)) + for _, v := range latest { + result = append(result, v) + } + return result +} diff --git a/internal/mirror/modules/filter_test.go b/internal/mirror/modules/filter_test.go index 6d7ef33a..97aa031e 100644 --- a/internal/mirror/modules/filter_test.go +++ b/internal/mirror/modules/filter_test.go @@ -250,7 +250,12 @@ func TestFilter_VersionsToMirror(t *testing.T) { want []string }{ { - name: "happy path: semver constraint ^", + // Caret constraint: keep highest patch in every (major, minor) + // bucket inside ^1.3.0 = >=1.3.0 <2.0.0. The bucket 1.3.x + // degenerates to the single tag v1.3.0 (so v1.3.0 stays); 1.4.x + // has only v1.4.1 (so v1.4.1 stays). v1.0.0..v1.2.0 are below the + // constraint and stay out. + name: "happy path: semver constraint ^ keeps only latest patch per minor", filter: Filter{ logger: logger, modules: map[string]VersionConstraint{ @@ -276,7 +281,38 @@ func TestFilter_VersionsToMirror(t *testing.T) { "v1.3.0", "v1.4.1"}, }, { - name: "semver constraint tilde ~ (>=1.3.0 <1.4.0)", + // Caret constraint with multiple patches in the same minor: only + // the highest patch (v1.3.3) survives the per-minor filter. + // v1.3.0 is dropped on purpose. This is the regression case for + // issue #220 (`code@v1.6.0` pulling every 1.6.x patch). Channel + // aliases are appended by the test harness because the constraint + // is non-exact (ShouldMirrorReleaseChannels=true). + name: "semver constraint ^ drops older patches in same minor", + filter: Filter{ + logger: logger, + modules: map[string]VersionConstraint{ + "module1": geConstraint("^1.3.0"), + }, + }, + mod: &Module{ + Name: "module1", + Releases: []string{ + "v1.0.0", "v1.1.0", "v1.2.0", + "v1.3.0", "v1.3.1", "v1.3.2", "v1.3.3", + "v1.4.0", "v1.4.1"}, + }, + want: []string{ + internal.AlphaChannel, + internal.BetaChannel, + internal.EarlyAccessChannel, + internal.StableChannel, + internal.RockSolidChannel, + "v1.3.3", "v1.4.1"}, + }, + { + // Tilde constraint = >=1.3.0 <1.4.0, which is one (major, minor) + // bucket. Latest patch v1.3.3 is the only output. + name: "semver constraint tilde ~ (>=1.3.0 <1.4.0) keeps only the latest patch", filter: Filter{ logger: logger, modules: map[string]VersionConstraint{ @@ -299,9 +335,12 @@ func TestFilter_VersionsToMirror(t *testing.T) { internal.EarlyAccessChannel, internal.StableChannel, internal.RockSolidChannel, - "v1.3.0", "v1.3.3"}, + "v1.3.3"}, }, { + // Explicit range: each minor bucket inside [1.1.0, 1.3.0) collapses + // to its highest patch — here the registry has only one tag per + // minor, so all matched versions survive. name: "semver constraint range >=1.1.0 <1.3.0", filter: Filter{ logger: logger, @@ -327,6 +366,175 @@ func TestFilter_VersionsToMirror(t *testing.T) { internal.RockSolidChannel, "v1.1.0", "v1.2.0"}, }, + { + // Explicit range with multiple patches per minor: collapse to the + // highest patch in each (major, minor) AND keep the >= anchor. + // `>=1.6.0` literally names v1.6.0 — the equality is part of the + // operator, so v1.6.0 must round-trip. v1.7.x has no anchor (the + // upper bound `<1.8.0` is exclusive) so 1.7.x degenerates to its + // latest patch v1.7.1. + name: "semver range collapses non-anchor minors but preserves >= anchor", + filter: Filter{ + logger: logger, + modules: map[string]VersionConstraint{ + "module1": geConstraint(">=1.6.0 <1.8.0"), + }, + }, + mod: &Module{ + Name: "module1", + Releases: []string{ + "v1.6.0", "v1.6.1", "v1.6.2", "v1.6.3", "v1.6.4", "v1.6.5", + "v1.7.0", "v1.7.1", + "v1.8.0", + }, + }, + want: []string{ + internal.AlphaChannel, + internal.BetaChannel, + internal.EarlyAccessChannel, + internal.StableChannel, + internal.RockSolidChannel, + "v1.6.0", "v1.6.5", "v1.7.1"}, + }, + { + // Bare `>=` constraint with no upper bound. The anchor is v1.40.0 + // and it sits in a minor that has a newer patch (v1.40.1). Under + // pure latest-patch-per-minor semantics v1.40.0 would be dropped; + // the anchor exception keeps it in the result. v1.41.x has no + // anchor and collapses to its latest patch. + name: "bare >= preserves anchor and keeps latest patch in same minor", + filter: Filter{ + logger: logger, + modules: map[string]VersionConstraint{ + "module1": geConstraint(">=1.40.0"), + }, + }, + mod: &Module{ + Name: "module1", + Releases: []string{ + "v1.39.5", + "v1.40.0", "v1.40.1", + "v1.41.0", "v1.41.2", + }, + }, + want: []string{ + internal.AlphaChannel, + internal.BetaChannel, + internal.EarlyAccessChannel, + internal.StableChannel, + internal.RockSolidChannel, + "v1.40.0", "v1.40.1", "v1.41.2"}, + }, + { + // `<=` is also an inclusive boundary. v1.42.5 must round-trip + // even though latest-patch-per-minor would prefer v1.42.7. + // Anchors stack: the lower bound `>=1.40.0` and the upper bound + // `<=1.42.5` are both honoured. + name: "<= preserves upper-bound anchor", + filter: Filter{ + logger: logger, + modules: map[string]VersionConstraint{ + "module1": geConstraint(">=1.40.0 <=1.42.5"), + }, + }, + mod: &Module{ + Name: "module1", + Releases: []string{ + "v1.40.0", "v1.40.1", + "v1.41.0", + "v1.42.0", "v1.42.5", + "v1.42.7", // Filtered out: above the upper bound. + }, + }, + want: []string{ + internal.AlphaChannel, + internal.BetaChannel, + internal.EarlyAccessChannel, + internal.StableChannel, + internal.RockSolidChannel, + "v1.40.0", "v1.40.1", "v1.41.0", "v1.42.5"}, + }, + { + // Strict `>` is exclusive — the named version is NOT an anchor + // because the user explicitly excluded it. v1.40.0 must NOT + // appear in the result; the 1.40.x bucket has only v1.40.1 + // (which sits inside the >, so it stays via latest-patch). + name: "strict > does not create anchor", + filter: Filter{ + logger: logger, + modules: map[string]VersionConstraint{ + "module1": geConstraint(">1.40.0 <1.42.0"), + }, + }, + mod: &Module{ + Name: "module1", + Releases: []string{ + "v1.40.0", "v1.40.1", + "v1.41.0", "v1.41.3", + }, + }, + want: []string{ + internal.AlphaChannel, + internal.BetaChannel, + internal.EarlyAccessChannel, + internal.StableChannel, + internal.RockSolidChannel, + "v1.40.1", "v1.41.3"}, + }, + { + // Caret is shorthand for a range — its lower bound is NOT an + // anchor. `^1.6.0` expands to `>=1.6.0 <2.0.0`; the implicit `>=` + // must not preserve v1.6.0 (issue #220 case: the user wrote + // `module@v1.6.0` and wants only the latest patch per minor). + name: "caret does not create anchor (issue #220 base case)", + filter: Filter{ + logger: logger, + modules: map[string]VersionConstraint{ + "module1": geConstraint("^1.6.0"), + }, + }, + mod: &Module{ + Name: "module1", + Releases: []string{ + "v1.6.0", "v1.6.1", "v1.6.2", "v1.6.3", "v1.6.4", "v1.6.5", + "v1.7.0", "v1.7.1", + }, + }, + want: []string{ + internal.AlphaChannel, + internal.BetaChannel, + internal.EarlyAccessChannel, + internal.StableChannel, + internal.RockSolidChannel, + "v1.6.5", "v1.7.1"}, + }, + { + // Anchor refers to a tag the registry doesn't carry. We must + // not invent a tag — restoreInclusiveAnchors only restores + // anchors that exist in `available`. Result is just the + // latest-patch-per-minor set. + name: ">= anchor that the registry does not have is silently skipped", + filter: Filter{ + logger: logger, + modules: map[string]VersionConstraint{ + "module1": geConstraint(">=1.40.0"), + }, + }, + mod: &Module{ + Name: "module1", + Releases: []string{ + "v1.40.1", "v1.40.2", + "v1.41.0", + }, + }, + want: []string{ + internal.AlphaChannel, + internal.BetaChannel, + internal.EarlyAccessChannel, + internal.StableChannel, + internal.RockSolidChannel, + "v1.40.2", "v1.41.0"}, + }, { name: "happy path: exact match", filter: Filter{ diff --git a/internal/mirror/modules/pull_modules_test.go b/internal/mirror/modules/pull_modules_test.go index 074e9a69..b3c9b820 100644 --- a/internal/mirror/modules/pull_modules_test.go +++ b/internal/mirror/modules/pull_modules_test.go @@ -59,46 +59,75 @@ var defaultRegistryVersions = []string{"v1.40.0", "v1.40.1", "v1.41.0", "v1.45.2 // Tests: which versions get pulled // ============================================================================= -// Regression for --include-module @: every tag in the registry -// that satisfies the semver constraint must end up in the download list, -// not only the tag pinned by the release channel snapshot. +// Regression for --include-module @: every (major, minor) bucket +// in the registry that satisfies the semver constraint must contribute exactly +// its highest patch to the download list. This pins down two contracts at the +// pull-flow boundary: +// +// 1. Constraints reject everything outside their range (negative side). +// 2. Constraints collapse same-minor patches to the latest one (the issue #220 +// fix: without this, `module@v1.6.0` would pull v1.6.0..v1.6.5 verbatim). +// +// The version pinned by the release channel snapshot (`channelVersion`) is +// always pulled in addition to the constraint-derived set, so it must appear +// in every wantTags below. func TestPullModules_SemverConstraintPullsAllMatchingTags(t *testing.T) { registryVersions := []string{ "v1.39.0", + // Two patches in 1.40.x to exercise the per-minor collapse. "v1.40.0", "v1.40.1", "v1.41.0", "v1.42.0", "v1.43.0", channelVersion, } cases := []struct { - name string - constraint string // text after "@" in --include-module - wantTags []string // tags expected to land in the download list - rejectTag string // tag present in the registry that the constraint must reject + name string + constraint string // text after "@" in --include-module + wantTags []string // tags expected to land in the download list + rejectTags []string // tags present in the registry that the constraint must reject (out of range or older patch in same minor) }{ { - name: "implicit caret (1.40.0 -> ^1.40.0)", + // 1.40.x collapses to v1.40.1 (latest patch). 1.41/1.42/1.43 each + // have a single tag, so they survive untouched. + name: "implicit caret (1.40.0 -> ^1.40.0) keeps only latest patch per minor", constraint: "1.40.0", - wantTags: []string{"v1.40.0", "v1.40.1", "v1.41.0", "v1.42.0", "v1.43.0", channelVersion}, - rejectTag: "v1.39.0", + wantTags: []string{"v1.40.1", "v1.41.0", "v1.42.0", "v1.43.0", channelVersion}, + rejectTags: []string{"v1.39.0", "v1.40.0"}, }, { - name: "explicit caret (^1.40.0)", + name: "explicit caret (^1.40.0) keeps only latest patch per minor", constraint: "^1.40.0", - wantTags: []string{"v1.40.0", "v1.40.1", "v1.41.0", "v1.42.0", "v1.43.0", channelVersion}, - rejectTag: "v1.39.0", + wantTags: []string{"v1.40.1", "v1.41.0", "v1.42.0", "v1.43.0", channelVersion}, + rejectTags: []string{"v1.39.0", "v1.40.0"}, }, { - name: "tilde (~1.40.0 - patch only)", + // Tilde collapses everything to a single (1.40.x) bucket; only + // v1.40.1 (the latest patch) makes it through. + name: "tilde (~1.40.0 - patch only) keeps only latest patch", constraint: "~1.40.0", - wantTags: []string{"v1.40.0", "v1.40.1", channelVersion}, - rejectTag: "v1.41.0", + wantTags: []string{"v1.40.1", channelVersion}, + rejectTags: []string{"v1.40.0", "v1.41.0"}, }, { - name: "explicit range (>=1.40.0 <1.43.0)", + // `>=1.40.0` literally names v1.40.0 — the equality is part of + // the operator and the user's explicit ask MUST be honoured. + // v1.40.x has two patches: the anchor v1.40.0 stays AND the + // latest patch v1.40.1 stays. Other minors collapse to their + // latest patch as usual; v1.43.0 is excluded by the strict < + // upper bound. + name: "explicit range (>=1.40.0 <1.43.0) preserves >= anchor and keeps latest patch per minor", constraint: ">=1.40.0 <1.43.0", wantTags: []string{"v1.40.0", "v1.40.1", "v1.41.0", "v1.42.0", channelVersion}, - rejectTag: "v1.43.0", + rejectTags: []string{"v1.43.0"}, + }, + { + // Bare `>=1.40.0` with no upper bound. Anchor v1.40.0 is kept, + // 1.40.x latest patch v1.40.1 is also kept (per-minor rule), + // 1.41/1.42/1.43 collapse to their (only) tag. + name: "bare >= preserves anchor and keeps latest patch in same minor", + constraint: ">=1.40.0", + wantTags: []string{"v1.40.0", "v1.40.1", "v1.41.0", "v1.42.0", "v1.43.0", channelVersion}, + rejectTags: []string{"v1.39.0"}, }, } @@ -112,8 +141,10 @@ func TestPullModules_SemverConstraintPullsAllMatchingTags(t *testing.T) { got := pulledModuleVersionRefs(t, svc, testModuleName) assert.ElementsMatch(t, taggedModuleRefs(testModuleName, tc.wantTags), got) - assert.NotContains(t, got, taggedModuleRef(testModuleName, tc.rejectTag), - "constraint %q must reject %s", tc.constraint, tc.rejectTag) + for _, rejected := range tc.rejectTags { + assert.NotContains(t, got, taggedModuleRef(testModuleName, rejected), + "constraint %q must reject %s (out-of-range or older patch in same minor)", tc.constraint, rejected) + } }) } } @@ -121,6 +152,10 @@ func TestPullModules_SemverConstraintPullsAllMatchingTags(t *testing.T) { // Each --include-module flag carries its own constraint - the matcher must // scope to the named module and not leak across modules. Mixes a semver and // an exact constraint to exercise both filter branches in one shot. +// +// console is wired with two patches in the same minor (v1.40.0, v1.40.1) so +// that the per-minor collapse rule is observable: only the latest patch +// (v1.40.1) survives. func TestPullModules_PerModuleConstraintIsolation(t *testing.T) { const ( consoleName = "console" @@ -132,7 +167,7 @@ func TestPullModules_PerModuleConstraintIsolation(t *testing.T) { addModule(reg, commanderName, "v0.5.1", []string{"v0.5.0", "v0.5.1", "v0.6.0"}) filter := mustNewFilter(t, FilterTypeWhitelist, - consoleName+"@~1.40.0", // tilde matches v1.40.x only + consoleName+"@~1.40.0", // tilde matches v1.40.x; collapses to latest patch v1.40.1 commanderName+"@=v0.6.0", // exact tag ) svc := newService(t, pkgclient.Adapt(upfake.NewClient(reg)), filter) @@ -140,15 +175,82 @@ func TestPullModules_PerModuleConstraintIsolation(t *testing.T) { require.NoError(t, svc.PullModules(context.Background())) assert.ElementsMatch(t, - taggedModuleRefs(consoleName, []string{"v1.40.0", "v1.40.1"}), + taggedModuleRefs(consoleName, []string{"v1.40.1"}), pulledModuleVersionRefs(t, svc, consoleName), - "console: tilde must match only v1.40.x") + "console: tilde must match only v1.40.x and collapse to the latest patch (v1.40.1)") + assert.NotContains(t, pulledModuleVersionRefs(t, svc, consoleName), + taggedModuleRef(consoleName, "v1.40.0"), + "console: older patch v1.40.0 must be dropped by the per-minor latest-patch filter") assert.ElementsMatch(t, taggedModuleRefs(commanderName, []string{"v0.6.0"}), pulledModuleVersionRefs(t, svc, commanderName), "commander: exact must match only v0.6.0 - no leak from console's tilde") } +// TestPullModules_Issue220_LatestPatchPerMinor reproduces the exact registry +// shape from https://github.com/deckhouse/deckhouse-cli/issues/220: +// +// d8 mirror pull --include-module code@v1.6.0 +// +// against a registry that publishes +// +// v1.6.0, v1.6.1, v1.6.2, v1.6.3, v1.6.4, v1.6.5, +// v1.7.0, v1.7.1 +// +// for the `code` module. Before the filterOnlyLatestPatches policy was +// applied at the module-filter level, the implicit caret expansion of +// `v1.6.0` (~ ^v1.6.0 ~ >=1.6.0 <2.0.0) made the puller download all eight +// version-tagged release images. The user observed exactly this: 8 version +// tags + 5 channel aliases = 13 release-channel pulls per module. +// +// Post-fix invariants: +// - The version-tagged module list MUST contain only the latest patch in +// each (major, minor) — v1.6.5 and v1.7.1. +// - Older patches in the same minor (v1.6.0..v1.6.4, v1.7.0) MUST be +// absent, because the user can re-pull them deliberately with +// `--include-module code@=v1.6.2`. +// - The release-channel set is a separate concern and is *not* re-asserted +// here; it is covered by TestPullModules_PerModuleConstraintIsolation +// and the LTS test below. +func TestPullModules_Issue220_LatestPatchPerMinor(t *testing.T) { + const ( + moduleName = "code" + // Channel snapshot points at the latest 1.6.x patch, the way a real + // dev registry would. This rules out the channel snapshot accidentally + // dragging an older patch back into the pull list. + issueChannelVersion = "v1.6.5" + ) + registryVersions := []string{ + "v1.6.0", "v1.6.1", "v1.6.2", "v1.6.3", "v1.6.4", "v1.6.5", + "v1.7.0", "v1.7.1", + } + + reg := singleModuleRegistry(moduleName, issueChannelVersion, registryVersions) + filter := mustNewFilter(t, FilterTypeWhitelist, moduleName+"@v1.6.0") + svc := newService(t, pkgclient.Adapt(upfake.NewClient(reg)), filter) + + require.NoError(t, svc.PullModules(context.Background())) + + got := pulledModuleVersionRefs(t, svc, moduleName) + + // Expected: only the latest patch per (major, minor). The channel snapshot + // (v1.6.5) coincides with the 1.6.x latest patch, so deduplication folds + // it in transparently. + wantLatestPatches := []string{"v1.6.5", "v1.7.1"} + assert.ElementsMatch(t, + taggedModuleRefs(moduleName, wantLatestPatches), + got, + "issue #220: --include-module code@v1.6.0 must collapse to one tag per minor") + + // Negative side: every older patch the user reported as wasted MUST be + // absent from the pull list. This is the headline regression — losing + // any of these assertions means the optimization was undone. + for _, dropped := range []string{"v1.6.0", "v1.6.1", "v1.6.2", "v1.6.3", "v1.6.4", "v1.7.0"} { + assert.NotContains(t, got, taggedModuleRef(moduleName, dropped), + "issue #220: older patch %s must be dropped by the latest-patch-per-minor filter", dropped) + } +} + // ============================================================================= // Tests: per-module ListTags policy // =============================================================================