fix(licensing): surface unreadable persisted v5 licenses and retry failed startup exchanges

Two silent-downgrade paths in the v5→v6 migration are now visible and
self-healing:

License load/decrypt failure (was: one log line, no UI state): when
license.enc exists but cannot be read, getTenantComponents now persists
a terminal commercial_migration state with reason
persisted_license_unreadable, so the licence panel and global banner
tell the customer to re-enter their v5 key instead of leaving them to
discover missing Pro features.

Failed startup exchange (was: once per process, Community until manual
restart): exchange failures classified as pending — license-server
blips, DNS failures, rate limits — now schedule a background retry loop
with backoff (30s → 30m cap) that re-attempts the exchange until it
succeeds, hits a terminal classification, or the org's service activates
through another path. The loop stops cleanly on manual activation and
StopAllBackgroundLoops.

Registers the new files in the subsystem registry (cloud-paid
commercial-migration verification policy + shared ownership) and pins
both behaviors in the cloud-paid and api-contracts contracts.
This commit is contained in:
rcourtman
2026-06-11 08:44:35 +01:00
parent cce1b3c783
commit f9b10316b8
10 changed files with 558 additions and 24 deletions
@@ -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
@@ -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
@@ -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",
+6
View File
@@ -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)
}
+24 -1
View File
@@ -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")
}
+116
View File
@@ -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)
}
}
}
+288
View File
@@ -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)
}
}
@@ -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,
}
}
@@ -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)
}
})
}
@@ -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",