From 5fea02ca245409d1c2b44a808b375a3bdcb9f6d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 14:59:44 +0000 Subject: [PATCH] feat(subscription): use ConnectRPC for subscription/billing clients The subscription and billing commands previously spoke native gRPC to the control plane, where an RPC error's status and rich ErrorDetail travel only in HTTP/2 trailers. The gRPC proxy in front of control-tower intermittently fails to forward those bodyless, trailer-only error responses, so callers saw "code = Internal desc = server closed the stream without sending trailers" instead of the real error. Successful responses were unaffected because their payload rides in the body. Switch these clients to the ConnectRPC protocol, which carries errors and their ErrorDetail in the response body, sidestepping trailer forwarding entirely. This mirrors the web app, which uses Connect and never exhibited the bug. - Add ControlPlaneCredentials to expose the resolved/refreshed token and tenant for building a Connect client. - Add connect.go: base URL resolution, an auth interceptor injecting the same authorization and x-tenant-id headers the gRPC per-RPC creds set, and a Connect-backed Service constructor. - Rewrite Service over the generated Connect clients; extract ErrorReason from connect.Error details and classify transient/deadline/not-found via connect.Code. - Update callers and tests to the Connect client and error model. Scoped to subscription/billing as a proof of concept; other commands still use gRPC. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014QuAcWAG8UNvgWQW38Kx5Q --- go.mod | 2 + go.sum | 4 + go.work.sum | 10 +-- internal/app/app.go | 37 +++++++++ internal/cmd/subscription/connect.go | 72 ++++++++++++++++ internal/cmd/subscription/create.go | 3 +- internal/cmd/subscription/customer.go | 7 +- internal/cmd/subscription/flow.go | 15 ++-- internal/cmd/subscription/ondemand.go | 12 +-- internal/cmd/subscription/portal.go | 4 +- internal/cmd/subscription/service.go | 83 +++++++++---------- internal/cmd/subscription/status.go | 4 +- .../cmd/subscription/subscription_test.go | 23 ++--- internal/cmd/subscription/trial.go | 3 +- 14 files changed, 191 insertions(+), 88 deletions(-) create mode 100644 internal/cmd/subscription/connect.go 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