@fjall/deploy-core 2.25.0 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/dist/.minified +1 -1
  2. package/dist/src/aws/index.d.ts +1 -1
  3. package/dist/src/aws/index.js +1 -1
  4. package/dist/src/aws/organisations/backup.d.ts +7 -2
  5. package/dist/src/aws/organisations/backup.js +2 -2
  6. package/dist/src/aws/organisations/index.d.ts +2 -2
  7. package/dist/src/aws/organisations/index.js +1 -1
  8. package/dist/src/aws/organisations/oidcProvider.d.ts +37 -0
  9. package/dist/src/aws/organisations/oidcProvider.js +1 -1
  10. package/dist/src/aws/organisations/organisation.d.ts +21 -0
  11. package/dist/src/aws/organisations/organisation.js +1 -1
  12. package/dist/src/aws/organisations/organisationalUnits.d.ts +15 -0
  13. package/dist/src/aws/organisations/organisationalUnits.js +1 -1
  14. package/dist/src/aws/utils/cloudformationEventHelpers.d.ts +45 -0
  15. package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -1
  16. package/dist/src/aws/utils/cloudformationEventTypes.d.ts +1 -1
  17. package/dist/src/aws/utils/cloudformationEvents.d.ts +2 -1
  18. package/dist/src/aws/utils/cloudformationEvents.js +1 -1
  19. package/dist/src/aws/utils/stackStatus.js +1 -1
  20. package/dist/src/index.d.ts +13 -2
  21. package/dist/src/index.js +1 -1
  22. package/dist/src/orchestration/activeDeploymentGuard.d.ts +41 -0
  23. package/dist/src/orchestration/activeDeploymentGuard.js +8 -4
  24. package/dist/src/orchestration/application/applicationDeploy.d.ts +9 -0
  25. package/dist/src/orchestration/application/applicationDeploy.js +1 -1
  26. package/dist/src/orchestration/application/applicationDeployHelpers.d.ts +20 -3
  27. package/dist/src/orchestration/application/applicationDeployHelpers.js +3 -3
  28. package/dist/src/orchestration/application/applicationDestroy.js +1 -1
  29. package/dist/src/orchestration/application/approvalGate.d.ts +15 -1
  30. package/dist/src/orchestration/application/approvalGate.js +1 -1
  31. package/dist/src/orchestration/application/databaseEndpointReconcile.d.ts +77 -0
  32. package/dist/src/orchestration/application/databaseEndpointReconcile.js +1 -0
  33. package/dist/src/orchestration/application/deploySessionPolicy.d.ts +9 -1
  34. package/dist/src/orchestration/application/deploySessionPolicy.js +1 -1
  35. package/dist/src/orchestration/application/destructionGate.d.ts +164 -0
  36. package/dist/src/orchestration/application/destructionGate.js +5 -0
  37. package/dist/src/orchestration/application/dockerBuildHelper.js +1 -1
  38. package/dist/src/orchestration/application/ecrCacheRepository.d.ts +14 -0
  39. package/dist/src/orchestration/application/ecrCacheRepository.js +1 -0
  40. package/dist/src/orchestration/application/leaseGate.d.ts +30 -0
  41. package/dist/src/orchestration/application/leaseGate.js +1 -0
  42. package/dist/src/orchestration/application/plan/approvalToken.d.ts +15 -0
  43. package/dist/src/orchestration/application/plan/approvalToken.js +1 -1
  44. package/dist/src/orchestration/application/plan/changeSetEscalation.d.ts +46 -0
  45. package/dist/src/orchestration/application/plan/changeSetEscalation.js +1 -0
  46. package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +13 -0
  47. package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -1
  48. package/dist/src/orchestration/application/plan/index.d.ts +4 -1
  49. package/dist/src/orchestration/application/plan/index.js +1 -1
  50. package/dist/src/orchestration/application/plan/loadDeployPlan.d.ts +32 -0
  51. package/dist/src/orchestration/application/plan/loadDeployPlan.js +1 -0
  52. package/dist/src/orchestration/application/plan/pinnedNames.d.ts +31 -0
  53. package/dist/src/orchestration/application/plan/pinnedNames.js +1 -0
  54. package/dist/src/orchestration/application/plan/registryOverlay.d.ts +83 -0
  55. package/dist/src/orchestration/application/plan/registryOverlay.js +1 -0
  56. package/dist/src/orchestration/application/plan/renderDeployPlan.d.ts +4 -1
  57. package/dist/src/orchestration/application/plan/renderDeployPlan.js +1 -1
  58. package/dist/src/orchestration/application/plan/types.d.ts +31 -0
  59. package/dist/src/orchestration/application/plan/types.js +1 -0
  60. package/dist/src/orchestration/contextHelpers.d.ts +37 -1
  61. package/dist/src/orchestration/contextHelpers.js +1 -1
  62. package/dist/src/orchestration/drift/classifyDriftFailure.d.ts +31 -0
  63. package/dist/src/orchestration/drift/classifyDriftFailure.js +1 -0
  64. package/dist/src/orchestration/drift/detectStackDrift.d.ts +37 -0
  65. package/dist/src/orchestration/drift/detectStackDrift.js +1 -0
  66. package/dist/src/orchestration/drift/driftPreFlight.d.ts +73 -0
  67. package/dist/src/orchestration/drift/driftPreFlight.js +7 -0
  68. package/dist/src/orchestration/drift/driftProbe.d.ts +113 -0
  69. package/dist/src/orchestration/drift/driftProbe.js +1 -0
  70. package/dist/src/orchestration/drift/driftSuspectJournal.d.ts +40 -0
  71. package/dist/src/orchestration/drift/driftSuspectJournal.js +1 -0
  72. package/dist/src/orchestration/drift/preFlightGate.d.ts +44 -0
  73. package/dist/src/orchestration/drift/preFlightGate.js +2 -0
  74. package/dist/src/orchestration/index.d.ts +19 -0
  75. package/dist/src/orchestration/index.js +1 -1
  76. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.d.ts +7 -0
  77. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.js +1 -1
  78. package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
  79. package/dist/src/orchestration/organisation/organisationDestroy.js +3 -3
  80. package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +8 -1
  81. package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -1
  82. package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -5
  83. package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +28 -2
  84. package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -1
  85. package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
  86. package/dist/src/orchestration/remediation/driftRepairGate.d.ts +66 -0
  87. package/dist/src/orchestration/remediation/driftRepairGate.js +1 -0
  88. package/dist/src/orchestration/remediation/forensicsCapture.d.ts +49 -0
  89. package/dist/src/orchestration/remediation/forensicsCapture.js +1 -0
  90. package/dist/src/orchestration/remediation/forgetResource.d.ts +423 -0
  91. package/dist/src/orchestration/remediation/forgetResource.js +1 -0
  92. package/dist/src/orchestration/remediation/pinRemediation.d.ts +121 -0
  93. package/dist/src/orchestration/remediation/pinRemediation.js +1 -0
  94. package/dist/src/orchestration/remediation/postFailureRepairOffer.d.ts +77 -0
  95. package/dist/src/orchestration/remediation/postFailureRepairOffer.js +14 -0
  96. package/dist/src/orchestration/remediation/recreatePreFlight.d.ts +47 -0
  97. package/dist/src/orchestration/remediation/recreatePreFlight.js +1 -0
  98. package/dist/src/orchestration/remediation/recreateResource.d.ts +110 -0
  99. package/dist/src/orchestration/remediation/recreateResource.js +1 -0
  100. package/dist/src/orchestration/remediation/remediationJournal.d.ts +88 -0
  101. package/dist/src/orchestration/remediation/remediationJournal.js +2 -0
  102. package/dist/src/orchestration/remediation/removalUpdate.d.ts +135 -0
  103. package/dist/src/orchestration/remediation/removalUpdate.js +1 -0
  104. package/dist/src/orchestration/remediation/retainFlip.d.ts +294 -0
  105. package/dist/src/orchestration/remediation/retainFlip.js +1 -0
  106. package/dist/src/services/application/ApplicationStackService.d.ts +5 -3
  107. package/dist/src/services/application/ApplicationStackService.js +1 -1
  108. package/dist/src/services/application/applicationStackHelpers.d.ts +8 -6
  109. package/dist/src/services/application/applicationStackHelpers.js +3 -3
  110. package/dist/src/services/index.d.ts +2 -2
  111. package/dist/src/services/index.js +1 -1
  112. package/dist/src/services/infrastructure/CdkArgumentBuilder.js +1 -1
  113. package/dist/src/services/infrastructure/CdkEventMonitoring.d.ts +15 -0
  114. package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -1
  115. package/dist/src/services/infrastructure/CdkOutputAnalyser.d.ts +8 -0
  116. package/dist/src/services/infrastructure/CdkOutputAnalyser.js +2 -1
  117. package/dist/src/services/infrastructure/CdkService.js +3 -3
  118. package/dist/src/services/infrastructure/CdkServiceTypes.d.ts +7 -0
  119. package/dist/src/services/infrastructure/CfnRegistryService.d.ts +68 -0
  120. package/dist/src/services/infrastructure/CfnRegistryService.js +1 -0
  121. package/dist/src/services/infrastructure/CloudFormationService.d.ts +5 -2
  122. package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
  123. package/dist/src/services/infrastructure/EcsServiceResolver.d.ts +23 -3
  124. package/dist/src/services/infrastructure/EcsServiceResolver.js +1 -1
  125. package/dist/src/services/infrastructure/cdkServiceHelpers.js +1 -1
  126. package/dist/src/services/infrastructure/cfnRegistrySeed.d.ts +10 -0
  127. package/dist/src/services/infrastructure/cfnRegistrySeed.js +1 -0
  128. package/dist/src/services/infrastructure/changeSetProbe.d.ts +88 -0
  129. package/dist/src/services/infrastructure/changeSetProbe.js +1 -0
  130. package/dist/src/services/infrastructure/index.d.ts +2 -0
  131. package/dist/src/services/infrastructure/index.js +1 -1
  132. package/dist/src/services/supporting/CdkContextBuilder.d.ts +1 -0
  133. package/dist/src/services/supporting/CdkContextBuilder.js +1 -1
  134. package/dist/src/types/callbackKeys.d.ts +1 -1
  135. package/dist/src/types/callbackKeys.js +1 -1
  136. package/dist/src/types/callbacks.d.ts +22 -0
  137. package/dist/src/types/config/entitlements.d.ts +9 -0
  138. package/dist/src/types/config/orgConfig.d.ts +6 -0
  139. package/dist/src/types/deployment/DeploymentTypes.d.ts +1 -0
  140. package/dist/src/types/deploymentEventSchema.d.ts +1 -0
  141. package/dist/src/types/deploymentEventSchema.js +1 -1
  142. package/dist/src/types/destruction.d.ts +212 -0
  143. package/dist/src/types/destruction.js +1 -0
  144. package/dist/src/types/drift.d.ts +72 -0
  145. package/dist/src/types/drift.js +1 -0
  146. package/dist/src/types/index.d.ts +8 -2
  147. package/dist/src/types/index.js +1 -1
  148. package/dist/src/types/params.d.ts +161 -0
  149. package/dist/src/types/remediation.d.ts +126 -0
  150. package/dist/src/types/remediation.js +1 -0
  151. package/package.json +5 -4
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The unconditional destruction gate — runs at the read→mutate boundary of
3
+ * every application deploy, independent of the approval gate. No flag can
4
+ * disengage it: `autoApprove`, `skipConfirmation`, `force`, and approval
5
+ * tokens never satisfy it (drift-remediation plan § 1.2 / § 4).
6
+ *
7
+ * Ticketing is stateful/pinned-only (the § 9 ceremony-fatigue defence):
8
+ * - a replacement that loses data → replacement-required
9
+ * - a replacement of a custom-named
10
+ * resource (CloudFormation will REFUSE) → pinned-name-replacement
11
+ * - a delete of a stateful resource → data-loss-delete
12
+ * Stateless unpinned replacements and stateless deletes never ticket — the
13
+ * plan renderer and approval gate already surface them.
14
+ *
15
+ * Consent = exact, case-sensitive `{physicalName, verb}` pairs covering EVERY
16
+ * ticket resource. Surplus, misspelt, or wrong-verb consents fail closed with
17
+ * a per-name verdict table [G6]. `recreate` on unpinned findings executes as
18
+ * CloudFormation's native replacement / the consented delete; `recreate` on a
19
+ * pinned-name row runs the orchestrated snapshot-flip surgery
20
+ * (`runRecreateSurgery` — the 3f pre-bootstrap block); `pin` proceeds into
21
+ * the pre-bootstrap pin arm (live-value probe + codemod offer — the deploy
22
+ * stops for config write-back, nothing is applied). Verbs without an
23
+ * execution arm (`forget` deploy-side, `repair`) withhold with a clear
24
+ * message rather than burning a doomed run.
25
+ */
26
+ import { type Result } from "@fjall/generator";
27
+ import type { DeployCallbacks } from "../../types/callbacks.js";
28
+ import type { DeployOptions } from "../../types/params.js";
29
+ import type { DestructionConsent, DestructionConsentGate, DestructionConsentReport, DestructionFinding, DestructionOutcome, DestructionTicket, DestructionTicketResource, RemediationVerb } from "../../types/destruction.js";
30
+ import type { DeployPlanLoader } from "./plan/loadDeployPlan.js";
31
+ import type { DeployPlan } from "./plan/types.js";
32
+ export declare function legalRemediationVerbs(finding: DestructionFinding): readonly RemediationVerb[];
33
+ export declare function executableRemediationVerbs(finding: DestructionFinding): readonly RemediationVerb[];
34
+ /**
35
+ * [G11]/[H7] The verbs a surface may ADVERTISE for one ticket row — the
36
+ * row's legal vocabulary reduced to exactly what the gates would let
37
+ * execute. Advertising anything wider sells a consent the execution check
38
+ * (`collectExecutionHalts` here, the mirrored check in `runDriftRepairGate`)
39
+ * is guaranteed to halt; the operator types the ceremony, then the gate
40
+ * withholds — a dead-end loop the menu itself caused. The filter mirrors the
41
+ * halt quantifiers exactly:
42
+ * - `abort` always advertises — a matched abort is an operator decline,
43
+ * never an execution halt;
44
+ * - every other verb must be in `executableRemediationVerbs(finding)`;
45
+ * - `pin` additionally needs `pinProbeCoverage` over the offending
46
+ * properties (the v1 probe table).
47
+ * The gate halts stay as the backstop for hand-typed consents; the
48
+ * executable set and probe coverage live here (deploy-core) so no surface
49
+ * re-derives them. Consumers: the CLI's `--remediate` re-run hint, the
50
+ * interactive ceremony's verb menu, the agent ticket blocks and the shared
51
+ * ticket-row renderer below.
52
+ */
53
+ export declare function advertisableRemediationVerbs(resource: Pick<DestructionTicketResource, "verbs" | "finding" | "resourceType" | "offendingProperties">): RemediationVerb[];
54
+ /**
55
+ * `hash(assemblyDigest + canonical resource set)` — the TOCTOU binding for
56
+ * consents minted before apply time. Rows are keyed and sorted so the digest
57
+ * is independent of plan ordering; verbs are deliberately excluded (a verb
58
+ * vocabulary change must not invalidate a consent to the same resource set).
59
+ */
60
+ export declare function computeTicketDigest(assemblyDigest: string, resources: ReadonlyArray<Pick<DestructionTicketResource, "stack" | "logicalId" | "resourceType" | "physicalName" | "finding">>): string;
61
+ /**
62
+ * Build the destruction ticket from an overlay-corrected plan. Returns
63
+ * undefined when the plan contains no ticketable rows (the common case).
64
+ * `physicalName` is the template-pinned literal when one exists; otherwise
65
+ * the `remediationPlaceholderPhysicalId` address — still an exact,
66
+ * unambiguous string the operator can copy from the rendered ticket, and
67
+ * shared-source with the drift-repair ticket's fallback so the same resource
68
+ * reads identically on both tickets in the blocked-deploy → repair flow.
69
+ */
70
+ export declare function buildDestructionTicket(plan: DeployPlan, verifiedAbsentByNecessity?: ReadonlyArray<{
71
+ stackName: string;
72
+ logicalId: string;
73
+ }>): DestructionTicket | undefined;
74
+ export interface EvaluateDestructionConsentsParams {
75
+ ticket: DestructionTicket;
76
+ consents: readonly DestructionConsent[];
77
+ /**
78
+ * The ticket digest the consents were minted against (webapp pre-auth /
79
+ * resumed runs). A mismatch with the freshly recomputed digest fail-closes
80
+ * every consent — the plan changed between mint and apply.
81
+ */
82
+ consentDigest?: string;
83
+ }
84
+ /**
85
+ * Exact-match consent evaluation [G6]. Case-sensitive; never normalised.
86
+ * Every supplied consent gets a verdict row; every uncovered ticket resource
87
+ * fails the gate closed.
88
+ */
89
+ export declare function evaluateDestructionConsents(params: EvaluateDestructionConsentsParams): DestructionConsentReport;
90
+ /**
91
+ * Renderer for ticket rows — every surface renders these same lines. [H6]
92
+ * The verbs line advertises through `advertisableRemediationVerbs` at render
93
+ * time (the ticket itself — and its digest — keeps the full legal
94
+ * vocabulary): a raw `resource.verbs` here would contradict the G11-filtered
95
+ * re-run hint on the same card.
96
+ */
97
+ export declare function renderDestructionTicketLines(ticket: DestructionTicket): string[];
98
+ /** Renderer for the per-name verdict table [G6]. */
99
+ export declare function renderConsentVerdictLines(report: DestructionConsentReport): string[];
100
+ export interface RunDestructionGateParams {
101
+ /** The memoised plan supplier shared with the approval gate. */
102
+ loadPlan: DeployPlanLoader;
103
+ options: DeployOptions;
104
+ target: string;
105
+ callbacks: DeployCallbacks;
106
+ /**
107
+ * Interactive ceremony resolver (from `DeployParams.destructionConsentGate`).
108
+ * Consulted ONLY when the ticket is unsatisfied and NO flag consents were
109
+ * supplied — explicit `--remediate` consents that fail keep failing loudly
110
+ * with the verdict table [G6], never silently re-prompt. The resolution is
111
+ * re-evaluated through `evaluateDestructionConsents` + the digest TOCTOU
112
+ * check, so evaluation never moves surface-side.
113
+ */
114
+ consentGate?: DestructionConsentGate;
115
+ /** Flipless-by-necessity targets the drift pre-flight gate re-proved
116
+ * absent THIS deploy — their delete rows are suppressed from the ticket
117
+ * (see `buildDestructionTicket`). */
118
+ verifiedAbsentByNecessity?: ReadonlyArray<{
119
+ stackName: string;
120
+ logicalId: string;
121
+ }>;
122
+ }
123
+ export type DestructionGateOutcome =
124
+ /** No ticketable rows — the deploy proceeds untouched. */
125
+ {
126
+ kind: "clear";
127
+ }
128
+ /**
129
+ * Every resource consented with an executable verb — proceed. The deploy
130
+ * branches on `verbsByPhysicalName`: any `pin` row diverts into the
131
+ * pre-bootstrap pin arm instead of applying the destructive change.
132
+ */
133
+ | {
134
+ kind: "proceed";
135
+ ticket: DestructionTicket;
136
+ /** The consented verb per exact physical name (matched rows only). */
137
+ verbsByPhysicalName: ReadonlyMap<string, RemediationVerb>;
138
+ }
139
+ /**
140
+ * Consent withheld / incomplete / stale — nothing may be applied.
141
+ * `outcome` says WHY (surfaces must render each variant truthfully — a
142
+ * consented-but-not-executable user is never told to consent again);
143
+ * `reasons` carries the halt/decline lines. Both are identifier-only
144
+ * (physical names, verbs) — pre-masked by construction.
145
+ */
146
+ | {
147
+ kind: "withheld";
148
+ outcome: DestructionOutcome;
149
+ ticket: DestructionTicket;
150
+ report: DestructionConsentReport;
151
+ reasons: string[];
152
+ };
153
+ /**
154
+ * Run the unconditional destruction gate. Loads the (memoised) plan, builds
155
+ * the ticket, evaluates consents, and either clears the boundary or withholds
156
+ * the deploy — emitting the ticket and verdict table over the callbacks so
157
+ * every surface renders the same lines. Deliberately carries NO ready-to-run
158
+ * command [G5]; the CLI's human path appends its own re-run hint.
159
+ *
160
+ * Failure messages are pre-masked at construction — the sole failure path
161
+ * routes the plan error through `maskSensitiveOutput`, so callers may surface
162
+ * `error` to callback/log sinks directly.
163
+ */
164
+ export declare function runDestructionGate(params: RunDestructionGateParams): Promise<Result<DestructionGateOutcome, Error>>;
@@ -0,0 +1,5 @@
1
+ import{createHash as k}from"node:crypto";import{success as g,failure as N}from"@fjall/generator";import{maskSensitiveOutput as D}from"@fjall/util";import{remediationPlaceholderPhysicalId as x}from"../../types/remediation.js";import{pinProbeCoverage as m}from"../remediation/pinRemediation.js";const v={"replacement-required":["abort","pin","recreate"],"pinned-name-replacement":["abort","pin","recreate"],"data-loss-delete":["abort","forget","recreate"],"resource-deleted":["abort","repair","forget","recreate"],"stack-deleted":["abort","repair","recreate"]};function I(e){return v[e]}const f={"replacement-required":["pin","recreate"],"pinned-name-replacement":["pin","recreate"],"data-loss-delete":["recreate"],"resource-deleted":["forget"],"stack-deleted":[]};function C(e){return f[e]}function b(e){const i=C(e.finding);return e.verbs.filter(t=>t==="abort"||i.includes(t)&&(t!=="pin"||m(e.resourceType,e.offendingProperties).covered))}function L(e){if(e.action==="replace")return e.pinnedPhysicalName!==void 0?"pinned-name-replacement":e.dataLoss?"replacement-required":void 0;if(e.action==="delete"&&e.dataLoss&&!e.retained)return"data-loss-delete"}function P(e,i,t){if(i==="data-loss-delete")return"removed from the synthesised template \u2014 CloudFormation will delete the live resource and its data";const o=t.length>0?`${t.join(", ")} forces replacement`:"replacement required (no property-level detail available)",n=e.replacementMode==="may"||e.replacementMode==="conditional"?" (conditional verdict \u2014 worst-cased towards ceremony)":"";return i==="pinned-name-replacement"?`${o}${n}; CloudFormation refuses in-stack replacement of custom-named resources`:`${o}${n}`}function R(e,i){const t=k("sha256");t.update(e),t.update(`
2
+ `);const o=i.map(n=>`${n.stack}|${n.logicalId}|${n.resourceType}|${n.physicalName}|${n.finding}`).sort();for(const n of o)t.update(n),t.update(`
3
+ `);return t.digest("hex")}function T(e,i){const t=new Set((i??[]).map(n=>`${n.stackName}
4
+ ${n.logicalId}`)),o=[];for(const n of e.changes){if(n.action==="delete"&&t.has(`${n.stack}
5
+ ${n.address}`))continue;const s=L(n);if(s===void 0)continue;const r=n.propertyChanges.filter(d=>d.forcesReplacement).map(d=>d.path);o.push({stack:n.stack,logicalId:n.address,resourceType:n.resourceType,physicalName:n.pinnedPhysicalName??x(n.stack,n.address),finding:s,cause:P(n,s,r),offendingProperties:r,verdictSource:n.verdictSource??"cdk-spec (unverified)",dataLossRisk:n.dataLoss,verbs:[...v[s]]})}if(o.length!==0)return{ticketDigest:R(e.assemblyDigest,o),resources:o}}function y(e){const{ticket:i,consents:t,consentDigest:o}=e,n=o!==void 0&&o!==i.ticketDigest,s=new Map(i.resources.map(a=>[a.physicalName,a])),r=t.map(a=>{const u=s.get(a.physicalName);return u===void 0?{physicalName:a.physicalName,verb:a.verb,verdict:"unmatched",detail:"no pending destructive resource has this exact name"}:u.verbs.includes(a.verb)?{physicalName:a.physicalName,verb:a.verb,verdict:"matched"}:{physicalName:a.physicalName,verb:a.verb,verdict:"wrong-verb",detail:`legal verbs for ${u.finding}: ${b(u).join(", ")}`}}),d=new Set(r.filter(a=>a.verdict==="matched").map(a=>a.physicalName)),p=i.resources.map(a=>a.physicalName).filter(a=>!d.has(a)),h=!n&&p.length===0&&r.length>0&&r.every(a=>a.verdict==="matched");return{rows:r,uncovered:p,digestMismatch:n,satisfied:h,...n&&o!==void 0?{suppliedDigest:o,currentDigest:i.ticketDigest}:{}}}function E(e){const i=[];for(const t of e.resources){const o=t.dataLossRisk?" (data loss)":"";i.push(`! ${t.finding} ${t.resourceType} ${t.stack}/${t.logicalId}${o}`),i.push(` physical name: ${t.physicalName}`),i.push(` cause: ${t.cause}`),i.push(` legal verbs: ${b(t).join(" | ")}`)}return i}function M(e){const i=[];if(e.digestMismatch){const t=e.suppliedDigest!==void 0&&e.currentDigest!==void 0?` (consents were minted against '${e.suppliedDigest}'; the pending destructive changes now digest to '${e.currentDigest}')`:"";i.push(`consent digest mismatch: the plan changed since consent was given \u2014 re-review and consent again${t}`)}if(e.rows.length>0){i.push("consent verdicts:");for(const t of e.rows){const o=t.detail!==void 0?` \u2014 ${t.detail}`:"";i.push(` ${t.physicalName}=${t.verb} ${t.verdict}${o}`)}}if(e.uncovered.length>0){i.push("awaiting consent:");for(const t of e.uncovered)i.push(` ${t}`)}return i}function $(e,i){const t=new Map(e.resources.map(n=>[n.physicalName,n])),o=[];for(const n of i){if(n.verdict!=="matched")continue;const s=t.get(n.physicalName);if(s!==void 0){if(n.verb==="abort"){o.push(`${n.physicalName}: aborted by operator consent`);continue}if(!f[s.finding].includes(n.verb)){o.push(`${n.physicalName}: verb '${n.verb}' is accepted vocabulary but not yet executable in this release for ${s.finding} \u2014 executable today: ${f[s.finding].join(", ")||"none (abort only)"}`);continue}if(n.verb==="pin"){const r=m(s.resourceType,s.offendingProperties);r.covered||o.push(`${n.physicalName}: verb 'pin' has no live-value probe for ${r.unsupportedPairs.join(", ")} in this release \u2014 pin cannot capture the live value(s) into the config for this resource. Executable today for ${s.finding}: ${f[s.finding].filter(d=>d!=="pin").join(", ")||"none (abort only)"}`)}}}return o}async function A(e){const i=await e.loadPlan();if(!i.success)return N(new Error(D(`Destruction gate could not compute the deploy plan: ${i.error.message}`)));const t=T(i.data,e.verifiedAbsentByNecessity);if(t===void 0)return g({kind:"clear"});const o=c=>{e.callbacks.onLog?.(`Destruction gate: ${t.resources.length} destructive change(s) proceeding under named consent (${c.rows.map(l=>`${l.physicalName}=${l.verb}`).join(", ")})`,"warn");const w=new Map(c.rows.filter(l=>l.verdict==="matched").map(l=>[l.physicalName,l.verb]));return g({kind:"proceed",ticket:t,verbsByPhysicalName:w})},n=e.options.destructionConsents??[];let s=y({ticket:t,consents:n,...e.options.destructionConsentDigest!==void 0?{consentDigest:e.options.destructionConsentDigest}:{}}),r=s.satisfied?$(t,s.rows):[];if(s.satisfied&&r.length===0)return o(s);const d=[];if(e.consentGate!==void 0&&n.length===0&&e.options.destructionConsentDigest===void 0){const c=await e.consentGate.resolve({target:e.target,ticket:t});if(c.kind==="abort")d.push("declined at the interactive destruction ceremony \u2014 nothing was applied");else if(s=y({ticket:t,consents:c.consents,consentDigest:c.ticketDigest}),r=s.satisfied?$(t,s.rows):[],s.satisfied&&r.length===0)return o(s)}const p=[...d,...r],h=s.satisfied&&s.rows.some(c=>c.verdict==="matched"&&c.verb==="abort"),a=d.length>0||h?"declined":r.length>0?"consent-not-executable":s.rows.length>0||s.digestMismatch?"consents-invalid":"awaiting-consent",u={"awaiting-consent":`Destructive changes for ${e.target} require named consent \u2014 deploy withheld, nothing was applied`,declined:`Deployment of ${e.target} declined \u2014 the destructive changes were not consented; nothing was applied`,"consents-invalid":`Deployment of ${e.target} withheld \u2014 the supplied consents did not match the pending destructive changes; nothing was applied`,"consent-not-executable":`Deployment of ${e.target} withheld \u2014 consent was given, but the consented remediation is not yet executable in this release; nothing was applied`};e.callbacks.onLog?.(u[a],"warn");for(const c of E(t))e.callbacks.onLog?.(c,"warn");for(const c of M(s))e.callbacks.onLog?.(c,"warn");for(const c of p)e.callbacks.onLog?.(c,"warn");return(a==="awaiting-consent"||a==="consents-invalid")&&e.callbacks.onLog?.("Consent with --remediate <physicalName>=<verb> (repeatable; exact, case-sensitive) or --allow-replace <physicalName> for plain replacements. No blanket flag exists: --auto-approve, --skip-confirmation and approval tokens never satisfy this gate.","warn"),g({kind:"withheld",outcome:a,ticket:t,report:s,reasons:p})}export{b as advertisableRemediationVerbs,T as buildDestructionTicket,R as computeTicketDigest,y as evaluateDestructionConsents,C as executableRemediationVerbs,I as legalRemediationVerbs,M as renderConsentVerdictLines,E as renderDestructionTicketLines,A as runDestructionGate};
@@ -1 +1 @@
1
- import{isAbsolute as K,join as $,dirname as H,resolve as x}from"node:path";import{existsSync as T}from"node:fs";import{success as k,failure as f}from"@fjall/generator";import{deriveContentHashTag as F,maskSensitiveOutput as m,toKebab as I}from"@fjall/util";import{evaluateBakeGuard as U,evaluateResolvedBuildArgValues as G,acknowledgedBuildArgKeys as V,BAKE_GUARD_EXPOSURE_CLAUSE as M}from"@fjall/util/docker";import{logger as P}from"@fjall/util/logger";import{parseDockerServicesFromManifest as W}from"@fjall/util/manifest";import{STEP_IDS as b}from"../../types/stepDefinitions.js";import{resolveBuildSecrets as z,resolveSecretRefValue as X,resolveBuildSecretSessionProvider as Y,sourcedRefsFromBuildArgs as q}from"./buildSecretResolver.js";import{partitionForRegion as J}from"./buildSecretSession.js";import{resolveBuildArgs as Q}from"./buildArgResolver.js";import{validateBuildGroup as Z}from"./buildGroupValidator.js";const C="dockerBuildHelper",E="Building and pushing Docker image";function ee(e,s,r){const t=$(s,e);if(T(t))return{path:t,base:s};if(r!==""&&r!==s){const o=$(r,e);if(T(o))return{path:o,base:r}}return{path:t,base:s}}function te(e,s,r){const t=e.docker.path,o=e.docker.context;let a,i;if(K(t))a=t,i=s;else{const c=ee(t,s,r);a=c.path,i=c.base}return{buildContext:o?K(o)?x(o):x(i,o):x(H(a)),dockerfilePath:a,target:e.docker.target,resolvedViaFallback:i!==s}}function re(e){return`${e.buildContext}|${e.dockerfilePath}|${e.target??""}`}function ne(e,s){return`Dockerfile not found for service '${e}' at ${s}. Checked the synth directory and the working directory. If your Dockerfile lives at the project root with the CDK app in a subdirectory, set an absolute docker.context (the build context must share the Dockerfile's directory).`}function ie(e){return e.startsWith("cn-")?"amazonaws.com.cn":"amazonaws.com"}function oe(e,s,r){return`${e}.dkr.ecr.${s}.${ie(s)}/${I(r)}`}function se(e,s,r){const t=new Map;for(const o of e){const a=te(o,s,r),i=re(a),p=t.get(i);p?p.members.push(o):t.set(i,{identity:a,members:[o]})}return Array.from(t.values())}async function ae(e,s,r,t,o){const a=await e.buildAndPush({appName:s.appName,appPath:s.path,region:r,accountId:t},(i,p,c,v)=>{o.onDockerProgress?.(m(i),p,c,v)});return a.success?k({imageUri:a.data.imageUri,imageTag:a.data.imageTag}):f(a.error)}function L(){return{contentHashTagsByService:{},imageDigestsByService:{}}}async function de(e,s,r,t,o,a,i,p,c,v,y,h){const u=e.members[0];if(!T(e.identity.dockerfilePath))return f(new Error(ne(u.name,e.identity.dockerfilePath)));e.identity.resolvedViaFallback&&i.onLog?.(`Building service '${u.name}' from ${e.identity.dockerfilePath} (resolved at the project root).`,"info");const n=[...u.docker.buildSecrets??[],...h??[]],g=Z({serviceName:u.name,dockerfilePath:e.identity.dockerfilePath,buildContext:e.identity.buildContext,...e.identity.target!==void 0&&{target:e.identity.target},...u.docker.buildArgs!==void 0&&{buildArgs:u.docker.buildArgs},...n.length>0&&{buildSecrets:n},members:e.members.map(d=>({serviceName:d.name,...d.docker.buildArgs!==void 0&&{buildArgs:d.docker.buildArgs},...d.docker.buildSecrets!==void 0&&{buildSecrets:d.docker.buildSecrets}}))});if(!g.success)return f(g.error);const l=U(u.docker.buildArgs,I(r.appName));for(const d of l.warnings)i.onLog?.(m(d.message),"warn");if(l.findings.length>0)return f(new Error(m(l.findings.map(d=>d.message).join(" "))));const R=n,B=q(u.docker.buildArgs),A={appName:r.appName,region:t,accountId:o,partition:J(t)},S=await Y(p,[...R,...B],A);if(!S.success)return f(S.error);const D=S.data,N=await Q({manifestBuildArgs:u.docker.buildArgs,serviceDir:e.identity.buildContext,appDir:r.path,...v!==void 0&&{stage:v},shellEnv:process.env,...y!==void 0&&{flagBuildArgs:y},resolveSourcedValue:(d,j)=>X(D,{id:d,...j},c)});if(!N.success)return f(N.error);const O=N.data.buildArgs;for(const d of N.data.inferredDotenvKeys)i.onLog?.(m(`buildArg "${d}" was auto-declared from a .env file and will be baked into the image \u2014 ${M}.`),"warn");const _=G(O,I(r.appName),V(u.docker.buildArgs));if(_.length>0)return f(new Error(m(_.map(d=>d.message).join(" "))));let w;if(R.length>0){const d=await z(D,R,c);if(!d.success)return f(d.error);w=d.data}try{const d={appName:r.appName,appPath:e.identity.buildContext,region:t,accountId:o,buildContext:e.identity.buildContext,dockerfilePath:e.identity.dockerfilePath,pushByDigest:!0,serviceName:u.name,...e.identity.target!==void 0&&{target:e.identity.target},...Object.keys(O).length>0&&{buildArgs:O},...w!==void 0&&{buildSecrets:w.resolved}};return await ce(e,d,s,a,i)}finally{w!==void 0&&await w.cleanup().catch(()=>i.onLog?.("Build-secret cleanup failed.","warn"))}}async function ce(e,s,r,t,o){const a=await r.buildAndPush(s,(h,u,n,g)=>{o.onDockerProgress?.(m(h),u,n,g)});if(!a.success)return f(a.error);const i=a.data.imageDigest;if(typeof i!="string"||!i.startsWith("sha256:"))return f(new Error(`Buildx push succeeded but returned an invalid digest: ${m(String(i))}`));const p={},c={},v=[];for(const h of e.members){const u=h.docker.target,n=F(i,h.name,u);if(!n.success)return f(n.error);const g=n.data,l=h.name;p[l]=g,c[l]=i,v.push(`${t}:${g}`)}const y=await r.tagByDigest({sourceImage:t,digest:i,tags:v});return y.success?k({contentHashTagsByService:p,imageDigestsByService:c}):f(y.error)}async function Re(e,s,r,t){const o=e.dockerProvider;if(!o)return k(L());const a=s.awsProvider.getAccountId();if(!a)return t.onLog?.("Skipping Docker build \u2014 account ID not available","warn"),k(L());const i=s.awsProvider.getRegion(),p=$(r.path,"cdk.out"),c=W(p),v=oe(a,i,r.appName);if(P.debug(C,"runDockerBuild starting",{appName:r.appName,appPath:r.path,accountId:a,region:i,cdkOutPath:p,manifestServiceCount:c.length,manifestServices:c.map(n=>({name:n.name,path:n.docker.path,context:n.docker.context,target:n.docker.target})),stepId:b.DOCKER_OPERATIONS,stepName:E}),t.onStepStart?.(b.DOCKER_OPERATIONS,E),c.length===0){t.onLog?.("Initialising ECR repository\u2026","info");const n=await o.initialiseECR({appName:r.appName,region:i,accountId:a});n.success||t.onLog?.(`ECR initialisation failed: ${m(n.error.message)}`,"warn"),t.onLog?.("Building and pushing Docker image\u2026","info");const g=await ae(o,r,i,a,t);if(!g.success){P.debug(C,"Docker buildAndPush (legacy) failed",{appName:r.appName,error:m(g.error.message)}),t.onStepComplete?.(b.DOCKER_OPERATIONS,E,"error");const l=new Error(m(g.error.message));return t.onError?.(l),f(l)}return t.onStepComplete?.(b.DOCKER_OPERATIONS,E,"completed"),t.onLog?.(`Docker image pushed: ${g.data.imageUri}`,"info"),k(L())}const y=await o.preflight?.();if(y!==void 0&&!y.success){t.onStepComplete?.(b.DOCKER_OPERATIONS,E,"error");const n=new Error(m(y.error.message));return t.onError?.(n),f(n)}const h=se(c,r.path,e.workingDirectory);t.onLog?.(`Building and pushing ${c.length} service image(s) in ${h.length} group(s)\u2026`,"info");let u=!1;if(o.beginEcrSession!==void 0){const n=await o.beginEcrSession({accountId:a,region:i});if(!n.success){t.onStepComplete?.(b.DOCKER_OPERATIONS,E,"error");const g=new Error(m(n.error.message));return t.onError?.(g),f(g)}u=!0}try{const n={},g={};for(let l=0;l<h.length;l++){const R=h[l],B=R.members.map(S=>S.name).join(", ");t.onLog?.(`Building group ${l+1}/${h.length} (${B})\u2026`,"info");const A=await de(R,o,r,i,a,v,t,s.awsProvider,e.abortSignal,e.options?.environment,e.options?.flagBuildArgs,e.options?.flagBuildSecrets);if(!A.success){P.debug(C,"Docker buildAndPush failed",{appName:r.appName,leader:R.members[0]?.name,members:B,error:m(A.error.message)}),t.onStepComplete?.(b.DOCKER_OPERATIONS,E,"error");const S=new Error(m(A.error.message));return t.onError?.(S),f(S)}for(const[S,D]of Object.entries(A.data.contentHashTagsByService))n[S]=D;for(const[S,D]of Object.entries(A.data.imageDigestsByService))g[S]=D}return P.debug(C,"Docker buildAndPush succeeded",{appName:r.appName,services:c.map(l=>l.name),groupCount:h.length,contentHashTags:n}),t.onStepComplete?.(b.DOCKER_OPERATIONS,E,"completed"),t.onLog?.(`Docker images pushed for ${c.length} service(s)`,"info"),k({contentHashTagsByService:n,imageDigestsByService:g,repoUri:v})}finally{u&&o.endEcrSession!==void 0&&await o.endEcrSession().catch(n=>{P.debug(C,"endEcrSession cleanup failed",{error:m(n instanceof Error?n.message:String(n))})})}}export{E as DOCKER_BUILD_STEP_NAME,L as emptyBuildProducts,Re as runDockerBuild};
1
+ import{isAbsolute as F,join as L,dirname as M,resolve as _}from"node:path";import{existsSync as K}from"node:fs";import{success as N,failure as h}from"@fjall/generator";import{deriveContentHashTag as W,maskSensitiveOutput as b,toKebab as x}from"@fjall/util";import{evaluateBakeGuard as z,evaluateResolvedBuildArgValues as X,acknowledgedBuildArgKeys as Y,buildCacheRepositoryName as H,BAKE_GUARD_EXPOSURE_CLAUSE as q}from"@fjall/util/docker";import{runWithBoundedConcurrency as J,resolveBuildConcurrency as Q}from"@fjall/util/buildConcurrency";import{logger as T}from"@fjall/util/logger";import{parseDockerServicesFromManifest as Z}from"@fjall/util/manifest";import{STEP_IDS as A}from"../../types/stepDefinitions.js";import{isAborted as U}from"../../aws/organisations/types.js";import{resolveBuildSecrets as ee,resolveSecretRefValue as re,resolveBuildSecretSessionProvider as te,sourcedRefsFromBuildArgs as ne}from"./buildSecretResolver.js";import{partitionForRegion as oe}from"./buildSecretSession.js";import{resolveBuildArgs as ie}from"./buildArgResolver.js";import{validateBuildGroup as se}from"./buildGroupValidator.js";import{ensureBuildCacheRepository as ae}from"./ecrCacheRepository.js";const I="dockerBuildHelper",C="Building and pushing Docker image";function de(e,n,t){const r=L(n,e);if(K(r))return{path:r,base:n};if(t!==""&&t!==n){const i=L(t,e);if(K(i))return{path:i,base:t}}return{path:r,base:n}}function ue(e,n,t){const r=e.docker.path,i=e.docker.context;let s,a;if(F(r))s=r,a=n;else{const p=de(r,n,t);s=p.path,a=p.base}return{buildContext:i?F(i)?_(i):_(a,i):_(M(s)),dockerfilePath:s,target:e.docker.target,resolvedViaFallback:a!==n}}function ce(e){return`${e.buildContext}|${e.dockerfilePath}|${e.target??""}`}function le(e,n){return`Dockerfile not found for service '${e}' at ${n}. Checked the synth directory and the working directory. If your Dockerfile lives at the project root with the CDK app in a subdirectory, set an absolute docker.context (the build context must share the Dockerfile's directory).`}function fe(e){return e.startsWith("cn-")?"amazonaws.com.cn":"amazonaws.com"}function G(e,n){return`${e}.dkr.ecr.${n}.${fe(n)}`}function ge(e,n,t){return`${G(e,n)}/${x(t)}`}function me(e,n,t){return`${G(e,n)}/${H(x(t))}`}function pe(e,n,t){const r=new Map;for(const i of e){const s=ue(i,n,t),a=ce(s),m=r.get(a);m?m.members.push(i):r.set(a,{identity:s,members:[i]})}return Array.from(r.values())}async function he(e,n,t,r,i){const s=await e.buildAndPush({appName:n.appName,appPath:n.path,region:t,accountId:r},(a,m,p,S)=>{i.onDockerProgress?.(b(a),m,p,S)});return s.success?N({imageUri:s.data.imageUri,imageTag:s.data.imageTag}):h(s.error)}function j(){return{contentHashTagsByService:{},imageDigestsByService:{}}}async function be(e,n,t,r,i,s,a,m,p,S,w,l,E){const c=e.members[0];if(!K(e.identity.dockerfilePath))return h(new Error(le(c.name,e.identity.dockerfilePath)));e.identity.resolvedViaFallback&&m.onLog?.(`Building service '${c.name}' from ${e.identity.dockerfilePath} (resolved at the project root).`,"info");const y=[...c.docker.buildSecrets??[],...E??[]],v=se({serviceName:c.name,dockerfilePath:e.identity.dockerfilePath,buildContext:e.identity.buildContext,...e.identity.target!==void 0&&{target:e.identity.target},...c.docker.buildArgs!==void 0&&{buildArgs:c.docker.buildArgs},...y.length>0&&{buildSecrets:y},members:e.members.map(o=>({serviceName:o.name,...o.docker.buildArgs!==void 0&&{buildArgs:o.docker.buildArgs},...o.docker.buildSecrets!==void 0&&{buildSecrets:o.docker.buildSecrets}}))});if(!v.success)return h(v.error);const k=z(c.docker.buildArgs,x(t.appName));for(const o of k.warnings)m.onLog?.(b(o.message),"warn");if(k.findings.length>0)return h(new Error(b(k.findings.map(o=>o.message).join(" "))));const d=y,f=ne(c.docker.buildArgs),D={appName:t.appName,region:r,accountId:i,partition:oe(r)},$=await te(p,[...d,...f],D);if(!$.success)return h($.error);const O=$.data,R=await ie({manifestBuildArgs:c.docker.buildArgs,serviceDir:e.identity.buildContext,appDir:t.path,...w!==void 0&&{stage:w},shellEnv:process.env,...l!==void 0&&{flagBuildArgs:l},resolveSourcedValue:(o,P)=>re(O,{id:o,...P},S)});if(!R.success)return h(R.error);const B=R.data.buildArgs;for(const o of R.data.inferredDotenvKeys)m.onLog?.(b(`buildArg "${o}" was auto-declared from a .env file and will be baked into the image \u2014 ${q}.`),"warn");const u=X(B,x(t.appName),Y(c.docker.buildArgs));if(u.length>0)return h(new Error(b(u.map(o=>o.message).join(" "))));let g;if(d.length>0){const o=await ee(O,d,S);if(!o.success)return h(o.error);g=o.data}try{if(U(S))return h(new Error(`Build of service '${c.name}' was aborted before the docker build started`));const o={appName:t.appName,appPath:e.identity.buildContext,region:r,accountId:i,buildContext:e.identity.buildContext,dockerfilePath:e.identity.dockerfilePath,pushByDigest:!0,serviceName:c.name,...a!==void 0&&{cacheRepoUri:a},...e.identity.target!==void 0&&{target:e.identity.target},...Object.keys(B).length>0&&{buildArgs:B},...g!==void 0&&{buildSecrets:g.resolved}};return await ve(e,o,n,s,m)}finally{g!==void 0&&await g.cleanup().catch(()=>m.onLog?.("Build-secret cleanup failed.","warn"))}}async function ve(e,n,t,r,i){const s=await t.buildAndPush(n,(l,E,c,y)=>{i.onDockerProgress?.(b(l),E,c,y)});if(!s.success)return h(s.error);const a=s.data.imageDigest;if(typeof a!="string"||!a.startsWith("sha256:"))return h(new Error(`Buildx push succeeded but returned an invalid digest: ${b(String(a))}`));const m={},p={},S=[];for(const l of e.members){const E=l.docker.target,c=W(a,l.name,E);if(!c.success)return h(c.error);const y=c.data,v=l.name;m[v]=y,p[v]=a,S.push(`${r}:${y}`)}const w=await t.tagByDigest({sourceImage:r,digest:a,tags:S});return w.success?N({contentHashTagsByService:m,imageDigestsByService:p}):h(w.error)}async function Te(e,n,t,r){const i=e.dockerProvider;if(!i)return N(j());const s=n.awsProvider.getAccountId();if(!s)return r.onLog?.("Skipping Docker build \u2014 account ID not available","warn"),N(j());const a=n.awsProvider.getRegion(),m=L(t.path,"cdk.out"),p=Z(m),S=ge(s,a,t.appName);if(T.debug(I,"runDockerBuild starting",{appName:t.appName,appPath:t.path,accountId:s,region:a,cdkOutPath:m,manifestServiceCount:p.length,manifestServices:p.map(d=>({name:d.name,path:d.docker.path,context:d.docker.context,target:d.docker.target})),stepId:A.DOCKER_OPERATIONS,stepName:C}),r.onStepStart?.(A.DOCKER_OPERATIONS,C),p.length===0){r.onLog?.("Initialising ECR repository\u2026","info");const d=await i.initialiseECR({appName:t.appName,region:a,accountId:s});d.success||r.onLog?.(`ECR initialisation failed: ${b(d.error.message)}`,"warn"),r.onLog?.("Building and pushing Docker image\u2026","info");const f=await he(i,t,a,s,r);if(!f.success){T.debug(I,"Docker buildAndPush (legacy) failed",{appName:t.appName,error:b(f.error.message)}),r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"error");const D=new Error(b(f.error.message));return r.onError?.(D),h(D)}return r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"completed"),r.onLog?.(`Docker image pushed: ${f.data.imageUri}`,"info"),N(j())}const w=await i.preflight?.();if(w!==void 0&&!w.success){r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"error");const d=new Error(b(w.error.message));return r.onError?.(d),h(d)}const l=pe(p,t.path,e.workingDirectory);let E=me(s,a,t.appName);const c=await ae(n.awsProvider,H(x(t.appName)),e.abortSignal);c.success||(E=void 0,r.onLog?.(`Building without registry cache \u2014 ${c.error.message}`,"warn")),r.onLog?.(`Building and pushing ${p.length} service image(s) in ${l.length} group(s)\u2026`,"info");let y=!1;if(i.beginEcrSession!==void 0){const d=await i.beginEcrSession({accountId:s,region:a});if(!d.success){r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"error");const f=new Error(b(d.error.message));return r.onError?.(f),h(f)}y=!0}const v=new AbortController,k=()=>v.abort();e.abortSignal!==void 0&&(e.abortSignal.aborted?v.abort():e.abortSignal.addEventListener("abort",k,{once:!0}));try{const d=()=>U(e.abortSignal)?"the deployment was cancelled":"a sibling group failed";let f;const D=Q(l.length),$=l.map((u,g)=>async()=>{const o=u.members.map(V=>V.name).join(", ");if(v.signal.aborted)throw new Error(`Build of group ${g+1}/${l.length} (${o}) was not started \u2014 ${d()}`);r.onLog?.(`Building group ${g+1}/${l.length} (${o})\u2026`,"info");const P=await be(u,i,t,a,s,S,E,r,n.awsProvider,v.signal,e.options?.environment,e.options?.flagBuildArgs,e.options?.flagBuildSecrets);if(!P.success)throw f===void 0&&(f={error:P.error,leader:u.members[0]?.name,members:o}),v.abort(),P.error;return P.data}),O=await J($,D,{abortOnFirstFailure:!0,abortSignal:v.signal});if(f===void 0)for(let u=0;u<l.length;u++){const g=O[u];if(!(g!==void 0&&g.success)){f={error:g!==void 0&&!g.success?g.error:new Error(`Build of group ${u+1}/${l.length} never ran \u2014 ${d()}`),leader:l[u].members[0]?.name,members:l[u].members.map(o=>o.name).join(", ")};break}}if(f!==void 0){T.debug(I,"Docker buildAndPush failed",{appName:t.appName,leader:f.leader,members:f.members,error:b(f.error.message)}),r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"error");const u=new Error(b(f.error.message));return r.onError?.(u),h(u)}const R={},B={};for(const u of O)if(!(u===void 0||!u.success)){for(const[g,o]of Object.entries(u.data.contentHashTagsByService))R[g]=o;for(const[g,o]of Object.entries(u.data.imageDigestsByService))B[g]=o}return T.debug(I,"Docker buildAndPush succeeded",{appName:t.appName,services:p.map(u=>u.name),groupCount:l.length,contentHashTags:R}),r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"completed"),r.onLog?.(`Docker images pushed for ${p.length} service(s)`,"info"),N({contentHashTagsByService:R,imageDigestsByService:B,repoUri:S})}finally{e.abortSignal?.removeEventListener("abort",k),y&&i.endEcrSession!==void 0&&await i.endEcrSession().catch(d=>{T.debug(I,"endEcrSession cleanup failed",{error:b(d instanceof Error?d.message:String(d))})})}}export{C as DOCKER_BUILD_STEP_NAME,j as emptyBuildProducts,Te as runDockerBuild};
@@ -0,0 +1,14 @@
1
+ import { type Result } from "@fjall/generator";
2
+ import type { AwsProvider } from "../../aws/AwsProvider.js";
3
+ /**
4
+ * Idempotently provision the buildx registry-cache repository beside the app's
5
+ * image repository: create-if-absent, then (re)apply the shared untagged-image
6
+ * lifecycle policy. Mirrors the `fjall build` provisioner
7
+ * (`@fjall/cli` EcrBuildOrchestrator → EcrRepositoryService) — both address the
8
+ * same physical repo via `buildCacheRepositoryName` and the shared policy text,
9
+ * so neither path can drift the other.
10
+ *
11
+ * Failure messages are pre-masked at construction — callers may surface
12
+ * `error.message` to log/callback sinks without re-masking.
13
+ */
14
+ export declare function ensureBuildCacheRepository(awsProvider: AwsProvider, cacheRepositoryName: string, abortSignal?: AbortSignal): Promise<Result<void>>;
@@ -0,0 +1 @@
1
+ import{ECRClient as s,CreateRepositoryCommand as u,PutLifecyclePolicyCommand as m}from"@aws-sdk/client-ecr";import{success as y,failure as i}from"@fjall/generator";import{getErrorMessage as n,maskSensitiveOutput as c}from"@fjall/util";import{untaggedLifecyclePolicyText as d,CACHE_REPO_UNTAGGED_RETENTION_DAYS as p}from"@fjall/util/docker";import{logger as f}from"@fjall/util/logger";import{composeSdkAbortSignal as a,extractErrorName as g}from"../../aws/organisations/types.js";const C="EcrCacheRepository";async function O(l,r,t){const o=l.getClient(s);try{await o.send(new u({repositoryName:r,imageScanningConfiguration:{scanOnPush:!0},imageTagMutability:"MUTABLE"}),{abortSignal:a(t)}),f.debug(C,"Created build-cache repository",{cacheRepositoryName:r})}catch(e){if(g(e)!=="RepositoryAlreadyExistsException")return i(new Error(`Could not create build-cache repository '${r}': ${c(n(e))}`))}try{await o.send(new m({repositoryName:r,lifecyclePolicyText:d(p)}),{abortSignal:a(t)})}catch(e){return i(new Error(`Could not set lifecycle policy on build-cache repository '${r}': ${c(n(e))}`))}return y(void 0)}export{O as ensureBuildCacheRepository};
@@ -0,0 +1,30 @@
1
+ import { BaseServiceError } from "../../types/errors/ServiceError.js";
2
+ import type { DeployParams } from "../../types/params.js";
3
+ /**
4
+ * The deployment's lease was denied at a stack boundary (§5.3): the server has
5
+ * released or superseded this deployment (cancelled, swept, adopted by another
6
+ * runner, or force-released) while the engine was mid-flight. The engine aborts
7
+ * BEFORE starting the named stack's first mutating CloudFormation operation —
8
+ * already-completed stacks stand. The message is surface-neutral (no CLI flags
9
+ * or commands) and built from static literals + the stack name, so there is
10
+ * nothing to mask.
11
+ */
12
+ export declare class LeaseDeniedError extends BaseServiceError {
13
+ readonly stackName: string;
14
+ constructor(message: string, stackName: string);
15
+ }
16
+ /** Which mutating operation the gate protects — drives the denial wording. */
17
+ export type LeaseGateOperation = "deploy" | "destroy";
18
+ /**
19
+ * Stack-boundary lease gate (§5.3). Called immediately before a stack's first
20
+ * mutating CloudFormation operation. Returns a typed {@link LeaseDeniedError}
21
+ * when the lease is denied (the caller must abort without starting the stack),
22
+ * or `undefined` when the deploy may proceed.
23
+ *
24
+ * Consumes the resolved gate resolver + shutdown signal rather than the whole
25
+ * `DeployParams`. Fails OPEN: a `"unknown"` verdict (the liveness probe could
26
+ * not reach the server) proceeds, because a lease is a liveness signal and
27
+ * CloudFormation's own ClientRequestToken is the fencing backstop. An absent
28
+ * resolver is a no-op gate.
29
+ */
30
+ export declare function assertLeaseBeforeStack(assertLeaseForStack: DeployParams["assertLeaseForStack"], stackName: string, abortSignal?: AbortSignal, operation?: LeaseGateOperation): Promise<LeaseDeniedError | undefined>;
@@ -0,0 +1 @@
1
+ import{BaseServiceError as t}from"../../types/errors/ServiceError.js";class s extends t{stackName;constructor(e,n){super("LEASE_DENIED",e),this.stackName=n}}function o(r,e){return`Deployment lease denied ${e==="destroy"?`before destroying ${r}`:`before deploying ${r}`} \u2014 the server released or superseded this deployment (it may have been cancelled, adopted by another runner, or force-released). ${e==="destroy"?"Stacks already destroyed are gone; remaining stacks stand.":"Already-completed stacks stand."} Check the deployment in the Fjall dashboard.`}async function c(r,e,n,d="deploy"){if(r===void 0)return;if(await r(e,n)==="denied")return new s(o(e,d),e)}export{s as LeaseDeniedError,c as assertLeaseBeforeStack};
@@ -14,6 +14,21 @@
14
14
  * verification falls back to the digest match alone.
