@fjall/deploy-core 2.30.0 → 2.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.minified +1 -1
- package/dist/src/aws/organisations/oidcProvider.js +1 -1
- package/dist/src/aws/utils/cloudformationEventHelpers.d.ts +2 -2
- package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/orchestration/activeDeploymentGuard.d.ts +1 -1
- package/dist/src/orchestration/activeDeploymentGuard.js +3 -3
- package/dist/src/orchestration/application/applicationDeploy.js +1 -1
- package/dist/src/orchestration/application/applicationDeployHelpers.d.ts +19 -0
- package/dist/src/orchestration/application/applicationDeployHelpers.js +3 -3
- package/dist/src/orchestration/application/applicationDestroy.js +1 -1
- package/dist/src/orchestration/application/artefactCollection.d.ts +9 -1
- package/dist/src/orchestration/application/artefactCollection.js +1 -1
- package/dist/src/orchestration/application/codeOnlyDeploy.d.ts +10 -1
- package/dist/src/orchestration/application/codeOnlyDeploy.js +1 -1
- package/dist/src/orchestration/application/databaseEndpointReconcile.d.ts +2 -2
- package/dist/src/orchestration/application/databaseEndpointReconcile.js +1 -1
- package/dist/src/orchestration/application/destroyPreFlight.d.ts +33 -0
- package/dist/src/orchestration/application/destroyPreFlight.js +1 -0
- package/dist/src/orchestration/application/detectionPipeline.d.ts +1 -1
- package/dist/src/orchestration/application/detectionPipeline.js +1 -1
- package/dist/src/orchestration/application/dockerBuildHelper.d.ts +1 -0
- package/dist/src/orchestration/application/dockerBuildHelper.js +1 -1
- package/dist/src/orchestration/application/lambdaCodeOnlyRollout.d.ts +90 -0
- package/dist/src/orchestration/application/lambdaCodeOnlyRollout.js +1 -0
- package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +1 -1
- package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -1
- package/dist/src/orchestration/application/serviceImageTagsRollback.d.ts +1 -1
- package/dist/src/orchestration/application/serviceImageTagsRollback.js +1 -1
- package/dist/src/orchestration/application/taskDefinitionRoll.d.ts +1 -1
- package/dist/src/orchestration/application/taskDefinitionRoll.js +1 -1
- package/dist/src/orchestration/destroy.js +1 -1
- package/dist/src/orchestration/drift/driftPreFlight.js +3 -3
- package/dist/src/orchestration/index.d.ts +1 -1
- package/dist/src/orchestration/index.js +1 -1
- package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
- package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +1 -1
- package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -1
- package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +4 -4
- package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
- package/dist/src/orchestration/probes/deleteBlockProbes.d.ts +38 -0
- package/dist/src/orchestration/probes/deleteBlockProbes.js +1 -0
- package/dist/src/orchestration/remediation/recreatePreFlight.js +1 -1
- package/dist/src/orchestration/remediation/recreateResource.js +1 -1
- package/dist/src/orchestration/remediation/removalUpdate.d.ts +74 -13
- package/dist/src/orchestration/remediation/removalUpdate.js +1 -1
- package/dist/src/orchestration/restart/restartApplication.js +1 -1
- package/dist/src/orchestration/restart/secretsDrift.js +1 -1
- package/dist/src/orchestration/serviceFactory.d.ts +2 -0
- package/dist/src/orchestration/serviceFactory.js +1 -1
- package/dist/src/orchestration/stackCleanup/stackResources.d.ts +5 -3
- package/dist/src/services/application/ApplicationStackService.d.ts +2 -1
- package/dist/src/services/application/ApplicationStackService.js +1 -1
- package/dist/src/services/infrastructure/CloudFormationService.d.ts +11 -10
- package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
- package/dist/src/services/infrastructure/EcsService.d.ts +11 -9
- package/dist/src/services/infrastructure/EcsService.js +1 -1
- package/dist/src/services/infrastructure/EcsServiceResolver.d.ts +4 -4
- package/dist/src/services/infrastructure/EcsServiceResolver.js +1 -1
- package/dist/src/services/infrastructure/LambdaService.d.ts +62 -0
- package/dist/src/services/infrastructure/LambdaService.js +1 -0
- package/dist/src/types/application/ApplicationServiceTypes.d.ts +1 -0
- package/dist/src/types/application/ApplicationServiceTypes.js +1 -1
- package/dist/src/types/remediation.d.ts +1 -0
- package/dist/src/types/remediation.js +1 -1
- package/package.json +6 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
import{join as
|
|
1
|
+
import{join as v}from"path";import{failure as A,success as y}from"@fjall/generator";import{BackupClient as T}from"@aws-sdk/client-backup";import{IAMClient as G}from"@aws-sdk/client-iam";import{logger as M}from"@fjall/util/logger";import{getOrganisationStackName as Y}from"../../types/operations.js";import{accountHasDisasterRecovery as x,describeBackupVaultExists as B}from"../../aws/organisations/backup.js";import{describeAccountGlobalsExist as V,buildMissingAccountGlobalsAdvisory as _}from"../../aws/organisations/accountGlobals.js";import{targetsNonPrimaryRegion as j,reconcileOidcProviderIfCarried as H,synthOrFail as $,bootstrapOrFail as K,forwardOutput as q,forwardResourceProgress as z}from"../contextHelpers.js";import{accountTier as k,getErrorMessage as J,maskSensitiveOutput as N}from"@fjall/util";import{runDriftPreFlightGate as Q}from"../drift/preFlightGate.js";import{completeForgetAfterDeploy as U}from"../remediation/forgetResource.js";import{composePostFailureRepairOffer as W}from"../remediation/postFailureRepairOffer.js";import{buildOrgContext as X,resolveOrgDetailsForMemberComponent as Z,resolveOrgDetailsForSolo as tt}from"./orgContext.js";import{hasOrganisationTierAccount as ot}from"../../aws/organisations/accountGlobals.js";import{INFRA_STEPS as n,INFRA_STEP_TOTAL as a,emitOrgStackTargets as et,failPrepareStep as c,maskAndFail as l,readStackOutputsBestEffort as rt}from"./infraSteps.js";import{assertLeaseBeforeStack as nt}from"../application/leaseGate.js";async function At(o,r,d,g,D){const{callbacks:t}=o;t.onStepComplete?.(n.CONNECT.id,n.CONNECT.name,"completed",0,a),t.onStepStart?.(n.PREPARE.id,n.PREPARE.name,1,a);const R=r.awsProvider.getRegion(),f=o.orgConfig?.primaryRegion;if(f!==void 0&&j(R,f)){const e=await V(r.awsProvider.getClient(G),o.abortSignal);if(!e.success)return c(t),l(t,e.error.message);if(!e.data)return c(t),l(t,_({target:d.target,deployRegion:R,primaryRegion:f,...o.orgConfig!==void 0?{providerAccounts:o.orgConfig.providerAccounts}:{}}))}let m;if(ot(o.orgConfig?.providerAccounts)){const e=await Z(r,o.abortSignal);if(!e.success)return c(t),l(t,e.error.message);m=e.data}else m=await tt(r,o.abortSignal);const s=g==="account"?o.orgConfig?.providerAccounts.find(e=>e.id===r.awsProvider.getAccountId()):o.orgConfig?.providerAccounts.find(e=>k(e)==="platform"),I=g==="account"&&s!==void 0&&k(s)==="organisation",u=X(o,r,d,g,m,g==="account"?s?.name??d.target:void 0,s?.trailLifecycle,I),S=o.orgConfig?.disasterRecoveryRegion;if(g==="account"&&(S!==void 0&&S!=="")&&(s===void 0||x(s.environment,S))){const e=await B(r.awsProvider.getClient(T),o.abortSignal);if(!e.success)return c(t),l(t,e.error.message);u.fjallAdoptBackupVault=e.data}t.onLog?.(`Synthesising ${g} infrastructure\u2026`,"info");const b=await $(r,u,t,"CDK synthesis failed");if(!b.success)return c(t),b;const i=Y(d.type),O=r.awsProvider.getAccountId(),C=r.awsProvider.getRegion();et(t,[{stackName:i,...O!==""?{accountId:O}:{},...C!==""?{region:C}:{}}]);const w=await Q(r.awsProvider,{stackNames:[i],cdkOutPath:v(d.path,"cdk.out"),cfnService:r.cfnService,callbacks:t,...o.abortSignal!==void 0?{abortSignal:o.abortSignal}:{}});if(w.kind==="blocked")return c(t),l(t,w.message);const E=await K(r,u,t);if(!E.success)return c(t),E;t.onStepComplete?.(n.PREPARE.id,n.PREPARE.name,"completed",1,a);const p=await nt(o.assertLeaseForStack,i,o.abortSignal);if(p!==void 0)return t.onError?.(p),A(p);t.onStepStart?.(n.DEPLOY.id,n.DEPLOY.name,2,a);const P=await r.cdkService.runCdkDeploy(u,i,q(t),z(t),r.awsProvider);if(!P.success){t.onStepComplete?.(n.DEPLOY.id,n.DEPLOY.name,"error",2,a);const e=await W(r.awsProvider,{stackNames:[i],repairTarget:`--stack ${i}`,originalError:new Error(N(P.error)),cfnService:r.cfnService,...o.abortSignal!==void 0?{abortSignal:o.abortSignal}:{}});return t.onError?.(e),A(e)}t.onStepComplete?.(n.DEPLOY.id,n.DEPLOY.name,"completed",2,a);const h=await rt(r,t,i,"Failed to read stack outputs (non-critical)",o.abortSignal);await H(u,r.awsProvider,t,o.abortSignal),t.onStepStart?.(n.MONITORING.id,n.MONITORING.name,3,a),t.onStepComplete?.(n.MONITORING.id,n.MONITORING.name,"completed",3,a),await U(r.awsProvider,{stackNames:[i],onLog:(e,L)=>t.onLog?.(e,L),...o.abortSignal!==void 0?{abortSignal:o.abortSignal}:{}}).catch(e=>{M.debug("SingleComponentDeploy","Forget completion sweep failed",{error:N(J(e))})});const F=P.data?.status==="NO_CHANGES"&&o.options?.force!==!0;return y({target:d.target,deploymentType:"organisation",outputs:h,artefacts:[],...F?{noChanges:!0}:{},durationMs:Date.now()-D})}export{At as deploySingleComponent};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete-blocking probe registry — the shared source for every pre-mutation
|
|
3
|
+
* flow that must know whether CloudFormation's delete of a resource would be
|
|
4
|
+
* refused by a live service-side setting (RDS DeletionProtection, DynamoDB
|
|
5
|
+
* DeletionProtectionEnabled). Consumed by the recreate pre-flight (deploy-side
|
|
6
|
+
* pinned-name surgery) and the destroy pre-flight — both fail closed on a
|
|
7
|
+
* blocked or unverifiable resource because their callers mutate expensively
|
|
8
|
+
* after the check (a burned UpdateStack; emptied S3 buckets).
|
|
9
|
+
*
|
|
10
|
+
* Types without a registry entry proceed — the post-mutation DELETE_FAILED
|
|
11
|
+
* verification is the honest backstop for them.
|
|
12
|
+
*
|
|
13
|
+
* Probe failure messages are pre-masked at construction (masked SDK error
|
|
14
|
+
* text; physical IDs are identifiers, not credentials).
|
|
15
|
+
*/
|
|
16
|
+
import { type Result } from "@fjall/generator";
|
|
17
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
18
|
+
export type DeleteBlockProbe = (aws: AwsProvider, physicalId: string, abortSignal?: AbortSignal) => Promise<Result<{
|
|
19
|
+
blocked: boolean;
|
|
20
|
+
setting: string;
|
|
21
|
+
}, Error>>;
|
|
22
|
+
export interface DeleteBlockProbeEntry {
|
|
23
|
+
probe: DeleteBlockProbe;
|
|
24
|
+
/** IAM-method name for the "could not verify" failure copy. */
|
|
25
|
+
method: string;
|
|
26
|
+
/** [G8] The exact cure command for the halt copy — per resource type,
|
|
27
|
+
* so a DBCluster halt never teaches the inexecutable
|
|
28
|
+
* `modify-db-instance` against a cluster identifier. */
|
|
29
|
+
disableCommand: (physicalId: string) => string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Delete-blocking settings the pre-flights can verify, per CFN resource type.
|
|
33
|
+
* RDS DeletionProtection — fjall's own database constructs synthesise it ON
|
|
34
|
+
* by default (`deletionProtection ?? true`) — and DynamoDB's
|
|
35
|
+
* DeletionProtectionEnabled, which drift or out-of-band mutation can switch
|
|
36
|
+
* on even though fjall's DynamoDB construct never sets it.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DELETE_BLOCK_PROBES: Readonly<Record<string, DeleteBlockProbeEntry | undefined>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{DescribeTableCommand as b,DynamoDBClient as m}from"@aws-sdk/client-dynamodb";import{DescribeDBClustersCommand as D,DescribeDBInstancesCommand as u,RDSClient as d}from"@aws-sdk/client-rds";import{success as o,failure as i}from"@fjall/generator";import{getErrorMessage as s,maskSensitiveOutput as a}from"@fjall/util";import{composeSdkAbortSignal as c}from"../../aws/organisations/types.js";function l(e){const n=typeof e=="object"&&e!==null&&"name"in e?String(e.name):"";return/NotFound/i.test(n)}async function f(e,n,r){try{const t=await e.getClient(d).send(new u({DBInstanceIdentifier:n}),{abortSignal:c(r)});return o({blocked:t.DBInstances?.[0]?.DeletionProtection===!0,setting:"DeletionProtection"})}catch(t){return l(t)?o({blocked:!1,setting:"DeletionProtection"}):i(new Error(a(s(t))))}}async function p(e,n,r){try{const t=await e.getClient(d).send(new D({DBClusterIdentifier:n}),{abortSignal:c(r)});return o({blocked:t.DBClusters?.[0]?.DeletionProtection===!0,setting:"DeletionProtection"})}catch(t){return l(t)?o({blocked:!1,setting:"DeletionProtection"}):i(new Error(a(s(t))))}}async function C(e,n,r){try{const t=await e.getClient(m).send(new b({TableName:n}),{abortSignal:c(r)});return o({blocked:t.Table?.DeletionProtectionEnabled===!0,setting:"DeletionProtectionEnabled"})}catch(t){return l(t)?o({blocked:!1,setting:"DeletionProtectionEnabled"}):i(new Error(a(s(t))))}}const w={"AWS::RDS::DBInstance":{probe:f,method:"rds:DescribeDBInstances",disableCommand:e=>`aws rds modify-db-instance --db-instance-identifier ${e} --no-deletion-protection`},"AWS::RDS::DBCluster":{probe:p,method:"rds:DescribeDBClusters",disableCommand:e=>`aws rds modify-db-cluster --db-cluster-identifier ${e} --no-deletion-protection`},"AWS::DynamoDB::Table":{probe:C,method:"dynamodb:DescribeTable",disableCommand:e=>`aws dynamodb update-table --table-name ${e} --no-deletion-protection-enabled`}};export{w as DELETE_BLOCK_PROBES};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{CloudFormationClient as
|
|
1
|
+
import{CloudFormationClient as f,ListImportsCommand as g}from"@aws-sdk/client-cloudformation";import{success as u,failure as o}from"@fjall/generator";import{getErrorMessage as p,maskSensitiveOutput as w}from"@fjall/util";import{logger as y}from"@fjall/util/logger";import{composeSdkAbortSignal as $,isAborted as m}from"../../aws/organisations/types.js";import{isRemediationPlaceholderPhysicalId as b}from"../../types/remediation.js";import{DELETE_BLOCK_PROBES as E}from"../probes/deleteBlockProbes.js";import{fetchLiveStackState as N}from"./forgetResource.js";import{deriveRemovalTemplate as x}from"./removalUpdate.js";const I="RecreatePreFlight",R=/is not imported by any stack/i;async function k(l,n,t){const d=l.getClient(f),s=[];let a;try{do{if(m(t))return o(new Error(`aborted while listing imports of export '${n}'`));const r=await d.send(new g({ExportName:n,...a!==void 0?{NextToken:a}:{}}),{abortSignal:$(t)});s.push(...r.Imports??[]),a=r.NextToken}while(a!==void 0);return u(s)}catch(r){return R.test(p(r))?u([]):o(new Error(w(p(r))))}}async function _(l,n){for(const{stackName:t,targets:d}of n.stacks){if(m(n.abortSignal))return o(new Error(`Recreate pre-flight aborted before ${t} was checked \u2014 nothing was mutated in this run.`));const s=d.map(e=>e.logicalId),a=await N(l,t,n.abortSignal);if(!a.success)return o(new Error(`${a.error.message} Nothing was mutated in this run.`));const r=x(a.data.templateBody,s,t);if(!r.success)return o(new Error(`${r.error.message} Nothing was mutated in this run.`));const h=new Set(r.data.alreadyAbsent);for(const e of r.data.strippedExports){if(e.exportName===void 0)return o(new Error(`Recreate pre-flight for ${t}: removing ${s.join(", ")} strips output '${e.outputName}' whose Export name is not a literal string, so its cross-stack consumers cannot be verified \u2014 perform the replacement manually. Nothing was mutated in this run.`));const i=await k(l,e.exportName,n.abortSignal);if(!i.success)return o(new Error(`Recreate pre-flight for ${t}: could not verify whether export '${e.exportName}' (output '${e.outputName}') is imported by other stacks: ${i.error.message}. Retry the deploy. Nothing was mutated in this run.`));if(i.data.length>0)return o(new Error(`Recreate pre-flight for ${t}: removing ${s.join(", ")} strips export '${e.exportName}' (output '${e.outputName}'), which is imported by ${i.data.join(", ")} \u2014 CloudFormation refuses to drop an export while imports exist, so the surgery would fail mid-deploy. Redeploy the importing stack(s) without the import first, or perform the replacement manually. Nothing was mutated in this run.`))}for(const e of d){if(h.has(e.logicalId))continue;const i=E[e.resourceType];if(i===void 0)continue;if(b(e,t)){y.debug(I,"No live physical id to probe \u2014 skipping",{stackName:t,logicalId:e.logicalId});continue}const c=await i.probe(l,e.physicalId,n.abortSignal);if(!c.success)return o(new Error(`Recreate pre-flight for ${t}: could not verify that ${e.physicalId} (${e.logicalId}) is deletable \u2014 ${i.method} failed: ${c.error.message}. Retry the deploy. Nothing was mutated in this run.`));if(c.data.blocked)return o(new Error(`Recreate pre-flight for ${t}: ${e.physicalId} (${e.logicalId}) has ${c.data.setting} enabled \u2014 CloudFormation cannot delete it, so the pinned-name replacement would fail mid-surgery. Disable it on the live resource (e.g. '${i.disableCommand(e.physicalId)}'), then re-run the deploy. Nothing was mutated in this run.`))}}return u(void 0)}export{_ as runRecreatePreFlight};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{success as j,failure as w}from"@fjall/generator";import{completeForgetAfterConverge as z,fetchLiveStackState as B}from"./forgetResource.js";import{archiveCompletedRemediationJournal as Q,computeRemediationOpId as X,formatArchivedOperationNotice as Z,formatCompletedOperationNotice as ee,readRemediationJournal as te,writeRemediationJournal as ae}from"./remediationJournal.js";import{captureRemediationForensics as re}from"./forensicsCapture.js";import{applyRetainFlip as x,computeTemplateDigest as H,deriveRetainFlipTemplate as ne,findMarkedResourcesByOpId as oe,proveFlipMetadataOnly as ie,readRetainFlipState as I,verifyRetainFlipDiff as se,RECREATE_MARKER_KEY as A}from"./retainFlip.js";import{deriveRemovalTemplate as le,findRemovalDeletionFailures as ce,proveRemovalTargetsOnly as de}from"./removalUpdate.js";const pe=new Set(["AWS::DocDB::DBCluster","AWS::EC2::Volume","AWS::ElastiCache::CacheCluster","AWS::ElastiCache::ReplicationGroup","AWS::Neptune::DBCluster","AWS::RDS::DBCluster","AWS::RDS::DBInstance","AWS::Redshift::Cluster"]);function ue(i){return pe.has(i)?"Snapshot":"Delete"}const fe=30*6e4;function me(i){const o={};for(const s of i)o[s.logicalId]=ue(s.resourceType);return o}function U(i,o,s,e){const R=e.filter(n=>n!==s);if(R.length===0)return j(void 0);const p=R.map(n=>{const f=oe(i,n,o,A);return`${n} covers ${f.success&&f.data.length>0?f.data.join(", "):"(could not recover its target set)"}`});return w(new Error(`Recreate operation ${s} on ${o} found target resources already carrying a ${A} marker from a different operation \u2014 its journal may live on another machine, and completing this operation over it would strand that surgery. Marked resources recovered from the live template: ${p.join("; ")}. Complete the foreign operation first (re-run its converge deploy on the machine that holds its journal), then re-run this deploy. Nothing was mutated.`))}async function we(i,o,s){const{stackName:e,targets:R,onProgress:p,abortSignal:n}=o;if(R.length===0)return w(new Error("Recreate surgery requires at least one target"));const f=s?.now??(()=>new Date),O=i.getAccountId(),N=i.getRegion(),u=R.map(a=>a.logicalId),l=X(e,u),P=me(R),b={markerKey:A,policyForTarget:a=>P[a]??"Delete"},E=o.appName!==void 0?`Re-run 'fjall deploy ${o.appName}' to resume the recreate.`:"Re-run the deploy to resume the recreate.",F=a=>a.snapshotPolicies??P;let t=await te(O,N,e,l,s?.journal);if(t!==null&&t.verb!=="recreate"&&t.phase!=="done")return w(new Error(`Recreate operation ${l} on ${e} collides with an in-flight '${t.verb}' operation covering the same resources \u2014 complete it first with 'fjall drift resume ${l}', then re-run this deploy. Nothing was mutated.`));if(t!==null&&t.phase==="removal-applied"){const a=await z(i,{journal:t,...n!==void 0?{abortSignal:n}:{}},s);if(!a.success)return a;if(t=a.data.journal,a.data.status==="converge-pending"){const d=a.data.unverified,y=(o.plannedReplacementLogicalIds??[]).filter(c=>d.includes(c));if(y.length>0){const c=o.appName!==void 0?`fjall deploy ${o.appName}`:"fjall deploy";return w(new Error(`Recreate operation ${l} on ${e} already applied its removal, but the closure check could not verify ${y.join(", ")} (re-created, removed, or still orphaned) \u2014 and this deploy plans a pinned-name replacement for the same target(s), which CloudFormation would refuse while the name may still be held. Nothing further was mutated. Retry the deploy once AWS settles \u2014 a re-assert or converge still in flight clears on its own. If it persists, verify the resource state in the owning service; the terminating cure is to remove the resource(s) from the fjall config and re-run '${c}' \u2014 the converge clears the unverifiable declaration and the operation closes as removed \u2014 then re-add the configuration to re-create them under their pinned names.`))}return p?.(`Removal already applied on ${e} \u2014 the deploy re-creates any still-missing ${u.join(", ")} under the pinned names; already-present targets close once the post-deploy sweep verifies them`),j({opId:l,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})}}if(t!==null&&t.phase==="done"){if(o.doneJournalPolicy!=="archive-and-restart")return p?.(ee(t)),j({opId:l,phase:"done",convergePending:!1,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t});const a=await Q(t,s?.journal);if(!a.success)return a;p?.(Z(t)),t=null}const C=async a=>{const d=await ae(a,s?.journal);return d.success?j(a):w(d.error)};let m;if(t===null){const a=await B(i,e,n);if(!a.success)return a;const d=I(a.data.templateBody,u,e,b);if(!d.success)return d;const y=U(a.data.templateBody,e,l,d.data.markerOpIds);if(!y.success)return y;if(d.data.markerOpIds.includes(l)){p?.(`Recognised in-flight recreate markers on ${e} \u2014 resuming from the ${A} marker`);const c=f().toISOString(),$=await C({version:1,opId:l,verb:"recreate",accountId:O,region:N,stackName:e,...o.appName!==void 0?{appName:o.appName}:{},targets:[...R],phase:d.data.allFlipped?"flip-applied":"captured",createdAt:c,updatedAt:c,snapshotPolicies:P});if(!$.success)return $;t=$.data,m=a.data}else{p?.(`Capturing forensics for ${e} (${l})`);const c=await re(i,{stackName:e,opId:l,findings:o.findings??[],...n!==void 0?{abortSignal:n}:{}},s?.forensics);if(!c.success)return c;const $=f().toISOString(),D=await C({version:1,opId:l,verb:"recreate",accountId:O,region:N,stackName:e,...o.appName!==void 0?{appName:o.appName}:{},targets:[...R],phase:"captured",createdAt:$,updatedAt:$,forensicsPath:c.data.forensicsPath,snapshotPolicies:P,...o.consentedPhysicalNames!==void 0?{consentedPhysicalNames:[...o.consentedPhysicalNames]}:{}});if(!D.success)return D;t=D.data,m={templateBody:c.data.liveTemplateBody,capabilities:c.data.capabilities,parameterKeys:c.data.parameterKeys}}}if(m===void 0){const a=await B(i,e,n);if(!a.success)return a;m=a.data}let h=m.templateBody,v=I(h,u,e,b);if(!v.success)return v;const W=U(h,e,l,v.data.markerOpIds);if(!W.success)return W;const q=new Set(v.data.absentTargets),g=u.filter(a=>!q.has(a));if(g.length>0){if(v.data.unflippedTargets.filter(r=>g.includes(r)).length>0){p?.(`Deriving the snapshot flip for ${e}`);const r=ne({liveTemplateBody:h,stackName:e,targets:g,opId:l,at:f().toISOString(),spec:b});if(!r.success)return r;const T=se(h,r.data.flipTemplateBody,g,e,A);if(!T.success)return T;p?.("Proving the flip is metadata-only (review change set)");const S=await ie(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,targets:g,parameterKeys:m.parameterKeys,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!S.success)return S;const K=await C({...t,phase:"flip-verified",updatedAt:f().toISOString(),liveTemplateDigest:r.data.liveTemplateDigest,proof:{diffGuardPassed:!0,changeSetMetadataOnly:!0,noChanges:S.data.noChanges,provenAt:f().toISOString()}});if(!K.success)return K;t=K.data,p?.(`Applying the snapshot flip to ${e}`);const M=await x(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,capabilities:m.capabilities,parameterKeys:m.parameterKeys,derivedFromTemplateDigest:r.data.liveTemplateDigest,opId:l,operationLabel:"Snapshot-flip",resumeHint:E,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!M.success)return M;const _=await B(i,e,n);if(!_.success)return _;if(h=_.data.templateBody,v=I(h,u,e,b),!v.success)return v;const V=new Set(v.data.absentTargets),J=v.data.unflippedTargets.filter(Y=>!V.has(Y));if(J.length>0)return w(new Error(`Snapshot flip on ${e} reported ${M.data.outcome} but GetTemplate does not show the snapshot policy + the ${A} marker on ${J.join(", ")} \u2014 aborting to manual repair.`))}if(t.phase==="captured"||t.phase==="flip-verified"){const r=await C({...t,phase:"flip-applied",updatedAt:f().toISOString(),liveTemplateDigest:H(h)});if(!r.success)return r;t=r.data}p?.(`Deriving the surgical removal of ${g.join(", ")} from ${e}`);const d=le(h,u,e);if(!d.success)return w(new Error(`${d.error.message} The snapshot flip has already been applied on ${e}. ${E}`));p?.("Proving the removal touches only the recreate targets (review change set)");const y=await de(i,{stackName:e,removalTemplateBody:d.data.removalTemplateBody,targets:u,parameterKeys:m.parameterKeys,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!y.success)return y;if(!y.data.noChanges){const r=g.map(S=>`${S} \u2192 ${P[S]??"Delete"}`).join(", ");p?.(`Removing ${g.join(", ")} from ${e} (deletion policies: ${r})`);const T=await x(i,{stackName:e,flipTemplateBody:d.data.removalTemplateBody,capabilities:m.capabilities,parameterKeys:m.parameterKeys,derivedFromTemplateDigest:d.data.liveTemplateDigest,opId:l,operationLabel:"Recreate-removal",resumeHint:E,...n!==void 0?{abortSignal:n}:{}},{...s?.flip,updateTimeoutMs:s?.flip?.updateTimeoutMs??fe});if(!T.success)return T}const c=await B(i,e,n);if(!c.success)return c;h=c.data.templateBody;const $=I(h,u,e,b);if(!$.success)return $;const D=new Set($.data.absentTargets),L=u.filter(r=>!D.has(r));if(L.length>0)return w(new Error(`Recreate removal on ${e} completed but GetTemplate still shows ${L.join(", ")} \u2014 aborting to manual repair. ${E}`));if(!y.data.noChanges){const r=await ce(i,e,u,n);if(!r.success)return w(new Error(`Recreate removal on ${e} removed ${g.join(", ")} from the template, but the physical deletion outcome could not be verified: ${r.error.message}. ${E}`));if(r.data.length>0){const T=r.data.map(S=>`${S.logicalId}${S.statusReason!==void 0?` (${S.statusReason})`:""}`).join("; ");return w(new Error(`Recreate removal on ${e} removed ${g.join(", ")} from the template, but CloudFormation reported DELETE_FAILED for ${T} \u2014 the physical resource(s) still exist outside the stack and hold the pinned name(s), so the deploy's re-create would collide. Resolve the deletion failure and delete the orphaned resource(s) manually, then re-run the deploy to resume.`))}}const G=g.filter(r=>P[r]==="Snapshot");G.length>0&&p?.(`Final snapshots were requested for ${G.join(", ")} \u2014 locate them in the owning service's console to restore data into the re-created resources`)}const k=await C({...t,phase:"removal-applied",updatedAt:f().toISOString(),liveTemplateDigest:H(h)});return k.success?(t=k.data,p?.(`Surgical removal complete on ${e} \u2014 the deploy now re-creates ${u.join(", ")} under the pinned names`),j({opId:l,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})):k}export{pe as SNAPSHOT_CAPABLE_RESOURCE_TYPES,we as runRecreateSurgery,ue as snapshotPolicyForType};
|
|
1
|
+
import{success as E,failure as R}from"@fjall/generator";import{completeForgetAfterConverge as z,fetchLiveStackState as B}from"./forgetResource.js";import{archiveCompletedRemediationJournal as Q,computeRemediationOpId as X,formatArchivedOperationNotice as Z,formatCompletedOperationNotice as ee,readRemediationJournal as te,writeRemediationJournal as ae}from"./remediationJournal.js";import{captureRemediationForensics as re}from"./forensicsCapture.js";import{applyRetainFlip as x,computeTemplateDigest as H,deriveRetainFlipTemplate as oe,findMarkedResourcesByOpId as ne,proveFlipMetadataOnly as ie,readRetainFlipState as _,verifyRetainFlipDiff as se,RECREATE_MARKER_KEY as C}from"./retainFlip.js";import{deriveRemovalTemplate as le,findRemovalDeletionFailures as ce,findResourcesPresent as de,proveRemovalTargetsOnly as pe}from"./removalUpdate.js";const ue=new Set(["AWS::DocDB::DBCluster","AWS::EC2::Volume","AWS::ElastiCache::CacheCluster","AWS::ElastiCache::ReplicationGroup","AWS::Neptune::DBCluster","AWS::RDS::DBCluster","AWS::RDS::DBInstance","AWS::Redshift::Cluster"]);function fe(i){return ue.has(i)?"Snapshot":"Delete"}const me=30*6e4;function he(i){const n={};for(const c of i)n[c.logicalId]=fe(c.resourceType);return n}function U(i,n,c,e){const T=e.filter(o=>o!==c);if(T.length===0)return E(void 0);const p=T.map(o=>{const u=ne(i,o,n,C);return`${o} covers ${u.success&&u.data.length>0?u.data.join(", "):"(could not recover its target set)"}`});return R(new Error(`Recreate operation ${c} on ${n} found target resources already carrying a ${C} marker from a different operation \u2014 its journal may live on another machine, and completing this operation over it would strand that surgery. Marked resources recovered from the live template: ${p.join("; ")}. Complete the foreign operation first (re-run its converge deploy on the machine that holds its journal), then re-run this deploy. Nothing was mutated.`))}async function we(i,n,c){const{stackName:e,targets:T,onProgress:p,abortSignal:o}=n;if(T.length===0)return R(new Error("Recreate surgery requires at least one target"));const u=c?.now??(()=>new Date),O=i.getAccountId(),N=i.getRegion(),S=T.map(a=>a.logicalId),d=X(e,S),A=he(T),D={markerKey:C,policyForTarget:a=>A[a]??"Delete"},j=n.appName!==void 0?`Re-run 'fjall deploy ${n.appName}' to resume the recreate.`:"Re-run the deploy to resume the recreate.",F=a=>a.snapshotPolicies??A;let t=await te(O,N,e,d,c?.journal);if(t!==null&&t.verb!=="recreate"&&t.phase!=="done")return R(new Error(`Recreate operation ${d} on ${e} collides with an in-flight '${t.verb}' operation covering the same resources \u2014 complete it first with 'fjall drift resume ${d}', then re-run this deploy. Nothing was mutated.`));if(t!==null&&t.phase==="removal-applied"){const a=await z(i,{journal:t,...o!==void 0?{abortSignal:o}:{}},c);if(!a.success)return a;if(t=a.data.journal,a.data.status==="converge-pending"){const s=a.data.unverified,w=(n.plannedReplacementLogicalIds??[]).filter(l=>s.includes(l));if(w.length>0){const l=n.appName!==void 0?`fjall deploy ${n.appName}`:"fjall deploy";return R(new Error(`Recreate operation ${d} on ${e} already applied its removal, but the closure check could not verify ${w.join(", ")} (re-created, removed, or still orphaned) \u2014 and this deploy plans a pinned-name replacement for the same target(s), which CloudFormation would refuse while the name may still be held. Nothing further was mutated. Retry the deploy once AWS settles \u2014 a re-assert or converge still in flight clears on its own. If it persists, verify the resource state in the owning service; the terminating cure is to remove the resource(s) from the fjall config and re-run '${l}' \u2014 the converge clears the unverifiable declaration and the operation closes as removed \u2014 then re-add the configuration to re-create them under their pinned names.`))}return p?.(`Removal already applied on ${e} \u2014 the deploy re-creates any still-missing ${S.join(", ")} under the pinned names; already-present targets close once the post-deploy sweep verifies them`),E({opId:d,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})}}if(t!==null&&t.phase==="done"){if(n.doneJournalPolicy!=="archive-and-restart")return p?.(ee(t)),E({opId:d,phase:"done",convergePending:!1,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t});const a=await Q(t,c?.journal);if(!a.success)return a;p?.(Z(t)),t=null}const I=async a=>{const s=await ae(a,c?.journal);return s.success?E(a):R(s.error)};let f;if(t===null){const a=await B(i,e,o);if(!a.success)return a;const s=_(a.data.templateBody,S,e,D);if(!s.success)return s;const w=U(a.data.templateBody,e,d,s.data.markerOpIds);if(!w.success)return w;if(s.data.markerOpIds.includes(d)){p?.(`Recognised in-flight recreate markers on ${e} \u2014 resuming from the ${C} marker`);const l=u().toISOString(),y=await I({version:1,opId:d,verb:"recreate",accountId:O,region:N,stackName:e,...n.appName!==void 0?{appName:n.appName}:{},targets:[...T],phase:s.data.allFlipped?"flip-applied":"captured",createdAt:l,updatedAt:l,snapshotPolicies:A});if(!y.success)return y;t=y.data,f=a.data}else{p?.(`Capturing forensics for ${e} (${d})`);const l=await re(i,{stackName:e,opId:d,findings:n.findings??[],...o!==void 0?{abortSignal:o}:{}},c?.forensics);if(!l.success)return l;const y=u().toISOString(),P=await I({version:1,opId:d,verb:"recreate",accountId:O,region:N,stackName:e,...n.appName!==void 0?{appName:n.appName}:{},targets:[...T],phase:"captured",createdAt:y,updatedAt:y,forensicsPath:l.data.forensicsPath,snapshotPolicies:A,...n.consentedPhysicalNames!==void 0?{consentedPhysicalNames:[...n.consentedPhysicalNames]}:{}});if(!P.success)return P;t=P.data,f={templateBody:l.data.liveTemplateBody,capabilities:l.data.capabilities,parameterKeys:l.data.parameterKeys}}}if(f===void 0){const a=await B(i,e,o);if(!a.success)return a;f=a.data}let m=f.templateBody,v=_(m,S,e,D);if(!v.success)return v;const L=U(m,e,d,v.data.markerOpIds);if(!L.success)return L;const q=new Set(v.data.absentTargets),h=S.filter(a=>!q.has(a));let b=[];if(h.length>0){if(v.data.unflippedTargets.filter(r=>h.includes(r)).length>0){p?.(`Deriving the snapshot flip for ${e}`);const r=oe({liveTemplateBody:m,stackName:e,targets:h,opId:d,at:u().toISOString(),spec:D});if(!r.success)return r;const $=se(m,r.data.flipTemplateBody,h,e,C);if(!$.success)return $;p?.("Proving the flip is metadata-only (review change set)");const g=await ie(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,targets:h,parameterKeys:f.parameterKeys,...o!==void 0?{abortSignal:o}:{}},c?.flip);if(!g.success)return g;const K=await I({...t,phase:"flip-verified",updatedAt:u().toISOString(),liveTemplateDigest:r.data.liveTemplateDigest,proof:{diffGuardPassed:!0,changeSetMetadataOnly:!0,noChanges:g.data.noChanges,provenAt:u().toISOString()}});if(!K.success)return K;t=K.data,p?.(`Applying the snapshot flip to ${e}`);const M=await x(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,capabilities:f.capabilities,parameterKeys:f.parameterKeys,derivedFromTemplateDigest:r.data.liveTemplateDigest,opId:d,operationLabel:"Snapshot-flip",resumeHint:j,...o!==void 0?{abortSignal:o}:{}},c?.flip);if(!M.success)return M;const W=await B(i,e,o);if(!W.success)return W;if(m=W.data.templateBody,v=_(m,S,e,D),!v.success)return v;const V=new Set(v.data.absentTargets),J=v.data.unflippedTargets.filter(Y=>!V.has(Y));if(J.length>0)return R(new Error(`Snapshot flip on ${e} reported ${M.data.outcome} but GetTemplate does not show the snapshot policy + the ${C} marker on ${J.join(", ")} \u2014 aborting to manual repair.`))}if(t.phase==="captured"||t.phase==="flip-verified"){const r=await I({...t,phase:"flip-applied",updatedAt:u().toISOString(),liveTemplateDigest:H(m)});if(!r.success)return r;t=r.data}p?.(`Deriving the surgical removal of ${h.join(", ")} from ${e}`);const s=le(m,S,e);if(!s.success)return R(new Error(`${s.error.message} The snapshot flip has already been applied on ${e}. ${j}`));b=s.data.coRemoved.map(r=>r.logicalId);const w=[...S,...b];if(s.data.coRemoved.length>0){const r=s.data.coRemoved.map($=>`${$.logicalId} (${$.resourceType})`).join(", ");p?.(`Co-removing stateless referrer(s) of the recreate target(s): ${r} \u2014 CloudFormation cannot keep a resource that references a deleted one; the deploy re-creates them alongside the targets. No data is lost.`)}p?.("Proving the removal touches only the recreate targets (review change set)");const l=await pe(i,{stackName:e,removalTemplateBody:s.data.removalTemplateBody,targets:w,parameterKeys:f.parameterKeys,...o!==void 0?{abortSignal:o}:{}},c?.flip);if(!l.success)return l;if(!l.data.noChanges){const r=h.map(g=>`${g} \u2192 ${A[g]??"Delete"}`).join(", ");p?.(`Removing ${h.join(", ")}${b.length>0?` (with co-removed ${b.join(", ")})`:""} from ${e} (deletion policies: ${r})`);const $=await x(i,{stackName:e,flipTemplateBody:s.data.removalTemplateBody,capabilities:f.capabilities,parameterKeys:f.parameterKeys,derivedFromTemplateDigest:s.data.liveTemplateDigest,opId:d,operationLabel:"Recreate-removal",resumeHint:j,...o!==void 0?{abortSignal:o}:{}},{...c?.flip,updateTimeoutMs:c?.flip?.updateTimeoutMs??me});if(!$.success)return $}const y=await B(i,e,o);if(!y.success)return y;m=y.data.templateBody;const P=de(m,w,e);if(!P.success)return P;if(P.data.length>0)return R(new Error(`Recreate removal on ${e} completed but GetTemplate still shows ${P.data.join(", ")} \u2014 aborting to manual repair. ${j}`));if(!l.data.noChanges){const r=await ce(i,e,w,o);if(!r.success)return R(new Error(`Recreate removal on ${e} removed ${h.join(", ")} from the template, but the physical deletion outcome could not be verified: ${r.error.message}. ${j}`));if(r.data.length>0){const $=r.data.map(g=>`${g.status} for ${g.logicalId}${g.statusReason!==void 0?` (${g.statusReason})`:""}`).join("; ");return R(new Error(`Recreate removal on ${e} removed ${h.join(", ")} from the template, but CloudFormation reported ${$} \u2014 the physical resource(s) still exist outside the stack; a recreate target orphaned this way still holds its pinned name, so the deploy's re-create would collide. Resolve the deletion failure and delete the orphaned resource(s) manually, then re-run the deploy to resume.`))}}const G=h.filter(r=>A[r]==="Snapshot");G.length>0&&p?.(`Final snapshots were requested for ${G.join(", ")} \u2014 locate them in the owning service's console to restore data into the re-created resources`)}const k=await I({...t,phase:"removal-applied",updatedAt:u().toISOString(),liveTemplateDigest:H(m),...b.length>0?{coRemovedLogicalIds:b}:{}});return k.success?(t=k.data,p?.(`Surgical removal complete on ${e} \u2014 the deploy now re-creates ${S.join(", ")} under the pinned names`),E({opId:d,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})):k}export{ue as SNAPSHOT_CAPABLE_RESOURCE_TYPES,we as runRecreateSurgery,fe as snapshotPolicyForType};
|
|
@@ -8,12 +8,30 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Derivation is deliberately conservative:
|
|
10
10
|
* - target resources are removed; each remaining resource's `DependsOn`
|
|
11
|
-
* edges to a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* edges to a removed resource are pruned (a dangling DependsOn fails
|
|
12
|
+
* validation);
|
|
13
|
+
* - Outputs whose subtree references a removed resource (Ref / Fn::GetAtt /
|
|
14
|
+
* Fn::Sub) are removed (a dangling output reference fails validation);
|
|
15
|
+
* - a REMAINING RESOURCE that references a removed resource is resolved by
|
|
16
|
+
* the co-removal closure: a referrer on the explicit provably-safe
|
|
17
|
+
* allowlist (`CO_REMOVABLE_RESOURCE_TYPES` — the DatabaseFactory scaffold
|
|
18
|
+
* glue that ALWAYS pairs a pinned-name database with a
|
|
19
|
+
* SecretTargetAttachment referrer, so failing on every referrer would make
|
|
20
|
+
* the flagship recreate impossible on scaffold topology) joins the removal
|
|
21
|
+
* set, because CloudFormation re-resolves a remaining referrer's
|
|
22
|
+
* Ref/GetAtt at execution time and leaving it behind dooms the update.
|
|
23
|
+
* EVERY other referrer fails the derivation closed with the referencing
|
|
24
|
+
* paths (paths only, never values) — the ceremony consented to the targets
|
|
25
|
+
* alone, and `isStatefulResourceType` is a data-loss LABEL, not a
|
|
26
|
+
* deletion-authorisation gate: types it does not list (nested-stack
|
|
27
|
+
* containers with stateful children, EC2 instances with attached volumes,
|
|
28
|
+
* Custom:: providers whose Delete handlers run arbitrary code, secrets,
|
|
29
|
+
* user pools) can still destroy data or state. An allowlisted referrer
|
|
30
|
+
* carrying a retaining DeletionPolicy also fails closed: CloudFormation
|
|
31
|
+
* would DELETE_SKIPPED it, and the converge's re-create would collide with
|
|
32
|
+
* the orphaned physical resource. Co-removals are reported to the caller,
|
|
33
|
+
* not consent-bearing: no data is lost by construction, and the converge
|
|
34
|
+
* re-creates whatever the synthesised template still declares.
|
|
17
35
|
*
|
|
18
36
|
* `proveRemovalTargetsOnly` mirrors the retain flip's review change set: a
|
|
19
37
|
* `fjall-preflight-` change set must report ONLY `Remove` actions on the
|
|
@@ -82,6 +100,30 @@ export interface StrippedExport {
|
|
|
82
100
|
* string (unresolvable locally — the pre-flight fails closed on it). */
|
|
83
101
|
exportName?: string;
|
|
84
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* The ONLY resource types the co-removal closure may pull into the removal
|
|
105
|
+
* set. Membership requires ALL of: the type holds no data, deleting it runs
|
|
106
|
+
* no side effects beyond detachment (no Custom:: Delete handlers, no nested
|
|
107
|
+
* children), and the converge re-creates it verbatim from synth. Everything
|
|
108
|
+
* else fails the derivation closed — the ceremony consented to the TARGETS
|
|
109
|
+
* only, and `isStatefulResourceType` is a data-loss label, not a
|
|
110
|
+
* deletion-authorisation gate (it does not list nested-stack containers,
|
|
111
|
+
* EC2 instances, Custom:: providers, secrets or user pools, all of which can
|
|
112
|
+
* destroy data or run delete-time code).
|
|
113
|
+
*/
|
|
114
|
+
export declare const CO_REMOVABLE_RESOURCE_TYPES: readonly ["AWS::SecretsManager::SecretTargetAttachment", "AWS::SecretsManager::RotationSchedule"];
|
|
115
|
+
/** An allowlisted glue resource the derivation pulled into the removal
|
|
116
|
+
* because it references a removal-set member — CloudFormation re-resolves a
|
|
117
|
+
* remaining referrer's Ref/GetAtt/Fn::Sub at execution time, so a referrer
|
|
118
|
+
* left behind dooms the removal update. Allowlist-only by construction
|
|
119
|
+
* (`CO_REMOVABLE_RESOURCE_TYPES`): every other referrer fails the derivation
|
|
120
|
+
* closed instead. */
|
|
121
|
+
export interface CoRemovedResource {
|
|
122
|
+
logicalId: string;
|
|
123
|
+
resourceType: string;
|
|
124
|
+
/** Where it references the removal set (identifier-only paths). */
|
|
125
|
+
referencePaths: string[];
|
|
126
|
+
}
|
|
85
127
|
export interface RemovalDerivation {
|
|
86
128
|
removalTemplateBody: string;
|
|
87
129
|
liveTemplateDigest: string;
|
|
@@ -91,15 +133,32 @@ export interface RemovalDerivation {
|
|
|
91
133
|
* make the removal update fail mid-deploy (CFN refuses to drop an export
|
|
92
134
|
* while imports exist); `runRecreatePreFlight` checks them via ListImports. */
|
|
93
135
|
strippedExports: StrippedExport[];
|
|
136
|
+
/** Allowlisted glue referrers co-removed with the targets
|
|
137
|
+
* (inbound-reference fixpoint closure over `CO_REMOVABLE_RESOURCE_TYPES`).
|
|
138
|
+
* Reported to the operator, never consent-bearing — no data loss by
|
|
139
|
+
* construction. */
|
|
140
|
+
coRemoved: CoRemovedResource[];
|
|
94
141
|
}
|
|
95
142
|
/**
|
|
96
|
-
* Derive the removal template: live template minus the target resources
|
|
97
|
-
*
|
|
98
|
-
*
|
|
143
|
+
* Derive the removal template: live template minus the target resources and
|
|
144
|
+
* the co-removal closure of their allowlisted glue referrers, with DependsOn
|
|
145
|
+
* edges pruned and removal-referencing Outputs dropped. Any remaining
|
|
146
|
+
* resource referencing the removal set that is type-less, stateful, off the
|
|
147
|
+
* `CO_REMOVABLE_RESOURCE_TYPES` allowlist, or carrying a retaining
|
|
148
|
+
* DeletionPolicy fails the derivation closed with the paths.
|
|
99
149
|
*/
|
|
100
150
|
export declare function deriveRemovalTemplate(liveTemplateBody: string, targets: readonly string[], stackName: string): Result<RemovalDerivation, Error>;
|
|
151
|
+
/**
|
|
152
|
+
* The subset of `logicalIds` still present in the template's Resources —
|
|
153
|
+
* the surgery's post-removal absence check over targets + co-removed alike
|
|
154
|
+
* (`readRetainFlipState` is flip-target-oriented; presence is all that
|
|
155
|
+
* matters here).
|
|
156
|
+
*/
|
|
157
|
+
export declare function findResourcesPresent(templateBody: string, logicalIds: readonly string[], stackName: string): Result<string[], Error>;
|
|
101
158
|
export interface RemovalDeletionFailure {
|
|
102
159
|
logicalId: string;
|
|
160
|
+
/** DELETE_FAILED, or DELETE_SKIPPED (a retaining-policy escape). */
|
|
161
|
+
status: string;
|
|
103
162
|
/** Masked at construction — a status reason can embed handler output. */
|
|
104
163
|
statusReason?: string;
|
|
105
164
|
}
|
|
@@ -108,10 +167,12 @@ export interface RemovalDeletionFailure {
|
|
|
108
167
|
* CloudFormation deletes removed resources during the update-CLEANUP phase,
|
|
109
168
|
* and a DELETE_FAILED there leaves the stack UPDATE_COMPLETE with the target
|
|
110
169
|
* gone from the template but orphaned alive — still holding its pinned name,
|
|
111
|
-
* so the converge's re-create would collide.
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* operation's events
|
|
170
|
+
* so the converge's re-create would collide. DELETE_SKIPPED is the same
|
|
171
|
+
* orphan via a retaining DeletionPolicy: the derivation refuses retain-policy
|
|
172
|
+
* co-removals, so a skip here means the template or policy changed mid-run —
|
|
173
|
+
* fail closed regardless. Scan the just-settled operation's events on the
|
|
174
|
+
* targets. Only meaningful immediately after this run's removal update (the
|
|
175
|
+
* scan reads the LAST operation's events).
|
|
115
176
|
*/
|
|
116
177
|
export declare function findRemovalDeletionFailures(aws: AwsProvider, stackName: string, targets: readonly string[], abortSignal?: AbortSignal): Promise<Result<RemovalDeletionFailure[], Error>>;
|
|
117
178
|
export interface RemovalProofResult {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{randomUUID as
|
|
1
|
+
import{randomUUID as N}from"node:crypto";import{CloudFormationClient as P,CreateChangeSetCommand as j,DeleteChangeSetCommand as F,DescribeChangeSetCommand as I}from"@aws-sdk/client-cloudformation";import{success as E,failure as h}from"@fjall/generator";import{getErrorMessage as _,maskSensitiveOutput as v}from"@fjall/util";import{logger as M}from"@fjall/util/logger";import{composeSdkAbortSignal as b,isAborted as O}from"../../aws/organisations/types.js";import{isStatefulResourceType as x}from"../application/plan/classify.js";import{PREFLIGHT_CHANGE_SET_PREFIX as k}from"../../services/infrastructure/changeSetProbe.js";import{sleepAbortable as U}from"../../util/sleepAbortable.js";import{fetchLastOperationEvents as G}from"../drift/classifyDriftFailure.js";import{composeFlipCapabilities as B,computeTemplateDigest as K,echoPreviousParameters as V,resolveTemplateLocation as H,deleteStagedTemplate as J}from"./retainFlip.js";const W="RemovalUpdate",Y=/didn't contain changes|No updates are to be performed/i,z=new Set(["CREATE_COMPLETE","FAILED"]),X=3e3,q=9e4;function C(n,o){let e;try{e=JSON.parse(n)}catch{return h(new Error(`The live template for ${o} is not JSON (YAML-authored stacks are not supported by the removal update) \u2014 aborting to manual repair.`))}return typeof e!="object"||e===null||Array.isArray(e)?h(new Error(`The live template for ${o} did not parse to an object \u2014 aborting to manual repair.`)):E(e)}function R(n,o,e){if(typeof n=="string")return[];if(Array.isArray(n))return n.flatMap((c,u)=>R(c,o,`${e}[${u}]`));if(typeof n!="object"||n===null)return[];const r=n,t=[],l=r.Ref;typeof l=="string"&&o.has(l)&&t.push(`${e}/Ref(${l})`);const a=r["Fn::GetAtt"];typeof a=="string"&&o.has(a.split(".")[0]??"")&&t.push(`${e}/Fn::GetAtt(${a.split(".")[0]??""})`),Array.isArray(a)&&typeof a[0]=="string"&&o.has(a[0])&&t.push(`${e}/Fn::GetAtt(${a[0]})`);const s=r["Fn::Sub"],f=typeof s=="string"?s:Array.isArray(s)&&typeof s[0]=="string"?s[0]:void 0;if(f!==void 0)for(const c of f.matchAll(/\$\{([^!}][^}]*)\}/g)){const u=(c[1]??"").split(".")[0]??"";o.has(u)&&t.push(`${e}/Fn::Sub(${u})`)}for(const[c,u]of Object.entries(r))t.push(...R(u,o,`${e}/${c}`));return t}function pe(n,o,e){const r=C(n,e);if(!r.success)return r;const t=new Set(o),l=[];for(const[a,s]of Object.entries(r.data.Resources??{})){const f=t.has(a)?new Set([...t].filter(c=>c!==a)):t;f.size!==0&&R(s,f,a).length>0&&l.push(a)}return E(l)}function he(n,o,e,r){const t=C(n,r);if(!t.success)return t;const l=t.data.Resources??{},a=[];for(const s of o){const f=l[s];if(f===void 0)continue;const c=e.filter(u=>u!==s&&R(f,new Set([u]),s).length>0);c.length>0&&a.push({pendingTarget:s,deadReferences:c})}return E(a)}function Q(n,o){const e=n.DependsOn;if(typeof e=="string"){o.has(e)&&delete n.DependsOn;return}if(Array.isArray(e)){const r=e.filter(t=>typeof t!="string"||!o.has(t));r.length===0?delete n.DependsOn:r.length!==e.length&&(n.DependsOn=r)}}const L=["AWS::SecretsManager::SecretTargetAttachment","AWS::SecretsManager::RotationSchedule"],Z=new Set(L),ee=new Set(["Retain","RetainExceptOnCreate"]);function $(n){return`${n.slice(0,10).join(", ")}${n.length>10?` (+${n.length-10} more)`:""}`}function me(n,o,e){const r=C(n,e);if(!r.success)return r;const t=r.data,l=t.Resources??{},a=o.filter(i=>l[i]===void 0),s=new Set(o),f=[];let c=!0;for(;c;){c=!1;for(const[i,d]of Object.entries(l)){if(s.has(i))continue;const p=R(d,s,i);if(p.length===0)continue;const m=d.Type;if(typeof m!="string")return h(new Error(`Cannot orchestrate the recreate for ${e}: ${i} references the removal set at ${$(p)} but declares no resource Type, so fjall cannot prove it is safe to co-remove. Restructure the dependency or perform the replacement manually.`));if(x(m))return h(new Error(`Cannot orchestrate the recreate for ${e}: ${i} (${m}) references the removal set at ${$(p)} \u2014 it is a stateful resource holding data, and removing it was not consented. Restructure the dependency or perform the replacement manually.`));if(!Z.has(m))return h(new Error(`Cannot orchestrate the recreate for ${e}: ${i} (${m}) references the removal set at ${$(p)} \u2014 fjall only co-removes provably-safe glue resources (${L.join(", ")}), and removing anything else was not consented. Restructure the dependency or perform the replacement manually.`));const S=d.DeletionPolicy;if(typeof S=="string"&&ee.has(S))return h(new Error(`Cannot orchestrate the recreate for ${e}: ${i} (${m}) references the removal set at ${$(p)} but carries DeletionPolicy: ${S} \u2014 CloudFormation would skip its physical deletion (DELETE_SKIPPED) and the converge's re-create would collide with the retained resource. Restructure the dependency or perform the replacement manually.`));s.add(i),f.push({logicalId:i,resourceType:m,referencePaths:p}),c=!0}}for(const i of s)delete l[i];const u=[];for(const[i,d]of Object.entries(l))Q(d,s),u.push(...R(d,s,i));if(u.length>0)return h(new Error(`Cannot orchestrate the recreate for ${e}: other resources still reference ${[...s].join(", ")} at ${$(u)} \u2014 removing the target would break them. Restructure the dependency or perform the replacement manually.`));const g=[],y=t.Outputs;if(typeof y=="object"&&y!==null){for(const[i,d]of Object.entries(y))if(R(d,s,`Outputs/${i}`).length>0){const p=typeof d=="object"&&d!==null?d.Export:void 0;if(typeof p=="object"&&p!==null){const m=p.Name;g.push({outputName:i,...typeof m=="string"?{exportName:m}:{}})}delete y[i]}Object.keys(y).length===0&&delete t.Outputs}return E({removalTemplateBody:JSON.stringify(t),liveTemplateDigest:K(n),alreadyAbsent:a,strippedExports:g,coRemoved:f})}function ge(n,o,e){const r=C(n,e);if(!r.success)return r;const t=r.data.Resources??{};return E(o.filter(l=>t[l]!==void 0))}const te=new Set(["DELETE_FAILED","DELETE_SKIPPED"]);async function Se(n,o,e,r){const t=await G(n,o,r);if(!t.success)return t;const l=new Set(e);return E(t.data.filter(a=>l.has(a.logicalId)&&te.has(a.status)).map(a=>({logicalId:a.logicalId,status:a.status,...a.statusReason!==void 0?{statusReason:v(a.statusReason)}:{}})))}async function Ee(n,o,e){const{stackName:r,abortSignal:t}=o,l=e?.pollIntervalMs??X,a=e?.changeSetTimeoutMs??q,s=e?.now??(()=>Date.now()),f=n.getClient(P),c=`${k}removal-${N()}`;let u;try{const g=await H(n,c,o.removalTemplateBody,t);if(!g.success)return g;u=g.data.key,await f.send(new j({StackName:r,ChangeSetName:c,ChangeSetType:"UPDATE",IncludeNestedStacks:!0,Capabilities:B([]),Description:"fjall recreate-removal targets-only proof \u2014 review-only, never executed",...V(o.parameterKeys),...g.data.location}),{abortSignal:b(t)});const y=s();let i,d;for(;!O(t);){const T=await f.send(new I({StackName:r,ChangeSetName:c}),{abortSignal:b(t)});if(i=T.Status,d=T.StatusReason,i!==void 0&&z.has(i))break;if(s()-y>=a)return h(new Error(`Recreate-removal proof timed out after ${a}ms for ${r} (last status: ${i??"unknown"})`));await U(l,t)}if(O(t))return h(new Error(`Recreate-removal proof aborted while polling ${r}`));if(i==="FAILED")return d!==void 0&&Y.test(d)?E({noChanges:!0}):h(new Error(`Recreate-removal proof change set failed for ${r}: ${v(d??"no status reason reported")}`));const p=[],m=new Set(o.targets);let S;do{if(O(t))return h(new Error(`Recreate-removal proof aborted while reading ${r}`));const T=await f.send(new I({StackName:r,ChangeSetName:c,...S!==void 0?{NextToken:S}:{}}),{abortSignal:b(t)});for(const D of T.Changes??[]){if(D.Type!=="Resource")continue;const w=D.ResourceChange,A=w?.LogicalResourceId;if(!(w===void 0||A===void 0)){if(!m.has(A)){p.push(`${A} (not a removal target)`);continue}w.Action!=="Remove"&&p.push(`${A} (action ${w.Action??"unknown"})`)}}S=T.NextToken}while(S!==void 0);return p.length>0?h(new Error(`Recreate removal is NOT targets-only for ${r} \u2014 the review change set reported changes at ${[...new Set(p)].join(", ")}. Aborting before the removal ran; the snapshot flip has already been applied, so re-run the deploy to resume once the change is understood.`)):E({noChanges:!1})}catch(g){return h(new Error(`Recreate-removal proof failed for ${r}: ${v(_(g))}`))}finally{await f.send(new F({StackName:r,ChangeSetName:c}),{abortSignal:b()}).catch(g=>{M.warn(W,"Failed to delete recreate-removal proof change set",{stackName:r,changeSetName:c,error:v(_(g))})}),u!==void 0&&await J(n,u)}}export{L as CO_REMOVABLE_RESOURCE_TYPES,me as deriveRemovalTemplate,he as findDeadReferencesFromTargets,Se as findRemovalDeletionFailures,ge as findResourcesPresent,pe as findTargetReferrers,Ee as proveRemovalTargetsOnly};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{success as $,failure as g}from"@fjall/generator";import{CONTENT_HASH_TAG_PATTERN as
|
|
1
|
+
import{success as $,failure as g}from"@fjall/generator";import{CONTENT_HASH_TAG_PATTERN as I,maskSensitiveOutput as R}from"@fjall/util";import{logger as x}from"@fjall/util/logger";import{SimpleAwsProvider as O}from"../../aws/SimpleAwsProvider.js";import{CloudFormationService as L}from"../../services/infrastructure/CloudFormationService.js";import{EcsService as P}from"../../services/infrastructure/EcsService.js";import{EcsServiceResolver as M}from"../../services/infrastructure/EcsServiceResolver.js";import{EcrImageInspectorService as _,stripTagOrDigest as G}from"../../services/infrastructure/EcrImageInspectorService.js";import{SsmParameterMetadataService as F}from"../../services/infrastructure/SsmParameterMetadataService.js";import{extractEcsServiceName as k}from"../../aws/utils/arnParser.js";import{isAborted as H}from"../../aws/organisations/types.js";import{matchesServiceName as U,resolveServiceTaskDefinition as V}from"../application/serviceImageTagsRollback.js";import{deriveEcrRepositoryArn as j}from"../application/codeOnlyDeploy.js";import{registerAndRollTaskDefinition as B}from"../application/taskDefinitionRoll.js";import{computeSecretsDrift as y}from"./secretsDrift.js";const Y="restartApplication";function h(n){const e=new O(n),i=new L(e);return{ecsService:new P(e),ecsResolver:new M(i),ecrImageInspector:new _(e),ssmService:new F(e)}}async function b(n,e){const{target:i}=e;let r,t;if(e.knownServices!==void 0)r=e.knownServices.clusterArn,t=e.knownServices.serviceArns;else{const s=await n.getDeployableClusterAndServices(i,e.abortSignal);if(!s.success)return g(new Error(`Failed to discover ECS services for ${i}: ${R(s.error.message)}`));r=s.data.clusterArn,t=s.data.serviceArns}if(r===void 0||t.length===0)return g(new Error(`No running ECS services found for ${i} \u2014 nothing to restart. Deploy the application first.`));if(e.serviceName!==void 0){const s=t.filter(o=>{const a=k(o);return a!==void 0&&e.serviceName!==void 0?U(a,e.serviceName):!1});if(s.length===0){const o=t.map(a=>k(a)).filter(a=>a!==void 0).join(", ");return g(new Error(`No ECS service matching "${e.serviceName}" found for ${i}. Live services: ${o||"none"}`))}t=s}return $({clusterArn:r,serviceArns:t})}async function ce(n,e){const{ecsService:i,ecsResolver:r,ssmService:t}=e??h(n.awsCredentials),s=await b(r,n);return s.success?y({ecsService:i,ssmService:t},s.data,n.abortSignal):g(s.error)}async function fe(n,e){const{target:i,callbacks:r,abortSignal:t}=n,s=Date.now(),{ecsService:o,ecsResolver:a,ecrImageInspector:S,ssmService:u}=e??h(n.awsCredentials),f=await b(a,n);if(!f.success)return r.onError?.(f.error),g(f.error);const{clusterArn:w,serviceArns:c}=f.data,d=await y({ecsService:o,ssmService:u},{clusterArn:w,serviceArns:c},t);let l,E={};d.success?(l=d.data,E=d.data.parameterVersions):r.onLog?.(`Could not compute secrets drift before restart: ${R(d.error.message)}. Restarting anyway.`,"warn");const A=[];for(const D of c){if(H(t))return $({target:i,artefacts:A,appliedParameterVersions:E,...l!==void 0?{preRestartDrift:l}:{},durationMs:Date.now()-s,cancelled:!0});const C=Date.now(),m=k(D)??D;r.onLog?.(`Restarting ${m}\u2026`,"info");const v=await V({ecsService:o},w,D,m,t);if(!v.success){const p=new Error(`Restart failed for ${m}: ${R(v.error.message)}`);return r.onError?.(p),g(p)}const T=await B({ecsService:o},{clusterArn:w,serviceArn:D,serviceName:m,taskDefinition:v.data.taskDefinition,containerDefinitions:v.data.containerDefinitions,previousTaskDefArn:v.data.previousTaskDefArn,startedAtMs:C},r,t);if(!T.success){const p=new Error(`Restart failed for ${m}: ${R(T.error.message)}`);return r.onError?.(p),g(p)}const N=await q(S,m,v.data.containerDefinitions,T.data.newTaskDefArn,v.data.previousTaskDefArn,t);N!==void 0?A.push(N):r.onLog?.(`Restarted ${m}, but its image tag could not be recovered \u2014 the restart release will omit this service's artefact.`,"warn")}return x.debug(Y,`Restarted ${i}`,{services:c.length,artefacts:A.length}),$({target:i,artefacts:A,appliedParameterVersions:E,...l!==void 0?{preRestartDrift:l}:{},durationMs:Date.now()-s})}async function q(n,e,i,r,t,s){const o=i.find(c=>c.image)?.image;if(o===void 0)return;const a=G(o),S=j(a);let u,f;const w=o.lastIndexOf("@");if(w>0){f=o.slice(w+1);const c=await n.getImageTagsByDigest(a,f,s);if(c.success&&c.data!==void 0){const d=c.data;u=d.find(l=>I.test(l))??d[0]}}else{const c=o.lastIndexOf(":");if(c>0){u=o.slice(c+1);const d=await n.getImageDigest(a,u,s);d.success&&(f=d.data)}}if(!(u===void 0||u===""))return{serviceName:e,imageTag:u,imageUri:`${a}:${u}`,taskDefinitionArn:r,previousTaskDefinitionArn:t,...f!==void 0?{imageDigest:f}:{},...S!==""?{ecrRepositoryArn:S}:{}}}export{h as createRestartServices,ce as probeSecretsDrift,fe as restart};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{success as
|
|
1
|
+
import{success as D,failure as S}from"@fjall/generator";import{extractEcsServiceName as g}from"../../aws/utils/arnParser.js";import{isAborted as h}from"../../aws/organisations/types.js";const N=/^arn:aws[a-z-]*:ssm:[a-z0-9-]*:\d{12}:parameter/;function k(t){if(t.startsWith("arn:")){const r=t.match(N);if(!r)return;const n=t.slice(r[0].length);return n.startsWith("/")?n:`/${n}`}return t}function w(t){const r=new Set;for(const n of t.containerDefinitions??[])for(const a of n.secrets??[]){if(a.valueFrom===void 0)continue;const s=k(a.valueFrom);s!==void 0&&r.add(s)}return[...r].sort()}function M(t,r,n){if(t===void 0)return[];const a=new Map(n.map(o=>[o.name,o])),s=[];for(const o of r){const i=a.get(o);i?.lastModifiedDate!==void 0&&i.lastModifiedDate.getTime()>t.getTime()&&s.push({name:o,lastModifiedDate:i.lastModifiedDate.toISOString(),...i.version!==void 0?{version:i.version}:{}})}return s}async function b(t,r,n){const a=await t.ecsService.describeServices(r.clusterArn,r.serviceArns,n);if(!a.success)return S(new Error(`Failed to describe services: ${a.error.message}`));const s=[],o=new Set;for(const e of a.data){if(h(n))break;const c=e.serviceArn??"",d=e.serviceName??(c!==""?g(c):void 0);if(d===void 0||d==="")continue;const m=e.taskDefinition;if(m===void 0){s.push({serviceName:d,serviceArn:c,consumed:[]});continue}const f=await t.ecsService.getLatestTaskDefinition(m,n);if(!f.success||f.data===void 0){s.push({serviceName:d,serviceArn:c,taskDefinitionArn:m,consumed:[]});continue}const l=w(f.data);l.forEach(v=>o.add(v)),s.push({serviceName:d,serviceArn:c,taskDefinitionArn:m,...f.data.registeredAt!==void 0?{registeredAt:f.data.registeredAt}:{},consumed:l})}const i=await t.ssmService.describeParametersByName([...o].sort(),n);if(!i.success)return S(i.error);const u=i.data,p={};for(const e of u)e.version!==void 0&&(p[e.name]=e.version);const A=s.map(e=>{const c=M(e.registeredAt,e.consumed,u);return{serviceName:e.serviceName,serviceArn:e.serviceArn,...e.taskDefinitionArn!==void 0?{taskDefinitionArn:e.taskDefinitionArn}:{},...e.registeredAt!==void 0?{taskDefRegisteredAt:e.registeredAt.toISOString()}:{},consumedParameters:e.consumed,stale:c.length>0,staleParameters:c}});return D({services:A,anyStale:A.some(e=>e.stale),parameterVersions:p})}export{b as computeSecretsDrift,M as deriveStaleParameters,w as extractConsumedSsmParameters,k as ssmParameterNameFromValueFrom};
|
|
@@ -3,6 +3,7 @@ import { CdkService } from "../services/infrastructure/CdkService.js";
|
|
|
3
3
|
import { CloudFormationService } from "../services/infrastructure/CloudFormationService.js";
|
|
4
4
|
import { EcsService } from "../services/infrastructure/EcsService.js";
|
|
5
5
|
import { EcsServiceResolver } from "../services/infrastructure/EcsServiceResolver.js";
|
|
6
|
+
import { LambdaService } from "../services/infrastructure/LambdaService.js";
|
|
6
7
|
import { EcrImageInspectorService } from "../services/infrastructure/EcrImageInspectorService.js";
|
|
7
8
|
import { ApplicationStackService } from "../services/application/ApplicationStackService.js";
|
|
8
9
|
import { TemplateHashService } from "../services/supporting/TemplateHashService.js";
|
|
@@ -14,6 +15,7 @@ export interface DeployServices {
|
|
|
14
15
|
cfnService: CloudFormationService;
|
|
15
16
|
ecsService: EcsService;
|
|
16
17
|
ecsResolver: EcsServiceResolver;
|
|
18
|
+
lambdaService: LambdaService;
|
|
17
19
|
ecrImageInspector: EcrImageInspectorService;
|
|
18
20
|
stackService: ApplicationStackService;
|
|
19
21
|
hashService: TemplateHashService;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{SimpleAwsProvider as
|
|
1
|
+
import{SimpleAwsProvider as v}from"../aws/SimpleAwsProvider.js";import{CdkService as f}from"../services/infrastructure/CdkService.js";import{CdkArgumentBuilder as p}from"../services/infrastructure/CdkArgumentBuilder.js";import{CdkProcessManager as w}from"../services/infrastructure/CdkProcessManager.js";import{CloudFormationService as g}from"../services/infrastructure/CloudFormationService.js";import{EcsService as u}from"../services/infrastructure/EcsService.js";import{EcsServiceResolver as b}from"../services/infrastructure/EcsServiceResolver.js";import{LambdaService as k}from"../services/infrastructure/LambdaService.js";import{EcrImageInspectorService as y}from"../services/infrastructure/EcrImageInspectorService.js";import{ApplicationStackService as A}from"../services/application/ApplicationStackService.js";import{TemplateHashService as C}from"../services/supporting/TemplateHashService.js";import{FrameworkRegistry as F}from"./builders/frameworkRegistry.js";function j(e){const r=new v(e.awsCredentials,e.mintScopedBuildSecretSession);r.exportToEnv();const c=new p,i=new w(c,e.abortSignal),n=new f({processManager:i,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{},...e.callbacks.onFailureAnalysis!==void 0?{onFailureAnalysis:e.callbacks.onFailureAnalysis}:{}}),o=new g(r),t=new u(r),s=new b(o),a=new k(r),l=new y(r),m=new A(n,o,r),S=new C,d=F.createDefault();return{awsProvider:r,cdkService:n,cfnService:o,ecsService:t,ecsResolver:s,lambdaService:a,ecrImageInspector:l,stackService:m,hashService:S,frameworkRegistry:d,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{},dispose(){i.dispose()}}}export{j as createDeployServices};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { type CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
|
2
|
-
/** Paginate ListStackResources with a bounded page limit and collect
|
|
3
|
-
export declare function collectStackResources(cfnClient: CloudFormationClient, stackName: string, filter: (resource: {
|
|
2
|
+
/** Paginate ListStackResources with a bounded page limit and collect mapped matches. */
|
|
3
|
+
export declare function collectStackResources<T = string>(cfnClient: CloudFormationClient, stackName: string, filter: (resource: {
|
|
4
4
|
ResourceType?: string;
|
|
5
5
|
ResourceStatus?: string;
|
|
6
6
|
}) => boolean, getId: (resource: {
|
|
7
7
|
PhysicalResourceId?: string;
|
|
8
8
|
LogicalResourceId?: string;
|
|
9
|
-
|
|
9
|
+
ResourceType?: string;
|
|
10
|
+
ResourceStatus?: string;
|
|
11
|
+
}) => T | undefined, abortSignal?: AbortSignal): Promise<T[]>;
|
|
@@ -25,6 +25,7 @@ interface DeployStackOptions {
|
|
|
25
25
|
* tags to the content-hash tag pushed in the preceding Docker phase.
|
|
26
26
|
*/
|
|
27
27
|
parameters?: Record<string, string>;
|
|
28
|
+
abortSignal?: AbortSignal;
|
|
28
29
|
}
|
|
29
30
|
interface ParallelStackCallbacks {
|
|
30
31
|
onOutput?: (chunk: string, stackId: ApplicationStack) => void;
|
|
@@ -76,7 +77,7 @@ export declare class ApplicationStackService {
|
|
|
76
77
|
/**
|
|
77
78
|
* Get stack outputs for a deployed stack
|
|
78
79
|
*/
|
|
79
|
-
getStackOutputs(appName: string, stackType: ApplicationStack): Promise<Result<Record<string, string | number | boolean>, ApplicationError>>;
|
|
80
|
+
getStackOutputs(appName: string, stackType: ApplicationStack, abortSignal?: AbortSignal): Promise<Result<Record<string, string | number | boolean>, ApplicationError>>;
|
|
80
81
|
/**
|
|
81
82
|
* Resolve the website URL for an application from CloudFormation stack outputs.
|
|
82
83
|
* Checks Compute stack for LoadBalancer URL, then CDN stack for CloudFront distribution.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{CloudFormationClient as R}from"@aws-sdk/client-cloudformation";import{S3Client as v}from"@aws-sdk/client-s3";import{capturedSweepBuckets as D,preEmptyStackBuckets as N,sweepOrphanedDestroyBuckets as F}from"../../orchestration/stackCleanup.js";import{STACK_NOT_FOUND_PATTERN as _,CDK_NO_STACKS_MATCH as L}from"../../types/constants.js";import{assertLeaseBeforeStack as B}from"../../orchestration/application/leaseGate.js";import{getApplicationStackName as h}from"../../types/operations.js";import{success as f,failure as y}from"@fjall/generator";import{ApplicationError as w}from"../../types/application/ApplicationServiceTypes.js";import{logger as l}from"@fjall/util/logger";import{maskSensitiveOutput as P}from"@fjall/util";import{convertCloudFormationOutputsToRecord as O}from"../supporting/helpers.js";import{destroyAllStacks as E,mapSettledResults as A,resolveWebsiteUrl as $}from"./applicationStackHelpers.js";class J{cdkService;cloudFormationService;aws;constructor(e,o,
|
|
1
|
+
import{CloudFormationClient as R}from"@aws-sdk/client-cloudformation";import{S3Client as v}from"@aws-sdk/client-s3";import{capturedSweepBuckets as D,preEmptyStackBuckets as N,sweepOrphanedDestroyBuckets as F}from"../../orchestration/stackCleanup.js";import{STACK_NOT_FOUND_PATTERN as _,CDK_NO_STACKS_MATCH as L}from"../../types/constants.js";import{assertLeaseBeforeStack as B}from"../../orchestration/application/leaseGate.js";import{getApplicationStackName as h}from"../../types/operations.js";import{success as f,failure as y}from"@fjall/generator";import{ApplicationError as w}from"../../types/application/ApplicationServiceTypes.js";import{logger as l}from"@fjall/util/logger";import{maskSensitiveOutput as P}from"@fjall/util";import{convertCloudFormationOutputsToRecord as O}from"../supporting/helpers.js";import{destroyAllStacks as E,mapSettledResults as A,resolveWebsiteUrl as $}from"./applicationStackHelpers.js";class J{cdkService;cloudFormationService;aws;constructor(e,o,r){this.cdkService=e,this.cloudFormationService=o,this.aws=r}async runCdkSynth(e){return this.cdkService.runCdkSynth(e)}async deployStack(e,o,r,a){const n=o.target,i=h(n,e),c=await this.cdkService.runCdkDeploy(o,i,r?.onOutput,r?.onResourceProgress,this.aws,void 0,a?.parameters);if(c.success){const s=c.data;l.debug("ApplicationStackService","CDK deploy result",{stackName:i,stackType:e,success:!0,message:s.message,status:s.status,skipped:s.details?.skipped,hasOutput:!!s.details?.output});const u=await this.cloudFormationService.getStackOutputs(i,void 0,a?.abortSignal);l.debug("ApplicationStackService","Stack outputs after deploy",{stackName:i,hasOutputs:u.success&&u.data.length>0,outputCount:u.success?u.data.length:0});const t=u.success&&u.data.length>0?O(u.data):void 0;return f({stackType:e,stackName:i,skipped:s.details?.skipped===!0,outputs:t})}else{const s=P(c.error||`Failed to deploy ${e} infrastructure`);return l.error("ApplicationStackService","Stack deployment failed",{stackType:e,target:o.target,error:s}),y(new w(s,{errorType:"deployment_failed",appName:o.target,operation:"deployStack",stackType:e}))}}async deployStacksInParallel(e,o,r){const a=r?.onOutput,n=r?.onResourceProgress,i=r?.onStackComplete,c=await Promise.allSettled(e.map(async s=>{const u=Date.now(),t=await this.deployStack(s,o,{onOutput:a?p=>a(p,s):void 0,onResourceProgress:n?p=>n(p,s):void 0}),S=Date.now()-u,d={stack:s,success:t.success,error:t.success?void 0:t.error,duration:S,...t.success&&t.data.outputs!==void 0?{outputs:t.data.outputs}:{}};return!t.success&&t.error&&l.debug("deployStacksInParallel",`Stack ${s} failed`,{error:t.error.message}),i?.(s,t.success,S,t.success?void 0:t.error),d}));return f(A(c,e))}async destroyStack(e,o,r,a,n,i){const c=o.target,s=h(c,e);let u=[],t=a===!0;this.aws!==void 0&&!t&&(t=(await this.cdkService.runCdkSynth(o)).success);const S=await B(i,s,n,"destroy");if(S!==void 0)return y(S);if(this.aws!==void 0)if(t){const m=await N(this.aws.getClient(R),this.aws.getClient(v),s,{onLog:r?.onLog,onStackCleanupProgress:r?.onStackCleanupProgress},n);u=D(m)}else l.debug("ApplicationStackService","Skipping pre-empty pass after failed validation synth \u2014 the destroy will surface the error",{stackName:s});else l.debug("ApplicationStackService","No AWS provider available, skipping pre-empty pass",{stackName:s});const d=a===!0||t&&o.assemblyDir===void 0,p=await this.cdkService.runCdkDestroy(o,s,r?.onOutput,r?.onResourceProgress,this.aws,d);if(p.success)return this.aws!==void 0&&u.length>0&&await F(this.aws.getClient(v),u,{onLog:r?.onLog,onStackCleanupProgress:r?.onStackCleanupProgress},n),f({stackType:e,stackName:s,skipped:!1,outputs:void 0});{const m=P(p.error||`Failed to destroy ${e} stack`);return l.error("ApplicationStackService","Stack destroy failed",{stackType:e,target:o.target,error:m}),y(new w(m,{errorType:"destroy_failed",appName:o.target,operation:"destroyStack",stackType:e}))}}async destroyStacksInParallel(e,o,r,a,n){const i=r?.onOutput,c=r?.onResourceProgress,s=r?.onStackComplete,u=await Promise.allSettled(e.map(async t=>{const S=Date.now(),d=await this.destroyStack(t,o,{onOutput:i?k=>i(k,t):void 0,onResourceProgress:c?k=>c(k,t):void 0,onLog:r?.onLog,onStackCleanupProgress:r?.onStackCleanupProgress},a,n),p=Date.now()-S,m=d.success?"":d.error?.message??"",C=m.includes(_)||m.includes(L),g={stack:t,success:d.success||C,error:d.success||C?void 0:d.error,duration:p};return!g.success&&g.error&&l.debug("destroyStacksInParallel",`Stack ${t} failed`,{error:g.error.message}),s?.(t,g.success,p,g.success?void 0:g.error),g}));return f(A(u,e))}async getStackOutputs(e,o,r){const a=h(e,o),n=await this.cloudFormationService.getStackOutputs(a,void 0,r);return n.success?f(O(n.data)):y(new w(`Failed to get outputs for ${a}`,{errorType:"stack_error",appName:e,operation:"getStackOutputs",stackType:o,details:n.error}))}async resolveWebsiteUrl(e){return $(e,this.cloudFormationService)}async destroyAllStacks(e,o,r,a,n){return E(this,e,o,r,a,n)}}export{J as ApplicationStackService};
|
|
@@ -39,12 +39,12 @@ export declare class CloudFormationService {
|
|
|
39
39
|
/**
|
|
40
40
|
* Get stack outputs for services that need them
|
|
41
41
|
*/
|
|
42
|
-
getStackOutputs(stackName: string, callbacks?: CloudFormationCallbacks): Promise<Result<CloudFormationOutput[], CloudFormationError>>;
|
|
42
|
+
getStackOutputs(stackName: string, callbacks?: CloudFormationCallbacks, abortSignal?: AbortSignal): Promise<Result<CloudFormationOutput[], CloudFormationError>>;
|
|
43
43
|
/**
|
|
44
|
-
* Get stack status directly.
|
|
45
|
-
* (
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* Get stack status directly. The send always composes the caller's abort
|
|
45
|
+
* signal (when present) with the SDK timeout via composeSdkAbortSignal;
|
|
46
|
+
* polling callers survive a timeout blip because classifyAwsError maps
|
|
47
|
+
* TimeoutError to a recoverable "timeout" error.
|
|
48
48
|
*/
|
|
49
49
|
getStackStatus(stackName: string, callbacks?: CloudFormationCallbacks, abortSignal?: AbortSignal): Promise<Result<CloudFormationStackStatus | null, CloudFormationError>>;
|
|
50
50
|
/**
|
|
@@ -54,11 +54,11 @@ export declare class CloudFormationService {
|
|
|
54
54
|
/**
|
|
55
55
|
* Look up specific CloudFormation exports by exact name.
|
|
56
56
|
*/
|
|
57
|
-
getExportsByNames(names: string[]): Promise<Result<Map<string, string>, CloudFormationError>>;
|
|
57
|
+
getExportsByNames(names: string[], abortSignal?: AbortSignal): Promise<Result<Map<string, string>, CloudFormationError>>;
|
|
58
58
|
/**
|
|
59
59
|
* List all CloudFormation exports
|
|
60
60
|
*/
|
|
61
|
-
listExports(callbacks?: CloudFormationCallbacks): Promise<Result<Array<{
|
|
61
|
+
listExports(callbacks?: CloudFormationCallbacks, abortSignal?: AbortSignal): Promise<Result<Array<{
|
|
62
62
|
Name: string;
|
|
63
63
|
Value: string;
|
|
64
64
|
}>, CloudFormationError>>;
|
|
@@ -66,7 +66,7 @@ export declare class CloudFormationService {
|
|
|
66
66
|
* Delete a CloudFormation stack directly via the API.
|
|
67
67
|
* Unlike CDK destroy, this works on stacks in DELETE_FAILED state.
|
|
68
68
|
*/
|
|
69
|
-
deleteStack(stackName: string): Promise<Result<void, CloudFormationError>>;
|
|
69
|
+
deleteStack(stackName: string, abortSignal?: AbortSignal): Promise<Result<void, CloudFormationError>>;
|
|
70
70
|
/**
|
|
71
71
|
* Check whether a CloudFormation stack exists as a deployable, healthy stack.
|
|
72
72
|
*
|
|
@@ -76,7 +76,7 @@ export declare class CloudFormationService {
|
|
|
76
76
|
* treating a broken stack as "exists" would skip the deploy that is needed
|
|
77
77
|
* to replace it.
|
|
78
78
|
*/
|
|
79
|
-
stackExists(stackName: string, client?: CloudFormationClient): Promise<boolean>;
|
|
79
|
+
stackExists(stackName: string, client?: CloudFormationClient, abortSignal?: AbortSignal): Promise<boolean>;
|
|
80
80
|
/**
|
|
81
81
|
* Fetch the deployed CloudFormation template for a stack, used by the deploy
|
|
82
82
|
* approval gate to diff the live template against the freshly synthesised one.
|
|
@@ -87,7 +87,7 @@ export declare class CloudFormationService {
|
|
|
87
87
|
* stack_not_found failure; the gate treats that as an empty baseline (every
|
|
88
88
|
* resource is a creation).
|
|
89
89
|
*/
|
|
90
|
-
getTemplate(stackName: string): Promise<Result<string, CloudFormationError>>;
|
|
90
|
+
getTemplate(stackName: string, abortSignal?: AbortSignal): Promise<Result<string, CloudFormationError>>;
|
|
91
91
|
/**
|
|
92
92
|
* Poll stack status until deletion completes, fails, or times out.
|
|
93
93
|
*/
|
|
@@ -95,6 +95,7 @@ export declare class CloudFormationService {
|
|
|
95
95
|
timeoutMs?: number;
|
|
96
96
|
pollIntervalMs?: number;
|
|
97
97
|
onProgress?: (message: string) => void;
|
|
98
|
+
abortSignal?: AbortSignal;
|
|
98
99
|
}): Promise<Result<void, CloudFormationError>>;
|
|
99
100
|
/**
|
|
100
101
|
* Converge a subset of a stack's parameters to new values, preserving every
|