@fjall/deploy-core 2.27.0 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) 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 +12 -1
  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/leaseGate.d.ts +30 -0
  30. package/dist/src/orchestration/application/leaseGate.js +1 -0
  31. package/dist/src/orchestration/application/plan/approvalToken.d.ts +15 -0
  32. package/dist/src/orchestration/application/plan/approvalToken.js +1 -1
  33. package/dist/src/orchestration/application/plan/changeSetEscalation.d.ts +46 -0
  34. package/dist/src/orchestration/application/plan/changeSetEscalation.js +1 -0
  35. package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +13 -0
  36. package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -1
  37. package/dist/src/orchestration/application/plan/index.d.ts +4 -1
  38. package/dist/src/orchestration/application/plan/index.js +1 -1
  39. package/dist/src/orchestration/application/plan/loadDeployPlan.d.ts +32 -0
  40. package/dist/src/orchestration/application/plan/loadDeployPlan.js +1 -0
  41. package/dist/src/orchestration/application/plan/pinnedNames.d.ts +31 -0
  42. package/dist/src/orchestration/application/plan/pinnedNames.js +1 -0
  43. package/dist/src/orchestration/application/plan/registryOverlay.d.ts +83 -0
  44. package/dist/src/orchestration/application/plan/registryOverlay.js +1 -0
  45. package/dist/src/orchestration/application/plan/renderDeployPlan.d.ts +4 -1
  46. package/dist/src/orchestration/application/plan/renderDeployPlan.js +1 -1
  47. package/dist/src/orchestration/application/plan/types.d.ts +31 -0
  48. package/dist/src/orchestration/application/plan/types.js +1 -0
  49. package/dist/src/orchestration/contextHelpers.d.ts +37 -1
  50. package/dist/src/orchestration/contextHelpers.js +1 -1
  51. package/dist/src/orchestration/drift/classifyDriftFailure.d.ts +31 -0
  52. package/dist/src/orchestration/drift/classifyDriftFailure.js +1 -0
  53. package/dist/src/orchestration/drift/detectStackDrift.d.ts +37 -0
  54. package/dist/src/orchestration/drift/detectStackDrift.js +1 -0
  55. package/dist/src/orchestration/drift/driftPreFlight.d.ts +73 -0
  56. package/dist/src/orchestration/drift/driftPreFlight.js +7 -0
  57. package/dist/src/orchestration/drift/driftProbe.d.ts +113 -0
  58. package/dist/src/orchestration/drift/driftProbe.js +1 -0
  59. package/dist/src/orchestration/drift/driftSuspectJournal.d.ts +40 -0
  60. package/dist/src/orchestration/drift/driftSuspectJournal.js +1 -0
  61. package/dist/src/orchestration/drift/preFlightGate.d.ts +44 -0
  62. package/dist/src/orchestration/drift/preFlightGate.js +2 -0
  63. package/dist/src/orchestration/index.d.ts +19 -0
  64. package/dist/src/orchestration/index.js +1 -1
  65. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.d.ts +7 -0
  66. package/dist/src/orchestration/organisation/cascadeDestroyHelpers.js +1 -1
  67. package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
  68. package/dist/src/orchestration/organisation/organisationDestroy.js +3 -3
  69. package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +8 -1
  70. package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -1
  71. package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -5
  72. package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +21 -2
  73. package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -1
  74. package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
  75. package/dist/src/orchestration/remediation/driftRepairGate.d.ts +66 -0
  76. package/dist/src/orchestration/remediation/driftRepairGate.js +1 -0
  77. package/dist/src/orchestration/remediation/forensicsCapture.d.ts +49 -0
  78. package/dist/src/orchestration/remediation/forensicsCapture.js +1 -0
  79. package/dist/src/orchestration/remediation/forgetResource.d.ts +423 -0
  80. package/dist/src/orchestration/remediation/forgetResource.js +1 -0
  81. package/dist/src/orchestration/remediation/pinRemediation.d.ts +121 -0
  82. package/dist/src/orchestration/remediation/pinRemediation.js +1 -0
  83. package/dist/src/orchestration/remediation/postFailureRepairOffer.d.ts +77 -0
  84. package/dist/src/orchestration/remediation/postFailureRepairOffer.js +14 -0
  85. package/dist/src/orchestration/remediation/recreatePreFlight.d.ts +47 -0
  86. package/dist/src/orchestration/remediation/recreatePreFlight.js +1 -0
  87. package/dist/src/orchestration/remediation/recreateResource.d.ts +110 -0
  88. package/dist/src/orchestration/remediation/recreateResource.js +1 -0
  89. package/dist/src/orchestration/remediation/remediationJournal.d.ts +88 -0
  90. package/dist/src/orchestration/remediation/remediationJournal.js +2 -0
  91. package/dist/src/orchestration/remediation/removalUpdate.d.ts +135 -0
  92. package/dist/src/orchestration/remediation/removalUpdate.js +1 -0
  93. package/dist/src/orchestration/remediation/retainFlip.d.ts +294 -0
  94. package/dist/src/orchestration/remediation/retainFlip.js +1 -0
  95. package/dist/src/services/application/ApplicationStackService.d.ts +5 -3
  96. package/dist/src/services/application/ApplicationStackService.js +1 -1
  97. package/dist/src/services/application/applicationStackHelpers.d.ts +8 -6
  98. package/dist/src/services/application/applicationStackHelpers.js +3 -3
  99. package/dist/src/services/index.d.ts +2 -2
  100. package/dist/src/services/index.js +1 -1
  101. package/dist/src/services/infrastructure/CdkEventMonitoring.d.ts +15 -0
  102. package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -1
  103. package/dist/src/services/infrastructure/CdkOutputAnalyser.d.ts +8 -0
  104. package/dist/src/services/infrastructure/CdkOutputAnalyser.js +2 -1
  105. package/dist/src/services/infrastructure/CdkService.js +3 -3
  106. package/dist/src/services/infrastructure/CfnRegistryService.d.ts +68 -0
  107. package/dist/src/services/infrastructure/CfnRegistryService.js +1 -0
  108. package/dist/src/services/infrastructure/CloudFormationService.d.ts +5 -2
  109. package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
  110. package/dist/src/services/infrastructure/EcsServiceResolver.d.ts +23 -3
  111. package/dist/src/services/infrastructure/EcsServiceResolver.js +1 -1
  112. package/dist/src/services/infrastructure/cfnRegistrySeed.d.ts +10 -0
  113. package/dist/src/services/infrastructure/cfnRegistrySeed.js +1 -0
  114. package/dist/src/services/infrastructure/changeSetProbe.d.ts +88 -0
  115. package/dist/src/services/infrastructure/changeSetProbe.js +1 -0
  116. package/dist/src/services/infrastructure/index.d.ts +2 -0
  117. package/dist/src/services/infrastructure/index.js +1 -1
  118. package/dist/src/types/callbackKeys.d.ts +1 -1
  119. package/dist/src/types/callbackKeys.js +1 -1
  120. package/dist/src/types/callbacks.d.ts +22 -0
  121. package/dist/src/types/config/entitlements.d.ts +9 -0
  122. package/dist/src/types/config/orgConfig.d.ts +6 -0
  123. package/dist/src/types/deploymentEventSchema.d.ts +1 -0
  124. package/dist/src/types/deploymentEventSchema.js +1 -1
  125. package/dist/src/types/destruction.d.ts +212 -0
  126. package/dist/src/types/destruction.js +1 -0
  127. package/dist/src/types/drift.d.ts +72 -0
  128. package/dist/src/types/drift.js +1 -0
  129. package/dist/src/types/index.d.ts +8 -2
  130. package/dist/src/types/index.js +1 -1
  131. package/dist/src/types/params.d.ts +142 -0
  132. package/dist/src/types/remediation.d.ts +126 -0
  133. package/dist/src/types/remediation.js +1 -0
  134. package/package.json +5 -4
@@ -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 pe}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 ge(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});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,p=b?.detection.hasDockerfile===!0,l=s||p;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:p,hasDockerfile:l,manifestDockerServiceCount:o.length,manifestDockerPaths:o.map(m=>m.docker.path)}),!s&&!p&&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);ge(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(),p=Ae(e.orgConfig),l=await Oe({cloudFormation:a.awsProvider.getClient(he),ec2:new Se({region:p,...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 p=w[s];r&&p===P.COMPUTE&&(t.onStepStart?.(G.DOCKER_OPERATIONS,de),t.onStepComplete?.(G.DOCKER_OPERATIONS,de,"skipped"));const l=le(p,"deploy"),u=ce(p,"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});ge(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(),p=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,...p!==void 0?{destructionTicket:p}:{},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 p=r.resources.filter(l=>l.finding==="pinned-name-replacement"&&o.get(l.physicalName)==="recreate");if(p.length>0){const l=new Map;for(const c of p){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(g=>g.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(g=>({logicalId:g.logicalId,resourceType:g.resourceType,physicalId:g.physicalName})),appName:n.appName,consentedPhysicalNames:R,doneJournalPolicy:"archive-and-restart",plannedReplacementLogicalIds:m.map(g=>g.logicalId),onProgress:g=>t.onLog?.(g,"info"),...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(!L.success){const g=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}${g}`));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),p=le(o,"deploy"),l=ce(o,"deploy");if(!N.has(s)){t.onStepStart?.(p,l,r,v),t.onLog?.(`Skipping ${o} \u2014 no changes detected`,"info"),t.onStepComplete?.(p,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 g=await xe(k,n,a,x,t,r,v,f,O,A);if(!g.success)return await D(),d(await pe(a.awsProvider,{stackNames:k.map(j=>I(n.appName,j)),appName:n.appName,originalError:g.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 g=await _(e.assertLeaseForStack,s,e.abortSignal);if(g!==void 0)return await D(),t.onError?.(g),d(g)}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 pe(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,ge 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};
@@ -0,0 +1,30 @@
1
+ import { BaseServiceError } from "../../types/errors/ServiceError.js";
2
+ import type { DeployParams } from "../../types/params.js";
3
+ /**
4
+ * The deployment's lease was denied at a stack boundary (§5.3): the server has
5
+ * released or superseded this deployment (cancelled, swept, adopted by another
6
+ * runner, or force-released) while the engine was mid-flight. The engine aborts
7
+ * BEFORE starting the named stack's first mutating CloudFormation operation —
8
+ * already-completed stacks stand. The message is surface-neutral (no CLI flags
9
+ * or commands) and built from static literals + the stack name, so there is
10
+ * nothing to mask.
11
+ */
12
+ export declare class LeaseDeniedError extends BaseServiceError {
13
+ readonly stackName: string;
14
+ constructor(message: string, stackName: string);
15
+ }
16
+ /** Which mutating operation the gate protects — drives the denial wording. */
17
+ export type LeaseGateOperation = "deploy" | "destroy";
18
+ /**
19
+ * Stack-boundary lease gate (§5.3). Called immediately before a stack's first
20
+ * mutating CloudFormation operation. Returns a typed {@link LeaseDeniedError}
21
+ * when the lease is denied (the caller must abort without starting the stack),
22
+ * or `undefined` when the deploy may proceed.
23
+ *
24
+ * Consumes the resolved gate resolver + shutdown signal rather than the whole
25
+ * `DeployParams`. Fails OPEN: a `"unknown"` verdict (the liveness probe could
26
+ * not reach the server) proceeds, because a lease is a liveness signal and
27
+ * CloudFormation's own ClientRequestToken is the fencing backstop. An absent
28
+ * resolver is a no-op gate.
29
+ */
30
+ export declare function assertLeaseBeforeStack(assertLeaseForStack: DeployParams["assertLeaseForStack"], stackName: string, abortSignal?: AbortSignal, operation?: LeaseGateOperation): Promise<LeaseDeniedError | undefined>;
@@ -0,0 +1 @@
1
+ import{BaseServiceError as t}from"../../types/errors/ServiceError.js";class s extends t{stackName;constructor(e,n){super("LEASE_DENIED",e),this.stackName=n}}function o(r,e){return`Deployment lease denied ${e==="destroy"?`before destroying ${r}`:`before deploying ${r}`} \u2014 the server released or superseded this deployment (it may have been cancelled, adopted by another runner, or force-released). ${e==="destroy"?"Stacks already destroyed are gone; remaining stacks stand.":"Already-completed stacks stand."} Check the deployment in the Fjall dashboard.`}async function c(r,e,n,d="deploy"){if(r===void 0)return;if(await r(e,n)==="denied")return new s(o(e,d),e)}export{s as LeaseDeniedError,c as assertLeaseBeforeStack};
@@ -14,6 +14,21 @@
14
14
  * verification falls back to the digest match alone.
15
15
  */
16
16
  export declare const DEFAULT_APPROVAL_TTL_MS: number;
17
+ /**
18
+ * TTL for the token re-minted when the destruction gate withholds an
19
+ * approval-gated deploy. The consent ceremony can legitimately take far
20
+ * longer than {@link DEFAULT_APPROVAL_TTL_MS} — the webapp lets a
21
+ * `destructive_pending` row await a human for 24 hours
22
+ * (`sweepAbandonedDestructivePendingSystem`) before abandoning it, and the
23
+ * original 15-minute token would expire long before that, making the
24
+ * ceremony unreachable for approval-gated deploys. 24h + 1h margin covers a
25
+ * consent granted at the very end of the abandon window plus claim/queue
26
+ * latency. Digest binding is unchanged: the re-minted token still refuses as
27
+ * `superseded` the moment the plan moves, so only the TIME bound is relaxed —
28
+ * and the withheld run already verified the original approval against this
29
+ * same digest.
30
+ */
31
+ export declare const DESTRUCTION_RESUME_TTL_MS: number;
17
32
  export declare const APPROVAL_TOKEN_PATTERN: RegExp;
18
33
  export interface SignApprovalTokenOptions {
19
34
  readonly assemblyDigest: string;
@@ -1 +1 @@
1
- import{createHmac as c,randomBytes as l,timingSafeEqual as m}from"node:crypto";import{digestHead as i}from"./assemblyDigest.js";const a=16,p=900*1e3,x=/^([0-9a-f]{32})\.([0-9]{1,16})\.([0-9a-f]{32})$/;function A(){const e=process.env.FJALL_APPROVAL_TOKEN_SECRET;return e!==void 0&&e!==""?{secret:Buffer.from(e,"utf8"),stable:!0}:{secret:l(32),stable:!1}}const{secret:u,stable:E}=A();function k(e){const t=i(e.assemblyDigest),r=e.now??Date.now,o=e.ttlMs??p,n=r()+o,s=c("sha256",u).update(`${t}.${n}`).digest().subarray(0,a).toString("hex");return{token:`${t}.${n}.${s}`,expiresAt:new Date(n).toISOString()}}function g(e){const t=x.exec(e.token);if(t===null)return{ok:!1,reason:"malformed"};const r=t[1],o=t[2],n=t[3];if(r===void 0||o===void 0||n===void 0)return{ok:!1,reason:"malformed"};if(r!==i(e.assemblyDigest))return{ok:!1,reason:"superseded"};const s=parseInt(o,10);if(!Number.isFinite(s))return{ok:!1,reason:"malformed"};if((e.now??Date.now)()>s)return{ok:!1,reason:"expired"};if(E){const d=c("sha256",u).update(`${r}.${o}`).digest().subarray(0,a),f=Buffer.from(n,"hex");if(f.length!==a||!m(d,f))return{ok:!1,reason:"tampered"}}return{ok:!0}}export{x as APPROVAL_TOKEN_PATTERN,p as DEFAULT_APPROVAL_TTL_MS,k as signApprovalToken,g as verifyApprovalToken};
1
+ import{createHmac as c,randomBytes as l,timingSafeEqual as m}from"node:crypto";import{digestHead as i}from"./assemblyDigest.js";const a=16,p=900*1e3,k=1500*60*1e3,T=/^([0-9a-f]{32})\.([0-9]{1,16})\.([0-9a-f]{32})$/;function E(){const e=process.env.FJALL_APPROVAL_TOKEN_SECRET;return e!==void 0&&e!==""?{secret:Buffer.from(e,"utf8"),stable:!0}:{secret:l(32),stable:!1}}const{secret:u,stable:S}=E();function g(e){const t=i(e.assemblyDigest),r=e.now??Date.now,o=e.ttlMs??p,n=r()+o,s=c("sha256",u).update(`${t}.${n}`).digest().subarray(0,a).toString("hex");return{token:`${t}.${n}.${s}`,expiresAt:new Date(n).toISOString()}}function L(e){const t=T.exec(e.token);if(t===null)return{ok:!1,reason:"malformed"};const r=t[1],o=t[2],n=t[3];if(r===void 0||o===void 0||n===void 0)return{ok:!1,reason:"malformed"};if(r!==i(e.assemblyDigest))return{ok:!1,reason:"superseded"};const s=parseInt(o,10);if(!Number.isFinite(s))return{ok:!1,reason:"malformed"};if((e.now??Date.now)()>s)return{ok:!1,reason:"expired"};if(S){const d=c("sha256",u).update(`${r}.${o}`).digest().subarray(0,a),f=Buffer.from(n,"hex");if(f.length!==a||!m(d,f))return{ok:!1,reason:"tampered"}}return{ok:!0}}export{T as APPROVAL_TOKEN_PATTERN,p as DEFAULT_APPROVAL_TTL_MS,k as DESTRUCTION_RESUME_TTL_MS,g as signApprovalToken,L as verifyApprovalToken};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Change-set escalation — Layer 2 of the replacement oracle (drift-remediation
3
+ * plan § 2, [G4]). Runs the review-only change-set probe ONLY on rows whose
4
+ * Layer-1 verdict is `conditional`/`may` on a stateful or pinned-name
5
+ * resource: definite createOnly hits are already proven, and stateless
6
+ * unpinned conditionals never ticket, so probing either buys no proof. A
7
+ * non-engaged deploy issues zero probe calls.
8
+ *
9
+ * Verdict precedence: change-set > registry > cdk-spec.
10
+ * Replacement: False → the ceremony DOWNGRADES to a warning — the row
11
+ * becomes an in-place update (directive 2:
12
+ * never block on a false positive)
13
+ * Replacement: True → conditional upgrades to definite, verdictSource
14
+ * `"change-set"`
15
+ * Replacement: Conditional → stays worst-case (the registry verdict stands)
16
+ * probe failure → the registry verdict stands, annotated
17
+ * `"registry (change-set unverified)"` — degrade
18
+ * to the conservative verdict, never to silence
19
+ *
20
+ * A resource the change set omits entirely (including the benign
21
+ * `didn't contain changes` outcome) will not be touched by the update —
22
+ * treated as `False`.
23
+ */
24
+ import type { ChangeSetProbeRunner } from "../../../services/infrastructure/changeSetProbe.js";
25
+ import type { StackTemplatePair } from "./buildDeployPlan.js";
26
+ import type { DeployPlan, DeployPlanResourceChange } from "./types.js";
27
+ /**
28
+ * The [G4] trigger predicate: a replacement row that is neither proven
29
+ * (`will`) nor irrelevant (`none`), on a resource that would actually ticket
30
+ * — stateful (data loss) or pinned-name (CloudFormation refusal).
31
+ */
32
+ export declare function needsChangeSetEscalation(change: DeployPlanResourceChange): boolean;
33
+ export interface ApplyChangeSetEscalationParams {
34
+ plan: DeployPlan;
35
+ probe: ChangeSetProbeRunner;
36
+ /** The diffed template pairs — the probe submits `newTemplate`. */
37
+ stacks: readonly StackTemplatePair[];
38
+ abortSignal?: AbortSignal;
39
+ }
40
+ /**
41
+ * Apply the change-set escalation to an overlay-corrected plan. Never throws
42
+ * and never fails the plan: a failed or aborted probe leaves the conservative
43
+ * registry verdict in place, annotated unverified. One probe per escalated
44
+ * stack covers every candidate row in it.
45
+ */
46
+ export declare function applyChangeSetEscalation(params: ApplyChangeSetEscalationParams): Promise<DeployPlan>;
@@ -0,0 +1 @@
1
+ import{maskSensitiveOutput as k}from"@fjall/util";import{logger as u}from"@fjall/util/logger";import{isAborted as S}from"../../../aws/organisations/types.js";import{isDataLoss as w,isStatefulResourceType as g}from"./classify.js";import{recountActions as T}from"./registryOverlay.js";import{renderPlanSummary as C}from"./renderDeployPlan.js";const p="ChangeSetEscalation";function M(t){return t.action!=="replace"||t.retained||t.replacementMode!=="conditional"&&t.replacementMode!=="may"?!1:g(t.resourceType)||t.pinnedPhysicalName!==void 0}function b(t){return`change-set preflight proved this update applies in place (${t.replacementMode} verdict downgraded)`}function N(t){return{...t,action:"update",replacementMode:"none",destructive:!1,dataLoss:!1,propertyChanges:t.propertyChanges.map(r=>({...r,forcesReplacement:!1})),verdictSource:"change-set",interruptionNote:b(t)}}function L(t){return{...t,action:"replace",replacementMode:"will",destructive:!0,dataLoss:w({action:"replace",retained:t.retained,resourceType:t.resourceType}),verdictSource:"change-set"}}function $(t){return t.verdictSource!=="registry"?t:{...t,verdictSource:"registry (change-set unverified)"}}async function A(t){const{plan:r,probe:f,stacks:l,abortSignal:i}=t,c=r.changes.filter(M);if(c.length===0)return r;const m=new Map(l.map(e=>[e.stackName,e])),y=[...new Set(c.map(e=>e.stack))],a=new Map;for(const e of y){if(S(i)){a.set(e,"unverified");continue}const n=m.get(e);if(n===void 0){u.warn(p,"No synthesised template for escalated stack",{stackName:e}),a.set(e,"unverified");continue}const s=await f.probeStackReplacements({stackName:e,template:n.newTemplate,...i!==void 0?{abortSignal:i}:{}});if(!s.success){u.warn(p,"Change-set probe degraded to registry verdict",{stackName:e,error:k(s.error.message)}),a.set(e,"unverified");continue}a.set(e,s.data.replacementByLogicalId)}const v=new Set(c.map(e=>`${e.stack}|${e.address}`)),o=r.changes.map(e=>{if(!v.has(`${e.stack}|${e.address}`))return e;const n=a.get(e.stack);if(n===void 0||n==="unverified")return $(e);const s=n.get(e.address)??"False";return s==="False"?N(e):s==="True"?L(e):(u.warn(p,"Change set could not decide \u2014 keeping worst case",{stack:e.stack,address:e.address,resourceType:e.resourceType}),e)}),d=T(o);return d["no-op"]=r.counts["no-op"],{...r,changes:o,counts:d,hasDestructiveChanges:o.some(e=>e.destructive),summary:C({counts:d,changes:o})}}export{A as applyChangeSetEscalation,M as needsChangeSetEscalation};
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { type Result } from "@fjall/generator";
8
8
  import type { CloudFormationError } from "../../../services/infrastructure/CloudFormationService.js";
9
+ import type { CfnRegistrySummaryReader } from "../../../services/infrastructure/CfnRegistryService.js";
10
+ import type { ChangeSetProbeRunner } from "../../../services/infrastructure/changeSetProbe.js";
9
11
  import type { DeployPlan } from "./types.js";
10
12
  /** The narrow CFN read surface the gate needs — CloudFormationService satisfies it. */
11
13
  export interface CfnTemplateReader {
@@ -20,5 +22,16 @@ export interface ComputeDeployPlanParams {
20
22
  cfnService: CfnTemplateReader;
21
23
  stacks: ComputeDeployPlanStack[];
22
24
  assemblyDigest: string;
25
+ /**
26
+ * Registry oracle for replacement-verdict recomputation. Optional — without
27
+ * it the plan carries raw cloudformation-diff verdicts (no `verdictSource`).
28
+ */
29
+ registry?: CfnRegistrySummaryReader;
30
+ /**
31
+ * Change-set escalation probe [G4]. Optional — without it conditional
32
+ * verdicts stay worst-cased into the ceremony (the sanctioned D2 fallback).
33
+ */
34
+ changeSetProbe?: ChangeSetProbeRunner;
35
+ abortSignal?: AbortSignal;
23
36
  }
24
37
  export declare function computeDeployPlan(params: ComputeDeployPlanParams): Promise<Result<DeployPlan, Error>>;
@@ -1 +1 @@
1
- import{success as o,failure as n}from"@fjall/generator";import{maskSensitiveOutput as i}from"@fjall/util";import{buildDeployPlan as m}from"./buildDeployPlan.js";function p(t){const r=t.trim();if(r==="")return o({});try{return o(JSON.parse(r))}catch(e){return n(new Error(`Failed to parse deployed template: ${i(e instanceof Error?e.message:String(e))}`))}}async function d(t){const r=[];for(const e of t.stacks){const s=await t.cfnService.getTemplate(e.stackName);let c={};if(s.success){const a=p(s.data);if(!a.success)return n(a.error);c=a.data}else if(s.error.errorType!=="stack_not_found")return n(s.error);r.push({stackName:e.stackName,oldTemplate:c,newTemplate:e.newTemplate})}return o(m(r,t.assemblyDigest))}export{d as computeDeployPlan};
1
+ import{success as i,failure as s}from"@fjall/generator";import{maskSensitiveOutput as c}from"@fjall/util";import{buildDeployPlan as f}from"./buildDeployPlan.js";import{applyChangeSetEscalation as u}from"./changeSetEscalation.js";import{applyRegistryOverlay as p}from"./registryOverlay.js";function d(e){const r=e.trim();if(r==="")return i({});try{return i(JSON.parse(r))}catch(t){return s(new Error(`Failed to parse deployed template: ${c(t instanceof Error?t.message:String(t))}`))}}async function k(e){const r=[];for(const o of e.stacks){const n=await e.cfnService.getTemplate(o.stackName);let l={};if(n.success){const a=d(n.data);if(!a.success)return s(a.error);l=a.data}else if(n.error.errorType!=="stack_not_found")return s(n.error);r.push({stackName:o.stackName,oldTemplate:l,newTemplate:o.newTemplate})}let t=f(r,e.assemblyDigest);return e.registry!==void 0&&(t=await p({plan:t,registry:e.registry,stacks:r,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})),e.changeSetProbe!==void 0&&(t=await u({plan:t,probe:e.changeSetProbe,stacks:r,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})),i(t)}export{k as computeDeployPlan};
@@ -1,6 +1,9 @@
1
1
  /** Deploy-plan computation + rendering — the approval gate's read-side core. */
2
- export type { DeployPlan, DeployPlanResourceChange } from "./types.js";
2
+ export { REPLACEMENT_VERDICT_SOURCES, type DeployPlan, type DeployPlanResourceChange, type ReplacementVerdictSource } from "./types.js";
3
3
  export { buildDeployPlan, type StackTemplatePair } from "./buildDeployPlan.js";
4
+ export { applyRegistryOverlay, overlayResourceChange, registryVerdictForProperty, type ApplyRegistryOverlayParams, type OverlayResourceChangeResult, type RegistryVerdictDisagreement } from "./registryOverlay.js";
5
+ export { applyChangeSetEscalation, needsChangeSetEscalation, type ApplyChangeSetEscalationParams } from "./changeSetEscalation.js";
6
+ export { findPinnedPhysicalName, resourcePropertiesFromTemplate } from "./pinnedNames.js";
4
7
  export { computeDeployPlan, type CfnTemplateReader, type ComputeDeployPlanParams, type ComputeDeployPlanStack } from "./computeDeployPlan.js";
5
8
  export { computeAssemblyDigest, digestHead } from "./assemblyDigest.js";
6
9
  export { classifyImpact, derivePropertyChanges, isDataLoss, isStatefulResourceType, type ImpactClassification } from "./classify.js";
@@ -1 +1 @@
1
- import{buildDeployPlan as o}from"./buildDeployPlan.js";import{computeDeployPlan as a}from"./computeDeployPlan.js";import{computeAssemblyDigest as s,digestHead as l}from"./assemblyDigest.js";import{classifyImpact as m,derivePropertyChanges as i,isDataLoss as P,isStatefulResourceType as f}from"./classify.js";import{renderPlanSummary as T,renderPlanLines as y,toWirePlanChanges as d}from"./renderDeployPlan.js";import{signApprovalToken as x,verifyApprovalToken as L,DEFAULT_APPROVAL_TTL_MS as c,APPROVAL_TOKEN_PATTERN as g}from"./approvalToken.js";export{g as APPROVAL_TOKEN_PATTERN,c as DEFAULT_APPROVAL_TTL_MS,o as buildDeployPlan,m as classifyImpact,s as computeAssemblyDigest,a as computeDeployPlan,i as derivePropertyChanges,l as digestHead,P as isDataLoss,f as isStatefulResourceType,y as renderPlanLines,T as renderPlanSummary,x as signApprovalToken,d as toWirePlanChanges,L as verifyApprovalToken};
1
+ import{REPLACEMENT_VERDICT_SOURCES as o}from"./types.js";import{buildDeployPlan as t}from"./buildDeployPlan.js";import{applyRegistryOverlay as s,overlayResourceChange as n,registryVerdictForProperty as l}from"./registryOverlay.js";import{applyChangeSetEscalation as m,needsChangeSetEscalation as y}from"./changeSetEscalation.js";import{findPinnedPhysicalName as f,resourcePropertiesFromTemplate as T}from"./pinnedNames.js";import{computeDeployPlan as d}from"./computeDeployPlan.js";import{computeAssemblyDigest as x,digestHead as A}from"./assemblyDigest.js";import{classifyImpact as R,derivePropertyChanges as u,isDataLoss as C,isStatefulResourceType as L}from"./classify.js";import{renderPlanSummary as _,renderPlanLines as h,toWirePlanChanges as v}from"./renderDeployPlan.js";import{signApprovalToken as O,verifyApprovalToken as N,DEFAULT_APPROVAL_TTL_MS as V,APPROVAL_TOKEN_PATTERN as F}from"./approvalToken.js";export{F as APPROVAL_TOKEN_PATTERN,V as DEFAULT_APPROVAL_TTL_MS,o as REPLACEMENT_VERDICT_SOURCES,m as applyChangeSetEscalation,s as applyRegistryOverlay,t as buildDeployPlan,R as classifyImpact,x as computeAssemblyDigest,d as computeDeployPlan,u as derivePropertyChanges,A as digestHead,f as findPinnedPhysicalName,C as isDataLoss,L as isStatefulResourceType,y as needsChangeSetEscalation,n as overlayResourceChange,l as registryVerdictForProperty,h as renderPlanLines,_ as renderPlanSummary,T as resourcePropertiesFromTemplate,O as signApprovalToken,v as toWirePlanChanges,N as verifyApprovalToken};