Decouple monitored-system continuity capture from billing reads

This commit is contained in:
rcourtman 2026-04-08 18:32:43 +01:00
parent 87bec03f58
commit 81f64edace
27 changed files with 1035 additions and 155 deletions

View file

@ -205,7 +205,10 @@ management, and fleet control surfaces.
grandfathering: lifecycle surfaces may react to the resulting license or
entitlements payloads, but they must not cache their own pre-activation
host counts, synthesize a second grandfather floor, or treat install-time
fleet inventory as the authority for commercial continuity.
fleet inventory as the authority for commercial continuity. They also must
not depend on a status or entitlements read to seal pending grandfather
continuity, or reinterpret continuity-verification payloads as a real
`0 / limit` monitored-system state.
## Forbidden Paths

View file

@ -1204,6 +1204,14 @@ first non-nil read-state. If provider-owned supplemental inventories are still
between initial wiring and the first canonical store rebuild, the API must
keep the grandfather floor uncaptured and expose usage as unavailable rather
than sealing continuity against a partial startup graph.
That continuity capture is owned by the shared licensing reconciler rather
than ordinary read handlers. `/api/license/status` and
`/api/license/entitlements` may expose `monitored_system_continuity`
(`plan_limit`, `effective_limit`, optional `grandfathered_floor`,
`capture_pending`, `captured_at`) and limit-level
`current_unavailable_reason`, but those request paths must not seal the
grandfather floor synchronously just because a billing read happened to arrive
after the canonical usage view became available.
That same configured-path contract now also has an explicit shared owner for
manual auth env files: `internal/api/auth_env_path.go` must remain the only
place that derives `.env` from configured runtime paths, and neighboring

View file

@ -792,6 +792,12 @@ supplemental inventories such as TrueNAS or VMware are still between initial
provider wiring and the first canonical store rebuild after their baseline
poll, cloud-paid continuity must keep usage unavailable and delay floor
capture rather than sealing a lower count forever.
That continuity capture is reconciler-owned rather than read-owned. Ordinary
status or entitlement reads may expose pending continuity state, but they must
not persist the grandfather floor directly from the request path once a
migrated installation is running. The owning licensing reconciler may backfill
the floor asynchronously after canonical monitored-system usage becomes
settled.
That continuity rule cannot depend on webhook metadata being perfect. The
canonical Stripe price-to-plan lookup in `pkg/licensing/features.go` and
`pkg/licensing/stripe_subscription.go` must recognize the still-renewing
@ -811,6 +817,12 @@ the monitored-system model as well. `ProLicensePanel.tsx`,
retail capacity as monitored systems rather than agents for Community, Relay,
Pro, and Pro+, while leaving Cloud/MSP pricing semantics unchanged and
preserving grandfathered v5 continuity copy as an explicit boundary policy.
That same settings-owned presentation must make monitored-system continuity
explicit when migrated estates are involved: the plan surface must render the
base plan limit, the effective monitored-system limit, any grandfathered
floor, and whether continuity capture is still pending. When monitored-system
usage is unavailable during continuity verification, the shell must present a
verification state rather than implying `0 / limit`.
That same pricing boundary now also owns the shared frontend plan-definition
models. `frontend-modern/src/utils/cloudPlans.ts` and
`frontend-modern/src/utils/selfHostedPlans.ts` are the canonical frontend

View file

@ -39,15 +39,16 @@ truth for live infrastructure data.
15. `docker-entrypoint.sh`
16. `internal/monitoring/truenas_poller.go`
17. `internal/monitoring/vmware_poller.go`
18. `internal/dockeragent/swarm.go`
19. `internal/monitoring/guest_memory_sources.go`
20. `internal/monitoring/guest_memory_stability.go`
21. `internal/monitoring/monitor_polling_vm.go`
22. `internal/monitoring/monitor_pve_guest_builders.go`
23. `internal/monitoring/monitor_pve_guest_poll.go`
24. `internal/monitoring/guest_disk_stability.go`
25. `internal/monitoring/mock_metrics_history.go`
26. `internal/monitoring/mock_chart_history.go`
18. `internal/monitoring/monitored_system_usage.go`
19. `internal/dockeragent/swarm.go`
20. `internal/monitoring/guest_memory_sources.go`
21. `internal/monitoring/guest_memory_stability.go`
22. `internal/monitoring/monitor_polling_vm.go`
23. `internal/monitoring/monitor_pve_guest_builders.go`
24. `internal/monitoring/monitor_pve_guest_poll.go`
25. `internal/monitoring/guest_disk_stability.go`
26. `internal/monitoring/mock_metrics_history.go`
27. `internal/monitoring/mock_chart_history.go`
## Shared Boundaries
@ -101,6 +102,13 @@ settling: monitoring must fail closed until every active connection in that
provider has reached an initial baseline and the canonical monitor store has
rebuilt at or after that provider watermark, otherwise billing and upgrade
continuity can freeze against a transient startup undercount.
That same monitoring boundary also owns the machine-readable unavailable-state
contract for monitored-system usage. `internal/monitoring/monitored_system_usage.go`
must emit canonical reason codes such as
`monitor_state_unavailable`, `supplemental_inventory_unsettled`, and
`supplemental_inventory_rebuild_pending` when usage cannot yet be resolved, so
commercial surfaces can show verification or recovery state without inventing
their own readiness heuristics or falling back to a fake `0 / limit`.
VMware vSphere now also has a locked phase-1 ingestion boundary under this
lane. The admitted direction is vCenter-only in phase 1, and monitoring must
stay API-first through the

View file

@ -175,7 +175,9 @@ querying, and the operator-facing storage health presentation layer.
but they must not infer a second capacity floor from protected inventory,
backup counts, or recovery-point presence when commercial continuity is
already defined by the canonical monitored-system resolver and activation
persistence.
persistence. They also must not rely on billing-status reads to finalize a
pending grandfather floor or collapse continuity-verification payloads
into a real `0 / limit` monitored-system reading.
## Forbidden Paths

View file

