Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions internal/mirror/cmd/pull/errdetect/diagnose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<version> (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,
Expand Down
55 changes: 55 additions & 0 deletions internal/mirror/cmd/pull/errdetect/diagnose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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=<version>")
}

// 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")))
}
Expand Down
7 changes: 7 additions & 0 deletions internal/mirror/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
10 changes: 10 additions & 0 deletions internal/mirror/installer/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down
72 changes: 72 additions & 0 deletions internal/mirror/installer/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading