mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-28 17:49:21 +00:00
Decouple monitored-system continuity capture from billing reads
This commit is contained in:
parent
87bec03f58
commit
81f64edace
27 changed files with 1035 additions and 155 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
}`
|
||||
|
||||
|
|
|
|||
180
internal/api/legacy_grandfather_reconcile.go
Normal file
180
internal/api/legacy_grandfather_reconcile.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue