@fjall/deploy-core 2.25.0 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/dist/.minified +1 -1
  2. package/dist/src/aws/index.d.ts +1 -1
  3. package/dist/src/aws/index.js +1 -1
  4. package/dist/src/aws/organisations/backup.d.ts +7 -2
  5. package/dist/src/aws/organisations/backup.js +2 -2
  6. package/dist/src/aws/organisations/index.d.ts +2 -2
  7. package/dist/src/aws/organisations/index.js +1 -1
  8. package/dist/src/aws/organisations/oidcProvider.d.ts +37 -0
  9. package/dist/src/aws/organisations/oidcProvider.js +1 -1
  10. package/dist/src/aws/organisations/organisation.d.ts +21 -0
  11. package/dist/src/aws/organisations/organisation.js +1 -1
  12. package/dist/src/aws/organisations/organisationalUnits.d.ts +15 -0
  13. package/dist/src/aws/organisations/organisationalUnits.js +1 -1
  14. package/dist/src/aws/utils/cloudformationEventHelpers.d.ts +45 -0
  15. package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -1
  16. package/dist/src/aws/utils/cloudformationEventTypes.d.ts +1 -1
  17. package/dist/src/aws/utils/cloudformationEvents.d.ts +2 -1
  18. package/dist/src/aws/utils/cloudformationEvents.js +1 -1
  19. package/dist/src/aws/utils/stackStatus.js +1 -1
  20. package/dist/src/index.d.ts +13 -2
  21. package/dist/src/index.js +1 -1
  22. package/dist/src/orchestration/activeDeploymentGuard.d.ts +41 -0
  23. package/dist/src/orchestration/activeDeploymentGuard.js +8 -4
  24. package/dist/src/orchestration/application/applicationDeploy.d.ts +9 -0
  25. package/dist/src/orchestration/application/applicationDeploy.js +1 -1
  26. package/dist/src/orchestration/application/applicationDeployHelpers.d.ts +20 -3
  27. package/dist/src/orchestration/application/applicationDeployHelpers.js +3 -3
  28. package/dist/src/orchestration/application/applicationDestroy.js +1 -1
  29. package/dist/src/orchestration/application/approvalGate.d.ts +15 -1
  30. package/dist/src/orchestration/application/approvalGate.js +1 -1
  31. package/dist/src/orchestration/application/databaseEndpointReconcile.d.ts +77 -0
  32. package/dist/src/orchestration/application/databaseEndpointReconcile.js +1 -0
  33. package/dist/src/orchestration/application/deploySessionPolicy.d.ts +9 -1
  34. package/dist/src/orchestration/application/deploySessionPolicy.js +1 -1
  35. package/dist/src/orchestration/application/destructionGate.d.ts +164 -0
  36. package/dist/src/orchestration/application/destructionGate.js +5 -0
  37. package/dist/src/orchestration/application/dockerBuildHelper.js +1 -1
  38. package/dist/src/orchestration/application/ecrCacheRepository.d.ts +14 -0
  39. package/dist/src/orchestration/application/ecrCacheRepository.js +1 -0
  40. package/dist/src/orchestration/application/leaseGate.d.ts +30 -0
  41. package/dist/src/orchestration/application/leaseGate.js +1 -0
  42. package/dist/src/orchestration/application/plan/approvalToken.d.ts +15 -0
  43. package/dist/src/orchestration/application/plan/approvalToken.js +1 -1
  44. package/dist/src/orchestration/application/plan/changeSetEscalation.d.ts +46 -0
  45. package/dist/src/orchestration/application/plan/changeSetEscalation.js +1 -0
  46. package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +13 -0
  47. package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -1
  48. package/dist/src/orchestration/application/plan/index.d.ts +4 -1
  49. package/dist/src/orchestration/application/plan/index.js +1 -1
  50. package/dist/src/orchestration/application/plan/loadDeployPlan.d.ts +32 -0
  51. package/dist/src/orchestration/application/plan/loadDeployPlan.js +1 -0
  52. package/dist/src/orchestration/application/plan/pinnedNames.d.ts +31 -0
  53. package/dist/src/orchestration/application/plan/pinnedNames.js +1 -0
  54. package/dist/src/orchestration/application/plan/registryOverlay.d.ts +83 -0
  55. package/dist/src/orchestration/application/plan/registryOverlay.js +1 -0
  56. package/dist/src/orchestration/application/plan/renderDeployPlan.d.ts +4 -1
  57. package/dist/src/orchestration/application/plan/renderDeployPlan.js +1 -1
  58. package/dist/src/orchestration/application/plan/types.d.ts +31 -0
  59. package/dist/src/orchestration/application/plan/types.js +1 -0
  60. package/dist/src/orchestration/contextHelpers.d.ts +37 -1
  61. package/dist/src/orchestration/contextHelpers.js +1 -1
  62. package/dist/src/orchestration/drift/classifyDriftFailure.d.ts +31 -0
  63. package/dist/src/orchestration/drift/classifyDriftFailure.js +1 -0
  64. package/dist/src/orchestration/drift/detectStackDrift.d.ts +37 -0
  65. package/dist/src/orchestration/drift/detectStackDrift.js +1 -0
  66. package/dist/src/orchestration/drift/driftPreFlight.d.ts +73 -0
  67. package/dist/src/orchestration/drift/driftPreFlight.js +7 -0
  68. package/dist/src/orchestration/drift/driftProbe.d.ts +113 -0
  69. package/dist/src/orchestration/drift/driftProbe.js +1 -0
  70. package/dist/src/orchestration/drift/driftSuspectJournal.d.ts +40 -0
  71. package/dist/src/orchestration/drift/driftSuspectJournal.js +1 -0
  72. package/dist/src/orchestration/drift/preFlightGate.d.ts +44 -0
  73. package/dist/src/orchestration/drift/preFlightGate.js +2 -0
  74. package/dist/src/orchestration/index.d.ts +19 -0
  75. package/dist/src/orchestration/index.js +1 -1
  76. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.d.ts +7 -0
  77. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.js +1 -1
  78. package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
  79. package/dist/src/orchestration/organisation/organisationDestroy.js +3 -3
  80. package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +8 -1
  81. package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -1
  82. package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -5
  83. package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +28 -2
  84. package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -1
  85. package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
  86. package/dist/src/orchestration/remediation/driftRepairGate.d.ts +66 -0
  87. package/dist/src/orchestration/remediation/driftRepairGate.js +1 -0
  88. package/dist/src/orchestration/remediation/forensicsCapture.d.ts +49 -0
  89. package/dist/src/orchestration/remediation/forensicsCapture.js +1 -0
  90. package/dist/src/orchestration/remediation/forgetResource.d.ts +423 -0
  91. package/dist/src/orchestration/remediation/forgetResource.js +1 -0
  92. package/dist/src/orchestration/remediation/pinRemediation.d.ts +121 -0
  93. package/dist/src/orchestration/remediation/pinRemediation.js +1 -0
  94. package/dist/src/orchestration/remediation/postFailureRepairOffer.d.ts +77 -0
  95. package/dist/src/orchestration/remediation/postFailureRepairOffer.js +14 -0
  96. package/dist/src/orchestration/remediation/recreatePreFlight.d.ts +47 -0
  97. package/dist/src/orchestration/remediation/recreatePreFlight.js +1 -0
  98. package/dist/src/orchestration/remediation/recreateResource.d.ts +110 -0
  99. package/dist/src/orchestration/remediation/recreateResource.js +1 -0
  100. package/dist/src/orchestration/remediation/remediationJournal.d.ts +88 -0
  101. package/dist/src/orchestration/remediation/remediationJournal.js +2 -0
  102. package/dist/src/orchestration/remediation/removalUpdate.d.ts +135 -0
  103. package/dist/src/orchestration/remediation/removalUpdate.js +1 -0
  104. package/dist/src/orchestration/remediation/retainFlip.d.ts +294 -0
  105. package/dist/src/orchestration/remediation/retainFlip.js +1 -0
  106. package/dist/src/services/application/ApplicationStackService.d.ts +5 -3
  107. package/dist/src/services/application/ApplicationStackService.js +1 -1
  108. package/dist/src/services/application/applicationStackHelpers.d.ts +8 -6
  109. package/dist/src/services/application/applicationStackHelpers.js +3 -3
  110. package/dist/src/services/index.d.ts +2 -2
  111. package/dist/src/services/index.js +1 -1
  112. package/dist/src/services/infrastructure/CdkArgumentBuilder.js +1 -1
  113. package/dist/src/services/infrastructure/CdkEventMonitoring.d.ts +15 -0
  114. package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -1
  115. package/dist/src/services/infrastructure/CdkOutputAnalyser.d.ts +8 -0
  116. package/dist/src/services/infrastructure/CdkOutputAnalyser.js +2 -1
  117. package/dist/src/services/infrastructure/CdkService.js +3 -3
  118. package/dist/src/services/infrastructure/CdkServiceTypes.d.ts +7 -0
  119. package/dist/src/services/infrastructure/CfnRegistryService.d.ts +68 -0
  120. package/dist/src/services/infrastructure/CfnRegistryService.js +1 -0
  121. package/dist/src/services/infrastructure/CloudFormationService.d.ts +5 -2
  122. package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
  123. package/dist/src/services/infrastructure/EcsServiceResolver.d.ts +23 -3
  124. package/dist/src/services/infrastructure/EcsServiceResolver.js +1 -1
  125. package/dist/src/services/infrastructure/cdkServiceHelpers.js +1 -1
  126. package/dist/src/services/infrastructure/cfnRegistrySeed.d.ts +10 -0
  127. package/dist/src/services/infrastructure/cfnRegistrySeed.js +1 -0
  128. package/dist/src/services/infrastructure/changeSetProbe.d.ts +88 -0
  129. package/dist/src/services/infrastructure/changeSetProbe.js +1 -0
  130. package/dist/src/services/infrastructure/index.d.ts +2 -0
  131. package/dist/src/services/infrastructure/index.js +1 -1
  132. package/dist/src/services/supporting/CdkContextBuilder.d.ts +1 -0
  133. package/dist/src/services/supporting/CdkContextBuilder.js +1 -1
  134. package/dist/src/types/callbackKeys.d.ts +1 -1
  135. package/dist/src/types/callbackKeys.js +1 -1
  136. package/dist/src/types/callbacks.d.ts +22 -0
  137. package/dist/src/types/config/entitlements.d.ts +9 -0
  138. package/dist/src/types/config/orgConfig.d.ts +6 -0
  139. package/dist/src/types/deployment/DeploymentTypes.d.ts +1 -0
  140. package/dist/src/types/deploymentEventSchema.d.ts +1 -0
  141. package/dist/src/types/deploymentEventSchema.js +1 -1
  142. package/dist/src/types/destruction.d.ts +212 -0
  143. package/dist/src/types/destruction.js +1 -0
  144. package/dist/src/types/drift.d.ts +72 -0
  145. package/dist/src/types/drift.js +1 -0
  146. package/dist/src/types/index.d.ts +8 -2
  147. package/dist/src/types/index.js +1 -1
  148. package/dist/src/types/params.d.ts +161 -0
  149. package/dist/src/types/remediation.d.ts +126 -0
  150. package/dist/src/types/remediation.js +1 -0
  151. package/package.json +5 -4
