mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Decouple monitored-system continuity capture from billing reads
This commit is contained in:
@@ -82,6 +82,10 @@ type EntitlementPayload struct {
|
||||
// CommercialMigration reports unresolved paid-license migration work entering
|
||||
// from v5-era commercial state.
|
||||
CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"`
|
||||
|
||||
// MonitoredSystemContinuity exposes migrated monitored-system continuity
|
||||
// state for billing and support-grade plan-limit presentation.
|
||||
MonitoredSystemContinuity *MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
|
||||
}
|
||||
|
||||
// CommercialPosturePayload is the canonical non-billing commercial contract
|
||||
@@ -158,6 +162,10 @@ type LimitStatus struct {
|
||||
// usage value rather than an unavailable best-effort fallback.
|
||||
CurrentAvailable *bool `json:"current_available,omitempty"`
|
||||
|
||||
// CurrentUnavailableReason explains why Current is unavailable when
|
||||
// CurrentAvailable is false.
|
||||
CurrentUnavailableReason string `json:"current_unavailable_reason,omitempty"`
|
||||
|
||||
// State describes the over-limit UX state.
|
||||
// Values: "ok", "warning", "enforced"
|
||||
State string `json:"state"`
|
||||
@@ -186,11 +194,12 @@ func (c LegacyConnectionCounts) Total() int64 {
|
||||
}
|
||||
|
||||
type EntitlementUsageSnapshot struct {
|
||||
MonitoredSystems int64
|
||||
MonitoredSystemsAvailable bool
|
||||
Nodes int64 // legacy compatibility alias for monitored systems
|
||||
Guests int64
|
||||
LegacyConnections LegacyConnectionCounts
|
||||
MonitoredSystems int64
|
||||
MonitoredSystemsAvailable bool
|
||||
MonitoredSystemsUnavailableReason string
|
||||
Nodes int64 // legacy compatibility alias for monitored systems
|
||||
Guests int64
|
||||
LegacyConnections LegacyConnectionCounts
|
||||
}
|
||||
|
||||
func (s EntitlementUsageSnapshot) monitoredSystemCount() int64 {
|
||||
@@ -207,6 +216,10 @@ func (s EntitlementUsageSnapshot) monitoredSystemCountAvailable() bool {
|
||||
return s.MonitoredSystems > 0 || s.Nodes > 0
|
||||
}
|
||||
|
||||
func (s EntitlementUsageSnapshot) monitoredSystemCountUnavailableReason() string {
|
||||
return s.MonitoredSystemsUnavailableReason
|
||||
}
|
||||
|
||||
// BuildEntitlementPayload constructs the normalized payload from LicenseStatus.
|
||||
func BuildEntitlementPayload(status *LicenseStatus, subscriptionState string) EntitlementPayload {
|
||||
return BuildEntitlementPayloadWithUsage(status, subscriptionState, EntitlementUsageSnapshot{}, nil)
|
||||
@@ -334,6 +347,10 @@ func BuildEntitlementPayloadWithUsage(
|
||||
LegacyConnections: usage.LegacyConnections,
|
||||
HasMigrationGap: false,
|
||||
}
|
||||
if status.MonitoredSystemContinuity != nil {
|
||||
continuity := *status.MonitoredSystemContinuity
|
||||
payload.MonitoredSystemContinuity = &continuity
|
||||
}
|
||||
|
||||
if payload.Capabilities == nil {
|
||||
payload.Capabilities = []string{}
|
||||
@@ -363,13 +380,17 @@ func BuildEntitlementPayloadWithUsage(
|
||||
// Build limits.
|
||||
if status.MaxMonitoredSystems > 0 {
|
||||
currentSystems := usage.monitoredSystemCount()
|
||||
payload.Limits = append(payload.Limits, LimitStatus{
|
||||
limit := LimitStatus{
|
||||
Key: MaxMonitoredSystemsLicenseGateKey,
|
||||
Limit: int64(status.MaxMonitoredSystems),
|
||||
Current: currentSystems,
|
||||
CurrentAvailable: boolPointer(usage.monitoredSystemCountAvailable()),
|
||||
State: LimitState(currentSystems, int64(status.MaxMonitoredSystems)),
|
||||
})
|
||||
}
|
||||
if !usage.monitoredSystemCountAvailable() {
|
||||
limit.CurrentUnavailableReason = usage.monitoredSystemCountUnavailableReason()
|
||||
}
|
||||
payload.Limits = append(payload.Limits, limit)
|
||||
}
|
||||
if status.MaxGuests > 0 {
|
||||
payload.Limits = append(payload.Limits, LimitStatus{
|
||||
|
||||
@@ -156,7 +156,9 @@ func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *tes
|
||||
MaxMonitoredSystems: 50,
|
||||
}
|
||||
|
||||
payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{}, nil)
|
||||
payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{
|
||||
MonitoredSystemsUnavailableReason: "supplemental_inventory_unsettled",
|
||||
}, nil)
|
||||
if len(payload.Limits) != 1 {
|
||||
t.Fatalf("expected one limit, got %d", len(payload.Limits))
|
||||
}
|
||||
@@ -166,6 +168,46 @@ func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *tes
|
||||
if payload.Limits[0].CurrentAvailable == nil || *payload.Limits[0].CurrentAvailable {
|
||||
t.Fatalf("expected unresolved current availability to be false, got %+v", payload.Limits[0].CurrentAvailable)
|
||||
}
|
||||
if payload.Limits[0].CurrentUnavailableReason != "supplemental_inventory_unsettled" {
|
||||
t.Fatalf("CurrentUnavailableReason=%q, want %q", payload.Limits[0].CurrentUnavailableReason, "supplemental_inventory_unsettled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEntitlementPayloadWithUsage_CopiesMonitoredSystemContinuity(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
Features: append([]string(nil), TierFeatures[TierPro]...),
|
||||
MaxMonitoredSystems: 23,
|
||||
MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{
|
||||
PlanLimit: 10,
|
||||
GrandfatheredFloor: 23,
|
||||
EffectiveLimit: 23,
|
||||
CapturePending: false,
|
||||
CapturedAt: 123,
|
||||
},
|
||||
}
|
||||
|
||||
payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{
|
||||
MonitoredSystems: 23,
|
||||
MonitoredSystemsAvailable: true,
|
||||
}, nil)
|
||||
|
||||
if payload.MonitoredSystemContinuity == nil {
|
||||
t.Fatal("expected monitored-system continuity to be copied")
|
||||
}
|
||||
if payload.MonitoredSystemContinuity.PlanLimit != 10 {
|
||||
t.Fatalf("PlanLimit=%d, want %d", payload.MonitoredSystemContinuity.PlanLimit, 10)
|
||||
}
|
||||
if payload.MonitoredSystemContinuity.EffectiveLimit != 23 {
|
||||
t.Fatalf("EffectiveLimit=%d, want %d", payload.MonitoredSystemContinuity.EffectiveLimit, 23)
|
||||
}
|
||||
if payload.MonitoredSystemContinuity.GrandfatheredFloor != 23 {
|
||||
t.Fatalf("GrandfatheredFloor=%d, want %d", payload.MonitoredSystemContinuity.GrandfatheredFloor, 23)
|
||||
}
|
||||
if payload.MonitoredSystemContinuity.CapturePending {
|
||||
t.Fatal("expected continuity capture to be settled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEntitlementPayload_CopiesStatusDisplayFields(t *testing.T) {
|
||||
|
||||
+23
-12
@@ -194,16 +194,27 @@ const (
|
||||
|
||||
// LicenseStatus is the JSON response for license status API.
|
||||
type LicenseStatus struct {
|
||||
Valid bool `json:"valid"`
|
||||
Tier Tier `json:"tier"`
|
||||
PlanVersion string `json:"plan_version,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
IsLifetime bool `json:"is_lifetime"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
Features []string `json:"features"`
|
||||
MaxMonitoredSystems int `json:"max_monitored_systems,omitempty"`
|
||||
MaxGuests int `json:"max_guests,omitempty"`
|
||||
InGracePeriod bool `json:"in_grace_period,omitempty"`
|
||||
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Tier Tier `json:"tier"`
|
||||
PlanVersion string `json:"plan_version,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
IsLifetime bool `json:"is_lifetime"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
Features []string `json:"features"`
|
||||
MaxMonitoredSystems int `json:"max_monitored_systems,omitempty"`
|
||||
MaxGuests int `json:"max_guests,omitempty"`
|
||||
InGracePeriod bool `json:"in_grace_period,omitempty"`
|
||||
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
|
||||
MonitoredSystemContinuity *MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
|
||||
}
|
||||
|
||||
// MonitoredSystemContinuityStatus describes the effective monitored-system
|
||||
// limit continuity applied to a migrated legacy installation.
|
||||
type MonitoredSystemContinuityStatus struct {
|
||||
PlanLimit int `json:"plan_limit"`
|
||||
GrandfatheredFloor int `json:"grandfathered_floor,omitempty"`
|
||||
EffectiveLimit int `json:"effective_limit"`
|
||||
CapturePending bool `json:"capture_pending"`
|
||||
CapturedAt int64 `json:"captured_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -230,6 +230,48 @@ func TestClaims_EffectiveLimitsPreservesNonCloudPlanLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseStatusJSON_EncodesMonitoredSystemContinuity(t *testing.T) {
|
||||
status := LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierPro,
|
||||
MaxMonitoredSystems: 23,
|
||||
MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{
|
||||
PlanLimit: 10,
|
||||
GrandfatheredFloor: 23,
|
||||
EffectiveLimit: 23,
|
||||
CapturePending: false,
|
||||
CapturedAt: 123,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal status: %v", err)
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("decode status json: %v", err)
|
||||
}
|
||||
|
||||
continuity, ok := decoded["monitored_system_continuity"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected monitored_system_continuity object, got %#v", decoded["monitored_system_continuity"])
|
||||
}
|
||||
if got := continuity["plan_limit"]; got != float64(10) {
|
||||
t.Fatalf("plan_limit=%v, want %v", got, float64(10))
|
||||
}
|
||||
if got := continuity["effective_limit"]; got != float64(23) {
|
||||
t.Fatalf("effective_limit=%v, want %v", got, float64(23))
|
||||
}
|
||||
if got := continuity["grandfathered_floor"]; got != float64(23) {
|
||||
t.Fatalf("grandfathered_floor=%v, want %v", got, float64(23))
|
||||
}
|
||||
if got := continuity["capture_pending"]; got != false {
|
||||
t.Fatalf("capture_pending=%v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaims_EffectiveLimitsMissingCloudPlanFailsClosed(t *testing.T) {
|
||||
claims := Claims{
|
||||
Tier: TierCloud,
|
||||
|
||||
@@ -469,6 +469,59 @@ func (s *Service) CaptureLegacyMonitoredSystemGrandfatherFloor(count int) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) needsLegacyMonitoredSystemCaptureLocked() bool {
|
||||
if s == nil || s.activationState == nil {
|
||||
return false
|
||||
}
|
||||
return normalizeActivationContinuity(s.activationState.Continuity).needsLegacyMonitoredSystemCapture()
|
||||
}
|
||||
|
||||
// NeedsLegacyMonitoredSystemCapture reports whether a migrated activation is
|
||||
// still waiting for its one-time monitored-system continuity capture.
|
||||
func (s *Service) NeedsLegacyMonitoredSystemCapture() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.needsLegacyMonitoredSystemCaptureLocked()
|
||||
}
|
||||
|
||||
func (s *Service) monitoredSystemContinuityStatusLocked() *MonitoredSystemContinuityStatus {
|
||||
if s == nil || s.activationState == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
continuity := normalizeActivationContinuity(s.activationState.Continuity)
|
||||
if !continuity.LegacyMigration {
|
||||
return nil
|
||||
}
|
||||
|
||||
planLimit := 0
|
||||
if gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT); err == nil && gc != nil {
|
||||
planLimit = gc.MaxMonitoredSystems
|
||||
}
|
||||
|
||||
effectiveLimit := planLimit
|
||||
if s.license != nil {
|
||||
effectiveLimit = monitoredSystemLimitFromClaims(s.license.Claims)
|
||||
}
|
||||
if effectiveLimit <= 0 {
|
||||
effectiveLimit = planLimit
|
||||
}
|
||||
|
||||
status := &MonitoredSystemContinuityStatus{
|
||||
PlanLimit: planLimit,
|
||||
EffectiveLimit: effectiveLimit,
|
||||
CapturePending: continuity.needsLegacyMonitoredSystemCapture(),
|
||||
CapturedAt: continuity.GrandfatheredMonitoredSystemsCapturedAt,
|
||||
}
|
||||
if continuity.GrandfatheredMaxMonitoredSystems > 0 {
|
||||
status.GrandfatheredFloor = continuity.GrandfatheredMaxMonitoredSystems
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// Clear removes the current license.
|
||||
// If an activation-key license is present, it also stops the refresh loop and clears the state.
|
||||
func (s *Service) Clear() {
|
||||
@@ -704,6 +757,7 @@ func (s *Service) Status() *LicenseStatus {
|
||||
if isDemoMode() || isDevMode() {
|
||||
status.Features = devModeFeatures()
|
||||
}
|
||||
status.MonitoredSystemContinuity = s.monitoredSystemContinuityStatusLocked()
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -720,6 +774,7 @@ func (s *Service) Status() *LicenseStatus {
|
||||
if maxGuests, ok := s.license.Claims.EffectiveLimits()["max_guests"]; ok {
|
||||
status.MaxGuests = safeIntFromInt64(maxGuests)
|
||||
}
|
||||
status.MonitoredSystemContinuity = s.monitoredSystemContinuityStatusLocked()
|
||||
|
||||
// Apply the tier default monitored-system limit when claims don't specify one.
|
||||
// For recognized tiers, use their defined limit (0 = unlimited for Cloud/MSP/Enterprise).
|
||||
|
||||
@@ -266,6 +266,92 @@ func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorPersistsAndUpdatesSt
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatus_ExposesMonitoredSystemContinuity(t *testing.T) {
|
||||
setupTestPublicKey(t)
|
||||
|
||||
grantJWT := makeTestGrantJWT(t, &GrantClaims{
|
||||
LicenseID: "lic_continuity_status",
|
||||
Tier: "pro",
|
||||
PlanKey: "v5_pro_monthly_grandfathered",
|
||||
State: "active",
|
||||
MaxMonitoredSystems: 10,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
continuity ActivationContinuity
|
||||
wantPlanLimit int
|
||||
wantEffectiveLimit int
|
||||
wantFloor int
|
||||
wantCapturePending bool
|
||||
wantMaxSystems int
|
||||
}{
|
||||
{
|
||||
name: "pending capture",
|
||||
continuity: ActivationContinuity{
|
||||
LegacyMigration: true,
|
||||
},
|
||||
wantPlanLimit: 10,
|
||||
wantEffectiveLimit: 10,
|
||||
wantFloor: 0,
|
||||
wantCapturePending: true,
|
||||
wantMaxSystems: 10,
|
||||
},
|
||||
{
|
||||
name: "captured floor",
|
||||
continuity: ActivationContinuity{
|
||||
LegacyMigration: true,
|
||||
GrandfatheredMaxMonitoredSystems: 23,
|
||||
GrandfatheredMonitoredSystemsCapturedAt: 123,
|
||||
},
|
||||
wantPlanLimit: 10,
|
||||
wantEffectiveLimit: 23,
|
||||
wantFloor: 23,
|
||||
wantCapturePending: false,
|
||||
wantMaxSystems: 23,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := NewService()
|
||||
if err := svc.RestoreActivation(&ActivationState{
|
||||
InstallationID: "inst_continuity_status",
|
||||
InstallationToken: "pit_live_continuity_status",
|
||||
LicenseID: "lic_continuity_status",
|
||||
GrantJWT: grantJWT,
|
||||
GrantJTI: "grant_continuity_status",
|
||||
InstanceFingerprint: "fp-continuity-status",
|
||||
Continuity: tt.continuity,
|
||||
}); err != nil {
|
||||
t.Fatalf("RestoreActivation: %v", err)
|
||||
}
|
||||
|
||||
status := svc.Status()
|
||||
if status.MaxMonitoredSystems != tt.wantMaxSystems {
|
||||
t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, tt.wantMaxSystems)
|
||||
}
|
||||
if status.MonitoredSystemContinuity == nil {
|
||||
t.Fatal("expected monitored-system continuity in status")
|
||||
}
|
||||
if status.MonitoredSystemContinuity.PlanLimit != tt.wantPlanLimit {
|
||||
t.Fatalf("PlanLimit=%d, want %d", status.MonitoredSystemContinuity.PlanLimit, tt.wantPlanLimit)
|
||||
}
|
||||
if status.MonitoredSystemContinuity.EffectiveLimit != tt.wantEffectiveLimit {
|
||||
t.Fatalf("EffectiveLimit=%d, want %d", status.MonitoredSystemContinuity.EffectiveLimit, tt.wantEffectiveLimit)
|
||||
}
|
||||
if status.MonitoredSystemContinuity.GrandfatheredFloor != tt.wantFloor {
|
||||
t.Fatalf("GrandfatheredFloor=%d, want %d", status.MonitoredSystemContinuity.GrandfatheredFloor, tt.wantFloor)
|
||||
}
|
||||
if status.MonitoredSystemContinuity.CapturePending != tt.wantCapturePending {
|
||||
t.Fatalf("CapturePending=%v, want %v", status.MonitoredSystemContinuity.CapturePending, tt.wantCapturePending)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceActivate_RejectsMalformedLegacyKeyOutsideDevMode(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user