diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f0611eb94..a115f3442 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -728,6 +728,11 @@ operator-facing filter query for lifecycle-adjacent drill-down links. Any legacy `provider` alias support must remain compatibility-only input behind the shared API/router layer rather than becoming the route shape lifecycle surfaces copy back out to operators. +That same lifecycle-adjacent recovery drill-down boundary must also stay on +canonical `itemResourceId` filter and payload vocabulary. When lifecycle +surfaces deep-link into shared recovery handlers or consume recovery payloads, +they should treat legacy `subjectResourceId` only as an API-layer compatibility +alias rather than reviving it as the route or runtime model they expose. The updater/runtime surfaces must preserve the one-shot `updated_from` continuity handoff and the non-TLS continuity path for supported self-hosted diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9b77a26fa..3dcc6ea18 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -200,6 +200,7 @@ Own canonical runtime payload shapes between backend and frontend. 13. Keep recovery payload filters canonical across `/api/recovery/rollups`, `/api/recovery/points`, `/api/recovery/series`, and `/api/recovery/facets`: when `internal/api/recovery_handlers.go` adds a governed recovery filter or display field such as provider-neutral `itemType`, the same normalized transport must land across all four endpoints and the contract tests must pin both outbound payload shape and accepted query aliases in the same slice 14. Keep recovery platform-query vocabulary canonical across that same `/api/recovery/*` surface: operator-facing transport must emit `platform` as the canonical query field, accepted legacy `provider` aliases must remain compatibility-only input, and `internal/api/contract_test.go` must pin that fallback behavior in the same slice as any handler change 15. Keep recovery payload platform vocabulary canonical across that same `/api/recovery/*` surface: point payloads must expose `platform`, rollup payloads must expose `platforms`, and any compatibility `provider` / `providers` aliases must remain secondary fallback fields rather than replacing the shared response model +16. Keep recovery linked-resource vocabulary canonical across that same `/api/recovery/*` surface: points and rollups must expose `itemResourceId` as the canonical linked-resource field, accepted legacy `subjectResourceId` aliases must remain compatibility-only input or secondary payload fields, and the shared proof surface must pin that normalization in the same slice as any handler change ## Current State @@ -1388,6 +1389,12 @@ operator-facing query field across `/api/recovery/rollups`, `/api/recovery/point mapping that boundary onto internal provider fields, but accepted legacy `provider` aliases must be compatibility-only input and must not replace the canonical transport query shape. +That same recovery API boundary must also treat `itemResourceId` as the +canonical linked-resource filter and payload field across those same +`/api/recovery/*` endpoints. Accepted legacy `subjectResourceId` aliases may +remain as compatibility-only input or secondary payload fields during the v6 +transition, but the shared transport contract and frontend decode path must +normalize them back onto canonical `itemResourceId`. That same outbound recovery transport now also treats `platform` and `platforms` as the canonical response fields for point and rollup payloads. Compatibility `provider` and `providers` fields may remain during the v6 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 5b24008c8..0536a626e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -200,6 +200,10 @@ That same rule applies to recovery detail helpers. Provider-specific helper names like `isPbsProvider` should become platform-specific helpers like `isPbsPlatform` once the runtime recovery model is already canonically platform-first. +The same canonical boundary applies to linked-resource identifiers. Recovery +API payloads, query filters, and normalized frontend runtime models should use +`itemResourceId` as the canonical field while accepting or emitting +`subjectResourceId` only as a compatibility alias during the transition. That same presenter boundary should also own canonical item-type derivation. Recovery surfaces must resolve rollup and point item types through one shared item-type helper instead of repeating `display.itemType` / `subjectType` / diff --git a/frontend-modern/src/api/__tests__/recoveryTransport.test.ts b/frontend-modern/src/api/__tests__/recoveryTransport.test.ts new file mode 100644 index 000000000..e5372cd9d --- /dev/null +++ b/frontend-modern/src/api/__tests__/recoveryTransport.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeRecoveryPointsResponse, + normalizeRecoveryRollupsResponse, +} from '@/utils/recoveryPlatformModel'; + +describe('recovery transport', () => { + it('normalizes legacy subject resource ids onto canonical item resource ids', () => { + expect( + normalizeRecoveryPointsResponse({ + data: [ + { + id: 'point-1', + provider: 'truenas', + kind: 'snapshot', + mode: 'snapshot', + outcome: 'success', + subjectResourceId: 'res-1', + }, + ], + meta: { page: 1, limit: 100, total: 1, totalPages: 1 }, + }), + ).toEqual({ + data: [ + { + id: 'point-1', + platform: 'truenas', + kind: 'snapshot', + mode: 'snapshot', + outcome: 'success', + itemResourceId: 'res-1', + }, + ], + meta: { page: 1, limit: 100, total: 1, totalPages: 1 }, + }); + + expect( + normalizeRecoveryRollupsResponse({ + data: [ + { + rollupId: 'rollup-1', + lastOutcome: 'success', + providers: ['truenas'], + subjectResourceId: 'res-1', + }, + ], + meta: { page: 1, limit: 100, total: 1, totalPages: 1 }, + }), + ).toEqual({ + data: [ + { + rollupId: 'rollup-1', + lastOutcome: 'success', + platforms: ['truenas'], + itemResourceId: 'res-1', + }, + ], + meta: { page: 1, limit: 100, total: 1, totalPages: 1 }, + }); + }); +}); diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index 3a2fea39b..3fd50417d 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -126,7 +126,7 @@ const Recovery: Component = () => { const label = getRecoveryRollupItemLabel(rollup, resourceIndex); const haystack = [ rollup.rollupId, - rollup.subjectResourceId || '', + rollup.itemResourceId || '', label, rollupItemType, rollup.subjectRef?.namespace || '', diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx index 015b12bf2..ba6d2bcc3 100644 --- a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx @@ -175,7 +175,7 @@ export const RecoveryPointDetails: Component = (props if (p.verified != null) pairs.push({ k: 'Verified', v: p.verified ? 'Verified' : 'Not Verified' }); if (p.encrypted != null) pairs.push({ k: 'Encrypted', v: p.encrypted ? 'Encrypted' : 'Not Encrypted' }); - if (p.subjectResourceId) pairs.push({ k: 'Item Resource', v: p.subjectResourceId }); + if (p.itemResourceId) pairs.push({ k: 'Item Resource', v: p.itemResourceId }); if (p.repositoryResourceId) pairs.push({ k: 'Target Resource', v: p.repositoryResourceId }); if (p.subjectRef) pairs.push({ k: 'Item Ref', v: labelForRef(p.subjectRef) }); if (p.repositoryRef) pairs.push({ k: 'Target Ref', v: labelForRef(p.repositoryRef) }); diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index 2853b4c9d..44d2f2a29 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -23,7 +23,7 @@ vi.mock('@solidjs/router', async () => { const rollupsPayload = [ { rollupId: 'res:vm-123', - subjectResourceId: 'vm-123', + itemResourceId: 'vm-123', display: { subjectType: 'proxmox-vm', itemType: 'vm' }, lastAttemptAt: '2026-02-14T10:00:00.000Z', lastSuccessAt: '2026-02-14T10:00:00.000Z', @@ -260,7 +260,7 @@ describe('Recovery', () => { it('renders canonical rollup and history item labels when linked resources are unavailable', async () => { rollupsPayload.push({ rollupId: 'res:vm-404', - subjectResourceId: 'vm-404', + itemResourceId: 'vm-404', display: { itemLabel: 'Archive VM' }, lastAttemptAt: '2026-02-12T08:00:00.000Z', lastSuccessAt: '2026-02-12T08:00:00.000Z', diff --git a/frontend-modern/src/hooks/useRecoveryPoints.ts b/frontend-modern/src/hooks/useRecoveryPoints.ts index 7dfc8e9f9..e77214e9b 100644 --- a/frontend-modern/src/hooks/useRecoveryPoints.ts +++ b/frontend-modern/src/hooks/useRecoveryPoints.ts @@ -23,6 +23,7 @@ export type RecoveryPointsQuery = { mode?: string | null; outcome?: string | null; itemType?: string | null; + itemResourceId?: string | null; subjectResourceId?: string | null; // Normalized filters (server-side) @@ -58,7 +59,8 @@ const normalizeQuery = (query: RecoveryPointsQuery | undefined): RecoveryPointsQ mode: norm(q.mode) || null, outcome: norm(q.outcome) || null, itemType: norm(q.itemType) || null, - subjectResourceId: norm(q.subjectResourceId) || null, + itemResourceId: norm(q.itemResourceId) || norm(q.subjectResourceId) || null, + subjectResourceId: null, q: norm(q.q) || null, cluster: norm(q.cluster) || null, @@ -98,7 +100,7 @@ const buildURL = (query: RecoveryPointsQuery | undefined): string => { if (q.mode) params.set('mode', q.mode); if (q.outcome) params.set('outcome', q.outcome); if (q.itemType) params.set('itemType', q.itemType); - if (q.subjectResourceId) params.set('subjectResourceId', q.subjectResourceId); + if (q.itemResourceId) params.set('itemResourceId', q.itemResourceId); if (q.q) params.set('q', q.q); if (q.cluster) params.set('cluster', q.cluster); diff --git a/frontend-modern/src/hooks/useRecoveryRollups.ts b/frontend-modern/src/hooks/useRecoveryRollups.ts index e9e18850c..3f45661a6 100644 --- a/frontend-modern/src/hooks/useRecoveryRollups.ts +++ b/frontend-modern/src/hooks/useRecoveryRollups.ts @@ -15,6 +15,7 @@ export type RecoveryRollupsQuery = { mode?: string | null; outcome?: string | null; itemType?: string | null; + itemResourceId?: string | null; subjectResourceId?: string | null; q?: string | null; cluster?: string | null; @@ -36,7 +37,8 @@ const normalizeQuery = (query: RecoveryRollupsQuery | undefined): RecoveryRollup mode: norm(q.mode) || null, outcome: norm(q.outcome) || null, itemType: norm(q.itemType) || null, - subjectResourceId: norm(q.subjectResourceId) || null, + itemResourceId: norm(q.itemResourceId) || norm(q.subjectResourceId) || null, + subjectResourceId: null, q: norm(q.q) || null, cluster: norm(q.cluster) || null, node: norm(q.node) || null, @@ -72,7 +74,7 @@ const buildURL = (page: number, limit: number, query: RecoveryRollupsQuery | und if (q.mode) params.set('mode', q.mode); if (q.outcome) params.set('outcome', q.outcome); if (q.itemType) params.set('itemType', q.itemType); - if (q.subjectResourceId) params.set('subjectResourceId', q.subjectResourceId); + if (q.itemResourceId) params.set('itemResourceId', q.itemResourceId); if (q.q) params.set('q', q.q); if (q.cluster) params.set('cluster', q.cluster); if (q.node) params.set('node', q.node); diff --git a/frontend-modern/src/types/recovery.ts b/frontend-modern/src/types/recovery.ts index 2d7e8038b..3db5b314f 100644 --- a/frontend-modern/src/types/recovery.ts +++ b/frontend-modern/src/types/recovery.ts @@ -58,7 +58,7 @@ export interface RecoveryPoint { encrypted?: boolean | null; immutable?: boolean | null; - subjectResourceId?: string; + itemResourceId?: string; repositoryResourceId?: string; subjectRef?: RecoveryExternalRef | null; repositoryRef?: RecoveryExternalRef | null; @@ -70,6 +70,7 @@ export interface RecoveryPoint { export interface RecoveryPointTransport extends RecoveryPoint { display?: RecoveryPointDisplayTransport | null; provider?: RecoveryPlatform; + subjectResourceId?: string; } export interface RecoveryResponseMeta { @@ -91,7 +92,7 @@ export interface RecoveryPointsTransportResponse { export interface ProtectionRollup { rollupId: string; - subjectResourceId?: string; + itemResourceId?: string; subjectRef?: RecoveryExternalRef | null; display?: RecoveryPointDisplay | null; @@ -105,6 +106,7 @@ export interface ProtectionRollup { export interface ProtectionRollupTransport extends ProtectionRollup { display?: RecoveryPointDisplayTransport | null; providers?: RecoveryPlatform[]; + subjectResourceId?: string; } export interface RecoveryRollupsResponse { diff --git a/frontend-modern/src/utils/__tests__/recoveryPlatformModel.test.ts b/frontend-modern/src/utils/__tests__/recoveryPlatformModel.test.ts index 912e8fbbf..26514aa09 100644 --- a/frontend-modern/src/utils/__tests__/recoveryPlatformModel.test.ts +++ b/frontend-modern/src/utils/__tests__/recoveryPlatformModel.test.ts @@ -48,6 +48,7 @@ describe('recoveryPlatformModel', () => { kind: 'backup', mode: 'remote', outcome: 'success', + subjectResourceId: 'vm-123', display: { subjectLabel: 'Archive VM', subjectType: 'proxmox-vm', @@ -59,6 +60,7 @@ describe('recoveryPlatformModel', () => { kind: 'backup', mode: 'remote', outcome: 'success', + itemResourceId: 'vm-123', display: { itemLabel: 'Archive VM', itemType: 'proxmox-vm', @@ -69,6 +71,7 @@ describe('recoveryPlatformModel', () => { normalizeRecoveryRollup({ rollupId: 'rollup-1', lastOutcome: 'success', + subjectResourceId: 'vm-123', providers: ['proxmox-pbs', 'kubernetes'], display: { subjectLabel: 'Legacy Dataset', @@ -78,6 +81,7 @@ describe('recoveryPlatformModel', () => { ).toEqual({ rollupId: 'rollup-1', lastOutcome: 'success', + itemResourceId: 'vm-123', platforms: ['proxmox-pbs', 'kubernetes'], display: { itemLabel: 'Legacy Dataset', @@ -96,19 +100,21 @@ describe('recoveryPlatformModel', () => { kind: 'snapshot', mode: 'snapshot', outcome: 'success', + subjectResourceId: 'res-1', }, ], meta: { page: 1, limit: 100, total: 1, totalPages: 1 }, }), ).toEqual({ data: [ - { - id: 'point-1', - platform: 'truenas', - kind: 'snapshot', - mode: 'snapshot', - outcome: 'success', - }, + { + id: 'point-1', + platform: 'truenas', + kind: 'snapshot', + mode: 'snapshot', + outcome: 'success', + itemResourceId: 'res-1', + }, ], meta: { page: 1, limit: 100, total: 1, totalPages: 1 }, }); @@ -119,6 +125,7 @@ describe('recoveryPlatformModel', () => { { rollupId: 'rollup-1', lastOutcome: 'warning', + subjectResourceId: 'res-1', providers: ['truenas'], }, ], @@ -126,11 +133,12 @@ describe('recoveryPlatformModel', () => { }), ).toEqual({ data: [ - { - rollupId: 'rollup-1', - lastOutcome: 'warning', - platforms: ['truenas'], - }, + { + rollupId: 'rollup-1', + lastOutcome: 'warning', + itemResourceId: 'res-1', + platforms: ['truenas'], + }, ], meta: { page: 1, limit: 100, total: 1, totalPages: 1 }, }); diff --git a/frontend-modern/src/utils/__tests__/recoveryRecordPresentation.test.ts b/frontend-modern/src/utils/__tests__/recoveryRecordPresentation.test.ts index 3a09a51c6..04c4f3aa5 100644 --- a/frontend-modern/src/utils/__tests__/recoveryRecordPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/recoveryRecordPresentation.test.ts @@ -21,11 +21,11 @@ describe('recoveryRecordPresentation', () => { const rollup = { rollupId: 'rollup-1', - subjectResourceId: 'res-1', + itemResourceId: 'res-1', } as ProtectionRollup; const point = { id: 'point-1', - subjectResourceId: 'res-1', + itemResourceId: 'res-1', } as RecoveryPoint; expect(getRecoveryRollupItemLabel(rollup, resources)).toBe('db-01'); @@ -69,12 +69,12 @@ describe('recoveryRecordPresentation', () => { const rollup = { rollupId: 'res:res-3', - subjectResourceId: 'res-3', + itemResourceId: 'res-3', display: { itemLabel: 'Archive VM' }, } as ProtectionRollup; const linkedPoint = { id: 'point-3', - subjectResourceId: 'res-2', + itemResourceId: 'res-2', display: { itemLabel: 'billing-api' }, } as RecoveryPoint; diff --git a/frontend-modern/src/utils/recoveryPlatformModel.ts b/frontend-modern/src/utils/recoveryPlatformModel.ts index bb460fa45..b5c226fdc 100644 --- a/frontend-modern/src/utils/recoveryPlatformModel.ts +++ b/frontend-modern/src/utils/recoveryPlatformModel.ts @@ -25,6 +25,11 @@ interface RecoveryRollupPlatformsLike { providers?: string[] | null; } +interface RecoveryItemResourceLike { + itemResourceId?: string | null; + subjectResourceId?: string | null; +} + const normalizeRecoveryDisplay = ( display: RecoveryPointDisplay | RecoveryPointDisplayTransport | null | undefined, ): RecoveryPointDisplay | null | undefined => { @@ -48,6 +53,10 @@ const normalizeRecoveryDisplay = ( }; }; +const getRecoveryItemResourceId = ( + value: RecoveryItemResourceLike | null | undefined, +): string => toTrimmedString(value?.itemResourceId) || toTrimmedString(value?.subjectResourceId); + export const getRecoveryPointPlatform = ( point: RecoveryPointPlatformLike | null | undefined, ): string => toTrimmedString(point?.platform) || toTrimmedString(point?.provider); @@ -73,12 +82,19 @@ const normalizeRecoveryMeta = (meta: RecoveryResponseMeta | null | undefined): R export const normalizeRecoveryPoint = ( point: RecoveryPointTransport | RecoveryPoint, ): RecoveryPoint => { - const { provider: _provider, display, ...rest } = point as RecoveryPointTransport; + const { + provider: _provider, + subjectResourceId: _subjectResourceId, + display, + ...rest + } = point as RecoveryPointTransport; const platform = getRecoveryPointPlatform(point); + const itemResourceId = getRecoveryItemResourceId(point); const normalizedDisplay = normalizeRecoveryDisplay(display); return { ...(rest as RecoveryPoint), ...(platform ? { platform } : {}), + ...(itemResourceId ? { itemResourceId } : {}), ...(display !== undefined ? { display: normalizedDisplay } : {}), }; }; @@ -86,12 +102,19 @@ export const normalizeRecoveryPoint = ( export const normalizeRecoveryRollup = ( rollup: ProtectionRollupTransport | ProtectionRollup, ): ProtectionRollup => { - const { providers: _providers, display, ...rest } = rollup as ProtectionRollupTransport; + const { + providers: _providers, + subjectResourceId: _subjectResourceId, + display, + ...rest + } = rollup as ProtectionRollupTransport; const platforms = getRecoveryRollupPlatforms(rollup); + const itemResourceId = getRecoveryItemResourceId(rollup); const normalizedDisplay = normalizeRecoveryDisplay(display); return { ...(rest as ProtectionRollup), ...(platforms.length > 0 ? { platforms } : {}), + ...(itemResourceId ? { itemResourceId } : {}), ...(display !== undefined ? { display: normalizedDisplay } : {}), }; }; diff --git a/frontend-modern/src/utils/recoveryRecordPresentation.ts b/frontend-modern/src/utils/recoveryRecordPresentation.ts index 50d6858fc..bef0cd11a 100644 --- a/frontend-modern/src/utils/recoveryRecordPresentation.ts +++ b/frontend-modern/src/utils/recoveryRecordPresentation.ts @@ -24,7 +24,7 @@ export function getRecoveryRollupItemLabel( rollup: ProtectionRollup, resourcesById: Map, ): string { - const itemResourceId = (rollup.subjectResourceId || '').trim(); + const itemResourceId = (rollup.itemResourceId || '').trim(); const displayLabel = String(rollup.display?.itemLabel || '').trim(); const linkedResourceLabel = getRecoveryLinkedResourceLabel(itemResourceId, resourcesById); if (linkedResourceLabel) return linkedResourceLabel; @@ -47,7 +47,7 @@ export function getRecoveryPointItemLabel( point: RecoveryPoint, resourcesById: Map, ): string { - const itemResourceId = (point.subjectResourceId || '').trim(); + const itemResourceId = (point.itemResourceId || '').trim(); const displayLabel = String(point.display?.itemLabel || '').trim(); const linkedResourceLabel = getRecoveryLinkedResourceLabel(itemResourceId, resourcesById); if (linkedResourceLabel) return linkedResourceLabel; diff --git a/internal/api/recovery_handlers.go b/internal/api/recovery_handlers.go index 9caa96624..65caed3f1 100644 --- a/internal/api/recovery_handlers.go +++ b/internal/api/recovery_handlers.go @@ -63,6 +63,7 @@ type recoveryPointPayload struct { Encrypted *bool `json:"encrypted,omitempty"` Immutable *bool `json:"immutable,omitempty"` + ItemResourceID string `json:"itemResourceId,omitempty"` SubjectResourceID string `json:"subjectResourceId,omitempty"` RepositoryResourceID string `json:"repositoryResourceId,omitempty"` SubjectRef *recovery.ExternalRef `json:"subjectRef,omitempty"` @@ -73,6 +74,7 @@ type recoveryPointPayload struct { type recoveryRollupPayload struct { RollupID string `json:"rollupId"` + ItemResourceID string `json:"itemResourceId,omitempty"` SubjectResourceID string `json:"subjectResourceId,omitempty"` SubjectRef *recovery.ExternalRef `json:"subjectRef,omitempty"` Display *recovery.RecoveryPointDisplay `json:"display,omitempty"` @@ -97,6 +99,7 @@ func buildRecoveryPointPayload(point recovery.RecoveryPoint) recoveryPointPayloa Verified: point.Verified, Encrypted: point.Encrypted, Immutable: point.Immutable, + ItemResourceID: point.SubjectResourceID, SubjectResourceID: point.SubjectResourceID, RepositoryResourceID: point.RepositoryResourceID, SubjectRef: point.SubjectRef, @@ -109,6 +112,7 @@ func buildRecoveryPointPayload(point recovery.RecoveryPoint) recoveryPointPayloa func buildRecoveryRollupPayload(rollup recovery.ProtectionRollup) recoveryRollupPayload { return recoveryRollupPayload{ RollupID: rollup.RollupID, + ItemResourceID: rollup.SubjectResourceID, SubjectResourceID: rollup.SubjectResourceID, SubjectRef: rollup.SubjectRef, Display: rollup.Display, @@ -160,6 +164,13 @@ func parseRecoveryPlatformQuery(qs url.Values) recovery.Provider { ))) } +func parseRecoveryItemResourceIDQuery(qs url.Values) string { + return strings.TrimSpace(firstNonEmpty( + qs.Get("itemResourceId"), + qs.Get("subjectResourceId"), + )) +} + func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -190,7 +201,7 @@ func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Reque Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), - SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), + SubjectResourceID: parseRecoveryItemResourceIDQuery(qs), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, To: to, @@ -307,7 +318,7 @@ func (h *RecoveryHandlers) HandleListSeries(w http.ResponseWriter, r *http.Reque Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), - SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), + SubjectResourceID: parseRecoveryItemResourceIDQuery(qs), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, To: to, @@ -373,7 +384,7 @@ func (h *RecoveryHandlers) HandleListFacets(w http.ResponseWriter, r *http.Reque Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), - SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), + SubjectResourceID: parseRecoveryItemResourceIDQuery(qs), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, To: to, @@ -635,7 +646,7 @@ func (h *RecoveryHandlers) HandleListRollups(w http.ResponseWriter, r *http.Requ Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), - SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), + SubjectResourceID: parseRecoveryItemResourceIDQuery(qs), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, To: to, diff --git a/internal/api/recovery_handlers_test.go b/internal/api/recovery_handlers_test.go index de1991e2b..33d812e66 100644 --- a/internal/api/recovery_handlers_test.go +++ b/internal/api/recovery_handlers_test.go @@ -52,6 +52,47 @@ func TestParseRecoveryPlatformQuery(t *testing.T) { } } +func TestParseRecoveryItemResourceIDQuery(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + qs url.Values + want string + }{ + { + name: "prefers canonical item resource query", + qs: url.Values{ + "itemResourceId": []string{" vm-123 "}, + "subjectResourceId": []string{"legacy-vm"}, + }, + want: "vm-123", + }, + { + name: "falls back to legacy subject resource query", + qs: url.Values{ + "subjectResourceId": []string{" vm-404 "}, + }, + want: "vm-404", + }, + { + name: "returns empty when neither is present", + qs: url.Values{}, + want: "", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := parseRecoveryItemResourceIDQuery(tc.qs); got != tc.want { + t.Fatalf("parseRecoveryItemResourceIDQuery() = %q, want %q", got, tc.want) + } + }) + } +} + func TestHandleListPointsAcceptsCanonicalPlatformQuery(t *testing.T) { prevMock := mock.IsMockEnabled() mock.SetEnabled(true) @@ -90,6 +131,24 @@ func TestHandleListPointsAcceptsCanonicalPlatformQuery(t *testing.T) { } } +func TestBuildRecoveryPointPayloadExposesCanonicalItemResourceIDField(t *testing.T) { + payload := buildRecoveryPointPayload(recovery.RecoveryPoint{ + ID: "point-1", + Provider: recovery.Provider("truenas"), + Kind: recovery.Kind("snapshot"), + Mode: recovery.Mode("snapshot"), + Outcome: recovery.Outcome("success"), + SubjectResourceID: "vm-123", + }) + + if payload.ItemResourceID != "vm-123" { + t.Fatalf("payload.ItemResourceID = %q, want %q", payload.ItemResourceID, "vm-123") + } + if payload.SubjectResourceID != "vm-123" { + t.Fatalf("payload.SubjectResourceID = %q, want %q", payload.SubjectResourceID, "vm-123") + } +} + func TestHandleListRollupsExposeCanonicalPlatformsPayload(t *testing.T) { prevMock := mock.IsMockEnabled() mock.SetEnabled(true) @@ -127,3 +186,18 @@ func TestHandleListRollupsExposeCanonicalPlatformsPayload(t *testing.T) { } } } + +func TestBuildRecoveryRollupPayloadExposesCanonicalItemResourceIDField(t *testing.T) { + payload := buildRecoveryRollupPayload(recovery.ProtectionRollup{ + RollupID: "res:vm-123", + SubjectResourceID: "vm-123", + LastOutcome: recovery.Outcome("success"), + }) + + if payload.ItemResourceID != "vm-123" { + t.Fatalf("payload.ItemResourceID = %q, want %q", payload.ItemResourceID, "vm-123") + } + if payload.SubjectResourceID != "vm-123" { + t.Fatalf("payload.SubjectResourceID = %q, want %q", payload.SubjectResourceID, "vm-123") + } +}