mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user