diff --git a/go.mod b/go.mod index 9c3d2f3..3c6b40c 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/safedep/cli go 1.26.2 require ( + buf.build/gen/go/safedep/api/connectrpc/go v1.20.0-20260729065101-65a78c3a5c24.1 buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260729065101-65a78c3a5c24.1 buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260729065101-65a78c3a5c24.1 + connectrpc.com/connect v1.20.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/cli/browser v1.0.0 diff --git a/go.sum b/go.sum index fc66c66..4a197c1 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,14 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/safedep/api/connectrpc/go v1.20.0-20260729065101-65a78c3a5c24.1 h1:ESKYtuN9lSsWwKYFDivHIP5Rsgg3vH/pFUJQ7377EbA= +buf.build/gen/go/safedep/api/connectrpc/go v1.20.0-20260729065101-65a78c3a5c24.1/go.mod h1:eWlTMo/Qs6a8ouj8Ty7XaCpryUmd7HIofu2k7YgFvus= buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260729065101-65a78c3a5c24.1 h1:d04k+qJXDAcj9Q7btzZanxoRTqqqoID0nhI3tAPjONY= buf.build/gen/go/safedep/api/grpc/go v1.6.2-20260729065101-65a78c3a5c24.1/go.mod h1:6qW8kKBDx2CJ2noMblRozEIlhOX6QFzLg/GHvYAz0WY= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260729065101-65a78c3a5c24.1 h1:b7QdtcjjdKZQCH1aWqLQRzyViWcO3YbFzg3ff0eSU/g= buf.build/gen/go/safedep/api/protocolbuffers/go v1.36.11-20260729065101-65a78c3a5c24.1/go.mod h1:I8E+sZXJNqzWBtSlRGCoiEorLSRiix50h2R/66aBzME= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= diff --git a/go.work.sum b/go.work.sum index 365481f..8d8347c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -10,6 +10,8 @@ cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCS cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0= cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/ClickHouse/ch-go v0.66.0/go.mod h1:noiHWyLMJAZ5wYuq3R/K0TcRhrNA8h7o1AqHX0klEhM= github.com/ClickHouse/clickhouse-go/v2 v2.35.0/go.mod h1:O2FFT/rugdpGEW2VKyEGyMUWyQU0ahmenY9/emxLPxs= @@ -57,7 +59,6 @@ github.com/cloudwego/eino v0.7.13/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/Wqiy github.com/cloudwego/eino-ext/components/model/claude v0.1.17/go.mod h1:2sGGgwpR60LW+RdG/hcjdGBVEVEJ6EkKxlva8mju9BI= github.com/cloudwego/eino-ext/components/model/gemini v0.1.5/go.mod h1:yWVzN9Y5TU9MERRStVnfa28uXQbbAk+z/2SCi+G32Vc= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= -github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= @@ -65,10 +66,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= @@ -120,14 +119,11 @@ github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OS github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/package-url/packageurl-go v0.1.3 h1:4juMED3hHiz0set3Vq3KeQ75KD1avthoXLtmE3I0PLs= -github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q= github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= @@ -180,5 +176,3 @@ gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g= gorm.io/plugin/prometheus v0.1.0/go.mod h1:5nrc/JrWCUNoDXCY4eOae/FK/J5WjQ0axXuFusCzdTc= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/internal/app/app.go b/internal/app/app.go index 1587a98..96564ad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -241,6 +241,43 @@ func (a *App) ControlPlane() (*cloud.Client, error) { return a.controlPlane, nil } +// ControlPlaneCredentials resolves and, if expired, silently refreshes the +// control-plane credentials, returning the raw access token and tenant +// domain. Used to build a ConnectRPC client for the control plane, whose +// error responses (and rich ErrorDetail) travel in the response body rather +// than HTTP/2 trailers. +func (a *App) ControlPlaneCredentials() (token, tenant string, err error) { + resolver, err := a.TokenResolver() + if err != nil { + return "", "", err + } + + a.mu.Lock() + defer a.mu.Unlock() + + creds, err := resolver.Resolve() + if err != nil { + return "", "", errors.New("not authenticated for control plane: run `safedep auth login`") + } + + creds, err = a.refreshIfExpiredLocked(creds) + if err != nil { + return "", "", err + } + + token, err = creds.GetToken() + if err != nil { + return "", "", err + } + + tenant, err = creds.GetTenantDomain() + if err != nil { + return "", "", err + } + + return token, tenant, nil +} + // refreshIfExpiredLocked silently refreshes the access token when it is // expired. It must be called with a.mu held. On success it returns new // credentials backed by the freshly-saved keychain entry. On refresh diff --git a/internal/cmd/subscription/connect.go b/internal/cmd/subscription/connect.go new file mode 100644 index 0000000..1c2cd9f --- /dev/null +++ b/internal/cmd/subscription/connect.go @@ -0,0 +1,72 @@ +package subscription + +import ( + "context" + "net/http" + "os" + + ctconnect "buf.build/gen/go/safedep/api/connectrpc/go/safedep/services/controltower/v1/controltowerv1connect" + "connectrpc.com/connect" + "github.com/safedep/cli/internal/app" +) + +// The subscription/billing commands talk to the control plane over the +// ConnectRPC protocol rather than native gRPC. Connect carries RPC errors and +// their rich ErrorDetail in the response body, not in HTTP/2 trailers, which +// sidesteps the intermittent trailer loss observed through the gRPC proxy path +// (the proxy mishandles bodyless, trailer-only error responses). + +const ( + envControlPlaneAddr = "SAFEDEP_CLOUD_CONTROL_ADDR" + defaultControlPlaneAddr = "cloud.safedep.io:443" + insecureTransportEnv = "INSECURE_GRPC_CLIENT_USE_INSECURE_TRANSPORT" + authorizationHeader = "authorization" + tenantIDHeader = "x-tenant-id" +) + +// serviceFor builds a Connect-backed Service from the app's control-plane +// credentials, refreshing the token if needed. +func serviceFor(a *app.App) (*Service, error) { + token, tenant, err := a.ControlPlaneCredentials() + if err != nil { + return nil, err + } + return newConnectService(token, tenant), nil +} + +func controlPlaneBaseURL() string { + addr := os.Getenv(envControlPlaneAddr) + if addr == "" { + addr = defaultControlPlaneAddr + } + scheme := "https" + if os.Getenv(insecureTransportEnv) == "true" { + scheme = "http" + } + return scheme + "://" + addr +} + +// authInterceptor injects the same credentials the gRPC per-RPC creds set: +// the raw token in `authorization` and the tenant domain in `x-tenant-id`. +func authInterceptor(token, tenant string) connect.UnaryInterceptorFunc { + return func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if token != "" { + req.Header().Set(authorizationHeader, token) + } + if tenant != "" { + req.Header().Set(tenantIDHeader, tenant) + } + return next(ctx, req) + } + } +} + +func newConnectService(token, tenant string) *Service { + baseURL := controlPlaneBaseURL() + opts := connect.WithInterceptors(authInterceptor(token, tenant)) + return &Service{ + sub: ctconnect.NewSubscriptionServiceClient(http.DefaultClient, baseURL, opts), + billing: ctconnect.NewBillingServiceClient(http.DefaultClient, baseURL, opts), + } +} diff --git a/internal/cmd/subscription/create.go b/internal/cmd/subscription/create.go index 825b08e..cf213b5 100644 --- a/internal/cmd/subscription/create.go +++ b/internal/cmd/subscription/create.go @@ -38,11 +38,10 @@ func createCmd(a *app.App) *cobra.Command { if seats < 1 { return errors.New("--seats must be at least 1") } - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - svc := NewService(client.Connection()) res, err := runCreate(cmd.Context(), svc, form, seats, wait, timeout) if err != nil { return err diff --git a/internal/cmd/subscription/customer.go b/internal/cmd/subscription/customer.go index 725734b..d70db06 100644 --- a/internal/cmd/subscription/customer.go +++ b/internal/cmd/subscription/customer.go @@ -159,11 +159,10 @@ func createCustomerCmd(a *app.App) *cobra.Command { Long: "Create the billing customer profile for the tenant account. Prompts interactively on a terminal; requires flags otherwise.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - svc := NewService(client.Connection()) if _, exists, err := svc.GetCustomer(cmd.Context()); err != nil { return err } else if exists { @@ -194,11 +193,11 @@ func showCustomerCmd(a *app.App) *cobra.Command { Long: "Show the billing customer profile linked to the tenant account.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - cust, exists, err := NewService(client.Connection()).GetCustomer(cmd.Context()) + cust, exists, err := svc.GetCustomer(cmd.Context()) if err != nil { return err } diff --git a/internal/cmd/subscription/flow.go b/internal/cmd/subscription/flow.go index 9b051a1..9ccb5c5 100644 --- a/internal/cmd/subscription/flow.go +++ b/internal/cmd/subscription/flow.go @@ -6,12 +6,11 @@ import ( "fmt" "time" + "connectrpc.com/connect" "github.com/cli/browser" clitui "github.com/safedep/cli/internal/tui" "github.com/safedep/dry/log" "github.com/safedep/dry/tui" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) // Web app billing pages reused as checkout/portal return targets, matching @@ -102,18 +101,18 @@ func pollUntilStatus(ctx context.Context, svc StatusGetter, w statusWaiter, time } // isDeadlineExceeded reports whether err is our overall-timeout expiring, -// covering both the context sentinel and a gRPC status carrying -// codes.DeadlineExceeded (the two do not satisfy errors.Is each other). +// covering both the context sentinel and a Connect error carrying +// CodeDeadlineExceeded (the two do not satisfy errors.Is each other). func isDeadlineExceeded(ctx context.Context, err error) bool { return errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) || - status.Code(err) == codes.DeadlineExceeded + connect.CodeOf(err) == connect.CodeDeadlineExceeded } -// isTransient reports whether a gRPC error is worth retrying within the wait. +// isTransient reports whether a Connect error is worth retrying within the wait. func isTransient(err error) bool { - switch status.Code(err) { - case codes.Unavailable, codes.Aborted: + switch connect.CodeOf(err) { + case connect.CodeUnavailable, connect.CodeAborted: return true default: return false diff --git a/internal/cmd/subscription/ondemand.go b/internal/cmd/subscription/ondemand.go index 12abb69..1765e52 100644 --- a/internal/cmd/subscription/ondemand.go +++ b/internal/cmd/subscription/ondemand.go @@ -36,11 +36,11 @@ func ondemandEnableCmd(a *app.App) *cobra.Command { tui.Info("Terms: %s", termsURL) return errors.New("re-run with --accept-terms to confirm") } - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - state, err := NewService(client.Connection()).EnableOnDemand(cmd.Context(), termsVersion) + state, err := svc.EnableOnDemand(cmd.Context(), termsVersion) if err != nil { return err } @@ -59,11 +59,11 @@ func ondemandDisableCmd(a *app.App) *cobra.Command { Long: "Opt out of usage-based overage billing. Included seat limits continue to apply.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - state, err := NewService(client.Connection()).DisableOnDemand(cmd.Context()) + state, err := svc.DisableOnDemand(cmd.Context()) if err != nil { return err } @@ -80,11 +80,11 @@ func ondemandStatusCmd(a *app.App) *cobra.Command { Long: "Show the tenant account's on-demand billing state: opt-in, payment method, and dunning posture.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - state, err := NewService(client.Connection()).OnDemandState(cmd.Context()) + state, err := svc.OnDemandState(cmd.Context()) if err != nil { return err } diff --git a/internal/cmd/subscription/portal.go b/internal/cmd/subscription/portal.go index 1e6db72..8d30c50 100644 --- a/internal/cmd/subscription/portal.go +++ b/internal/cmd/subscription/portal.go @@ -24,11 +24,11 @@ func portalOpenCmd(a *app.App) *cobra.Command { "cancellation for the tenant account.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - url, err := NewService(client.Connection()).Portal(cmd.Context(), portalReturnURL) + url, err := svc.Portal(cmd.Context(), portalReturnURL) if err != nil { return err } diff --git a/internal/cmd/subscription/service.go b/internal/cmd/subscription/service.go index 076a0c5..05f390d 100644 --- a/internal/cmd/subscription/service.go +++ b/internal/cmd/subscription/service.go @@ -6,14 +6,12 @@ import ( "fmt" "time" - ctv1grpc "buf.build/gen/go/safedep/api/grpc/go/safedep/services/controltower/v1/controltowerv1grpc" + ctconnect "buf.build/gen/go/safedep/api/connectrpc/go/safedep/services/controltower/v1/controltowerv1connect" msgv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/controltower/v1" errorv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/error/v1" ctv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/services/controltower/v1" + "connectrpc.com/connect" "github.com/safedep/cli/internal/tui" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) // termsVersion is the on-demand billing terms version the CLI records as @@ -22,7 +20,7 @@ import ( const termsVersion = "2026-07-23" // One narrow interface per operation so commands and tests depend only on -// what they use. Service is the single gRPC-backed implementation over both +// what they use. Service is the single Connect-backed implementation over both // the subscription and billing service clients. type StatusGetter interface { @@ -64,15 +62,8 @@ type OnDemandDisabler interface { } type Service struct { - sub ctv1grpc.SubscriptionServiceClient - billing ctv1grpc.BillingServiceClient -} - -func NewService(conn *grpc.ClientConn) *Service { - return &Service{ - sub: ctv1grpc.NewSubscriptionServiceClient(conn), - billing: ctv1grpc.NewBillingServiceClient(conn), - } + sub ctconnect.SubscriptionServiceClient + billing ctconnect.BillingServiceClient } var ( @@ -161,21 +152,22 @@ type ProviderError struct { } func (s *Service) Status(ctx context.Context) (*AccountStatus, error) { - res, err := s.sub.GetSubscriptionAccountStatus(ctx, &ctv1.GetSubscriptionAccountStatusRequest{}) + res, err := s.sub.GetSubscriptionAccountStatus(ctx, connect.NewRequest(&ctv1.GetSubscriptionAccountStatusRequest{})) if err != nil { return nil, fmt.Errorf("subscription: status: %w", err) } - out := &AccountStatus{Status: statusToken(res.GetStatus())} - if info := res.GetSubscriptionAccountInfo(); info != nil { + msg := res.Msg + out := &AccountStatus{Status: statusToken(msg.GetStatus())} + if info := msg.GetSubscriptionAccountInfo(); info != nil { out.Tier = tierToken(info.GetBillingTier()) } - if t := res.GetTrialStatus(); t != nil { + if t := msg.GetTrialStatus(); t != nil { out.Trial = &TrialInfo{DaysRemaining: t.GetDaysRemaining()} if t.GetExpiresAt() != nil { out.Trial.ExpiresAt = t.GetExpiresAt().AsTime() } } - for _, e := range res.GetEntitlements() { + for _, e := range msg.GetEntitlements() { out.Entitlements = append(out.Entitlements, featureToken(e.GetEntitlement().GetFeature())) } // On-demand summary is best-effort: a failure here must not fail status. @@ -186,7 +178,7 @@ func (s *Service) Status(ctx context.Context) (*AccountStatus, error) { } func (s *Service) ActivateTrial(ctx context.Context) error { - _, err := s.sub.ActivateTrialSubscription(ctx, &ctv1.ActivateTrialSubscriptionRequest{}) + _, err := s.sub.ActivateTrialSubscription(ctx, connect.NewRequest(&ctv1.ActivateTrialSubscriptionRequest{})) if err != nil { return fmt.Errorf("subscription: activate trial: %w", err) } @@ -194,14 +186,14 @@ func (s *Service) ActivateTrial(ctx context.Context) error { } func (s *Service) GetCustomer(ctx context.Context) (*Customer, bool, error) { - res, err := s.billing.GetBillingCustomer(ctx, &ctv1.GetBillingCustomerRequest{}) + res, err := s.billing.GetBillingCustomer(ctx, connect.NewRequest(&ctv1.GetBillingCustomerRequest{})) if err != nil { - if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound { + if connect.CodeOf(err) == connect.CodeNotFound { return nil, false, nil } return nil, false, fmt.Errorf("subscription: get customer: %w", err) } - return customerFromProto(res.GetBillingCustomer()), true, nil + return customerFromProto(res.Msg.GetBillingCustomer()), true, nil } func (s *Service) CreateCustomer(ctx context.Context, in CustomerInput) (*Customer, []ProviderError, error) { @@ -219,15 +211,15 @@ func (s *Service) CreateCustomer(ctx context.Context, in CustomerInput) (*Custom if in.TaxID != "" { req.SetCustomerTaxId(in.TaxID) } - res, err := s.billing.CreateBillingCustomer(ctx, req) + res, err := s.billing.CreateBillingCustomer(ctx, connect.NewRequest(req)) if err != nil { return nil, nil, fmt.Errorf("subscription: create customer: %w", err) } var perr []ProviderError - for _, e := range res.GetErrors() { + for _, e := range res.Msg.GetErrors() { perr = append(perr, ProviderError{Type: e.GetType(), Param: e.GetParam(), Message: e.GetMessage()}) } - return customerFromProto(res.GetBillingCustomer()), perr, nil + return customerFromProto(res.Msg.GetBillingCustomer()), perr, nil } func (s *Service) Checkout(ctx context.Context, in CheckoutInput) (*CheckoutResult, error) { @@ -241,12 +233,12 @@ func (s *Service) Checkout(ctx context.Context, in CheckoutInput) (*CheckoutResu if in.Seats > 0 { req.SetQuantity(in.Seats) } - res, err := s.billing.CreateBillingSubscriptionCheckoutSession(ctx, req) + res, err := s.billing.CreateBillingSubscriptionCheckoutSession(ctx, connect.NewRequest(req)) if err != nil { return nil, fmt.Errorf("subscription: checkout: %w", err) } - info := res.GetStatusInfo() - out := &CheckoutResult{URL: res.GetCheckoutSessionUrl()} + info := res.Msg.GetStatusInfo() + out := &CheckoutResult{URL: res.Msg.GetCheckoutSessionUrl()} switch info.GetStatus() { case ctv1.CreateBillingSubscriptionCheckoutSessionResponse_STATUS_SUCCESS: out.Outcome = checkoutSuccess @@ -265,37 +257,37 @@ func (s *Service) Portal(ctx context.Context, returnURL string) (string, error) flow.SetReturnUrl(returnURL) req := &ctv1.CreateBillingCustomerPortalSessionRequest{} req.SetFlowInfo(flow) - res, err := s.billing.CreateBillingCustomerPortalSession(ctx, req) + res, err := s.billing.CreateBillingCustomerPortalSession(ctx, connect.NewRequest(req)) if err != nil { return "", fmt.Errorf("subscription: portal: %w", err) } - return res.GetCustomerPortalUrl(), nil + return res.Msg.GetCustomerPortalUrl(), nil } func (s *Service) OnDemandState(ctx context.Context) (*OnDemandState, error) { - res, err := s.billing.GetOnDemandBillingState(ctx, &ctv1.GetOnDemandBillingStateRequest{}) + res, err := s.billing.GetOnDemandBillingState(ctx, connect.NewRequest(&ctv1.GetOnDemandBillingStateRequest{})) if err != nil { return nil, fmt.Errorf("subscription: on-demand state: %w", err) } - return onDemandFromProto(res.GetState()), nil + return onDemandFromProto(res.Msg.GetState()), nil } func (s *Service) EnableOnDemand(ctx context.Context, terms string) (*OnDemandState, error) { req := &ctv1.EnableOnDemandBillingRequest{} req.SetTermsVersion(terms) - res, err := s.billing.EnableOnDemandBilling(ctx, req) + res, err := s.billing.EnableOnDemandBilling(ctx, connect.NewRequest(req)) if err != nil { return nil, mapOnDemandEnableError(err) } - return onDemandFromProto(res.GetState()), nil + return onDemandFromProto(res.Msg.GetState()), nil } func (s *Service) DisableOnDemand(ctx context.Context) (*OnDemandState, error) { - res, err := s.billing.DisableOnDemandBilling(ctx, &ctv1.DisableOnDemandBillingRequest{}) + res, err := s.billing.DisableOnDemandBilling(ctx, connect.NewRequest(&ctv1.DisableOnDemandBillingRequest{})) if err != nil { return nil, fmt.Errorf("subscription: disable on-demand: %w", err) } - return onDemandFromProto(res.GetState()), nil + return onDemandFromProto(res.Msg.GetState()), nil } // mapOnDemandEnableError routes the typed ErrorReason to an actionable @@ -311,15 +303,20 @@ func mapOnDemandEnableError(err error) error { } } -// errorReason extracts the typed business ErrorReason from a gRPC status -// error's details, or UNSPECIFIED when none is present. +// errorReason extracts the typed business ErrorReason from a Connect error's +// details, or UNSPECIFIED when none is present. Connect carries these details +// in the response body, so they survive proxy hops that drop HTTP/2 trailers. func errorReason(err error) errorv1.ErrorReason { - st, ok := status.FromError(err) - if !ok { + var cerr *connect.Error + if !errors.As(err, &cerr) { return errorv1.ErrorReason_ERROR_REASON_UNSPECIFIED } - for _, d := range st.Details() { - if detail, ok := d.(*errorv1.ErrorDetail); ok { + for _, d := range cerr.Details() { + msg, verr := d.Value() + if verr != nil { + continue + } + if detail, ok := msg.(*errorv1.ErrorDetail); ok { return detail.GetReason() } } diff --git a/internal/cmd/subscription/status.go b/internal/cmd/subscription/status.go index 73df742..c14454c 100644 --- a/internal/cmd/subscription/status.go +++ b/internal/cmd/subscription/status.go @@ -31,11 +31,11 @@ func statusCmd(a *app.App) *cobra.Command { Long: "Show the tenant account's subscription status, tier, trial, and on-demand billing. Pass --entitlements to also list the account's entitlements.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - acct, err := runStatus(cmd.Context(), NewService(client.Connection())) + acct, err := runStatus(cmd.Context(), svc) if err != nil { return err } diff --git a/internal/cmd/subscription/subscription_test.go b/internal/cmd/subscription/subscription_test.go index 8f56895..9d47d9f 100644 --- a/internal/cmd/subscription/subscription_test.go +++ b/internal/cmd/subscription/subscription_test.go @@ -7,13 +7,12 @@ import ( "time" errorv1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/error/v1" + "connectrpc.com/connect" "github.com/safedep/cli/internal/app" "github.com/safedep/cli/internal/config" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) // fakeSvc satisfies every subscription interface; tests wire only the funcs @@ -92,16 +91,18 @@ func TestMapOnDemandEnableError(t *testing.T) { t.Parallel() detail := &errorv1.ErrorDetail{} detail.SetReason(tt.reason) - st, err := status.New(codes.FailedPrecondition, "denied").WithDetails(detail) - require.NoError(t, err) - assert.Contains(t, mapOnDemandEnableError(st.Err()).Error(), tt.want) + cerr := connect.NewError(connect.CodeFailedPrecondition, errors.New("denied")) + d, derr := connect.NewErrorDetail(detail) + require.NoError(t, derr) + cerr.AddDetail(d) + assert.Contains(t, mapOnDemandEnableError(cerr).Error(), tt.want) }) } } func TestMapOnDemandEnableError_UntypedPassthrough(t *testing.T) { t.Parallel() - err := status.Error(codes.Unavailable, "boom") + err := connect.NewError(connect.CodeUnavailable, errors.New("boom")) assert.Contains(t, mapOnDemandEnableError(err).Error(), "enable on-demand") } @@ -243,7 +244,7 @@ func TestPollUntilStatus_RetriesTransientThenSucceeds(t *testing.T) { svc := &fakeSvc{statusFn: func(context.Context) (*AccountStatus, error) { calls++ if calls == 1 { - return nil, status.Error(codes.Unavailable, "temporary") + return nil, connect.NewError(connect.CodeUnavailable, errors.New("temporary")) } return &AccountStatus{Status: statusActive}, nil }} @@ -256,17 +257,17 @@ func TestPollUntilStatus_RetriesTransientThenSucceeds(t *testing.T) { func TestPollUntilStatus_NonTransientErrorReturned(t *testing.T) { t.Parallel() svc := &fakeSvc{statusFn: func(context.Context) (*AccountStatus, error) { - return nil, status.Error(codes.PermissionDenied, "nope") + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("nope")) }} _, err := pollUntilStatus(context.Background(), svc, activeWaiter(), time.Minute) require.Error(t, err) - assert.Equal(t, codes.PermissionDenied, status.Code(err), "non-transient errors surface as-is") + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err), "non-transient errors surface as-is") } -func TestPollUntilStatus_GRPCDeadlineMapsToTimeout(t *testing.T) { +func TestPollUntilStatus_DeadlineMapsToTimeout(t *testing.T) { t.Parallel() svc := &fakeSvc{statusFn: func(context.Context) (*AccountStatus, error) { - return nil, status.Error(codes.DeadlineExceeded, "deadline") + return nil, connect.NewError(connect.CodeDeadlineExceeded, errors.New("deadline")) }} _, err := pollUntilStatus(context.Background(), svc, activeWaiter(), time.Minute) require.Error(t, err) diff --git a/internal/cmd/subscription/trial.go b/internal/cmd/subscription/trial.go index c62b230..6985997 100644 --- a/internal/cmd/subscription/trial.go +++ b/internal/cmd/subscription/trial.go @@ -38,11 +38,10 @@ func trialEnableCmd(a *app.App) *cobra.Command { Long: "Activate the free trial subscription. Creates a billing profile first if none exists (interactive on a terminal, flags otherwise).", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - client, err := a.ControlPlane() + svc, err := serviceFor(a) if err != nil { return err } - svc := NewService(client.Connection()) acct, confirmed, err := runTrialEnable(cmd.Context(), svc, form, wait, timeout) if err != nil { return err