diff --git a/cmd/root.go b/cmd/root.go index e0cb6e53b..e31ddfef4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,6 +44,9 @@ import ( "github.com/spf13/pflag" "github.com/spf13/viper" "go.opencensus.io/trace" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) var ( @@ -1073,6 +1076,9 @@ func runSignalWrapper(cmd *Command) (err error) { sd, err := stackdriver.NewExporter(stackdriver.Options{ ProjectID: cmd.conf.TelemetryProject, MetricPrefix: cmd.conf.TelemetryPrefix, + OnError: func(err error) { + cmd.logger.Errorf("Failed to export to Stackdriver: %v", formatStackdriverError(err)) + }, }) if err != nil { return err @@ -1302,3 +1308,58 @@ func startHTTPServer(ctx context.Context, l cloudsql.Logger, addr string, mux *h l.Errorf("failed to shutdown HTTP server: %v\n", err) } } + +func formatStackdriverError(err error) string { + st, ok := status.FromError(err) + if !ok { + return err.Error() + } + + // Default message: code and description + msg := fmt.Sprintf("rpc error: code = %s desc = %s", st.Code(), st.Message()) + + // Try to extract details + details := st.Details() + if len(details) > 0 { + var detailStrings []string + for _, detail := range details { + switch d := detail.(type) { + case *errdetails.ErrorInfo: + detailStrings = append(detailStrings, fmt.Sprintf("ErrorInfo: Reason=%s, Domain=%s, Metadata=%v", d.Reason, d.Domain, d.Metadata)) + case *errdetails.Help: + for _, link := range d.Links { + detailStrings = append(detailStrings, fmt.Sprintf("Help: %s (%s)", link.Description, link.Url)) + } + default: + detailStrings = append(detailStrings, fmt.Sprintf("%T: %v", d, d)) + } + } + if len(detailStrings) > 0 { + msg = fmt.Sprintf("%s (details: %s)", msg, strings.Join(detailStrings, "; ")) + } + } + + // Add a general hint if we suspect permission issues based on code, message or details + isPermissionIssue := st.Code() == codes.PermissionDenied || + strings.Contains(strings.ToLower(st.Message()), "permission") || + strings.Contains(strings.ToLower(st.Message()), "denied") + + if !isPermissionIssue { + // Also check details for permission-like reasons + for _, detail := range details { + if ei, ok := detail.(*errdetails.ErrorInfo); ok { + if ei.Reason == "ACCESS_TOKEN_SCOPE_INSUFFICIENT" || + strings.Contains(strings.ToLower(ei.Reason), "permission") { + isPermissionIssue = true + break + } + } + } + } + + if isPermissionIssue { + msg = fmt.Sprintf("%s - Hint: The Service Account may be missing required IAM permissions. Please ensure it has 'Monitoring Metric Writer' (roles/monitoring.metricWriter) and 'Cloud Trace Agent' (roles/cloudtrace.agent) roles.", msg) + } + + return msg +} diff --git a/cmd/root_test.go b/cmd/root_test.go index 868627ccf..e7d870485 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -31,6 +31,9 @@ import ( "github.com/GoogleCloudPlatform/cloud-sql-proxy/v2/internal/proxy" "github.com/google/go-cmp/cmp" "github.com/spf13/cobra" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func withDefaults(c *proxy.Config) *proxy.Config { @@ -1583,3 +1586,76 @@ func TestQuitQuitQuitWithErrors(t *testing.T) { t.Fatalf("want = %v, got = %v", errCloseFailed, got) } } + +func TestFormatStackdriverError(t *testing.T) { + tcs := []struct { + desc string + err error + wantSub string + wantHint bool + }{ + { + desc: "generic error", + err: errors.New("some random error"), + wantSub: "some random error", + wantHint: false, + }, + { + desc: "gRPC error without details", + err: status.Error(codes.Internal, "Internal error encountered"), + wantSub: "rpc error: code = Internal desc = Internal error encountered", + wantHint: false, + }, + { + desc: "gRPC PermissionDenied error", + err: status.Error(codes.PermissionDenied, "Permission denied"), + wantSub: "rpc error: code = PermissionDenied desc = Permission denied", + wantHint: true, + }, + { + desc: "gRPC error with ErrorInfo permission reason", + err: func() error { + st, _ := status.New(codes.Internal, "Internal error encountered").WithDetails( + &errdetails.ErrorInfo{ + Reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT", + Domain: "googleapis.com", + }, + ) + return st.Err() + }(), + wantSub: "rpc error: code = Internal desc = Internal error encountered", + wantHint: true, + }, + { + desc: "gRPC error with Help link", + err: func() error { + st, _ := status.New(codes.Aborted, "Aborted").WithDetails( + &errdetails.Help{ + Links: []*errdetails.Help_Link{ + { + Description: "See docs", + Url: "http://docs", + }, + }, + }, + ) + return st.Err() + }(), + wantSub: "Help: See docs (http://docs)", + wantHint: false, + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := formatStackdriverError(tc.err) + if !strings.Contains(got, tc.wantSub) { + t.Errorf("formatStackdriverError(%v) = %q, want to contain %q", tc.err, got, tc.wantSub) + } + hasHint := strings.Contains(got, "Hint: The Service Account may be missing required IAM permissions") + if hasHint != tc.wantHint { + t.Errorf("formatStackdriverError(%v) = %q, hasHint = %v, wantHint = %v", tc.err, got, hasHint, tc.wantHint) + } + }) + } +} diff --git a/go.mod b/go.mod index 1f82684f4..22c0d88b6 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.47.0 google.golang.org/api v0.289.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a + google.golang.org/grpc v1.82.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) @@ -79,9 +81,7 @@ require ( golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto v0.0.0-20260720211330-0afa2a65878a // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect - google.golang.org/grpc v1.82.1 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 9726dc080..8e7cc0774 100644 --- a/go.sum +++ b/go.sum @@ -640,10 +640,10 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20260720211330-0afa2a65878a h1:MVNwR9RFj7qfpMtNK71pq97FgrLG0lVHZh+VbM2LZeI= -google.golang.org/genproto v0.0.0-20260720211330-0afa2a65878a/go.mod h1:0qnvndM9dUhat9AtF1jqYN6WZ+tMxEAFImo3WNvUX7w= -google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a h1:97PfJ4tCxY5C7NzzgGqQEMZmXbISdvSArNNEOoUGKBg= -google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=