@fjall/deploy-core 2.27.0 → 2.30.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/organisations/backup.d.ts +7 -2
  3. package/dist/src/aws/organisations/backup.js +2 -2
  4. package/dist/src/aws/organisations/index.d.ts +1 -1
  5. package/dist/src/aws/organisations/index.js +1 -1
  6. package/dist/src/aws/organisations/oidcProvider.d.ts +37 -0
  7. package/dist/src/aws/organisations/oidcProvider.js +1 -1
  8. package/dist/src/aws/organisations/organisation.d.ts +21 -0
  9. package/dist/src/aws/organisations/organisation.js +1 -1
  10. package/dist/src/aws/utils/cloudformationEventHelpers.d.ts +45 -0
  11. package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -1
  12. package/dist/src/aws/utils/cloudformationEventTypes.d.ts +1 -1
  13. package/dist/src/aws/utils/cloudformationEvents.d.ts +2 -1
  14. package/dist/src/aws/utils/cloudformationEvents.js +1 -1
  15. package/dist/src/aws/utils/stackStatus.js +1 -1
  16. package/dist/src/index.d.ts +16 -3
  17. package/dist/src/index.js +1 -1
  18. package/dist/src/orchestration/activeDeploymentGuard.d.ts +41 -0
  19. package/dist/src/orchestration/activeDeploymentGuard.js +8 -4
  20. package/dist/src/orchestration/application/applicationDeploy.d.ts +9 -0
  21. package/dist/src/orchestration/application/applicationDeploy.js +1 -1
  22. package/dist/src/orchestration/application/applicationDeployHelpers.d.ts +20 -3
  23. package/dist/src/orchestration/application/applicationDeployHelpers.js +3 -3
  24. package/dist/src/orchestration/application/applicationDestroy.js +1 -1
  25. package/dist/src/orchestration/application/approvalGate.d.ts +15 -1
  26. package/dist/src/orchestration/application/approvalGate.js +1 -1
  27. package/dist/src/orchestration/application/destructionGate.d.ts +164 -0
  28. package/dist/src/orchestration/application/destructionGate.js +5 -0
  29. package/dist/src/orchestration/application/dockerBuildHelper.js +1 -1
  30. package/dist/src/orchestration/application/leaseGate.d.ts +30 -0
  31. package/dist/src/orchestration/application/leaseGate.js +1 -0
  32. package/dist/src/orchestration/application/openNextBuild.d.ts +1 -1
  33. package/dist/src/orchestration/application/openNextBuild.js +3 -3
  34. package/dist/src/orchestration/application/plan/approvalToken.d.ts +15 -0
  35. package/dist/src/orchestration/application/plan/approvalToken.js +1 -1
  36. package/dist/src/orchestration/application/plan/changeSetEscalation.d.ts +46 -0
  37. package/dist/src/orchestration/application/plan/changeSetEscalation.js +1 -0
  38. package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +13 -0
  39. package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -1
  40. package/dist/src/orchestration/application/plan/index.d.ts +4 -1
  41. package/dist/src/orchestration/application/plan/index.js +1 -1
  42. package/dist/src/orchestration/application/plan/loadDeployPlan.d.ts +32 -0
  43. package/dist/src/orchestration/application/plan/loadDeployPlan.js +1 -0
  44. package/dist/src/orchestration/application/plan/pinnedNames.d.ts +31 -0
  45. package/dist/src/orchestration/application/plan/pinnedNames.js +1 -0
  46. package/dist/src/orchestration/application/plan/registryOverlay.d.ts +83 -0
  47. package/dist/src/orchestration/application/plan/registryOverlay.js +1 -0
  48. package/dist/src/orchestration/application/plan/renderDeployPlan.d.ts +4 -1
  49. package/dist/src/orchestration/application/plan/renderDeployPlan.js +1 -1
  50. package/dist/src/orchestration/application/plan/types.d.ts +31 -0
  51. package/dist/src/orchestration/application/plan/types.js +1 -0
  52. package/dist/src/orchestration/builders/frameworkRegistry.js +1 -1
  53. package/dist/src/orchestration/builders/index.d.ts +1 -0
  54. package/dist/src/orchestration/builders/index.js +1 -1
  55. package/dist/src/orchestration/builders/openNextBuilder.js +1 -1
  56. package/dist/src/orchestration/builders/staticSiteBuilder.d.ts +27 -0
  57. package/dist/src/orchestration/builders/staticSiteBuilder.js +2 -0
  58. package/dist/src/orchestration/contextHelpers.d.ts +37 -1
  59. package/dist/src/orchestration/contextHelpers.js +1 -1
  60. package/dist/src/orchestration/drift/classifyDriftFailure.d.ts +31 -0
  61. package/dist/src/orchestration/drift/classifyDriftFailure.js +1 -0
  62. package/dist/src/orchestration/drift/detectStackDrift.d.ts +37 -0
  63. package/dist/src/orchestration/drift/detectStackDrift.js +1 -0
  64. package/dist/src/orchestration/drift/driftPreFlight.d.ts +73 -0
  65. package/dist/src/orchestration/drift/driftPreFlight.js +7 -0
  66. package/dist/src/orchestration/drift/driftProbe.d.ts +113 -0
  67. package/dist/src/orchestration/drift/driftProbe.js +1 -0
  68. package/dist/src/orchestration/drift/driftSuspectJournal.d.ts +40 -0
  69. package/dist/src/orchestration/drift/driftSuspectJournal.js +1 -0
  70. package/dist/src/orchestration/drift/preFlightGate.d.ts +44 -0
  71. package/dist/src/orchestration/drift/preFlightGate.js +2 -0
  72. package/dist/src/orchestration/index.d.ts +19 -0
  73. package/dist/src/orchestration/index.js +1 -1
  74. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.d.ts +7 -0
  75. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.js +1 -1
  76. package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
  77. package/dist/src/orchestration/organisation/organisationDestroy.js +3 -3
  78. package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +8 -1
  79. package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -1
  80. package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -5
  81. package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +21 -2
  82. package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -1
  83. package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
  84. package/dist/src/orchestration/remediation/driftRepairGate.d.ts +66 -0
  85. package/dist/src/orchestration/remediation/driftRepairGate.js +1 -0
  86. package/dist/src/orchestration/remediation/forensicsCapture.d.ts +49 -0
  87. package/dist/src/orchestration/remediation/forensicsCapture.js +1 -0
  88. package/dist/src/orchestration/remediation/forgetResource.d.ts +423 -0
  89. package/dist/src/orchestration/remediation/forgetResource.js +1 -0
  90. package/dist/src/orchestration/remediation/pinRemediation.d.ts +121 -0
  91. package/dist/src/orchestration/remediation/pinRemediation.js +1 -0
  92. package/dist/src/orchestration/remediation/postFailureRepairOffer.d.ts +77 -0
  93. package/dist/src/orchestration/remediation/postFailureRepairOffer.js +14 -0
  94. package/dist/src/orchestration/remediation/recreatePreFlight.d.ts +47 -0
  95. package/dist/src/orchestration/remediation/recreatePreFlight.js +1 -0
  96. package/dist/src/orchestration/remediation/recreateResource.d.ts +110 -0
  97. package/dist/src/orchestration/remediation/recreateResource.js +1 -0
  98. package/dist/src/orchestration/remediation/remediationJournal.d.ts +88 -0
  99. package/dist/src/orchestration/remediation/remediationJournal.js +2 -0
  100. package/dist/src/orchestration/remediation/removalUpdate.d.ts +135 -0
  101. package/dist/src/orchestration/remediation/removalUpdate.js +1 -0
  102. package/dist/src/orchestration/remediation/retainFlip.d.ts +294 -0
  103. package/dist/src/orchestration/remediation/retainFlip.js +1 -0
  104. package/dist/src/orchestration/spawnHelpers.d.ts +9 -0
  105. package/dist/src/orchestration/spawnHelpers.js +1 -1
  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/CdkEventMonitoring.d.ts +15 -0
  113. package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -1
  114. package/dist/src/services/infrastructure/CdkOutputAnalyser.d.ts +8 -0
  115. package/dist/src/services/infrastructure/CdkOutputAnalyser.js +2 -1
  116. package/dist/src/services/infrastructure/CdkService.js +3 -3
  117. package/dist/src/services/infrastructure/CfnRegistryService.d.ts +68 -0
  118. package/dist/src/services/infrastructure/CfnRegistryService.js +1 -0
  119. package/dist/src/services/infrastructure/CloudFormationService.d.ts +5 -2
  120. package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
  121. package/dist/src/services/infrastructure/EcsServiceResolver.d.ts +23 -3
  122. package/dist/src/services/infrastructure/EcsServiceResolver.js +1 -1
  123. package/dist/src/services/infrastructure/cfnRegistrySeed.d.ts +10 -0
  124. package/dist/src/services/infrastructure/cfnRegistrySeed.js +1 -0
  125. package/dist/src/services/infrastructure/changeSetProbe.d.ts +88 -0
  126. package/dist/src/services/infrastructure/changeSetProbe.js +1 -0
  127. package/dist/src/services/infrastructure/index.d.ts +2 -0
  128. package/dist/src/services/infrastructure/index.js +1 -1
  129. package/dist/src/types/callbackKeys.d.ts +1 -1
  130. package/dist/src/types/callbackKeys.js +1 -1
  131. package/dist/src/types/callbacks.d.ts +22 -0
  132. package/dist/src/types/config/entitlements.d.ts +9 -0
  133. package/dist/src/types/config/orgConfig.d.ts +6 -0
  134. package/dist/src/types/deployEvent.d.ts +10 -3
  135. package/dist/src/types/deployEvent.js +1 -1
  136. package/dist/src/types/deploymentEventSchema.d.ts +1 -0
  137. package/dist/src/types/deploymentEventSchema.js +1 -1
  138. package/dist/src/types/destruction.d.ts +212 -0
  139. package/dist/src/types/destruction.js +1 -0
  140. package/dist/src/types/detection/patternTypes.d.ts +9 -4
  141. package/dist/src/types/detection/patternTypes.js +1 -1
  142. package/dist/src/types/drift.d.ts +72 -0
  143. package/dist/src/types/drift.js +1 -0
  144. package/dist/src/types/frameworkBuilder.d.ts +14 -0
  145. package/dist/src/types/index.d.ts +44 -4
  146. package/dist/src/types/index.js +1 -1
  147. package/dist/src/types/operations.d.ts +2 -1
  148. package/dist/src/types/params.d.ts +142 -0
  149. package/dist/src/types/remediation.d.ts +126 -0
  150. package/dist/src/types/remediation.js +1 -0
  151. package/package.json +6 -4
@@ -2,6 +2,15 @@ import { type Result } from "@fjall/generator";
2
2
  import type { DeployParams, DeployResult } from "../../types/params.js";
3
3
  import type { ApplicationOperation } from "../../types/operations.js";
4
4
  import type { DeployServices } from "../serviceFactory.js";
