diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 3f401de3c..774506940 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -743,19 +743,20 @@ payload shape change when the portal presents compact client rows. destination, but the browser/API contract must not reintroduce Pulse-Pro-as-page-name copy in callback titles, actions, or retry guidance. -40. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary. -41. `internal/api/org_handlers.go` shared with `organization-settings`: organization management handlers are both an organization settings control surface and a canonical API payload contract boundary. -42. `internal/api/org_lifecycle_handlers.go` shared with `organization-settings`: organization lifecycle handlers are both an organization settings control surface and a canonical API payload contract boundary. -43. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. -44. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. +40. `internal/api/licensing_legacy_retry.go` shared with `cloud-paid`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership. +41. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary. +42. `internal/api/org_handlers.go` shared with `organization-settings`: organization management handlers are both an organization settings control surface and a canonical API payload contract boundary. +43. `internal/api/org_lifecycle_handlers.go` shared with `organization-settings`: organization lifecycle handlers are both an organization settings control surface and a canonical API payload contract boundary. +44. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. +45. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. That same shared boundary also owns public hosted-signup response privacy: syntactically valid `/api/public/signup` requests must return one generic `202 Accepted` Pulse Account message whether provisioning/email side effects ran or were suppressed by the owner-email limiter, while invalid bodies and true server failures remain explicit. -45. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface. -46. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. -47. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. +46. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface. +47. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. +48. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. That same shared security/API boundary owns CSRF replacement-token concurrency. When parallel browser mutations arrive with stale or missing CSRF tokens for the same session, `internal/api/csrf_store.go` may retain @@ -763,7 +764,7 @@ payload shape change when the portal presents compact client rows. replacement can validate its retry. Logout, password-change, and explicit session revocation must still delete the full session token set rather than leaving any retained replacement token valid. -48. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. +49. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. Token owner identity is reserved for the server-authenticated principal: shared token-minting helpers must derive `owner_user_id` from the current session or caller token and reject extension metadata that tries to @@ -779,15 +780,15 @@ payload shape change when the portal presents compact client rows. before minting a `relay:mobile:access` credential. Community installs may receive the standard license-required response, but direct API calls must not bypass Relay entitlement by creating mobile runtime tokens. -49. `internal/api/setup_script_render.go` shared with `agent-lifecycle`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). -50. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. -51. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. -52. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. +50. `internal/api/setup_script_render.go` shared with `agent-lifecycle`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). +51. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. +52. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. +53. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. Development-mode missing-binary responses must report the build command for the requested normalized OS/architecture, not a hard-coded Linux target, so installer preflight failures point operators at the artifact they actually need. -53. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary. +54. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary. Update-plan responses own the structured readiness verdict for server updater capability, rollback support, agent continuity, v5 agent migration transport security, and agent reporting token scope. That verdict is part @@ -4959,6 +4960,14 @@ trial-rate-limit acquisition payloads from an ordinary self-hosted runtime; expose a start-trial client method or in-app CTA in the same slice as any handler change. Commercial migration state must travel through `commercial_migration`, not through trial-denial reason strings. +That migration transport must cover every degraded path, not just exchange +rejections: when a persisted v5 license exists but cannot be read or +decrypted, the runtime must publish a terminal `commercial_migration` state +(`persisted_license_unreadable`) instead of degrading to Community behind a +log line. Startup legacy-exchange failures classified as pending must +self-retry in the background with backoff for the life of the process — +a transient license-server or DNS failure at first boot must not require a +manual restart or panel retry to complete a paid migration. That same shared commercial API boundary also owns hosted self-serve failure transport semantics. Hosted trial request and verification failures may render owned HTML pages, but they must preserve the originating Pulse instance and diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index de639c3e6..ebe4f0ca4 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -165,8 +165,17 @@ Stripe-free and avoids a cloud-control-plane report data path across clients. preserve the raw runtime build and expose a normalized `pro`, `community`, or `unknown` status so paid-runtime support triage does not depend on interpreting Docker tags, public release names, or customer screenshots. -5. `internal/api/payments_webhook_handlers.go` shared with `api-contracts`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. -6. `internal/api/public_signup_handlers.go` shared with `api-contracts`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. + That same shared licensing boundary also owns paid-migration degradation + visibility and recovery. A persisted v5 license that exists but cannot be + read or decrypted must publish a terminal `commercial_migration` state + (`persisted_license_unreadable`) instead of downgrading to Community + behind a log line, and startup legacy-exchange failures classified as + pending must self-retry in the background with backoff for the life of + the process so a transient license-server or DNS failure at first boot + never strands a paying upgrader on Community until a manual restart. +5. `internal/api/licensing_legacy_retry.go` shared with `api-contracts`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership. +6. `internal/api/payments_webhook_handlers.go` shared with `api-contracts`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership. +7. `internal/api/public_signup_handlers.go` shared with `api-contracts`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership. That shared monitored-system presentation boundary also owns disabled provider-connection copy. Commercial entitlement surfaces must treat canonical zero-delta and removal-only TrueNAS or VMware previews as non-consuming or @@ -182,10 +191,10 @@ Stripe-free and avoids a cloud-control-plane report data path across clients. syntactically valid `/api/public/signup` requests resolve to one uniform `202 Accepted` Pulse Account response whether provisioning/email side effects ran or were suppressed by owner-email throttling. -6. `internal/cloudcp/auth/magiclink.go` shared with `security-privacy`: control-plane magic-link HMAC handling is both a Pulse Cloud account-access boundary and a security/privacy token-secrecy boundary. -7. `internal/cloudcp/auth/magiclink_store.go` shared with `security-privacy`: control-plane magic-link persistence is both a Pulse Cloud account-access boundary and a security/privacy storage-hardening boundary. -8. `internal/cloudcp/docker/labels.go` shared with `deployment-installability`: hosted tenant Docker labels are both a Pulse Cloud runtime contract boundary and a deployment-installability rollout boundary. -9. `internal/cloudcp/docker/manager.go` shared with `deployment-installability`: hosted tenant container management is both a Pulse Cloud runtime contract boundary and a deployment-installability rollout boundary. +7. `internal/cloudcp/auth/magiclink.go` shared with `security-privacy`: control-plane magic-link HMAC handling is both a Pulse Cloud account-access boundary and a security/privacy token-secrecy boundary. +8. `internal/cloudcp/auth/magiclink_store.go` shared with `security-privacy`: control-plane magic-link persistence is both a Pulse Cloud account-access boundary and a security/privacy storage-hardening boundary. +9. `internal/cloudcp/docker/labels.go` shared with `deployment-installability`: hosted tenant Docker labels are both a Pulse Cloud runtime contract boundary and a deployment-installability rollout boundary. +10. `internal/cloudcp/docker/manager.go` shared with `deployment-installability`: hosted tenant container management is both a Pulse Cloud runtime contract boundary and a deployment-installability rollout boundary. Hosted tenant container creation must also bound Docker `json-file` logs through the control-plane Docker manager so tenant runtime logging cannot fill the live Pulse Cloud host independently of tenant data quotas. @@ -258,18 +267,18 @@ Stripe-free and avoids a cloud-control-plane report data path across clients. networks, tenant-local report branding, and provider MSP operator commands with `provider_hosted_msp` while preserving its own control-plane mode value for status, backup manifests, and operational audit. -10. `internal/cloudcp/provider_msp_backup.go` shared with `deployment-installability`: provider-hosted MSP backup is both a cloud-paid license/account/runtime continuity boundary and a deployment-installability recovery artifact boundary. +11. `internal/cloudcp/provider_msp_backup.go` shared with `deployment-installability`: provider-hosted MSP backup is both a cloud-paid license/account/runtime continuity boundary and a deployment-installability recovery artifact boundary. License-backed provider MSP backups must include the signed MSP license file as a recovery artifact while exposing only license metadata in command output, restore must recover that license as an explicit operator artifact, and the archive must stay Stripe-free so provider-hosted MSP recovery does not inherit Pulse-hosted SaaS billing assumptions. -11. `internal/cloudcp/provider_msp_recovery.go` shared with `deployment-installability`: provider-hosted MSP failed-workspace recovery is both a cloud-paid license/account/runtime continuity boundary and a deployment-installability recovery artifact boundary. +12. `internal/cloudcp/provider_msp_recovery.go` shared with `deployment-installability`: provider-hosted MSP failed-workspace recovery is both a cloud-paid license/account/runtime continuity boundary and a deployment-installability recovery artifact boundary. Provider-hosted MSP recovery must require the signed provider MSP license source by default, preserve the client workspace boundary, refuse to start from empty tenant data, and mark a workspace active only after the canonical tenant-runtime rollout path has produced a healthy runtime. -12. `internal/cloudcp/tenant_runtime_rollout.go` shared with `deployment-installability`: hosted tenant runtime rollout is both a Pulse Cloud runtime contract boundary and a deployment-installability release-rollout boundary. +13. `internal/cloudcp/tenant_runtime_rollout.go` shared with `deployment-installability`: hosted tenant runtime rollout is both a Pulse Cloud runtime contract boundary and a deployment-installability release-rollout boundary. Hosted tenant runtime reconciliation must treat a registered tenant with preserved tenant data but no live Docker runtime as a recoverable managed state, not as a terminal skip. The control-plane-owned reconcile path must diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index c833e6c94..8f9d19d94 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -489,6 +489,14 @@ "cloud-paid" ] }, + { + "path": "internal/api/licensing_legacy_retry.go", + "rationale": "the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership", + "subsystems": [ + "api-contracts", + "cloud-paid" + ] + }, { "path": "internal/api/notifications.go", "rationale": "notification handlers are both a notification delivery control surface and a canonical API payload contract boundary", @@ -2341,6 +2349,7 @@ "match_prefixes": [], "match_files": [ "pkg/licensing/commercial_migration.go", + "pkg/licensing/commercial_migration_load.go", "pkg/licensing/http.go", "pkg/licensing/quickstart_credits.go", "pkg/licensing/trial_start.go", @@ -2350,6 +2359,7 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "pkg/licensing/commercial_migration_load_test.go", "pkg/licensing/commercial_migration_test.go", "pkg/licensing/http_test.go", "pkg/licensing/quickstart_credits_test.go", diff --git a/internal/api/licensing_bridge.go b/internal/api/licensing_bridge.go index ff41b342b..0191fb3b3 100644 --- a/internal/api/licensing_bridge.go +++ b/internal/api/licensing_bridge.go @@ -104,6 +104,12 @@ func classifyLegacyExchangeErrorFromLicensing(err error) *commercialMigrationSta return pkglicensing.ClassifyLegacyExchangeError(err) } +func classifyPersistedLicenseLoadErrorFromLicensing(err error) *commercialMigrationStatusModel { + return pkglicensing.ClassifyPersistedLicenseLoadError(err) +} + +const commercialMigrationStatePendingValue = pkglicensing.CommercialMigrationStatePending + func isValidBillingSubscriptionStateFromLicensing(state subscriptionState) bool { return pkglicensing.IsValidBillingSubscriptionState(state) } diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index fc897a068..c30bd648f 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -64,6 +64,12 @@ type LicenseHandlers struct { hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop runtimeVersion string runtimeIdentity runtimeIdentityModel + + // legacyExchangeRetries tracks orgs with a background v5→v6 exchange + // retry loop in flight so only one loop runs per org. + legacyExchangeRetries sync.Map // map[string]struct{} + // legacyExchangeRetrySchedule overrides the retry backoff in tests. + legacyExchangeRetrySchedule []time.Duration } // NewLicenseHandlers creates a new license handlers instance. @@ -136,6 +142,12 @@ func (h *LicenseHandlers) StopAllBackgroundLoops() { } return true }) + h.legacyExchangeRetries.Range(func(key, _ any) bool { + if orgID, ok := key.(string); ok { + h.stopLegacyExchangeRetry(orgID) + } + return true + }) h.services.Range(func(_, value any) bool { if svc, ok := value.(*licenseService); ok { svc.StopGrantRefresh() @@ -576,13 +588,23 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ if loadErr != nil { if !os.IsNotExist(loadErr) { log.Warn().Str("org_id", orgID).Err(loadErr).Msg("Failed to load persisted legacy license") + // A license.enc that exists but cannot be read means a paid v5 + // install is about to present as Community — surface it as a + // migration notice instead of degrading silently. + if persistErr := h.setCommercialMigrationState(orgID, classifyPersistedLicenseLoadErrorFromLicensing(loadErr)); persistErr != nil { + log.Warn().Str("org_id", orgID).Err(persistErr).Msg("Failed to persist commercial migration state for unreadable legacy license") + } } } else if strings.TrimSpace(legacyJWT) != "" { if _, err := service.Activate(legacyJWT); err != nil { - if persistErr := h.setCommercialMigrationState(orgID, classifyLegacyExchangeErrorFromLicensing(err)); persistErr != nil { + migrationStatus := classifyLegacyExchangeErrorFromLicensing(err) + if persistErr := h.setCommercialMigrationState(orgID, migrationStatus); persistErr != nil { log.Warn().Str("org_id", orgID).Err(persistErr).Msg("Failed to persist commercial migration state") } log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to auto-exchange persisted legacy license") + if migrationStatus != nil && migrationStatus.State == commercialMigrationStatePendingValue { + h.scheduleLegacyExchangeRetry(orgID, legacyJWT) + } } else if service.IsActivated() { if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { log.Warn().Str("org_id", orgID).Err(clearErr).Msg("Failed to clear commercial migration state after successful auto-exchange") @@ -947,6 +969,7 @@ func (h *LicenseHandlers) activateLicenseKey(ctx context.Context, licenseKey str if service.IsActivated() { h.stopHostedEntitlementRefreshLoop(orgID) + h.stopLegacyExchangeRetry(orgID) if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { log.Warn().Err(clearErr).Str("org_id", orgID).Msg("Failed to clear commercial migration state after activation") } diff --git a/internal/api/licensing_legacy_retry.go b/internal/api/licensing_legacy_retry.go new file mode 100644 index 000000000..2a6f1021c --- /dev/null +++ b/internal/api/licensing_legacy_retry.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "strings" + "time" + + "github.com/rs/zerolog/log" +) + +// defaultLegacyExchangeRetrySchedule backs off quickly at first (a DNS blip +// at boot usually clears in seconds) and settles at the final interval for +// the lifetime of the process. +var defaultLegacyExchangeRetrySchedule = []time.Duration{ + 30 * time.Second, + time.Minute, + 2 * time.Minute, + 5 * time.Minute, + 10 * time.Minute, + 30 * time.Minute, +} + +// scheduleLegacyExchangeRetry retries a failed startup v5→v6 license exchange +// in the background. The startup exchange runs once per process; without a +// retry loop, a transient license-server failure at first boot left a paying +// v5 customer on Community until they manually restarted or retried from the +// license panel. Only retryable (pending) failures schedule a loop; terminal +// classifications keep their persisted migration state for the UI. +func (h *LicenseHandlers) scheduleLegacyExchangeRetry(orgID, legacyJWT string) { + if h == nil || strings.TrimSpace(legacyJWT) == "" { + return + } + stop := make(chan struct{}) + if _, alreadyRunning := h.legacyExchangeRetries.LoadOrStore(orgID, stop); alreadyRunning { + return + } + + schedule := h.legacyExchangeRetrySchedule + if len(schedule) == 0 { + schedule = defaultLegacyExchangeRetrySchedule + } + + go func() { + defer h.legacyExchangeRetries.CompareAndDelete(orgID, stop) + + for attempt := 0; ; attempt++ { + timer := time.NewTimer(schedule[min(attempt, len(schedule)-1)]) + select { + case <-stop: + timer.Stop() + return + case <-timer.C: + } + + v, ok := h.services.Load(orgID) + if !ok { + // Tenant evicted; the next getTenantComponents call re-runs + // the startup exchange itself. + return + } + service := v.(*licenseService) + if service.IsActivated() || service.Current() != nil { + // Resolved through another path (manual activation). + return + } + + if _, err := service.Activate(legacyJWT); err != nil { + migrationStatus := classifyLegacyExchangeErrorFromLicensing(err) + if persistErr := h.setCommercialMigrationState(orgID, migrationStatus); persistErr != nil { + log.Warn().Str("org_id", orgID).Err(persistErr).Msg("Failed to persist commercial migration state during background legacy exchange retry") + } + if migrationStatus == nil || migrationStatus.State != commercialMigrationStatePendingValue { + log.Warn().Str("org_id", orgID).Err(err).Msg("Legacy license migration failed terminally; stopping background retries") + return + } + log.Info().Str("org_id", orgID).Int("attempt", attempt+1).Err(err).Msg("Legacy license migration still pending; will retry in background") + continue + } + + if !service.IsActivated() { + // Exchange "succeeded" without producing an activation; a + // retry cannot improve on that, so leave state for the UI. + return + } + + if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { + log.Warn().Str("org_id", orgID).Err(clearErr).Msg("Failed to clear commercial migration state after background legacy exchange") + } + service.StartGrantRefresh(context.Background()) + if feedToken := revocationFeedToken(); feedToken != "" { + service.StartRevocationPoll(context.Background(), feedToken) + } + h.syncReleaseDemoFixtureRuntime(orgID, service) + if current := service.Current(); current != nil { + log.Info(). + Str("org_id", orgID). + Str("license_id", current.Claims.LicenseID). + Int("attempts", attempt+1). + Msg("Background retry completed v5 legacy license migration") + } + return + } + }() +} + +// stopLegacyExchangeRetry cancels a pending background exchange retry loop. +func (h *LicenseHandlers) stopLegacyExchangeRetry(orgID string) { + if h == nil { + return + } + if v, ok := h.legacyExchangeRetries.LoadAndDelete(orgID); ok { + if stop, ok := v.(chan struct{}); ok { + close(stop) + } + } +} diff --git a/internal/api/licensing_legacy_retry_test.go b/internal/api/licensing_legacy_retry_test.go new file mode 100644 index 000000000..53ebf33ce --- /dev/null +++ b/internal/api/licensing_legacy_retry_test.go @@ -0,0 +1,288 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + licensetestsupport "github.com/rcourtman/pulse-go-rewrite/pkg/licensing/testsupport" +) + +// TestGetTenantComponents_RetriesFailedExchangeInBackground covers the v5→v6 +// upgrade path where the license server is unreachable at first boot: the +// startup exchange fails, and the background retry must complete the +// migration without a manual restart. +func TestGetTenantComponents_RetriesFailedExchangeInBackground(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + + grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ + LicenseID: "lic_retry", + Tier: "pro", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "retry@example.com", + }) + if err != nil { + t.Fatalf("generate grant jwt: %v", err) + } + pkglicensing.SetPublicKey(grantPublicKey) + t.Cleanup(func() { pkglicensing.SetPublicKey(nil) }) + + // Fail the first two exchange attempts with a retryable error, then + // succeed: boot fails, retry #1 fails, retry #2 migrates. + var exchangeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/licenses/exchange" { + t.Errorf("path = %q, want /v1/licenses/exchange", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if exchangeCalls.Add(1) <= 2 { + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "service_unavailable", + "message": "exchange unavailable", + "retryable": true, + }) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{ + License: pkglicensing.ActivateResponseLicense{ + LicenseID: "lic_retry", + State: "active", + Tier: "pro", + Features: []string{"relay"}, + }, + Installation: pkglicensing.ActivateResponseInstallation{ + InstallationID: "inst_retry", + InstallationToken: "pit_live_retry", + Status: "active", + }, + Grant: pkglicensing.GrantEnvelope{ + JWT: grantJWT, + JTI: "grant_retry", + ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), + }, + }) + })) + defer server.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) + + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + cp, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("init default persistence: %v", err) + } + + legacyJWT, err := licensetestsupport.GenerateLicenseForTesting("retry@example.com", pkglicensing.TierPro, 365*24*time.Hour) + if err != nil { + t.Fatalf("generate test license: %v", err) + } + persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir()) + if err != nil { + t.Fatalf("new persistence: %v", err) + } + if err := persistence.Save(legacyJWT); err != nil { + t.Fatalf("save legacy JWT: %v", err) + } + + handlers := NewLicenseHandlers(mtp, false) + handlers.legacyExchangeRetrySchedule = []time.Duration{10 * time.Millisecond} + t.Cleanup(handlers.StopAllBackgroundLoops) + + ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") + svc := handlers.Service(ctx) + if svc == nil { + t.Fatal("expected non-nil service") + } + if svc.IsActivated() { + t.Fatal("expected startup exchange to fail before background retry") + } + + deadline := time.Now().Add(5 * time.Second) + for !svc.IsActivated() { + if time.Now().After(deadline) { + t.Fatalf("background retry never completed migration; exchange calls = %d", exchangeCalls.Load()) + } + time.Sleep(20 * time.Millisecond) + } + + if calls := exchangeCalls.Load(); calls != 3 { + t.Errorf("exchange calls = %d, want 3 (boot + 2 retries)", calls) + } + if current := svc.Current(); current == nil || current.Claims.LicenseID != "lic_retry" { + t.Fatalf("expected migrated license to be active, got %#v", current) + } + + // Migration state must clear once the background retry succeeds. + store := config.NewFileBillingStore(baseDir) + waitForClearedMigration := time.Now().Add(2 * time.Second) + for { + state, err := store.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState: %v", err) + } + if state == nil || state.CommercialMigration == nil { + break + } + if time.Now().After(waitForClearedMigration) { + t.Fatalf("commercial migration state never cleared: %+v", state.CommercialMigration) + } + time.Sleep(20 * time.Millisecond) + } +} + +// TestGetTenantComponents_StopsRetryingOnTerminalExchangeFailure pins that a +// terminal classification (e.g. 401 invalid key) halts the retry loop instead +// of hammering the license server forever. +func TestGetTenantComponents_StopsRetryingOnTerminalExchangeFailure(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + + var exchangeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls := exchangeCalls.Add(1) + if calls == 1 { + // Retryable boot failure schedules the loop. + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "service_unavailable", + "message": "exchange unavailable", + "retryable": true, + }) + return + } + // Terminal failure on retry. + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "invalid_license", + "message": "license key rejected", + }) + })) + defer server.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL) + + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + cp, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("init default persistence: %v", err) + } + + legacyJWT, err := licensetestsupport.GenerateLicenseForTesting("terminal@example.com", pkglicensing.TierPro, 365*24*time.Hour) + if err != nil { + t.Fatalf("generate test license: %v", err) + } + persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir()) + if err != nil { + t.Fatalf("new persistence: %v", err) + } + if err := persistence.Save(legacyJWT); err != nil { + t.Fatalf("save legacy JWT: %v", err) + } + + handlers := NewLicenseHandlers(mtp, false) + handlers.legacyExchangeRetrySchedule = []time.Duration{10 * time.Millisecond} + t.Cleanup(handlers.StopAllBackgroundLoops) + + ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") + if svc := handlers.Service(ctx); svc == nil { + t.Fatal("expected non-nil service") + } + + // Wait for the loop to hit the terminal failure and exit. + deadline := time.Now().Add(5 * time.Second) + for { + if _, running := handlers.legacyExchangeRetries.Load("default"); !running { + break + } + if time.Now().After(deadline) { + t.Fatal("retry loop still running after terminal failure") + } + time.Sleep(20 * time.Millisecond) + } + + if calls := exchangeCalls.Load(); calls != 2 { + t.Errorf("exchange calls = %d, want 2 (boot + 1 terminal retry)", calls) + } + + store := config.NewFileBillingStore(baseDir) + state, err := store.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState: %v", err) + } + if state == nil || state.CommercialMigration == nil { + t.Fatal("expected terminal commercial migration state to persist") + } + if state.CommercialMigration.State != pkglicensing.CommercialMigrationStateFailed { + t.Fatalf("commercial_migration.state=%q, want %q", state.CommercialMigration.State, pkglicensing.CommercialMigrationStateFailed) + } + + // No further calls should arrive after the loop stopped. + time.Sleep(100 * time.Millisecond) + if calls := exchangeCalls.Load(); calls != 2 { + t.Errorf("exchange calls after stop = %d, want 2", calls) + } +} + +// TestGetTenantComponents_SurfacesUnreadablePersistedLicense pins that a +// license.enc that exists but cannot be decrypted produces a commercial +// migration notice instead of a silent downgrade to Community. +func TestGetTenantComponents_SurfacesUnreadablePersistedLicense(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + cp, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("init default persistence: %v", err) + } + + // A license.enc sealed under key material this install cannot derive + // (simulates a v5.0.0-era file whose machine-id material is gone). + if err := os.MkdirAll(cp.GetConfigDir(), 0o700); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + garbage := []byte("bm90LWEtcmVhbC1jaXBoZXJ0ZXh0LWJ1dC1iYXNlNjQtZGVjb2RhYmxl") + if err := os.WriteFile(filepath.Join(cp.GetConfigDir(), pkglicensing.LicenseFileName), garbage, 0o600); err != nil { + t.Fatalf("write undecryptable license file: %v", err) + } + + handlers := NewLicenseHandlers(mtp, false) + t.Cleanup(handlers.StopAllBackgroundLoops) + + ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") + svc := handlers.Service(ctx) + if svc == nil { + t.Fatal("expected non-nil service") + } + if svc.IsActivated() { + t.Fatal("expected no activation from an unreadable license file") + } + + store := config.NewFileBillingStore(baseDir) + state, err := store.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState: %v", err) + } + if state == nil || state.CommercialMigration == nil { + t.Fatal("expected commercial migration state for unreadable persisted license") + } + if state.CommercialMigration.State != pkglicensing.CommercialMigrationStateFailed { + t.Fatalf("commercial_migration.state=%q, want %q", state.CommercialMigration.State, pkglicensing.CommercialMigrationStateFailed) + } + if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonPersistedUnreadable { + t.Fatalf("commercial_migration.reason=%q, want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonPersistedUnreadable) + } +} diff --git a/pkg/licensing/commercial_migration_load.go b/pkg/licensing/commercial_migration_load.go new file mode 100644 index 000000000..5fdb4208f --- /dev/null +++ b/pkg/licensing/commercial_migration_load.go @@ -0,0 +1,29 @@ +package licensing + +// Kept in a separate file from ClassifyLegacyExchangeError so the +// load-failure contract can evolve with persistence (not exchange) concerns. + +const ( + // CommercialMigrationReasonPersistedUnreadable marks a persisted v5 + // license file that exists but cannot be read or decrypted on this + // system (e.g. license.enc sealed under key material this install can + // no longer derive). + CommercialMigrationReasonPersistedUnreadable CommercialMigrationReason = "persisted_license_unreadable" +) + +// ClassifyPersistedLicenseLoadError converts a failure to read or decrypt the +// persisted v5 license into the commercial-migration contract. Without this, +// an undecryptable license.enc degraded a paid install to Community with +// nothing but a log line. Re-running the exchange cannot fix an unreadable +// file, so the state is terminal and the remedy is re-entering the v5 key. +func ClassifyPersistedLicenseLoadError(err error) *CommercialMigrationStatus { + if err == nil { + return nil + } + return &CommercialMigrationStatus{ + Source: CommercialMigrationSourceV5License, + State: CommercialMigrationStateFailed, + Reason: CommercialMigrationReasonPersistedUnreadable, + RecommendedAction: CommercialMigrationActionEnterSupportedV5, + } +} diff --git a/pkg/licensing/commercial_migration_load_test.go b/pkg/licensing/commercial_migration_load_test.go new file mode 100644 index 000000000..0a9e18a8f --- /dev/null +++ b/pkg/licensing/commercial_migration_load_test.go @@ -0,0 +1,43 @@ +package licensing + +import ( + "errors" + "testing" +) + +func TestClassifyPersistedLicenseLoadError(t *testing.T) { + t.Run("nil error yields no status", func(t *testing.T) { + if status := ClassifyPersistedLicenseLoadError(nil); status != nil { + t.Fatalf("expected nil status, got %+v", status) + } + }) + + t.Run("load failure is terminal with re-enter-key action", func(t *testing.T) { + status := ClassifyPersistedLicenseLoadError(errors.New("failed to decrypt license: cipher: message authentication failed")) + if status == nil { + t.Fatal("expected status, got nil") + } + if status.Source != CommercialMigrationSourceV5License { + t.Errorf("Source = %q, want %q", status.Source, CommercialMigrationSourceV5License) + } + if status.State != CommercialMigrationStateFailed { + t.Errorf("State = %q, want %q", status.State, CommercialMigrationStateFailed) + } + if status.Reason != CommercialMigrationReasonPersistedUnreadable { + t.Errorf("Reason = %q, want %q", status.Reason, CommercialMigrationReasonPersistedUnreadable) + } + if status.RecommendedAction != CommercialMigrationActionEnterSupportedV5 { + t.Errorf("RecommendedAction = %q, want %q", status.RecommendedAction, CommercialMigrationActionEnterSupportedV5) + } + }) + + t.Run("survives contract normalization", func(t *testing.T) { + status := NormalizeCommercialMigrationStatus(ClassifyPersistedLicenseLoadError(errors.New("unreadable"))) + if status == nil { + t.Fatal("normalization dropped the load-failure status") + } + if status.Reason != CommercialMigrationReasonPersistedUnreadable { + t.Errorf("Reason = %q, want %q", status.Reason, CommercialMigrationReasonPersistedUnreadable) + } + }) +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index bb1f09f52..9d7ddf41f 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -2914,6 +2914,7 @@ index 1111111..2222222 100644 "allow_same_subsystem_tests": False, "test_prefixes": [], "exact_files": [ + "pkg/licensing/commercial_migration_load_test.go", "pkg/licensing/commercial_migration_test.go", "pkg/licensing/http_test.go", "pkg/licensing/quickstart_credits_test.go",