diff --git a/internal/mirror/cmd/pull/errdetect/diagnose.go b/internal/mirror/cmd/pull/errdetect/diagnose.go index 8a1793852..e502ca4ab 100644 --- a/internal/mirror/cmd/pull/errdetect/diagnose.go +++ b/internal/mirror/cmd/pull/errdetect/diagnose.go @@ -33,28 +33,30 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote/transport" + "github.com/deckhouse/deckhouse-cli/internal" "github.com/deckhouse/deckhouse-cli/internal/mirror/errmatch" "github.com/deckhouse/deckhouse-cli/internal/mirror/modules" "github.com/deckhouse/deckhouse-cli/pkg/diagnostic" ) const ( - categoryEOF = "Connection terminated unexpectedly (EOF)" - categoryTLS = "TLS/certificate verification failed" - categoryAuth = "Authentication failed" - categoryAuth401 = "Authentication failed (HTTP 401 Unauthorized)" - categoryAuth403 = "Access denied (HTTP 403 Forbidden)" - categoryRateLimit = "Rate limited by registry (HTTP 429 Too Many Requests)" - categoryServerError = "Registry server error" - categoryDNS = "DNS resolution failed" - categoryTimeout = "Operation timed out" - categoryNetwork = "Network connection failed" - categoryDiskFull = "Disk space exhausted" - categoryPermission = "Permission denied" - categoryImageNotFound = "Image not found in registry" - categoryRepoNotFound = "Repository not found in registry" - categoryEmptyConstraint = "Version constraint is missing after '@'" - categoryPathConstraint = "Version constraint is a path, not a version" + categoryEOF = "Connection terminated unexpectedly (EOF)" + categoryTLS = "TLS/certificate verification failed" + categoryAuth = "Authentication failed" + categoryAuth401 = "Authentication failed (HTTP 401 Unauthorized)" + categoryAuth403 = "Access denied (HTTP 403 Forbidden)" + categoryRateLimit = "Rate limited by registry (HTTP 429 Too Many Requests)" + categoryServerError = "Registry server error" + categoryDNS = "DNS resolution failed" + categoryTimeout = "Operation timed out" + categoryNetwork = "Network connection failed" + categoryDiskFull = "Disk space exhausted" + categoryPermission = "Permission denied" + categoryImageNotFound = "Image not found in registry" + categoryRepoNotFound = "Repository not found in registry" + categoryEmptyConstraint = "Version constraint is missing after '@'" + categoryPathConstraint = "Version constraint is a path, not a version" + categoryNoReleaseChannels = "No release channels found in source registry" ) // Diagnose analyzes an error and returns a *diagnostic.HelpfulError @@ -313,6 +315,24 @@ func Diagnose(err error) *diagnostic.HelpfulError { }, } + case errors.Is(err, internal.ErrNoReleaseChannels): + return &diagnostic.HelpfulError{ + Category: categoryNoReleaseChannels, + OriginalErr: err, + Suggestions: []diagnostic.Suggestion{ + { + Cause: "The source registry publishes no Deckhouse release channels (e.g. it was filled from a tag-based mirror bundle)", + Solutions: []string{ + "Specify the version to pull explicitly: --deckhouse-tag= (e.g. --deckhouse-tag=v1.70.4)", + }, + }, + { + Cause: "License key does not have access to the requested edition or version", + Solutions: []string{"Verify the --license key grants access to the requested Deckhouse edition"}, + }, + }, + } + case errmatch.IsImageNotFound(err): return &diagnostic.HelpfulError{ Category: categoryImageNotFound, diff --git a/internal/mirror/cmd/pull/errdetect/diagnose_test.go b/internal/mirror/cmd/pull/errdetect/diagnose_test.go index 00a22dfa1..427d24fb2 100644 --- a/internal/mirror/cmd/pull/errdetect/diagnose_test.go +++ b/internal/mirror/cmd/pull/errdetect/diagnose_test.go @@ -31,6 +31,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + dkpclient "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/pkg/diagnostic" ) @@ -65,6 +68,7 @@ func TestDiagnose_AllCategories(t *testing.T) { {"Permission", fmt.Errorf("create file: %w", os.ErrPermission), categoryPermission}, {"ImageNotFound", errors.New("MANIFEST_UNKNOWN: not found"), categoryImageNotFound}, {"RepoNotFound", errors.New("NAME_UNKNOWN: repo"), categoryRepoNotFound}, + {"NoReleaseChannels", fmt.Errorf("%w: %w", internal.ErrNoReleaseChannels, errors.New("image not found")), categoryNoReleaseChannels}, } for _, tt := range tests { @@ -109,6 +113,57 @@ func allSolutions(diag *diagnostic.HelpfulError) string { return strings.Join(parts, " ") } +// releaseChannelHEAD404 mimics the error the registry client returns for a +// tag probed with HEAD that is missing: the ErrImageNotFound sentinel wrapping +// a *transport.Error with no diagnostic codes (HEAD responses have no body). +func releaseChannelHEAD404() error { + return fmt.Errorf("failed to check if image exists: %w", + fmt.Errorf("%w: %w", dkpclient.ErrImageNotFound, &transport.Error{StatusCode: http.StatusNotFound})) +} + +// TestDiagnose_NoReleaseChannels feeds the production-shaped chain built by +// validatePlatformAccess when the scan finds no channel at all: the dedicated +// category must fire and point the user at --deckhouse-tag. +func TestDiagnose_NoReleaseChannels(t *testing.T) { + err := fmt.Errorf("pull from registry: pull platform: validate platform access: %w", + fmt.Errorf("%w (checked: alpha, beta, early-access, stable, rock-solid, lts): %w", + internal.ErrNoReleaseChannels, releaseChannelHEAD404())) + + diag := Diagnose(err) + require.NotNil(t, diag) + assert.Equal(t, categoryNoReleaseChannels, diag.Category) + assert.Contains(t, allSolutions(diag), "--deckhouse-tag=") +} + +// TestDiagnose_NoReleaseChannels_WinsOverImageNotFound pins the switch order: +// the chain also matches the generic image-not-found matcher (MANIFEST_UNKNOWN +// diagnostic code), but the dedicated no-channels category must win. +func TestDiagnose_NoReleaseChannels_WinsOverImageNotFound(t *testing.T) { + getShaped := &transport.Error{ + StatusCode: http.StatusNotFound, + Errors: []transport.Diagnostic{{Code: transport.ManifestUnknownErrorCode}}, + } + err := fmt.Errorf("%w: %w", internal.ErrNoReleaseChannels, + fmt.Errorf("%w: %w", dkpclient.ErrImageNotFound, getShaped)) + + diag := Diagnose(err) + require.NotNil(t, diag) + assert.Equal(t, categoryNoReleaseChannels, diag.Category) +} + +// TestDiagnose_ImageNotFound_HeadShapedChain covers the production shape for a +// missing specific tag (no sentinel in the chain): HEAD 404 carries no +// diagnostic codes, so the "404 Not Found" string fallback must classify it as +// image-not-found and surface the --deckhouse-tag suggestion. +func TestDiagnose_ImageNotFound_HeadShapedChain(t *testing.T) { + err := fmt.Errorf(`failed to check Deckhouse tag "v9.99.9" exists in registry: %w`, releaseChannelHEAD404()) + + diag := Diagnose(err) + require.NotNil(t, diag) + assert.Equal(t, categoryImageNotFound, diag.Category) + assert.Contains(t, allSolutions(diag), "--deckhouse-tag") +} + func TestDiagnose_NoUnsupportedOCI(t *testing.T) { assert.Nil(t, Diagnose(errors.New("MANIFEST_INVALID: vnd.aquasec.trivy"))) } diff --git a/internal/mirror/installer/installer.go b/internal/mirror/installer/installer.go index 70d35dc73..156cdf170 100644 --- a/internal/mirror/installer/installer.go +++ b/internal/mirror/installer/installer.go @@ -70,6 +70,12 @@ type Service struct { // it, so the count must be taken now, not in Stats() which runs afterwards. pulledImages int + // accessSkipped records that PullInstaller gracefully skipped the phase + // because the installer access check failed (e.g. the repo returned 404). + // Stats() uses it to report the phase as not attempted instead of + // misrepresenting the skip as a successful pull. + accessSkipped bool + // logger is for internal debug logging logger *dkplog.Logger // userLogger is for user-facing informational messages @@ -114,6 +120,7 @@ func (svc *Service) PullInstaller(ctx context.Context) error { err := svc.validateInstallerAccess(ctx) if err != nil { svc.userLogger.Warnf("installer access: %v", err) + svc.accessSkipped = true return nil } diff --git a/internal/mirror/installer/stats.go b/internal/mirror/installer/stats.go index c7f9a21bc..6e09204c7 100644 --- a/internal/mirror/installer/stats.go +++ b/internal/mirror/installer/stats.go @@ -30,12 +30,22 @@ type ComponentStats struct { // the planned count from the download list; otherwise it reports the actual // number of manifests pulled into the OCI layout, captured before packing (see // Service.pulledImages). +// +// When PullInstaller gracefully skipped the phase on an access error (e.g. the +// installer repo returns 404), the phase is reported as not attempted so the +// summary renders "not pulled" rather than falsely claiming the tag was +// mirrored. This applies to dry-run as well: a skipped phase must not show up +// in the plan. func (svc *Service) Stats() ComponentStats { tag := defaultTargetTag if svc.options.TargetTag != "" { tag = svc.options.TargetTag } + if svc.accessSkipped { + return ComponentStats{Attempted: false, Tag: tag} + } + if svc.options.DryRun { return ComponentStats{Attempted: true, Images: len(svc.downloadList.Installer), Tag: tag} } diff --git a/internal/mirror/installer/stats_test.go b/internal/mirror/installer/stats_test.go index cff2bed51..02ece456a 100644 --- a/internal/mirror/installer/stats_test.go +++ b/internal/mirror/installer/stats_test.go @@ -97,3 +97,75 @@ func TestStats_RealPull_SurvivesPacking(t *testing.T) { require.Equal(t, 1, stats.Images, "installer count must survive packing (captured before bundle.Pack deletes the layout)") } + +// TestStats_AccessFailure_ReportsNotAttempted is the regression test for the bug +// where a gracefully-skipped installer pull (the installer repo returned 404, so +// PullInstaller warns and returns nil) was still reported as a successful pull of +// tag "latest" in the summary. With nothing pulled, Stats must report +// Attempted=false so the summary renders "not pulled". +func TestStats_AccessFailure_ReportsNotAttempted(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := fake.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + // Tag that does not exist in the stub registry: the access check + // fails, PullInstaller gracefully skips, nothing is pulled. + TargetTag: "v9.99.0", + BundleDir: bundleDir, + DryRun: false, + }, + logger, + userLogger, + ) + + require.NoError(t, svc.PullInstaller(context.Background())) + + stats := svc.Stats() + require.False(t, stats.Attempted, "gracefully-skipped installer must not report a successful pull") + require.Equal(t, 0, stats.Images) +} + +// TestStats_DryRun_AccessFailure_ReportsNotAttempted mirrors the access-failure +// regression test for dry-run: a gracefully-skipped installer must not appear in +// the pull plan either. Before the accessSkipped flag, the dry-run branch of +// Stats unconditionally reported Attempted=true, so the summary rendered the +// installer tag as planned even though the phase was skipped. +func TestStats_DryRun_AccessFailure_ReportsNotAttempted(t *testing.T) { + workingDir := t.TempDir() + bundleDir := t.TempDir() + + stubClient := fake.NewRegistryClientStub() + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + regSvc := registryservice.NewService(stubClient, pkg.FEEdition, logger) + + svc := NewService( + regSvc, + workingDir, + &Options{ + // Tag that does not exist in the stub registry: the access check + // fails, PullInstaller gracefully skips, nothing is planned. + TargetTag: "v9.99.0", + BundleDir: bundleDir, + DryRun: true, + }, + logger, + userLogger, + ) + + require.NoError(t, svc.PullInstaller(context.Background())) + + stats := svc.Stats() + require.False(t, stats.Attempted, "gracefully-skipped installer must not appear in the dry-run plan") + require.Equal(t, 0, stats.Images) +} diff --git a/internal/mirror/platform/platform.go b/internal/mirror/platform/platform.go index e73e65d78..5ca188f48 100644 --- a/internal/mirror/platform/platform.go +++ b/internal/mirror/platform/platform.go @@ -217,7 +217,6 @@ func (svc *Service) PullPlatform(ctx context.Context) error { func (svc *Service) validatePlatformAccess(ctx context.Context) error { // Default to stable channel if no specific tag is set targetTag := internal.StableChannel - fallbackTag := internal.LTSChannel if svc.options.TargetTag != "" { targetTag = svc.options.TargetTag @@ -227,33 +226,83 @@ func (svc *Service) validatePlatformAccess(ctx context.Context) error { // Check if target is a release channel (like "stable", "beta") or a specific tag if internal.ChannelIsValid(targetTag) { - err := svc.deckhouseService.ReleaseChannels().CheckImageExists(ctx, targetTag) + return svc.validateReleaseChannelAccess(ctx, targetTag, svc.options.TargetTag != "") + } + + // For specific tags, check if the tag exists + err := svc.deckhouseService.CheckImageExists(ctx, targetTag) + if err != nil { + return fmt.Errorf("failed to check Deckhouse tag %q exists in registry: %w", targetTag, err) + } + + return nil +} + +// validateReleaseChannelAccess verifies registry access through release channels. +// +// The requested channel is probed first: its existence both proves access and +// guarantees the pull has something to fetch. When the channel was requested +// explicitly (--deckhouse-tag) and is missing, that is a hard error listing +// the channels the registry does publish - proceeding would produce an empty +// bundle. For a default pull any existing channel proves access: editions +// differ in which channels they publish (e.g. CSE ships only "lts"). +func (svc *Service) validateReleaseChannelAccess(ctx context.Context, channel string, explicit bool) error { + err := svc.deckhouseService.ReleaseChannels().CheckImageExists(ctx, channel) + if err == nil { + return nil + } + + // Everything but ErrImageNotFound means we can't reach the registry at all + if !errors.Is(err, client.ErrImageNotFound) { + return fmt.Errorf("failed to check release channel %q exists in registry: %w", channel, err) + } + + requestedErr := err + lastNotFound := err + + allChannels := slices.Concat(internal.GetAllDefaultReleaseChannels(), []string{internal.LTSChannel}) + existing := make([]string, 0, len(allChannels)) + + for _, ch := range allChannels { + if ch == channel { + continue + } + + err := svc.deckhouseService.ReleaseChannels().CheckImageExists(ctx, ch) if err == nil { - return nil + if !explicit { + return nil + } + + existing = append(existing, ch) + + continue } - // Everything but ErrImageNotFound means we can't reach the registry at all if !errors.Is(err, client.ErrImageNotFound) { - return fmt.Errorf("failed to check release channel %q exists in registry: %w", targetTag, err) - } + // The explicit channel is already missing and is the error to + // report; a broken sibling channel must not mask it. + if explicit { + svc.logger.Debug("Skipping release channel probe failure", slog.String("channel", ch), slog.String("error", err.Error())) - // Channel not found (CSE edition may not have "stable"). - // Fall back to LTS to verify registry access. - fallbackErr := svc.deckhouseService.ReleaseChannels().CheckImageExists(ctx, fallbackTag) - if fallbackErr != nil { - return fmt.Errorf("failed to check release channel %q exists in registry: %w", fallbackTag, fallbackErr) + continue + } + + return fmt.Errorf("failed to check release channel %q exists in registry: %w", ch, err) } - return nil + lastNotFound = err } - // For specific tags, check if the tag exists - err := svc.deckhouseService.CheckImageExists(ctx, targetTag) - if err != nil { - return fmt.Errorf("failed to check Deckhouse tag %q exists in registry: %w", targetTag, err) + if explicit && len(existing) > 0 { + return fmt.Errorf("release channel %q not found in the source registry (available: %s): %w", + channel, strings.Join(existing, ", "), requestedErr) } - return nil + // No channel exists in the registry. ErrNoReleaseChannels drives the + // errdetect hint pointing the user at --deckhouse-tag; the wrapped + // not-found chain keeps the underlying 404 diagnosable. + return fmt.Errorf("%w (checked: %s): %w", internal.ErrNoReleaseChannels, strings.Join(allChannels, ", "), lastNotFound) } // findTagsToMirror determines which Deckhouse release tags should be mirrored @@ -480,7 +529,7 @@ var ErrSomeChannelsFailed = errors.New("some channels failed to fetch") func (svc *Service) validateChannelResults(results map[string]releaseChannelVersionResult) (channelVersions, error) { versions := make(channelVersions, len(results)) - someChannelsIsFailed := false + failedChannels := make([]string, 0, len(results)) for channel, result := range results { if result.err == nil { @@ -489,16 +538,19 @@ func (svc *Service) validateChannelResults(results map[string]releaseChannelVers continue } - if result.err != nil { - someChannelsIsFailed = true - } + failedChannels = append(failedChannels, channel) } - if someChannelsIsFailed { - return versions, ErrSomeChannelsFailed + if len(failedChannels) == 0 { + return versions, nil } - return versions, nil + // Keep one underlying failure in the chain: it carries the not-found + // context the errdetect diagnostics need. Sort for a stable message. + slices.Sort(failedChannels) + first := failedChannels[0] + + return versions, fmt.Errorf("%w: channel %q: %w", ErrSomeChannelsFailed, first, results[first].err) } // matchChannelsToTags matches requested tags to channel versions and returns matching versions and channels diff --git a/internal/mirror/platform/validate_access_test.go b/internal/mirror/platform/validate_access_test.go index 70844ddb0..76f8ef813 100644 --- a/internal/mirror/platform/validate_access_test.go +++ b/internal/mirror/platform/validate_access_test.go @@ -19,20 +19,23 @@ package platform import ( "context" "log/slog" + "net/http" "testing" "github.com/Masterminds/semver/v3" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" dkplog "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/deckhouse-cli/internal" + localfake "github.com/deckhouse/deckhouse-cli/pkg/fake" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" - localreg "github.com/deckhouse/deckhouse/pkg/registry" + pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" + localreg "github.com/deckhouse/deckhouse/pkg/registry" upfake "github.com/deckhouse/deckhouse/pkg/registry/fake" - localfake "github.com/deckhouse/deckhouse-cli/pkg/fake" - pkgclient "github.com/deckhouse/deckhouse-cli/pkg/registry/client" ) // newTestPlatformService is a test helper that builds a Service with only the @@ -68,6 +71,52 @@ func emptyStub() localreg.Client { return pkgclient.Adapt(upfake.NewClient(upfake.NewRegistry("registry.deckhouse.ru/deckhouse/fe"))) } +// rockSolidOnlyStub returns a stub registry that publishes only the rock-solid +// channel: neither "stable" nor "lts" exists, so access validation must scan +// the remaining channels to succeed. +func rockSolidOnlyStub() localreg.Client { + reg := upfake.NewRegistry("registry.deckhouse.ru/deckhouse/fe") + img := upfake.NewImageBuilder(). + WithFile("version.json", `{"version":"v1.68.0"}`). + MustBuild() + reg.MustAddImage("release-channel", "rock-solid", img) + return pkgclient.Adapt(upfake.NewClient(reg)) +} + +// erroringClient wraps a stub client and fails CheckImageExists for one tag +// with a fixed error, so tests can exercise non-404 probe outcomes. +type erroringClient struct { + localreg.Client + failTag string + failErr error +} + +// WithSegment re-wraps the derived client: without this the decoration is +// lost when the service scopes into the release-channel repository. +func (c *erroringClient) WithSegment(segments ...string) localreg.Client { + return &erroringClient{Client: c.Client.WithSegment(segments...), failTag: c.failTag, failErr: c.failErr} +} + +func (c *erroringClient) CheckImageExists(ctx context.Context, tag string) error { + if tag == c.failTag { + return c.failErr + } + + return c.Client.CheckImageExists(ctx, tag) +} + +// erroring builds a makeClient callback that decorates the given stub with a +// per-tag CheckImageExists failure. +func erroring(makeInner func() localreg.Client, failTag string, statusCode int) func() localreg.Client { + return func() localreg.Client { + return &erroringClient{ + Client: makeInner(), + failTag: failTag, + failErr: &transport.Error{StatusCode: statusCode}, + } + } +} + func TestService_validatePlatformAccess(t *testing.T) { logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) userLogger := log.NewSLogger(slog.LevelWarn) @@ -78,6 +127,7 @@ func TestService_validatePlatformAccess(t *testing.T) { targetTag string wantErr bool errContains string + wantErrIs []error }{ { name: "no target tag defaults to stable channel which exists", @@ -104,24 +154,93 @@ func TestService_validatePlatformAccess(t *testing.T) { wantErr: false, }, { - name: "channel not found falls back to LTS successfully", + name: "default pull with lts-only registry passes", + makeClient: ltsOnlyStub, + targetTag: "", + wantErr: false, + }, + { + name: "explicitly requested lts exists", makeClient: ltsOnlyStub, - targetTag: "stable", + targetTag: "lts", + wantErr: false, + }, + { + name: "only rock-solid channel exists, default stable scans and succeeds", + makeClient: rockSolidOnlyStub, + targetTag: "", wantErr: false, }, { - name: "channel not found and LTS also missing returns error", + // An explicitly requested channel must exist: proceeding on a + // sibling channel would end in an empty bundle. + name: "explicitly requested channel missing fails listing available channels", + makeClient: ltsOnlyStub, + targetTag: "stable", + wantErr: true, + errContains: "available: lts", + wantErrIs: []error{localreg.ErrImageNotFound}, + }, + { + name: "explicit channel missing on partial registry lists available", + makeClient: rockSolidOnlyStub, + targetTag: "beta", + wantErr: true, + errContains: "available: rock-solid", + wantErrIs: []error{localreg.ErrImageNotFound}, + }, + { + // The wrap contract the errdetect hints depend on: the sentinel + // selects the dedicated no-channels category, the not-found chain + // keeps the underlying 404 diagnosable. + name: "no channel at all returns no-channels error", makeClient: emptyStub, targetTag: "stable", wantErr: true, - errContains: "release channel", + errContains: "release channel found", + wantErrIs: []error{localreg.ErrImageNotFound, internal.ErrNoReleaseChannels}, }, { - name: "LTS channel missing with empty registry", + name: "explicit beta on empty registry returns no-channels error", makeClient: emptyStub, targetTag: "beta", wantErr: true, - errContains: "lts", + errContains: "release channel found", + wantErrIs: []error{internal.ErrNoReleaseChannels}, + }, + { + name: "requested channel healthy while sibling channel is broken", + makeClient: erroring(ltsOnlyStub, "alpha", http.StatusInternalServerError), + targetTag: "lts", + wantErr: false, + }, + { + name: "explicit missing channel skips broken sibling", + makeClient: erroring(ltsOnlyStub, "alpha", http.StatusInternalServerError), + targetTag: "stable", + wantErr: true, + errContains: "available: lts", + }, + { + name: "default pull aborts on non-404 error during scan", + makeClient: erroring(ltsOnlyStub, "alpha", http.StatusInternalServerError), + targetTag: "", + wantErr: true, + errContains: `channel "alpha"`, + }, + { + name: "broken requested channel fails naming it", + makeClient: erroring(localfake.NewRegistryClientStub, "stable", http.StatusInternalServerError), + targetTag: "", + wantErr: true, + errContains: `channel "stable"`, + }, + { + name: "unauthorized on requested channel fails fast", + makeClient: erroring(localfake.NewRegistryClientStub, "stable", http.StatusUnauthorized), + targetTag: "", + wantErr: true, + errContains: "401", }, { name: "semver tag exists in root repository", @@ -169,6 +288,9 @@ func TestService_validatePlatformAccess(t *testing.T) { if tt.errContains != "" { assert.Contains(t, err.Error(), tt.errContains) } + for _, target := range tt.wantErrIs { + assert.ErrorIs(t, err, target) + } } else { require.NoError(t, err) } @@ -266,6 +388,23 @@ func TestService_findTagsToMirror(t *testing.T) { } } +// TestService_findTagsToMirror_PartialRegistryKeepsNotFoundCause covers a +// registry that passes access validation (one channel exists) but misses +// required default channels: the resulting failure must keep the underlying +// not-found chain so the errdetect diagnostics stay actionable. +func TestService_findTagsToMirror_PartialRegistryKeepsNotFoundCause(t *testing.T) { + logger := dkplog.NewLogger(dkplog.WithLevel(slog.LevelWarn)) + userLogger := log.NewSLogger(slog.LevelWarn) + + svc := newTestPlatformService(rockSolidOnlyStub(), &Options{}, logger, userLogger) + + _, _, err := svc.findTagsToMirror(context.Background()) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrSomeChannelsFailed) + assert.ErrorIs(t, err, localreg.ErrImageNotFound) +} + // mustParseSemver is a test helper that panics if version parsing fails. func mustParseSemver(v string) *semver.Version { ver, err := semver.NewVersion(v) diff --git a/internal/package.go b/internal/package.go index 23b8532ea..58ddd8e0c 100644 --- a/internal/package.go +++ b/internal/package.go @@ -16,6 +16,8 @@ limitations under the License. package internal +import "errors" + const ( AlphaChannel = "alpha" BetaChannel = "beta" @@ -25,6 +27,10 @@ const ( LTSChannel = "lts" ) +// ErrNoReleaseChannels means the source registry serves the repository but +// publishes none of the known release channels. +var ErrNoReleaseChannels = errors.New("no release channel found in the source registry") + func GetAllDefaultReleaseChannels() []string { return []string{ AlphaChannel,