mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix(cloud): surface real backend errors from pmg cloud sync (#365)
* fix(cloud): surface real backend errors from pmg cloud sync runSync wrapped every DrainToCloud failure as a network error, masking the actual cause — an entitlement failure surfaced as "check your network connectivity", which made backend issues very hard to diagnose. Classify the error first (usefulerror gRPC converters map backend statuses to authentication, entitlement, quota and server errors) and pass it through. The network-flavored message remains only as the fallback when nothing can classify the error. Bump safedep/dry to pick up nested-Any ErrorInfo extraction so entitlement classification also works against control-tower versions that re-wrap status details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MHdvpFXb2shyMzUDyR8QKA * chore: bump safedep/dry to latest branch commit Picks up the review follow-up in dry#128 (skip unmarshalling unknown detail types when unwrapping Any). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MHdvpFXb2shyMzUDyR8QKA * chore: bump safedep/dry to post-merge main Replaces the dry#128 branch pseudo-version with the squashed main commit now that the nested-Any ErrorInfo fix has merged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MHdvpFXb2shyMzUDyR8QKA --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2d938ea381
commit
2f1c348a06
+26
-12
@@ -44,20 +44,34 @@ func runSync(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
synced, err := audit.DrainToCloud(cmd.Context(), cfg, manualSyncLockTimeout, syncTimeout)
|
synced, err := audit.DrainToCloud(cmd.Context(), cfg, manualSyncLockTimeout, syncTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, audit.ErrSyncInProgress) {
|
ui.ErrorExit(syncFailureError(err))
|
||||||
ui.ErrorExit(usefulerror.NewUsefulError().
|
|
||||||
WithCode(errcodes.Lifecycle).
|
|
||||||
WithHumanError("Another cloud sync is already in progress").
|
|
||||||
WithHelp("Wait for the in-progress sync to finish, then try again"))
|
|
||||||
}
|
|
||||||
ui.ErrorExit(usefulerror.NewUsefulError().
|
|
||||||
Wrap(err).
|
|
||||||
WithCode(errcodes.Network).
|
|
||||||
WithHumanError("Failed to sync events to SafeDep Cloud").
|
|
||||||
WithHelp("Check your network connectivity and ensure SafeDep Cloud is reachable").
|
|
||||||
WithAdditionalHelp("Override the cloud endpoint with SAFEDEP_CLOUD_DATA_ADDR if needed"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Successf("Synced %d events to SafeDep Cloud", synced)
|
ui.Successf("Synced %d events to SafeDep Cloud", synced)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func syncFailureError(err error) error {
|
||||||
|
if errors.Is(err, audit.ErrSyncInProgress) {
|
||||||
|
return usefulerror.NewUsefulError().
|
||||||
|
WithCode(errcodes.Lifecycle).
|
||||||
|
WithHumanError("Another cloud sync is already in progress").
|
||||||
|
WithHelp("Wait for the in-progress sync to finish, then try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
if usefulErr, ok := usefulerror.AsUsefulError(err); ok {
|
||||||
|
return usefulErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return usefulerror.NewUsefulError().
|
||||||
|
Wrap(err).
|
||||||
|
WithCode(errcodes.Network).
|
||||||
|
WithHumanError("Failed to sync events to SafeDep Cloud").
|
||||||
|
WithHelp("Check your network connectivity and ensure SafeDep Cloud is reachable").
|
||||||
|
WithAdditionalHelp("Override the cloud endpoint with SAFEDEP_CLOUD_DATA_ADDR if needed")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package cloud
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/usefulerror"
|
||||||
|
"github.com/safedep/pmg/errcodes"
|
||||||
|
"github.com/safedep/pmg/internal/audit"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func entitlementStatusErr(t *testing.T, anyNesting int) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
detail, err := anypb.New(&errdetails.ErrorInfo{
|
||||||
|
Reason: "entitlement_not_available",
|
||||||
|
Domain: "safedep.io",
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"feature": "FEATURE_ENDPOINT_SYNC",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for range anyNesting {
|
||||||
|
detail, err = anypb.New(detail)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
st := status.New(codes.PermissionDenied, "service execution failed: entitlement verification failed")
|
||||||
|
stProto := st.Proto()
|
||||||
|
stProto.Details = append(stProto.Details, detail)
|
||||||
|
|
||||||
|
return status.FromProto(stProto).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncFailureError(t *testing.T) {
|
||||||
|
wrap := func(err error) error {
|
||||||
|
return fmt.Errorf("endpointsync: sync failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
expectedCode string
|
||||||
|
expectedHuman string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sync already in progress",
|
||||||
|
err: fmt.Errorf("outer: %w", audit.ErrSyncInProgress),
|
||||||
|
expectedCode: errcodes.Lifecycle,
|
||||||
|
expectedHuman: "Another cloud sync is already in progress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "authentication failure passes through",
|
||||||
|
err: wrap(status.Error(codes.Unauthenticated, "invalid API key")),
|
||||||
|
expectedCode: usefulerror.ErrAuthenticationFailed,
|
||||||
|
expectedHuman: "Authentication failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entitlement failure passes through",
|
||||||
|
err: wrap(entitlementStatusErr(t, 0)),
|
||||||
|
expectedCode: usefulerror.ErrMissingEntitlements,
|
||||||
|
expectedHuman: "Permission denied",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "entitlement failure with re-wrapped details passes through",
|
||||||
|
// Old control-tower serror.Add re-packed details into nested Any
|
||||||
|
// layers; classification must survive that wire format too.
|
||||||
|
err: wrap(entitlementStatusErr(t, 2)),
|
||||||
|
expectedCode: usefulerror.ErrMissingEntitlements,
|
||||||
|
expectedHuman: "Permission denied",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission denied without entitlement detail",
|
||||||
|
err: wrap(status.Error(codes.PermissionDenied, "no access")),
|
||||||
|
expectedCode: usefulerror.ErrAuthorizationFailed,
|
||||||
|
expectedHuman: "Permission denied",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server internal error passes through",
|
||||||
|
err: wrap(status.Error(codes.Internal, "server closed the stream without sending trailers")),
|
||||||
|
expectedCode: usefulerror.ErrInternalServerError,
|
||||||
|
expectedHuman: "Internal server error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unclassifiable error falls back to network",
|
||||||
|
err: errors.New("something odd happened"),
|
||||||
|
expectedCode: errcodes.Network,
|
||||||
|
expectedHuman: "Failed to sync events to SafeDep Cloud",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := syncFailureError(tt.err)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
usefulErr, ok := usefulerror.AsUsefulError(err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedCode, usefulErr.Code())
|
||||||
|
assert.Equal(t, tt.expectedHuman, usefulErr.HumanError())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ require (
|
|||||||
github.com/landlock-lsm/go-landlock v0.7.0
|
github.com/landlock-lsm/go-landlock v0.7.0
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
|
||||||
github.com/posthog/posthog-go v1.5.12
|
github.com/posthog/posthog-go v1.5.12
|
||||||
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537
|
github.com/safedep/dry v0.0.0-20260710090004-346776184be5
|
||||||
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a
|
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a
|
||||||
github.com/sony/gobreaker/v2 v2.4.0
|
github.com/sony/gobreaker/v2 v2.4.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
|||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537 h1:IiUlF9LzpoTUdV9RqsstiFRkPdP8fFQmCfbAHoNii0k=
|
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537 h1:IiUlF9LzpoTUdV9RqsstiFRkPdP8fFQmCfbAHoNii0k=
|
||||||
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537/go.mod h1:OUa+lopsqWFoDCzy3/DXqzi8JDl3g2q82JeskLq/Tpk=
|
github.com/safedep/dry v0.0.0-20260620130340-2af76e505537/go.mod h1:OUa+lopsqWFoDCzy3/DXqzi8JDl3g2q82JeskLq/Tpk=
|
||||||
|
github.com/safedep/dry v0.0.0-20260710083559-4501f2533064 h1:xNE9r8aJk/RxwPGK7wpT4Nm8cjLwO+vtGG8n6pfEhbk=
|
||||||
|
github.com/safedep/dry v0.0.0-20260710083559-4501f2533064/go.mod h1:WfJPfXgWWLfgi72PGllhHRUGoj7vh+zJ5cx4RJ4qRdM=
|
||||||
|
github.com/safedep/dry v0.0.0-20260710084513-c7378927978f h1:WeminE3k3HbGPhPPsJcBZP6bomTcg7tuESCCUzFl2Mc=
|
||||||
|
github.com/safedep/dry v0.0.0-20260710084513-c7378927978f/go.mod h1:WfJPfXgWWLfgi72PGllhHRUGoj7vh+zJ5cx4RJ4qRdM=
|
||||||
|
github.com/safedep/dry v0.0.0-20260710090004-346776184be5 h1:KDcSwAhNqLudyqn4iJEYacbAc1yf/g613BnpPTi7dm0=
|
||||||
|
github.com/safedep/dry v0.0.0-20260710090004-346776184be5/go.mod h1:WfJPfXgWWLfgi72PGllhHRUGoj7vh+zJ5cx4RJ4qRdM=
|
||||||
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a h1:oJu4dgmz/weiU3CMhFKiXd5zwgvPwPsm20MzG/uAt0s=
|
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a h1:oJu4dgmz/weiU3CMhFKiXd5zwgvPwPsm20MzG/uAt0s=
|
||||||
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a/go.mod h1:fyt+PACz6dtEoqsnE0BPPv/lHpuBG/8zkDqeIVcyRY4=
|
github.com/safedep/ptyx v0.2.1-0.20260529140457-d1f745842a6a/go.mod h1:fyt+PACz6dtEoqsnE0BPPv/lHpuBG/8zkDqeIVcyRY4=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
|||||||
Reference in New Issue
Block a user