@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.
- package/dist/.minified +1 -1
- package/dist/src/aws/index.d.ts +1 -1
- package/dist/src/aws/index.js +1 -1
- package/dist/src/aws/organisations/backup.d.ts +7 -2
- package/dist/src/aws/organisations/backup.js +2 -2
- package/dist/src/aws/organisations/index.d.ts +2 -2
- package/dist/src/aws/organisations/index.js +1 -1
- package/dist/src/aws/organisations/oidcProvider.d.ts +37 -0
- package/dist/src/aws/organisations/oidcProvider.js +1 -1
- package/dist/src/aws/organisations/organisation.d.ts +21 -0
- package/dist/src/aws/organisations/organisation.js +1 -1
- package/dist/src/aws/organisations/organisationalUnits.d.ts +15 -0
- package/dist/src/aws/organisations/organisationalUnits.js +1 -1
- package/dist/src/aws/utils/cloudformationEventHelpers.d.ts +45 -0
- package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -1
- package/dist/src/aws/utils/cloudformationEventTypes.d.ts +1 -1
- package/dist/src/aws/utils/cloudformationEvents.d.ts +2 -1
- package/dist/src/aws/utils/cloudformationEvents.js +1 -1
- package/dist/src/aws/utils/stackStatus.js +1 -1
- package/dist/src/index.d.ts +13 -2
- package/dist/src/index.js +1 -1
- package/dist/src/orchestration/activeDeploymentGuard.d.ts +41 -0
- package/dist/src/orchestration/activeDeploymentGuard.js +8 -4
- package/dist/src/orchestration/application/applicationDeploy.d.ts +9 -0
- package/dist/src/orchestration/application/applicationDeploy.js +1 -1
- package/dist/src/orchestration/application/applicationDeployHelpers.d.ts +20 -3
- package/dist/src/orchestration/application/applicationDeployHelpers.js +3 -3
- package/dist/src/orchestration/application/applicationDestroy.js +1 -1
- package/dist/src/orchestration/application/approvalGate.d.ts +15 -1
- package/dist/src/orchestration/application/approvalGate.js +1 -1
- package/dist/src/orchestration/application/databaseEndpointReconcile.d.ts +77 -0
- package/dist/src/orchestration/application/databaseEndpointReconcile.js +1 -0
- package/dist/src/orchestration/application/deploySessionPolicy.d.ts +9 -1
- package/dist/src/orchestration/application/deploySessionPolicy.js +1 -1
- package/dist/src/orchestration/application/destructionGate.d.ts +164 -0
- package/dist/src/orchestration/application/destructionGate.js +5 -0
- package/dist/src/orchestration/application/dockerBuildHelper.js +1 -1
- package/dist/src/orchestration/application/ecrCacheRepository.d.ts +14 -0
- package/dist/src/orchestration/application/ecrCacheRepository.js +1 -0
- package/dist/src/orchestration/application/leaseGate.d.ts +30 -0
- package/dist/src/orchestration/application/leaseGate.js +1 -0
- package/dist/src/orchestration/application/plan/approvalToken.d.ts +15 -0
- package/dist/src/orchestration/application/plan/approvalToken.js +1 -1
- package/dist/src/orchestration/application/plan/changeSetEscalation.d.ts +46 -0
- package/dist/src/orchestration/application/plan/changeSetEscalation.js +1 -0
- package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +13 -0
- package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -1
- package/dist/src/orchestration/application/plan/index.d.ts +4 -1
- package/dist/src/orchestration/application/plan/index.js +1 -1
- package/dist/src/orchestration/application/plan/loadDeployPlan.d.ts +32 -0
- package/dist/src/orchestration/application/plan/loadDeployPlan.js +1 -0
- package/dist/src/orchestration/application/plan/pinnedNames.d.ts +31 -0
- package/dist/src/orchestration/application/plan/pinnedNames.js +1 -0
- package/dist/src/orchestration/application/plan/registryOverlay.d.ts +83 -0
- package/dist/src/orchestration/application/plan/registryOverlay.js +1 -0
- package/dist/src/orchestration/application/plan/renderDeployPlan.d.ts +4 -1
- package/dist/src/orchestration/application/plan/renderDeployPlan.js +1 -1
- package/dist/src/orchestration/application/plan/types.d.ts +31 -0
- package/dist/src/orchestration/application/plan/types.js +1 -0
- package/dist/src/orchestration/contextHelpers.d.ts +37 -1
- package/dist/src/orchestration/contextHelpers.js +1 -1
- package/dist/src/orchestration/drift/classifyDriftFailure.d.ts +31 -0
- package/dist/src/orchestration/drift/classifyDriftFailure.js +1 -0
- package/dist/src/orchestration/drift/detectStackDrift.d.ts +37 -0
- package/dist/src/orchestration/drift/detectStackDrift.js +1 -0
- package/dist/src/orchestration/drift/driftPreFlight.d.ts +73 -0
- package/dist/src/orchestration/drift/driftPreFlight.js +7 -0
- package/dist/src/orchestration/drift/driftProbe.d.ts +113 -0
- package/dist/src/orchestration/drift/driftProbe.js +1 -0
- package/dist/src/orchestration/drift/driftSuspectJournal.d.ts +40 -0
- package/dist/src/orchestration/drift/driftSuspectJournal.js +1 -0
- package/dist/src/orchestration/drift/preFlightGate.d.ts +44 -0
- package/dist/src/orchestration/drift/preFlightGate.js +2 -0
- package/dist/src/orchestration/index.d.ts +19 -0
- package/dist/src/orchestration/index.js +1 -1
- package/dist/src/orchestration/organisation/cascadeDestroyHelpers.d.ts +7 -0
- package/dist/src/orchestration/organisation/cascadeDestroyHelpers.js +1 -1
- package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
- package/dist/src/orchestration/organisation/organisationDestroy.js +3 -3
- package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +8 -1
- package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -1
- package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -5
- package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +28 -2
- package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -1
- package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
- package/dist/src/orchestration/remediation/driftRepairGate.d.ts +66 -0
- package/dist/src/orchestration/remediation/driftRepairGate.js +1 -0
- package/dist/src/orchestration/remediation/forensicsCapture.d.ts +49 -0
- package/dist/src/orchestration/remediation/forensicsCapture.js +1 -0
- package/dist/src/orchestration/remediation/forgetResource.d.ts +423 -0
- package/dist/src/orchestration/remediation/forgetResource.js +1 -0
- package/dist/src/orchestration/remediation/pinRemediation.d.ts +121 -0
- package/dist/src/orchestration/remediation/pinRemediation.js +1 -0
- package/dist/src/orchestration/remediation/postFailureRepairOffer.d.ts +77 -0
- package/dist/src/orchestration/remediation/postFailureRepairOffer.js +14 -0
- package/dist/src/orchestration/remediation/recreatePreFlight.d.ts +47 -0
- package/dist/src/orchestration/remediation/recreatePreFlight.js +1 -0
- package/dist/src/orchestration/remediation/recreateResource.d.ts +110 -0
- package/dist/src/orchestration/remediation/recreateResource.js +1 -0
- package/dist/src/orchestration/remediation/remediationJournal.d.ts +88 -0
- package/dist/src/orchestration/remediation/remediationJournal.js +2 -0
- package/dist/src/orchestration/remediation/removalUpdate.d.ts +135 -0
- package/dist/src/orchestration/remediation/removalUpdate.js +1 -0
- package/dist/src/orchestration/remediation/retainFlip.d.ts +294 -0
- package/dist/src/orchestration/remediation/retainFlip.js +1 -0
- package/dist/src/services/application/ApplicationStackService.d.ts +5 -3
- package/dist/src/services/application/ApplicationStackService.js +1 -1
- package/dist/src/services/application/applicationStackHelpers.d.ts +8 -6
- package/dist/src/services/application/applicationStackHelpers.js +3 -3
- package/dist/src/services/index.d.ts +2 -2
- package/dist/src/services/index.js +1 -1
- package/dist/src/services/infrastructure/CdkArgumentBuilder.js +1 -1
- package/dist/src/services/infrastructure/CdkEventMonitoring.d.ts +15 -0
- package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -1
- package/dist/src/services/infrastructure/CdkOutputAnalyser.d.ts +8 -0
- package/dist/src/services/infrastructure/CdkOutputAnalyser.js +2 -1
- package/dist/src/services/infrastructure/CdkService.js +3 -3
- package/dist/src/services/infrastructure/CdkServiceTypes.d.ts +7 -0
- package/dist/src/services/infrastructure/CfnRegistryService.d.ts +68 -0
- package/dist/src/services/infrastructure/CfnRegistryService.js +1 -0
- package/dist/src/services/infrastructure/CloudFormationService.d.ts +5 -2
- package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
- package/dist/src/services/infrastructure/EcsServiceResolver.d.ts +23 -3
- package/dist/src/services/infrastructure/EcsServiceResolver.js +1 -1
- package/dist/src/services/infrastructure/cdkServiceHelpers.js +1 -1
- package/dist/src/services/infrastructure/cfnRegistrySeed.d.ts +10 -0
- package/dist/src/services/infrastructure/cfnRegistrySeed.js +1 -0
- package/dist/src/services/infrastructure/changeSetProbe.d.ts +88 -0
- package/dist/src/services/infrastructure/changeSetProbe.js +1 -0
- package/dist/src/services/infrastructure/index.d.ts +2 -0
- package/dist/src/services/infrastructure/index.js +1 -1
- package/dist/src/services/supporting/CdkContextBuilder.d.ts +1 -0
- package/dist/src/services/supporting/CdkContextBuilder.js +1 -1
- package/dist/src/types/callbackKeys.d.ts +1 -1
- package/dist/src/types/callbackKeys.js +1 -1
- package/dist/src/types/callbacks.d.ts +22 -0
- package/dist/src/types/config/entitlements.d.ts +9 -0
- package/dist/src/types/config/orgConfig.d.ts +6 -0
- package/dist/src/types/deployment/DeploymentTypes.d.ts +1 -0
- package/dist/src/types/deploymentEventSchema.d.ts +1 -0
- package/dist/src/types/deploymentEventSchema.js +1 -1
- package/dist/src/types/destruction.d.ts +212 -0
- package/dist/src/types/destruction.js +1 -0
- package/dist/src/types/drift.d.ts +72 -0
- package/dist/src/types/drift.js +1 -0
- package/dist/src/types/index.d.ts +8 -2
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/params.d.ts +161 -0
- package/dist/src/types/remediation.d.ts +126 -0
- package/dist/src/types/remediation.js +1 -0
- 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};
|