Skip to content

Commit 7c7c19b

Browse files
authored
[deckhouse-cli] mirror: support multiple version constraints for the same module/package (#379)
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1 parent 57dc52f commit 7c7c19b

5 files changed

Lines changed: 285 additions & 68 deletions

File tree

internal/mirror/modules/constraints.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,101 @@ func (e *ExactTagConstraint) IsExact() bool {
257257
func (e *ExactTagConstraint) HasChannelAlias() bool {
258258
return e.channel != ""
259259
}
260+
261+
// MultiConstraint is the OR-combination of several constraints declared for
262+
// the same name. It is produced when a user repeats a name on the command
263+
// line, e.g. `--include-package test@=v0.0.2 --include-package test@=v0.0.3`,
264+
// so that all of the named versions are pulled instead of only the last one.
265+
//
266+
// A value Match-es when ANY sub-constraint matches. It is considered exact
267+
// only when EVERY sub-constraint is exact (so a set of pinned tags keeps the
268+
// "no release-channel discovery, no tag listing" fast paths), and it advertises
269+
// a channel alias when ANY sub-constraint does.
270+
type MultiConstraint struct {
271+
constraints []VersionConstraint
272+
}
273+
274+
// Constraints returns the sub-constraints in declaration order.
275+
func (m *MultiConstraint) Constraints() []VersionConstraint {
276+
return m.constraints
277+
}
278+
279+
func (m *MultiConstraint) Match(version interface{}) bool {
280+
for _, c := range m.constraints {
281+
if c.Match(version) {
282+
return true
283+
}
284+
}
285+
286+
return false
287+
}
288+
289+
func (m *MultiConstraint) IsExact() bool {
290+
for _, c := range m.constraints {
291+
if !c.IsExact() {
292+
return false
293+
}
294+
}
295+
296+
return true
297+
}
298+
299+
func (m *MultiConstraint) HasChannelAlias() bool {
300+
for _, c := range m.constraints {
301+
if c.HasChannelAlias() {
302+
return true
303+
}
304+
}
305+
306+
return false
307+
}
308+
309+
// mergeConstraints OR-combines an already-registered constraint with an
310+
// additional one declared for the same name, flattening nested
311+
// MultiConstraints so repeated declarations stay a single flat list.
312+
func mergeConstraints(existing, additional VersionConstraint) VersionConstraint {
313+
if multi, ok := existing.(*MultiConstraint); ok {
314+
multi.constraints = append(multi.constraints, additional)
315+
return multi
316+
}
317+
318+
return &MultiConstraint{constraints: []VersionConstraint{existing, additional}}
319+
}
320+
321+
// ExactConstraintsOf flattens a constraint into the exact-tag constraints it
322+
// contains. A plain ExactTagConstraint yields itself; a MultiConstraint yields
323+
// every exact sub-constraint; anything else yields nothing.
324+
func ExactConstraintsOf(c VersionConstraint) []*ExactTagConstraint {
325+
switch t := c.(type) {
326+
case *ExactTagConstraint:
327+
return []*ExactTagConstraint{t}
328+
case *MultiConstraint:
329+
var out []*ExactTagConstraint
330+
for _, sub := range t.constraints {
331+
out = append(out, ExactConstraintsOf(sub)...)
332+
}
333+
334+
return out
335+
default:
336+
return nil
337+
}
338+
}
339+
340+
// SemverConstraintsOf flattens a constraint into the semantic-version
341+
// constraints it contains, used by the proxy-registry probe which can only
342+
// walk semver ranges.
343+
func SemverConstraintsOf(c VersionConstraint) []*SemanticVersionConstraint {
344+
switch t := c.(type) {
345+
case *SemanticVersionConstraint:
346+
return []*SemanticVersionConstraint{t}
347+
case *MultiConstraint:
348+
var out []*SemanticVersionConstraint
349+
for _, sub := range t.constraints {
350+
out = append(out, SemverConstraintsOf(sub)...)
351+
}
352+
353+
return out
354+
default:
355+
return nil
356+
}
357+
}

internal/mirror/modules/filter.go

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,30 +58,35 @@ func NewFilter(filterExpressions []string, filterType FilterType) (*Filter, erro
5858
}
5959

6060
for _, filterExpr := range filterExpressions {
61-
moduleName, versionStr, hasVersion := strings.Cut(strings.TrimSpace(filterExpr), "@")
61+
name, versionStr, hasVersion := strings.Cut(strings.TrimSpace(filterExpr), "@")
6262

63-
moduleName = strings.TrimSpace(moduleName)
64-
if moduleName == "" {
65-
return nil, fmt.Errorf("Malformed filter expression %q: empty module name", filterExpr)
63+
name = strings.TrimSpace(name)
64+
if name == "" {
65+
return nil, fmt.Errorf("Malformed filter expression %q: empty name", filterExpr)
6666
}
6767

68-
if _, moduleRedeclared := filter.modules[moduleName]; moduleRedeclared {
69-
return nil, fmt.Errorf("Malformed filter expression: module %s is declared multiple times", moduleName)
68+
var constraint VersionConstraint
69+
if !hasVersion {
70+
constraint, _ = NewSemanticVersionConstraint(">=0.0.0")
71+
} else {
72+
var err error
73+
74+
constraint, err = parseVersionConstraint(versionStr)
75+
if err != nil {
76+
return nil, err
77+
}
7078
}
7179

72-
if !hasVersion {
73-
constraint, _ := NewSemanticVersionConstraint(">=0.0.0")
74-
filter.modules[moduleName] = constraint
80+
// Repeating a name is allowed and OR-combines the constraints, so a
81+
// user can pull several pinned versions at once, e.g.
82+
// `--include-package test@=v0.0.2 --include-package test@=v0.0.3`.
83+
if existing, redeclared := filter.modules[name]; redeclared {
84+
filter.modules[name] = mergeConstraints(existing, constraint)
7585

7686
continue
7787
}
7888

79-
constraint, err := parseVersionConstraint(versionStr)
80-
if err != nil {
81-
return nil, err
82-
}
83-
84-
filter.modules[moduleName] = constraint
89+
filter.modules[name] = constraint
8590
}
8691

8792
return filter, nil
@@ -237,6 +242,21 @@ func (f *Filter) VersionsToMirror(mod *Module) []string {
237242
return nil
238243
}
239244

245+
return deduplicateTags(versionsForConstraint(constraint, mod))
246+
}
247+
248+
// versionsForConstraint resolves a single constraint (or, recursively, every
249+
// sub-constraint of a MultiConstraint) into concrete tags to pull.
250+
func versionsForConstraint(constraint VersionConstraint, mod *Module) []string {
251+
if multi, ok := constraint.(*MultiConstraint); ok {
252+
var tags = make([]string, 0, len(multi.constraints))
253+
for _, sub := range multi.constraints {
254+
tags = append(tags, versionsForConstraint(sub, mod)...)
255+
}
256+
257+
return tags
258+
}
259+
240260
if constraint.IsExact() {
241261
exact, isExactTag := constraint.(*ExactTagConstraint)
242262
if !isExactTag {
@@ -270,6 +290,28 @@ func (f *Filter) VersionsToMirror(mod *Module) []string {
270290
return tags
271291
}
272292

293+
// deduplicateTags removes duplicate tags while preserving first-seen order,
294+
// so overlapping sub-constraints of a MultiConstraint don't yield repeats.
295+
func deduplicateTags(tags []string) []string {
296+
if len(tags) == 0 {
297+
return tags
298+
}
299+
300+
seen := make(map[string]struct{}, len(tags))
301+
out := make([]string, 0, len(tags))
302+
303+
for _, tag := range tags {
304+
if _, dup := seen[tag]; dup {
305+
continue
306+
}
307+
308+
seen[tag] = struct{}{}
309+
out = append(out, tag)
310+
}
311+
312+
return out
313+
}
314+
273315
// restoreInclusiveAnchors re-introduces any anchor versions (named via >=/<=
274316
// in the user's constraint string) that were dropped by filterOnlyLatestPatches.
275317
//

internal/mirror/modules/filter_test.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ func TestNewFilter(t *testing.T) {
6161
wantErr: true,
6262
},
6363
{
64-
name: "duplicate module",
64+
name: "duplicate module is merged, not rejected",
6565
expressions: []string{"module@1.2.3", "module@2.3.4"},
66-
wantErr: true,
66+
wantErr: false,
6767
},
6868
}
6969

@@ -555,6 +555,33 @@ func TestFilter_VersionsToMirror(t *testing.T) {
555555
},
556556
want: []string{"v1.3.0"},
557557
},
558+
{
559+
// Several exact tags pinned for the same name (the user repeated
560+
// --include-package test@=vX). They merge into a MultiConstraint
561+
// of exacts, which stays exact so no release channels are added,
562+
// and every pinned tag is pulled.
563+
name: "multiple exact tags pinned for the same name pull all of them",
564+
filter: Filter{
565+
logger: logger,
566+
modules: map[string]VersionConstraint{
567+
"module1": mergeConstraints(
568+
mergeConstraints(
569+
NewExactTagConstraint("v0.0.2"),
570+
NewExactTagConstraint("v0.0.3"),
571+
),
572+
NewExactTagConstraint("v0.0.8"),
573+
),
574+
},
575+
},
576+
mod: &Module{
577+
Name: "module1",
578+
Releases: []string{
579+
internal.AlphaChannel,
580+
internal.StableChannel,
581+
"v0.0.1", "v0.0.2", "v0.0.3", "v0.0.4", "v0.0.8"},
582+
},
583+
want: []string{"v0.0.2", "v0.0.3", "v0.0.8"},
584+
},
558585
}
559586
for _, tt := range tests {
560587
t.Run(tt.name, func(t *testing.T) {

internal/mirror/modules/modules.go

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,8 @@ func (svc *Service) listTagsIfConstrained(ctx context.Context, moduleName string
509509
// this function, and a future constraint type would need its own
510510
// proxy-aware code path.
511511
func (svc *Service) probeModuleTags(ctx context.Context, moduleName string, constraint VersionConstraint) ([]string, error) {
512-
semverConstraint, ok := constraint.(*SemanticVersionConstraint)
513-
if !ok {
512+
semverConstraints := SemverConstraintsOf(constraint)
513+
if len(semverConstraints) == 0 {
514514
// Be loud — silently falling back to ListTags here would defeat
515515
// the whole purpose of --proxy-registry on a registry that
516516
// refuses catalog access.
@@ -532,14 +532,17 @@ func (svc *Service) probeModuleTags(ctx context.Context, moduleName string, cons
532532
return false, fmt.Errorf("check module %s tag %q: %w", moduleName, tag, err)
533533
}
534534

535-
versions, err := ProbeAvailableVersions(ctx, semverConstraint, check)
536-
if err != nil {
537-
return nil, fmt.Errorf("probe tags for module %s: %w", moduleName, err)
538-
}
535+
tags := make([]string, 0)
536+
537+
for _, semverConstraint := range semverConstraints {
538+
versions, err := ProbeAvailableVersions(ctx, semverConstraint, check)
539+
if err != nil {
540+
return nil, fmt.Errorf("probe tags for module %s: %w", moduleName, err)
541+
}
539542

540-
tags := make([]string, 0, len(versions))
541-
for _, v := range versions {
542-
tags = append(tags, "v"+v.String())
543+
for _, v := range versions {
544+
tags = append(tags, "v"+v.String())
545+
}
543546
}
544547

545548
return tags, nil
@@ -1028,32 +1031,40 @@ func (svc *Service) applyChannelAliases(moduleName string) error {
10281031
return nil
10291032
}
10301033

1031-
exact, ok := constraint.(*ExactTagConstraint)
1032-
if !ok {
1033-
return nil
1034-
}
1035-
10361034
moduleLayout := svc.layout.Module(moduleName)
10371035
if moduleLayout == nil || moduleLayout.ModulesReleaseChannels == nil {
10381036
return nil
10391037
}
10401038

1041-
desc, err := layouts.FindImageDescriptorByTag(moduleLayout.ModulesReleaseChannels.Path(), exact.Tag())
1042-
if err != nil {
1043-
if errors.Is(err, layouts.ErrImageNotFound) {
1044-
return nil
1045-
}
1039+
exacts := ExactConstraintsOf(constraint)
10461040

1047-
return err
1048-
}
1041+
// A single pinned tag without an explicit +channel suffix is published to
1042+
// every release channel (the historical --deckhouse-tag-like behaviour).
1043+
// When several tags are pinned at once, propagating each to all channels
1044+
// would just clobber one another, so only tags that name their own channel
1045+
// (=vX.Y.Z+stable) are aliased; the rest are simply pulled as-is.
1046+
propagateToAllChannels := len(exacts) == 1 && !exacts[0].HasChannelAlias()
1047+
1048+
for _, exact := range exacts {
1049+
desc, err := layouts.FindImageDescriptorByTag(moduleLayout.ModulesReleaseChannels.Path(), exact.Tag())
1050+
if err != nil {
1051+
if errors.Is(err, layouts.ErrImageNotFound) {
1052+
continue
1053+
}
10491054

1050-
if exact.HasChannelAlias() {
1051-
if err := layouts.TagImage(moduleLayout.ModulesReleaseChannels.Path(), desc.Digest, exact.Channel()); err != nil {
10521055
return err
10531056
}
1054-
} else {
1055-
// Tag all channels with this version
1056-
for _, channel := range append(internal.GetAllDefaultReleaseChannels(), internal.LTSChannel) {
1057+
1058+
var channels []string
1059+
1060+
switch {
1061+
case exact.HasChannelAlias():
1062+
channels = []string{exact.Channel()}
1063+
case propagateToAllChannels:
1064+
channels = append(internal.GetAllDefaultReleaseChannels(), internal.LTSChannel)
1065+
}
1066+
1067+
for _, channel := range channels {
10571068
if err := layouts.TagImage(moduleLayout.ModulesReleaseChannels.Path(), desc.Digest, channel); err != nil {
10581069
return err
10591070
}

0 commit comments

Comments
 (0)