@ -49,6 +49,42 @@ describe('LicenseAPI', () => {
});
});
it('preserves monitored-system continuity fields from the entitlement payload', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
tier: 'pro',
subscription_state: 'active',
capabilities: ['relay'],
limits: [
{
key: 'max_monitored_systems',
limit: 10,
current: 0,
current_available: false,
current_unavailable_reason: 'supplemental_inventory_unsettled',
state: 'ok',
},
],
upgrade_reasons: [],
monitored_system_continuity: {
plan_limit: 10,
effective_limit: 10,
capture_pending: true,
},
});
const result = await LicenseAPI.getCommercialEntitlements();
expect(result.monitored_system_continuity).toMatchObject({
plan_limit: 10,
effective_limit: 10,
capture_pending: true,
});
expect(result.limits[0]).toMatchObject({
current_available: false,
current_unavailable_reason: 'supplemental_inventory_unsettled',
});
});
it('reads commercial posture from the public-safe commercial endpoint', async () => {
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
tier: 'pro',

View file

@ -13,6 +13,7 @@ export interface LicenseStatus {
max_guests?: number;
in_grace_period?: boolean;
grace_period_end?: string | null;
monitored_system_continuity?: MonitoredSystemContinuityStatus;
}
export interface EntitlementLimitStatus {
@ -20,6 +21,8 @@ export interface EntitlementLimitStatus {
// 0 means unlimited
limit: number;
current: number;
current_available?: boolean;
current_unavailable_reason?: string;
// "ok" | "warning" | "enforced" (string for forward-compat)
state: string;
}
@ -43,6 +46,14 @@ export interface CommercialMigrationStatus {
recommended_action?: string;
}
export interface MonitoredSystemContinuityStatus {
plan_limit: number;
grandfathered_floor?: number;
effective_limit: number;
capture_pending: boolean;
captured_at?: number;
}
// Mirrors internal/api/subscription_entitlements.go:RuntimeCapabilitiesPayload
export interface LicenseRuntimeCapabilities {
capabilities: string[];
@ -80,6 +91,7 @@ export interface LicenseCommercialEntitlements extends LicenseCommercialPosture
in_grace_period?: boolean;
grace_period_end?: string;
max_history_days?: number;
monitored_system_continuity?: MonitoredSystemContinuityStatus;
}
export type LicenseEntitlements = LicenseCommercialEntitlements;

View file

@ -67,6 +67,7 @@ export const ProLicensePanel: Component = () => {
hasLicenseDetails={state.hasLicenseDetails()}
hasPaidFeatures={state.hasPaidFeatures()}
loading={state.loading()}
monitoredSystemContinuityNotice={state.monitoredSystemContinuityNotice()}
onReload={() => void state.loadPanelData()}
purchaseActivationNotice={state.purchaseActivationNotice()}
showMonitoredSystemUpgradeArrival={state.showMonitoredSystemUpgradeArrival()}

View file

@ -34,6 +34,7 @@ interface ProLicensePlanSectionProps {
hasLicenseDetails: boolean;
hasPaidFeatures: boolean;
loading: boolean;
monitoredSystemContinuityNotice: Notice | null;
onReload: () => void;
onStartTrial: () => void;
purchaseActivationNotice: Notice | null;
@ -132,6 +133,14 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
</div>
)}
</Show>
<Show when={props.monitoredSystemContinuityNotice}>
{(notice) => (
<div class={`mb-4 rounded-md border p-3 text-sm ${notice().tone}`}>
<p class="font-medium">{notice().title}</p>
<p class="mt-1 text-xs opacity-90">{notice().body}</p>
</div>
)}
</Show>
<Show when={props.trialEnded && !licenseEntitlementsLoadError() && trialEndedNotice}>
<div class={`mb-4 rounded-md border p-3 text-sm ${trialEndedNotice?.tone ?? ''}`}>
<p class="font-medium">{trialEndedNotice?.title}</p>

View file

@ -272,6 +272,93 @@ describe('ProLicensePanel', () => {
}
});
it('shows continuity verification while the migrated monitored-system floor is still pending', async () => {
mockEntitlements = {
capabilities: ['relay'],
limits: [
{
key: 'max_monitored_systems',
limit: 10,
current: 0,
current_available: false,
current_unavailable_reason: 'supplemental_inventory_unsettled',
state: 'ok',
},
],
subscription_state: 'active',
upgrade_reasons: [],
tier: 'pro',
plan_version: 'v5_pro_monthly_grandfathered',
licensed_email: 'owner@example.com',
trial_eligible: false,
monitored_system_continuity: {
plan_limit: 10,
effective_limit: 10,
capture_pending: true,
},
};
renderPanel();
await waitFor(() => {
expect(screen.getByText('Migration continuity verification pending')).toBeInTheDocument();
});
expect(
screen.getByText(/still collecting the first supplemental inventory baseline/i),
).toBeInTheDocument();
expect(screen.getByText('Verifying…')).toBeInTheDocument();
expect(screen.getByText('Unavailable')).toBeInTheDocument();
expect(screen.getByText('Plan Monitored System Limit')).toBeInTheDocument();
expect(screen.getByText('Effective Monitored System Limit')).toBeInTheDocument();
expect(screen.getByText('Continuity Capture')).toBeInTheDocument();
expect(screen.getByText('Pending')).toBeInTheDocument();
expect(screen.queryByText('0 / 10')).not.toBeInTheDocument();
});
it('shows grandfathered monitored-system continuity once the migrated floor is captured', async () => {
mockEntitlements = {
capabilities: ['relay'],
limits: [
{
key: 'max_monitored_systems',
limit: 23,
current: 23,
current_available: true,
state: 'enforced',
},
],
subscription_state: 'active',
upgrade_reasons: [],
tier: 'pro',
plan_version: 'v5_pro_monthly_grandfathered',
licensed_email: 'owner@example.com',
trial_eligible: false,
monitored_system_continuity: {
plan_limit: 10,
grandfathered_floor: 23,
effective_limit: 23,
capture_pending: false,
captured_at: 1_768_000_000,
},
};
renderPanel();
await waitFor(() => {
expect(screen.getByText('Grandfathered monitored-system floor')).toBeInTheDocument();
});
expect(
screen.getByText(/keeps an effective monitored-system limit of 23/i),
).toBeInTheDocument();
expect(screen.getByText('23 / 23')).toBeInTheDocument();
expect(screen.getByText('Plan Monitored System Limit')).toBeInTheDocument();
expect(screen.getByText('Effective Monitored System Limit')).toBeInTheDocument();
expect(screen.getByText('Grandfathered Floor')).toBeInTheDocument();
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
});
it('renders all capability strings as human-readable labels (no raw snake_case)', async () => {
mockEntitlements = {
capabilities: [

View file

@ -15,6 +15,7 @@ import {
getCommercialMigrationNotice,
getGrandfatheredPriceContinuityNotice,
getLicenseFeatureLabel,
getMonitoredSystemContinuityNotice,
getPurchaseActivationNotice,
getLicenseSubscriptionStatusPresentation,
getLicenseTierLabel,
@ -212,13 +213,38 @@ export function useProLicensePanelState() {
const limitStatus = (key: string) => entitlements()?.limits?.find((entry) => entry.key === key);
const monitoredSystemUsage = createMemo(() => limitStatus('max_monitored_systems')?.current ?? 0);
const monitoredSystemLimit = createMemo(() => limitStatus('max_monitored_systems')?.limit ?? 0);
const monitoredSystemLimitStatus = createMemo(() => limitStatus('max_monitored_systems'));
const monitoredSystemUsageAvailable = createMemo(
() => monitoredSystemLimitStatus()?.current_available !== false,
);
const monitoredSystemUsage = createMemo(() => monitoredSystemLimitStatus()?.current ?? 0);
const monitoredSystemLimit = createMemo(() => monitoredSystemLimitStatus()?.limit ?? 0);
const monitoredSystemUsageSummary = createMemo(() => {
if (!monitoredSystemUsageAvailable()) {
return 'Verifying…';
}
const limit = monitoredSystemLimit();
if (limit > 0) {
return `${monitoredSystemUsage()} / ${limit}`;
}
return monitoredSystemUsage();
});
const remainingSystemCapacity = createMemo(() => {
if (!monitoredSystemUsageAvailable()) return 'Unavailable';
const limit = monitoredSystemLimit();
if (limit <= 0) return 'Unlimited';
return Math.max(limit - monitoredSystemUsage(), 0);
});
const monitoredSystemContinuity = createMemo(() => entitlements()?.monitored_system_continuity);
const monitoredSystemContinuityNotice = createMemo(() =>
getMonitoredSystemContinuityNotice(monitoredSystemContinuity(), monitoredSystemLimitStatus()),
);
const continuityCapturedAt = createMemo(() => {
const capturedAt = monitoredSystemContinuity()?.captured_at;
return typeof capturedAt === 'number' && capturedAt > 0
? formatUnixDate(capturedAt)
: undefined;
});
const trialEnded = createMemo(
() =>
@ -257,8 +283,7 @@ export function useProLicensePanelState() {
planTerms: formattedPlanTerms() || undefined,
expires: displayedExpiry(),
daysRemaining: displayedDaysRemaining() ?? 'Unknown',
monitoredSystems: monitoredSystemUsage(),
monitoredSystemLimit: monitoredSystemLimit() > 0 ? monitoredSystemLimit() : undefined,
monitoredSystemsSummary: monitoredSystemUsageSummary(),
remainingSystemCapacity: remainingSystemCapacity(),
maxMonitoredSystems:
typeof limitStatus('max_monitored_systems')?.limit === 'number' &&
@ -269,6 +294,8 @@ export function useProLicensePanelState() {
typeof limitStatus('max_guests')?.limit === 'number' && limitStatus('max_guests')!.limit > 0
? limitStatus('max_guests')!.limit
: 'Unlimited',
monitoredSystemContinuity: monitoredSystemContinuity() ?? null,
continuityCapturedAt: continuityCapturedAt(),
}),
);
@ -327,6 +354,7 @@ export function useProLicensePanelState() {
clearing,
commercialMigrationNotice,
commercialPlanModel,
monitoredSystemContinuityNotice,
entitlements,
formattedFeatures,
grandfatheredPriceNotice,

View file

@ -12,6 +12,7 @@ import {
getLicenseStatusLoadingState,
getLicenseSubscriptionStatusPresentation,
getLicenseTierLabel,
getMonitoredSystemContinuityNotice,
getNoActiveProLicenseState,
getOrganizationBillingLicenseStatusLabel,
getInactiveProUpsellNotice,
@ -156,11 +157,51 @@ describe('licensePresentation', () => {
title: 'Grandfathered v5 pricing',
tone: expect.stringContaining('green'),
});
expect(getGrandfatheredPriceContinuityNotice('v5_pro_annual_grandfathered', 'grace')).toMatchObject({
expect(
getGrandfatheredPriceContinuityNotice('v5_pro_annual_grandfathered', 'grace'),
).toMatchObject({
title: 'Grandfathered v5 pricing',
});
expect(getGrandfatheredPriceContinuityNotice('v5_lifetime_grandfathered', 'active')).toBeNull();
expect(getGrandfatheredPriceContinuityNotice('v5_pro_monthly_grandfathered', 'expired')).toBeNull();
expect(
getGrandfatheredPriceContinuityNotice('v5_pro_monthly_grandfathered', 'expired'),
).toBeNull();
});
it('returns monitored-system continuity notices for pending verification and captured grandfathering', () => {
expect(
getMonitoredSystemContinuityNotice(
{
plan_limit: 10,
effective_limit: 10,
capture_pending: true,
},
{
current_available: false,
current_unavailable_reason: 'supplemental_inventory_unsettled',
},
),
).toMatchObject({
title: 'Migration continuity verification pending',
tone: expect.stringContaining('amber'),
});
expect(
getMonitoredSystemContinuityNotice(
{
plan_limit: 10,
grandfathered_floor: 23,
effective_limit: 23,
capture_pending: false,
captured_at: 123,
},
{
current_available: true,
},
),
).toMatchObject({
title: 'Grandfathered monitored-system floor',
tone: expect.stringContaining('green'),
});
});
it('returns canonical trial activation notices', () => {
@ -221,12 +262,12 @@ describe('licensePresentation', () => {
} as never),
).toContain('Trial (ends');
expect(getBillingAdminTrialStatus({ subscription_state: 'active' } as never)).toBe('No trial');
expect(getBillingAdminOrganizationBadges({ soft_deleted: true, suspended: true } as never)).toMatchObject([
{ label: 'soft-deleted' },
]);
expect(getBillingAdminOrganizationBadges({ soft_deleted: false, suspended: true } as never)).toMatchObject([
{ label: 'suspended' },
]);
expect(
getBillingAdminOrganizationBadges({ soft_deleted: true, suspended: true } as never),
).toMatchObject([{ label: 'soft-deleted' }]);
expect(
getBillingAdminOrganizationBadges({ soft_deleted: false, suspended: true } as never),
).toMatchObject([{ label: 'suspended' }]);
expect(getBillingAdminStateUpdateSuccessMessage('active')).toBe(
'Organization billing activated',
);

View file

@ -1,4 +1,5 @@
import type { LicenseStatus } from '@/api/license';
import type { MonitoredSystemContinuityStatus } from '@/api/license';
export interface CommercialStatValue {
label: string;
@ -28,15 +29,19 @@ export interface SelfHostedCommercialModelInput {
planTerms?: string;
expires: string;
daysRemaining: string | number;
monitoredSystems: number;
monitoredSystemLimit?: number;
monitoredSystemsSummary: string | number;
remainingSystemCapacity: string | number;
maxMonitoredSystems: string | number;
maxGuests: string | number;
monitoredSystemContinuity?: MonitoredSystemContinuityStatus | null;
continuityCapturedAt?: string;
}
export interface HostedCommercialModelInput {
status?: Pick<LicenseStatus, 'email' | 'is_lifetime' | 'expires_at' | 'max_monitored_systems' | 'max_guests'> | null;
status?: Pick<
LicenseStatus,
'email' | 'is_lifetime' | 'expires_at' | 'max_monitored_systems' | 'max_guests'
> | null;
tierLabel: string;
licenseStatusLabel: string;
organizationCount: number;
@ -55,10 +60,7 @@ export const buildSelfHostedCommercialPlanModel = (
summary: [
{
label: 'Monitored Systems',
value:
typeof input.monitoredSystemLimit === 'number' && input.monitoredSystemLimit > 0
? `${input.monitoredSystems} / ${input.monitoredSystemLimit}`
: input.monitoredSystems,
value: input.monitoredSystemsSummary,
},
{
label: 'Remaining System Capacity',
@ -94,10 +96,44 @@ export const buildSelfHostedCommercialPlanModel = (
label: 'Days Remaining',
value: input.daysRemaining,
},
{
label: 'Included Monitored Systems',
value: input.maxMonitoredSystems,
},
...(input.monitoredSystemContinuity
? [
{
label: 'Plan Monitored System Limit',
value:
input.monitoredSystemContinuity.plan_limit > 0
? input.monitoredSystemContinuity.plan_limit
: 'Unlimited',
},
{
label: 'Effective Monitored System Limit',
value:
input.monitoredSystemContinuity.effective_limit > 0
? input.monitoredSystemContinuity.effective_limit
: 'Unlimited',
},
...(typeof input.monitoredSystemContinuity.grandfathered_floor === 'number' &&
input.monitoredSystemContinuity.grandfathered_floor > 0
? [
{
label: 'Grandfathered Floor',
value: input.monitoredSystemContinuity.grandfathered_floor,
},
]
: []),
{
label: 'Continuity Capture',
value: input.monitoredSystemContinuity.capture_pending
? 'Pending'
: input.continuityCapturedAt || 'Captured',
},
]
: [
{
label: 'Included Monitored Systems',
value: input.maxMonitoredSystems,
},
]),
{
label: 'Max Guests',
value: input.maxGuests,

View file

@ -1,5 +1,10 @@
import type { BillingState, HostedOrganizationSummary } from '@/api/billingAdmin';
import type { CommercialMigrationStatus, LicenseStatus } from '@/api/license';
import type {
CommercialMigrationStatus,
EntitlementLimitStatus,
LicenseStatus,
MonitoredSystemContinuityStatus,
} from '@/api/license';
import { CLOUD_PLAN_LABELS } from '@/utils/cloudPlans';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
@ -154,6 +159,51 @@ export const getGrandfatheredPriceContinuityNotice = (
};
};
const monitoredSystemUnavailableReasonBody = (reason?: string): string => {
switch ((reason || '').trim().toLowerCase()) {
case 'supplemental_inventory_unsettled':
return 'Pulse is still collecting the first supplemental inventory baseline for this installation. Current monitored-system usage will appear after that baseline completes.';
case 'supplemental_inventory_rebuild_pending':
return 'Pulse has collected supplemental inventory, but it is still rebuilding the canonical monitored-system ledger. Current usage will appear when that rebuild finishes.';
case 'monitor_state_unavailable':
default:
return 'Pulse cannot currently verify monitored-system usage for this installation. Refresh after the monitoring runtime settles.';
}
};
export const getMonitoredSystemContinuityNotice = (
continuity?: MonitoredSystemContinuityStatus | null,
limit?: Pick<EntitlementLimitStatus, 'current_available' | 'current_unavailable_reason'> | null,
): LicenseInlineNotice | null => {
const currentAvailable = limit?.current_available !== false;
if (!currentAvailable) {
const title =
continuity?.capture_pending === true
? 'Migration continuity verification pending'
: 'Monitored-system usage unavailable';
return {
tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100',
title,
body: monitoredSystemUnavailableReasonBody(limit?.current_unavailable_reason),
};
}
if (
continuity &&
typeof continuity.grandfathered_floor === 'number' &&
continuity.grandfathered_floor > 0 &&
continuity.effective_limit > continuity.plan_limit
) {
return {
tone: 'border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-900 text-green-900 dark:text-green-100',
title: 'Grandfathered monitored-system floor',
body: `This migrated v5 installation keeps an effective monitored-system limit of ${continuity.effective_limit}. The current plan includes ${continuity.plan_limit}, and the observed legacy estate was grandfathered at ${continuity.grandfathered_floor}.`,
};
}
return null;
};
export const getCommercialMigrationActionText = (action?: string): string => {
switch (action) {
case 'retry_activation':

View file

@ -4824,7 +4824,14 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot(
Tier: pkglicensing.TierPro,
Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...),
MaxMonitoredSystems: 15,
}, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{}, nil)
MonitoredSystemContinuity: &pkglicensing.MonitoredSystemContinuityStatus{
PlanLimit: 15,
EffectiveLimit: 15,
CapturePending: true,
},
}, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{
MonitoredSystemsUnavailableReason: "supplemental_inventory_unsettled",
}, nil)
got, err := json.Marshal(payload)
if err != nil {
@ -4833,7 +4840,7 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot(
const want = `{
"capabilities":["update_alerts","sso","ai_patrol","relay","mobile_app","push_notifications","long_term_metrics","ai_alerts","ai_autofix","kubernetes_ai","agent_profiles","advanced_sso","rbac","audit_logging","advanced_reporting"],
"limits":[{"key":"max_monitored_systems","limit":15,"current":0,"current_available":false,"state":"ok"}],
"limits":[{"key":"max_monitored_systems","limit":15,"current":0,"current_available":false,"current_unavailable_reason":"supplemental_inventory_unsettled","state":"ok"}],
"subscription_state":"active",
"upgrade_reasons":[],
"tier":"pro",
@ -4844,7 +4851,8 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot(
"trial_eligible":false,
"max_history_days":90,
"legacy_connections":{"proxmox_nodes":0,"docker_hosts":0,"kubernetes_clusters":0},
"has_migration_gap":false
"has_migration_gap":false,
"monitored_system_continuity":{"plan_limit":15,"effective_limit":15,"capture_pending":true}
}`
assertJSONSnapshot(t, got, want)
@ -4973,42 +4981,64 @@ func TestContract_LegacyMigrationGrandfatherFloorJSONSnapshot(t *testing.T) {
if err := json.Unmarshal(entRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode entitlements: %v", err)
}
statusContinuity := status.MonitoredSystemContinuity
if statusContinuity != nil {
copied := *statusContinuity
if copied.CapturedAt > 0 {
copied.CapturedAt = 123
}
statusContinuity = &copied
}
payloadContinuity := payload.MonitoredSystemContinuity
if payloadContinuity != nil {
copied := *payloadContinuity
if copied.CapturedAt > 0 {
copied.CapturedAt = 123
}
payloadContinuity = &copied
}
got, err := json.Marshal(struct {
Status struct {
Tier pkglicensing.Tier `json:"tier"`
PlanVersion string `json:"plan_version"`
MaxMonitoredSystems int `json:"max_monitored_systems"`
Valid bool `json:"valid"`
Tier pkglicensing.Tier `json:"tier"`
PlanVersion string `json:"plan_version"`
MaxMonitoredSystems int `json:"max_monitored_systems"`
Valid bool `json:"valid"`
MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
} `json:"status"`
Entitlements struct {
Tier string `json:"tier"`
PlanVersion string `json:"plan_version"`
SubscriptionState string `json:"subscription_state"`
Limits []pkglicensing.LimitStatus `json:"limits"`
Tier string `json:"tier"`
PlanVersion string `json:"plan_version"`
SubscriptionState string `json:"subscription_state"`
Limits []pkglicensing.LimitStatus `json:"limits"`
MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
} `json:"entitlements"`
}{
Status: struct {
Tier pkglicensing.Tier `json:"tier"`
PlanVersion string `json:"plan_version"`
MaxMonitoredSystems int `json:"max_monitored_systems"`
Valid bool `json:"valid"`
Tier pkglicensing.Tier `json:"tier"`
PlanVersion string `json:"plan_version"`
MaxMonitoredSystems int `json:"max_monitored_systems"`
Valid bool `json:"valid"`
MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
}{
Tier: status.Tier,
PlanVersion: status.PlanVersion,
MaxMonitoredSystems: status.MaxMonitoredSystems,
Valid: status.Valid,
Tier: status.Tier,
PlanVersion: status.PlanVersion,
MaxMonitoredSystems: status.MaxMonitoredSystems,
Valid: status.Valid,
MonitoredSystemContinuity: statusContinuity,
},
Entitlements: struct {
Tier string `json:"tier"`
PlanVersion string `json:"plan_version"`
SubscriptionState string `json:"subscription_state"`
Limits []pkglicensing.LimitStatus `json:"limits"`
Tier string `json:"tier"`
PlanVersion string `json:"plan_version"`
SubscriptionState string `json:"subscription_state"`
Limits []pkglicensing.LimitStatus `json:"limits"`
MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"`
}{
Tier: payload.Tier,
PlanVersion: payload.PlanVersion,
SubscriptionState: payload.SubscriptionState,
Limits: payload.Limits,
Tier: payload.Tier,
PlanVersion: payload.PlanVersion,
SubscriptionState: payload.SubscriptionState,
Limits: payload.Limits,
MonitoredSystemContinuity: payloadContinuity,
},
})
if err != nil {
@ -5020,13 +5050,15 @@ func TestContract_LegacyMigrationGrandfatherFloorJSONSnapshot(t *testing.T) {
"tier":"pro",
"plan_version":"v5_pro_monthly_grandfathered",
"max_monitored_systems":23,
"valid":true
"valid":true,
"monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":23,"capture_pending":false,"captured_at":123}
},
"entitlements":{
"tier":"pro",
"plan_version":"v5_pro_monthly_grandfathered",
"subscription_state":"active",
"limits":[{"key":"max_monitored_systems","limit":23,"current":23,"current_available":true,"state":"enforced"}]
"limits":[{"key":"max_monitored_systems","limit":23,"current":23,"current_available":true,"state":"enforced"}],
"monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":23,"capture_pending":false,"captured_at":123}
}
}`

View file

@ -0,0 +1,180 @@
package api
import (
"context"
"sync"
"time"
"github.com/rs/zerolog/log"
)
const legacyGrandfatherReconcileInterval = 5 * time.Second
type legacyGrandfatherReconcileLoop struct {
mu sync.Mutex
cancel context.CancelFunc
wg sync.WaitGroup
running bool
}
func (l *legacyGrandfatherReconcileLoop) isRunning() bool {
if l == nil {
return false
}
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}
func (h *LicenseHandlers) legacyGrandfatherReconcileLoop(orgID string) *legacyGrandfatherReconcileLoop {
if h == nil {
return nil
}
if loop, ok := h.legacyGrandfatherReconcile.Load(orgID); ok {
if typed, ok := loop.(*legacyGrandfatherReconcileLoop); ok {
return typed
}
}
loop := &legacyGrandfatherReconcileLoop{}
actual, _ := h.legacyGrandfatherReconcile.LoadOrStore(orgID, loop)
if typed, ok := actual.(*legacyGrandfatherReconcileLoop); ok {
return typed
}
return loop
}
func (h *LicenseHandlers) ensureLegacyGrandfatherReconcileLoop(orgID string, service *licenseService) {
if h == nil || service == nil {
return
}
orgID = normalizeHostedEntitlementOrgID(orgID)
if !service.NeedsLegacyMonitoredSystemCapture() {
h.stopLegacyGrandfatherReconcileLoop(orgID)
return
}
loop := h.legacyGrandfatherReconcileLoop(orgID)
if loop == nil {
return
}
if loop.isRunning() {
return
}
loop.mu.Lock()
defer loop.mu.Unlock()
if loop.running {
return
}
ctx, cancel := context.WithCancel(context.Background())
loop.cancel = cancel
loop.running = true
loop.wg.Add(1)
go func() {
defer func() {
loop.mu.Lock()
loop.running = false
loop.mu.Unlock()
h.legacyGrandfatherReconcile.Delete(orgID)
loop.wg.Done()
}()
h.runLegacyGrandfatherReconcileLoop(ctx, orgID, service)
}()
}
func (h *LicenseHandlers) stopLegacyGrandfatherReconcileLoop(orgID string) {
if h == nil {
return
}
orgID = normalizeHostedEntitlementOrgID(orgID)
value, ok := h.legacyGrandfatherReconcile.Load(orgID)
if !ok {
return
}
loop, ok := value.(*legacyGrandfatherReconcileLoop)
if !ok || loop == nil {
h.legacyGrandfatherReconcile.Delete(orgID)
return
}
loop.mu.Lock()
if !loop.running {
loop.mu.Unlock()
h.legacyGrandfatherReconcile.Delete(orgID)
return
}
cancel := loop.cancel
loop.running = false
loop.mu.Unlock()
if cancel != nil {
cancel()
}
loop.wg.Wait()
h.legacyGrandfatherReconcile.Delete(orgID)
}
func (h *LicenseHandlers) runLegacyGrandfatherReconcileLoop(
ctx context.Context,
orgID string,
service *licenseService,
) {
ticker := time.NewTicker(legacyGrandfatherReconcileInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reconcileCtx := context.WithValue(context.Background(), OrgIDContextKey, orgID)
h.reconcileLegacyMigrationGrandfatherFloor(reconcileCtx, orgID, service)
if !service.NeedsLegacyMonitoredSystemCapture() {
return
}
}
}
}
func (h *LicenseHandlers) reconcileLegacyMigrationGrandfatherFloor(
ctx context.Context,
orgID string,
service *licenseService,
) {
if h == nil || service == nil {
return
}
orgID = normalizeHostedEntitlementOrgID(orgID)
if !service.NeedsLegacyMonitoredSystemCapture() {
return
}
if ctx == nil {
ctx = context.Background()
}
if resolved := GetOrgID(ctx); resolved == "" {
ctx = context.WithValue(ctx, OrgIDContextKey, orgID)
}
count, ok := h.canonicalMonitoredSystemGrandfatherFloor(ctx)
if !ok {
h.ensureLegacyGrandfatherReconcileLoop(orgID, service)
return
}
if err := service.CaptureLegacyMonitoredSystemGrandfatherFloor(count); err != nil {
log.Warn().
Str("org_id", orgID).
Int("monitored_systems", count).
Err(err).
Msg("Failed to persist migrated monitored-system grandfather floor")
h.ensureLegacyGrandfatherReconcileLoop(orgID, service)
return
}
if service.NeedsLegacyMonitoredSystemCapture() {
h.ensureLegacyGrandfatherReconcileLoop(orgID, service)
}
}

View file

@ -53,20 +53,21 @@ func revocationFeedToken() string {
// LicenseHandlers handles license management API endpoints.
type LicenseHandlers struct {
mtPersistence *config.MultiTenantPersistence
hostedMode bool
cfg *config.Config
services sync.Map // map[string]*licenseService
trialLimiter *RateLimiter
trialReplay *jtiReplayStore
trialInitiations *trialSignupInitiationStore
purchaseHandoffs *purchaseCheckoutHandoffStore
trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error)
monitor *monitoring.Monitor
mtMonitor *monitoring.MultiTenantMonitor
conversionRecorder *conversionRecorder
conversionHealth *conversionPipelineHealth
hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop
mtPersistence *config.MultiTenantPersistence
hostedMode bool
cfg *config.Config
services sync.Map // map[string]*licenseService
trialLimiter *RateLimiter
trialReplay *jtiReplayStore
trialInitiations *trialSignupInitiationStore
purchaseHandoffs *purchaseCheckoutHandoffStore
trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error)
monitor *monitoring.Monitor
mtMonitor *monitoring.MultiTenantMonitor
conversionRecorder *conversionRecorder
conversionHealth *conversionPipelineHealth
hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop
legacyGrandfatherReconcile sync.Map // map[string]*legacyGrandfatherReconcileLoop
}
// NewLicenseHandlers creates a new license handlers instance.
@ -165,6 +166,12 @@ func (h *LicenseHandlers) StopAllBackgroundLoops() {
}
return true
})
h.legacyGrandfatherReconcile.Range(func(key, value any) bool {
if orgID, ok := key.(string); ok {
h.stopLegacyGrandfatherReconcileLoop(orgID)
}
return true
})
h.services.Range(func(_, value any) bool {
if svc, ok := value.(*licenseService); ok {
svc.StopGrantRefresh()
@ -515,7 +522,7 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ
if err := h.ensureEvaluatorForOrg(orgID, svc); err != nil {
log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to refresh license evaluator for org")
}
h.ensureLegacyMigrationGrandfatherFloor(ctx, svc)
h.ensureLegacyGrandfatherReconcileLoop(orgID, svc)
h.ensureHostedEntitlementRefreshForOrg(orgID, svc)
// We need persistence too, reconstruct it or cache it?
// Reconstructing persistence is cheap (just a struct with path).
@ -553,7 +560,7 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ
if err := service.RestoreActivation(activationState); err != nil {
log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to restore activation")
} else {
h.ensureLegacyMigrationGrandfatherFloor(ctx, service)
h.ensureLegacyGrandfatherReconcileLoop(orgID, service)
if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil {
log.Warn().Str("org_id", orgID).Err(clearErr).Msg("Failed to clear commercial migration state after activation restore")
}
@ -583,7 +590,7 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ
}
log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to auto-exchange persisted legacy license")
} else if service.IsActivated() {
h.ensureLegacyMigrationGrandfatherFloor(ctx, service)
h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service)
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")
}
@ -608,11 +615,13 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ
service.StopGrantRefresh() // stop our orphaned refresh loop if started
service.StopRevocationPoll() // stop our orphaned revocation poller if started
svc := actual.(*licenseService)
h.ensureLegacyGrandfatherReconcileLoop(orgID, svc)
h.ensureHostedEntitlementRefreshForOrg(orgID, svc)
p, pErr := h.getPersistenceForOrg(orgID)
return svc, p, pErr
}
h.ensureLegacyGrandfatherReconcileLoop(orgID, service)
h.ensureHostedEntitlementRefreshForOrg(orgID, service)
return service, persistence, nil
@ -629,29 +638,6 @@ func (h *LicenseHandlers) canonicalMonitoredSystemGrandfatherFloor(ctx context.C
return int(usage.MonitoredSystems), true
}
func (h *LicenseHandlers) ensureLegacyMigrationGrandfatherFloor(ctx context.Context, service *licenseService) {
if h == nil || service == nil {
return
}
count, ok := h.canonicalMonitoredSystemGrandfatherFloor(ctx)
if !ok {
return
}
if err := service.CaptureLegacyMonitoredSystemGrandfatherFloor(count); err != nil {
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
log.Warn().
Str("org_id", orgID).
Int("monitored_systems", count).
Err(err).
Msg("Failed to persist migrated monitored-system grandfather floor")
}
}
func (h *LicenseHandlers) ensureEvaluatorForOrg(orgID string, service *licenseService) error {
if h == nil || service == nil || h.mtPersistence == nil {
return nil
@ -1350,7 +1336,7 @@ func (h *LicenseHandlers) activateLicenseKey(ctx context.Context, licenseKey str
if service.IsActivated() {
if migratedLegacyKey {
h.ensureLegacyMigrationGrandfatherFloor(ctx, service)
h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service)
}
h.stopHostedEntitlementRefreshLoop(orgID)
if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil {
@ -1813,6 +1799,7 @@ func (h *LicenseHandlers) HandleClearLicense(w http.ResponseWriter, r *http.Requ
// Preserve trial_started_at and free-tier bookkeeping so the effective trial
// ends immediately but trial reuse remains blocked.
if h != nil && h.mtPersistence != nil {
h.stopLegacyGrandfatherReconcileLoop(orgID)
h.stopHostedEntitlementRefreshLoop(orgID)
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
existing, err := billingStore.GetBillingState(orgID)

View file

@ -652,15 +652,60 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet
handlers := NewLicenseHandlers(mtp, false)
handlers.SetMonitors(monitor, nil)
t.Cleanup(handlers.StopAllBackgroundLoops)
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
svc := handlers.Service(ctx)
if svc == nil {
t.Fatal("expected non-nil service")
readStatus := func() pkglicensing.LicenseStatus {
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil).WithContext(ctx)
rec := httptest.NewRecorder()
handlers.HandleLicenseStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("license status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var status pkglicensing.LicenseStatus
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
t.Fatalf("decode license status: %v", err)
}
return status
}
if got := svc.Status().MaxMonitoredSystems; got != 10 {
readEntitlements := func() EntitlementPayload {
req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)
rec := httptest.NewRecorder()
handlers.HandleEntitlements(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("license entitlements=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload EntitlementPayload
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode entitlements: %v", err)
}
return payload
}
status := readStatus()
if got := status.MaxMonitoredSystems; got != 10 {
t.Fatalf("initial status.MaxMonitoredSystems=%d, want 10 while supplemental inventory is unsettled", got)
}
if status.MonitoredSystemContinuity == nil || !status.MonitoredSystemContinuity.CapturePending {
t.Fatalf("expected pending continuity in status payload, got %+v", status.MonitoredSystemContinuity)
}
if status.MonitoredSystemContinuity.PlanLimit != 10 || status.MonitoredSystemContinuity.EffectiveLimit != 10 {
t.Fatalf("unexpected pending continuity status payload: %+v", status.MonitoredSystemContinuity)
}
payload := readEntitlements()
if payload.MonitoredSystemContinuity == nil || !payload.MonitoredSystemContinuity.CapturePending {
t.Fatalf("expected pending continuity in entitlements payload, got %+v", payload.MonitoredSystemContinuity)
}
if len(payload.Limits) != 1 {
t.Fatalf("expected one monitored-system limit, got %+v", payload.Limits)
}
if payload.Limits[0].CurrentAvailable == nil || *payload.Limits[0].CurrentAvailable {
t.Fatalf("expected unavailable monitored-system usage while supplemental inventory is unsettled, got %+v", payload.Limits[0])
}
if payload.Limits[0].CurrentUnavailableReason != "supplemental_inventory_unsettled" {
t.Fatalf("CurrentUnavailableReason=%q, want %q", payload.Limits[0].CurrentUnavailableReason, "supplemental_inventory_unsettled")
}
activationState, err := persistence.LoadActivationState()
if err != nil {
@ -674,32 +719,55 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet
}
provider.settle(23)
svc = handlers.Service(ctx)
if got := svc.Status().MaxMonitoredSystems; got != 10 {
status = readStatus()
if got := status.MaxMonitoredSystems; got != 10 {
t.Fatalf("stale supplemental store should not capture grandfather floor yet, got %d", got)
}
monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider)
svc = handlers.Service(ctx)
if got := svc.Status().MaxMonitoredSystems; got != 23 {
t.Fatalf("status.MaxMonitoredSystems=%d, want 23 after supplemental store rebuild", got)
status = readStatus()
if got := status.MaxMonitoredSystems; got != 10 {
t.Fatalf("status read should not capture grandfather floor directly after canonical store rebuild, got %d", got)
}
activationState, err = persistence.LoadActivationState()
if err != nil {
t.Fatalf("reload activation state: %v", err)
}
if activationState == nil {
t.Fatal("expected activation state after grandfather capture")
}
if activationState.Continuity.GrandfatheredMaxMonitoredSystems != 23 {
t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 23", activationState.Continuity.GrandfatheredMaxMonitoredSystems)
}
if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 {
t.Fatal("expected grandfather capture timestamp after supplemental store rebuild")
deadline := time.Now().Add(8 * time.Second)
for {
activationState, err = persistence.LoadActivationState()
if err != nil {
t.Fatalf("reload activation state: %v", err)
}
if activationState != nil &&
activationState.Continuity.GrandfatheredMaxMonitoredSystems == 23 &&
activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("expected async grandfather capture after canonical store rebuild, last activation state=%+v", activationState)
}
time.Sleep(100 * time.Millisecond)
}
handlers.StopAllBackgroundLoops()
status = readStatus()
if got := status.MaxMonitoredSystems; got != 23 {
t.Fatalf("status.MaxMonitoredSystems=%d, want 23 after async grandfather capture", got)
}
if status.MonitoredSystemContinuity == nil || status.MonitoredSystemContinuity.CapturePending {
t.Fatalf("expected settled continuity in status payload after async capture, got %+v", status.MonitoredSystemContinuity)
}
if status.MonitoredSystemContinuity.GrandfatheredFloor != 23 || status.MonitoredSystemContinuity.EffectiveLimit != 23 {
t.Fatalf("unexpected settled continuity status payload: %+v", status.MonitoredSystemContinuity)
}
payload = readEntitlements()
if payload.MonitoredSystemContinuity == nil || payload.MonitoredSystemContinuity.CapturePending {
t.Fatalf("expected settled continuity in entitlements payload after async capture, got %+v", payload.MonitoredSystemContinuity)
}
if len(payload.Limits) != 1 || payload.Limits[0].Current != 23 {
t.Fatalf("expected settled monitored-system usage in entitlements payload, got %+v", payload.Limits)
}
if payload.Limits[0].CurrentAvailable == nil || !*payload.Limits[0].CurrentAvailable {
t.Fatalf("expected available monitored-system usage after async capture, got %+v", payload.Limits[0])
}
}
func buildGrandfatherFloorMonitor(count int) *monitoring.Monitor {

View file

@ -174,6 +174,8 @@ func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlem
usage.MonitoredSystemsAvailable = true
usage.LegacyConnections = legacyConnectionCountsFromReadState(state.ReadState)
monitorResolved = true
} else if usage.MonitoredSystemsUnavailableReason == "" {
usage.MonitoredSystemsUnavailableReason = state.UnavailableReason
}
}
}
@ -183,6 +185,8 @@ func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlem
usage.MonitoredSystems = int64(state.Count)
usage.MonitoredSystemsAvailable = true
usage.LegacyConnections = legacyConnectionCountsFromReadState(state.ReadState)
} else if usage.MonitoredSystemsUnavailableReason == "" {
usage.MonitoredSystemsUnavailableReason = state.UnavailableReason
}
}

View file

@ -98,11 +98,17 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) {
"SupplementalInventoryReadyAt(m *Monitor, orgID string) (time.Time, bool)",
},
"monitored_system_usage.go": {
"MonitoredSystemUsageUnavailableMonitorState",
"MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled",
"MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending",
"func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot {",
"readyAt, settled := m.supplementalInventoryReadyAt(orgID)",
"if !settled {",
"UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled,",
"if freshness.IsZero() || freshness.Before(readyAt) {",
"return MonitoredSystemUsageSnapshot{ReadState: readState}",
"UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending,",
"Count: unifiedresources.MonitoredSystemCount(readState),",
"Available: true,",
},
"truenas_poller.go": {
"func (p *TrueNASPoller) SupplementalInventoryReadyAt(_ *Monitor, orgID string) (time.Time, bool) {",

View file

@ -11,34 +11,47 @@ import (
// supply a canonical monitored-system count that is safe for billing and
// admission enforcement to consume.
type MonitoredSystemUsageSnapshot struct {
Count int
ReadState unifiedresources.ReadState
Available bool
Count int
ReadState unifiedresources.ReadState
Available bool
UnavailableReason string
}
const (
MonitoredSystemUsageUnavailableMonitorState = "monitor_state_unavailable"
MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled = "supplemental_inventory_unsettled"
MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending = "supplemental_inventory_rebuild_pending"
)
// MonitoredSystemUsage returns the canonical monitored-system count only when
// the current unified view is settled enough for billing boundaries. When
// supplemental provider-owned sources are still settling, the result fails
// closed with Available=false.
func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot {
if m == nil {
return MonitoredSystemUsageSnapshot{}
return MonitoredSystemUsageSnapshot{UnavailableReason: MonitoredSystemUsageUnavailableMonitorState}
}
readState := m.GetUnifiedReadStateOrSnapshot()
if readState == nil {
return MonitoredSystemUsageSnapshot{}
return MonitoredSystemUsageSnapshot{UnavailableReason: MonitoredSystemUsageUnavailableMonitorState}
}
orgID := normalizedMonitorUsageOrgID(m)
readyAt, settled := m.supplementalInventoryReadyAt(orgID)
if !settled {
return MonitoredSystemUsageSnapshot{ReadState: readState}
return MonitoredSystemUsageSnapshot{
ReadState: readState,
UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled,
}
}
if !readyAt.IsZero() {
freshness := m.currentUnifiedResourceFreshness()
if freshness.IsZero() || freshness.Before(readyAt) {
return MonitoredSystemUsageSnapshot{ReadState: readState}
return MonitoredSystemUsageSnapshot{
ReadState: readState,
UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending,
}
}
}

View file

@ -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{

View file

@ -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) {

View file

@ -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"`
}

View file

@ -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,

View file

@ -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).

View file

@ -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")