Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
10 changes: 2 additions & 8 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -57,18 +59,15 @@ 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=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
37 changes: 37 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions internal/cmd/subscription/connect.go
Original file line number Diff line number Diff line change
@@ -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),
}
}
3 changes: 1 addition & 2 deletions internal/cmd/subscription/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions internal/cmd/subscription/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 7 additions & 8 deletions internal/cmd/subscription/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions internal/cmd/subscription/ondemand.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/subscription/portal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading