diff --git a/cmd/cloud/sync.go b/cmd/cloud/sync.go index f7818db..9d9f22c 100644 --- a/cmd/cloud/sync.go +++ b/cmd/cloud/sync.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "github.com/safedep/dry/cloud" "github.com/safedep/dry/usefulerror" "github.com/safedep/pmg/config" "github.com/safedep/pmg/errcodes" @@ -52,10 +53,10 @@ func runSync(cmd *cobra.Command, args []string) error { } // syncFailureError maps a DrainToCloud failure to a user-facing error. Errors -// the backend already classified (authentication, entitlements, quota — via -// gRPC status and usefulerror converters) pass through so the real cause is -// shown; the network-flavored message is only a fallback for errors nothing -// can classify. +// the backend already classified (entitlements, quota — via gRPC status and +// usefulerror converters) pass through so the real cause is shown, except +// auth failures which get credential setup guidance; the network-flavored +// message is only a fallback for errors nothing can classify. func syncFailureError(err error) error { if errors.Is(err, audit.ErrSyncInProgress) { return usefulerror.NewUsefulError(). @@ -64,8 +65,16 @@ func syncFailureError(err error) error { WithHelp("Wait for the in-progress sync to finish, then try again") } + if errors.Is(err, cloud.ErrMissingCredentials) { + return usefulerror.NewUsefulError(). + Wrap(err). + WithCode(errcodes.CloudCredentialsNotFound). + WithHumanError("SafeDep Cloud credentials are not configured"). + WithHelp("Run 'pmg cloud login' to store credentials, or set the SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables") + } + if usefulErr, ok := usefulerror.AsUsefulError(err); ok { - return usefulErr + return withCredentialGuidance(usefulErr, err) } return usefulerror.NewUsefulError(). @@ -75,3 +84,20 @@ func syncFailureError(err error) error { WithHelp("Check your network connectivity and ensure SafeDep Cloud is reachable"). WithAdditionalHelp("Override the cloud endpoint with SAFEDEP_CLOUD_DATA_ADDR if needed") } + +// withCredentialGuidance rebrands generic auth failures (invalid API key, +// wrong tenant, gateway 401/403) with how to fix credentials in pmg. Richer +// classifications like missing entitlements keep their own code and help. +func withCredentialGuidance(usefulErr usefulerror.UsefulError, err error) error { + code := usefulErr.Code() + if code != usefulerror.ErrAuthenticationFailed && code != usefulerror.ErrAuthorizationFailed { + return usefulErr + } + + return usefulerror.NewUsefulError(). + Wrap(err). + WithCode(code). + WithHumanError("SafeDep Cloud rejected your credentials"). + WithHelp("Run 'pmg cloud login' to update credentials, or check the SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables"). + WithAdditionalHelp(usefulErr.AdditionalHelp()) +} diff --git a/cmd/cloud/sync_test.go b/cmd/cloud/sync_test.go index 6142bda..acd296e 100644 --- a/cmd/cloud/sync_test.go +++ b/cmd/cloud/sync_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/safedep/dry/cloud" "github.com/safedep/dry/usefulerror" "github.com/safedep/pmg/errcodes" "github.com/safedep/pmg/internal/audit" @@ -50,6 +51,7 @@ func TestSyncFailureError(t *testing.T) { err error expectedCode string expectedHuman string + expectedHelp string }{ { name: "sync already in progress", @@ -58,16 +60,25 @@ func TestSyncFailureError(t *testing.T) { expectedHuman: "Another cloud sync is already in progress", }, { - name: "authentication failure passes through", + name: "missing credentials maps to setup guidance", + err: fmt.Errorf("init cloud sync client: failed to resolve cloud credentials: %w", cloud.ErrMissingCredentials), + expectedCode: errcodes.CloudCredentialsNotFound, + expectedHuman: "SafeDep Cloud credentials are not configured", + expectedHelp: "Run 'pmg cloud login' to store credentials, or set the SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables", + }, + { + name: "authentication failure gets credential guidance", err: wrap(status.Error(codes.Unauthenticated, "invalid API key")), expectedCode: usefulerror.ErrAuthenticationFailed, - expectedHuman: "Authentication failed", + expectedHuman: "SafeDep Cloud rejected your credentials", + expectedHelp: "Run 'pmg cloud login' to update credentials, or check the SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables", }, { name: "entitlement failure passes through", err: wrap(entitlementStatusErr(t, 0)), expectedCode: usefulerror.ErrMissingEntitlements, expectedHuman: "Permission denied", + expectedHelp: "Access to this feature requires a SafeDep subscription. See https://safedep.io/pricing", }, { name: "entitlement failure with re-wrapped details passes through", @@ -78,10 +89,11 @@ func TestSyncFailureError(t *testing.T) { expectedHuman: "Permission denied", }, { - name: "permission denied without entitlement detail", + name: "permission denied without entitlement detail gets credential guidance", err: wrap(status.Error(codes.PermissionDenied, "no access")), expectedCode: usefulerror.ErrAuthorizationFailed, - expectedHuman: "Permission denied", + expectedHuman: "SafeDep Cloud rejected your credentials", + expectedHelp: "Run 'pmg cloud login' to update credentials, or check the SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables", }, { name: "server internal error passes through", @@ -107,6 +119,9 @@ func TestSyncFailureError(t *testing.T) { assert.Equal(t, tt.expectedCode, usefulErr.Code()) assert.Equal(t, tt.expectedHuman, usefulErr.HumanError()) + if tt.expectedHelp != "" { + assert.Equal(t, tt.expectedHelp, usefulErr.Help()) + } }) } } diff --git a/errcodes/codes.go b/errcodes/codes.go index 76169f0..6fce035 100644 --- a/errcodes/codes.go +++ b/errcodes/codes.go @@ -35,6 +35,11 @@ const ( // suspicious package) and the run was gated with --fail-on-violation. ProxyPolicyViolation = "ProxyPolicyViolation" + // Cloud error codes. CloudCredentialsNotFound is returned when a cloud + // operation needs SafeDep Cloud credentials but none are configured in the + // keychain or environment. + CloudCredentialsNotFound = "CloudCredentialsNotFound" + // Unknown mirrors the default code that dry/usefulerror returns for errors // created without an explicit code, so unset and explicitly-unknown errors // classify identically (e.g. the bug-report hint in ui.ErrorExit).