diff --git a/README.md b/README.md index 96eed04e..3b2cef39 100644 --- a/README.md +++ b/README.md @@ -724,6 +724,8 @@ Automated authentication for web services. The `run` command orchestrates the fu ### Org +- `kernel org entitlements` - Show the organization's effective plan, feature access, and limits + - `--output json`, `-o json` - Output the raw entitlement response - `kernel org limits get` - Show the organization's concurrency limit and the default per-project cap applied to projects without an explicit override - `--output json`, `-o json` - Output raw JSON object - `kernel org limits set` - Set the default per-project concurrency cap applied to projects without an explicit override diff --git a/cmd/org.go b/cmd/org.go index ffd8c02b..e8ba0d52 100644 --- a/cmd/org.go +++ b/cmd/org.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "github.com/kernel/cli/pkg/util" @@ -19,8 +20,14 @@ type OrgLimitsService interface { Update(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (res *kernel.OrgLimits, err error) } +// OrgEntitlementsService defines the organization entitlements operation used by the CLI. +type OrgEntitlementsService interface { + Get(ctx context.Context, opts ...option.RequestOption) (res *kernel.OrgEntitlements, err error) +} + type OrgCmd struct { - limits OrgLimitsService + limits OrgLimitsService + entitlements OrgEntitlementsService } type OrgLimitsGetInput struct { @@ -32,6 +39,32 @@ type OrgLimitsSetInput struct { Output string } +type OrgEntitlementsInput struct { + Output string +} + +func (c OrgCmd) Entitlements(ctx context.Context, in OrgEntitlementsInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + entitlements, err := c.entitlements.Get(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if entitlements == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(entitlements) + } + + renderOrgEntitlements(entitlements) + return nil +} + func (c OrgCmd) LimitsGet(ctx context.Context, in OrgLimitsGetInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -125,6 +158,81 @@ func orgLimitFieldPresent(field respjson.Field) bool { return field.Raw() != respjson.Omitted } +func renderOrgEntitlements(entitlements *kernel.OrgEntitlements) { + if entitlements == nil { + pterm.Info.Println("No organization entitlements found") + return + } + + PrintTableNoPad(orgEntitlementRows(entitlements), true) +} + +func orgEntitlementRows(entitlements *kernel.OrgEntitlements) pterm.TableData { + status := formatNullableEntitlementString(entitlements.Plan.Status, entitlements.Plan.JSON.Status) + trialEndsAt := "unknown" + if entitlements.Plan.JSON.TrialEndsAt.Raw() == respjson.Null { + trialEndsAt = "none" + } else if entitlements.Plan.JSON.TrialEndsAt.Valid() { + trialEndsAt = util.FormatLocal(entitlements.Plan.TrialEndsAt) + } + + features := entitlements.Features + limits := entitlements.Limits + return pterm.TableData{ + {"Category", "Entitlement", "Value"}, + {"Plan", "Contractual plan", entitlements.Plan.ID}, + {"Plan", "Effective plan", entitlements.Plan.EffectiveID}, + {"Plan", "Status", status}, + {"Plan", "Trialing", fmt.Sprintf("%t", entitlements.Plan.IsTrialing)}, + {"Plan", "Trial ends at", trialEndsAt}, + {"Feature", "Profiles", fmt.Sprintf("%t", features.Profiles.Enabled)}, + {"Feature", "File I/O", fmt.Sprintf("%t", features.FileIo.Enabled)}, + {"Feature", "Browser replays", fmt.Sprintf("%t", features.BrowserReplays.Enabled)}, + {"Feature", "Browser replay retention (days)", fmt.Sprintf("%d", features.BrowserReplays.RetentionDays)}, + {"Feature", "Browser extensions", fmt.Sprintf("%t", features.BrowserExtensions.Enabled)}, + {"Feature", "Max stored extensions", formatEntitlementLimitValue(features.BrowserExtensions.MaxStoredPerOrg, features.BrowserExtensions.JSON.MaxStoredPerOrg)}, + {"Feature", "Browser pools", fmt.Sprintf("%t", features.BrowserPools.Enabled)}, + {"Feature", "Managed auth", fmt.Sprintf("%t", features.ManagedAuth.Enabled)}, + {"Feature", "Max managed auth connections", formatEntitlementLimitValue(features.ManagedAuth.MaxConnections, features.ManagedAuth.JSON.MaxConnections)}, + {"Feature", "Health check minimum (seconds)", fmt.Sprintf("%d", features.ManagedAuth.HealthCheckIntervalMinSeconds)}, + {"Feature", "Health check default (seconds)", fmt.Sprintf("%d", features.ManagedAuth.HealthCheckIntervalDefaultSeconds)}, + {"Feature", "Health check maximum (seconds)", fmt.Sprintf("%d", features.ManagedAuth.HealthCheckIntervalMaxSeconds)}, + {"Feature", "Credentials", fmt.Sprintf("%t", features.Credentials.Enabled)}, + {"Feature", "Credential providers", fmt.Sprintf("%t", features.CredentialProviders.Enabled)}, + {"Feature", "Managed proxies", fmt.Sprintf("%t", features.ManagedProxies.Enabled)}, + {"Feature", "Custom proxies", fmt.Sprintf("%t", features.CustomProxies.Enabled)}, + {"Feature", "Proxy bypass hosts", fmt.Sprintf("%t", features.ProxyBypassHosts.Enabled)}, + {"Feature", "GPU", fmt.Sprintf("%t", features.GPU.Enabled)}, + {"Limit", "Max concurrent browsers", fmt.Sprintf("%d", limits.MaxConcurrentBrowsers)}, + {"Limit", "Max concurrent invocations", fmt.Sprintf("%d", limits.MaxConcurrentInvocations)}, + {"Limit", "Default max concurrent invocations per app", fmt.Sprintf("%d", limits.DefaultMaxConcurrentInvocationsPerApp)}, + } +} + +func formatNullableEntitlementString(value string, field respjson.Field) string { + if field.Raw() == respjson.Null { + return "none" + } + if !field.Valid() { + return "unknown" + } + var decoded string + if err := json.Unmarshal([]byte(field.Raw()), &decoded); err != nil { + return "unknown" + } + return value +} + +func formatEntitlementLimitValue(value int64, field respjson.Field) string { + if field.Raw() == respjson.Null { + return "unlimited" + } + if !field.Valid() { + return "unknown" + } + return fmt.Sprintf("%d", value) +} + // --- Cobra wiring --- var orgCmd = &cobra.Command{ @@ -160,9 +268,20 @@ var orgLimitsSetCmd = &cobra.Command{ RunE: runOrgLimitsSet, } +var orgEntitlementsCmd = &cobra.Command{ + Use: "entitlements", + Short: "Get effective organization entitlements", + Long: "Show the authenticated organization's effective feature access and limits after applying its plan, trial, status, and organization-specific overrides. Unlimited values are shown as unlimited.", + Args: cobra.NoArgs, + RunE: runOrgEntitlements, +} + func getOrgHandler(cmd *cobra.Command) OrgCmd { client := getKernelClient(cmd) - return OrgCmd{limits: &client.Organization.Limits} + return OrgCmd{ + limits: &client.Organization.Limits, + entitlements: &client.Organization.Entitlements, + } } func runOrgLimitsGet(cmd *cobra.Command, args []string) error { @@ -184,12 +303,20 @@ func runOrgLimitsSet(cmd *cobra.Command, args []string) error { }) } +func runOrgEntitlements(cmd *cobra.Command, args []string) error { + c := getOrgHandler(cmd) + output, _ := cmd.Flags().GetString("output") + return c.Entitlements(cmd.Context(), OrgEntitlementsInput{Output: output}) +} + func init() { addJSONOutputFlag(orgLimitsGetCmd) orgLimitsSetCmd.Flags().Int64("default-project-max-concurrent-sessions", 0, "Default maximum concurrent browsers for projects without an explicit override (0 to remove the default)") addJSONOutputFlag(orgLimitsSetCmd) + addJSONOutputFlag(orgEntitlementsCmd) orgLimitsCmd.AddCommand(orgLimitsGetCmd) orgLimitsCmd.AddCommand(orgLimitsSetCmd) orgCmd.AddCommand(orgLimitsCmd) + orgCmd.AddCommand(orgEntitlementsCmd) } diff --git a/cmd/org_test.go b/cmd/org_test.go index 946800b0..e55713fd 100644 --- a/cmd/org_test.go +++ b/cmd/org_test.go @@ -2,12 +2,16 @@ package cmd import ( "context" + "encoding/json" "errors" "testing" + "time" + "github.com/kernel/cli/pkg/util" "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/respjson" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) @@ -16,6 +20,238 @@ type FakeOrgLimitsService struct { UpdateFunc func(ctx context.Context, body kernel.OrganizationLimitUpdateParams, opts ...option.RequestOption) (*kernel.OrgLimits, error) } +type FakeOrgEntitlementsService struct { + GetFunc func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) +} + +func (f *FakeOrgEntitlementsService) Get(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, opts...) + } + return nil, nil +} + +func testOrgEntitlementsWithUnlimitedValues(t *testing.T) *kernel.OrgEntitlements { + t.Helper() + var entitlements kernel.OrgEntitlements + err := json.Unmarshal([]byte(`{ + "plan":{"id":"FREE","effective_id":"START_UP","status":null,"is_trialing":true,"trial_ends_at":null}, + "features":{ + "profiles":{"enabled":true}, + "file_io":{"enabled":true}, + "browser_replays":{"enabled":true,"retention_days":30}, + "browser_extensions":{"enabled":true,"max_stored_per_org":null}, + "browser_pools":{"enabled":true}, + "managed_auth":{"enabled":true,"max_connections":null,"health_check_interval_min_seconds":1200,"health_check_interval_default_seconds":3600,"health_check_interval_max_seconds":86400}, + "credentials":{"enabled":true}, + "credential_providers":{"enabled":true}, + "managed_proxies":{"enabled":true}, + "custom_proxies":{"enabled":true}, + "proxy_bypass_hosts":{"enabled":true}, + "gpu":{"enabled":false} + }, + "limits":{"max_concurrent_browsers":150,"max_concurrent_invocations":150,"default_max_concurrent_invocations_per_app":20} + }`), &entitlements) + assert.NoError(t, err) + return &entitlements +} + +func TestOrgEntitlementRows_CompleteProjection(t *testing.T) { + var entitlements kernel.OrgEntitlements + err := json.Unmarshal([]byte(`{ + "plan":{"id":"HOBBYIST","effective_id":"START_UP","status":"ACTIVE","is_trialing":true,"trial_ends_at":"2030-01-02T03:04:05Z"}, + "features":{ + "profiles":{"enabled":true}, + "file_io":{"enabled":false}, + "browser_replays":{"enabled":true,"retention_days":17}, + "browser_extensions":{"enabled":false,"max_stored_per_org":23}, + "browser_pools":{"enabled":true}, + "managed_auth":{"enabled":false,"max_connections":29,"health_check_interval_min_seconds":31,"health_check_interval_default_seconds":37,"health_check_interval_max_seconds":41}, + "credentials":{"enabled":true}, + "credential_providers":{"enabled":false}, + "managed_proxies":{"enabled":true}, + "custom_proxies":{"enabled":false}, + "proxy_bypass_hosts":{"enabled":true}, + "gpu":{"enabled":false} + }, + "limits":{"max_concurrent_browsers":43,"max_concurrent_invocations":47,"default_max_concurrent_invocations_per_app":53} + }`), &entitlements) + assert.NoError(t, err) + + assert.Equal(t, pterm.TableData{ + {"Category", "Entitlement", "Value"}, + {"Plan", "Contractual plan", "HOBBYIST"}, + {"Plan", "Effective plan", "START_UP"}, + {"Plan", "Status", "ACTIVE"}, + {"Plan", "Trialing", "true"}, + {"Plan", "Trial ends at", util.FormatLocal(entitlements.Plan.TrialEndsAt)}, + {"Feature", "Profiles", "true"}, + {"Feature", "File I/O", "false"}, + {"Feature", "Browser replays", "true"}, + {"Feature", "Browser replay retention (days)", "17"}, + {"Feature", "Browser extensions", "false"}, + {"Feature", "Max stored extensions", "23"}, + {"Feature", "Browser pools", "true"}, + {"Feature", "Managed auth", "false"}, + {"Feature", "Max managed auth connections", "29"}, + {"Feature", "Health check minimum (seconds)", "31"}, + {"Feature", "Health check default (seconds)", "37"}, + {"Feature", "Health check maximum (seconds)", "41"}, + {"Feature", "Credentials", "true"}, + {"Feature", "Credential providers", "false"}, + {"Feature", "Managed proxies", "true"}, + {"Feature", "Custom proxies", "false"}, + {"Feature", "Proxy bypass hosts", "true"}, + {"Feature", "GPU", "false"}, + {"Limit", "Max concurrent browsers", "43"}, + {"Limit", "Max concurrent invocations", "47"}, + {"Limit", "Default max concurrent invocations per app", "53"}, + }, orgEntitlementRows(&entitlements)) +} + +func TestOrgEntitlementRows_BooleanFieldProvenance(t *testing.T) { + tests := []struct { + entitlement string + set func(*kernel.OrgEntitlements) + }{ + {"Trialing", func(e *kernel.OrgEntitlements) { e.Plan.IsTrialing = true }}, + {"Profiles", func(e *kernel.OrgEntitlements) { e.Features.Profiles.Enabled = true }}, + {"File I/O", func(e *kernel.OrgEntitlements) { e.Features.FileIo.Enabled = true }}, + {"Browser replays", func(e *kernel.OrgEntitlements) { e.Features.BrowserReplays.Enabled = true }}, + {"Browser extensions", func(e *kernel.OrgEntitlements) { e.Features.BrowserExtensions.Enabled = true }}, + {"Browser pools", func(e *kernel.OrgEntitlements) { e.Features.BrowserPools.Enabled = true }}, + {"Managed auth", func(e *kernel.OrgEntitlements) { e.Features.ManagedAuth.Enabled = true }}, + {"Credentials", func(e *kernel.OrgEntitlements) { e.Features.Credentials.Enabled = true }}, + {"Credential providers", func(e *kernel.OrgEntitlements) { e.Features.CredentialProviders.Enabled = true }}, + {"Managed proxies", func(e *kernel.OrgEntitlements) { e.Features.ManagedProxies.Enabled = true }}, + {"Custom proxies", func(e *kernel.OrgEntitlements) { e.Features.CustomProxies.Enabled = true }}, + {"Proxy bypass hosts", func(e *kernel.OrgEntitlements) { e.Features.ProxyBypassHosts.Enabled = true }}, + {"GPU", func(e *kernel.OrgEntitlements) { e.Features.GPU.Enabled = true }}, + } + + for _, tt := range tests { + t.Run(tt.entitlement, func(t *testing.T) { + var entitlements kernel.OrgEntitlements + tt.set(&entitlements) + rows := orgEntitlementRows(&entitlements) + values := make(map[string]string, len(rows)) + for _, row := range rows { + values[row[1]] = row[2] + } + + for _, candidate := range tests { + expected := "false" + if candidate.entitlement == tt.entitlement { + expected = "true" + } + assert.Equal(t, expected, values[candidate.entitlement], candidate.entitlement) + } + }) + } +} + +func TestOrgEntitlementRows_NullableFieldStates(t *testing.T) { + populatedTrialEnd, err := time.Parse(time.RFC3339, "2031-02-03T04:05:06Z") + assert.NoError(t, err) + + tests := []struct { + name string + payload string + expectedStatus string + expectedTrialEnd string + expectedMaxStored string + expectedMaxAuthConns string + }{ + { + name: "populated", + payload: `{"plan":{"status":"ACTIVE","trial_ends_at":"2031-02-03T04:05:06Z"},"features":{"browser_extensions":{"max_stored_per_org":11},"managed_auth":{"max_connections":13}}}`, + expectedStatus: "ACTIVE", + expectedTrialEnd: util.FormatLocal(populatedTrialEnd), + expectedMaxStored: "11", + expectedMaxAuthConns: "13", + }, + { + name: "explicit null", + payload: `{"plan":{"status":null,"trial_ends_at":null},"features":{"browser_extensions":{"max_stored_per_org":null},"managed_auth":{"max_connections":null}}}`, + expectedStatus: "none", + expectedTrialEnd: "none", + expectedMaxStored: "unlimited", + expectedMaxAuthConns: "unlimited", + }, + { + name: "omitted", + payload: `{"plan":{},"features":{"browser_extensions":{},"managed_auth":{}}}`, + expectedStatus: "unknown", + expectedTrialEnd: "unknown", + expectedMaxStored: "unknown", + expectedMaxAuthConns: "unknown", + }, + { + name: "malformed", + payload: `{"plan":{"status":7,"trial_ends_at":"not-a-date"},"features":{"browser_extensions":{"max_stored_per_org":"many"},"managed_auth":{"max_connections":"many"}}}`, + expectedStatus: "unknown", + expectedTrialEnd: "unknown", + expectedMaxStored: "unknown", + expectedMaxAuthConns: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var entitlements kernel.OrgEntitlements + assert.NoError(t, json.Unmarshal([]byte(tt.payload), &entitlements)) + + rows := orgEntitlementRows(&entitlements) + assert.Equal(t, pterm.TableData{ + {"Plan", "Status", tt.expectedStatus}, + {"Plan", "Trial ends at", tt.expectedTrialEnd}, + {"Feature", "Max stored extensions", tt.expectedMaxStored}, + {"Feature", "Max managed auth connections", tt.expectedMaxAuthConns}, + }, pterm.TableData{rows[3], rows[5], rows[11], rows[14]}) + }) + } +} + +func TestOrgEntitlements_RendersTrialEndInLocalTime(t *testing.T) { + var entitlements kernel.OrgEntitlements + err := json.Unmarshal([]byte(`{ + "plan":{"id":"FREE","effective_id":"START_UP","status":"ACTIVE","is_trialing":true,"trial_ends_at":"2030-01-02T03:04:05Z"}, + "features":{}, + "limits":{} + }`), &entitlements) + assert.NoError(t, err) + + buf := capturePtermOutput(t) + renderOrgEntitlements(&entitlements) + + assert.Contains(t, buf.String(), util.FormatLocal(entitlements.Plan.TrialEndsAt)) +} + +func TestOrgEntitlements_JSONPreservesNullUnlimitedValues(t *testing.T) { + c := OrgCmd{entitlements: &FakeOrgEntitlementsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + return testOrgEntitlementsWithUnlimitedValues(t), nil + }, + }} + + out := captureStdout(t, func() { + assert.NoError(t, c.Entitlements(context.Background(), OrgEntitlementsInput{Output: "json"})) + }) + assert.Contains(t, out, `"max_stored_per_org": null`) + assert.Contains(t, out, `"max_connections": null`) +} + +func TestOrgEntitlements_SurfacesAPIError(t *testing.T) { + capturePtermOutput(t) + c := OrgCmd{entitlements: &FakeOrgEntitlementsService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgEntitlements, error) { + return nil, errors.New("boom") + }, + }} + + assert.Error(t, c.Entitlements(context.Background(), OrgEntitlementsInput{})) +} + func (f *FakeOrgLimitsService) Get(ctx context.Context, opts ...option.RequestOption) (*kernel.OrgLimits, error) { if f.GetFunc != nil { return f.GetFunc(ctx, opts...) diff --git a/go.mod b/go.mod index ebe99635..502421b9 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.91.0 + github.com/kernel/kernel-go-sdk v0.92.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index 7808eb39..04679b80 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.91.0 h1:/bJKFJQ8ZwAyl+r8P1sUW8NQYEjDekYZJ5R8Sml5bus= -github.com/kernel/kernel-go-sdk v0.91.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.92.0 h1:3EeoPahTcGEo97BCbwT50gu8QJnawfL166z12hc8Ucg= +github.com/kernel/kernel-go-sdk v0.92.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=