5
+ /**
6
+ * Surface the stacks this operation will mutate (§5.9 reconciler coordinates),
7
+ * post-plan and before the first mutating CFN op. A reconciler persists these
8
+ * so a crashed run's in-flight stacks can be located by name/account/region
9
+ * without waiting for failure output. No-ops when no consumer wired the
10
+ * callback or the operation touches no stacks (fully skipped re-run).
11
+ * Shared by the deploy and destroy legs (F-X7) — one emission contract.
12
+ */
13
+ export declare function emitStackTargets(callbacks: DeployParams["callbacks"], services: DeployServices, stackNames: readonly string[]): void;
5
14
  /**
6
15
  * Core application deployment orchestration.
7
16
  *
@@ -1 +1 @@
1
- import{join as Y}from"path";import{CloudFormationClient as ne}from"@aws-sdk/client-cloudformation";import{EC2Client as oe}from"@aws-sdk/client-ec2";import{success as v,failure as l}from"@fjall/generator";import{logger as N}from"@fjall/util/logger";import{ImageTagSchema as ie,imageTagParameterName as se,maskSensitiveOutput as k}from"@fjall/util";import{parseDockerServicesFromManifest as ce}from"@fjall/util/manifest";import{stubCallerIdentity as le}from"../../types/deployment/index.js";import{getApplicationDeployOrder as de,getApplicationStackName as J,getApplicationStepName as Q,getApplicationStepId as V,APPLICATION_STACKS as S}from"../../types/operations.js";import{CdkContextBuilder as pe}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as ue,bootstrapOrFail as ge}from"../contextHelpers.js";import{runDetectionPipeline as fe}from"./detectionPipeline.js";import{STEP_IDS as R,STEP_NAMES as me}from"../../types/stepDefinitions.js";import{StepRegistry as ke}from"../../steps/stepRegistry.js";import{checkTargetReadiness as ye}from"../../aws/targetReadiness.js";import{cascadeHomeRegion as he}from"../organisation/cascadeHelpers.js";import{withStepLifecycle as be}from"../stepLifecycle.js";import{DOCKER_BUILD_STEP_NAME as X,runDockerBuild as Se}from"./dockerBuildHelper.js";import{getParallelPhase2Stacks as we,deployParallelPhase as Te,deployStackSequential as De,runDockerPreCompute as ve,createBuildCallbacks as Ee}from"./applicationDeployHelpers.js";import{captureDatabaseConnectionOutputs as Ce,captureServiceTaskDefinitions as Oe,reconcileDatabaseEndpointChange as Ie}from"./databaseEndpointReconcile.js";import{deployCodeOnly as Ne}from"./codeOnlyDeploy.js";import{collectFullDeployArtefacts as Re}from"./artefactCollection.js";import{runApprovalGate as Pe}from"./approvalGate.js";const P="applicationDeploy";function Ae(e){const i=Object.entries(e);if(i.length===0)return;const a={};for(const[r,s]of i){const h=se(r);a[h]=s}return a}function Me(e){if(e?.mode!==void 0&&e.mode!=="rollback"){if(e.imageTag)return l(new Error(`--image-tag requires rollback mode (explicit mode '${e.mode}' was supplied alongside an image tag)`));if(e.serviceImageTags)return l(new Error(`serviceImageTags requires rollback mode (explicit mode '${e.mode}' was supplied alongside serviceImageTags)`))}if(e?.mode==="rollback"){if(e.imageTag&&e.serviceImageTags)return l(new Error("rollback mode accepts exactly one of --image-tag or serviceImageTags \u2014 they are mutually exclusive"));if(!e.imageTag&&!e.serviceImageTags)return l(new Error("rollback mode requires --image-tag <tag> or serviceImageTags naming the image(s) to roll to"))}if(e?.serviceImageTags!==void 0){if(e.serviceName)return l(new Error("serviceImageTags already names its services \u2014 --service cannot be combined with it"));const i=Object.entries(e.serviceImageTags);if(i.length===0)return l(new Error("serviceImageTags must name at least one service"));for(const[a,r]of i){const s=ie.safeParse(r);if(!s.success)return l(new Error(`serviceImageTags.${a} is not a valid image tag: ${s.error.message}`))}}return v(void 0)}async function nt(e,i,a){const{callbacks:r,options:s}=e,h=Date.now(),E=s?.mode??(s?.imageTag||s?.serviceImageTags?"rollback":s?.deployOnly?"code-only":"full");if(E==="restart"){const t=new Error('mode "restart" is not a deploy \u2014 call the restart() entry point (fjall rollout) instead');return r.onError?.(t),l(t)}const B=Me(s);if(!B.success){const t=k(B.error.message),n=new Error(t);return r.onError?.(n),l(n)}const C=pe.buildDeploymentContext({deployType:"application",target:a.appName,path:a.path,region:i.awsProvider.getRegion(),callerIdentity:le(i.awsProvider.getAccountId()),...ue({orgConfig:e.orgConfig,identity:e.identity,skipOidc:e.options?.skipOidc})},{verbose:s?.verbose,infraOnly:s?.infraOnly},e.orgConfig),u=i.frameworkRegistry.resolve({appPath:a.path});let O;const w=E==="rollback";if(u&&!w){O=u.builder.plan({appPath:a.path},u.detection);const t=Ee(r),n=await u.builder.build(a.path,O,t,{skipBuild:s?.skipBuild,infraOnly:s?.infraOnly});if(!n.success){const o=new Error(k(n.error.message));return r.onError?.(o),l(o)}}if(E==="code-only"||w){r.onLog?.(w?s?.serviceImageTags?`Rollback mode \u2014 rolling ${Object.keys(s.serviceImageTags).length} service(s) to their release-recorded image tags`:`Rollback mode \u2014 rolling to image tag ${s?.imageTag}`:"Deploy-only mode \u2014 skipping infrastructure pipeline","info");const t=Y(a.path,"cdk.out"),n=ce(t),o=n.length>0,p=u?.detection.hasDockerfile===!0,d=o||p;N.debug(P,"Deploy-only branch entered",{mode:E,imageTag:s?.imageTag,serviceImageTags:s?.serviceImageTags,appName:a.appName,appPath:a.path,cdkOutPath:t,dockerProviderAvailable:e.dockerProvider!==void 0,builderName:u?.builder.name,hasDockerfileFromManifest:o,hasDockerfileFromDisk:p,hasDockerfile:d,manifestDockerServiceCount:n.length,manifestDockerPaths:n.map(y=>y.docker.path)}),!o&&!p&&r.onLog?.("No Dockerfile detected via manifest or appPath \u2014 skipping Docker build. If this app uses a cross-repo Dockerfile, ensure a full deploy has run first to populate cdk.out/fjall-manifest.json.","warn");let f={};if(!w&&e.dockerProvider!==void 0&&d){N.debug(P,"Running Docker build before code-only deploy",{source:o?"manifest":"disk"});const y=await Se(e,i,a,r);if(!y.success)return l(y.error);f=y.data.contentHashTagsByService}else N.debug(P,"Skipping Docker build",{reason:w?"rollback":e.dockerProvider===void 0?"no dockerProvider":"no Dockerfile detected"});return Ne(e,i,a,f)}r.onLog?.("Analysing infrastructure\u2026","info");const A=await fe(a,i,C,r);if(!A.success){const t=new Error(k(A.error.message));return r.onError?.(t),l(t)}const c=A.data;try{await r.onDetectionComplete?.({...c,builderName:u?.builder.name??"unknown"})}catch(t){const n=t instanceof Error?t.message:String(t),o=new Error(k(n));return r.onError?.(o),l(o)}const Z={deploymentType:"application",operation:"deploy",deployOnly:!1,infraOnly:s?.infraOnly??!1,hasDockerfile:c.hasDockerfile,pattern:c.pattern,resources:c.resources,...u&&{builderName:u.builder.name}},L=ke.getSteps(Z),_=L.findIndex(t=>t.id===R.TARGET_READINESS),j=await be(r,{stepId:R.TARGET_READINESS,stepName:me.TARGET_READINESS,..._>=0&&{stepIndex:_,totalSteps:L.length}},async()=>{if(s?.skipReadinessCheck)return r.onLog?.("Skipping target readiness check (--skip-readiness-check)","warn"),{kind:"skipped",data:void 0};const t=i.awsProvider.getAccountId(),n=e.orgConfig?.providerAccounts.find(f=>f.id===t)?.name,o=i.awsProvider.getCredentials(),p=he(e.orgConfig),d=await ye({cloudFormation:i.awsProvider.getClient(ne),ec2:new oe({region:p,...o!==void 0&&{credentials:o}})},{id:t,...n!==void 0&&{name:n}},i.awsProvider.getRegion(),e.orgConfig,e.abortSignal);return d.success?d.data.ready?{kind:"completed",data:void 0}:{kind:"error",error:new Error(k(d.data.advisory))}:{kind:"error",error:new Error(k(d.error.message))}},e.abortSignal);if(!j.success)return j;const g=O?O.deployOrder:de({pattern:c.pattern,resources:c.resources}),b=g.length,H=c.hasDockerfile&&e.dockerProvider!==void 0&&g.includes(S.COMPUTE)&&s?.infraOnly!==!0;if(!c.hasDifferences&&!s?.force&&!H){r.onLog?.("No infrastructure changes detected","info"),r.onLog?.(s?.infraOnly===!0?"Infrastructure-only mode \u2014 application code was not rebuilt. Run with --deploy-only to deploy code changes.":"Nothing to deploy. Run with --deploy-only to redeploy application code.","info");const t=c.hasDockerfile&&e.dockerProvider!==void 0&&g.includes(S.COMPUTE);for(let o=0;o<g.length;o++){const p=g[o];t&&p===S.COMPUTE&&(r.onStepStart?.(R.DOCKER_OPERATIONS,X),r.onStepComplete?.(R.DOCKER_OPERATIONS,X,"skipped"));const d=V(p,"deploy"),f=Q(p,"deploy");r.onStepStart?.(d,f,o,b),r.onStepComplete?.(d,f,"skipped",o,b)}const n=await i.stackService.resolveWebsiteUrl(a.appName);return v({target:a.appName,deploymentType:"application",outputs:n?{websiteUrl:n}:void 0,noChanges:!0,artefacts:[],durationMs:Date.now()-h})}if(e.approvalGate!==void 0||s?.approvalToken!==void 0&&s.approvalToken!==""||s?.planOnly===!0){const t=Y(a.path,"cdk.out"),n=await Pe({approvalGate:e.approvalGate,options:s??{},target:a.appName,cdkOutPath:t,detection:{stackChanges:c.stackChanges,currentHashes:c.currentHashes},cfnService:i.cfnService,callbacks:r});if(!n.success)return n;const o=n.data;if(o.kind==="rejected")return r.onLog?.(`Deploy rejected: ${k(o.reason)}`,"warn"),v({target:a.appName,deploymentType:"application",rejected:!0,artefacts:[],...o.planDetail!==void 0?{planDetail:o.planDetail}:{},durationMs:Date.now()-h});if(o.kind==="awaiting")return v({target:a.appName,deploymentType:"application",awaitingApproval:!0,approvalToken:o.approvalToken,assemblyDigest:o.assemblyDigest,planDetail:o.planDetail,artefacts:[],durationMs:Date.now()-h})}const U=await ge(i,C,r);if(!U.success)return U;const G=J(a.appName,S.DATABASE),M=g.includes(S.DATABASE)&&((c.stackChanges.get(G)??!0)||s?.force===!0),ee=M?await Ce(i.cfnService,a.appName):void 0,te=M?await Oe(i.ecsResolver,i.ecsService,a.appName):void 0,T={},D=new Map;let $;const I=async()=>{!M||!D.has(G)||await Ie({appName:a.appName,connectionOutputsBefore:ee,serviceTaskDefinitionsBefore:te,services:{cfnService:i.cfnService,ecsService:i.ecsService,ecsResolver:i.ecsResolver},callbacks:r,...e.confirmDatabaseEndpointRestart!==void 0?{confirmRestart:e.confirmDatabaseEndpointRestart}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})};for(let t=0;t<g.length;t++){const n=g[t],o=J(a.appName,n),p=V(n,"deploy"),d=Q(n,"deploy"),f=c.stackChanges.get(o)??!0,y=n===S.COMPUTE&&H;if(!f&&!s?.force&&!y){r.onStepStart?.(p,d,t,b),r.onLog?.(`Skipping ${n} \u2014 no changes detected`,"info"),r.onStepComplete?.(p,d,"skipped",t,b);continue}const x=we(g,t,c.stackChanges,a.appName,s?.force);if(x.length>=2){const z=await Te(x,a,i,C,r,t,b,c,T,D);if(!z.success)return await I(),l(z.error);t+=x.length-1;continue}const m=await ve(n,e,i,a,r,c.hasDockerfile);if(m!==null&&!m.success)return await I(),l(m.error);m!==null&&m.success&&($=m.data);const ae=m!==null&&m.success?m.data.contentHashTagsByService:{},K=Ae(ae),W=await De(n,i,C,r,t,b,T,K!==void 0?{parameters:K}:void 0);if(!W.success)return await I(),l(W.error);const q=c.currentHashes.get(o);q&&D.set(o,q)}if(await I(),D.size>0){const t=await i.hashService.updateStateAfterDeploy(a.path,D);if(!t.success){const n=k(t.error.message);N.debug(P,"Failed to update state file",{error:n}),r.onLog?.(`Warning: failed to update state file \u2014 next deploy may re-deploy unchanged stacks: ${n}`,"warn")}}const F=await i.stackService.resolveWebsiteUrl(a.appName);F&&(T.websiteUrl=F);const re=await Re(i,a.appName,$,r);return v({target:a.appName,deploymentType:"application",outputs:Object.keys(T).length>0?T:void 0,artefacts:re,durationMs:Date.now()-h})}export{nt as deployApplication};
1
+ import{join as ie}from"path";import{CloudFormationClient as he}from"@aws-sdk/client-cloudformation";import{EC2Client as Se}from"@aws-sdk/client-ec2";import{success as E,failure as d}from"@fjall/generator";import{logger as M}from"@fjall/util/logger";import{ImageTagSchema as be,getErrorMessage as se,imageTagParameterName as we,maskSensitiveOutput as y}from"@fjall/util";import{parseDockerServicesFromManifest as Te}from"@fjall/util/manifest";import{stubCallerIdentity as Ne}from"../../types/deployment/index.js";import{getApplicationDeployOrder as Ee,getApplicationStackName as I,getApplicationStepName as ce,getApplicationStepId as le,APPLICATION_STACKS as P}from"../../types/operations.js";import{CdkContextBuilder as Pe}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as ve,bootstrapOrFail as De}from"../contextHelpers.js";import{runDetectionPipeline as Re}from"./detectionPipeline.js";import{STEP_IDS as G,STEP_NAMES as Ie}from"../../types/stepDefinitions.js";import{StepRegistry as Ce}from"../../steps/stepRegistry.js";import{checkTargetReadiness as Oe}from"../../aws/targetReadiness.js";import{cascadeHomeRegion as Ae}from"../organisation/cascadeHelpers.js";import{withStepLifecycle as Le}from"../stepLifecycle.js";import{DOCKER_BUILD_STEP_NAME as de,runDockerBuild as Me}from"./dockerBuildHelper.js";import{getParallelPhase2Stacks as Fe,getStackNamesToDeploy as $e,deployParallelPhase as xe,deployStackSequential as Be,runDockerPreCompute as je,createBuildCallbacks as Ge}from"./applicationDeployHelpers.js";import{captureDatabaseConnectionOutputs as _e,captureServiceTaskDefinitions as Ue,reconcileDatabaseEndpointChange as He}from"./databaseEndpointReconcile.js";import{deployCodeOnly as Ke}from"./codeOnlyDeploy.js";import{checkDeployPathStackAvailability as ue}from"../activeDeploymentGuard.js";import{assertLeaseBeforeStack as _}from"./leaseGate.js";import{collectFullDeployArtefacts as We}from"./artefactCollection.js";import{CfnRegistryService as qe}from"../../services/infrastructure/CfnRegistryService.js";import{ChangeSetProbe as ze}from"../../services/infrastructure/changeSetProbe.js";import{runApprovalGate as Je}from"./approvalGate.js";import{buildDestructionTicket as Ye,runDestructionGate as Qe}from"./destructionGate.js";import{DESTRUCTION_RESUME_TTL_MS as Ve,signApprovalToken as Xe}from"./plan/approvalToken.js";import{computeAssemblyDigest as Ze}from"./plan/assemblyDigest.js";import{createMemoisedPlanLoader as et}from"./plan/loadDeployPlan.js";import{runDriftPreFlightGate as tt}from"../drift/preFlightGate.js";import{completeForgetAfterDeploy as rt}from"../remediation/forgetResource.js";import{composePostFailureRepairOffer as ge}from"../remediation/postFailureRepairOffer.js";import{renderPinReportLines as at,runPinRemediation as nt}from"../remediation/pinRemediation.js";import{runRecreatePreFlight as ot}from"../remediation/recreatePreFlight.js";import{runRecreateSurgery as it}from"../remediation/recreateResource.js";const F="applicationDeploy";function st(e){const a=Object.entries(e);if(a.length===0)return;const n={};for(const[t,i]of a){const h=we(t);n[h]=i}return n}function ct(e){if(e?.mode!==void 0&&e.mode!=="rollback"){if(e.imageTag)return d(new Error(`--image-tag requires rollback mode (explicit mode '${e.mode}' was supplied alongside an image tag)`));if(e.serviceImageTags)return d(new Error(`serviceImageTags requires rollback mode (explicit mode '${e.mode}' was supplied alongside serviceImageTags)`))}if(e?.mode==="rollback"){if(e.imageTag&&e.serviceImageTags)return d(new Error("rollback mode accepts exactly one of --image-tag or serviceImageTags \u2014 they are mutually exclusive"));if(!e.imageTag&&!e.serviceImageTags)return d(new Error("rollback mode requires --image-tag <tag> or serviceImageTags naming the image(s) to roll to"))}if(e?.serviceImageTags!==void 0){if(e.serviceName)return d(new Error("serviceImageTags already names its services \u2014 --service cannot be combined with it"));const a=Object.entries(e.serviceImageTags);if(a.length===0)return d(new Error("serviceImageTags must name at least one service"));for(const[n,t]of a){const i=be.safeParse(t);if(!i.success)return d(new Error(`serviceImageTags.${n} is not a valid image tag: ${i.error.message}`))}}return E(void 0)}function pe(e,a,n){if(e.onStackTargets===void 0||n.length===0)return;const t=a.awsProvider.getAccountId(),i=a.awsProvider.getRegion();e.onStackTargets(n.map(h=>({stackName:h,...t!==""?{accountId:t}:{},...i!==""?{region:i}:{}})))}async function qt(e,a,n){const{callbacks:t,options:i}=e,h=Date.now(),$=i?.mode??(i?.imageTag||i?.serviceImageTags?"rollback":i?.deployOnly?"code-only":"full");if($==="restart"){const r=new Error('mode "restart" is not a deploy \u2014 call the restart() entry point (fjall rollout) instead');return t.onError?.(r),d(r)}const J=ct(i);if(!J.success){const r=y(J.error.message),o=new Error(r);return t.onError?.(o),d(o)}const x=Pe.buildDeploymentContext({deployType:"application",target:n.appName,path:n.path,region:a.awsProvider.getRegion(),callerIdentity:Ne(a.awsProvider.getAccountId()),...ve({orgConfig:e.orgConfig,identity:e.identity,skipOidc:e.options?.skipOidc})},{verbose:i?.verbose,infraOnly:i?.infraOnly},e.orgConfig),b=a.frameworkRegistry.resolve({appPath:n.path});let B;const C=$==="rollback";if(b&&!C){B=b.builder.plan({appPath:n.path},b.detection);const r=Ge(t),o=await b.builder.build(n.path,B,r,{skipBuild:i?.skipBuild,infraOnly:i?.infraOnly,...e.abortSignal!==void 0&&{abortSignal:e.abortSignal}});if(!o.success){const s=new Error(y(o.error.message));return t.onError?.(s),d(s)}}if($==="code-only"||C){t.onLog?.(C?i?.serviceImageTags?`Rollback mode \u2014 rolling ${Object.keys(i.serviceImageTags).length} service(s) to their release-recorded image tags`:`Rollback mode \u2014 rolling to image tag ${i?.imageTag}`:"Deploy-only mode \u2014 skipping infrastructure pipeline","info");const r=ie(n.path,"cdk.out"),o=Te(r),s=o.length>0,g=b?.detection.hasDockerfile===!0,l=s||g;M.debug(F,"Deploy-only branch entered",{mode:$,imageTag:i?.imageTag,serviceImageTags:i?.serviceImageTags,appName:n.appName,appPath:n.path,cdkOutPath:r,dockerProviderAvailable:e.dockerProvider!==void 0,builderName:b?.builder.name,hasDockerfileFromManifest:s,hasDockerfileFromDisk:g,hasDockerfile:l,manifestDockerServiceCount:o.length,manifestDockerPaths:o.map(m=>m.docker.path)}),!s&&!g&&t.onLog?.("No Dockerfile detected via manifest or appPath \u2014 skipping Docker build. If this app uses a cross-repo Dockerfile, ensure a full deploy has run first to populate cdk.out/fjall-manifest.json.","warn");const u=await ue([I(n.appName,P.COMPUTE)],a.cfnService,e.abortSignal);if(!u.success)return t.onError?.(u.error),d(u.error);pe(t,a,[I(n.appName,P.COMPUTE)]);let k={};if(!C&&e.dockerProvider!==void 0&&l){M.debug(F,"Running Docker build before code-only deploy",{source:s?"manifest":"disk"});const m=await Me(e,a,n,t);if(!m.success)return d(m.error);k=m.data.contentHashTagsByService}else M.debug(F,"Skipping Docker build",{reason:C?"rollback":e.dockerProvider===void 0?"no dockerProvider":"no Dockerfile detected"});const c=await _(e.assertLeaseForStack,I(n.appName,P.COMPUTE),e.abortSignal);return c!==void 0?(t.onError?.(c),d(c)):Ke(e,a,n,k)}t.onLog?.("Analysing infrastructure\u2026","info");const U=await Re(n,a,x,t);if(!U.success){const r=new Error(y(U.error.message));return t.onError?.(r),d(r)}const f=U.data;try{await t.onDetectionComplete?.({...f,builderName:b?.builder.name??"unknown"})}catch(r){const o=new Error(y(se(r)));return t.onError?.(o),d(o)}const fe={deploymentType:"application",operation:"deploy",deployOnly:!1,infraOnly:i?.infraOnly??!1,hasDockerfile:f.hasDockerfile,pattern:f.pattern,resources:f.resources,...b&&{builderName:b.builder.name}},Y=Ce.getSteps(fe),Q=Y.findIndex(r=>r.id===G.TARGET_READINESS),V=await Le(t,{stepId:G.TARGET_READINESS,stepName:Ie.TARGET_READINESS,...Q>=0&&{stepIndex:Q,totalSteps:Y.length}},async()=>{if(i?.skipReadinessCheck)return t.onLog?.("Skipping target readiness check (--skip-readiness-check)","warn"),{kind:"skipped",data:void 0};const r=a.awsProvider.getAccountId(),o=e.orgConfig?.providerAccounts.find(u=>u.id===r)?.name,s=a.awsProvider.getCredentials(),g=Ae(e.orgConfig),l=await Oe({cloudFormation:a.awsProvider.getClient(he),ec2:new Se({region:g,...s!==void 0&&{credentials:s}})},{id:r,...o!==void 0&&{name:o}},a.awsProvider.getRegion(),e.orgConfig,e.abortSignal);return l.success?l.data.ready?{kind:"completed",data:void 0}:{kind:"error",error:new Error(y(l.data.advisory))}:{kind:"error",error:new Error(y(l.error.message))}},e.abortSignal);if(!V.success)return V;const w=B?B.deployOrder:Ee({pattern:f.pattern,resources:f.resources}),v=w.length,X=f.hasDockerfile&&e.dockerProvider!==void 0&&w.includes(P.COMPUTE)&&i?.infraOnly!==!0;if(!f.hasDifferences&&!i?.force&&!X){t.onLog?.("No infrastructure changes detected","info"),t.onLog?.(i?.infraOnly===!0?"Infrastructure-only mode \u2014 application code was not rebuilt. Run with --deploy-only to deploy code changes.":"Nothing to deploy. Run with --deploy-only to redeploy application code.","info");const r=f.hasDockerfile&&e.dockerProvider!==void 0&&w.includes(P.COMPUTE);for(let s=0;s<w.length;s++){const g=w[s];r&&g===P.COMPUTE&&(t.onStepStart?.(G.DOCKER_OPERATIONS,de),t.onStepComplete?.(G.DOCKER_OPERATIONS,de,"skipped"));const l=le(g,"deploy"),u=ce(g,"deploy");t.onStepStart?.(l,u,s,v),t.onStepComplete?.(l,u,"skipped",s,v)}const o=await a.stackService.resolveWebsiteUrl(n.appName);return E({target:n.appName,deploymentType:"application",outputs:o?{websiteUrl:o}:void 0,noChanges:!0,artefacts:[],durationMs:Date.now()-h})}const H=ie(n.path,"cdk.out"),N=$e({deployOrder:w,stackChanges:f.stackChanges,appName:n.appName,force:i?.force===!0,computeBuildPossible:X});pe(t,a,[...N]);const K=await ue([...N],a.cfnService,e.abortSignal);if(!K.success)return t.onError?.(K.error),d(K.error);const Z=Ze(f.currentHashes),W=et({cfnService:a.cfnService,cdkOutPath:H,changedStacks:[...N],assemblyDigest:Z,registry:new qe(a.awsProvider),changeSetProbe:new ze(a.awsProvider),...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}}),q=await tt(a.awsProvider,{stackNames:[...N],cdkOutPath:H,cfnService:a.cfnService,callbacks:t,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(q.kind==="blocked"){const r=new Error(q.message);return t.onError?.(r),d(r)}const ee=q.verifiedAbsentByNecessity??[],te=e.approvalGate!==void 0||i?.approvalToken!==void 0&&i.approvalToken!==""||i?.planOnly===!0;if(te){const r=await Je({approvalGate:e.approvalGate,options:i??{},target:n.appName,cdkOutPath:H,detection:{stackChanges:f.stackChanges,currentHashes:f.currentHashes},cfnService:a.cfnService,loadPlan:W,callbacks:t,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(!r.success)return r;const o=r.data;if(o.kind==="rejected")return t.onLog?.(`Deploy rejected: ${y(o.reason)}`,"warn"),E({target:n.appName,deploymentType:"application",rejected:!0,artefacts:[],...o.planDetail!==void 0?{planDetail:o.planDetail}:{},durationMs:Date.now()-h});if(o.kind==="awaiting"){const s=await W(),g=s.success?Ye(s.data,ee):void 0;return E({target:n.appName,deploymentType:"application",awaitingApproval:!0,approvalToken:o.approvalToken,assemblyDigest:o.assemblyDigest,planDetail:o.planDetail,...g!==void 0?{destructionTicket:g}:{},artefacts:[],durationMs:Date.now()-h})}}const T=await Qe({loadPlan:W,options:i??{},target:n.appName,callbacks:t,verifiedAbsentByNecessity:ee,...e.destructionConsentGate!==void 0?{consentGate:e.destructionConsentGate}:{}});if(!T.success)return t.onError?.(T.error),d(T.error);if(T.data.kind==="withheld"){const r=te?Xe({assemblyDigest:Z,ttlMs:Ve}):void 0;return E({target:n.appName,deploymentType:"application",destructionPending:!0,destructionTicket:T.data.ticket,destructionOutcome:T.data.outcome,destructionHaltReasons:T.data.reasons,...r!==void 0?{destructionResumeToken:r.token,destructionResumeExpiresAt:r.expiresAt}:{},artefacts:[],durationMs:Date.now()-h})}if(T.data.kind==="proceed"){const{ticket:r,verbsByPhysicalName:o}=T.data,s=r.resources.filter(l=>o.get(l.physicalName)==="pin");if(s.length>0){t.onLog?.(`Pin remediation for ${n.appName}: probing live value(s) for ${s.length} consented pin(s) \u2014 deploy stopped for config write-back; nothing was applied`,"warn");const l=await nt(a.awsProvider,{targets:s.map(u=>({stack:u.stack,logicalId:u.logicalId,resourceType:u.resourceType,physicalName:u.physicalName,offendingProperties:u.offendingProperties})),...e.infrastructureSource!==void 0?{infrastructureContent:e.infrastructureSource.content,infrastructureFilePath:e.infrastructureSource.filePath}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});for(const u of at(l))t.onLog?.(u,"warn");return s.length<r.resources.length&&t.onLog?.("Remaining consented change(s) were not applied \u2014 the config write-back changes the plan, so they re-consent on the next deploy","warn"),E({target:n.appName,deploymentType:"application",pinPending:!0,pinReport:l,pinDeferredConsents:r.resources.length-s.length,destructionTicket:r,artefacts:[],durationMs:Date.now()-h})}const g=r.resources.filter(l=>l.finding==="pinned-name-replacement"&&o.get(l.physicalName)==="recreate");if(g.length>0){const l=new Map;for(const c of g){const m=l.get(c.stack)??[];m.push(c),l.set(c.stack,m)}const u=await ot(a.awsProvider,{stacks:[...l].map(([c,m])=>({stackName:c,targets:m.map(S=>({logicalId:S.logicalId,resourceType:S.resourceType,physicalId:S.physicalName}))})),...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(!u.success){const c=new Error(y(u.error.message));return t.onError?.(c),d(c)}const k=[];for(const[c,m]of l){const S=await _(e.assertLeaseForStack,c,e.abortSignal,"destroy");if(S!==void 0)return k.length>0&&t.onLog?.(`Surgery already completed on ${k.join("; ")} \u2014 those resources were removed and re-running the deploy resumes their journals and re-creates them.`,"warn"),t.onError?.(S),d(S);const R=m.map(p=>p.physicalName);t.onLog?.(`Recreate remediation for ${n.appName}: orchestrating the pinned-name replacement of ${R.join(", ")} on ${c}`,"warn");const L=await it(a.awsProvider,{stackName:c,targets:m.map(p=>({logicalId:p.logicalId,resourceType:p.resourceType,physicalId:p.physicalName})),appName:n.appName,consentedPhysicalNames:R,doneJournalPolicy:"archive-and-restart",plannedReplacementLogicalIds:m.map(p=>p.logicalId),onProgress:p=>t.onLog?.(p,"info"),...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(!L.success){const p=k.length>0?` Surgery already completed on ${k.join("; ")} \u2014 those resources were removed and re-running the deploy resumes their journals and re-creates them.`:"",j=new Error(y(`${L.error.message}${p}`));return t.onError?.(j),d(j)}k.push(`${c} (${R.join(", ")})`)}}}const re=await De(a,x,t);if(!re.success)return re;const ae=I(n.appName,P.DATABASE),z=N.has(ae),me=z?await _e(a.cfnService,n.appName):void 0,ke=z?await Ue(a.ecsResolver,a.ecsService,n.appName):void 0,O={},A=new Map;let ne;const D=async()=>{!z||!A.has(ae)||await He({appName:n.appName,connectionOutputsBefore:me,serviceTaskDefinitionsBefore:ke,services:{cfnService:a.cfnService,ecsService:a.ecsService,ecsResolver:a.ecsResolver},callbacks:t,...e.confirmDatabaseEndpointRestart!==void 0?{confirmRestart:e.confirmDatabaseEndpointRestart}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})};for(let r=0;r<w.length;r++){const o=w[r],s=I(n.appName,o),g=le(o,"deploy"),l=ce(o,"deploy");if(!N.has(s)){t.onStepStart?.(g,l,r,v),t.onLog?.(`Skipping ${o} \u2014 no changes detected`,"info"),t.onStepComplete?.(g,l,"skipped",r,v);continue}const u=await _(e.assertLeaseForStack,s,e.abortSignal);if(u!==void 0)return await D(),t.onError?.(u),d(u);const k=Fe(w,r,n.appName,N);if(k.length>=2){const p=await xe(k,n,a,x,t,r,v,f,O,A);if(!p.success)return await D(),d(await ge(a.awsProvider,{stackNames:k.map(j=>I(n.appName,j)),appName:n.appName,originalError:p.error,cfnService:a.cfnService,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}}));r+=k.length-1;continue}const c=await je(o,e,a,n,t,f.hasDockerfile);if(c!==null&&!c.success)return await D(),d(c.error);c!==null&&c.success&&(ne=c.data);const m=c!==null&&c.success?c.data.contentHashTagsByService:{},S=st(m);if(c!==null){const p=await _(e.assertLeaseForStack,s,e.abortSignal);if(p!==void 0)return await D(),t.onError?.(p),d(p)}const R=await Be(o,a,x,t,r,v,O,S!==void 0?{parameters:S}:void 0);if(!R.success)return await D(),d(await ge(a.awsProvider,{stackNames:[s],appName:n.appName,originalError:R.error,cfnService:a.cfnService,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}}));const L=f.currentHashes.get(s);L&&A.set(s,L)}if(await D(),A.size>0){const r=await a.hashService.updateStateAfterDeploy(n.path,A);if(!r.success){const o=y(r.error.message);M.debug(F,"Failed to update state file",{error:o}),t.onLog?.(`Warning: failed to update state file \u2014 next deploy may re-deploy unchanged stacks: ${o}`,"warn")}}const oe=await a.stackService.resolveWebsiteUrl(n.appName);oe&&(O.websiteUrl=oe);const ye=await We(a,n.appName,ne,t);return await rt(a.awsProvider,{stackNames:[...N],appName:n.appName,onLog:(r,o)=>t.onLog?.(r,o),...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}}).catch(r=>{M.debug(F,"Forget completion sweep failed",{error:y(se(r))})}),E({target:n.appName,deploymentType:"application",outputs:Object.keys(O).length>0?O:void 0,artefacts:ye,durationMs:Date.now()-h})}export{qt as deployApplication,pe as emitStackTargets};
@@ -7,12 +7,29 @@ import type { DeployServices } from "../serviceFactory.js";
7
7
  import type { DetectionResult } from "./detectionPipeline.js";
8
8
  import { type DockerBuildProducts } from "./dockerBuildHelper.js";
9
9
  import type { BuildCallbacks } from "../../types/frameworkBuilder.js";
10
+ /**
11
+ * Single source of the deploy loop's stack-inclusion predicate: a stack
12
+ * deploys when its template hash changed, when `--force` overrides the hash
13
+ * gate, or when Compute must run for an image build. The destruction gate
14
+ * MUST diff exactly this set — a second derivation lets a hash-unchanged
15
+ * stack deploy under `--force` without the gate ever seeing its live diff
16
+ * (local v1 templates vs a live stack moved to v2 out-of-band: hashes match,
17
+ * the stack escapes the plan, and the forced deploy applies a destructive
18
+ * v2→v1 revert with no ticket).
19
+ */
20
+ export declare function getStackNamesToDeploy(params: {
21
+ deployOrder: readonly ApplicationStack[];
22
+ stackChanges: ReadonlyMap<string, boolean>;
23
+ appName: string;
24
+ force: boolean;
25
+ computeBuildPossible: boolean;
26
+ }): ReadonlySet<string>;
10
27
  /**
11
28
  * Identify Phase 2 stacks (Storage, Messaging, Database) that are consecutive
12
- * in the deploy order and have changes. Returns them for parallel deployment
13
- * only if there are at least 2.
29
+ * in the deploy order and included in the to-deploy set. Returns them for
30
+ * parallel deployment only if there are at least 2.
14
31
  */