@@ -0,0 +1,77 @@
1
+ /**
2
+ * In-session post-failure repair offer (drift-remediation plan § 8 Phase 6):
3
+ * when a stack deploy step fails, the engine re-probes that stack in the SAME
4
+ * session through the bounded [G2] pipeline (the failure analysis has just
5
+ * journalled any NotFound-classified suspects). A CONFIRMED out-of-band
6
+ * deletion upgrades the returned error to the typed
7
+ * `DriftRepairAvailableError` — the interactive surface defers on the class
8
+ * (the `BuildxUnavailableInteractive` shape) while the non-interactive
9
+ * surface reads the same offer from the message text.
10
+ *
11
+ * Best-effort by contract: any probe failure, abort, or empty verdict returns
12
+ * the ORIGINAL error untouched — the offer must never mask the deploy's true
13
+ * root cause.
14
+ *
15
+ * Messages are pre-masked at construction: the original analysed error is
16
+ * masked at the monitor boundary, and the appended offer lines are
17
+ * identifier-only (stack, logical ID, resource type, physical ID). The
18
+ * command teaches the flag SHAPE with a placeholder name, never a filled
19
+ * consent [G5].
20
+ */
21
+ import type { AwsProvider } from "../../aws/AwsProvider.js";
22
+ import type { CloudFormationService } from "../../services/infrastructure/CloudFormationService.js";
23
+ import type { DriftFinding } from "../../types/drift.js";
24
+ import type { DriftJournalOptions } from "../drift/driftSuspectJournal.js";
25
+ import { type RemediationJournalOptions } from "./remediationJournal.js";
26
+ export declare class DriftRepairAvailableError extends Error {
27
+ readonly findings: readonly DriftFinding[];
28
+ constructor(message: string, findings: readonly DriftFinding[]);
29
+ }
30
+ /** Identifier-only offer block appended beneath the analysed deploy error. */
31
+ export declare function formatDriftRepairOffer(findings: readonly DriftFinding[], appName?: string, repairTarget?: string): string;
32
+ /** One confirmed finding covered by an in-flight recreate journal [G1]. */
33
+ export interface RecreateCoveredFinding {
34
+ finding: DriftFinding;
35
+ opId: string;
36
+ appName?: string;
37
+ }
38
+ /**
39
+ * [G1] Identifier-only notice for confirmed deletions COVERED by an in-flight
40
+ * recreate operation. The `=forget` offer must never be taught for these —
41
+ * the forget arm's verb guard fails closed on the recreate journal, whose
42
+ * teach names the deploy, whose failure re-offers `=forget`: a circular teach
43
+ * loop. The two-cure block is the only terminating exit.
44
+ */
45
+ export declare function formatRecreateCoveredNotice(covered: readonly RecreateCoveredFinding[], fallbackAppName?: string): string;
46
+ export interface ComposePostFailureRepairOfferParams {
47
+ /** The stack(s) whose deploy step just failed. */
48
+ stackNames: readonly string[];
49
+ /** Names the repair command's target in the offer. */
50
+ appName?: string;
51
+ /**
52
+ * Overrides the repair command's target fragment in the offer (e.g.
53
+ * "--stack Account" for a single-component deploy, whose stack is not
54
+ * addressable by app name). `appName` still names the recreate cures —
55
+ * those teach 'fjall deploy', where a flag fragment would be wrong.
56
+ */
57
+ repairTarget?: string;
58
+ originalError: Error;
59
+ cfnService: Pick<CloudFormationService, "getStackStatus">;
60
+ journal?: DriftJournalOptions;
61
+ /** Remediation-journal root override — tests point this at a temp dir. */
62
+ remediationJournal?: RemediationJournalOptions;
63
+ abortSignal?: AbortSignal;
64
+ }
65
+ /**
66
+ * Upgrade a failed deploy's error with the in-session repair offer when the
67
+ * post-failure probe CONFIRMS an out-of-band deletion on the failed stack.
68
+ * Returns the original error on every other outcome.
69
+ *
70
+ * [G1] Findings covered by an in-flight RECREATE journal never receive the
71
+ * `=forget` offer (circular teach — see `formatRecreateCoveredNotice`): a
72
+ * covered-only verdict returns a PLAIN Error carrying the two-cure notice
73
+ * (never `DriftRepairAvailableError`, whose interactive drift-repair offer
74
+ * would walk the same loop), and a mixed verdict offers `=forget` for the
75
+ * uncovered findings only, with the covered notice appended.
76
+ */
77
+ export declare function composePostFailureRepairOffer(aws: AwsProvider, params: ComposePostFailureRepairOfferParams): Promise<Error>;
@@ -0,0 +1,14 @@
1
+ import{getErrorMessage as m,maskSensitiveOutput as g}from"@fjall/util";import{logger as h}from"@fjall/util/logger";import{DriftProbe as $}from"../drift/driftProbe.js";import{runDriftPreFlight as N}from"../drift/driftPreFlight.js";import{formatRecreateInFlightCures as v}from"./forgetResource.js";import{listActiveRemediationOps as y}from"./remediationJournal.js";const I="PostFailureRepairOffer";class b extends Error{findings;constructor(e,r){super(e),this.name="DriftRepairAvailableError",this.findings=r}}function k(o,e,r){const i=o.map(n=>` ${n.stackName} \u203A ${n.logicalId} (${n.resourceType})${n.physicalId!==void 0?` [${n.physicalId}]`:""} \u2014 ${n.verdictDetail}`),t=r!==void 0&&r!==""?r:e!==void 0&&e!==""?e:"<target>";return`Out-of-band deletion CONFIRMED \u2014 CloudFormation still tracks ${o.length} deleted resource(s):
2
+ ${i.join(`
3
+ `)}
4
+ Repair in this session: fjall drift repair ${t} --remediate <physicalName>=forget
5
+ (names are exact and case-sensitive; 'fjall drift detect' shows the full report)`}function u(o,e){const r=o.map(({finding:t,opId:n})=>` ${t.stackName} \u203A ${t.logicalId} (${t.resourceType})${t.physicalId!==void 0?` [${t.physicalId}]`:""} \u2014 covered by in-flight recreate remediation ${n}`),i=o.find(t=>t.appName!==void 0)?.appName??e;return`${o.length} deleted resource(s) are already covered by an in-flight recreate remediation:
6
+ ${r.join(`
7
+ `)}
8
+ Do not use '--remediate <physicalName>=forget' for these \u2014 the forget arm fails closed on the in-flight recreate journal. ${v(i)}`}async function F(o,e){try{const r=await N({aws:o,probe:new $(o),...e.journal!==void 0?{journal:e.journal}:{}},{stackNames:e.stackNames,cfnService:e.cfnService,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(!r.success||r.data.confirmed.length===0)return e.originalError;const{covered:i,uncovered:t}=await R(o,r.data.confirmed,e.remediationJournal);if(t.length===0)return new Error(`${e.originalError.message}
9
+
10
+ ${u(i,e.appName)}`);const n=i.length>0?`
11
+
12
+ ${u(i,e.appName)}`:"";return new b(`${e.originalError.message}
13
+
14
+ ${k(t,e.appName,e.repairTarget)}${n}`,t)}catch(r){return h.debug(I,"Post-failure repair probe threw \u2014 offer skipped",{stackNames:[...e.stackNames],error:g(m(r))}),e.originalError}}async function R(o,e,r){const i=o.getAccountId(),t=o.getRegion(),n=new Map,l=[],d=[];for(const a of e){let c=n.get(a.stackName);c===void 0&&(c=await y(i,t,a.stackName,r),n.set(a.stackName,c));const s=c.find(f=>f.verb==="recreate"&&f.targets.some(p=>p.logicalId===a.logicalId));s!==void 0?l.push({finding:a,opId:s.opId,...s.appName!==void 0?{appName:s.appName}:{}}):d.push(a)}return{covered:l,uncovered:d}}export{b as DriftRepairAvailableError,F as composePostFailureRepairOffer,k as formatDriftRepairOffer,u as formatRecreateCoveredNotice};
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The recreate pre-flight — ONE pure-read pass over EVERY consented stack
3
+ * BEFORE any recreate surgery mutates anything (drift-remediation plan §
4
+ * Phase 7 fix pass 1). The surgery's removal phase can only fail EXPENSIVELY
5
+ * once the snapshot flip has landed (a burned UpdateStack, an orphaned
6
+ * mid-surgery journal), so every check that can be answered from reads runs
7
+ * here, pre-capture, across the whole consented set:
8
+ *
9
+ * 1. reference scan — `deriveRemovalTemplate` over the live template bytes:
10
+ * a remaining resource referencing a target fails closed with the paths;
11
+ * 2. export-consumer scan — every Output the removal would strip that
12
+ * carries an `Export` is checked via cfn:ListImports: CloudFormation
13
+ * refuses to drop an export while imports exist, so an imported export
14
+ * fails closed naming the importing stacks;
15
+ * 3. delete-blocking probe — targets whose owning service exposes a
16
+ * delete-refusal setting (RDS DeletionProtection — fjall's own database
17
+ * constructs default it ON) are probed live: an enabled setting fails
18
+ * closed pre-capture, because CloudFormation's delete would DELETE_FAILED
19
+ * mid-surgery after the flip already mutated the stack.
20
+ *
21
+ * All-stacks-before-any-flip: a multi-stack consent must never delete stack
22
+ * A's resource and then discover stack B was doomed — the caller passes every
23
+ * consented stack and nothing runs until all pass.
24
+ *
25
+ * Failure messages are pre-masked at construction (identifier-only content
26
+ * plus masked SDK error text).
27
+ */
28
+ import { type Result } from "@fjall/generator";
29
+ import type { AwsProvider } from "../../aws/AwsProvider.js";
30
+ import { type RemediationTarget } from "../../types/remediation.js";
31
+ export interface RecreatePreFlightStack {
32
+ stackName: string;
33
+ targets: readonly RemediationTarget[];
34
+ }
35
+ export interface RunRecreatePreFlightParams {
36
+ /** EVERY consented stack — the pass must clear all of them before the
37
+ * caller flips any. */
38
+ stacks: readonly RecreatePreFlightStack[];
39
+ abortSignal?: AbortSignal;
40
+ }
41
+ /**
42
+ * Run the merged pre-flight over every consented stack. Pure reads only —
43
+ * the first failure aborts the whole consented set before any surgery runs.
44
+ * Safe on resume paths too: targets already absent from the live template
45
+ * (removal landed) skip their checks.
46
+ */
47
+ export declare function runRecreatePreFlight(aws: AwsProvider, params: RunRecreatePreFlightParams): Promise<Result<void, Error>>;
@@ -0,0 +1 @@
1
+ import{CloudFormationClient as y,ListImportsCommand as D}from"@aws-sdk/client-cloudformation";import{DescribeDBClustersCommand as $,DescribeDBInstancesCommand as E,RDSClient as h}from"@aws-sdk/client-rds";import{success as l,failure as i}from"@fjall/generator";import{getErrorMessage as m,maskSensitiveOutput as p}from"@fjall/util";import{logger as I}from"@fjall/util/logger";import{composeSdkAbortSignal as f,isAborted as g}from"../../aws/organisations/types.js";import{isRemediationPlaceholderPhysicalId as N}from"../../types/remediation.js";import{fetchLiveStackState as C}from"./forgetResource.js";import{deriveRemovalTemplate as S}from"./removalUpdate.js";const k="RecreatePreFlight",R=/is not imported by any stack/i;function b(t){const o=typeof t=="object"&&t!==null&&"name"in t?String(t.name):"";return/NotFound/i.test(o)}async function x(t,o,r){try{const n=await t.getClient(h).send(new E({DBInstanceIdentifier:o}),{abortSignal:f(r)});return l({blocked:n.DBInstances?.[0]?.DeletionProtection===!0,setting:"DeletionProtection"})}catch(n){return b(n)?l({blocked:!1,setting:"DeletionProtection"}):i(new Error(p(m(n))))}}async function P(t,o,r){try{const n=await t.getClient(h).send(new $({DBClusterIdentifier:o}),{abortSignal:f(r)});return l({blocked:n.DBClusters?.[0]?.DeletionProtection===!0,setting:"DeletionProtection"})}catch(n){return b(n)?l({blocked:!1,setting:"DeletionProtection"}):i(new Error(p(m(n))))}}const v={"AWS::RDS::DBInstance":{probe:x,method:"rds:DescribeDBInstances",disableCommand:t=>`aws rds modify-db-instance --db-instance-identifier ${t} --no-deletion-protection`},"AWS::RDS::DBCluster":{probe:P,method:"rds:DescribeDBClusters",disableCommand:t=>`aws rds modify-db-cluster --db-cluster-identifier ${t} --no-deletion-protection`}};async function B(t,o,r){const n=t.getClient(y),d=[];let c;try{do{if(g(r))return i(new Error(`aborted while listing imports of export '${o}'`));const s=await n.send(new D({ExportName:o,...c!==void 0?{NextToken:c}:{}}),{abortSignal:f(r)});d.push(...s.Imports??[]),c=s.NextToken}while(c!==void 0);return l(d)}catch(s){return R.test(m(s))?l([]):i(new Error(p(m(s))))}}async function G(t,o){for(const{stackName:r,targets:n}of o.stacks){if(g(o.abortSignal))return i(new Error(`Recreate pre-flight aborted before ${r} was checked \u2014 nothing was mutated in this run.`));const d=n.map(e=>e.logicalId),c=await C(t,r,o.abortSignal);if(!c.success)return i(new Error(`${c.error.message} Nothing was mutated in this run.`));const s=S(c.data.templateBody,d,r);if(!s.success)return i(new Error(`${s.error.message} Nothing was mutated in this run.`));const w=new Set(s.data.alreadyAbsent);for(const e of s.data.strippedExports){if(e.exportName===void 0)return i(new Error(`Recreate pre-flight for ${r}: removing ${d.join(", ")} strips output '${e.outputName}' whose Export name is not a literal string, so its cross-stack consumers cannot be verified \u2014 perform the replacement manually. Nothing was mutated in this run.`));const a=await B(t,e.exportName,o.abortSignal);if(!a.success)return i(new Error(`Recreate pre-flight for ${r}: could not verify whether export '${e.exportName}' (output '${e.outputName}') is imported by other stacks: ${a.error.message}. Retry the deploy. Nothing was mutated in this run.`));if(a.data.length>0)return i(new Error(`Recreate pre-flight for ${r}: removing ${d.join(", ")} strips export '${e.exportName}' (output '${e.outputName}'), which is imported by ${a.data.join(", ")} \u2014 CloudFormation refuses to drop an export while imports exist, so the surgery would fail mid-deploy. Redeploy the importing stack(s) without the import first, or perform the replacement manually. Nothing was mutated in this run.`))}for(const e of n){if(w.has(e.logicalId))continue;const a=v[e.resourceType];if(a===void 0)continue;if(N(e,r)){I.debug(k,"No live physical id to probe \u2014 skipping",{stackName:r,logicalId:e.logicalId});continue}const u=await a.probe(t,e.physicalId,o.abortSignal);if(!u.success)return i(new Error(`Recreate pre-flight for ${r}: could not verify that ${e.physicalId} (${e.logicalId}) is deletable \u2014 ${a.method} failed: ${u.error.message}. Retry the deploy. Nothing was mutated in this run.`));if(u.data.blocked)return i(new Error(`Recreate pre-flight for ${r}: ${e.physicalId} (${e.logicalId}) has ${u.data.setting} enabled \u2014 CloudFormation cannot delete it, so the pinned-name replacement would fail mid-surgery. Disable it on the live resource (e.g. '${a.disableCommand(e.physicalId)}'), then re-run the deploy. Nothing was mutated in this run.`))}}return l(void 0)}export{G as runRecreatePreFlight};
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The `recreate` orchestrator — the snapshot-flip variant of the forget
3
+ * surgery (drift-remediation plan § Phase 7), for the pinned-name-replacement
4
+ * class: CloudFormation refuses to replace a custom-named resource in place
5
+ * ("cannot update a stack when a custom-named resource requires replacing"),
6
+ * so fjall performs the replacement surgically inside the SAME deploy run:
7
+ *
8
+ * captured → flip-verified (snapshot flip) → flip-applied →
9
+ * removal-applied (surgical UpdateStack deletes the physical resource,
10
+ * snapshotting per policy) → (the deploy continues and re-CREATEs the
11
+ * resource under its pinned name) → done (post-deploy sweep)
12
+ *
13
+ * Unlike forget, the removal phase IS here: the synthesised template still
14
+ * declares the resource, so the converge cannot remove it — a surgical
15
+ * live-template-minus-targets update (`removalUpdate.ts`) deletes it first,
16
+ * freeing the pinned name. Closure then flows through the SAME machinery as
17
+ * forget (`completeForgetAfterConverge` → `resolveConvergedTargets`, marker
18
+ * key derived from the journal's verb): a re-created target is
19
+ * present-unmarked and physically probed live. Absent targets at sweep
20
+ * closure were removed instead of re-created (the config no longer declares
21
+ * them) — correct to close, the deletion was consented.
22
+ *
23
+ * Consent is the CALLER's job (the destruction ceremony runs before 3f
24
+ * invokes this). Resumability rides the deploy path, not `drift resume`: at
25
+ * any phase before removal-applied the pinned-name finding still fires, the
26
+ * ceremony re-consents, and this surgery resumes idempotently; after
27
+ * removal-applied the plan shows a plain CREATE (no ticket) and the sweep
28
+ * closes the journal. `drift resume`/`drift repair` fail closed on recreate
29
+ * journals (verb guard in `forgetResource.ts`) teaching the deploy re-run.
30
+ *
31
+ * Cross-incarnation edge: a NEW pinned-name finding on the same stack +
32
+ * logical-ID set reuses the opId (derivation excludes the verb and has no
33
+ * incarnation component). A resume that finds the prior incarnation at
34
+ * removal-applied first attempts closure; a closed journal then archives and
35
+ * restarts fresh under `doneJournalPolicy: "archive-and-restart"` (the 3f
36
+ * caller's policy), so the new consent gets a new surgery.
37
+ *
38
+ * The `fjall:recreate` marker key is deliberately distinct from
39
+ * `fjall:forget`: the two surgeries' resume/ownership scans never adopt each
40
+ * other's markers. Overlapping forget + recreate operations on one resource
41
+ * are an operator error, guarded at the opId collision point by the journal
42
+ * verb checks.
43
+ *
44
+ * Failure messages are pre-masked at construction.
45
+ */
46
+ import { type Result } from "@fjall/generator";
47
+ import type { AwsProvider } from "../../aws/AwsProvider.js";
48
+ import type { DriftFinding } from "../../types/drift.js";
49
+ import { type RemediationJournalRecord, type RemediationPhase, type RemediationTarget } from "../../types/remediation.js";
50
+ import { type ForgetOrchestrationOptions } from "./forgetResource.js";
51
+ import { type FlipDeletionPolicy } from "./retainFlip.js";
52
+ /** Options shape shared with the forget orchestrator (coupled machinery). */
53
+ export type RecreateOrchestrationOptions = ForgetOrchestrationOptions;
54
+ /**
55
+ * CFN resource types that support `DeletionPolicy: Snapshot`
56
+ * (CloudFormation UserGuide § aws-attribute-deletionpolicy). Everything else
57
+ * flips to `Delete` — the data loss was consented at the ceremony; the
58
+ * snapshot is best-effort mitigation where the platform offers it.
59
+ */
60
+ export declare const SNAPSHOT_CAPABLE_RESOURCE_TYPES: ReadonlySet<string>;
61
+ export declare function snapshotPolicyForType(resourceType: string): Extract<FlipDeletionPolicy, "Snapshot" | "Delete">;
62
+ export interface RecreateSurgeryParams {
63
+ stackName: string;
64
+ targets: readonly RemediationTarget[];
65
+ /** The deploy target that owns the stack — names the resume command. */
66
+ appName?: string;
67
+ /** Evidence persisted in the forensics bundle. */
68
+ findings?: readonly DriftFinding[];
69
+ /** Audit trail: the exact physical names consented at the ceremony. */
70
+ consentedPhysicalNames?: readonly string[];
71
+ /** Mirror of the forget arm's policy — the 3f deploy caller passes
72
+ * "archive-and-restart" so a done journal from a prior incarnation of the
73
+ * same stack + logical-ID set never false-reports completion. */
74
+ doneJournalPolicy?: "report-completed" | "archive-and-restart";
75
+ /**
76
+ * [G7] Logical IDs the CURRENT deploy plan replaces under a pinned name.
77
+ * A removal-applied resume whose closure could NOT verify a target
78
+ * (converge-pending) must FAIL CLOSED when that target overlaps this set —
79
+ * a converge-pending success would fall through to the native pinned-name
80
+ * replacement CloudFormation refuses, burning the run. The 3f caller passes
81
+ * its consented rows' logical IDs (by construction the plan replaces every
82
+ * one of them); callers with no planned replacement may omit.
83
+ */
84
+ plannedReplacementLogicalIds?: readonly string[];
85
+ onProgress?: (message: string) => void;
86
+ abortSignal?: AbortSignal;
87
+ }
88
+ export interface RecreateSurgeryReport {
89
+ opId: string;
90
+ /** Journal phase after this pass. */
91
+ phase: RemediationPhase;
92
+ /** True until the deploy re-creates the targets and the post-deploy sweep
93
+ * closes the journal — the caller MUST continue the deploy on true. */
94
+ convergePending: boolean;
95
+ forensicsPath?: string;
96
+ /** The DeletionPolicy stamped per target logical ID. */
97
+ snapshotPolicies: Record<string, "Snapshot" | "Delete">;
98
+ journal: RemediationJournalRecord;
99
+ }
100
+ /**
101
+ * The orchestrated recreate: forensics capture (abort on failure — no
102
+ * capture, no mutate), snapshot-flip derivation + both proofs, flip apply,
103
+ * surgical removal (proof + apply + GetTemplate absence verification), then
104
+ * removal-applied. The report is convergePending — the SAME deploy run
105
+ * continues past the surgery and re-creates the resource under its pinned
106
+ * name; the post-deploy sweep closes the journal. Idempotent and resumable:
107
+ * safe to re-run at any phase (resumption rides the deploy path — the
108
+ * pinned-name finding re-fires and re-consents until the removal lands).
109
+ */
110
+ export declare function runRecreateSurgery(aws: AwsProvider, params: RecreateSurgeryParams, options?: RecreateOrchestrationOptions): Promise<Result<RecreateSurgeryReport, Error>>;
@@ -0,0 +1 @@
1
+ import{success as j,failure as w}from"@fjall/generator";import{completeForgetAfterConverge as z,fetchLiveStackState as B}from"./forgetResource.js";import{archiveCompletedRemediationJournal as Q,computeRemediationOpId as X,formatArchivedOperationNotice as Z,formatCompletedOperationNotice as ee,readRemediationJournal as te,writeRemediationJournal as ae}from"./remediationJournal.js";import{captureRemediationForensics as re}from"./forensicsCapture.js";import{applyRetainFlip as x,computeTemplateDigest as H,deriveRetainFlipTemplate as ne,findMarkedResourcesByOpId as oe,proveFlipMetadataOnly as ie,readRetainFlipState as I,verifyRetainFlipDiff as se,RECREATE_MARKER_KEY as A}from"./retainFlip.js";import{deriveRemovalTemplate as le,findRemovalDeletionFailures as ce,proveRemovalTargetsOnly as de}from"./removalUpdate.js";const pe=new Set(["AWS::DocDB::DBCluster","AWS::EC2::Volume","AWS::ElastiCache::CacheCluster","AWS::ElastiCache::ReplicationGroup","AWS::Neptune::DBCluster","AWS::RDS::DBCluster","AWS::RDS::DBInstance","AWS::Redshift::Cluster"]);function ue(i){return pe.has(i)?"Snapshot":"Delete"}const fe=30*6e4;function me(i){const o={};for(const s of i)o[s.logicalId]=ue(s.resourceType);return o}function U(i,o,s,e){const R=e.filter(n=>n!==s);if(R.length===0)return j(void 0);const p=R.map(n=>{const f=oe(i,n,o,A);return`${n} covers ${f.success&&f.data.length>0?f.data.join(", "):"(could not recover its target set)"}`});return w(new Error(`Recreate operation ${s} on ${o} found target resources already carrying a ${A} marker from a different operation \u2014 its journal may live on another machine, and completing this operation over it would strand that surgery. Marked resources recovered from the live template: ${p.join("; ")}. Complete the foreign operation first (re-run its converge deploy on the machine that holds its journal), then re-run this deploy. Nothing was mutated.`))}async function we(i,o,s){const{stackName:e,targets:R,onProgress:p,abortSignal:n}=o;if(R.length===0)return w(new Error("Recreate surgery requires at least one target"));const f=s?.now??(()=>new Date),O=i.getAccountId(),N=i.getRegion(),u=R.map(a=>a.logicalId),l=X(e,u),P=me(R),b={markerKey:A,policyForTarget:a=>P[a]??"Delete"},E=o.appName!==void 0?`Re-run 'fjall deploy ${o.appName}' to resume the recreate.`:"Re-run the deploy to resume the recreate.",F=a=>a.snapshotPolicies??P;let t=await te(O,N,e,l,s?.journal);if(t!==null&&t.verb!=="recreate"&&t.phase!=="done")return w(new Error(`Recreate operation ${l} on ${e} collides with an in-flight '${t.verb}' operation covering the same resources \u2014 complete it first with 'fjall drift resume ${l}', then re-run this deploy. Nothing was mutated.`));if(t!==null&&t.phase==="removal-applied"){const a=await z(i,{journal:t,...n!==void 0?{abortSignal:n}:{}},s);if(!a.success)return a;if(t=a.data.journal,a.data.status==="converge-pending"){const d=a.data.unverified,y=(o.plannedReplacementLogicalIds??[]).filter(c=>d.includes(c));if(y.length>0){const c=o.appName!==void 0?`fjall deploy ${o.appName}`:"fjall deploy";return w(new Error(`Recreate operation ${l} on ${e} already applied its removal, but the closure check could not verify ${y.join(", ")} (re-created, removed, or still orphaned) \u2014 and this deploy plans a pinned-name replacement for the same target(s), which CloudFormation would refuse while the name may still be held. Nothing further was mutated. Retry the deploy once AWS settles \u2014 a re-assert or converge still in flight clears on its own. If it persists, verify the resource state in the owning service; the terminating cure is to remove the resource(s) from the fjall config and re-run '${c}' \u2014 the converge clears the unverifiable declaration and the operation closes as removed \u2014 then re-add the configuration to re-create them under their pinned names.`))}return p?.(`Removal already applied on ${e} \u2014 the deploy re-creates any still-missing ${u.join(", ")} under the pinned names; already-present targets close once the post-deploy sweep verifies them`),j({opId:l,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})}}if(t!==null&&t.phase==="done"){if(o.doneJournalPolicy!=="archive-and-restart")return p?.(ee(t)),j({opId:l,phase:"done",convergePending:!1,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t});const a=await Q(t,s?.journal);if(!a.success)return a;p?.(Z(t)),t=null}const C=async a=>{const d=await ae(a,s?.journal);return d.success?j(a):w(d.error)};let m;if(t===null){const a=await B(i,e,n);if(!a.success)return a;const d=I(a.data.templateBody,u,e,b);if(!d.success)return d;const y=U(a.data.templateBody,e,l,d.data.markerOpIds);if(!y.success)return y;if(d.data.markerOpIds.includes(l)){p?.(`Recognised in-flight recreate markers on ${e} \u2014 resuming from the ${A} marker`);const c=f().toISOString(),$=await C({version:1,opId:l,verb:"recreate",accountId:O,region:N,stackName:e,...o.appName!==void 0?{appName:o.appName}:{},targets:[...R],phase:d.data.allFlipped?"flip-applied":"captured",createdAt:c,updatedAt:c,snapshotPolicies:P});if(!$.success)return $;t=$.data,m=a.data}else{p?.(`Capturing forensics for ${e} (${l})`);const c=await re(i,{stackName:e,opId:l,findings:o.findings??[],...n!==void 0?{abortSignal:n}:{}},s?.forensics);if(!c.success)return c;const $=f().toISOString(),D=await C({version:1,opId:l,verb:"recreate",accountId:O,region:N,stackName:e,...o.appName!==void 0?{appName:o.appName}:{},targets:[...R],phase:"captured",createdAt:$,updatedAt:$,forensicsPath:c.data.forensicsPath,snapshotPolicies:P,...o.consentedPhysicalNames!==void 0?{consentedPhysicalNames:[...o.consentedPhysicalNames]}:{}});if(!D.success)return D;t=D.data,m={templateBody:c.data.liveTemplateBody,capabilities:c.data.capabilities,parameterKeys:c.data.parameterKeys}}}if(m===void 0){const a=await B(i,e,n);if(!a.success)return a;m=a.data}let h=m.templateBody,v=I(h,u,e,b);if(!v.success)return v;const W=U(h,e,l,v.data.markerOpIds);if(!W.success)return W;const q=new Set(v.data.absentTargets),g=u.filter(a=>!q.has(a));if(g.length>0){if(v.data.unflippedTargets.filter(r=>g.includes(r)).length>0){p?.(`Deriving the snapshot flip for ${e}`);const r=ne({liveTemplateBody:h,stackName:e,targets:g,opId:l,at:f().toISOString(),spec:b});if(!r.success)return r;const T=se(h,r.data.flipTemplateBody,g,e,A);if(!T.success)return T;p?.("Proving the flip is metadata-only (review change set)");const S=await ie(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,targets:g,parameterKeys:m.parameterKeys,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!S.success)return S;const K=await C({...t,phase:"flip-verified",updatedAt:f().toISOString(),liveTemplateDigest:r.data.liveTemplateDigest,proof:{diffGuardPassed:!0,changeSetMetadataOnly:!0,noChanges:S.data.noChanges,provenAt:f().toISOString()}});if(!K.success)return K;t=K.data,p?.(`Applying the snapshot flip to ${e}`);const M=await x(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,capabilities:m.capabilities,parameterKeys:m.parameterKeys,derivedFromTemplateDigest:r.data.liveTemplateDigest,opId:l,operationLabel:"Snapshot-flip",resumeHint:E,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!M.success)return M;const _=await B(i,e,n);if(!_.success)return _;if(h=_.data.templateBody,v=I(h,u,e,b),!v.success)return v;const V=new Set(v.data.absentTargets),J=v.data.unflippedTargets.filter(Y=>!V.has(Y));if(J.length>0)return w(new Error(`Snapshot flip on ${e} reported ${M.data.outcome} but GetTemplate does not show the snapshot policy + the ${A} marker on ${J.join(", ")} \u2014 aborting to manual repair.`))}if(t.phase==="captured"||t.phase==="flip-verified"){const r=await C({...t,phase:"flip-applied",updatedAt:f().toISOString(),liveTemplateDigest:H(h)});if(!r.success)return r;t=r.data}p?.(`Deriving the surgical removal of ${g.join(", ")} from ${e}`);const d=le(h,u,e);if(!d.success)return w(new Error(`${d.error.message} The snapshot flip has already been applied on ${e}. ${E}`));p?.("Proving the removal touches only the recreate targets (review change set)");const y=await de(i,{stackName:e,removalTemplateBody:d.data.removalTemplateBody,targets:u,parameterKeys:m.parameterKeys,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!y.success)return y;if(!y.data.noChanges){const r=g.map(S=>`${S} \u2192 ${P[S]??"Delete"}`).join(", ");p?.(`Removing ${g.join(", ")} from ${e} (deletion policies: ${r})`);const T=await x(i,{stackName:e,flipTemplateBody:d.data.removalTemplateBody,capabilities:m.capabilities,parameterKeys:m.parameterKeys,derivedFromTemplateDigest:d.data.liveTemplateDigest,opId:l,operationLabel:"Recreate-removal",resumeHint:E,...n!==void 0?{abortSignal:n}:{}},{...s?.flip,updateTimeoutMs:s?.flip?.updateTimeoutMs??fe});if(!T.success)return T}const c=await B(i,e,n);if(!c.success)return c;h=c.data.templateBody;const $=I(h,u,e,b);if(!$.success)return $;const D=new Set($.data.absentTargets),L=u.filter(r=>!D.has(r));if(L.length>0)return w(new Error(`Recreate removal on ${e} completed but GetTemplate still shows ${L.join(", ")} \u2014 aborting to manual repair. ${E}`));if(!y.data.noChanges){const r=await ce(i,e,u,n);if(!r.success)return w(new Error(`Recreate removal on ${e} removed ${g.join(", ")} from the template, but the physical deletion outcome could not be verified: ${r.error.message}. ${E}`));if(r.data.length>0){const T=r.data.map(S=>`${S.logicalId}${S.statusReason!==void 0?` (${S.statusReason})`:""}`).join("; ");return w(new Error(`Recreate removal on ${e} removed ${g.join(", ")} from the template, but CloudFormation reported DELETE_FAILED for ${T} \u2014 the physical resource(s) still exist outside the stack and hold the pinned name(s), so the deploy's re-create would collide. Resolve the deletion failure and delete the orphaned resource(s) manually, then re-run the deploy to resume.`))}}const G=g.filter(r=>P[r]==="Snapshot");G.length>0&&p?.(`Final snapshots were requested for ${G.join(", ")} \u2014 locate them in the owning service's console to restore data into the re-created resources`)}const k=await C({...t,phase:"removal-applied",updatedAt:f().toISOString(),liveTemplateDigest:H(h)});return k.success?(t=k.data,p?.(`Surgical removal complete on ${e} \u2014 the deploy now re-creates ${u.join(", ")} under the pinned names`),j({opId:l,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})):k}export{pe as SNAPSHOT_CAPABLE_RESOURCE_TYPES,we as runRecreateSurgery,ue as snapshotPolicyForType};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Persistent remediation journal for the two-deploy `forget` orchestration
3
+ * (drift-remediation plan § 3.2). One JSON file per operation under
4
+ * `~/.fjall/state/remediation/<accountId>/<region>/<stackName>/<opId>.json`;
5
+ * writes are atomic (tmp + rename) against concurrent processes sharing the
6
+ * directory.
7
+ *
8
+ * Unlike the drift-suspect journal (a hint), journal WRITES here fail loudly
9
+ * — a phase transition that cannot persist aborts the orchestration rather
10
+ * than mutating without a resume record. READS stay fail-soft: a corrupt or
11
+ * schema-drifted file reads as `null` (warn-logged) because the AWS-side
12
+ * `fjall:forget` marker [G3] re-derives the state a lost journal would have
13
+ * carried.
14
+ *
15
+ * Completed records are the audit trail, retained 90 days
16
+ * (`sweepExpiredRemediationJournals`); the sweep also removes the record's
17
+ * forensics bundle — the done journal holds the only `forensicsPath` pointer,
18
+ * so a bundle outliving its journal would accumulate unreferenced forever.
19
+ */
20
+ import { type Result } from "@fjall/generator";
21
+ import { type RemediationJournalRecord } from "../../types/remediation.js";
22
+ export declare const REMEDIATION_JOURNAL_RETENTION_DAYS = 90;
23
+ export interface RemediationJournalOptions {
24
+ /** Override the journal root — tests point this at a temp dir. */
25
+ journalDir?: string;
26
+ }
27
+ export declare function defaultRemediationJournalDir(): string;
28
+ /**
29
+ * opId = digest of stack + logical-ID set (plan §3.2) — order-insensitive so
30
+ * re-detection reproduces the same id and a re-run resumes instead of forking
31
+ * a second operation. 16 hex chars keeps it path- and prompt-friendly.
32
+ */
33
+ export declare function computeRemediationOpId(stackName: string, logicalIds: readonly string[]): string;
34
+ /**
35
+ * [G5] Verb-accurate done-journal notices, shared by BOTH surgery orchestrators
36
+ * (`runForgetSurgery` and `runRecreateSurgery` each meet cross-verb DONE
37
+ * journals — coupled values, one home). Deriving from `journal.verb` keeps a
38
+ * done RECREATE journal read through the forget arm from reporting "Forget
39
+ * operation … already completed", and vice versa.
40
+ */
41
+ export declare function formatCompletedOperationNotice(journal: Pick<RemediationJournalRecord, "verb" | "opId">): string;
42
+ export declare function formatArchivedOperationNotice(journal: Pick<RemediationJournalRecord, "verb" | "opId">): string;
43
+ /**
44
+ * Persist the journal record (atomic tmp + rename). Fails loudly — callers
45
+ * abort the orchestration when the resume record cannot be written.
46
+ */
47
+ export declare function writeRemediationJournal(record: RemediationJournalRecord, options?: RemediationJournalOptions): Promise<Result<string, Error>>;
48
+ /**
49
+ * Read one operation's journal. Missing, corrupt, or schema-drifted files
50
+ * read as `null` (warn-logged) — the fjall:forget marker [G3] re-derives.
51
+ */
52
+ export declare function readRemediationJournal(accountId: string, region: string, stackName: string, opId: string, options?: RemediationJournalOptions): Promise<RemediationJournalRecord | null>;
53
+ /**
54
+ * Archive a COMPLETED journal out of the resume keyspace: renamed to
55
+ * `<opId>.done-<completedAt>.json` in place, it stops matching
56
+ * `findRemediationJournalByOpId` and the fresh-repair read while remaining a
57
+ * parseable done record — `listActiveRemediationOps` still excludes it and
58
+ * the retention sweep still ages it out. Used when a fresh repair meets a
59
+ * done journal from a prior incarnation of the same opId.
60
+ */
61
+ export declare function archiveCompletedRemediationJournal(record: RemediationJournalRecord, options?: RemediationJournalOptions): Promise<Result<string, Error>>;
62
+ export interface RemediationJournalContext {
63
+ accountId: string;
64
+ region: string;
65
+ }
66
+ /**
67
+ * Locate a journal by opId alone (`fjall drift resume <opId>` has no stack
68
+ * context). Scans the account/region/stack keyspace for `<opId>.json`. The
69
+ * opId has no account/region component, so the same operation replayed in two
70
+ * contexts (staging + prod) collides — with multiple matches the one under
71
+ * the current credentials' account/region wins; when none matches, the
72
+ * failure enumerates every match so the operator can switch context.
73
+ */
74
+ export declare function findRemediationJournalByOpId(opId: string, context?: RemediationJournalContext, options?: RemediationJournalOptions): Promise<Result<RemediationJournalRecord | null, Error>>;
75
+ /** All non-done operations for one stack — the [G2] pre-flight consumes. */
76
+ export declare function listActiveRemediationOps(accountId: string, region: string, stackName: string, options?: RemediationJournalOptions): Promise<RemediationJournalRecord[]>;
77
+ /**
78
+ * All non-done operations across EVERY stack in one account/region — the
79
+ * post-deploy closure sweep consumes (an open recreate journal can live on a
80
+ * stack the current deploy skipped, and that sweep is the recreate arm's
81
+ * sole closure point).
82
+ */
83
+ export declare function listActiveRemediationOpsForContext(accountId: string, region: string, options?: RemediationJournalOptions): Promise<RemediationJournalRecord[]>;
84
+ /**
85
+ * Best-effort sweep of completed journals past the 90-day audit retention.
86
+ * Never fails the caller.
87
+ */
88
+ export declare function sweepExpiredRemediationJournals(options?: RemediationJournalOptions, now?: () => Date): Promise<void>;
@@ -0,0 +1,2 @@
1
+ import{createHash as J}from"node:crypto";import{mkdir as b,readdir as j,readFile as $,rename as N,rm as x,rmdir as E,writeFile as S}from"node:fs/promises";import{homedir as T}from"node:os";import{basename as O,dirname as I,join as m}from"node:path";import{success as h,failure as g}from"@fjall/generator";import{getErrorMessage as f,maskSensitiveOutput as s}from"@fjall/util";import{logger as l}from"@fjall/util/logger";import{RemediationJournalRecordSchema as _}from"../../types/remediation.js";const d="RemediationJournal",k=90;function w(){return m(T(),".fjall","state","remediation")}function V(e,n){const r=J("sha256");r.update(e);for(const t of[...n].sort())r.update(`
2
+ `),r.update(t);return r.digest("hex").slice(0,16)}function q(e){return`${C(e.verb)} operation ${e.opId} already completed`}function Q(e){return`Archived completed ${e.verb} operation ${e.opId} from a previous incarnation \u2014 starting a fresh operation`}function C(e){return e.charAt(0).toUpperCase()+e.slice(1)}const D=/^[A-Za-z0-9][A-Za-z0-9-]*$/,F=/^[a-f0-9]{8,64}$/;function v(e,n,r,t,a){for(const[o,u,i]of[["accountId",n,D],["region",r,D],["stackName",t,D],["opId",a,F]])if(!i.test(u))return g(new Error(`Refusing remediation journal path: unsafe ${o} segment "${s(u)}"`));return h(m(e,n,r,t,`${a}.json`))}async function X(e,n){const r=n?.journalDir??w(),t=v(r,e.accountId,e.region,e.stackName,e.opId);if(!t.success)return t;try{await b(I(t.data),{recursive:!0});const a=`${t.data}.tmp-${process.pid}`;return await S(a,JSON.stringify(e,null,2),"utf8"),await N(a,t.data),h(t.data)}catch(a){return g(new Error(`Failed to write remediation journal for ${e.stackName}/${e.opId}: ${s(f(a))}`))}}async function P(e,n,r,t,a){const o=a?.journalDir??w(),u=v(o,e,n,r,t);if(!u.success)return null;let i;try{i=await $(u.data,"utf8")}catch{return null}return R(i,`${r}/${t}`)}function R(e,n){let r;try{r=JSON.parse(e)}catch(a){return l.warn(d,"Corrupt remediation journal ignored",{label:n,error:s(f(a))}),null}const t=_.safeParse(r);return t.success?t.data:(l.warn(d,"Schema-drifted remediation journal ignored",{label:n,error:s(t.error.message)}),null)}async function ee(e,n){const r=n?.journalDir??w(),t=v(r,e.accountId,e.region,e.stackName,e.opId);if(!t.success)return t;const a=(e.completedAt??e.updatedAt).replace(/[^0-9A-Za-z.-]/g,""),o=t.data.replace(/\.json$/,`.done-${a}.json`);try{return await N(t.data,o),h(o)}catch(u){return g(new Error(`Failed to archive the completed remediation journal for ${e.stackName}/${e.opId}: ${s(f(u))}`))}}async function te(e,n,r){if(!F.test(e))return h(null);const t=r?.journalDir??w();let a;try{a=await A(t,3)}catch{return h(null)}const o=[];for(const i of a.filter(c=>c.endsWith(`/${e}.json`))){let c;try{c=await $(i,"utf8")}catch{continue}const p=R(c,e);p!==null&&o.push(p)}if(o.length===0)return h(null);if(o.length===1)return h(o[0]??null);const u=o.find(i=>i.accountId===n?.accountId&&i.region===n?.region);return u!==void 0?h(u):g(new Error(`Remediation operation ${e} exists in more than one context: ${o.map(i=>`${i.accountId}/${i.region}`).join(", ")}${n!==void 0?` \u2014 none matches the current credentials (${n.accountId}/${n.region}). Switch to the matching account/region and re-run 'fjall drift resume ${e}'.`:"."}`))}async function M(e,n,r,t){const a=t?.journalDir??w(),o=m(a,e,n,r);if(!D.test(r))return[];let u;try{u=await j(o)}catch{return[]}const i=[];for(const c of u){if(!c.endsWith(".json"))continue;let p;try{p=await $(m(o,c),"utf8")}catch{continue}const y=R(p,`${r}/${c}`);y!==null&&y.phase!=="done"&&i.push(y)}return i}async function re(e,n,r){const t=r?.journalDir??w();let a;try{a=await j(m(t,e,n))}catch{return[]}const o=[];for(const u of a)o.push(...await M(e,n,u,r));return o}async function ne(e,n=()=>new Date){const r=e?.journalDir??w(),t=n().getTime()-k*24*60*60*1e3;let a;try{a=await A(r,3)}catch{return}for(const o of a){let u;try{u=await $(o,"utf8")}catch{continue}const i=R(u,o);if(i===null||i.phase!=="done")continue;const c=Date.parse(i.completedAt??i.updatedAt);if(Number.isNaN(c)||c>=t)continue;let p=!0;await x(o,{force:!0}).catch(y=>{p=!1,l.debug(d,"Failed to sweep expired remediation journal",{file:o,error:s(f(y))})}),!(!p||i.forensicsPath===void 0)&&await K(i.forensicsPath,i.opId)}}async function K(e,n){const r=I(e),t=O(e)===n;if(!F.test(n)||!t&&O(r)!==n){l.debug(d,"Kept forensics bundle \u2014 unexpected path shape",{forensicsPath:e});return}if(t){await z(e);return}let a=!0;await x(e,{recursive:!0,force:!0}).catch(o=>{a=!1,l.warn(d,"Failed to sweep the expired journal's forensics",{forensicsPath:e,error:s(f(o))})}),a&&await E(r).catch(o=>{l.debug(d,"Kept forensics opId directory",{opDir:r,error:s(f(o))})})}async function z(e){let n;try{n=await j(e,{withFileTypes:!0})}catch(t){l.warn(d,"Failed to sweep the expired journal's forensics",{forensicsPath:e,error:s(f(t))});return}let r=!1;for(const t of n){if(!t.isFile()){r=!0;continue}await x(m(e,t.name),{force:!0}).catch(a=>{r=!0,l.warn(d,"Failed to sweep the expired journal's forensics",{forensicsPath:m(e,t.name),error:s(f(a))})})}if(r){l.debug(d,"Kept flat forensics directory \u2014 nested bundles live",{forensicsPath:e});return}await E(e).catch(t=>{l.debug(d,"Kept forensics opId directory",{forensicsPath:e,error:s(f(t))})})}async function A(e,n){const r=await j(e,{withFileTypes:!0}),t=[];for(const a of r){const o=m(e,a.name);a.isFile()&&a.name.endsWith(".json")?t.push(o):a.isDirectory()&&n>0&&t.push(...await A(o,n-1).catch(()=>[]))}return t}export{k as REMEDIATION_JOURNAL_RETENTION_DAYS,ee as archiveCompletedRemediationJournal,V as computeRemediationOpId,w as defaultRemediationJournalDir,te as findRemediationJournalByOpId,Q as formatArchivedOperationNotice,q as formatCompletedOperationNotice,M as listActiveRemediationOps,re as listActiveRemediationOpsForContext,P as readRemediationJournal,ne as sweepExpiredRemediationJournals,X as writeRemediationJournal};
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The surgical removal update — the recreate orchestration's middle phase
3
+ * (drift-remediation plan § Phase 7): derive a removal template from the LIVE
4
+ * template (`GetTemplate`, never synth) with the target resources deleted, so
5
+ * CloudFormation deletes the physical resources under the snapshot-flip's
6
+ * DeletionPolicy (Snapshot where supported) and frees the pinned names for
7
+ * the converge deploy to re-create.
8
+ *
9
+ * Derivation is deliberately conservative:
10
+ * - target resources are removed; each remaining resource's `DependsOn`
11
+ * edges to a target are pruned (a dangling DependsOn fails validation);
12
+ * - Outputs whose subtree references a target (Ref / Fn::GetAtt / Fn::Sub)
13
+ * are removed (a dangling output reference fails validation);
14
+ * - a REMAINING RESOURCE that references a target fails the derivation
15
+ * closed with the referencing paths (paths only, never values) — silently
16
+ * deleting or rewriting a live sibling resource is never fjall's call.
17
+ *
18
+ * `proveRemovalTargetsOnly` mirrors the retain flip's review change set: a
19
+ * `fjall-preflight-` change set must report ONLY `Remove` actions on the
20
+ * target logical IDs before the apply may run. The apply itself reuses
21
+ * `applyRetainFlip` (template-agnostic UpdateStack + TOCTOU digest re-check +
22
+ * poll) under a "Recreate-removal" label.
23
+ *
24
+ * Failure messages are pre-masked at construction (all content is
25
+ * identifier-only: logical IDs, paths, stack names).
26
+ */
27
+ import { type Result } from "@fjall/generator";
28
+ import type { AwsProvider } from "../../aws/AwsProvider.js";
29
+ import { type RetainFlipOptions } from "./retainFlip.js";
30
+ /**
31
+ * The logical IDs of RESOURCES that reference any target via Ref, Fn::GetAtt
32
+ * or Fn::Sub (DependsOn excluded — it names an ordering edge, not a value
33
+ * CloudFormation must resolve). The forget arm's defect-#5 referrer guard:
34
+ * CloudFormation re-resolves a modified resource's Ref/GetAtt for its
35
+ * dependents at execution time, which fails for a DEAD target — so a flip
36
+ * UpdateStack on a policy-pending dead target with live referrers can never
37
+ * succeed and must fail closed before any mutation. Reuses the removal
38
+ * derivation's canonical reference traversal (single source of truth).
39
+ *
40
+ * Unlike the removal derivation (where targets vanish together, so
41
+ * target-to-target references are moot), the flip MODIFIES targets in place —
42
+ * a target referencing a modified dead sibling is re-evaluated exactly like
43
+ * an outside referrer (the live incident proved even DEAD referrers fail the
44
+ * re-resolution), so distinct targets referencing each other are reported.
45
+ *
46
+ * Scan covers the Resources section only. An Output referencing a modified
47
+ * dead target is NOT flagged: whether CloudFormation re-evaluates Outputs at
48
+ * execution time is unproven live, and Output-references-stateful-resource is
49
+ * so common in CDK synth that guarding on it would block legitimate flips; a
50
+ * missed referrer degrades to a clean rollback, never an unsafe mutation
51
+ * (residual recorded in the flipless ADR, 2026-07-12).
52
+ */
53
+ export declare function findTargetReferrers(liveTemplateBody: string, targets: readonly string[], stackName: string): Result<string[], Error>;
54
+ export interface DeadReferenceFromTarget {
55
+ /** The policy-pending target whose own properties carry the reference. */
56
+ pendingTarget: string;
57
+ /** The dead siblings it references (Ref / Fn::GetAtt / Fn::Sub). */
58
+ deadReferences: string[];
59
+ }
60
+ /**
61
+ * The policy-pending targets whose OWN properties reference another dead
62
+ * target. The defect-#5 guard's outbound direction: the second live incident
63
+ * (2026-07-12, KMS alias flip) proved CloudFormation also resolves a MODIFIED
64
+ * resource's own outbound Ref/GetAtt at execution time — even when the
65
+ * referenced resource is not itself modified — so a flip UpdateStack on a
66
+ * pending target referencing ANY dead sibling rolls back exactly like the
67
+ * inbound referrer case. `findTargetReferrers` cannot see this shape: it
68
+ * scans for references TO the pending set, and the doomed reference points
69
+ * FROM the pending target at a sibling outside that set.
70
+ *
71
+ * `deadTargets` is the caller's known-deleted set (for the forget arm: the
72
+ * op's targets minus any retained-by-redeclaration, which are proven
73
+ * physically present and safe to reference). Self-references are excluded to
74
+ * mirror the intra-set precedent, though CloudFormation rejects them anyway.
75
+ */
76
+ export declare function findDeadReferencesFromTargets(liveTemplateBody: string, pendingTargets: readonly string[], deadTargets: readonly string[], stackName: string): Result<DeadReferenceFromTarget[], Error>;
77
+ /** An Output the derivation stripped that carries an `Export` block — the
78
+ * pre-flight must prove no other stack imports it before any mutation. */
79
+ export interface StrippedExport {
80
+ outputName: string;
81
+ /** The literal export name; undefined when `Export.Name` is not a plain
82
+ * string (unresolvable locally — the pre-flight fails closed on it). */
83
+ exportName?: string;
84
+ }
85
+ export interface RemovalDerivation {
86
+ removalTemplateBody: string;
87
+ liveTemplateDigest: string;
88
+ /** Targets already absent from the live template (removal landed). */
89
+ alreadyAbsent: string[];
90
+ /** Stripped Outputs carrying an Export — cross-stack consumers of these
91
+ * make the removal update fail mid-deploy (CFN refuses to drop an export
92
+ * while imports exist); `runRecreatePreFlight` checks them via ListImports. */
93
+ strippedExports: StrippedExport[];
94
+ }
95
+ /**
96
+ * Derive the removal template: live template minus the target resources,
97
+ * with DependsOn edges pruned and target-referencing Outputs dropped. A
98
+ * remaining RESOURCE referencing a target fails closed with the paths.
99
+ */
100
+ export declare function deriveRemovalTemplate(liveTemplateBody: string, targets: readonly string[], stackName: string): Result<RemovalDerivation, Error>;
101
+ export interface RemovalDeletionFailure {
102
+ logicalId: string;
103
+ /** Masked at construction — a status reason can embed handler output. */
104
+ statusReason?: string;
105
+ }
106
+ /**
107
+ * GetTemplate absence alone cannot prove the physical deletions succeeded:
108
+ * CloudFormation deletes removed resources during the update-CLEANUP phase,
109
+ * and a DELETE_FAILED there leaves the stack UPDATE_COMPLETE with the target
110
+ * gone from the template but orphaned alive — still holding its pinned name,
111
+ * so the converge's re-create would collide. Scan the just-settled
112
+ * operation's events for DELETE_FAILED on the targets. Only meaningful
113
+ * immediately after this run's removal update (the scan reads the LAST
114
+ * operation's events).
115
+ */
116
+ export declare function findRemovalDeletionFailures(aws: AwsProvider, stackName: string, targets: readonly string[], abortSignal?: AbortSignal): Promise<Result<RemovalDeletionFailure[], Error>>;
117
+ export interface RemovalProofResult {
118
+ /** CFN reported the removal contains no changes — the caller MUST re-verify
119
+ * target absence via GetTemplate, never assume the removal landed. */
120
+ noChanges: boolean;
121
+ }
122
+ export interface ProveRemovalTargetsOnlyParams {
123
+ stackName: string;
124
+ removalTemplateBody: string;
125
+ targets: readonly string[];
126
+ /** Live parameter keys, echoed UsePreviousValue (see retainFlip). */
127
+ parameterKeys: readonly string[];
128
+ abortSignal?: AbortSignal;
129
+ }
130
+ /**
131
+ * Review-only `fjall-preflight-` change set proving every reported resource
132
+ * change is a `Remove` on a target logical ID. Nothing is executed; the
133
+ * change set is deleted in `finally`.
134
+ */
135
+ export declare function proveRemovalTargetsOnly(aws: AwsProvider, params: ProveRemovalTargetsOnlyParams, options?: RetainFlipOptions): Promise<Result<RemovalProofResult, Error>>;
@@ -0,0 +1 @@
1
+ import{randomUUID as I}from"node:crypto";import{CloudFormationClient as k,CreateChangeSetCommand as _,DeleteChangeSetCommand as F,DescribeChangeSetCommand as O}from"@aws-sdk/client-cloudformation";import{success as h,failure as m}from"@fjall/generator";import{getErrorMessage as N,maskSensitiveOutput as A}from"@fjall/util";import{logger as L}from"@fjall/util/logger";import{composeSdkAbortSignal as $,isAborted as R}from"../../aws/organisations/types.js";import{PREFLIGHT_CHANGE_SET_PREFIX as j}from"../../services/infrastructure/changeSetProbe.js";import{sleepAbortable as x}from"../../util/sleepAbortable.js";import{fetchLastOperationEvents as M}from"../drift/classifyDriftFailure.js";import{composeFlipCapabilities as P,computeTemplateDigest as U,echoPreviousParameters as G,resolveTemplateLocation as H,deleteStagedTemplate as J}from"./retainFlip.js";const B="RemovalUpdate",K=/didn't contain changes|No updates are to be performed/i,z=new Set(["CREATE_COMPLETE","FAILED"]),V=3e3,X=9e4;function v(a,o){let r;try{r=JSON.parse(a)}catch{return m(new Error(`The live template for ${o} is not JSON (YAML-authored stacks are not supported by the removal update) \u2014 aborting to manual repair.`))}return typeof r!="object"||r===null||Array.isArray(r)?m(new Error(`The live template for ${o} did not parse to an object \u2014 aborting to manual repair.`)):h(r)}function S(a,o,r){if(typeof a=="string")return[];if(Array.isArray(a))return a.flatMap((s,c)=>S(s,o,`${r}[${c}]`));if(typeof a!="object"||a===null)return[];const t=a,e=[],u=t.Ref;typeof u=="string"&&o.has(u)&&e.push(`${r}/Ref(${u})`);const n=t["Fn::GetAtt"];typeof n=="string"&&o.has(n.split(".")[0]??"")&&e.push(`${r}/Fn::GetAtt(${n.split(".")[0]??""})`),Array.isArray(n)&&typeof n[0]=="string"&&o.has(n[0])&&e.push(`${r}/Fn::GetAtt(${n[0]})`);const f=t["Fn::Sub"],i=typeof f=="string"?f:Array.isArray(f)&&typeof f[0]=="string"?f[0]:void 0;if(i!==void 0)for(const s of i.matchAll(/\$\{([^!}][^}]*)\}/g)){const c=(s[1]??"").split(".")[0]??"";o.has(c)&&e.push(`${r}/Fn::Sub(${c})`)}for(const[s,c]of Object.entries(t))e.push(...S(c,o,`${r}/${s}`));return e}function se(a,o,r){const t=v(a,r);if(!t.success)return t;const e=new Set(o),u=[];for(const[n,f]of Object.entries(t.data.Resources??{})){const i=e.has(n)?new Set([...e].filter(s=>s!==n)):e;i.size!==0&&S(f,i,n).length>0&&u.push(n)}return h(u)}function ie(a,o,r,t){const e=v(a,t);if(!e.success)return e;const u=e.data.Resources??{},n=[];for(const f of o){const i=u[f];if(i===void 0)continue;const s=r.filter(c=>c!==f&&S(i,new Set([c]),f).length>0);s.length>0&&n.push({pendingTarget:f,deadReferences:s})}return h(n)}function Y(a,o){const r=a.DependsOn;if(typeof r=="string"){o.has(r)&&delete a.DependsOn;return}if(Array.isArray(r)){const t=r.filter(e=>typeof e!="string"||!o.has(e));t.length===0?delete a.DependsOn:t.length!==r.length&&(a.DependsOn=t)}}function ce(a,o,r){const t=v(a,r);if(!t.success)return t;const e=t.data,u=e.Resources??{},n=new Set(o),f=o.filter(l=>u[l]===void 0);for(const l of o)delete u[l];const i=[];for(const[l,p]of Object.entries(u))Y(p,n),i.push(...S(p,n,l));if(i.length>0)return m(new Error(`Cannot orchestrate the recreate for ${r}: other resources still reference ${[...n].join(", ")} at ${i.slice(0,10).join(", ")}${i.length>10?` (+${i.length-10} more)`:""} \u2014 removing the target would break them. Restructure the dependency or perform the replacement manually.`));const s=[],c=e.Outputs;if(typeof c=="object"&&c!==null){for(const[l,p]of Object.entries(c))if(S(p,n,`Outputs/${l}`).length>0){const d=typeof p=="object"&&p!==null?p.Export:void 0;if(typeof d=="object"&&d!==null){const g=d.Name;s.push({outputName:l,...typeof g=="string"?{exportName:g}:{}})}delete c[l]}Object.keys(c).length===0&&delete e.Outputs}return h({removalTemplateBody:JSON.stringify(e),liveTemplateDigest:U(a),alreadyAbsent:f,strippedExports:s})}async function le(a,o,r,t){const e=await M(a,o,t);if(!e.success)return e;const u=new Set(r);return h(e.data.filter(n=>u.has(n.logicalId)&&n.status==="DELETE_FAILED").map(n=>({logicalId:n.logicalId,...n.statusReason!==void 0?{statusReason:A(n.statusReason)}:{}})))}async function fe(a,o,r){const{stackName:t,abortSignal:e}=o,u=r?.pollIntervalMs??V,n=r?.changeSetTimeoutMs??X,f=r?.now??(()=>Date.now()),i=a.getClient(k),s=`${j}removal-${I()}`;let c;try{const l=await H(a,s,o.removalTemplateBody,e);if(!l.success)return l;c=l.data.key,await i.send(new _({StackName:t,ChangeSetName:s,ChangeSetType:"UPDATE",IncludeNestedStacks:!0,Capabilities:P([]),Description:"fjall recreate-removal targets-only proof \u2014 review-only, never executed",...G(o.parameterKeys),...l.data.location}),{abortSignal:$(e)});const p=f();let d,g;for(;!R(e);){const y=await i.send(new O({StackName:t,ChangeSetName:s}),{abortSignal:$(e)});if(d=y.Status,g=y.StatusReason,d!==void 0&&z.has(d))break;if(f()-p>=n)return m(new Error(`Recreate-removal proof timed out after ${n}ms for ${t} (last status: ${d??"unknown"})`));await x(u,e)}if(R(e))return m(new Error(`Recreate-removal proof aborted while polling ${t}`));if(d==="FAILED")return g!==void 0&&K.test(g)?h({noChanges:!0}):m(new Error(`Recreate-removal proof change set failed for ${t}: ${A(g??"no status reason reported")}`));const T=[],D=new Set(o.targets);let b;do{if(R(e))return m(new Error(`Recreate-removal proof aborted while reading ${t}`));const y=await i.send(new O({StackName:t,ChangeSetName:s,...b!==void 0?{NextToken:b}:{}}),{abortSignal:$(e)});for(const C of y.Changes??[]){if(C.Type!=="Resource")continue;const w=C.ResourceChange,E=w?.LogicalResourceId;if(!(w===void 0||E===void 0)){if(!D.has(E)){T.push(`${E} (not a removal target)`);continue}w.Action!=="Remove"&&T.push(`${E} (action ${w.Action??"unknown"})`)}}b=y.NextToken}while(b!==void 0);return T.length>0?m(new Error(`Recreate removal is NOT targets-only for ${t} \u2014 the review change set reported changes at ${[...new Set(T)].join(", ")}. Aborting before the removal ran; the snapshot flip has already been applied, so re-run the deploy to resume once the change is understood.`)):h({noChanges:!1})}catch(l){return m(new Error(`Recreate-removal proof failed for ${t}: ${A(N(l))}`))}finally{await i.send(new F({StackName:t,ChangeSetName:s}),{abortSignal:$()}).catch(l=>{L.warn(B,"Failed to delete recreate-removal proof change set",{stackName:t,changeSetName:s,error:A(N(l))})}),c!==void 0&&await J(a,c)}}export{ce as deriveRemovalTemplate,ie as findDeadReferencesFromTargets,le as findRemovalDeletionFailures,se as findTargetReferrers,fe as proveRemovalTargetsOnly};