diff --git a/internal/mirror/platform/platform.go b/internal/mirror/platform/platform.go index 96bdc179..e73e65d7 100644 --- a/internal/mirror/platform/platform.go +++ b/internal/mirror/platform/platform.go @@ -27,6 +27,7 @@ import ( "maps" "path/filepath" "slices" + "strconv" "strings" "time" @@ -305,20 +306,26 @@ type parsedTags struct { // channelVersions represents the fetched versions from release channels type channelVersions map[string]*semver.Version +// suspendedChannels is the set of release channels marked "suspend": true in their version.json. +// Empty when --ignore-suspend is set. +type suspendedChannels map[string]struct{} + // versionsToMirror determines which Deckhouse release versions should be mirrored // It collects current versions from all release channels and filters available releases // to include only versions that should be mirrored based on the mirroring strategy func (svc *Service) versionsToMirror(ctx context.Context, tagsToMirror []string) (*VersionsToMirrorResult, error) { isTagsMirror := len(tagsToMirror) > 0 if isTagsMirror { - svc.userLogger.Infof("Skipped releases lookup as tag %q is specifically requested with --deckhouse-tag", svc.options.TargetTag) + // Release channels are still read below: they resolve channel-name tags and channel + // aliases. Only the rock-solid..alpha version range discovery is skipped. + svc.userLogger.Infof("Skipped releases range discovery as tag %q is specifically requested with --deckhouse-tag", svc.options.TargetTag) } // Parse input tags into categories parsed := svc.parseInputTags(tagsToMirror) // Fetch current versions from all release channels - channelVersions, err := svc.fetchReleaseChannelVersions(ctx) + channelVersions, suspended, err := svc.fetchReleaseChannels(ctx) if err != nil && !errors.Is(err, ErrSomeChannelsFailed) { return nil, fmt.Errorf("failed to fetch release channel versions: %w", err) } @@ -330,6 +337,14 @@ func (svc *Service) versionsToMirror(ctx context.Context, tagsToMirror []string) // Match channels and versions based on requested tags versions, matchedChannels := svc.matchChannelsToTags(tagsToMirror, channelVersions, parsed.semverVersions) + // Refuse only when the request actually resolves to a suspended channel. In full discovery + // every channel is matched, so any suspension is fatal, as before. A specific --deckhouse-tag + // that matches no channel is unaffected by suspensions elsewhere in the registry. + // Runs before expandVersionRange, which relies on the alpha and rock-solid snapshots. + if err := checkSuspendedChannels(matchedChannels, suspended); err != nil { + return nil, err + } + // If specific tags requested, return immediately if isTagsMirror { return &VersionsToMirrorResult{ @@ -403,31 +418,42 @@ func (svc *Service) parseInputTags(tags []string) parsedTags { return result } -// fetchReleaseChannelVersions retrieves current versions from all release channels -func (svc *Service) fetchReleaseChannelVersions(ctx context.Context) (channelVersions, error) { +// fetchReleaseChannels retrieves the current state of every release channel: +// the version each one points to and which of them are suspended. +// Suspended channels are reported through suspendedChannels rather than failing the fetch: +// whether a suspension is fatal depends on the channels the request resolves to, which is +// only known after matchChannelsToTags. +func (svc *Service) fetchReleaseChannels(ctx context.Context) (channelVersions, suspendedChannels, error) { defaultChannels := internal.GetAllDefaultReleaseChannels() allChannels := slices.Concat(defaultChannels, []string{internal.LTSChannel}) channelResults := make(map[string]releaseChannelVersionResult, len(allChannels)) + suspended := make(suspendedChannels, len(allChannels)) // - LTS exists: fetch all channels (default + LTS), missing default channels are OK // - LTS doesn't exist: all default channels are required - ltsVersion, ltsErr := svc.getReleaseChannelVersionFromRegistry(ctx, internal.LTSChannel) + ltsInfo, ltsErr := svc.getReleaseChannelInfoFromRegistry(ctx, internal.LTSChannel) if ltsErr != nil && !errors.Is(ltsErr, client.ErrImageNotFound) { - return nil, fmt.Errorf("get LTS release channel: %w", ltsErr) + return nil, nil, fmt.Errorf("get LTS release channel: %w", ltsErr) } + // hasLTS means "found", not "found and not suspended": a suspended LTS channel must not + // flip the CSE branch into requiring every default channel. hasLTS := ltsErr == nil if hasLTS { // Other channels will be appended below (if exists) - channelResults[internal.LTSChannel] = releaseChannelVersionResult{ver: ltsVersion} + channelResults[internal.LTSChannel] = releaseChannelVersionResult{ver: ltsInfo.version} + + if ltsInfo.suspended { + suspended[internal.LTSChannel] = struct{}{} + } } for _, channel := range defaultChannels { - version, err := svc.getReleaseChannelVersionFromRegistry(ctx, channel) + info, err := svc.getReleaseChannelInfoFromRegistry(ctx, channel) // Real error (network, auth) - fail immediately if err != nil && !errors.Is(err, client.ErrImageNotFound) { - return nil, fmt.Errorf("failed to get release channel version from registry for channel %s: %w", channel, err) + return nil, nil, fmt.Errorf("failed to get release channel version from registry for channel %s: %w", channel, err) } // Missing default channels are OK when LTS is present (e.g., CSE edition) @@ -435,11 +461,17 @@ func (svc *Service) fetchReleaseChannelVersions(ctx context.Context) (channelVer continue } - channelResults[channel] = releaseChannelVersionResult{ver: version, err: err} + if info.suspended { + suspended[channel] = struct{}{} + } + + channelResults[channel] = releaseChannelVersionResult{ver: info.version, err: err} } // Validate and extract successful channel versions - return svc.validateChannelResults(channelResults) + versions, err := svc.validateChannelResults(channelResults) + + return versions, suspended, err } var ErrSomeChannelsFailed = errors.New("some channels failed to fetch") @@ -506,6 +538,37 @@ func (svc *Service) tagMatchesChannel(tag, channelName string, channelVersion *s return tag == channelName || tag == "v"+channelVersion.String() } +// checkSuspendedChannels refuses the pull when a channel the request resolves to is suspended. +// Suspension of channels the request does not resolve to is not an error. +func checkSuspendedChannels(matchedChannels []string, suspended suspendedChannels) error { + if len(suspended) == 0 { + return nil + } + + blocked := make([]string, 0, len(suspended)) + + for _, channel := range matchedChannels { + if _, ok := suspended[channel]; ok { + blocked = append(blocked, strconv.Quote(channel)) + } + } + + if len(blocked) == 0 { + return nil + } + + // matchedChannels comes from a map, so sort for a stable message. + slices.Sort(blocked) + + noun := "channel" + if len(blocked) > 1 { + noun = "channels" + } + + return fmt.Errorf("source registry contains suspended release %s %s, try again later (use --ignore-suspend to override)", + noun, strings.Join(blocked, ", ")) +} + // expandVersionRange expands the version range for full discovery mode func (svc *Service) expandVersionRange(ctx context.Context, channelVersions channelVersions, baseVersions []*semver.Version) ([]*semver.Version, error) { semverConstraint, hasSemverConstraint := svc.options.IncludeConstraint.(*modules.SemanticVersionConstraint) @@ -674,7 +737,7 @@ func filterVersionsByConstraint(versions []*semver.Version, constraint *modules. // filterChannelsByConstraint drops release channels whose current snapshot // version falls outside the user-supplied constraint. Channels that resolve -// to a missing version (defensive — fetchReleaseChannelVersions normally +// to a missing version (defensive — fetchReleaseChannels 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 { @@ -765,37 +828,47 @@ func mapKeysToSlice(m map[string]struct{}) []string { return keys } -// getReleaseChannelVersionFromRegistry retrieves the current version for a specific release channel -// It fetches the release image and metadata, validates the channel is not suspended, -// and stores the image in the layout for later use -func (svc *Service) getReleaseChannelVersionFromRegistry(ctx context.Context, releaseChannel string) (*semver.Version, error) { +// releaseChannelInfo is the current state a release channel publishes in its version.json: +// the version it points to and whether it is suspended. +type releaseChannelInfo struct { + version *semver.Version + suspended bool +} + +// getReleaseChannelInfoFromRegistry retrieves the current state of a specific release channel. +// +// Suspension is reported as data, not as an error: at this point we do not yet know whether the +// request resolves to this channel. versionsToMirror is the enforcement point. +// A suspended channel still yields its version (needed to match it against the requested tag), +// but never enters the download list. +func (svc *Service) getReleaseChannelInfoFromRegistry(ctx context.Context, releaseChannel string) (releaseChannelInfo, error) { image, err := svc.deckhouseService.ReleaseChannels().GetImage(ctx, releaseChannel) if err != nil { - return nil, fmt.Errorf("get %s release channel image: %w", releaseChannel, err) + return releaseChannelInfo{}, fmt.Errorf("get %s release channel image: %w", releaseChannel, err) } meta, err := svc.deckhouseService.ReleaseChannels().GetMetadata(ctx, releaseChannel) if err != nil { - return nil, fmt.Errorf("cannot get %s release channel version.json: %w", releaseChannel, err) - } - - if meta.Suspend && !svc.options.IgnoreSuspend { - return nil, fmt.Errorf("source registry contains suspended release channel %q, try again later (use --ignore-suspend to override)", releaseChannel) + return releaseChannelInfo{}, fmt.Errorf("cannot get %s release channel version.json: %w", releaseChannel, err) } ver, err := semver.NewVersion(meta.Version) if err != nil { - return nil, fmt.Errorf("release channel version is not semver %q: %w", meta.Version, err) + return releaseChannelInfo{}, fmt.Errorf("release channel version is not semver %q: %w", meta.Version, err) + } + + if meta.Suspend && !svc.options.IgnoreSuspend { + return releaseChannelInfo{version: ver, suspended: true}, nil } digest, err := image.Digest() if err != nil { - return nil, fmt.Errorf("cannot get %s release channel image digest: %w", releaseChannel, err) + return releaseChannelInfo{}, fmt.Errorf("cannot get %s release channel image digest: %w", releaseChannel, err) } imageMeta, err := image.GetMetadata() if err != nil { - return nil, fmt.Errorf("cannot get %s release channel image tag reference: %w", releaseChannel, err) + return releaseChannelInfo{}, fmt.Errorf("cannot get %s release channel image tag reference: %w", releaseChannel, err) } svc.userLogger.Debugf("image reference: %s@%s", imageMeta, digest.String()) @@ -804,7 +877,7 @@ func (svc *Service) getReleaseChannelVersionFromRegistry(ctx context.Context, re // Just record in downloadList for later pull svc.downloadList.DeckhouseReleaseChannel[imageMeta.GetTagReference()] = puller.NewImageMeta(meta.Version, imageMeta.GetTagReference(), &digest) - return ver, nil + return releaseChannelInfo{version: ver}, nil } func (svc *Service) pullDeckhousePlatform(ctx context.Context, tagsToMirror []string) error { diff --git a/internal/mirror/platform/platform_test.go b/internal/mirror/platform/platform_test.go index 04bcb758..7217ae76 100644 --- a/internal/mirror/platform/platform_test.go +++ b/internal/mirror/platform/platform_test.go @@ -312,3 +312,54 @@ func TestService_versionsToMirror_Deduplication(t *testing.T) { // No custom tags assert.Empty(t, result.CustomTags) } + +func TestCheckSuspendedChannels(t *testing.T) { + tests := []struct { + name string + matchedChannels []string + suspended suspendedChannels + wantErr string + }{ + { + name: "no suspended channels", + matchedChannels: []string{"alpha", "stable"}, + suspended: suspendedChannels{}, + }, + { + name: "suspension outside of matched channels is ignored", + matchedChannels: []string{"stable"}, + suspended: suspendedChannels{"rock-solid": {}}, + }, + { + name: "no matched channels", + matchedChannels: nil, + suspended: suspendedChannels{"rock-solid": {}}, + }, + { + name: "single matched suspended channel keeps the legacy message", + matchedChannels: []string{"rock-solid"}, + suspended: suspendedChannels{"rock-solid": {}}, + wantErr: `source registry contains suspended release channel "rock-solid", try again later (use --ignore-suspend to override)`, + }, + { + name: "multiple matched suspended channels are sorted and pluralized", + matchedChannels: []string{"rock-solid", "stable", "alpha"}, + suspended: suspendedChannels{"rock-solid": {}, "alpha": {}}, + wantErr: `source registry contains suspended release channels "alpha", "rock-solid", try again later (use --ignore-suspend to override)`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkSuspendedChannels(tt.matchedChannels, tt.suspended) + + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + }) + } +} diff --git a/internal/mirror/platform/pull_platform_test.go b/internal/mirror/platform/pull_platform_test.go index b55b2351..e210867b 100644 --- a/internal/mirror/platform/pull_platform_test.go +++ b/internal/mirror/platform/pull_platform_test.go @@ -55,25 +55,27 @@ userLogger *log.SLogger, } } -// suspendedStub returns a stub where the alpha release channel is suspended -// (version.json contains "suspend": true). -func suspendedStub() localreg.Client { - // reuse the full stub but replace the alpha release-channel image +// suspendedStubWith returns a stub with the five standard release channels +// where every channel listed in suspendedChannels carries "suspend": true in +// its version.json. extraTags are published to the root, install and +// install-standalone repositories only, so they match no channel version. +func suspendedStubWith(suspendedChannels []string, extraTags ...string) localreg.Client { reg := upfake.NewRegistry(stubRootURL) - // Standard channels — alpha is suspended. - channels := map[string]struct { - version string - suspend bool - }{ - "alpha": {version: "v1.72.10", suspend: true}, - "beta": {version: "v1.71.0"}, - "early-access": {version: "v1.70.0"}, - "stable": {version: "v1.69.0"}, - "rock-solid": {version: "v1.68.0"}, + suspended := make(map[string]bool, len(suspendedChannels)) + for _, ch := range suspendedChannels { + suspended[ch] = true } - for ch, data := range channels { - content := fmt.Sprintf(`{"version":%q,"suspend":%v}`, data.version, data.suspend) + + channels := map[string]string{ + "alpha": "v1.72.10", + "beta": "v1.71.0", + "early-access": "v1.70.0", + "stable": "v1.69.0", + "rock-solid": "v1.68.0", + } + for ch, version := range channels { + content := fmt.Sprintf(`{"version":%q,"suspend":%v}`, version, suspended[ch]) img := upfake.NewImageBuilder().WithFile("version.json", content).MustBuild() reg.MustAddImage("release-channel", ch, img) } @@ -81,6 +83,7 @@ func suspendedStub() localreg.Client { // Root + installer repos (same as default stub). versionTags := []string{"alpha", "beta", "early-access", "stable", "rock-solid", "v1.72.10", "v1.71.0", "v1.70.0", "v1.69.0", "v1.68.0", "pr12345"} + versionTags = append(versionTags, extraTags...) for _, tag := range versionTags { img := upfake.NewImageBuilder(). WithFile("version.json", `{"version":"v1.72.10"}`). @@ -93,6 +96,37 @@ func suspendedStub() localreg.Client { return pkgclient.Adapt(upfake.NewClient(reg)) } +// suspendedStub returns a stub where the alpha release channel is suspended +// (version.json contains "suspend": true). +func suspendedStub() localreg.Client { + return suspendedStubWith([]string{"alpha"}) +} + +// suspendedLTSOnlyStub builds a CSE-like registry whose only release channel +// is a suspended LTS. extraTags are published to the root, install and +// install-standalone repositories alongside the LTS version tag. +func suspendedLTSOnlyStub(ltsVersion string, extraTags ...string) localreg.Client { + reg := upfake.NewRegistry(stubRootURL) + + channelImg := upfake.NewImageBuilder(). + WithFile("version.json", fmt.Sprintf(`{"version":%q,"suspend":true}`, ltsVersion)). + MustBuild() + reg.MustAddImage("release-channel", "lts", channelImg) + + tags := append([]string{ltsVersion}, extraTags...) + for _, tag := range tags { + img := upfake.NewImageBuilder(). + WithFile("version.json", fmt.Sprintf(`{"version":%q}`, ltsVersion)). + WithFile("deckhouse/candi/images_digests.json", `{}`). + MustBuild() + reg.MustAddImage("", tag, img) + reg.MustAddImage("install", tag, img) + reg.MustAddImage("install-standalone", tag, img) + } + + return pkgclient.Adapt(upfake.NewClient(reg)) +} + // ---- validatePlatformAccess behaviour ---- func TestPullPlatform_ErrorWhenRegistryEmpty(t *testing.T) { @@ -155,6 +189,162 @@ func TestPullPlatform_IgnoreSuspend_SucceedsWithSuspendedChannel(t *testing.T) { assert.GreaterOrEqual(t, len(svc.downloadList.Deckhouse), 5) } +// ---- TargetTag vs suspended channels ---- + +// TestPullPlatform_DryRun_SemverTag_UnrelatedChannelSuspended_Succeeds is the +// regression for `d8 mirror pull --deckhouse-tag vX.Y.Z` aborting with +// +// failed to get release channel version from registry for channel rock-solid: +// source registry contains suspended release channel "rock-solid", try again later +// +// when the requested version does not belong to the suspended channel at all. +// A suspension may only block pulls that resolve to the suspended channel; +// pinning an unrelated tag must succeed and must not enqueue the suspended +// channel alias. +func TestPullPlatform_DryRun_SemverTag_UnrelatedChannelSuspended_Succeeds(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + // v1.71.5 exists in the registry but is not the current version of any channel. + svc := newDryRunService( + suspendedStubWith([]string{"rock-solid"}, "v1.71.5"), + &Options{TargetTag: "v1.71.5"}, + logger, + userLogger, + ) + err := svc.PullPlatform(context.Background()) + require.NoError(t, err, + "a suspended channel unrelated to the requested tag must not abort the pull") + + rootURL := stubRootURL + assert.Contains(t, svc.downloadList.Deckhouse, rootURL+":v1.71.5") + assert.Len(t, svc.downloadList.Deckhouse, 1, + "only the requested tag must be enqueued for download") + assert.NotContains(t, svc.downloadList.DeckhouseReleaseChannel, + rootURL+"/release-channel:rock-solid", + "suspended channel alias must not be enqueued") +} + +// TestPullPlatform_DryRun_SemverTag_MatchingSuspendedChannel_Fails pins the +// conservative half of the contract: a tag equal to a suspended channel's +// current version resolves to that channel (its alias would be bundled), so +// the pull is refused unless --ignore-suspend is given. +func TestPullPlatform_DryRun_SemverTag_MatchingSuspendedChannel_Fails(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + // v1.72.10 is the current version of the suspended alpha channel. + svc := newDryRunService(suspendedStub(), &Options{TargetTag: "v1.72.10"}, logger, userLogger) + err := svc.PullPlatform(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "suspended") + assert.Contains(t, err.Error(), `"alpha"`) +} + +func TestPullPlatform_DryRun_ChannelTag_SuspendedChannel_Fails(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService(suspendedStub(), &Options{TargetTag: "alpha"}, logger, userLogger) + err := svc.PullPlatform(context.Background()) + + require.Error(t, err, "requesting a suspended channel by name must be refused") + assert.Contains(t, err.Error(), "suspended") + assert.Contains(t, err.Error(), `"alpha"`) +} + +func TestPullPlatform_DryRun_ChannelTag_OtherChannelSuspended_Succeeds(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + // stable is healthy; only alpha is suspended. + svc := newDryRunService(suspendedStub(), &Options{TargetTag: "stable"}, logger, userLogger) + err := svc.PullPlatform(context.Background()) + require.NoError(t, err, + "a suspended channel must not block pulling another, healthy channel") + + rootURL := stubRootURL + assert.Contains(t, svc.downloadList.Deckhouse, rootURL+":v1.69.0") + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + rootURL+"/release-channel:stable") + assert.NotContains(t, svc.downloadList.DeckhouseReleaseChannel, + rootURL+"/release-channel:alpha", + "suspended channel alias must not be enqueued") +} + +func TestPullPlatform_DryRun_CustomTag_ChannelSuspended_Succeeds(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService(suspendedStub(), &Options{TargetTag: "pr12345"}, logger, userLogger) + err := svc.PullPlatform(context.Background()) + require.NoError(t, err, + "custom tags match no channel and must not be affected by suspensions") + + assert.Contains(t, svc.downloadList.Deckhouse, stubRootURL+":pr12345") + assert.Len(t, svc.downloadList.Deckhouse, 1) +} + +func TestPullPlatform_DryRun_IgnoreSuspend_TagMatchingSuspendedChannel_Succeeds(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService( + suspendedStub(), + &Options{TargetTag: "v1.72.10", IgnoreSuspend: true}, + logger, + userLogger, + ) + err := svc.PullPlatform(context.Background()) + require.NoError(t, err, + "--ignore-suspend must override the suspension on the matched channel") + + rootURL := stubRootURL + assert.Contains(t, svc.downloadList.Deckhouse, rootURL+":v1.72.10") + assert.Contains(t, svc.downloadList.DeckhouseReleaseChannel, + rootURL+"/release-channel:alpha", + "with --ignore-suspend the matched channel alias is enqueued as usual") +} + +// TestPullPlatform_DryRun_FullDiscovery_SuspendedLTS_Fails pins two contracts +// at once: a suspended LTS still counts as present, so missing default +// channels stay non-fatal (CSE registries), and full discovery resolves to +// every fetched channel, so the suspension itself is what aborts the pull. +func TestPullPlatform_DryRun_FullDiscovery_SuspendedLTS_Fails(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService(suspendedLTSOnlyStub("v1.68.0"), nil, logger, userLogger) + err := svc.PullPlatform(context.Background()) + + require.Error(t, err) + assert.Contains(t, err.Error(), `suspended release channel "lts"`, + "the failure must come from the suspension, not from the missing default channels") +} + +// TestPullPlatform_DryRun_SuspendedLTS_UnrelatedTag_Succeeds covers the CSE +// variant of the unrelated-suspension scenario: the only channel (LTS) is +// suspended, the requested tag does not match it, so the pull must succeed. +func TestPullPlatform_DryRun_SuspendedLTS_UnrelatedTag_Succeeds(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newDryRunService( + suspendedLTSOnlyStub("v1.68.0", "v1.71.5"), + &Options{TargetTag: "v1.71.5"}, + logger, + userLogger, + ) + err := svc.PullPlatform(context.Background()) + require.NoError(t, err) + + assert.Contains(t, svc.downloadList.Deckhouse, stubRootURL+":v1.71.5") + assert.NotContains(t, svc.downloadList.DeckhouseReleaseChannel, + stubRootURL+"/release-channel:lts", + "suspended LTS alias must not be enqueued") +} + // ---- TargetTag: specific semver ---- func TestPullPlatform_DryRun_SemverTag_PopulatesDownloadList(t *testing.T) { @@ -749,7 +939,7 @@ func ltsOnlySourceStub(ver string) localreg.Client { // // The root cause was platform.NewService seeding the downloadList rootURL // from registryService.GetRoot() (non-edition root) while -// getReleaseChannelVersionFromRegistry fed the same map with edition-scoped +// getReleaseChannelInfo fed the same map with edition-scoped // keys obtained through deckhouseService. The fix is to seed the rootURL // with registryService.GetEditionRoot() so every key in the downloadList // carries the edition segment. diff --git a/internal/mirror/pull_test.go b/internal/mirror/pull_test.go index 370d8f07..ac076e4d 100644 --- a/internal/mirror/pull_test.go +++ b/internal/mirror/pull_test.go @@ -190,6 +190,54 @@ func TestPull_SuspendedChannel_ReturnsError(t *testing.T) { assert.Contains(t, err.Error(), "suspended") } +// TestPull_TargetTag_UnrelatedSuspendedChannel_Succeeds is the end-to-end +// regression for `d8 mirror pull --deckhouse-tag vX.Y.Z` aborting when an +// unrelated release channel is suspended. A suspension may only block pulls +// that resolve to the suspended channel. +func TestPull_TargetTag_UnrelatedSuspendedChannel_Succeeds(t *testing.T) { + reg := upfake.NewRegistry(pullStubRootURL) + channelVersions := map[string]struct { + ver string + suspend bool + }{ + "alpha": {"v1.72.10", false}, + "beta": {"v1.71.0", false}, + "early-access": {"v1.70.0", false}, + "stable": {"v1.69.0", false}, + "rock-solid": {"v1.68.0", true}, + } + for ch, data := range channelVersions { + content := `{"version":"` + data.ver + `"}` + if data.suspend { + content = `{"version":"` + data.ver + `","suspend":true}` + } + img := upfake.NewImageBuilder().WithFile("version.json", content).MustBuild() + reg.MustAddImage("release-channel", ch, img) + reg.MustAddImage("release-channel", data.ver, img) + } + img := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"v1.71.5"}`). + WithFile("deckhouse/candi/images_digests.json", `{}`). + MustBuild() + for _, tag := range []string{"v1.72.10", "v1.71.5", "v1.71.0", "v1.70.0", "v1.69.0", "v1.68.0"} { + reg.MustAddImage("", tag, img) + reg.MustAddImage("install", tag, img) + reg.MustAddImage("install-standalone", tag, img) + } + + // v1.71.5 is not the current version of any channel; rock-solid is suspended. + svc := newPullService(t, pkgclient.Adapt(upfake.NewClient(reg)), "v1.71.5", &PullServiceOptions{ + SkipSecurity: true, + SkipModules: true, + SkipInstaller: true, + IgnoreSuspend: false, + }) + + _, err := svc.Pull(context.Background()) + require.NoError(t, err, + "suspended rock-solid must not block --deckhouse-tag pull of an unrelated version") +} + // --------------------------------------------------------------------------- // Success path tests // ---------------------------------------------------------------------------