15
15
  */
16
16
  export declare const DEFAULT_APPROVAL_TTL_MS: number;
17
+ /**
18
+ * TTL for the token re-minted when the destruction gate withholds an
19
+ * approval-gated deploy. The consent ceremony can legitimately take far
20
+ * longer than {@link DEFAULT_APPROVAL_TTL_MS} — the webapp lets a
21
+ * `destructive_pending` row await a human for 24 hours
22
+ * (`sweepAbandonedDestructivePendingSystem`) before abandoning it, and the
23
+ * original 15-minute token would expire long before that, making the
24
+ * ceremony unreachable for approval-gated deploys. 24h + 1h margin covers a
25
+ * consent granted at the very end of the abandon window plus claim/queue
26
+ * latency. Digest binding is unchanged: the re-minted token still refuses as
27
+ * `superseded` the moment the plan moves, so only the TIME bound is relaxed —
28
+ * and the withheld run already verified the original approval against this
29
+ * same digest.
30
+ */
31
+ export declare const DESTRUCTION_RESUME_TTL_MS: number;
17
32
  export declare const APPROVAL_TOKEN_PATTERN: RegExp;
18
33
  export interface SignApprovalTokenOptions {
19
34
  readonly assemblyDigest: string;
@@ -1 +1 @@
1
- import{createHmac as c,randomBytes as l,timingSafeEqual as m}from"node:crypto";import{digestHead as i}from"./assemblyDigest.js";const a=16,p=900*1e3,x=/^([0-9a-f]{32})\.([0-9]{1,16})\.([0-9a-f]{32})$/;function A(){const e=process.env.FJALL_APPROVAL_TOKEN_SECRET;return e!==void 0&&e!==""?{secret:Buffer.from(e,"utf8"),stable:!0}:{secret:l(32),stable:!1}}const{secret:u,stable:E}=A();function k(e){const t=i(e.assemblyDigest),r=e.now??Date.now,o=e.ttlMs??p,n=r()+o,s=c("sha256",u).update(`${t}.${n}`).digest().subarray(0,a).toString("hex");return{token:`${t}.${n}.${s}`,expiresAt:new Date(n).toISOString()}}function g(e){const t=x.exec(e.token);if(t===null)return{ok:!1,reason:"malformed"};const r=t[1],o=t[2],n=t[3];if(r===void 0||o===void 0||n===void 0)return{ok:!1,reason:"malformed"};if(r!==i(e.assemblyDigest))return{ok:!1,reason:"superseded"};const s=parseInt(o,10);if(!Number.isFinite(s))return{ok:!1,reason:"malformed"};if((e.now??Date.now)()>s)return{ok:!1,reason:"expired"};if(E){const d=c("sha256",u).update(`${r}.${o}`).digest().subarray(0,a),f=Buffer.from(n,"hex");if(f.length!==a||!m(d,f))return{ok:!1,reason:"tampered"}}return{ok:!0}}export{x as APPROVAL_TOKEN_PATTERN,p as DEFAULT_APPROVAL_TTL_MS,k as signApprovalToken,g as verifyApprovalToken};
1
+ import{createHmac as c,randomBytes as l,timingSafeEqual as m}from"node:crypto";import{digestHead as i}from"./assemblyDigest.js";const a=16,p=900*1e3,k=1500*60*1e3,T=/^([0-9a-f]{32})\.([0-9]{1,16})\.([0-9a-f]{32})$/;function E(){const e=process.env.FJALL_APPROVAL_TOKEN_SECRET;return e!==void 0&&e!==""?{secret:Buffer.from(e,"utf8"),stable:!0}:{secret:l(32),stable:!1}}const{secret:u,stable:S}=E();function g(e){const t=i(e.assemblyDigest),r=e.now??Date.now,o=e.ttlMs??p,n=r()+o,s=c("sha256",u).update(`${t}.${n}`).digest().subarray(0,a).toString("hex");return{token:`${t}.${n}.${s}`,expiresAt:new Date(n).toISOString()}}function L(e){const t=T.exec(e.token);if(t===null)return{ok:!1,reason:"malformed"};const r=t[1],o=t[2],n=t[3];if(r===void 0||o===void 0||n===void 0)return{ok:!1,reason:"malformed"};if(r!==i(e.assemblyDigest))return{ok:!1,reason:"superseded"};const s=parseInt(o,10);if(!Number.isFinite(s))return{ok:!1,reason:"malformed"};if((e.now??Date.now)()>s)return{ok:!1,reason:"expired"};if(S){const d=c("sha256",u).update(`${r}.${o}`).digest().subarray(0,a),f=Buffer.from(n,"hex");if(f.length!==a||!m(d,f))return{ok:!1,reason:"tampered"}}return{ok:!0}}export{T as APPROVAL_TOKEN_PATTERN,p as DEFAULT_APPROVAL_TTL_MS,k as DESTRUCTION_RESUME_TTL_MS,g as signApprovalToken,L as verifyApprovalToken};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Change-set escalation — Layer 2 of the replacement oracle (drift-remediation
3
+ * plan § 2, [G4]). Runs the review-only change-set probe ONLY on rows whose
4
+ * Layer-1 verdict is `conditional`/`may` on a stateful or pinned-name
5
+ * resource: definite createOnly hits are already proven, and stateless
6
+ * unpinned conditionals never ticket, so probing either buys no proof. A
7
+ * non-engaged deploy issues zero probe calls.
8
+ *
9
+ * Verdict precedence: change-set > registry > cdk-spec.
10
+ * Replacement: False → the ceremony DOWNGRADES to a warning — the row
11
+ * becomes an in-place update (directive 2:
12
+ * never block on a false positive)
13
+ * Replacement: True → conditional upgrades to definite, verdictSource
14
+ * `"change-set"`
15
+ * Replacement: Conditional → stays worst-case (the registry verdict stands)
16
+ * probe failure → the registry verdict stands, annotated
17
+ * `"registry (change-set unverified)"` — degrade
18
+ * to the conservative verdict, never to silence
19
+ *
20
+ * A resource the change set omits entirely (including the benign
21
+ * `didn't contain changes` outcome) will not be touched by the update —
22
+ * treated as `False`.
23
+ */
24
+ import type { ChangeSetProbeRunner } from "../../../services/infrastructure/changeSetProbe.js";
25
+ import type { StackTemplatePair } from "./buildDeployPlan.js";
26
+ import type { DeployPlan, DeployPlanResourceChange } from "./types.js";
27
+ /**
28
+ * The [G4] trigger predicate: a replacement row that is neither proven
29
+ * (`will`) nor irrelevant (`none`), on a resource that would actually ticket
30
+ * — stateful (data loss) or pinned-name (CloudFormation refusal).
31
+ */
32
+ export declare function needsChangeSetEscalation(change: DeployPlanResourceChange): boolean;
33
+ export interface ApplyChangeSetEscalationParams {
34
+ plan: DeployPlan;
35
+ probe: ChangeSetProbeRunner;
36
+ /** The diffed template pairs — the probe submits `newTemplate`. */
37
+ stacks: readonly StackTemplatePair[];
38
+ abortSignal?: AbortSignal;
39
+ }
40
+ /**
41
+ * Apply the change-set escalation to an overlay-corrected plan. Never throws
42
+ * and never fails the plan: a failed or aborted probe leaves the conservative
43
+ * registry verdict in place, annotated unverified. One probe per escalated
44
+ * stack covers every candidate row in it.
45
+ */
46
+ export declare function applyChangeSetEscalation(params: ApplyChangeSetEscalationParams): Promise<DeployPlan>;
@@ -0,0 +1 @@
1
+ import{maskSensitiveOutput as k}from"@fjall/util";import{logger as u}from"@fjall/util/logger";import{isAborted as S}from"../../../aws/organisations/types.js";import{isDataLoss as w,isStatefulResourceType as g}from"./classify.js";import{recountActions as T}from"./registryOverlay.js";import{renderPlanSummary as C}from"./renderDeployPlan.js";const p="ChangeSetEscalation";function M(t){return t.action!=="replace"||t.retained||t.replacementMode!=="conditional"&&t.replacementMode!=="may"?!1:g(t.resourceType)||t.pinnedPhysicalName!==void 0}function b(t){return`change-set preflight proved this update applies in place (${t.replacementMode} verdict downgraded)`}function N(t){return{...t,action:"update",replacementMode:"none",destructive:!1,dataLoss:!1,propertyChanges:t.propertyChanges.map(r=>({...r,forcesReplacement:!1})),verdictSource:"change-set",interruptionNote:b(t)}}function L(t){return{...t,action:"replace",replacementMode:"will",destructive:!0,dataLoss:w({action:"replace",retained:t.retained,resourceType:t.resourceType}),verdictSource:"change-set"}}function $(t){return t.verdictSource!=="registry"?t:{...t,verdictSource:"registry (change-set unverified)"}}async function A(t){const{plan:r,probe:f,stacks:l,abortSignal:i}=t,c=r.changes.filter(M);if(c.length===0)return r;const m=new Map(l.map(e=>[e.stackName,e])),y=[...new Set(c.map(e=>e.stack))],a=new Map;for(const e of y){if(S(i)){a.set(e,"unverified");continue}const n=m.get(e);if(n===void 0){u.warn(p,"No synthesised template for escalated stack",{stackName:e}),a.set(e,"unverified");continue}const s=await f.probeStackReplacements({stackName:e,template:n.newTemplate,...i!==void 0?{abortSignal:i}:{}});if(!s.success){u.warn(p,"Change-set probe degraded to registry verdict",{stackName:e,error:k(s.error.message)}),a.set(e,"unverified");continue}a.set(e,s.data.replacementByLogicalId)}const v=new Set(c.map(e=>`${e.stack}|${e.address}`)),o=r.changes.map(e=>{if(!v.has(`${e.stack}|${e.address}`))return e;const n=a.get(e.stack);if(n===void 0||n==="unverified")return $(e);const s=n.get(e.address)??"False";return s==="False"?N(e):s==="True"?L(e):(u.warn(p,"Change set could not decide \u2014 keeping worst case",{stack:e.stack,address:e.address,resourceType:e.resourceType}),e)}),d=T(o);return d["no-op"]=r.counts["no-op"],{...r,changes:o,counts:d,hasDestructiveChanges:o.some(e=>e.destructive),summary:C({counts:d,changes:o})}}export{A as applyChangeSetEscalation,M as needsChangeSetEscalation};
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { type Result } from "@fjall/generator";
8
8
  import type { CloudFormationError } from "../../../services/infrastructure/CloudFormationService.js";
9
+ import type { CfnRegistrySummaryReader } from "../../../services/infrastructure/CfnRegistryService.js";
10
+ import type { ChangeSetProbeRunner } from "../../../services/infrastructure/changeSetProbe.js";
9
11
  import type { DeployPlan } from "./types.js";
10
12
  /** The narrow CFN read surface the gate needs — CloudFormationService satisfies it. */
11
13
  export interface CfnTemplateReader {
@@ -20,5 +22,16 @@ export interface ComputeDeployPlanParams {
20
22
  cfnService: CfnTemplateReader;
21
23
  stacks: ComputeDeployPlanStack[];
22
24
  assemblyDigest: string;
25
+ /**
26
+ * Registry oracle for replacement-verdict recomputation. Optional — without
27
+ * it the plan carries raw cloudformation-diff verdicts (no `verdictSource`).
28
+ */
29
+ registry?: CfnRegistrySummaryReader;
30
+ /**
31
+ * Change-set escalation probe [G4]. Optional — without it conditional
32
+ * verdicts stay worst-cased into the ceremony (the sanctioned D2 fallback).
33
+ */
34
+ changeSetProbe?: ChangeSetProbeRunner;
35
+ abortSignal?: AbortSignal;
23
36
  }
24
37
  export declare function computeDeployPlan(params: ComputeDeployPlanParams): Promise<Result<DeployPlan, Error>>;
@@ -1 +1 @@
1
- import{success as o,failure as n}from"@fjall/generator";import{maskSensitiveOutput as i}from"@fjall/util";import{buildDeployPlan as m}from"./buildDeployPlan.js";function p(t){const r=t.trim();if(r==="")return o({});try{return o(JSON.parse(r))}catch(e){return n(new Error(`Failed to parse deployed template: ${i(e instanceof Error?e.message:String(e))}`))}}async function d(t){const r=[];for(const e of t.stacks){const s=await t.cfnService.getTemplate(e.stackName);let c={};if(s.success){const a=p(s.data);if(!a.success)return n(a.error);c=a.data}else if(s.error.errorType!=="stack_not_found")return n(s.error);r.push({stackName:e.stackName,oldTemplate:c,newTemplate:e.newTemplate})}return o(m(r,t.assemblyDigest))}export{d as computeDeployPlan};
1
+ import{success as i,failure as s}from"@fjall/generator";import{maskSensitiveOutput as c}from"@fjall/util";import{buildDeployPlan as f}from"./buildDeployPlan.js";import{applyChangeSetEscalation as u}from"./changeSetEscalation.js";import{applyRegistryOverlay as p}from"./registryOverlay.js";function d(e){const r=e.trim();if(r==="")return i({});try{return i(JSON.parse(r))}catch(t){return s(new Error(`Failed to parse deployed template: ${c(t instanceof Error?t.message:String(t))}`))}}async function k(e){const r=[];for(const o of e.stacks){const n=await e.cfnService.getTemplate(o.stackName);let l={};if(n.success){const a=d(n.data);if(!a.success)return s(a.error);l=a.data}else if(n.error.errorType!=="stack_not_found")return s(n.error);r.push({stackName:o.stackName,oldTemplate:l,newTemplate:o.newTemplate})}let t=f(r,e.assemblyDigest);return e.registry!==void 0&&(t=await p({plan:t,registry:e.registry,stacks:r,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})),e.changeSetProbe!==void 0&&(t=await u({plan:t,probe:e.changeSetProbe,stacks:r,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})),i(t)}export{k as computeDeployPlan};
@@ -1,6 +1,9 @@
1
1
  /** Deploy-plan computation + rendering — the approval gate's read-side core. */
2
- export type { DeployPlan, DeployPlanResourceChange } from "./types.js";
2
+ export { REPLACEMENT_VERDICT_SOURCES, type DeployPlan, type DeployPlanResourceChange, type ReplacementVerdictSource } from "./types.js";
3
3
  export { buildDeployPlan, type StackTemplatePair } from "./buildDeployPlan.js";
4
+ export { applyRegistryOverlay, overlayResourceChange, registryVerdictForProperty, type ApplyRegistryOverlayParams, type OverlayResourceChangeResult, type RegistryVerdictDisagreement } from "./registryOverlay.js";
5
+ export { applyChangeSetEscalation, needsChangeSetEscalation, type ApplyChangeSetEscalationParams } from "./changeSetEscalation.js";
6
+ export { findPinnedPhysicalName, resourcePropertiesFromTemplate } from "./pinnedNames.js";
4
7
  export { computeDeployPlan, type CfnTemplateReader, type ComputeDeployPlanParams, type ComputeDeployPlanStack } from "./computeDeployPlan.js";
5
8
  export { computeAssemblyDigest, digestHead } from "./assemblyDigest.js";
6
9
  export { classifyImpact, derivePropertyChanges, isDataLoss, isStatefulResourceType, type ImpactClassification } from "./classify.js";
@@ -1 +1 @@
1
- import{buildDeployPlan as o}from"./buildDeployPlan.js";import{computeDeployPlan as a}from"./computeDeployPlan.js";import{computeAssemblyDigest as s,digestHead as l}from"./assemblyDigest.js";import{classifyImpact as m,derivePropertyChanges as i,isDataLoss as P,isStatefulResourceType as f}from"./classify.js";import{renderPlanSummary as T,renderPlanLines as y,toWirePlanChanges as d}from"./renderDeployPlan.js";import{signApprovalToken as x,verifyApprovalToken as L,DEFAULT_APPROVAL_TTL_MS as c,APPROVAL_TOKEN_PATTERN as g}from"./approvalToken.js";export{g as APPROVAL_TOKEN_PATTERN,c as DEFAULT_APPROVAL_TTL_MS,o as buildDeployPlan,m as classifyImpact,s as computeAssemblyDigest,a as computeDeployPlan,i as derivePropertyChanges,l as digestHead,P as isDataLoss,f as isStatefulResourceType,y as renderPlanLines,T as renderPlanSummary,x as signApprovalToken,d as toWirePlanChanges,L as verifyApprovalToken};
1
+ import{REPLACEMENT_VERDICT_SOURCES as o}from"./types.js";import{buildDeployPlan as t}from"./buildDeployPlan.js";import{applyRegistryOverlay as s,overlayResourceChange as n,registryVerdictForProperty as l}from"./registryOverlay.js";import{applyChangeSetEscalation as m,needsChangeSetEscalation as y}from"./changeSetEscalation.js";import{findPinnedPhysicalName as f,resourcePropertiesFromTemplate as T}from"./pinnedNames.js";import{computeDeployPlan as d}from"./computeDeployPlan.js";import{computeAssemblyDigest as x,digestHead as A}from"./assemblyDigest.js";import{classifyImpact as R,derivePropertyChanges as u,isDataLoss as C,isStatefulResourceType as L}from"./classify.js";import{renderPlanSummary as _,renderPlanLines as h,toWirePlanChanges as v}from"./renderDeployPlan.js";import{signApprovalToken as O,verifyApprovalToken as N,DEFAULT_APPROVAL_TTL_MS as V,APPROVAL_TOKEN_PATTERN as F}from"./approvalToken.js";export{F as APPROVAL_TOKEN_PATTERN,V as DEFAULT_APPROVAL_TTL_MS,o as REPLACEMENT_VERDICT_SOURCES,m as applyChangeSetEscalation,s as applyRegistryOverlay,t as buildDeployPlan,R as classifyImpact,x as computeAssemblyDigest,d as computeDeployPlan,u as derivePropertyChanges,A as digestHead,f as findPinnedPhysicalName,C as isDataLoss,L as isStatefulResourceType,y as needsChangeSetEscalation,n as overlayResourceChange,l as registryVerdictForProperty,h as renderPlanLines,_ as renderPlanSummary,T as resourcePropertiesFromTemplate,O as signApprovalToken,v as toWirePlanChanges,N as verifyApprovalToken};
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Deploy-plan loading — reads each changed stack's synthesised template from
3
+ * cdk.out and delegates to {@link computeDeployPlan} (live-template diff +
4
+ * registry overlay). Extracted from the approval gate so the unconditional
5
+ * destruction gate shares the SAME computation: `createMemoisedPlanLoader`
6
+ * guarantees a plan-gated deploy never diffs twice, while a non-engaged deploy
7
+ * computes the plan exactly once for the destruction gate.
8
+ */
9
+ import { type Result } from "@fjall/generator";
10
+ import type { CfnRegistrySummaryReader } from "../../../services/infrastructure/CfnRegistryService.js";
11
+ import type { ChangeSetProbeRunner } from "../../../services/infrastructure/changeSetProbe.js";
12
+ import { type CfnTemplateReader } from "./computeDeployPlan.js";
13
+ import type { DeployPlan } from "./types.js";
14
+ export interface LoadDeployPlanParams {
15
+ cfnService: CfnTemplateReader;
16
+ cdkOutPath: string;
17
+ changedStacks: string[];
18
+ assemblyDigest: string;
19
+ registry?: CfnRegistrySummaryReader;
20
+ changeSetProbe?: ChangeSetProbeRunner;
21
+ abortSignal?: AbortSignal;
22
+ }
23
+ /** A zero-argument plan supplier shared between the gates. */
24
+ export type DeployPlanLoader = () => Promise<Result<DeployPlan, Error>>;
25
+ export declare function loadDeployPlan(params: LoadDeployPlanParams): Promise<Result<DeployPlan, Error>>;
26
+ /**
27
+ * Memoise the plan computation for one deploy invocation: the first call
28
+ * computes (template reads + live diff + registry overlay), every later call
29
+ * returns the same settled promise — including a failure, which both gates
30
+ * must observe identically.
31
+ */
32
+ export declare function createMemoisedPlanLoader(params: LoadDeployPlanParams): DeployPlanLoader;
@@ -0,0 +1 @@
1
+ import{join as c}from"node:path";import{readFile as a}from"node:fs/promises";import{failure as l}from"@fjall/generator";import{maskSensitiveOutput as s}from"@fjall/util";import{computeDeployPlan as f}from"./computeDeployPlan.js";async function d(e){const t=[];for(const r of e.changedStacks){const i=c(e.cdkOutPath,`${r}.template.json`);let o;try{o=JSON.parse(await a(i,"utf8"))}catch(n){return l(new Error(s(`Failed to read synthesised template for ${r}: ${n instanceof Error?n.message:String(n)}`)))}t.push({stackName:r,newTemplate:o})}return f({cfnService:e.cfnService,stacks:t,assemblyDigest:e.assemblyDigest,...e.registry!==void 0?{registry:e.registry}:{},...e.changeSetProbe!==void 0?{changeSetProbe:e.changeSetProbe}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})}function y(e){let t;return()=>(t??=d(e),t)}export{y as createMemoisedPlanLoader,d as loadDeployPlan};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pinned-name detection: a resource is custom-named iff the property named by
3
+ * the registry schema's `primaryIdentifier` (fallback: the small
4
+ * `@fjall/util` map for types whose identifier is a generated attribute) is
5
+ * explicitly present in the synthesised template with a LITERAL value. The
6
+ * template is the source of truth — CDK-generated names arrive as intrinsics
7
+ * (`Ref` / `Fn::*`) or are omitted entirely, so a literal string here means
8
+ * the author pinned the physical name.
9
+ *
10
+ * `replacement-required ∧ custom-named ⇒ pinned-name-replacement`: the update
11
+ * fails at apply time with "CloudFormation cannot update a stack when a
12
+ * custom-named resource requires replacing" — the Phase 2 destruction gate
13
+ * consumes this signal; Phase 1 annotates it on the plan row.
14
+ */
15
+ /**
16
+ * Detect a pinned physical name on a template resource. Returns the literal
17
+ * name, or undefined when the resource is CDK-named (intrinsic or absent) or
18
+ * the naming property cannot be determined.
19
+ */
20
+ export declare function findPinnedPhysicalName(params: {
21
+ resourceType: string;
22
+ /** The resource's template `Properties` block. */
23
+ properties: Record<string, unknown> | undefined;
24
+ /** Registry `primaryIdentifier` pointers, when the oracle resolved them. */
25
+ primaryIdentifier?: readonly string[];
26
+ }): string | undefined;
27
+ /**
28
+ * Extract a resource's `Properties` block from a parsed CloudFormation
29
+ * template without asserting the template's overall shape.
30
+ */
31
+ export declare function resourcePropertiesFromTemplate(template: unknown, logicalId: string): Record<string, unknown> | undefined;
@@ -0,0 +1 @@
1
+ import{PHYSICAL_NAME_FALLBACK_PROPERTIES as f}from"@fjall/util";const o="/properties/";function d(e){if(e===void 0||e.length!==1)return;const r=e[0];if(r===void 0||!r.startsWith(o))return;const n=r.slice(o.length);if(!(n===""||n.includes("/")))return n}function u(e){if(typeof e=="string"&&e!=="")return e;if(typeof e=="number")return String(e)}function s(e){if(e.properties===void 0)return;const r=d(e.primaryIdentifier);if(r!==void 0){const t=u(e.properties[r]);if(t!==void 0)return t}const n=f[e.resourceType];if(n!==void 0)return u(e.properties[n])}function p(e,r){if(typeof e!="object"||e===null)return;const n=e.Resources;if(typeof n!="object"||n===null)return;const t=n[r];if(typeof t!="object"||t===null)return;const i=t.Properties;if(!(typeof i!="object"||i===null||Array.isArray(i)))return i}export{s as findPinnedPhysicalName,p as resourcePropertiesFromTemplate};