15
- export declare function getParallelPhase2Stacks(deployOrder: readonly ApplicationStack[], currentIndex: number, stackChanges: Map<string, boolean>, appName: string, force?: boolean): ApplicationStack[];
32
+ export declare function getParallelPhase2Stacks(deployOrder: readonly ApplicationStack[], currentIndex: number, appName: string, stackNamesToDeploy: ReadonlySet<string>): ApplicationStack[];
16
33
  /**
17
34
  * Deploy a group of stacks in parallel, reporting step events for each.
18
35
  */
@@ -1,4 +1,4 @@
1
- import{success as h,failure as S}from"@fjall/generator";import{maskSensitiveOutput as C}from"@fjall/util";import{isAborted as R}from"../../aws/organisations/types.js";import{APPLICATION_STACKS as E,getApplicationStackName as O,getApplicationStepName as y,getApplicationStepId as N,PARALLEL_DEPLOY_GROUPS as L}from"../../types/operations.js";import{emitProgress as I}from"../../services/supporting/helpers.js";import{forwardOutput as x,forwardResourceProgress as _}from"../contextHelpers.js";import{emptyBuildProducts as k,runDockerBuild as $}from"./dockerBuildHelper.js";import{runWelcomeImageSetup as j}from"./welcomeImageHelper.js";import{withStepLifecycle as D}from"../stepLifecycle.js";function W(t,o,s,p,r){const d=new Set(L.PHASE_2.stacks),i=[];for(let n=o;n<t.length&&d.has(t[n]);n++)i.push(t[n]);if(i.length<2)return[];if(!r){const n=i.filter(f=>{const m=O(p,f);return s.get(m)!==!1});return n.length>=2?n:[]}return i}async function Y(t,o,s,p,r,d,i,n,f,m){for(let e=0;e<t.length;e++){const u=t[e],l=N(u,"deploy"),c=y(u,"deploy");r.onStepStart?.(l,c,d+e,i)}I(r,"Deploying infrastructure in parallel\u2026"),r.onParallelPhaseStart?.(t,"Storage and database resources (parallel)");const a=await s.stackService.deployStacksInParallel(t,p,{onOutput:x(r),onResourceProgress:(e,u)=>{r.onResourceProgress?.(e),u&&r.onParallelStackResourceProgress?.(u,e)},onStackComplete:(e,u,l,c)=>{const P=N(e,"deploy"),A=y(e,"deploy"),B=d+t.indexOf(e),w=R(s.abortSignal);r.onStepComplete?.(P,A,u?"completed":w?"cancelled":"error",B,i),!u&&c&&!w&&r.onError?.(new Error(C(c.message)))}});if(!a.success)return r.onParallelPhaseComplete?.([]),S(a.error);const g=a.data.filter(e=>!e.success);if(r.onParallelPhaseComplete?.(a.data.map(e=>({stack:e.stack,success:e.success,error:e.error}))),g.length>0){const e=g.map(l=>l.stack).join(", "),u=g.map(l=>`${l.stack}: ${C(l.error?.message??"Unknown error")}`).join(`
2
- `);return S(new Error(`Failed to deploy stacks: ${e}
1
+ import{success as h,failure as m}from"@fjall/generator";import{maskSensitiveOutput as w}from"@fjall/util";import{isAborted as R}from"../../aws/organisations/types.js";import{APPLICATION_STACKS as B,getApplicationStackName as y,getApplicationStepName as N,getApplicationStepId as C,PARALLEL_DEPLOY_GROUPS as k}from"../../types/operations.js";import{emitProgress as L}from"../../services/supporting/helpers.js";import{forwardOutput as x,forwardResourceProgress as I}from"../contextHelpers.js";import{emptyBuildProducts as _,runDockerBuild as D}from"./dockerBuildHelper.js";import{runWelcomeImageSetup as $}from"./welcomeImageHelper.js";import{withStepLifecycle as j}from"../stepLifecycle.js";function W(e){const r=new Set;for(const n of e.deployOrder){const u=y(e.appName,n),o=e.stackChanges.get(u)??!0,a=n===B.COMPUTE&&e.computeBuildPossible;(o||e.force||a)&&r.add(u)}return r}function Y(e,r,n,u){const o=new Set(k.PHASE_2.stacks),a=[];for(let s=r;s<e.length&&o.has(e[s]);s++)a.push(e[s]);if(a.length<2)return[];const p=a.filter(s=>u.has(y(n,s)));return p.length>=2?p:[]}async function z(e,r,n,u,o,a,p,s,g,S){for(let t=0;t<e.length;t++){const i=e[t],c=C(i,"deploy"),d=N(i,"deploy");o.onStepStart?.(c,d,a+t,p)}L(o,"Deploying infrastructure in parallel\u2026"),o.onParallelPhaseStart?.(e,"Storage and database resources (parallel)");const l=await n.stackService.deployStacksInParallel(e,u,{onOutput:x(o),onResourceProgress:(t,i)=>{o.onResourceProgress?.(t),i&&o.onParallelStackResourceProgress?.(i,t)},onStackComplete:(t,i,c,d)=>{const P=C(t,"deploy"),A=N(t,"deploy"),E=a+e.indexOf(t),O=R(n.abortSignal);o.onStepComplete?.(P,A,i?"completed":O?"cancelled":"error",E,p),!i&&d&&!O&&o.onError?.(new Error(w(d.message)))}});if(!l.success)return o.onParallelPhaseComplete?.([]),m(l.error);const f=l.data.filter(t=>!t.success);if(o.onParallelPhaseComplete?.(l.data.map(t=>({stack:t.stack,success:t.success,error:t.error}))),f.length>0){const t=f.map(c=>c.stack).join(", "),i=f.map(c=>`${c.stack}: ${w(c.error?.message??"Unknown error")}`).join(`
2
+ `);return m(new Error(`Failed to deploy stacks: ${t}
3
3
 
4
- ${u}`))}for(const e of a.data){if(e.success&&e.outputs)for(const[c,P]of Object.entries(e.outputs))f[c]=String(P);const u=O(o.appName,e.stack),l=n.currentHashes.get(u);l&&m.set(u,l)}return h(void 0)}async function z(t,o,s,p,r,d,i,n){const f=N(t,"deploy"),m=y(t,"deploy");return D(p,{stepId:f,stepName:m,stepIndex:r,totalSteps:d},async()=>{const a=await o.stackService.deployStack(t,s,{onOutput:x(p),onResourceProgress:_(p)},n?.parameters!==void 0?{parameters:n.parameters}:void 0);if(!a.success)return{kind:"error",error:a.error};if(a.data.outputs)for(const[g,e]of Object.entries(a.data.outputs))i[g]=String(e);return{kind:"completed",data:void 0}},o.abortSignal)}async function J(t,o,s,p,r,d){if(t!==E.COMPUTE||!o.dockerProvider)return null;if(d){const n=await $(o,s,p,r);return n.success?h(n.data):S(n.error)}const i=await j(o,s,p,r);return i.success?h(k()):S(i.error)}function Q(t){return{onBuildStart:o=>{t.onOpenNextBuildStart?.(),t.onLog?.(`${o} build started`,"info")},onBuildProgress:(o,s)=>{t.onOpenNextProgress?.(s)},onBuildComplete:o=>{t.onOpenNextBuildComplete?.(),t.onLog?.(`${o} build complete`,"info")},onBuildError:(o,s)=>{t.onOpenNextBuildError?.(s)}}}export{Q as createBuildCallbacks,Y as deployParallelPhase,z as deployStackSequential,W as getParallelPhase2Stacks,J as runDockerPreCompute};
4
+ ${i}`))}for(const t of l.data){if(t.success&&t.outputs)for(const[d,P]of Object.entries(t.outputs))g[d]=String(P);const i=y(r.appName,t.stack),c=s.currentHashes.get(i);c&&S.set(i,c)}return h(void 0)}async function J(e,r,n,u,o,a,p,s){const g=C(e,"deploy"),S=N(e,"deploy");return j(u,{stepId:g,stepName:S,stepIndex:o,totalSteps:a},async()=>{const l=await r.stackService.deployStack(e,n,{onOutput:x(u),onResourceProgress:I(u)},s?.parameters!==void 0?{parameters:s.parameters}:void 0);if(!l.success)return{kind:"error",error:l.error};if(l.data.outputs)for(const[f,t]of Object.entries(l.data.outputs))p[f]=String(t);return{kind:"completed",data:void 0}},r.abortSignal)}async function Q(e,r,n,u,o,a){if(e!==B.COMPUTE||!r.dockerProvider)return null;if(a){const s=await D(r,n,u,o);return s.success?h(s.data):m(s.error)}const p=await $(r,n,u,o);return p.success?h(_()):m(p.error)}function V(e){return{onBuildStart:r=>{e.onOpenNextBuildStart?.(),e.onLog?.(`${r} build started`,"info")},onBuildProgress:(r,n)=>{e.onOpenNextProgress?.(n)},onBuildComplete:r=>{e.onOpenNextBuildComplete?.(),e.onLog?.(`${r} build complete`,"info")},onBuildError:(r,n)=>{e.onOpenNextBuildError?.(n)}}}export{V as createBuildCallbacks,z as deployParallelPhase,J as deployStackSequential,Y as getParallelPhase2Stacks,W as getStackNamesToDeploy,Q as runDockerPreCompute};
@@ -1 +1 @@
1
- import{success as w,failure as b}from"@fjall/generator";import{getErrorMessage as y}from"@fjall/util";import{logger as S}from"@fjall/util/logger";import{getApplicationDestroyOrder as N,getApplicationStepName as h,getApplicationStepId as P}from"../../types/operations.js";import{stubCallerIdentity as x}from"../../types/deployment/index.js";import{deriveResourcesFromManifestStacks as D}from"../../types/detection/patternDetection.js";import{CdkContextBuilder as R}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as I}from"../contextHelpers.js";import{deleteStateFile as L,readStateFile as O}from"../../types/config/FjallState.js";async function q(n,c,r){const{callbacks:t}=n,C=Date.now(),m=c.frameworkRegistry.resolve({appPath:r.path})?.detection.pattern??null;let p;try{const e=await O(r.path);if(e!==null){const o=Object.keys(e.templateHashes);o.length>0&&(p=D(o))}}catch(e){S.debug("applicationDestroy","Could not read state file for resource detection",{error:y(e)}),t.onLog?.("Could not read state file for resource detection \u2014 falling back to pattern detection","warn")}const k=R.buildDeploymentContext({deployType:"application",target:r.appName,path:r.path,region:c.awsProvider.getRegion(),callerIdentity:x(c.awsProvider.getAccountId()),...I({orgConfig:n.orgConfig,identity:n.identity})},{verbose:n.options?.verbose},n.orgConfig),d=N({pattern:m,resources:p}),s=d.length;t.onLog?.(`Destroying ${r.appName} (${s} stacks, ${m??"standard"} pattern)`,"info");const f=[],u=[],g=await c.stackService.destroyAllStacks(k,{onOutput:e=>{t.onOutput?.(e)},onLog:t.onLog,onStackCleanupProgress:t.onStackCleanupProgress,onResourceProgress:(e,o)=>{t.onResourceProgress?.(e),o&&t.onParallelStackResourceProgress?.(o,e)},onStackStart:(e,o)=>{const a=P(e,"destroy"),l=h(e,"destroy"),i=d.indexOf(e);t.onStepStart?.(a,l,i,s)},onStackComplete:async(e,o)=>{const a=P(e,"destroy"),l=h(e,"destroy"),i=d.indexOf(e);o.success?o.data?.skipped?(u.push(o.data.stackName||e),t.onStepComplete?.(a,l,"skipped",i,s)):(f.push(o.data?.stackName||e),t.onStepComplete?.(a,l,"completed",i,s)):(t.onStepComplete?.(a,l,"error",i,s),t.onError?.(o.error))},onParallelPhaseStart:(e,o)=>{t.onLog?.(`Parallel phase: ${o}`,"info"),t.onParallelPhaseStart?.(e,o)},onParallelPhaseComplete:e=>{const o=e.filter(a=>!a.success);o.length>0&&t.onLog?.(`Parallel phase completed with ${o.length} failure(s)`,"warn"),t.onParallelPhaseComplete?.(e)}},p,n.abortSignal);if(!g.success)return t.onError?.(g.error),b(g.error);try{await L(r.path)}catch(e){S.debug("applicationDestroy","Failed to delete state file (non-critical)",{error:y(e)}),t.onLog?.("Failed to delete state file (non-critical)","warn")}return w({target:r.appName,deploymentType:"application",stacksDestroyed:f,skippedStacks:u,durationMs:Date.now()-C})}export{q as destroyApplication};
1
+ import{success as w,failure as N}from"@fjall/generator";import{getErrorMessage as S}from"@fjall/util";import{logger as y}from"@fjall/util/logger";import{getApplicationDestroyOrder as b,getApplicationStackName as x,getApplicationStepName as h,getApplicationStepId as P}from"../../types/operations.js";import{emitStackTargets as D}from"./applicationDeploy.js";import{stubCallerIdentity as L}from"../../types/deployment/index.js";import{deriveResourcesFromManifestStacks as R}from"../../types/detection/patternDetection.js";import{CdkContextBuilder as A}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as F}from"../contextHelpers.js";import{deleteStateFile as I,readStateFile as O}from"../../types/config/FjallState.js";async function J(a,s,r){const{callbacks:t}=a,k=Date.now(),m=s.frameworkRegistry.resolve({appPath:r.path})?.detection.pattern??null;let d;try{const e=await O(r.path);if(e!==null){const o=Object.keys(e.templateHashes);o.length>0&&(d=R(o))}}catch(e){y.debug("applicationDestroy","Could not read state file for resource detection",{error:S(e)}),t.onLog?.("Could not read state file for resource detection \u2014 falling back to pattern detection","warn")}const C=A.buildDeploymentContext({deployType:"application",target:r.appName,path:r.path,region:s.awsProvider.getRegion(),callerIdentity:L(s.awsProvider.getAccountId()),...F({orgConfig:a.orgConfig,identity:a.identity})},{verbose:a.options?.verbose},a.orgConfig),p=b({pattern:m,resources:d}),l=p.length;t.onLog?.(`Destroying ${r.appName} (${l} stacks, ${m??"standard"} pattern)`,"info"),D(t,s,p.map(e=>x(r.appName,e)));const f=[],u=[],g=await s.stackService.destroyAllStacks(C,{onOutput:e=>{t.onOutput?.(e)},onLog:t.onLog,onStackCleanupProgress:t.onStackCleanupProgress,onResourceProgress:(e,o)=>{t.onResourceProgress?.(e),o&&t.onParallelStackResourceProgress?.(o,e)},onStackStart:(e,o)=>{const n=P(e,"destroy"),i=h(e,"destroy"),c=p.indexOf(e);t.onStepStart?.(n,i,c,l)},onStackComplete:async(e,o)=>{const n=P(e,"destroy"),i=h(e,"destroy"),c=p.indexOf(e);o.success?o.data?.skipped?(u.push(o.data.stackName||e),t.onStepComplete?.(n,i,"skipped",c,l)):(f.push(o.data?.stackName||e),t.onStepComplete?.(n,i,"completed",c,l)):(t.onStepComplete?.(n,i,"error",c,l),t.onError?.(o.error))},onParallelPhaseStart:(e,o)=>{t.onLog?.(`Parallel phase: ${o}`,"info"),t.onParallelPhaseStart?.(e,o)},onParallelPhaseComplete:e=>{const o=e.filter(n=>!n.success);o.length>0&&t.onLog?.(`Parallel phase completed with ${o.length} failure(s)`,"warn"),t.onParallelPhaseComplete?.(e)}},d,a.abortSignal,a.assertLeaseForStack);if(!g.success)return t.onError?.(g.error),N(g.error);try{await I(r.path)}catch(e){y.debug("applicationDestroy","Failed to delete state file (non-critical)",{error:S(e)}),t.onLog?.("Failed to delete state file (non-critical)","warn")}return w({target:r.appName,deploymentType:"application",stacksDestroyed:f,skippedStacks:u,durationMs:Date.now()-k})}export{J as destroyApplication};
@@ -16,7 +16,9 @@ import { type Result } from "@fjall/generator";
16
16
  import type { DeployCallbacks } from "../../types/callbacks.js";
