@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,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `forget` orchestrator — the composing layer over the remediation
|
|
3
|
+
* primitives (unlock-engine layering: privileged primitives in
|
|
4
|
+
* `retainFlip.ts`/`forensicsCapture.ts`, this module composes):
|
|
5
|
+
*
|
|
6
|
+
* captured → flip-verified → flip-applied → (converge deploy) →
|
|
7
|
+
* removal-applied → done
|
|
8
|
+
*
|
|
9
|
+
* `runForgetSurgery` drives phases 1–3. The removal phase is deliberately NOT
|
|
10
|
+
* here: it is the normal converge deploy (plan § 3.2 phase 4 — the synth no
|
|
11
|
+
* longer contains the resource, CFN sees delete-with-Retain and skips the
|
|
12
|
+
* physical call, and every other pending change lands in the same update).
|
|
13
|
+
* `completeForgetAfterConverge` closes the journal once a deploy (any surface
|
|
14
|
+
* — CLI, worker, another operator) has removed the targets from the live
|
|
15
|
+
* template.
|
|
16
|
+
*
|
|
17
|
+
* Consent is the CALLER's job (the destruction ceremony runs before surgery);
|
|
18
|
+
* `assessForgetResumability` tells the caller whether the ceremony may be
|
|
19
|
+
* skipped — a journal at any phase, or the in-template `fjall:forget` marker
|
|
20
|
+
* [G3], proves the destructive act was already consented (capture and flip
|
|
21
|
+
* only ever run post-ceremony), so resume never re-ceremonies (plan § 3.2).
|
|
22
|
+
* Targets advanced via the FLIPLESS fast-path (defect #5 — live policies
|
|
23
|
+
* already Retain, no UpdateStack, no marker) or the FLIPLESS-BY-NECESSITY
|
|
24
|
+
* path (same incident, outbound direction — the flip is structurally
|
|
25
|
+
* impossible because the target's own references resolve against dead
|
|
26
|
+
* siblings, and the target is verified physically deleted so the converge's
|
|
27
|
+
* removal is a physical no-op) have no AWS-side consent trace: with the
|
|
28
|
+
* journal lost they re-ceremony, never silently resume.
|
|
29
|
+
*
|
|
30
|
+
* Every phase re-derives its precondition from live state: the flip
|
|
31
|
+
* derivation, diff-guard and change-set proof re-run on every pass (the
|
|
32
|
+
* journalled proof is an audit record, never a skip-token), which is also
|
|
33
|
+
* what "live-template digest mismatch ⇒ re-verify from phase 2" degrades to.
|
|
34
|
+
*
|
|
35
|
+
* The closure machinery (`resolveConvergedTargets`,
|
|
36
|
+
* `completeForgetAfterConverge`, `completeForgetAfterDeploy`) serves BOTH
|
|
37
|
+
* surgery verbs — the recreate orchestrator (`recreateResource.ts`) closes
|
|
38
|
+
* through it, with the marker key and terminal field derived from the
|
|
39
|
+
* journal's verb. The surgery entry points here are forget-only and fail
|
|
40
|
+
* closed on a cross-verb journal (the opId derivation excludes the verb).
|
|
41
|
+
*
|
|
42
|
+
* Failure messages are pre-masked at construction.
|
|
43
|
+
*/
|
|
44
|
+
import { type Result } from "@fjall/generator";
|
|
45
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
46
|
+
import type { DriftFinding } from "../../types/drift.js";
|
|
47
|
+
import { type RemediationJournalRecord, type RemediationPhase, type RemediationTarget } from "../../types/remediation.js";
|
|
48
|
+
import { type DriftProbeOptions } from "../drift/driftProbe.js";
|
|
49
|
+
import { type DriftJournalOptions } from "../drift/driftSuspectJournal.js";
|
|
50
|
+
import { type ForensicsCaptureOptions } from "./forensicsCapture.js";
|
|
51
|
+
import { type RemediationJournalOptions } from "./remediationJournal.js";
|
|
52
|
+
import { type RetainFlipOptions } from "./retainFlip.js";
|
|
53
|
+
export interface ForgetOrchestrationOptions {
|
|
54
|
+
journal?: RemediationJournalOptions;
|
|
55
|
+
forensics?: ForensicsCaptureOptions;
|
|
56
|
+
flip?: RetainFlipOptions;
|
|
57
|
+
driftJournal?: DriftJournalOptions;
|
|
58
|
+
/** Poll caps + clock for the closure probe's drift-API fallback. */
|
|
59
|
+
probe?: DriftProbeOptions;
|
|
60
|
+
/** Injectable clock for journal/marker timestamps. */
|
|
61
|
+
now?: () => Date;
|
|
62
|
+
}
|
|
63
|
+
export interface ForgetSurgeryParams {
|
|
64
|
+
stackName: string;
|
|
65
|
+
targets: readonly RemediationTarget[];
|
|
66
|
+
/** The deploy target that owns the stack — names the converge command. */
|
|
67
|
+
appName?: string;
|
|
68
|
+
/** Drift-probe evidence persisted in the forensics bundle. */
|
|
69
|
+
findings?: readonly DriftFinding[];
|
|
70
|
+
/** Audit trail: the exact physical names consented at the ceremony. */
|
|
71
|
+
consentedPhysicalNames?: readonly string[];
|
|
72
|
+
/**
|
|
73
|
+
* How to treat a journal already at "done" for this opId. The default
|
|
74
|
+
* ("report-completed") suits an explicit `drift resume <opId>` — the
|
|
75
|
+
* operator asked about THAT operation. A REPAIR pass targeting a fresh
|
|
76
|
+
* finding must pass "archive-and-restart": the done journal belongs to a
|
|
77
|
+
* prior incarnation of the same stack + logical-ID set (opId has no
|
|
78
|
+
* incarnation component) and would otherwise false-report completion while
|
|
79
|
+
* the new deletion stays wedged.
|
|
80
|
+
*/
|
|
81
|
+
doneJournalPolicy?: "report-completed" | "archive-and-restart";
|
|
82
|
+
onProgress?: (message: string) => void;
|
|
83
|
+
abortSignal?: AbortSignal;
|
|
84
|
+
}
|
|
85
|
+
export interface ForgetSurgeryReport {
|
|
86
|
+
opId: string;
|
|
87
|
+
/** Journal phase after this pass. */
|
|
88
|
+
phase: RemediationPhase;
|
|
89
|
+
/** True until the converge deploy removes the targets from the template. */
|
|
90
|
+
convergePending: boolean;
|
|
91
|
+
forensicsPath?: string;
|
|
92
|
+
journal: RemediationJournalRecord;
|
|
93
|
+
}
|
|
94
|
+
/** Shared with the recreate orchestrator (`recreateResource.ts`). */
|
|
95
|
+
export interface LiveStackState {
|
|
96
|
+
templateBody: string;
|
|
97
|
+
capabilities: string[];
|
|
98
|
+
/** Live parameter keys — echoed UsePreviousValue on both flip CFN calls. */
|
|
99
|
+
parameterKeys: string[];
|
|
100
|
+
}
|
|
101
|
+
export declare function fetchLiveStackState(aws: AwsProvider, stackName: string, abortSignal?: AbortSignal): Promise<Result<LiveStackState, Error>>;
|
|
102
|
+
/**
|
|
103
|
+
* [G1] The two TERMINATING cures for an in-flight recreate operation, shared
|
|
104
|
+
* by every surface that meets one it cannot itself complete (the forget verb
|
|
105
|
+
* guards here, the post-failure repair offer, the [G2] recreate-converging
|
|
106
|
+
* warn, and the CLI drift surfaces — deferred-cure, open-operation and
|
|
107
|
+
* in-flight-remediation lines [H1], the only DISCOVERABLE teach when a
|
|
108
|
+
* byte-identical revert excludes the stack from the deploy pre-flight).
|
|
109
|
+
* A bare "re-run the deploy" teach is circular when the offending
|
|
110
|
+
* configuration change was reverted: the plan then carries no pinned-name
|
|
111
|
+
* replacement, the gate never re-fires, and the converge no-ops against the
|
|
112
|
+
* open journal forever. Only a config change terminates the operation —
|
|
113
|
+
* re-applying the replacement (the recreate completes) or removing the
|
|
114
|
+
* resource (the operation closes as removed).
|
|
115
|
+
*/
|
|
116
|
+
export declare function formatRecreateInFlightCures(appName?: string): string;
|
|
117
|
+
export type ForgetResumeSource = "journal" | "marker" | "none";
|
|
118
|
+
export interface ForeignMarkerOperation {
|
|
119
|
+
opId: string;
|
|
120
|
+
/**
|
|
121
|
+
* The logical IDs still carrying this opId's marker in the LIVE template
|
|
122
|
+
* [G3]. This may be a PROPER SUBSET of the operation's original target set
|
|
123
|
+
* (stripped markers are invisible) — adoption MUST prove completeness by
|
|
124
|
+
* hash identity: `computeRemediationOpId(stackName, markedLogicalIds)`
|
|
125
|
+
* reproducing `opId`.
|
|
126
|
+
*/
|
|
127
|
+
markedLogicalIds: string[];
|
|
128
|
+
/** logicalId → CFN resource Type from the live template — lets the
|
|
129
|
+
* adoption seam build targets for recovered IDs with no drift finding. */
|
|
130
|
+
markedResourceTypes: Record<string, string>;
|
|
131
|
+
}
|
|
132
|
+
export interface ForgetResumeAssessment {
|
|
133
|
+
opId: string;
|
|
134
|
+
/**
|
|
135
|
+
* True when the destructive act was already consented — a journal at any
|
|
136
|
+
* phase (capture only runs post-ceremony) or the in-template fjall:forget
|
|
137
|
+
* marker [G3]. The caller skips the ceremony; a fresh operation must not.
|
|
138
|
+
*/
|
|
139
|
+
alreadyConsented: boolean;
|
|
140
|
+
source: ForgetResumeSource;
|
|
141
|
+
journal?: RemediationJournalRecord;
|
|
142
|
+
/**
|
|
143
|
+
* Marker-only in-flight operations owned by OTHER opIds found on the
|
|
144
|
+
* assessed targets ([G3] — their journal is lost or on another machine).
|
|
145
|
+
* The caller must never mint an operation whose targets overlap these:
|
|
146
|
+
* adopt each op with exactly its own recovered set — first proving the
|
|
147
|
+
* recovery is COMPLETE via hash identity (see `ForeignMarkerOperation`) —
|
|
148
|
+
* or defer. Empty on the journal-resume path (not scanned — the journal
|
|
149
|
+
* already decides).
|
|
150
|
+
*/
|
|
151
|
+
foreignMarkers: ForeignMarkerOperation[];
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Pre-ceremony check: is this forget operation already in flight? Journal
|
|
155
|
+
* first (local resume record), then the live template's fjall:forget marker
|
|
156
|
+
* (AWS-side truth that survives machine loss — the journal-lost case).
|
|
157
|
+
*/
|
|
158
|
+
export declare function assessForgetResumability(aws: AwsProvider, params: Pick<ForgetSurgeryParams, "stackName" | "targets" | "abortSignal">, options?: ForgetOrchestrationOptions): Promise<Result<ForgetResumeAssessment, Error>>;
|
|
159
|
+
/**
|
|
160
|
+
* Phases 1–3 of the two-deploy forget: forensics capture (abort on failure —
|
|
161
|
+
* no capture, no mutate), flip derivation + both proofs, flip apply, then the
|
|
162
|
+
* completion check (a converge that already ran closes the journal in the
|
|
163
|
+
* same pass). Idempotent and resumable: safe to re-run at any phase.
|
|
164
|
+
*/
|
|
165
|
+
export declare function runForgetSurgery(aws: AwsProvider, params: ForgetSurgeryParams, options?: ForgetOrchestrationOptions): Promise<Result<ForgetSurgeryReport, Error>>;
|
|
166
|
+
export interface CompleteForgetParams {
|
|
167
|
+
journal: RemediationJournalRecord;
|
|
168
|
+
abortSignal?: AbortSignal;
|
|
169
|
+
}
|
|
170
|
+
export type CompleteForgetOutcome = {
|
|
171
|
+
status: "done";
|
|
172
|
+
journal: RemediationJournalRecord;
|
|
173
|
+
}
|
|
174
|
+
/** At least one target is still present (flipped) or unverifiable — the
|
|
175
|
+
* converge deploy has not run, or ran and could not be verified. The
|
|
176
|
+
* per-target buckets let callers act on the ACTUAL failure set instead of
|
|
177
|
+
* the whole target list [H4]. */
|
|
178
|
+
| {
|
|
179
|
+
status: "converge-pending";
|
|
180
|
+
journal: RemediationJournalRecord;
|
|
181
|
+
/** Targets no longer in the live template — their removal landed. */
|
|
182
|
+
absent: string[];
|
|
183
|
+
/** Targets verified retained-by-redeclaration (recreate: re-created). */
|
|
184
|
+
retained: string[];
|
|
185
|
+
/** Targets neither absent nor verifiably retained: still carrying this
|
|
186
|
+
* op's marker (converge not run, or the removal was reverted), or
|
|
187
|
+
* present-unmarked with a closure-probe verdict that fails closed
|
|
188
|
+
* (confirmed-deleted, unconfirmed, stack-missing, membership-gate
|
|
189
|
+
* "present", probe failure). Every entry is template-PRESENT at the
|
|
190
|
+
* closure read by construction. */
|
|
191
|
+
unverified: string[];
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Post-converge closure: when every target is either gone from the live
|
|
195
|
+
* template or verified retained-by-redeclaration (the on-demand closure probe
|
|
196
|
+
* in `resolveConvergedTargets`), advance the journal removal-applied → done
|
|
197
|
+
* and clear the forgotten logical IDs from the drift suspect journal (the
|
|
198
|
+
* wedge is gone). Called by `drift resume`, by a re-run of `drift repair`,
|
|
199
|
+
* and by the deploy engine after a successful deploy (cross-surface resume,
|
|
200
|
+
* plan § 3.2) — so the mainline converge deploy closes retained targets
|
|
201
|
+
* naturally at completion.
|
|
202
|
+
*
|
|
203
|
+
* Serves BOTH surgery verbs (the marker key and terminal field derive from
|
|
204
|
+
* `journal.verb`): for a recreate journal the `retained` bucket is the
|
|
205
|
+
* re-created (or operator-reverted) targets, recorded as `recreatedResources`;
|
|
206
|
+
* absent targets are those the converge that just ran no longer declares.
|
|
207
|
+
* Consent alone does not prove their deletion executed [G6] — the
|
|
208
|
+
* `probeAbsentClosureTargets` call below physically verifies each absent
|
|
209
|
+
* target, recording still-alive ones as `orphanedResources` and unverifiable
|
|
210
|
+
* ones as `unverifiedRemovals` rather than claiming an executed deletion.
|
|
211
|
+
*/
|
|
212
|
+
export declare function completeForgetAfterConverge(aws: AwsProvider, params: CompleteForgetParams, options?: ForgetOrchestrationOptions): Promise<Result<CompleteForgetOutcome, Error>>;
|
|
213
|
+
export interface PreFlightRemediationPartition {
|
|
214
|
+
/** Findings whose block stands — includes flip-not-live and still-declared
|
|
215
|
+
* findings below, so callers gate on `blocked.length` alone. */
|
|
216
|
+
blocked: DriftFinding[];
|
|
217
|
+
/**
|
|
218
|
+
* Blocked findings whose operation exists but has NOT applied the flip —
|
|
219
|
+
* the deploy would still wedge; the block message adds the resume hint.
|
|
220
|
+
*/
|
|
221
|
+
inProgressOpIds: string[];
|
|
222
|
+
/**
|
|
223
|
+
* Findings whose flip is verifiably live AND whose synthesised template
|
|
224
|
+
* omits the target: THIS deploy is the converge deploy — the block is
|
|
225
|
+
* suppressed so the surgery can complete.
|
|
226
|
+
*/
|
|
227
|
+
converging: Array<{
|
|
228
|
+
finding: DriftFinding;
|
|
229
|
+
opId: string;
|
|
230
|
+
}>;
|
|
231
|
+
/** Journal says flip-applied but the live template VERIFIABLY no longer
|
|
232
|
+
* carries Retain + our marker — surgery must re-run before deploying. */
|
|
233
|
+
flipNotLive: Array<{
|
|
234
|
+
finding: DriftFinding;
|
|
235
|
+
opId: string;
|
|
236
|
+
}>;
|
|
237
|
+
/** The live template could NOT be read, so the flip's liveness is
|
|
238
|
+
* unverifiable — fail-closed block whose cure is retrying the deploy, never
|
|
239
|
+
* the flip-stripped copy. `message` is pre-masked at construction. */
|
|
240
|
+
flipUnverified: Array<{
|
|
241
|
+
finding: DriftFinding;
|
|
242
|
+
opId: string;
|
|
243
|
+
message: string;
|
|
244
|
+
}>;
|
|
245
|
+
/** Flip is live but the synth still declares the target — this deploy is
|
|
246
|
+
* NOT the converge; the resource must leave the fjall config first. */
|
|
247
|
+
stillDeclared: Array<{
|
|
248
|
+
finding: DriftFinding;
|
|
249
|
+
opId: string;
|
|
250
|
+
}>;
|
|
251
|
+
/**
|
|
252
|
+
* SUPPRESSED findings covered by an in-flight RECREATE journal — the deploy
|
|
253
|
+
* itself is the cure, so blocking would deadlock (recreate resumes ONLY
|
|
254
|
+
* through the deploy; `drift repair` fails closed on its journal). Safe at
|
|
255
|
+
* every in-flight phase because the 3f surgery's removal update strips the
|
|
256
|
+
* dead-tracked target from the template BEFORE the converge — CloudFormation
|
|
257
|
+
* never updates the deleted resource, then the converge re-CREATEs it. See
|
|
258
|
+
* the phase-by-phase evidence in the function JSDoc below. Surfaced to the
|
|
259
|
+
* caller for the honest onLog line, never dropped silently.
|
|
260
|
+
*/
|
|
261
|
+
recreateConverging: Array<{
|
|
262
|
+
finding: DriftFinding;
|
|
263
|
+
opId: string;
|
|
264
|
+
appName?: string;
|
|
265
|
+
}>;
|
|
266
|
+
}
|
|
267
|
+
export interface PreFlightRemediationDeps {
|
|
268
|
+
aws: AwsProvider;
|
|
269
|
+
/**
|
|
270
|
+
* Whether the incoming deploy's synthesised template still declares a
|
|
271
|
+
* logical ID (read from cdk.out at the deploy call site — see
|
|
272
|
+
* `synthTemplateDeclaresResource`). `undefined` = the synth template could
|
|
273
|
+
* not be read; suppression then rests on the live-flip check alone rather
|
|
274
|
+
* than deadlocking a genuine converge on an unreadable artefact.
|
|
275
|
+
*/
|
|
276
|
+
synthDeclaresTarget: (stackName: string, logicalId: string) => Promise<boolean | undefined>;
|
|
277
|
+
/** Caller shutdown/cancel signal, threaded into the per-stack live-template
|
|
278
|
+
* probes; once aborted, remaining findings fail closed to `blocked`. */
|
|
279
|
+
abortSignal?: AbortSignal;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Answer the partition's synth-omission question from a cdk.out directory.
|
|
283
|
+
* Read failure returns `undefined` (debug-logged) — the caller decides how
|
|
284
|
+
* suppression degrades.
|
|
285
|
+
*/
|
|
286
|
+
export declare function synthTemplateDeclaresResource(cdkOutPath: string, stackName: string, logicalId: string): Promise<boolean | undefined>;
|
|
287
|
+
/**
|
|
288
|
+
* The [G2] pre-flight × remediation-journal interaction (plan § 3.2 decision,
|
|
289
|
+
* recorded in the Phase 6 report): a confirmed-deleted finding covered by a
|
|
290
|
+
* remediation journal at phase ≥ flip-applied does NOT block the deploy —
|
|
291
|
+
* blocking would deadlock the orchestration whose removal phase IS the next
|
|
292
|
+
* deploy. Operations still pre-flip keep the block (deploying before the
|
|
293
|
+
* Retain flip would re-wedge) but name the resume command.
|
|
294
|
+
*
|
|
295
|
+
* The journal phase alone is never the suppression authority. Both must hold:
|
|
296
|
+
* (1) the live template still carries the Retain policies for the target and
|
|
297
|
+
* no FOREIGN marker (`readRetainFlipState` — stripped policies re-block
|
|
298
|
+
* to `drift repair`; the policies are the safety property CFN honours at
|
|
299
|
+
* the converge's delete, and flipless targets [defect #5] are markerless
|
|
300
|
+
* by design, so our own marker is corroborating rather than required).
|
|
301
|
+
* Flipless-BY-NECESSITY targets (journal `fliplessByNecessity`) pass (1)
|
|
302
|
+
* while policy-pending: the flip was structurally impossible and their
|
|
303
|
+
* safety property is verified physical absence — re-proven by the
|
|
304
|
+
* finding in hand, this pre-flight's own confirmed-deleted verdict;
|
|
305
|
+
* (2) the deploy's synthesised template OMITS the target — a deploy that
|
|
306
|
+
* still declares the resource is not the converge: cdk would strip the
|
|
307
|
+
* flip and re-wedge on the dead resource.
|
|
308
|
+
* A target already ABSENT from the live template passes (1) — its removal
|
|
309
|
+
* landed and the deploy cannot touch it. An UNREADABLE live template proves
|
|
310
|
+
* neither state: those findings fail closed to `flipUnverified` (cure: retry
|
|
311
|
+
* the deploy), never to `flipNotLive` — its re-run-repair cure would be a lie.
|
|
312
|
+
*
|
|
313
|
+
* RECREATE coverage suppresses unconditionally (refines sanction 10). The
|
|
314
|
+
* forget-shaped conditions cannot apply — a recreate converge re-declares its
|
|
315
|
+
* targets by design, so demanding synth-omits would deadlock: recreate resumes
|
|
316
|
+
* ONLY through the deploy (`drift repair` fails closed on its journal, verb
|
|
317
|
+
* guard), and the deploy would be blocked by this very finding. Suppression is
|
|
318
|
+
* safe because the [G2] hazard is CloudFormation UPDATING a dead-tracked
|
|
319
|
+
* resource, and no in-flight phase lets that happen:
|
|
320
|
+
* - captured / flip-verified: the snapshot flip is metadata-only
|
|
321
|
+
* (DeletionPolicy + marker) — CFN never touches the physical resource; the
|
|
322
|
+
* forget arm applies the same `applyRetainFlip` machinery to dead-tracked
|
|
323
|
+
* targets as its core function.
|
|
324
|
+
* - flip-applied: the surgical removal DELETEs the target from the template;
|
|
325
|
+
* a physically-absent resource deletes as not-found success, and a genuine
|
|
326
|
+
* DELETE_FAILED is caught fail-closed by `findRemovalDeletionFailures`
|
|
327
|
+
* with a terminating cure.
|
|
328
|
+
* - removal-applied: the target is already stripped — the deploy is pure
|
|
329
|
+
* converge and re-creates it.
|
|
330
|
+
* - `runRecreatePreFlight` probes never block an absent target (NotFound →
|
|
331
|
+
* not blocked; template-absent targets skip probes entirely).
|
|
332
|
+
* The surgery re-runs before the converge because the pinned-name finding
|
|
333
|
+
* re-fires at the destruction gate while the journal is open (resumability
|
|
334
|
+
* rides the deploy path); if the config no longer declares the target, the
|
|
335
|
+
* converge removes the dead-tracked resource (not-found delete) and the
|
|
336
|
+
* post-deploy sweep closes the journal as removed-instead-of-re-created.
|
|
337
|
+
* DONE journals never suppress — `listActiveRemediationOps` excludes them.
|
|
338
|
+
*
|
|
339
|
+
* [G3] Degenerate third variant — config reverted byte-identically: when the
|
|
340
|
+
* operator reverts the offending configuration change while the journal is
|
|
341
|
+
* still PRE-flip-applied (captured/flip-verified), the plan carries no
|
|
342
|
+
* pinned-name replacement, so the gate never re-fires and no surgery resumes;
|
|
343
|
+
* the converge deploys a template identical to the live one, and the closure
|
|
344
|
+
* predicate can never account for the targets (present, and pre-flip-applied
|
|
345
|
+
* phases issue no closure probe — the retained bucket is empty by
|
|
346
|
+
* construction), so the journal wedges open. Worse, a byte-identical revert
|
|
347
|
+
* leaves the stack hash-unchanged, so it is EXCLUDED from the deploy set
|
|
348
|
+
* (`getStackNamesToDeploy`) and this partition never probes it — the
|
|
349
|
+
* recreate-converging warn is unreachable in exactly the degenerate that
|
|
350
|
+
* needs it. The drift surfaces (`fjall drift detect` / `fjall drift repair`
|
|
351
|
+
* open-operation and deferred-cure lines) are therefore the only
|
|
352
|
+
* DISCOVERABLE teach, which is why they carry the same two cures. (At
|
|
353
|
+
* flip-applied or later the converge strips the marker and the closure probe
|
|
354
|
+
* verifies the reverted target live — closing it as `recreatedResources`.)
|
|
355
|
+
* When the warn IS reachable it carries the two TERMINATING cures
|
|
356
|
+
* (`formatRecreateInFlightCures` [G1]): re-apply the replacement-requiring
|
|
357
|
+
* change (the recreate completes) or remove the resource from the config
|
|
358
|
+
* (the operation closes as removed). Suppression remains correct throughout
|
|
359
|
+
* — the wedge is a stale journal, never a CloudFormation hazard.
|
|
360
|
+
*/
|
|
361
|
+
export declare function partitionPreFlightByRemediation(deps: PreFlightRemediationDeps, confirmed: readonly DriftFinding[], options?: ForgetOrchestrationOptions): Promise<PreFlightRemediationPartition>;
|
|
362
|
+
export interface ByNecessityConvergeTarget {
|
|
363
|
+
stackName: string;
|
|
364
|
+
logicalId: string;
|
|
365
|
+
opId: string;
|
|
366
|
+
}
|
|
367
|
+
export interface ByNecessityConvergeCheck {
|
|
368
|
+
/** Targets re-proven absent against the owning service at THIS deploy's
|
|
369
|
+
* pre-flight (by the fresh probe here, or by the pre-flight's own
|
|
370
|
+
* confirmed-deleted finding for the same target). */
|
|
371
|
+
verifiedAbsent: ByNecessityConvergeTarget[];
|
|
372
|
+
/** Targets whose absence could NOT be re-proven — the converge must not
|
|
373
|
+
* run. `reason` is identifier-only plus the probe detail, which
|
|
374
|
+
* `probePhysicalPresence` pre-masks at construction. */
|
|
375
|
+
blocked: Array<ByNecessityConvergeTarget & {
|
|
376
|
+
reason: string;
|
|
377
|
+
}>;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Converge-time absence re-verification for flipless-BY-NECESSITY targets —
|
|
381
|
+
* and it must run on EVERY converge, independent of drift findings. A
|
|
382
|
+
* by-necessity target reaches the converge with its live DeletionPolicy
|
|
383
|
+
* still Delete (the Retain flip was structurally impossible), so the
|
|
384
|
+
* converge's removal issues a REAL physical delete. Its safety property is
|
|
385
|
+
* verified absence — but absence is point-in-time: a target recreated
|
|
386
|
+
* out-of-band after flip-applied probes PRESENT at the pre-flight, produces
|
|
387
|
+
* no confirmed finding, and therefore never reaches
|
|
388
|
+
* `partitionPreFlightByRemediation` — the finding-driven [G2] path has
|
|
389
|
+
* nothing to check exactly when the danger appears. This pass closes that
|
|
390
|
+
* hole by re-proving absence from the journal side: every flip-applied
|
|
391
|
+
* forget journal's `fliplessByNecessity` targets are re-probed against the
|
|
392
|
+
* owning service unless this pre-flight already confirmed them deleted.
|
|
393
|
+
* Anything not provably absent blocks the deploy (fail closed — proceeding
|
|
394
|
+
* would destroy the recreation). A target the synth still DECLARES is
|
|
395
|
+
* skipped: this deploy issues no removal for it, so no delete can fire.
|
|
396
|
+
*
|
|
397
|
+
* The probe routes through `probeSuspects`, never `probePhysicalPresence`
|
|
398
|
+
* directly: the converge's removal deletes whatever physical id the stack
|
|
399
|
+
* CURRENTLY tracks for the logicalId, so absence must be proven at that id.
|
|
400
|
+
* The listing-outranks rule re-resolves it — a target replaced under the
|
|
401
|
+
* same logicalId out-of-band probes at the live successor; probing the
|
|
402
|
+
* journal-captured id would re-confirm absence at a dead address while the
|
|
403
|
+
* converge destroys the live recreation.
|
|
404
|
+
*/
|
|
405
|
+
export declare function verifyByNecessityBeforeConverge(deps: PreFlightRemediationDeps, stackNames: readonly string[], freshlyConfirmed: ReadonlyMap<string, ReadonlySet<string>>, options?: ForgetOrchestrationOptions): Promise<ByNecessityConvergeCheck>;
|
|
406
|
+
export interface CompleteForgetAfterDeployParams {
|
|
407
|
+
stackNames: readonly string[];
|
|
408
|
+
/** When set, the sweep ALSO visits stacks outside `stackNames` that hold
|
|
409
|
+
* an open RECREATE journal recorded for this app — a resumed deploy can
|
|
410
|
+
* legitimately skip a stack whose re-create already landed, and this sweep
|
|
411
|
+
* is the recreate arm's sole closure point. */
|
|
412
|
+
appName?: string;
|
|
413
|
+
onLog?: (message: string, level: "info" | "warn") => void;
|
|
414
|
+
abortSignal?: AbortSignal;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Cross-surface resume (plan § 3.2): after a successful deploy, close any
|
|
418
|
+
* remediation operation (forget AND recreate — step 9b is the recreate arm's
|
|
419
|
+
* sole closure point) whose targets the deploy just converged — regardless of
|
|
420
|
+
* which surface started the surgery. Best-effort: a completion failure warns
|
|
421
|
+
* and never fails the deploy that just succeeded.
|
|
422
|
+
*/
|
|
423
|
+
export declare function completeForgetAfterDeploy(aws: AwsProvider, params: CompleteForgetAfterDeployParams, options?: ForgetOrchestrationOptions): Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{readFile as ge}from"node:fs/promises";import{join as he}from"node:path";import{CloudFormationClient as me,DescribeStacksCommand as ye,GetTemplateCommand as ve}from"@aws-sdk/client-cloudformation";import{success as $,failure as j}from"@fjall/generator";import{getErrorMessage as q,maskSensitiveOutput as _}from"@fjall/util";import{logger as J}from"@fjall/util/logger";import{composeSdkAbortSignal as Z,isAborted as ee}from"../../aws/organisations/types.js";import{isRemediationPlaceholderPhysicalId as H,remediationPhaseRank as A}from"../../types/remediation.js";import{DriftProbe as te,MEMBERSHIP_GATE_PROBE_METHOD as re,probePhysicalPresence as ae}from"../drift/driftProbe.js";import{clearDriftSuspects as be,readDriftSuspects as we,recordDriftSuspects as Ie}from"../drift/driftSuspectJournal.js";import{captureRemediationForensics as $e}from"./forensicsCapture.js";import{findDeadReferencesFromTargets as ke,findTargetReferrers as Se}from"./removalUpdate.js";import{archiveCompletedRemediationJournal as Re,computeRemediationOpId as ne,formatArchivedOperationNotice as Ne,formatCompletedOperationNotice as Te,listActiveRemediationOps as z,listActiveRemediationOpsForContext as Pe,readRemediationJournal as oe,sweepExpiredRemediationJournals as Ce,writeRemediationJournal as ie}from"./remediationJournal.js";import{applyRetainFlip as je,computeTemplateDigest as se,deriveRetainFlipTemplate as Oe,findMarkedResourcesByOpId as V,proveFlipMetadataOnly as Ee,readResourceTypes as Fe,readRetainFlipState as M,verifyRetainFlipDiff as Ae,FORGET_MARKER_KEY as ce,RECREATE_MARKER_KEY as Be}from"./retainFlip.js";const U="ForgetResource";async function x(n,t,s){const r=n.getClient(me);try{const d=await r.send(new ve({StackName:t,TemplateStage:"Original"}),{abortSignal:Z(s)});if(d.TemplateBody===void 0||d.TemplateBody==="")return j(new Error(`GetTemplate returned an empty template body for ${t}`));const o=(await r.send(new ye({StackName:t}),{abortSignal:Z(s)})).Stacks?.[0];return $({templateBody:d.TemplateBody,capabilities:o?.Capabilities??[],parameterKeys:(o?.Parameters??[]).flatMap(c=>c.ParameterKey!==void 0?[c.ParameterKey]:[])})}catch(d){return j(new Error(`Could not read the live state of ${t}: ${_(q(d))}`))}}function de(n){const t=`'fjall deploy${n!==void 0?` ${n}`:""}'`;return`Two cures terminate it: re-apply the replacement-requiring configuration change and re-run ${t}, re-consenting when prompted \u2014 the recreate completes; or remove the resource from the fjall config and run ${t} \u2014 the operation closes as removed.`}async function Ze(n,t,s){const r=t.targets.map(f=>f.logicalId),d=ne(t.stackName,r),a=await oe(n.getAccountId(),n.getRegion(),t.stackName,d,s?.journal);if(a!==null&&a.verb!=="forget"&&a.phase!=="done")return j(new Error(`Forget operation ${d} on ${t.stackName} collides with an in-flight '${a.verb}' operation covering the same resources \u2014 that surgery resumes only through its deploy. ${de(a.appName)} Nothing was mutated.`));if(a!==null&&a.phase!=="done")return $({opId:d,alreadyConsented:!0,source:"journal",journal:a,foreignMarkers:[]});const o=await x(n,t.stackName,t.abortSignal);if(!o.success)return o;const c=M(o.data.templateBody,r,t.stackName);if(!c.success)return c;const e=c.data.markerOpIds.filter(f=>f!==d).map(f=>{const l=V(o.data.templateBody,f,t.stackName),u=l.success?l.data:[];return{opId:f,markedLogicalIds:u,markedResourceTypes:Fe(o.data.templateBody,u,t.stackName)}});return c.data.markerOpIds.includes(d)?$({opId:d,alreadyConsented:!0,source:"marker",foreignMarkers:e}):$({opId:d,alreadyConsented:!1,source:"none",foreignMarkers:e})}function le(n,t,s,r,d){const a=r.filter(e=>e!==s);if(a.length===0)return $(void 0);const o=a.map(e=>{const f=V(n,e,t);return`${e} covers ${f.success&&f.data.length>0?f.data.join(", "):"(could not recover its target set)"}`}),c=d?`resume this operation with 'fjall drift resume ${s}'`:"re-run 'fjall drift repair' to restart this operation";return j(new Error(`Forget operation ${s} on ${t} found target resources already carrying a ${ce} 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: ${o.join("; ")}. Complete the foreign operation first \u2014 on the machine that holds its journal, run 'fjall drift resume ${a.join("' / 'fjall drift resume ")}' (or 'fjall drift repair' there if it is journal-less); once it converges, ${c}. Nothing was mutated.`))}async function Q(n,t,s,r,d){const a=s.targets.map(i=>i.logicalId),o=M(t,a,s.stackName);if(!o.success)return o;if(A(s.phase)<A("flip-applied"))return $({absent:o.data.absentTargets,retained:[]});const c=V(t,s.opId,s.stackName,s.verb==="recreate"?Be:ce);if(!c.success)return c;const e=new Set(c.data),f=new Set(o.data.absentTargets),l=s.targets.filter(i=>!f.has(i.logicalId)&&!e.has(i.logicalId));if(l.length===0)return $({absent:o.data.absentTargets,retained:[]});const u=await pe(n,s,l,r,d);return $({absent:o.data.absentTargets,retained:l.map(i=>i.logicalId).filter(i=>u.has(i))})}async function pe(n,t,s,r,d){const a=s.map(e=>({stackName:t.stackName,logicalId:e.logicalId,resourceType:e.resourceType,...H(e,t.stackName)?{}:{physicalId:e.physicalId},failedStatus:t.verb==="recreate"?"RECREATE_CLOSURE_CHECK":"FORGET_CLOSURE_CHECK",statusReason:`Presence verification at ${t.verb} closure`})),c=await new te(n,d?.probe).probeSuspects({stackName:t.stackName,suspects:a,...r!==void 0?{abortSignal:r}:{}});return c.success?new Set(c.data.filter(e=>e.verdict==="present"&&e.probeMethod!==re).map(e=>e.logicalId)):(J.debug(U,"Closure probe failed \u2014 failing closed (not retained)",{stackName:t.stackName,opId:t.opId,error:c.error.message}),new Set)}async function et(n,t,s){const{stackName:r,targets:d,onProgress:a,abortSignal:o}=t;if(d.length===0)return j(new Error("Forget surgery requires at least one target"));const c=s?.now??(()=>new Date),e=n.getAccountId(),f=n.getRegion(),l=d.map(h=>h.logicalId),u=ne(r,l);let i=await oe(e,f,r,u,s?.journal);if(i!==null&&i.verb!=="forget"&&i.phase!=="done")return j(new Error(`Forget surgery ${u} on ${r} collides with an in-flight '${i.verb}' operation covering the same resources \u2014 that surgery resumes only through its deploy. ${de(i.appName)} Nothing was mutated.`));if(i!==null&&i.phase==="done"){if(t.doneJournalPolicy!=="archive-and-restart")return a?.(Te(i)),$({opId:u,phase:"done",convergePending:!1,...i.forensicsPath!==void 0?{forensicsPath:i.forensicsPath}:{},journal:i});const h=await Re(i,s?.journal);if(!h.success)return h;a?.(Ne(i)),i=null}const k=async h=>{const N=await ie(h,s?.journal);return N.success?$(h):j(N.error)};let b;if(i===null){const h=await x(n,r,o);if(!h.success)return h;const N=M(h.data.templateBody,l,r);if(!N.success)return N;const D=le(h.data.templateBody,r,u,N.data.markerOpIds,!1);if(!D.success)return D;if(N.data.markerOpIds.includes(u)){a?.(`Recognised in-flight forget markers on ${r} \u2014 resuming from the fjall:forget marker`);const m=c().toISOString(),T=await k({version:1,opId:u,verb:"forget",accountId:e,region:f,stackName:r,...t.appName!==void 0?{appName:t.appName}:{},targets:[...d],phase:N.data.allFlipped?"flip-applied":"captured",createdAt:m,updatedAt:m});if(!T.success)return T;i=T.data,b=h.data}else{a?.(`Capturing forensics for ${r} (${u})`);const m=await $e(n,{stackName:r,opId:u,findings:t.findings??[],...o!==void 0?{abortSignal:o}:{}},s?.forensics);if(!m.success)return m;const T=c().toISOString(),I=await k({version:1,opId:u,verb:"forget",accountId:e,region:f,stackName:r,...t.appName!==void 0?{appName:t.appName}:{},targets:[...d],phase:"captured",createdAt:T,updatedAt:T,forensicsPath:m.data.forensicsPath,...t.consentedPhysicalNames!==void 0?{consentedPhysicalNames:[...t.consentedPhysicalNames]}:{}});if(!I.success)return I;i=I.data,b={templateBody:m.data.liveTemplateBody,capabilities:m.data.capabilities,parameterKeys:m.data.parameterKeys}}}if(b===void 0){const h=await x(n,r,o);if(!h.success)return h;b=h.data}let g=b.templateBody,p=M(g,l,r);if(!p.success)return p;const R=le(g,r,u,p.data.markerOpIds,!0);if(!R.success)return R;const O=i.phase,B=await Q(n,g,i,o,s);if(!B.success)return B;const w=new Set(B.data.retained),E=p.data.policyPendingTargets.filter(h=>!w.has(h)),F=[];let L=!1;if(E.length>0){const h=Se(g,E,r);if(!h.success)return h;if(h.data.length>0){const y=E.length>1;return j(new Error(`Forget surgery cannot proceed on ${r}: ${E.join(", ")} must have DeletionPolicy changed to Retain before removal, but other resources in the stack still reference the deleted resource${y?"s":""} (${h.data.join(", ")}) \u2014 CloudFormation re-resolves references to a deleted resource during any update that modifies it, so the policy change cannot be applied. Either restore the deleted resource${y?"s":""} out-of-band and re-run 'fjall drift detect' (the finding clears; this operation record then idles until the 90-day retention sweep), or remove the target and every referencing resource from the stack in one manual template update \u2014 they cannot outlive it. This shape is a recorded fjall limitation (drift-remediation ADR, 2026-07-12). Nothing was mutated.`))}const N=l.filter(y=>!w.has(y)),D=ke(g,E,N,r);if(!D.success)return D;let m=D.data;if(m.length>0){const y=new Set(m.flatMap(v=>v.deadReferences)),S=await pe(n,i,i.targets.filter(v=>y.has(v.logicalId)),o,s);S.size>0&&(m=m.map(v=>({...v,deadReferences:v.deadReferences.filter(P=>!S.has(P))})).filter(v=>v.deadReferences.length>0))}if(m.length>0){const y=[];for(const S of m){const v=i.targets.find(C=>C.logicalId===S.pendingTarget);if(v===void 0||H(v,r)){y.push(`${S.pendingTarget} (no probeable physical id on this operation)`);continue}const P=await ae(n,{resourceType:v.resourceType,physicalId:v.physicalId,...o!==void 0?{abortSignal:o}:{}});P.verdict==="confirmed-deleted"?F.push(S.pendingTarget):y.push(`${S.pendingTarget} (${P.verdict}: ${P.detail})`)}if(y.length>0){const S=m.map(C=>`${C.pendingTarget} itself references the deleted ${C.deadReferences.join(", ")}`).join("; "),v=new Set(m.flatMap(C=>C.deadReferences)).size>1,P=m.length>1;return j(new Error(`Forget surgery cannot proceed on ${r}: ${m.map(C=>C.pendingTarget).join(", ")} must have DeletionPolicy changed to Retain before removal, but ${S} \u2014 CloudFormation resolves a modified resource's own references at execution time, and a reference to a deleted resource cannot resolve, so the policy change cannot be applied. fjall proceeds without the policy change only when the referencing target is verified deleted against its owning service (the converge's removal is then a physical no-op), and verification did not confirm deletion for ${y.join("; ")}. Either restore the referenced resource${v?"s":""} out-of-band and resume this operation (the policy change can then apply), or remove the referencing target${P?"s":""} and every reference to ${P?"them":"it"} (including stack Outputs) from the stack in one manual template update \u2014 CloudFormation deletes template-removed resources from its stored state, so removing an already-deleted resource succeeds where modifying it cannot. The unverified-absence shape is a recorded fjall limitation (drift-remediation ADR, 2026-07-12). Nothing was mutated.`))}a?.(`${F.join(", ")} cannot carry the Retain flip (${m.map(S=>`${S.pendingTarget} references the deleted ${S.deadReferences.join(", ")}`).join("; ")}) but ${F.length===1?"is":"are"} verified deleted against the owning service \u2014 proceeding without the flip: the converge's removal of an already-deleted resource is a physical no-op (flipless by necessity)`)}const T=new Set(F),I=E.filter(y=>!T.has(y));if(I.length>0){L=!0,i.liveTemplateDigest!==void 0&&i.liveTemplateDigest!==se(g)&&A(i.phase)>=A("flip-verified")&&a?.(`Live template of ${r} changed since the flip was verified \u2014 re-verifying from the current template`),a?.(`Deriving the retain flip for ${r}`);const y=Oe({liveTemplateBody:g,stackName:r,targets:I,opId:u,at:c().toISOString()});if(!y.success)return y;const S=Ae(g,y.data.flipTemplateBody,I,r);if(!S.success)return S;a?.("Proving the flip is metadata-only (review change set)");const v=await Ee(n,{stackName:r,flipTemplateBody:y.data.flipTemplateBody,targets:I,parameterKeys:b.parameterKeys,...o!==void 0?{abortSignal:o}:{}},s?.flip);if(!v.success)return v;const P=await k({...i,phase:"flip-verified",updatedAt:c().toISOString(),liveTemplateDigest:y.data.liveTemplateDigest,proof:{diffGuardPassed:!0,changeSetMetadataOnly:!0,noChanges:v.data.noChanges,provenAt:c().toISOString()}});if(!P.success)return P;i=P.data,a?.(`Applying the retain flip to ${r}`);const C=await je(n,{stackName:r,flipTemplateBody:y.data.flipTemplateBody,capabilities:b.capabilities,parameterKeys:b.parameterKeys,derivedFromTemplateDigest:y.data.liveTemplateDigest,opId:u,...o!==void 0?{abortSignal:o}:{}},s?.flip);if(!C.success)return C;const Y=await x(n,r,o);if(!Y.success)return Y;if(g=Y.data.templateBody,p=M(g,l,r),!p.success)return p;const ue=new Set(I),W=p.data.unflippedTargets.filter(X=>ue.has(X)&&!w.has(X));if(W.length>0)return j(new Error(`Retain flip on ${r} reported ${C.data.outcome} but GetTemplate does not show Retain + the fjall:forget marker on ${W.join(", ")} \u2014 aborting to manual repair.`))}}if(A(i.phase)<A("flip-applied")){const h=new Set(F),N=p.data.policyPendingTargets.filter(I=>!w.has(I)&&!h.has(I));if(N.length>0)return j(new Error(`Retain flip on ${r} could not be recorded: ${N.join(", ")} do not carry the Retain deletion policies in the live template \u2014 re-run 'fjall drift repair' to re-verify.`));const D=new Set(p.data.absentTargets),m=p.data.unflippedTargets.filter(I=>!D.has(I)&&!w.has(I)&&!h.has(I));m.length>0&&a?.(`${m.join(", ")} already ${m.length===1?"carries":"carry"} DeletionPolicy Retain in the live template \u2014 no stack update needed (flipless fast-path)`);const T=await k({...i,phase:"flip-applied",updatedAt:c().toISOString(),liveTemplateDigest:se(g),...m.length>0?{fliplessTargets:m}:{},...F.length>0?{fliplessByNecessity:F}:{}});if(!T.success)return T;i=T.data}const K=!L&&i.phase===O?B:await Q(n,g,i,o,s);if(!K.success)return K;const G=K.data.retained;if(K.data.absent.length+G.length===l.length){const h=await fe(i,G,c,s?.journal,s?.driftJournal);return h.success?(i=h.data,a?.(G.length>0?`Forget operation ${u} complete \u2014 the converge kept ${G.join(", ")} (re-declared in the synthesised template)${K.data.absent.length>0?` and ${r} no longer tracks the remaining targets`:""}`:`Targets are no longer in the ${r} template \u2014 forget operation ${u} complete`),$({opId:u,phase:"done",convergePending:!1,...i.forensicsPath!==void 0?{forensicsPath:i.forensicsPath}:{},journal:i})):h}return a?.(`Retain flip is live on ${r} \u2014 run the converge deploy to complete the forget`),$({opId:u,phase:i.phase,convergePending:!0,...i.forensicsPath!==void 0?{forensicsPath:i.forensicsPath}:{},journal:i})}async function De(n,t,s){const{journal:r}=t;if(r.phase==="done")return $({status:"done",journal:r});const d=s?.now??(()=>new Date),a=r.targets.map(l=>l.logicalId),o=await x(n,r.stackName,t.abortSignal);if(!o.success)return o;const c=await Q(n,o.data.templateBody,r,t.abortSignal,s);if(!c.success)return c;if(c.data.absent.length+c.data.retained.length!==a.length){const l=new Set([...c.data.absent,...c.data.retained]);return $({status:"converge-pending",journal:r,absent:[...c.data.absent],retained:[...c.data.retained],unverified:a.filter(u=>!l.has(u))})}const e=r.verb==="recreate"&&c.data.absent.length>0?await Me(n,r,c.data.absent,t.abortSignal):void 0,f=await fe(r,c.data.retained,d,s?.journal,s?.driftJournal,e);return f.success?$({status:"done",journal:f.data}):f}async function Me(n,t,s,r){const d={orphaned:[],unverified:[]};for(const a of s){const o=t.targets.find(e=>e.logicalId===a);if(o===void 0)continue;if(H(o,t.stackName)){d.unverified.push(a);continue}const c=await ae(n,{resourceType:o.resourceType,physicalId:o.physicalId,...r!==void 0?{abortSignal:r}:{}});c.verdict==="present"?d.orphaned.push(a):c.verdict==="unconfirmed"&&d.unverified.push(a)}return d}async function fe(n,t,s,r,d,a){const o=s().toISOString(),c={...n,phase:"done",updatedAt:o,completedAt:o,...t.length>0?n.verb==="recreate"?{recreatedResources:[...t]}:{retainedByRedeclaration:[...t]}:{},...a!==void 0&&a.orphaned.length>0?{orphanedResources:[...a.orphaned]}:{},...a!==void 0&&a.unverified.length>0?{unverifiedRemovals:[...a.unverified]}:{}},e=await ie(c,r);return e.success?(await xe(n,d).catch(f=>{J.warn(U,"Failed to clear drift suspects after forget",{stackName:n.stackName,opId:n.opId,error:_(q(f))})}),$(c)):j(e.error)}async function xe(n,t){const s=await we(n.accountId,n.region,n.stackName,t);if(s===null)return;const r=new Set(n.targets.map(a=>a.logicalId)),d=s.suspects.filter(a=>!r.has(a.logicalId));if(d.length!==s.suspects.length){if(d.length===0){await be(n.accountId,n.region,n.stackName,t);return}await Ie({...s,suspects:d},t)}}async function tt(n,t,s){try{const r=await ge(he(n,`${t}.template.json`),"utf8"),d=JSON.parse(r);return typeof d.Resources=="object"&&d.Resources!==null&&Object.hasOwn(d.Resources,s)}catch(r){J.debug(U,"Could not read the synthesised template",{stackName:t,error:_(q(r))});return}}async function rt(n,t,s){await Ce(s?.journal,s?.now);const r=n.aws.getAccountId(),d=n.aws.getRegion(),a={blocked:[],inProgressOpIds:[],converging:[],flipNotLive:[],flipUnverified:[],stillDeclared:[],recreateConverging:[]},o=new Map,c=new Map;for(const e of t){if(ee(n.abortSignal)){a.blocked.push(e);continue}let f=o.get(e.stackName);f===void 0&&(f=await z(r,d,e.stackName,s?.journal),o.set(e.stackName,f));const l=f.find(p=>p.verb==="forget"&&p.targets.some(R=>R.logicalId===e.logicalId));if(l===void 0){const p=f.find(R=>R.verb==="recreate"&&R.targets.some(O=>O.logicalId===e.logicalId));if(p!==void 0){a.recreateConverging.push({finding:e,opId:p.opId,...p.appName!==void 0?{appName:p.appName}:{}});continue}a.blocked.push(e);continue}if(A(l.phase)<A("flip-applied")){a.blocked.push(e),a.inProgressOpIds.includes(l.opId)||a.inProgressOpIds.push(l.opId);continue}let u=c.get(e.stackName);if(u===void 0){const p=await x(n.aws,e.stackName,n.abortSignal);p.success?u={templateBody:p.data.templateBody}:(J.debug(U,"Could not read the live template for the [G2] partition",{stackName:e.stackName,error:p.error.message}),u={errorMessage:p.error.message}),c.set(e.stackName,u)}if("errorMessage"in u){a.blocked.push(e),a.flipUnverified.push({finding:e,opId:l.opId,message:u.errorMessage});continue}const i=M(u.templateBody,[e.logicalId],e.stackName),k=new Set(l.fliplessByNecessity??[]);if(!(i.success&&(i.data.absentTargets.length===1||(i.data.policyPendingTargets.length===0||k.has(e.logicalId))&&i.data.markerOpIds.every(p=>p===l.opId)))){a.blocked.push(e),a.flipNotLive.push({finding:e,opId:l.opId});continue}if(await n.synthDeclaresTarget(e.stackName,e.logicalId)===!0){a.blocked.push(e),a.stillDeclared.push({finding:e,opId:l.opId});continue}a.converging.push({finding:e,opId:l.opId})}return a}async function at(n,t,s,r){const d=n.aws.getAccountId(),a=n.aws.getRegion(),o={verifiedAbsent:[],blocked:[]};for(const c of t){const e=await z(d,a,c,r?.journal);for(const f of e){if(f.verb!=="forget"||A(f.phase)<A("flip-applied"))continue;const l=[];for(const g of f.fliplessByNecessity??[]){const p={stackName:c,logicalId:g,opId:f.opId};if(s.get(c)?.has(g)===!0){o.verifiedAbsent.push(p);continue}if(ee(n.abortSignal)){o.blocked.push({...p,reason:"absence re-verification aborted before it could run"});continue}if(await n.synthDeclaresTarget(c,g)===!0)continue;const O=f.targets.find(B=>B.logicalId===g);if(O===void 0||H(O,c)){o.blocked.push({...p,reason:"no probeable physical id on the covering operation"});continue}l.push({entry:p,target:O})}if(l.length===0)continue;const u=l.map(({entry:g,target:p})=>({stackName:c,logicalId:g.logicalId,resourceType:p.resourceType,physicalId:p.physicalId,failedStatus:"BY_NECESSITY_CONVERGE_CHECK",statusReason:"Absence re-verification before the converge deploy"})),k=await new te(n.aws,r?.probe).probeSuspects({stackName:c,suspects:u,...n.abortSignal!==void 0?{abortSignal:n.abortSignal}:{}});if(!k.success){for(const{entry:g}of l)o.blocked.push({...g,reason:`absence probe failed: ${k.error.message}`});continue}const b=new Map(k.data.map(g=>[g.logicalId,g]));for(const{entry:g}of l){const p=b.get(g.logicalId);if(p===void 0){o.blocked.push({...g,reason:"absence probe returned no verdict for this target"});continue}if(p.verdict==="confirmed-deleted"||p.verdict==="present"&&p.probeMethod===re){o.verifiedAbsent.push(g);continue}o.blocked.push({...g,reason:`${p.verdict}: ${p.verdictDetail}`})}}}return o}async function nt(n,t,s){const r=n.getAccountId(),d=n.getRegion(),a=[...t.stackNames];if(t.appName!==void 0){const o=new Set(a),c=await Pe(r,d,s?.journal);for(const e of c)e.verb==="recreate"&&e.appName===t.appName&&!o.has(e.stackName)&&(o.add(e.stackName),a.push(e.stackName))}for(const o of a){const c=await z(r,d,o,s?.journal);for(const e of c){const f=await De(n,{journal:e,...t.abortSignal!==void 0?{abortSignal:t.abortSignal}:{}},s);if(!f.success){const l=e.verb==="recreate"?`re-run 'fjall deploy${e.appName!==void 0?` ${e.appName}`:""}' to complete it`:`run 'fjall drift resume ${e.opId}' to complete it`;t.onLog?.(`Could not verify ${e.verb} operation ${e.opId} on ${o} after the deploy: ${_(f.error.message)} \u2014 ${l}`,"warn");continue}if(f.data.status==="done"&&e.verb==="recreate"){const l=f.data.journal,u=l.recreatedResources??[],i=new Set(u),k=new Set(l.orphanedResources??[]),b=new Set(l.unverifiedRemovals??[]),g=l.targets.map(w=>w.logicalId).filter(w=>!i.has(w)),p=g.filter(w=>!k.has(w)&&!b.has(w)),R=[];u.length>0&&R.push(`${u.join(", ")} verified live under the pinned names`),p.length>0&&R.push(`${p.join(", ")} removed instead of re-created (no longer declared)`),t.onLog?.(`Recreate operation ${e.opId} completed${R.length>0?` \u2014 ${R.join("; ")}`:""}`,"info");const O=g.filter(w=>k.has(w));if(O.length>0){const w=O.map(E=>{const F=l.targets.find(L=>L.logicalId===E)?.physicalId;return F!==void 0?`${E} [${F}]`:E}).join(", ");t.onLog?.(`Recreate operation ${e.opId}: ${w} left CloudFormation but the physical resource still exists outside CloudFormation \u2014 the consented deletion did not execute. Delete it via the owning service if it is no longer wanted.`,"warn")}const B=g.filter(w=>b.has(w));B.length>0&&t.onLog?.(`Recreate operation ${e.opId}: ${B.join(", ")} left CloudFormation but the physical deletion could not be verified \u2014 check the owning service before treating it as deleted.`,"warn")}else if(f.data.status==="done"){const l=f.data.journal.retainedByRedeclaration??[],u=new Set(l),i=f.data.journal.targets.map(b=>b.logicalId).filter(b=>!u.has(b)),k=i.length>0?`${o} no longer tracks ${i.join(", ")}${l.length>0?` (${l.join(", ")} retained by re-declaration)`:""}`:`${o} retained ${l.join(", ")} by re-declaration`;t.onLog?.(`Forget operation ${e.opId} completed \u2014 ${k}`,"info")}}}}export{Ze as assessForgetResumability,De as completeForgetAfterConverge,nt as completeForgetAfterDeploy,x as fetchLiveStackState,de as formatRecreateInFlightCures,rt as partitionPreFlightByRemediation,et as runForgetSurgery,tt as synthTemplateDeclaresResource,at as verifyByNecessityBeforeConverge};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `pin` arm — the non-destructive escape hatch (plan § 3): probe the LIVE
|
|
3
|
+
* value of each offending property from its owning service and emit an
|
|
4
|
+
* edit-ready codemod into the user's fjall config so the diff disappears.
|
|
5
|
+
* Degrades to report-only carrying the exact config line whenever any step
|
|
6
|
+
* cannot complete confidently — never a partial edit (the
|
|
7
|
+
* `restoreAndReconcileQuarantinedBucket` reconcile doctrine). The codemod is
|
|
8
|
+
* OFFERED to the caller (Q6): this module only computes `modifiedContent`;
|
|
9
|
+
* the surface that owns the config file decides to write it.
|
|
10
|
+
*
|
|
11
|
+
* v1 probe table: RDS DBInstance/DBCluster `Port`. Every other
|
|
12
|
+
* (resourceType, property) pair degrades honestly to `no-probe-support`
|
|
13
|
+
* naming the pair.
|
|
14
|
+
*
|
|
15
|
+
* Failure messages are pre-masked at construction.
|
|
16
|
+
*/
|
|
17
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
18
|
+
export interface PinTarget {
|
|
19
|
+
stack: string;
|
|
20
|
+
logicalId: string;
|
|
21
|
+
resourceType: string;
|
|
22
|
+
/** Ticket `physicalName` — a pinned literal, or the shared placeholder
|
|
23
|
+
* shape (`remediationPlaceholderPhysicalId`) for unpinned rows. */
|
|
24
|
+
physicalName: string;
|
|
25
|
+
/** Offending property paths from the ticket row (bare names or
|
|
26
|
+
* `/properties/`-prefixed pointers). */
|
|
27
|
+
offendingProperties: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/** One probed live value plus the exact config line that pins it. */
|
|
30
|
+
export interface PinnedProperty {
|
|
31
|
+
/** CloudFormation property name (e.g. "Port"). */
|
|
32
|
+
property: string;
|
|
33
|
+
/** fjall config property the codemod writes (e.g. "port"). */
|
|
34
|
+
configProperty: string;
|
|
35
|
+
liveValue: string | number;
|
|
36
|
+
/** The exact config line for manual application (e.g. `port: 5433`). */
|
|
37
|
+
configLine: string;
|
|
38
|
+
}
|
|
39
|
+
export type PinReportOnlyReason = "no-probe-support" | "physical-id-unresolved" | "probe-failed" | "no-infrastructure-content" | "unresolved-construct" | "edit-failed"
|
|
40
|
+
/** The codemod was a byte-identical no-op — the config already carries the
|
|
41
|
+
* probed value(s), so claiming an edit would loop the deploy forever. */
|
|
42
|
+
| "already-pinned";
|
|
43
|
+
interface PinTargetIdentity {
|
|
44
|
+
stack: string;
|
|
45
|
+
logicalId: string;
|
|
46
|
+
resourceType: string;
|
|
47
|
+
physicalName: string;
|
|
48
|
+
}
|
|
49
|
+
export type PinResourceOutcome = (PinTargetIdentity & {
|
|
50
|
+
outcome: "edit-ready";
|
|
51
|
+
/** The construct symbol the codemod edited. */
|
|
52
|
+
constructName: string;
|
|
53
|
+
pinned: PinnedProperty[];
|
|
54
|
+
}) | (PinTargetIdentity & {
|
|
55
|
+
outcome: "report-only";
|
|
56
|
+
reason: PinReportOnlyReason;
|
|
57
|
+
/** Pre-masked human detail (unsupported pairs, probe error, edit error). */
|
|
58
|
+
detail?: string;
|
|
59
|
+
/** Probed values that WERE resolved — the exact config lines the
|
|
60
|
+
* operator can apply by hand even though no edit was emitted. */
|
|
61
|
+
pinned?: PinnedProperty[];
|
|
62
|
+
/** Candidate construct names on ambiguous resolution. */
|
|
63
|
+
candidates?: string[];
|
|
64
|
+
});
|
|
65
|
+
export interface PinRemediationReport {
|
|
66
|
+
outcomes: PinResourceOutcome[];
|
|
67
|
+
/** The config content with EVERY edit-ready outcome applied, present iff
|
|
68
|
+
* at least one target reached edit-ready. Later targets compose on top of
|
|
69
|
+
* earlier edits. */
|
|
70
|
+
modifiedContent?: string;
|
|
71
|
+
/** The exact source snapshot the edits were composed against — present iff
|
|
72
|
+
* `modifiedContent` is. The write-back owner MUST verify the file on disk
|
|
73
|
+
* still matches this snapshot before replacing it wholesale: the consent
|
|
74
|
+
* ceremony can sit for minutes, and a whole-file write composed from a
|
|
75
|
+
* stale read silently reverts a concurrent user edit (lost update). */
|
|
76
|
+
sourceContent?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface PinProbeCoverage {
|
|
79
|
+
/** True iff EVERY offending property has a live-value probe. */
|
|
80
|
+
covered: boolean;
|
|
81
|
+
/** The `<resourceType>#<property>` pairs without a probe (all pairs when
|
|
82
|
+
* no offending properties were recorded — nothing is probeable). */
|
|
83
|
+
unsupportedPairs: string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether the v1 probe table can execute a pin for this resource. The
|
|
87
|
+
* destruction gate consults this BEFORE accepting a `pin` consent — declaring
|
|
88
|
+
* the verb executable and then degrading every target to report-only
|
|
89
|
+
* (no-probe-support) would stop the deploy for a write-back that can never
|
|
90
|
+
* happen.
|
|
91
|
+
*/
|
|
92
|
+
export declare function pinProbeCoverage(resourceType: string, offendingProperties: readonly string[]): PinProbeCoverage;
|
|
93
|
+
/**
|
|
94
|
+
* One-line detail for a pin outcome (everything after the physical name).
|
|
95
|
+
* Shared by `renderPinReportLines` and the CLI's labelled row rendering so
|
|
96
|
+
* both surfaces stay byte-identical. Identifier-only plus pre-masked details
|
|
97
|
+
* — safe for any sink.
|
|
98
|
+
*/
|
|
99
|
+
export declare function formatPinOutcomeDetail(outcome: PinResourceOutcome): string;
|
|
100
|
+
/**
|
|
101
|
+
* Render the per-resource pin outcome lines every surface shares (engine
|
|
102
|
+
* callbacks, CLI human output, agent output).
|
|
103
|
+
*/
|
|
104
|
+
export declare function renderPinReportLines(report: PinRemediationReport): string[];
|
|
105
|
+
export interface RunPinRemediationParams {
|
|
106
|
+
targets: readonly PinTarget[];
|
|
107
|
+
/** The user's infrastructure config source. Absent (worker surfaces with
|
|
108
|
+
* no config checkout) degrades every target to report-only with the exact
|
|
109
|
+
* config lines. */
|
|
110
|
+
infrastructureContent?: string;
|
|
111
|
+
/** Display path for codemod diagnostics. */
|
|
112
|
+
infrastructureFilePath?: string;
|
|
113
|
+
abortSignal?: AbortSignal;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Run the pin arm over consented targets. Never throws; every failure mode
|
|
117
|
+
* is an in-band report-only outcome (pre-masked at construction). Outcomes
|
|
118
|
+
* may be fewer than targets when the abort signal fires mid-loop.
|
|
119
|
+
*/
|
|
120
|
+
export declare function runPinRemediation(aws: AwsProvider, params: RunPinRemediationParams): Promise<PinRemediationReport>;
|
|
121
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CloudFormationClient as w,DescribeStackResourceCommand as B}from"@aws-sdk/client-cloudformation";import{DescribeDBClustersCommand as O,DescribeDBInstancesCommand as T,RDSClient as R}from"@aws-sdk/client-rds";import{success as h,failure as f}from"@fjall/generator";import{listResources as L,modifyResource as A}from"@fjall/generator/codemod";import{getErrorMessage as P,maskSensitiveOutput as D}from"@fjall/util";import{logger as j}from"@fjall/util/logger";import{composeSdkAbortSignal as C,isAborted as k}from"../../aws/organisations/types.js";import{remediationPlaceholderPhysicalId as x}from"../../types/remediation.js";const F="PinRemediation",N="/properties/";async function V(e,n,o){try{const i=(await e.getClient(R).send(new T({DBInstanceIdentifier:n}),{abortSignal:C(o)})).DBInstances?.[0]?.Endpoint?.Port;return i===void 0?f(new Error(`rds:DescribeDBInstances returned no endpoint port for '${n}'`)):h(i)}catch(t){return f(new Error(D(P(t))))}}async function W(e,n,o){try{const i=(await e.getClient(R).send(new O({DBClusterIdentifier:n}),{abortSignal:C(o)})).DBClusters?.[0]?.Port;return i===void 0?f(new Error(`rds:DescribeDBClusters returned no port for '${n}'`)):h(i)}catch(t){return f(new Error(D(P(t))))}}const v={"AWS::RDS::DBInstance#Port":{configProperty:"port",constructType:"database",method:"rds:DescribeDBInstances",probe:V},"AWS::RDS::DBCluster#Port":{configProperty:"port",constructType:"database",method:"rds:DescribeDBClusters",probe:W}};function E(e){return e.startsWith(N)?e.slice(N.length):e}function Z(e,n){if(n.length===0)return{covered:!1,unsupportedPairs:[`${e} (no offending properties recorded)`]};const o=n.map(E).filter(t=>v[`${e}#${t}`]===void 0).map(t=>`${e}#${t}`);return{covered:o.length===0,unsupportedPairs:o}}function _(e){return typeof e=="number"?String(e):JSON.stringify(e)}async function G(e,n,o){const t=x(n.stack,n.logicalId);if(n.physicalName!==t)return h(n.physicalName);try{const s=(await e.getClient(w).send(new B({StackName:n.stack,LogicalResourceId:n.logicalId}),{abortSignal:C(o)})).StackResourceDetail?.PhysicalResourceId;return s===void 0||s===""?f(new Error(`cfn:DescribeStackResource resolved no physical id for ${n.stack}/${n.logicalId}`)):h(s)}catch(r){return f(new Error(D(P(r))))}}function H(e,n,o){const t=L(e,n);if(!t.success)return{detail:`config parse failed: ${t.error.kind}`};const r=t.data.resources.filter(i=>i.type===o);return r.length===1&&r[0]!==void 0?{constructName:r[0].name}:r.length===0?{detail:`no ${o} construct found in the config`}:{candidates:r.map(i=>i.name),detail:`${r.length} ${o} constructs found \u2014 cannot pin unambiguously`}}function J(e){if(e.outcome==="edit-ready")return`pinned ${e.pinned.map(r=>r.configLine).join(", ")} on construct '${e.constructName}' \u2014 edit ready`;const n=e.detail!==void 0?` \u2014 ${e.detail}`:"",o=e.candidates!==void 0&&e.candidates.length>0?`; candidate constructs: ${e.candidates.join(", ")}`:"",t=e.reason!=="already-pinned"&&e.pinned!==void 0&&e.pinned.length>0?`; apply by hand: ${e.pinned.map(r=>r.configLine).join(", ")}`:"";return`report-only (${e.reason})${n}${o}${t}`}function ee(e){return e.outcomes.map(n=>`${n.physicalName}: ${J(n)}`)}async function ne(e,n){const o=n.infrastructureFilePath??"infrastructure.ts",t=[];let r=n.infrastructureContent,i=!1;for(const s of n.targets){if(k(n.abortSignal)){j.debug(F,"Aborted mid-loop \u2014 remaining targets not probed",{remaining:n.targets.length-t.length});break}const d={stack:s.stack,logicalId:s.logicalId,resourceType:s.resourceType,physicalName:s.physicalName},g=s.offendingProperties.map(E).map(c=>({property:c,spec:v[`${s.resourceType}#${c}`]})),b=g.filter(c=>c.spec!==void 0);if(g.length===0||b.length<g.length){const c=g.filter(u=>u.spec===void 0),p=g.length===0?`${s.resourceType} (no offending properties recorded)`:c.map(u=>`${s.resourceType}#${u.property}`).join(", ");t.push({...d,outcome:"report-only",reason:"no-probe-support",detail:`no live-value probe for ${p} in this release`});continue}const $=await G(e,s,n.abortSignal);if(!$.success){t.push({...d,outcome:"report-only",reason:"physical-id-unresolved",detail:$.error.message});continue}const a=[];let m;for(const{property:c,spec:p}of b){if(k(n.abortSignal)){m="aborted before probing completed";break}const u=await p.probe(e,$.data,n.abortSignal);if(!u.success){m=`${p.method}: ${u.error.message}`;break}a.push({property:c,configProperty:p.configProperty,liveValue:u.data,configLine:`${p.configProperty}: ${_(u.data)}`})}if(m!==void 0){t.push({...d,outcome:"report-only",reason:"probe-failed",detail:m,...a.length>0?{pinned:a}:{}});continue}if(r===void 0){t.push({...d,outcome:"report-only",reason:"no-infrastructure-content",detail:"no infrastructure config available on this surface \u2014 apply the config line(s) by hand",pinned:a});continue}const S=b[0]?.spec.constructType??"database",l=H(r,o,S);if(l.constructName===void 0){t.push({...d,outcome:"report-only",reason:"unresolved-construct",...l.detail!==void 0?{detail:l.detail}:{},...l.candidates!==void 0?{candidates:l.candidates}:{},pinned:a});continue}const I={};for(const c of a)I[c.configProperty]=c.liveValue;const y=A(r,{type:S,name:l.constructName,properties:I,filePath:o});if(!y.success){t.push({...d,outcome:"report-only",reason:"edit-failed",detail:`codemod failed: ${y.error.kind}`,pinned:a});continue}if(y.data.content===r){t.push({...d,outcome:"report-only",reason:"already-pinned",detail:"the config already contains the probed value(s) \u2014 no edit needed; if the deploy still plans a replacement, the drift lies outside these properties",pinned:a});continue}r=y.data.content,i=!0,t.push({...d,outcome:"edit-ready",constructName:l.constructName,pinned:a})}return{outcomes:t,...i&&r!==void 0&&n.infrastructureContent!==void 0?{modifiedContent:r,sourceContent:n.infrastructureContent}:{}}}export{J as formatPinOutcomeDetail,Z as pinProbeCoverage,ee as renderPinReportLines,ne as runPinRemediation};
|