17
17
  import type { DeployOptions } from "../../types/params.js";
18
18
  import type { ApprovalGate, ApprovalGatePlanDetail } from "../../types/approval.js";
19
- import { type CfnTemplateReader } from "./plan/computeDeployPlan.js";
19
+ import type { CfnRegistrySummaryReader } from "../../services/infrastructure/CfnRegistryService.js";
20
+ import type { CfnTemplateReader } from "./plan/computeDeployPlan.js";
21
+ import { type DeployPlanLoader } from "./plan/loadDeployPlan.js";
20
22
  export interface RunApprovalGateParams {
21
23
  /** The surface resolver. Optional — a token-resume or plan-only run needs none. */
22
24
  approvalGate?: ApprovalGate;
@@ -28,7 +30,19 @@ export interface RunApprovalGateParams {
28
30
  currentHashes: ReadonlyMap<string, string>;
29
31
  };
30
32
  cfnService: CfnTemplateReader;
33
+ /**
34
+ * Registry oracle for replacement-verdict recomputation. Optional — without
35
+ * it plan rows keep raw cloudformation-diff verdicts.
36
+ */
37
+ registry?: CfnRegistrySummaryReader;
38
+ /**
39
+ * Injected (memoised) plan supplier — applicationDeploy shares one loader
40
+ * between this gate and the unconditional destruction gate so a plan-gated
41
+ * deploy never diffs twice. Omitted, the gate builds its own.
42
+ */
43
+ loadPlan?: DeployPlanLoader;
31
44
  callbacks: DeployCallbacks;
45
+ abortSignal?: AbortSignal;
32
46
  }
33
47
  export type ApprovalGateOutcome = {
34
48
  kind: "proceed";
@@ -1 +1 @@
1
- import{join as k}from"node:path";import{readFile as g}from"node:fs/promises";import{success as s,failure as d}from"@fjall/generator";import{maskSensitiveOutput as h}from"@fjall/util";import{computeAssemblyDigest as v}from"./plan/assemblyDigest.js";import{computeDeployPlan as m}from"./plan/computeDeployPlan.js";import{renderPlanLines as y}from"./plan/renderDeployPlan.js";import{signApprovalToken as w,verifyApprovalToken as A}from"./plan/approvalToken.js";function x(e){switch(e){case"superseded":return"the plan changed since it was approved (re-plan and approve again)";case"expired":return"the approval window expired";case"tampered":return"the approval signature did not verify";case"malformed":return"the approval token was malformed"}}async function D(e){const t=[];for(const n of e.changedStacks){const i=k(e.cdkOutPath,`${n}.template.json`);let r;try{r=JSON.parse(await g(i,"utf8"))}catch(o){return d(new Error(h(`Failed to read synthesised template for ${n}: ${o instanceof Error?o.message:String(o)}`)))}t.push({stackName:n,newTemplate:r})}return m({cfnService:e.cfnService,stacks:t,assemblyDigest:e.assemblyDigest})}function S(e,t,n){const i=t.changeCount===0&&n.length>0?`no resource changes against the live stacks; template hashes differ from the last recorded deploy for ${n.length} stack(s) (${n.join(", ")}) \u2014 applying will redeploy them`:t.summary;e.onLog?.(`Deploy plan: ${i}`,"info");for(const r of y(t))e.onLog?.(r,"info")}async function G(e){const t=v(e.detection.currentHashes),n=e.options.approvalToken;if(n!==void 0&&n!==""){const a=A({token:n,assemblyDigest:t});if(a.ok)return e.callbacks.onLog?.("Approval token verified against the current plan \u2014 proceeding","info"),s({kind:"proceed"});const u=x(a.reason);return e.callbacks.onLog?.(`Approval refused: ${u}`,"warn"),s({kind:"rejected",reason:u})}const i=[...e.detection.stackChanges.entries()].filter(([,a])=>a).map(([a])=>a),r=await D({cfnService:e.cfnService,cdkOutPath:e.cdkOutPath,changedStacks:i,assemblyDigest:t});if(!r.success)return d(r.error);const o=r.data;S(e.callbacks,o,i);const p={hashDriftStacks:i,resourceChangeCount:o.changeCount,resourceChangedStacks:[...new Set(o.changes.map(a=>a.stack))]},c=w({assemblyDigest:t});if(e.options.autoApprove===!0)return e.callbacks.onLog?.("Plan auto-approved (--auto-approve)","info"),s({kind:"proceed"});if(e.options.planOnly===!0)return s({kind:"awaiting",approvalToken:c.token,assemblyDigest:t,expiresAt:c.expiresAt,planDetail:p});if(e.approvalGate===void 0)return s({kind:"awaiting",approvalToken:c.token,assemblyDigest:t,expiresAt:c.expiresAt,planDetail:p});const f={target:e.target,plan:o,approvalToken:c.token,expiresAt:c.expiresAt,hasDestructiveChanges:o.hasDestructiveChanges},l=await e.approvalGate.resolve(f);switch(l.kind){case"approved":return s({kind:"proceed"});case"rejected":return s({kind:"rejected",reason:l.reason,planDetail:p});case"suspended":return s({kind:"awaiting",approvalToken:l.approvalToken,assemblyDigest:t,expiresAt:c.expiresAt,planDetail:p})}}export{G as runApprovalGate};
1
+ import{success as o,failure as g}from"@fjall/generator";import{computeAssemblyDigest as k}from"./plan/assemblyDigest.js";import{loadDeployPlan as v}from"./plan/loadDeployPlan.js";import{renderPlanLines as h}from"./plan/renderDeployPlan.js";import{signApprovalToken as y,verifyApprovalToken as A}from"./plan/approvalToken.js";function w(e){switch(e){case"superseded":return"the plan changed since it was approved (re-plan and approve again)";case"expired":return"the approval window expired";case"tampered":return"the approval signature did not verify";case"malformed":return"the approval token was malformed"}}function m(e,n,r){const i=n.changeCount===0&&r.length>0?`no resource changes against the live stacks; template hashes differ from the last recorded deploy for ${r.length} stack(s) (${r.join(", ")}) \u2014 applying will redeploy them`:n.summary;e.onLog?.(`Deploy plan: ${i}`,"info");for(const d of h(n))e.onLog?.(d,"info")}async function D(e){const n=k(e.detection.currentHashes),r=e.options.approvalToken;if(r!==void 0&&r!==""){const t=A({token:r,assemblyDigest:n});if(t.ok)return e.callbacks.onLog?.("Approval token verified against the current plan \u2014 proceeding","info"),o({kind:"proceed"});const u=w(t.reason);return e.callbacks.onLog?.(`Approval refused: ${u}`,"warn"),o({kind:"rejected",reason:u})}const i=[...e.detection.stackChanges.entries()].filter(([,t])=>t).map(([t])=>t),p=await(e.loadPlan??(()=>v({cfnService:e.cfnService,cdkOutPath:e.cdkOutPath,changedStacks:i,assemblyDigest:n,...e.registry!==void 0?{registry:e.registry}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})))();if(!p.success)return g(p.error);const s=p.data;m(e.callbacks,s,i);const c={hashDriftStacks:i,resourceChangeCount:s.changeCount,resourceChangedStacks:[...new Set(s.changes.map(t=>t.stack))]},a=y({assemblyDigest:n});if(e.options.autoApprove===!0)return e.callbacks.onLog?.("Plan auto-approved (--auto-approve)","info"),o({kind:"proceed"});if(e.options.planOnly===!0)return o({kind:"awaiting",approvalToken:a.token,assemblyDigest:n,expiresAt:a.expiresAt,planDetail:c});if(e.approvalGate===void 0)return o({kind:"awaiting",approvalToken:a.token,assemblyDigest:n,expiresAt:a.expiresAt,planDetail:c});const f={target:e.target,plan:s,approvalToken:a.token,expiresAt:a.expiresAt,hasDestructiveChanges:s.hasDestructiveChanges},l=await e.approvalGate.resolve(f);switch(l.kind){case"approved":return o({kind:"proceed"});case"rejected":return o({kind:"rejected",reason:l.reason,planDetail:c});case"suspended":return o({kind:"awaiting",approvalToken:l.approvalToken,assemblyDigest:n,expiresAt:a.expiresAt,planDetail:c})}}export{D as runApprovalGate};
@@ -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 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};
1
+ import{isAbsolute as H,join as _,dirname as M,resolve as K}from"node:path";import{existsSync as j}from"node:fs";import{success as P,failure as h}from"@fjall/generator";import{deriveContentHashTag as z,maskSensitiveOutput as b,toKebab as x}from"@fjall/util";import{evaluateBakeGuard as X,evaluateResolvedBuildArgValues as Y,acknowledgedBuildArgKeys as q,createBakeWarningDeduper as J,buildCacheRepositoryName as U,BAKE_GUARD_EXPOSURE_CLAUSE as Q}from"@fjall/util/docker";import{runWithBoundedConcurrency as Z,resolveBuildConcurrency as ee}from"@fjall/util/buildConcurrency";import{logger as T}from"@fjall/util/logger";import{parseDockerServicesFromManifest as re}from"@fjall/util/manifest";import{STEP_IDS as A}from"../../types/stepDefinitions.js";import{isAborted as G}from"../../aws/organisations/types.js";import{resolveBuildSecrets as te,resolveSecretRefValue as ne,resolveBuildSecretSessionProvider as oe,sourcedRefsFromBuildArgs as ie}from"./buildSecretResolver.js";import{partitionForRegion as se}from"./buildSecretSession.js";import{resolveBuildArgs as ae}from"./buildArgResolver.js";import{validateBuildGroup as de}from"./buildGroupValidator.js";import{ensureBuildCacheRepository as ce}from"./ecrCacheRepository.js";const I="dockerBuildHelper",C="Building and pushing Docker image";function ue(e,n,t){const r=_(n,e);if(j(r))return{path:r,base:n};if(t!==""&&t!==n){const i=_(t,e);if(j(i))return{path:i,base:t}}return{path:r,base:n}}function le(e,n,t){const r=e.docker.path,i=e.docker.context;let s,a;if(H(r))s=r,a=n;else{const p=ue(r,n,t);s=p.path,a=p.base}return{buildContext:i?H(i)?K(i):K(a,i):K(M(s)),dockerfilePath:s,target:e.docker.target,resolvedViaFallback:a!==n}}function fe(e){return`${e.buildContext}|${e.dockerfilePath}|${e.target??""}`}function ge(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 me(e){return e.startsWith("cn-")?"amazonaws.com.cn":"amazonaws.com"}function V(e,n){return`${e}.dkr.ecr.${n}.${me(n)}`}function pe(e,n,t){return`${V(e,n)}/${x(t)}`}function he(e,n,t){return`${V(e,n)}/${U(x(t))}`}function be(e,n,t){const r=new Map;for(const i of e){const s=le(i,n,t),a=fe(s),m=r.get(a);m?m.members.push(i):r.set(a,{identity:s,members:[i]})}return Array.from(r.values())}async function ve(e,n,t,r,i){const s=await e.buildAndPush({appName:n.appName,appPath:n.path,region:t,accountId:r},(a,m,p,y)=>{i.onDockerProgress?.(b(a),m,p,y)});return s.success?P({imageUri:s.data.imageUri,imageTag:s.data.imageTag}):h(s.error)}function F(){return{contentHashTagsByService:{},imageDigestsByService:{}}}async function Se(e,n,t,r,i,s,a,m,p,y,E,f,R,w){const l=e.members[0];if(!j(e.identity.dockerfilePath))return h(new Error(ge(l.name,e.identity.dockerfilePath)));e.identity.resolvedViaFallback&&m.onLog?.(`Building service '${l.name}' from ${e.identity.dockerfilePath} (resolved at the project root).`,"info");const v=[...l.docker.buildSecrets??[],...R??[]],N=de({serviceName:l.name,dockerfilePath:e.identity.dockerfilePath,buildContext:e.identity.buildContext,...e.identity.target!==void 0&&{target:e.identity.target},...l.docker.buildArgs!==void 0&&{buildArgs:l.docker.buildArgs},...v.length>0&&{buildSecrets:v},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(!N.success)return h(N.error);const d=X(l.docker.buildArgs,x(t.appName));for(const o of d.warnings){const S=b(o.message);w(S)&&m.onLog?.(S,"warn")}if(d.findings.length>0)return h(new Error(b(d.findings.map(o=>o.message).join(" "))));const u=v,D=ie(l.docker.buildArgs),L={appName:t.appName,region:r,accountId:i,partition:se(r)},$=await oe(p,[...u,...D],L);if(!$.success)return h($.error);const O=$.data,k=await ae({manifestBuildArgs:l.docker.buildArgs,serviceDir:e.identity.buildContext,appDir:t.path,...E!==void 0&&{stage:E},shellEnv:process.env,...f!==void 0&&{flagBuildArgs:f},resolveSourcedValue:(o,S)=>ne(O,{id:o,...S},y)});if(!k.success)return h(k.error);const B=k.data.buildArgs;for(const o of k.data.inferredDotenvKeys){const S=b(`buildArg "${o}" was auto-declared from a .env file and will be baked into the image \u2014 ${Q}.`);w(S)&&m.onLog?.(S,"warn")}const c=Y(B,x(t.appName),q(l.docker.buildArgs));if(c.length>0)return h(new Error(b(c.map(o=>o.message).join(" "))));let g;if(u.length>0){const o=await te(O,u,y);if(!o.success)return h(o.error);g=o.data}try{if(G(y))return h(new Error(`Build of service '${l.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:l.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 ye(e,o,n,s,m)}finally{g!==void 0&&await g.cleanup().catch(()=>m.onLog?.("Build-secret cleanup failed.","warn"))}}async function ye(e,n,t,r,i){const s=await t.buildAndPush(n,(f,R,w,l)=>{i.onDockerProgress?.(b(f),R,w,l)});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={},y=[];for(const f of e.members){const R=f.docker.target,w=z(a,f.name,R);if(!w.success)return h(w.error);const l=w.data,v=f.name;m[v]=l,p[v]=a,y.push(`${r}:${l}`)}const E=await t.tagByDigest({sourceImage:r,digest:a,tags:y});return E.success?P({contentHashTagsByService:m,imageDigestsByService:p}):h(E.error)}async function Le(e,n,t,r){const i=e.dockerProvider;if(!i)return P(F());const s=n.awsProvider.getAccountId();if(!s)return r.onLog?.("Skipping Docker build \u2014 account ID not available","warn"),P(F());const a=n.awsProvider.getRegion(),m=_(t.path,"cdk.out"),p=re(m),y=pe(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 u=await ve(i,t,a,s,r);if(!u.success){T.debug(I,"Docker buildAndPush (legacy) failed",{appName:t.appName,error:b(u.error.message)}),r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"error");const D=new Error(b(u.error.message));return r.onError?.(D),h(D)}return r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"completed"),r.onLog?.(`Docker image pushed: ${u.data.imageUri}`,"info"),P(F())}const E=await i.preflight?.();if(E!==void 0&&!E.success){r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"error");const d=new Error(b(E.error.message));return r.onError?.(d),h(d)}const f=be(p,t.path,e.workingDirectory);let R=he(s,a,t.appName);const w=await ce(n.awsProvider,U(x(t.appName)),e.abortSignal);w.success||(R=void 0,r.onLog?.(`Building without registry cache \u2014 ${w.error.message}`,"warn")),r.onLog?.(`Building and pushing ${p.length} service image(s) in ${f.length} group(s)\u2026`,"info");let l=!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 u=new Error(b(d.error.message));return r.onError?.(u),h(u)}l=!0}const v=new AbortController,N=()=>v.abort();e.abortSignal!==void 0&&(e.abortSignal.aborted?v.abort():e.abortSignal.addEventListener("abort",N,{once:!0}));try{const d=()=>G(e.abortSignal)?"the deployment was cancelled":"a sibling group failed";let u;const D=ee(f.length),L=J(),$=f.map((c,g)=>async()=>{const o=c.members.map(W=>W.name).join(", ");if(v.signal.aborted)throw new Error(`Build of group ${g+1}/${f.length} (${o}) was not started \u2014 ${d()}`);r.onLog?.(`Building group ${g+1}/${f.length} (${o})\u2026`,"info");const S=await Se(c,i,t,a,s,y,R,r,n.awsProvider,v.signal,e.options?.environment,e.options?.flagBuildArgs,e.options?.flagBuildSecrets,L);if(!S.success)throw u===void 0&&(u={error:S.error,leader:c.members[0]?.name,members:o}),v.abort(),S.error;return S.data}),O=await Z($,D,{abortOnFirstFailure:!0,abortSignal:v.signal});if(u===void 0)for(let c=0;c<f.length;c++){const g=O[c];if(!(g!==void 0&&g.success)){u={error:g!==void 0&&!g.success?g.error:new Error(`Build of group ${c+1}/${f.length} never ran \u2014 ${d()}`),leader:f[c].members[0]?.name,members:f[c].members.map(o=>o.name).join(", ")};break}}if(u!==void 0){T.debug(I,"Docker buildAndPush failed",{appName:t.appName,leader:u.leader,members:u.members,error:b(u.error.message)}),r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"error");const c=new Error(b(u.error.message));return r.onError?.(c),h(c)}const k={},B={};for(const c of O)if(!(c===void 0||!c.success)){for(const[g,o]of Object.entries(c.data.contentHashTagsByService))k[g]=o;for(const[g,o]of Object.entries(c.data.imageDigestsByService))B[g]=o}return T.debug(I,"Docker buildAndPush succeeded",{appName:t.appName,services:p.map(c=>c.name),groupCount:f.length,contentHashTags:k}),r.onStepComplete?.(A.DOCKER_OPERATIONS,C,"completed"),r.onLog?.(`Docker images pushed for ${p.length} service(s)`,"info"),P({contentHashTagsByService:k,imageDigestsByService:B,repoUri:y})}finally{e.abortSignal?.removeEventListener("abort",N),l&&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,F as emptyBuildProducts,Le as runDockerBuild};
@@ -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};
@@ -25,4 +25,4 @@ export declare const IMPORT_MAP_TIMEOUT_MS = 30000;
25
25
  * `deployOnly` early-return check, so built artefacts are available
26
26
  * for CDK synth.
27
27
  */
28
- export declare function runOpenNextBuild(operation: ApplicationOperation, pattern: string | null | undefined, callbacks: DeployCallbacks, options?: DeployOptions): Promise<Result<void>>;
28
+ export declare function runOpenNextBuild(operation: ApplicationOperation, pattern: string | null | undefined, callbacks: DeployCallbacks, options?: DeployOptions, abortSignal?: AbortSignal): Promise<Result<void>>;
@@ -1,3 +1,3 @@
1
- import{createRequire as _}from"module";import{existsSync as g,statSync as S,rmSync as $}from"fs";import{join as l}from"path";import{success as f,failure as i}from"@fjall/generator";import{filterDangerousEnvVars as y,maskSensitiveOutput as d,parseShellArgs as v}from"@fjall/util";import{logger as c}from"@fjall/util/logger";import{isOpenNextPattern as b}from"../../types/detection/patternDetection.js";import{spawnWithTimeout as E}from"../spawnHelpers.js";const x="@opennextjs/aws",P=6e5,w=3e4;function T(s){const n=_(l(s,"package.json"));let e;try{e=n.resolve.paths(x)}catch(r){return i(new Error(`Cannot determine module search paths for ${x}. Ensure it is installed in your project. (${r instanceof Error?r.message:String(r)})`))}if(!e||e.length===0)return i(new Error(`Cannot determine module search paths for ${x}. Ensure it is installed in your project.`));for(const r of e){const t=l(r,"@opennextjs","aws");try{if(S(t).isDirectory()){const p=l(r,".bin","open-next");if(!g(p)){c.debug("openNextBuild","Package found but binary missing",{nodeModulesPath:r,binPath:p});continue}return c.debug("openNextBuild","Resolved OpenNext binary",{binPath:p}),f(p)}}catch(o){c.debug("openNextBuild","Package not at location",{nodeModulesPath:r,error:String(o)})}}return i(new Error(`Cannot find ${x} package. Ensure it is installed in your project: npm install ${x}`))}function O(s){const n=l(s,".open-next");if(g(n))try{$(n,{recursive:!0,force:!0}),c.debug("openNextBuild","Cleaned up stale .open-next directory",{path:n})}catch(e){c.debug("openNextBuild","Failed to clean up .open-next",{error:String(e)})}}function B(s,n,e,r){switch(s.type){case"timeout":return e?.(),r?.(n.timeoutMsg),i(new Error(n.timeoutMsg));case"enoent":return e?.(),r?.(n.enoentMsg),i(new Error(n.enoentMsg));case"spawn_error":{const t=`${n.spawnErrorPrefix}: ${s.error.message}`;return e?.(),r?.(t),i(new Error(t))}default:return}}async function D(s,n){n.onOpenNextProgress?.(d("Generating Payload import map..."));const[e,...r]=v("npx payload generate:importmap"),t=y(globalThis.process.env),o=await E({command:e,args:r,cwd:s,env:{...t,FORCE_COLOR:"0"},timeout:w,onStdoutData:m=>{n.onOpenNextProgress?.(d(m.trim()))}}),p=B(o,{timeoutMsg:`Payload import map generation timed out after ${w/1e3} seconds`,enoentMsg:"Payload CLI not found. Ensure payload is installed: npm install payload",spawnErrorPrefix:"Failed to generate Payload import map"});if(p)return p;if(o.type!=="success")return i(new Error("Unexpected spawn result"));if(o.code!==0){const m=d(o.stderr||o.stdout);return i(new Error(`Payload import map generation failed (exit code ${o.code}):
2
- ${m}`))}return c.debug("openNextBuild","Payload import map generated",{appPath:s}),n.onOpenNextProgress?.(d("Payload import map generated")),f(void 0)}async function H(s,n,e,r){if(!b(n))return f(void 0);if(r?.skipBuild||r?.infraOnly)return c.debug("openNextBuild","Build skipped",{skipBuild:r?.skipBuild,infraOnly:r?.infraOnly}),e.onLog?.(r?.infraOnly?"Infrastructure-only mode \u2014 skipping OpenNext build":"Build skipped (--skip-build)","info"),f(void 0);const t=s.path;if(n==="payload"){const u=await D(t,e);if(!u.success)return e.onOpenNextBuildError?.(d(u.error.message)),O(t),i(u.error)}const o=T(t);if(!o.success)return e.onOpenNextBuildError?.(d(o.error.message)),i(o.error);const p=o.data;e.onOpenNextBuildStart?.(),e.onOpenNextProgress?.(d("Starting OpenNext build..."));const m=y(globalThis.process.env),M=l(t,"node_modules"),a=await E({command:p,args:["build"],cwd:t,env:{...m,FORCE_COLOR:"0",NODE_PATH:M,npm_config_loglevel:"error",PNPM_HOME:m.PNPM_HOME||""},timeout:P,onStdoutData:u=>{e.onOpenNextProgress?.(d(u.trim()))},onStderrData:u=>{e.onOpenNextProgress?.(d(u.trim()))}}),N=B(a,{timeoutMsg:`OpenNext build timed out after ${P/1e3} seconds`,enoentMsg:"OpenNext binary not found. Ensure Node.js is installed and in PATH.",spawnErrorPrefix:"Failed to spawn OpenNext build"},()=>O(t),u=>e.onOpenNextBuildError?.(u));if(N)return N;if(a.type!=="success")return i(new Error("Unexpected spawn result"));if(a.code!==0){O(t);const u=d(a.stderr||a.stdout);return e.onOpenNextBuildError?.(`OpenNext build failed (exit code ${a.code})`),i(new Error(`OpenNext build failed (exit code ${a.code}):
3
- ${u}`))}const h=l(t,".open-next");return g(h)?(e.onOpenNextBuildComplete?.(),e.onOpenNextProgress?.(d("OpenNext build completed successfully")),f(void 0)):(e.onOpenNextBuildError?.("OpenNext build completed but .open-next directory not found"),i(new Error("OpenNext build completed but .open-next directory not found")))}export{P as BUILD_TIMEOUT_MS,w as IMPORT_MAP_TIMEOUT_MS,H as runOpenNextBuild};
1
+ import{createRequire as $}from"module";import{existsSync as O,statSync as v,rmSync as S}from"fs";import{join as f}from"path";import{success as l,failure as o}from"@fjall/generator";import{filterDangerousEnvVars as E,maskSensitiveOutput as u,parseShellArgs as b}from"@fjall/util";import{logger as c}from"@fjall/util/logger";import{isOpenNextPattern as T}from"../../types/detection/patternDetection.js";import{spawnWithTimeout as P}from"../spawnHelpers.js";const x="@opennextjs/aws",w=6e5,M=3e4;function D(s){const n=$(f(s,"package.json"));let e;try{e=n.resolve.paths(x)}catch(r){return o(new Error(`Cannot determine module search paths for ${x}. Ensure it is installed in your project. (${r instanceof Error?r.message:String(r)})`))}if(!e||e.length===0)return o(new Error(`Cannot determine module search paths for ${x}. Ensure it is installed in your project.`));for(const r of e){const p=f(r,"@opennextjs","aws");try{if(v(p).isDirectory()){const t=f(r,".bin","open-next");if(!O(t)){c.debug("openNextBuild","Package found but binary missing",{nodeModulesPath:r,binPath:t});continue}return c.debug("openNextBuild","Resolved OpenNext binary",{binPath:t}),l(t)}}catch(i){c.debug("openNextBuild","Package not at location",{nodeModulesPath:r,error:String(i)})}}return o(new Error(`Cannot find ${x} package. Ensure it is installed in your project: npm install ${x}`))}function N(s){const n=f(s,".open-next");if(O(n))try{S(n,{recursive:!0,force:!0}),c.debug("openNextBuild","Cleaned up stale .open-next directory",{path:n})}catch(e){c.debug("openNextBuild","Failed to clean up .open-next",{error:String(e)})}}function B(s,n,e,r){switch(s.type){case"timeout":return e?.(),r?.(n.timeoutMsg),o(new Error(n.timeoutMsg));case"aborted":return e?.(),r?.(n.abortedMsg),o(new Error(n.abortedMsg));case"enoent":return e?.(),r?.(n.enoentMsg),o(new Error(n.enoentMsg));case"spawn_error":{const p=`${n.spawnErrorPrefix}: ${s.error.message}`;return e?.(),r?.(p),o(new Error(p))}default:return}}async function j(s,n,e){n.onOpenNextProgress?.(u("Generating Payload import map..."));const[r,...p]=b("npx payload generate:importmap"),i=E(globalThis.process.env),t=await P({command:r,args:p,cwd:s,env:{...i,FORCE_COLOR:"0"},timeout:M,...e!==void 0&&{abortSignal:e},onStdoutData:m=>{n.onOpenNextProgress?.(u(m.trim()))}}),g=B(t,{timeoutMsg:`Payload import map generation timed out after ${M/1e3} seconds`,abortedMsg:"Payload import map generation cancelled",enoentMsg:"Payload CLI not found. Ensure payload is installed: npm install payload",spawnErrorPrefix:"Failed to generate Payload import map"});if(g)return g;if(t.type!=="success")return o(new Error("Unexpected spawn result"));if(t.code!==0){const m=u(t.stderr||t.stdout);return o(new Error(`Payload import map generation failed (exit code ${t.code}):
2
+ ${m}`))}return c.debug("openNextBuild","Payload import map generated",{appPath:s}),n.onOpenNextProgress?.(u("Payload import map generated")),l(void 0)}async function q(s,n,e,r,p){if(!T(n))return l(void 0);if(r?.skipBuild||r?.infraOnly)return c.debug("openNextBuild","Build skipped",{skipBuild:r?.skipBuild,infraOnly:r?.infraOnly}),e.onLog?.(r?.infraOnly?"Infrastructure-only mode \u2014 skipping OpenNext build":"Build skipped (--skip-build)","info"),l(void 0);const i=s.path;if(n==="payload"){const d=await j(i,e,p);if(!d.success)return e.onOpenNextBuildError?.(u(d.error.message)),N(i),o(d.error)}const t=D(i);if(!t.success)return e.onOpenNextBuildError?.(u(t.error.message)),o(t.error);const g=t.data;e.onOpenNextBuildStart?.(),e.onOpenNextProgress?.(u("Starting OpenNext build..."));const m=E(globalThis.process.env),h=f(i,"node_modules"),a=await P({command:g,args:["build"],cwd:i,env:{...m,FORCE_COLOR:"0",NODE_PATH:h,npm_config_loglevel:"error",PNPM_HOME:m.PNPM_HOME||""},timeout:w,...p!==void 0&&{abortSignal:p},onStdoutData:d=>{e.onOpenNextProgress?.(u(d.trim()))},onStderrData:d=>{e.onOpenNextProgress?.(u(d.trim()))}}),y=B(a,{timeoutMsg:`OpenNext build timed out after ${w/1e3} seconds`,abortedMsg:"OpenNext build cancelled",enoentMsg:"OpenNext binary not found. Ensure Node.js is installed and in PATH.",spawnErrorPrefix:"Failed to spawn OpenNext build"},()=>N(i),d=>e.onOpenNextBuildError?.(d));if(y)return y;if(a.type!=="success")return o(new Error("Unexpected spawn result"));if(a.code!==0){N(i);const d=u(a.stderr||a.stdout);return e.onOpenNextBuildError?.(`OpenNext build failed (exit code ${a.code})`),o(new Error(`OpenNext build failed (exit code ${a.code}):
3
+ ${d}`))}const _=f(i,".open-next");return O(_)?(e.onOpenNextBuildComplete?.(),e.onOpenNextProgress?.(u("OpenNext build completed successfully")),l(void 0)):(e.onOpenNextBuildError?.("OpenNext build completed but .open-next directory not found"),o(new Error("OpenNext build completed but .open-next directory not found")))}export{w as BUILD_TIMEOUT_MS,M as IMPORT_MAP_TIMEOUT_MS,q as runOpenNextBuild};
@@ -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};