@fjall/deploy-core 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.minified +1 -1
- package/dist/src/aws/index.d.ts +6 -2
- package/dist/src/aws/index.js +1 -1
- package/dist/src/aws/organisations/accountGlobals.d.ts +40 -0
- package/dist/src/aws/organisations/accountGlobals.js +1 -0
- package/dist/src/aws/organisations/accounts.js +1 -1
- package/dist/src/aws/organisations/importedAccounts.d.ts +16 -0
- package/dist/src/aws/organisations/importedAccounts.js +1 -0
- package/dist/src/aws/organisations/index.d.ts +3 -1
- package/dist/src/aws/organisations/index.js +1 -1
- package/dist/src/aws/organisations/rootAccess.d.ts +27 -0
- package/dist/src/aws/organisations/rootAccess.js +3 -0
- package/dist/src/aws/organisations/serviceAccess.d.ts +6 -0
- package/dist/src/aws/organisations/serviceAccess.js +1 -1
- package/dist/src/aws/organisations/types.d.ts +12 -0
- package/dist/src/aws/organisations/types.js +1 -1
- package/dist/src/aws/sts/assumeRoot.d.ts +46 -0
- package/dist/src/aws/sts/assumeRoot.js +1 -0
- package/dist/src/aws/targetReadiness.d.ts +70 -0
- package/dist/src/aws/targetReadiness.js +1 -0
- package/dist/src/aws/targetSetAdvisory.d.ts +24 -0
- package/dist/src/aws/targetSetAdvisory.js +1 -0
- package/dist/src/index.d.ts +13 -13
- package/dist/src/index.js +1 -1
- package/dist/src/orchestration/applicationDeploy.js +1 -1
- package/dist/src/orchestration/applicationDestroy.js +1 -1
- package/dist/src/orchestration/cascadeDestroyHelpers.d.ts +12 -1
- package/dist/src/orchestration/cascadeDestroyHelpers.js +1 -1
- package/dist/src/orchestration/cascadeHelpers.d.ts +21 -6
- package/dist/src/orchestration/cascadeHelpers.js +1 -1
- package/dist/src/orchestration/contextHelpers.d.ts +17 -2
- package/dist/src/orchestration/contextHelpers.js +1 -1
- package/dist/src/orchestration/index.d.ts +8 -3
- package/dist/src/orchestration/index.js +1 -1
- package/dist/src/orchestration/organisationDeploy/cascadeExecution.d.ts +28 -0
- package/dist/src/orchestration/organisationDeploy/cascadeExecution.js +1 -0
- package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +40 -0
- package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -0
- package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.d.ts +8 -0
- package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -0
- package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +12 -0
- package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -0
- package/dist/src/orchestration/organisationDeploy/resolveCascadeAccounts.d.ts +15 -0
- package/dist/src/orchestration/organisationDeploy/resolveCascadeAccounts.js +1 -0
- package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.d.ts +11 -0
- package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -0
- package/dist/src/orchestration/organisationDeploy/trailReconciliation.d.ts +21 -0
- package/dist/src/orchestration/organisationDeploy/trailReconciliation.js +1 -0
- package/dist/src/orchestration/organisationDeploy.d.ts +1 -5
- package/dist/src/orchestration/organisationDeploy.js +1 -5
- package/dist/src/orchestration/organisationDestroy.d.ts +1 -1
- package/dist/src/orchestration/organisationDestroy.js +2 -2
- package/dist/src/orchestration/organisationSetup.d.ts +18 -2
- package/dist/src/orchestration/organisationSetup.js +1 -1
- package/dist/src/orchestration/stackCleanup/bucketOps.d.ts +54 -0
- package/dist/src/orchestration/stackCleanup/bucketOps.js +1 -0
- package/dist/src/orchestration/stackCleanup/failedStack.d.ts +34 -0
- package/dist/src/orchestration/stackCleanup/failedStack.js +1 -0
- package/dist/src/orchestration/stackCleanup/logging.d.ts +9 -0
- package/dist/src/orchestration/stackCleanup/logging.js +1 -0
- package/dist/src/orchestration/stackCleanup/messages.d.ts +16 -0
- package/dist/src/orchestration/stackCleanup/messages.js +1 -0
- package/dist/src/orchestration/stackCleanup/orphanSweep.d.ts +25 -0
- package/dist/src/orchestration/stackCleanup/orphanSweep.js +1 -0
- package/dist/src/orchestration/stackCleanup/preEmpty.d.ts +35 -0
- package/dist/src/orchestration/stackCleanup/preEmpty.js +1 -0
- package/dist/src/orchestration/stackCleanup/stackResources.d.ts +9 -0
- package/dist/src/orchestration/stackCleanup/stackResources.js +1 -0
- package/dist/src/orchestration/stackCleanup.d.ts +13 -40
- package/dist/src/orchestration/stackCleanup.js +1 -1
- package/dist/src/orchestration/trailMigration/memberTrailCleanup.js +1 -1
- package/dist/src/orchestration/unlock/scpRemediation.d.ts +15 -0
- package/dist/src/orchestration/unlock/scpRemediation.js +1 -0
- package/dist/src/orchestration/unlock/unlockBucket.d.ts +37 -0
- package/dist/src/orchestration/unlock/unlockBucket.js +1 -0
- package/dist/src/orchestration/unlock/unlockQueue.d.ts +43 -0
- package/dist/src/orchestration/unlock/unlockQueue.js +1 -0
- package/dist/src/services/application/ApplicationStackService.d.ts +9 -10
- package/dist/src/services/application/ApplicationStackService.js +1 -1
- package/dist/src/services/application/applicationStackHelpers.d.ts +13 -8
- package/dist/src/services/application/applicationStackHelpers.js +3 -3
- package/dist/src/steps/stepRegistry.js +1 -1
- package/dist/src/types/FjallState.d.ts +7 -0
- package/dist/src/types/FjallState.js +1 -1
- package/dist/src/types/callbacks.d.ts +43 -2
- package/dist/src/types/callbacks.js +1 -0
- package/dist/src/types/deploymentEventSchema.d.ts +9 -0
- package/dist/src/types/deploymentEventSchema.js +1 -1
- package/dist/src/types/index.d.ts +5 -10
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/orgConfig.d.ts +8 -2
- package/dist/src/types/params.d.ts +12 -0
- package/dist/src/types/patternDetection.d.ts +0 -25
- package/dist/src/types/patternDetection.js +1 -1
- package/dist/src/types/stepDefinitions.d.ts +2 -0
- package/dist/src/types/stepDefinitions.js +1 -1
- package/package.json +6 -4
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{success as
|
|
1
|
+
import{CloudFormationClient as k}from"@aws-sdk/client-cloudformation";import{S3Client as T}from"@aws-sdk/client-s3";import{success as D,failure as N}from"@fjall/generator";import{maskSensitiveOutput as O,getErrorMessage as M,mapSettledWithConcurrency as _}from"@fjall/util";import{stubCallerIdentity as b}from"../types/deployment/index.js";import{ORGANISATION_TYPES as v,getOrganisationStackName as L}from"../types/operations.js";import{CdkContextBuilder as x}from"../services/supporting/CdkContextBuilder.js";import{buildParamsContext as B,synthOrFail as G,forwardOutput as Y,forwardResourceProgress as j}from"./contextHelpers.js";import{destroyCascadeAccount as $}from"./cascadeDestroyHelpers.js";import{capturedSweepBuckets as F,preEmptyStackBuckets as U,sweepOrphanedDestroyBuckets as H}from"./stackCleanup.js";import{partitionAccounts as W,buildRegionList as X,buildAccountRegionPairs as q,cascadeHomeRegion as z,CASCADE_MAX_CONCURRENCY as J}from"./cascadeHelpers.js";import{projectScalarSummary as K}from"./cascadeSummary.js";import{STEP_IDS as Q}from"../types/stepDefinitions.js";const E=Q.ORG_DESTROY,I="Destroying organisation infrastructure";async function lt(o,r,c){const S=Date.now(),{callbacks:t}=o,l=o.orgConfig?.providerAccounts??[],g=z(o.orgConfig),m=X(o.orgConfig),{platformAccount:u,memberAccounts:f}=W(l),h=o.options?.cascade!==!1;t.onLog?.(`Destroying organisation infrastructure (${l.length} accounts, ${m.length} region(s))`,"info");const n=[],p=[],w=[];let R;if(h){t.onCascadeStart?.();const A=Date.now();if(f.length>0){t.onCascadePhaseStart?.("accounts");const a=q(f,m),d=await _(a,J,({account:s,region:i})=>$(o,r,c,s,"account",i,t,{primaryRegion:g}));for(let s=0;s<d.length;s++){const i=d[s],e=a[s];if(!(!i||!e))if(i.status==="fulfilled")if(i.value.success)p.push(`Account-${e.account.name}-${e.region}`),w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"succeeded",durationMs:i.value.duration});else{const C=O(i.value.error??"Unknown error");w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"failed",durationMs:i.value.duration,error:C}),n.push({accountId:e.account.id,error:C})}else{const C=O(M(i.reason));w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"failed",durationMs:0,error:C}),n.push({accountId:e.account.id,error:C})}}t.onCascadePhaseComplete?.("accounts")}if(u){t.onCascadePhaseStart?.("platform");const a=await $(o,r,c,u,"platform",u.region??g,t);if(a.success)p.push("Platform"),R={accountId:u.id,result:"succeeded",durationMs:a.duration};else{const d=O(a.error??"Platform destroy failed");n.push({accountId:u.id,error:d}),R={accountId:u.id,result:"failed",durationMs:a.duration,error:d}}t.onCascadePhaseComplete?.("platform")}const y={members:w,...R!==void 0?{platform:R}:{},domainsDeployed:!1,errors:n,totalDurationMs:Date.now()-A};if(t.onCascadeComplete?.(K(y)),t.onCascadeLedger?.(y),n.length>0){const a=n.map(s=>` ${s.accountId}: ${s.error}`).join(`
|
|
2
2
|
`),d=new Error(`Cascade destroy completed with ${n.length} failure(s):
|
|
3
|
-
${a}`);t.onError?.(d),t.onLog?.(
|
|
3
|
+
${a}`);t.onError?.(d),t.onLog?.(d.message,"warn")}}if(n.length>0)return t.onLog?.("Skipping organisation root stack destroy due to cascade failures","warn"),D({target:c.target,deploymentType:"organisation",stacksDestroyed:p,durationMs:Date.now()-S,warnings:n.map(y=>`${y.accountId}: ${y.error}`)});t.onStepStart?.(E,I,0,1);const P=await V(o,r,c,r.awsProvider.getRegion(),t);if(P.success)p.push("Organisation"),t.onStepComplete?.(E,I,"completed",0,1);else return t.onStepComplete?.(E,I,"error",0,1),N(P.error);return D({target:c.target,deploymentType:"organisation",stacksDestroyed:p,durationMs:Date.now()-S})}async function V(o,r,c,S,t){const l=x.buildDeploymentContext({deployType:"organisation",target:c.target,path:c.path,region:S,callerIdentity:b(r.awsProvider.getAccountId()),...B({orgConfig:o.orgConfig,identity:o.identity})},{verbose:o.options?.verbose},o.orgConfig),g=L(v.ORGANISATION);t.onLog?.("Synthesising organisation infrastructure\u2026","info");const m=await G(r,l,t,"Organisation synth failed");if(!m.success)return m;const u=await U(r.awsProvider.getClient(k),r.awsProvider.getClient(T),g,t,o.abortSignal),f=F(u);t.onLog?.(`Destroying ${g} stack\u2026`,"info");const h=await r.cdkService.runCdkDestroy(l,g,Y(t),j(t),r.awsProvider,!0);if(!h.success){const n=new Error(O(`Organisation destroy failed: ${h.error}`));return t.onError?.(n),N(n)}return f.length>0&&await H(r.awsProvider.getClient(T),f,t,o.abortSignal),D(void 0)}export{lt as destroyOrganisation};
|
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { type Result } from "@fjall/generator";
|
|
2
2
|
import type { AwsProvider } from "../aws/AwsProvider.js";
|
|
3
3
|
import type { OUTree, AccountInfo } from "../aws/organisations/types.js";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Execution-order tuple of org-setup phases — single source of truth for the
|
|
6
|
+
* OrgSetupPhase union. The engine's hand-written phase blocks are tethered to
|
|
7
|
+
* this tuple by the "starts every declared phase" test in
|
|
8
|
+
* organisationSetup.test.ts: a variant added here without an engine block
|
|
9
|
+
* fails that test; an engine literal not listed here fails typecheck
|
|
10
|
+
* (executePhase/skipPhase take OrgSetupPhase).
|
|
11
|
+
*/
|
|
12
|
+
export declare const ORG_SETUP_PHASES: readonly ["create-organisation", "enable-policies", "enable-service-access", "enable-root-access", "enable-ram-sharing", "activate-trusted-access", "enable-ipam", "configure-backup", "create-accounts", "create-organisational-units", "place-accounts", "activate-cost-tags", "check-identity-centre", "register-security-delegates"];
|
|
13
|
+
export type OrgSetupPhase = (typeof ORG_SETUP_PHASES)[number];
|
|
5
14
|
export interface OrgSetupCallbacks {
|
|
6
15
|
onPhaseStart?(phase: OrgSetupPhase): void;
|
|
7
16
|
onPhaseComplete?(phase: OrgSetupPhase, result: "completed" | "skipped" | "error"): void;
|
|
8
17
|
onProgress?(message: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* Posture-change warnings that must be visible but never block the phase
|
|
20
|
+
* (e.g. the AC2.8 imported-account warning). Adapters that do not handle
|
|
21
|
+
* this receive the same message through onProgress instead.
|
|
22
|
+
*/
|
|
23
|
+
onWarning?(phase: OrgSetupPhase, message: string): void;
|
|
9
24
|
onError?(phase: OrgSetupPhase, error: Error): void;
|
|
10
25
|
}
|
|
11
26
|
export interface OrgSetupConfig {
|
|
@@ -18,6 +33,7 @@ export interface OrgSetupConfig {
|
|
|
18
33
|
accountPlacements?: AccountInfo[];
|
|
19
34
|
costAllocationTags?: string[];
|
|
20
35
|
skipIdentityCentre?: boolean;
|
|
36
|
+
skipRootAccessManagement?: boolean;
|
|
21
37
|
securityDelegateAccountId?: string;
|
|
22
38
|
}
|
|
23
39
|
export interface OrgSetupResult {
|
|
@@ -37,7 +53,7 @@ export interface OrgSetupResult {
|
|
|
37
53
|
/**
|
|
38
54
|
* Orchestrate the full AWS Organisation setup sequence.
|
|
39
55
|
*
|
|
40
|
-
* Runs up to
|
|
56
|
+
* Runs up to 14 phases sequentially. Non-fatal phase failures are recorded
|
|
41
57
|
* and execution continues. The only fatal failure is phase 1
|
|
42
58
|
* (create-organisation) since all subsequent phases depend on the org ID.
|
|
43
59
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{success as
|
|
1
|
+
import{success as y,failure as h}from"@fjall/generator";import{OrganizationsClient as K}from"@aws-sdk/client-organizations";import{RAMClient as $}from"@aws-sdk/client-ram";import{CloudFormationClient as B}from"@aws-sdk/client-cloudformation";import{EC2Client as G}from"@aws-sdk/client-ec2";import{IAMClient as _}from"@aws-sdk/client-iam";import{BackupClient as z}from"@aws-sdk/client-backup";import{CostExplorerClient as H}from"@aws-sdk/client-cost-explorer";import{SSOAdminClient as q}from"@aws-sdk/client-sso-admin";import{ensureOrganisationExists as J}from"../aws/organisations/organisation.js";import{enablePolicyTypes as Q}from"../aws/organisations/policies.js";import{enableServiceAccess as V}from"../aws/organisations/serviceAccess.js";import{enableRamSharing as X}from"../aws/organisations/ram.js";import{activateTrustedAccess as Y}from"../aws/organisations/trustedAccess.js";import{enableIpamDelegatedAdmin as Z}from"../aws/organisations/ipam.js";import{updateBackupGlobalSettings as b}from"../aws/organisations/backup.js";import{listAccounts as w,createAccount as k}from"../aws/organisations/accounts.js";import{ensureOrganisationalUnitsExist as ee,placeAccountsInOUs as te,buildAccountToOUMap as ne}from"../aws/organisations/organisationalUnits.js";import{activateCostAllocationTags as oe}from"../aws/organisations/costAllocation.js";import{checkIdentityCentreStatus as re}from"../aws/organisations/identityCentre.js";import{registerSecurityDelegates as se}from"../aws/organisations/delegatedAdmin.js";import{enableCentralisedRootAccess as ae}from"../aws/organisations/rootAccess.js";import{selectImportedMemberAccounts as ie,formatImportedAccountsWarning as ce}from"../aws/organisations/importedAccounts.js";import{isOULeaf as ue}from"../aws/organisations/types.js";const Ke=["create-organisation","enable-policies","enable-service-access","enable-root-access","enable-ram-sharing","activate-trusted-access","enable-ipam","configure-backup","create-accounts","create-organisational-units","place-accounts","activate-cost-tags","check-identity-centre","register-security-delegates"];async function $e(t,n,e,a){const o=[],s=[],r=[],m=[];let i;const u=t.getClient(K),M=t.getClient(_),x=t.getClient($),N=t.getClient(B),L=t.getClient(G),W=t.getClient(z),F=t.getClient(H),j=t.getClient(q);e?.onPhaseStart?.("create-organisation"),e?.onProgress?.("Ensuring AWS Organisation exists");const l=await J(u);if(!l.success)return e?.onError?.("create-organisation",l.error),e?.onPhaseComplete?.("create-organisation","error"),h(l.error);const{orgId:D,rootId:I,managementAccountId:S}=l.data;if(e?.onPhaseComplete?.("create-organisation","completed"),o.push("create-organisation"),await p("enable-policies",()=>(e?.onProgress?.("Enabling organisation policy types"),Q(u,I,{abortSignal:a})),o,r,e),await p("enable-service-access",()=>(e?.onProgress?.("Enabling AWS service access"),V(u)),o,r,e),n.skipRootAccessManagement?d("enable-root-access",s,e):await p("enable-root-access",async()=>{e?.onProgress?.("Enabling centralised root access management");const c=await ae(M,a);return(c.success?c.data.enabled:c.error.partialSummary.enabled).length>0&&await me(u,S,e),c},o,r,e),await p("enable-ram-sharing",()=>(e?.onProgress?.("Enabling RAM sharing"),X(x)),o,r,e),await p("activate-trusted-access",()=>(e?.onProgress?.("Activating CloudFormation trusted access"),Y(N)),o,r,e),n.platformAccountId){const c=n.platformAccountId;await p("enable-ipam",()=>(e?.onProgress?.("Enabling IPAM delegated administrator"),Z(L,c)),o,r,e)}else d("enable-ipam",s,e);await p("configure-backup",()=>(e?.onProgress?.("Updating backup global settings"),b(W)),o,r,e),e?.onPhaseStart?.("create-accounts"),e?.onProgress?.("Checking for missing accounts");const P=await pe(u,n.accounts,m,e,a);let E=[];P.success?(E=P.data,o.push("create-accounts"),e?.onPhaseComplete?.("create-accounts","completed")):C("create-accounts",P.error,r,e);let f={};e?.onPhaseStart?.("create-organisational-units"),e?.onProgress?.("Ensuring organisational units exist");const A=await ee(u,I,n.organisationalUnits);A.success?(f=A.data,o.push("create-organisational-units"),e?.onPhaseComplete?.("create-organisational-units","completed")):C("create-organisational-units",A.error,r,e);const g=Array.isArray(n.organisationalUnits)?void 0:n.organisationalUnits,R=n.accountPlacements??[],O=R.length===0&&g!==void 0?de(g,E,S):R;if(Object.keys(f).length===0)d("place-accounts",s,e);else if(n.accountPlacements===void 0&&g===void 0){const c=new Error("Account placements not provided despite OUs being created. Caller must populate accountPlacements (flat-list mode cannot derive placements internally).");r.push({phase:"place-accounts",error:c.message}),e?.onPhaseStart?.("place-accounts"),e?.onError?.("place-accounts",c),e?.onPhaseComplete?.("place-accounts","error")}else if(O.length===0)d("place-accounts",s,e);else{const c=g?ne(g,f):void 0;await p("place-accounts",()=>(e?.onProgress?.("Placing accounts in organisational units"),te(u,f,O,c)),o,r,e)}const v=n.costAllocationTags??[];if(v.length>0?await p("activate-cost-tags",()=>(e?.onProgress?.("Activating cost allocation tags"),oe(F,v.map(c=>({TagKey:c})))),o,r,e):d("activate-cost-tags",s,e),n.skipIdentityCentre)d("check-identity-centre",s,e);else{e?.onPhaseStart?.("check-identity-centre"),e?.onProgress?.("Checking Identity Centre status");const c=await re(j);c.success?(i=c.data.enabled?"enabled":"not-enabled",o.push("check-identity-centre"),e?.onPhaseComplete?.("check-identity-centre","completed")):C("check-identity-centre",c.error,r,e)}const T=n.securityDelegateAccountId;return T?await p("register-security-delegates",()=>(e?.onProgress?.("Registering security service delegated administrators"),se(u,T)),o,r,e):d("register-security-delegates",s,e),y({organisationId:D,createdAccounts:m,identityCentreStatus:i,phasesCompleted:o,phasesSkipped:s,errors:r})}function C(t,n,e,a){e.push({phase:t,error:n.message}),a?.onError?.(t,n),a?.onPhaseComplete?.(t,"error")}function d(t,n,e){n.push(t),e?.onPhaseStart?.(t),e?.onPhaseComplete?.(t,"skipped")}async function me(t,n,e){const a=await w(t);if(!a.success){e?.onProgress?.(`Could not check for imported member accounts: ${a.error.message}`);return}const o=ie(a.data,n);if(o.length===0)return;const s=ce(o);e?.onWarning?e.onWarning("enable-root-access",s):e?.onProgress?.(s)}async function p(t,n,e,a,o){o?.onPhaseStart?.(t);const s=await n();s.success?(e.push(t),o?.onPhaseComplete?.(t,"completed")):C(t,s.error,a,o)}async function pe(t,n,e,a,o){const s=await w(t);if(!s.success)return h(s.error);const r=new Set(s.data.map(i=>i.Name?.toLowerCase()).filter(i=>i!==void 0));for(const i of n){if(r.has(i.name.toLowerCase()))continue;a?.onProgress?.(`Account "${i.name}" is not yet a member of this AWS Organisation. Fjall will create a new account (with a new account ID). If "${i.name}" already exists as a standalone Fjall-connected account, abort now and import it into the organisation instead, because creating here produces a duplicate account. See the account-import runbook.`);const u=await k(t,i.name,i.email,void 0,void 0,o);if(!u.success)return h(u.error);e.push({name:u.data.accountName,accountId:u.data.accountId})}if(e.length===0)return y(s.data);const m=await w(t);return m.success?y(m.data):h(m.error)}function U(t){const n=[];for(const[e,a]of Object.entries(t))if(ue(a))for(const o of a)n.push({accountName:o,placementKey:e});else n.push(...U(a));return n}function de(t,n,e){const a=U(t),o=new Map;for(const r of n){const m=r.Name?.toLowerCase();m&&r.Id&&o.set(m,r)}const s=[];for(const{accountName:r,placementKey:m}of a){const i=o.get(r.toLowerCase());!i?.Id||!i.Name||e!==void 0&&i.Id===e||s.push({id:i.Id,name:i.Name,environment:m})}return s}export{Ke as ORG_SETUP_PHASES,$e as runOrganisationSetup};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { DeployCallbacks } from "../../types/callbacks.js";
|
|
3
|
+
/**
|
|
4
|
+
* Tag keys that opt a stack bucket into SDK-side pre-emptying before CFN
|
|
5
|
+
* destroy. `aws-cdk:auto-delete-objects` covers stacks deployed while the S3
|
|
6
|
+
* wrapper still used the CDK custom resource — an external CDK contract, so
|
|
7
|
+
* it stays a literal here; `SDK_PRE_EMPTY_TAG_KEY` is the marker the Phase 3
|
|
8
|
+
* wrapper applies after autoDeleteObjects retirement (canonical source:
|
|
9
|
+
* @fjall/util/aws infraTags, shared with the producing S3 wrapper).
|
|
10
|
+
* A bucket carrying neither tag (Retain) is never touched — ListStackResources
|
|
11
|
+
* cannot read removal policy, so this predicate is the Retain-skip mechanism.
|
|
12
|
+
* A tag only counts when its value is "true", mirroring the CDK auto-delete
|
|
13
|
+
* handler's own check: flipping the value is the documented opt-out for
|
|
14
|
+
* preserving data, and this pass must never be looser than the handler.
|
|
15
|
+
*/
|
|
16
|
+
export declare const PRE_EMPTY_TAG_KEYS: readonly ["aws-cdk:auto-delete-objects", "fjall:sdk-pre-empty"];
|
|
17
|
+
/**
|
|
18
|
+
* Error names meaning the bucket is already gone — convergence, not failure,
|
|
19
|
+
* for delete-side S3 calls. Shared with the trail-migration decommission
|
|
20
|
+
* (coupled values: a new gone-shape must be recognised by both sites).
|
|
21
|
+
*/
|
|
22
|
+
export declare const BUCKET_GONE_ERROR_NAMES: ReadonlySet<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Gone-class predicate for delete-side S3 calls: error name in
|
|
25
|
+
* BUCKET_GONE_ERROR_NAMES, with a message fallback because wrapped/unmodelled
|
|
26
|
+
* errors can carry the shape only in the message. NoSuchBucket is the one
|
|
27
|
+
* safe message probe — "NotFound"/"404" are too generic to match free text.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isBucketGoneError(error: unknown): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Outcome of an empty pass. "access-denied" is the quarantine signal
|
|
32
|
+
* `cleanupFailedStack` classifies; existing callers ignore the value and keep
|
|
33
|
+
* their warn-only behaviour.
|
|
34
|
+
*/
|
|
35
|
+
export type EmptyBucketOutcome = "emptied" | "missing" | "access-denied" | "failed";
|
|
36
|
+
/**
|
|
37
|
+
* Empty an S3 bucket by deleting all object versions and delete markers.
|
|
38
|
+
* Handles NoSuchBucket gracefully (bucket already gone).
|
|
39
|
+
* Exported for the org-trail migration's member-bucket decommission.
|
|
40
|
+
*/
|
|
41
|
+
export declare function emptyS3Bucket(s3Client: S3Client, bucketName: string, callbacks?: DeployCallbacks, abortSignal?: AbortSignal): Promise<EmptyBucketOutcome>;
|
|
42
|
+
export type PreEmptyVerdict = "matched" | "retained" | "gone" | "unreadable";
|
|
43
|
+
/**
|
|
44
|
+
* Classify a bucket for a destroy-intent pass via GetBucketTagging.
|
|
45
|
+
* - "matched": carries a pre-empty tag — the bucket may be emptied/deleted.
|
|
46
|
+
* - "retained": untagged (NoSuchTagSet / no matching tag) — never touched.
|
|
47
|
+
* - "gone": NoSuchBucket — already deleted; excluded from the retained
|
|
48
|
+
* report because nothing survives the destroy.
|
|
49
|
+
* - "unreadable": tags could not be read (throttle, denial) — never touched,
|
|
50
|
+
* already warned here; callers decide how to account for it (the pre-empty
|
|
51
|
+
* pass folds it into the conservative retained report, the orphan sweep
|
|
52
|
+
* counts it as a failure).
|
|
53
|
+
*/
|
|
54
|
+
export declare function classifyBucketForPreEmpty(s3Client: S3Client, bucketName: string, callbacks?: DeployCallbacks, abortSignal?: AbortSignal): Promise<PreEmptyVerdict>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ListObjectVersionsCommand as w,DeleteObjectsCommand as K,GetBucketTaggingCommand as x}from"@aws-sdk/client-s3";import{logger as f}from"@fjall/util/logger";import{getErrorMessage as y,maskSensitiveOutput as p}from"@fjall/util";import{SDK_PRE_EMPTY_TAG_KEY as C}from"@fjall/util/aws";import{composeSdkAbortSignal as B,extractErrorName as T,isAborted as b,isAccessDenied as D}from"../../aws/organisations/types.js";import{STACK_CLEANUP_LOG as E,warnViaCallbacks as g}from"./logging.js";const _=1e3,P=["aws-cdk:auto-delete-objects",C],V=new Set(["NoSuchBucket","NotFound","404"]);function $(t){return V.has(T(t))?!0:(t instanceof Error?t.message??"":"").includes("NoSuchBucket")}function A(t){const e=t instanceof Error?t.message??"":"";return D(T(t))||e.includes("AccessDenied")}async function L(t,e,i,u){let s,a,d=!1;i?.onStackCleanupProgress?.(e,"emptying-bucket");const r=1e3;let l=0;for(;l++<r;){if(b(u))return f.debug(E,`Aborted while emptying bucket ${e}`),"failed";let n;try{n=await t.send(new w({Bucket:e,KeyMarker:s,VersionIdMarker:a}),{abortSignal:B(u)})}catch(o){if($(o))return f.debug(E,`Bucket ${e} no longer exists, skipping`),"missing";const h=`Unexpected error emptying bucket ${e}: ${p(y(o))}`;return g(h,i),A(o)?"access-denied":"failed"}const S=[...n.Versions??[],...n.DeleteMarkers??[]];if(S.length===0)break;for(let o=0;o<S.length;o+=_){const h=S.slice(o,o+_);try{const c=(await t.send(new K({Bucket:e,Delete:{Objects:h.map(m=>({Key:m.Key,VersionId:m.VersionId})),Quiet:!0}}),{abortSignal:B(u)})).Errors??[];if(c.length>0){const m=`Failed to delete ${c.length} object(s) from ${e}: ${p(c[0]?.Message??c[0]?.Code??"unknown error")}`;if(g(m,i),c.some(M=>M.Code?.includes("AccessDenied")))return"access-denied";d=!0}}catch(k){const c=`Failed to delete batch from ${e}: ${p(y(k))}`;if(g(c,i),A(k))return"access-denied";d=!0}}if(!n.IsTruncated)break;s=n.NextKeyMarker,a=n.NextVersionIdMarker}if(l>r){const n=`Bucket ${e} reached ${r} page limit \u2014 some objects may remain`;return g(n,i),"failed"}return f.debug(E,`Emptied bucket ${e}`),d?"failed":"emptied"}async function Y(t,e,i,u){try{const s=await t.send(new x({Bucket:e}),{abortSignal:B(u)}),a=P;return(s.TagSet??[]).some(r=>r.Key!==void 0&&a.includes(r.Key)&&r.Value==="true")?"matched":"retained"}catch(s){const a=T(s),d=s instanceof Error?s.message??"":"",r=$(s),l=a==="NoSuchTagSet"||d.includes("NoSuchTagSet");if(r||l)return f.debug(E,`Bucket ${e} ${r?"no longer exists":"has no tags"} \u2014 skipping`),r?"gone":"retained";const n=`Could not read tags for bucket ${e}, leaving it untouched: ${p(y(s))}`;return g(n,i),"unreadable"}}export{V as BUCKET_GONE_ERROR_NAMES,P as PRE_EMPTY_TAG_KEYS,Y as classifyBucketForPreEmpty,L as emptyS3Bucket,$ as isBucketGoneError};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup for CloudFormation stacks stuck in failed states.
|
|
3
|
+
*
|
|
4
|
+
* Only operates on stacks that never successfully deployed:
|
|
5
|
+
* - ROLLBACK_FAILED -- creation failed, rollback failed
|
|
6
|
+
* - ROLLBACK_COMPLETE -- creation failed, rollback succeeded
|
|
7
|
+
* - DELETE_FAILED -- deletion already started
|
|
8
|
+
*
|
|
9
|
+
* Never touches UPDATE_ROLLBACK_FAILED (has live resources from previous deploy).
|
|
10
|
+
*/
|
|
11
|
+
import type { DeployCallbacks } from "../../types/callbacks.js";
|
|
12
|
+
interface StackCleanupCredentials {
|
|
13
|
+
accessKeyId: string;
|
|
14
|
+
secretAccessKey: string;
|
|
15
|
+
sessionToken?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Clean up a CloudFormation stack stuck in a failed state.
|
|
19
|
+
* Best-effort: all errors are caught and logged, never throws.
|
|
20
|
+
*
|
|
21
|
+
* Flow:
|
|
22
|
+
* 1. Check stack status -- skip if not in SAFE_CLEANUP_STATES
|
|
23
|
+
* 2. Find and empty S3 buckets blocking deletion
|
|
24
|
+
* 3. Delete the stack
|
|
25
|
+
* 4. Poll for DELETE_COMPLETE (5min timeout, 5s interval)
|
|
26
|
+
* 5. If DELETE_FAILED again, retry once with RetainResources for non-S3 failures
|
|
27
|
+
*/
|
|
28
|
+
export declare function cleanupFailedStack(stackName: string, region: string, credentials: StackCleanupCredentials, options?: {
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
pollMs?: number;
|
|
31
|
+
accountId?: string;
|
|
32
|
+
abortSignal?: AbortSignal;
|
|
33
|
+
}, callbacks?: DeployCallbacks): Promise<void>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CloudFormationClient as A,DescribeStacksCommand as m,DeleteStackCommand as C}from"@aws-sdk/client-cloudformation";import{S3Client as P}from"@aws-sdk/client-s3";import{NodeHttpHandler as D}from"@smithy/node-http-handler";import{logger as u}from"@fjall/util/logger";import{getErrorMessage as f,maskSensitiveOutput as p,sleep as R}from"@fjall/util";import{composeSdkAbortSignal as w,isAborted as _}from"../../aws/organisations/types.js";import{STACK_NOT_FOUND_PATTERN as g,isCleanableState as F}from"../../types/constants.js";import{emptyS3Bucket as I}from"./bucketOps.js";import{STACK_CLEANUP_LOG as s,warnViaCallbacks as M}from"./logging.js";import{formatQuarantineSuspectedMessage as k}from"./messages.js";import{collectStackResources as y}from"./stackResources.js";async function O(e,t,a){return y(e,t,r=>r.ResourceType==="AWS::S3::Bucket"&&r.ResourceStatus==="DELETE_FAILED",r=>r.PhysicalResourceId,a)}function h(e,t){return t===void 0?R(e):t.aborted?Promise.resolve():new Promise(a=>{const r=()=>{clearTimeout(n),a()},n=setTimeout(()=>{t.removeEventListener("abort",r),a()},e);t.addEventListener("abort",r,{once:!0})})}async function J(e,t,a,r,n){const S=r?.timeoutMs??3e5,E=r?.pollMs??5e3,o=r?.abortSignal;try{const c=new A({region:t,credentials:a,requestHandler:new D({requestTimeout:15e3})});let d;try{d=(await c.send(new m({StackName:e}),{abortSignal:w(o)})).Stacks?.[0]?.StackStatus}catch(i){if(i instanceof Error&&i.message?.includes(g)){u.debug(s,`Stack ${e} does not exist, no cleanup needed`);return}u.warn(s,`Failed to check stack status: ${p(f(i))}`,{stackName:e,region:t});return}if(!d||!F(d)){u.debug(s,`Stack ${e} status ${d??"unknown"} is not cleanable, skipping`);return}u.warn(s,`Cleaning up ${e} stack in ${d} state`,{region:t}),n?.onStackCleanupProgress?.(e,"deleting-stack");const b=new P({region:t,credentials:a,requestHandler:new D({requestTimeout:15e3})});try{const i=await O(c,e,o);for(const l of i){if(_(o))break;if(u.warn(s,`Emptying bucket ${l}`,{region:t}),await I(b,l,n,o)==="access-denied"){const L={bucketName:l,...r?.accountId!==void 0?{accountId:r.accountId}:{}};u.warn(s,k(e,L),{region:t}),n?.onStackCleanupProgress?.(e,"quarantine-suspected",L)}}}catch(i){const l=`Failed to empty S3 buckets: ${p(f(i))}`;M(l,n,{stackName:e,region:t})}await c.send(new C({StackName:e}),{abortSignal:w(o)}),n?.onStackCleanupProgress?.(e,"waiting");const T=await $(c,e,S,E,o);if(T==="DELETE_COMPLETE"){u.warn(s,`${e} stack deleted successfully`,{region:t}),n?.onStackCleanupProgress?.(e,"complete");return}if(T==="DELETE_FAILED"){u.warn(s,`${e} still in DELETE_FAILED, retrying with RetainResources`,{region:t});const i=await B(c,e,o);if(i.length===0)u.warn(s,`${e} in DELETE_FAILED but no non-bucket resources to retain \u2014 cannot retry`,{region:t}),n?.onStackCleanupProgress?.(e,"error");else{await c.send(new C({StackName:e,RetainResources:i}),{abortSignal:w(o)});const l=await $(c,e,S,E,o);l==="DELETE_COMPLETE"?(u.warn(s,`${e} stack deleted on retry (retained: ${i.join(", ")})`,{region:t}),n?.onStackCleanupProgress?.(e,"complete")):(u.warn(s,`${e} stack still not deleted after retry: ${l}`,{region:t}),n?.onStackCleanupProgress?.(e,"error"))}}}catch(c){u.warn(s,`Stack cleanup failed: ${p(f(c))}`,{stackName:e,region:t}),n?.onStackCleanupProgress?.(e,"error")}}async function $(e,t,a,r,n){const S=Date.now();for(;Date.now()-S<a;){if(await h(r,n),_(n))return"ABORTED";try{const o=(await e.send(new m({StackName:t}),{abortSignal:w(n)})).Stacks?.[0]?.StackStatus;if(!o||o==="DELETE_COMPLETE")return"DELETE_COMPLETE";if(o==="DELETE_FAILED")return"DELETE_FAILED";u.debug(s,`Waiting for ${t}: ${o}`)}catch(E){if(E instanceof Error&&E.message?.includes(g))return"DELETE_COMPLETE";throw u.debug(s,`Unexpected error polling ${t}: ${p(f(E))}`),E}}return"TIMEOUT"}async function B(e,t,a){return y(e,t,r=>r.ResourceStatus==="DELETE_FAILED"&&r.ResourceType!=="AWS::S3::Bucket",r=>r.LogicalResourceId,a)}export{J as cleanupFailedStack};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DeployCallbacks } from "../../types/callbacks.js";
|
|
2
|
+
/** Logger category shared by every stackCleanup sub-module. */
|
|
3
|
+
export declare const STACK_CLEANUP_LOG = "stackCleanup";
|
|
4
|
+
/**
|
|
5
|
+
* Dual-sink warn: the file-persisted logger plus the caller's onLog callback.
|
|
6
|
+
* Callers mask error-derived content before calling — messages pass through
|
|
7
|
+
* unmodified.
|
|
8
|
+
*/
|
|
9
|
+
export declare function warnViaCallbacks(message: string, callbacks: DeployCallbacks | undefined, meta?: Record<string, unknown>): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{logger as a}from"@fjall/util/logger";const t="stackCleanup";function l(o,n,r){a.warn(t,o,r),n?.onLog?.(o,"warn")}export{t as STACK_CLEANUP_LOG,l as warnViaCallbacks};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { StackCleanupQuarantineDetail } from "../../types/callbacks.js";
|
|
2
|
+
/**
|
|
3
|
+
* Render the quarantine-suspected remediation copy. Shared by the CLI
|
|
4
|
+
* adapters (and the webapp worker adapter) so consumers do not fork the
|
|
5
|
+
* wording.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatQuarantineSuspectedMessage(stackName: string, detail?: StackCleanupQuarantineDetail): string;
|
|
8
|
+
/**
|
|
9
|
+
* Render the retained-bucket report (AC3.5). The copy travels pre-formatted
|
|
10
|
+
* inside StackCleanupRetainedBucketsDetail.message so adapters render it
|
|
11
|
+
* verbatim without importing this function — the single travelling string
|
|
12
|
+
* keeps the remediation wording from forking across consumers.
|
|
13
|
+
* Bucket and stack names are resource identifiers, not credential values, so
|
|
14
|
+
* the message needs no masking.
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatRetainedBucketsMessage(stackName: string, retainedBuckets: readonly string[]): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function u(n,e){const t=e?.bucketName??"unknown bucket",o=e?.accountId!==void 0?` (account ${e.accountId})`:"",c=e?.bucketName!==void 0&&e?.accountId!==void 0?` \u2014 run \`fjall unlock bucket ${e.bucketName} --account ${e.accountId} --acknowledge-root-session\` to remove it`:" \u2014 a root session is required to remove it (fjall unlock bucket)";return`Bucket ${t} in stack ${n}${o} appears quarantined by a stranded deny policy${c}`}function a(n,e){return`Stack ${n}: bucket(s) retained, not deleted: ${e.join(", ")} \u2014 production buckets survive destroy by design (RemovalPolicy.RETAIN). Delete deliberately when the data is no longer needed, or redeploy with an explicit DESTROY removal policy before destroying.`}export{u as formatQuarantineSuspectedMessage,a as formatRetainedBucketsMessage};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { DeployCallbacks } from "../../types/callbacks.js";
|
|
3
|
+
export interface OrphanSweepSummary {
|
|
4
|
+
/** Marked buckets that survived the CFN destroy and were emptied + deleted SDK-side. */
|
|
5
|
+
swept: string[];
|
|
6
|
+
/** Marked buckets that still exist but could not be removed — warned, never fails the destroy. */
|
|
7
|
+
failed: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Convergent post-CFN orphan sweep (AC3.3). After CFN reports the stack gone,
|
|
11
|
+
* any bucket captured pre-destroy that still exists AND still carries a
|
|
12
|
+
* DESTROY-intent marker is emptied and deleted SDK-side, so re-running an
|
|
13
|
+
* interrupted destroy converges instead of stranding marked buckets.
|
|
14
|
+
*
|
|
15
|
+
* Convergence is bounded by capture-before-destroy: the captured list dies
|
|
16
|
+
* with the process, so a crash after CFN reports the stack gone but before
|
|
17
|
+
* this sweep completes leaves survivors a re-run cannot see — the deleted
|
|
18
|
+
* stack no longer lists its resources, and the re-run captures nothing.
|
|
19
|
+
*
|
|
20
|
+
* The tag re-probe is the name-reuse guard: a bucket recreated by another
|
|
21
|
+
* owner after CFN deleted the original classifies "retained" and is never
|
|
22
|
+
* touched. Already-gone buckets are convergence, not failures. The sweep
|
|
23
|
+
* never fails the destroy — per-bucket problems warn and continue.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sweepOrphanedDestroyBuckets(s3Client: S3Client, bucketNames: readonly string[], callbacks?: DeployCallbacks, abortSignal?: AbortSignal): Promise<OrphanSweepSummary>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{DeleteBucketCommand as p}from"@aws-sdk/client-s3";import{logger as a}from"@fjall/util/logger";import{getErrorMessage as d,maskSensitiveOutput as k}from"@fjall/util";import{composeSdkAbortSignal as h,isAborted as w}from"../../aws/organisations/types.js";import{classifyBucketForPreEmpty as y,emptyS3Bucket as g,isBucketGoneError as l}from"./bucketOps.js";import{STACK_CLEANUP_LOG as B,warnViaCallbacks as b}from"./logging.js";async function $(i,m,o,r){const t={swept:[],failed:[]};for(const e of m){if(w(r))break;const n=await y(i,e,o,r);if(n==="unreadable"){t.failed.push(e);continue}if(n!=="matched")continue;const s=await g(i,e,o,r);if(s==="missing")continue;if(s!=="emptied"){t.failed.push(e);continue}try{await i.send(new p({Bucket:e}),{abortSignal:h(r)})}catch(u){if(l(u))continue;const f=`Could not delete orphaned bucket ${e} after stack delete: ${k(d(u))}`;b(f,o),t.failed.push(e);continue}t.swept.push(e);const c=`Swept orphaned bucket ${e} left behind by the stack delete`;a.info(B,c),o?.onLog?.(c,"info")}return t}export{$ as sweepOrphanedDestroyBuckets};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
|
2
|
+
import type { S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
import { type Result } from "@fjall/generator";
|
|
4
|
+
import type { DeployCallbacks } from "../../types/callbacks.js";
|
|
5
|
+
export interface PreEmptyBucketsSummary {
|
|
6
|
+
/** Buckets carrying a pre-empty tag; the empty pass ran over them (warn-only inside). */
|
|
7
|
+
matched: string[];
|
|
8
|
+
/**
|
|
9
|
+
* Buckets evaluated and left untouched (no pre-empty tag, or tags
|
|
10
|
+
* unreadable). Already-deleted buckets appear in neither list — nothing
|
|
11
|
+
* survives the destroy, so reporting them as retained would mislead.
|
|
12
|
+
*/
|
|
13
|
+
skipped: string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* SDK-empty a live stack's pre-empty-tagged S3 buckets before CFN destroy, so
|
|
17
|
+
* the auto-delete custom resource (where present) is a fast no-op and never
|
|
18
|
+
* hits its Lambda timeout — the S3 quarantine trigger.
|
|
19
|
+
*
|
|
20
|
+
* Per-bucket failures (throttle, AccessDenied, anything) warn via callback and
|
|
21
|
+
* continue; they never produce a Result failure. Failure is reserved for
|
|
22
|
+
* "could not even list stack resources" — callers proceed to destroy on
|
|
23
|
+
* failure too, degrading to today's behaviour.
|
|
24
|
+
*/
|
|
25
|
+
export declare function preEmptyStackBuckets(cfnClient: CloudFormationClient, s3Client: S3Client, stackName: string, callbacks?: DeployCallbacks, abortSignal?: AbortSignal): Promise<Result<PreEmptyBucketsSummary>>;
|
|
26
|
+
/**
|
|
27
|
+
* Extract the marker-tagged buckets a pre-empty pass captured, or [] when the
|
|
28
|
+
* pass failed (the failure already warned via callbacks; destroy proceeds
|
|
29
|
+
* regardless).
|
|
30
|
+
*
|
|
31
|
+
* Call BEFORE the destroy — once CFN reports the stack gone the resource list
|
|
32
|
+
* is unreadable, so the captured list is the only record of which marked
|
|
33
|
+
* buckets the orphan sweep (AC3.3) may touch.
|
|
34
|
+
*/
|
|
35
|
+
export declare function capturedSweepBuckets(preEmptyResult: Result<PreEmptyBucketsSummary>): readonly string[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{success as m,failure as k}from"@fjall/generator";import{logger as h}from"@fjall/util/logger";import{getErrorMessage as n,maskSensitiveOutput as d}from"@fjall/util";import{isAborted as y}from"../../aws/organisations/types.js";import{STACK_NOT_FOUND_PATTERN as g}from"../../types/constants.js";import{classifyBucketForPreEmpty as S,emptyS3Bucket as B}from"./bucketOps.js";import{STACK_CLEANUP_LOG as w,warnViaCallbacks as a}from"./logging.js";import{formatRetainedBucketsMessage as A}from"./messages.js";import{collectStackResources as E}from"./stackResources.js";async function b(i,c,s,o,p){let u;try{u=await E(i,s,e=>e.ResourceType==="AWS::S3::Bucket",e=>e.PhysicalResourceId,p)}catch(e){if(e instanceof Error&&e.message?.includes(g))return h.debug(w,`Stack ${s} does not exist, nothing to pre-empty`),m({matched:[],skipped:[]});const r=`Could not list ${s} resources for the pre-empty pass: ${d(n(e))}`;return a(r,o),k(new Error(r))}const t={matched:[],skipped:[]};for(const e of u){if(y(p))break;try{const r=await S(c,e,o,p);r==="matched"?(t.matched.push(e),await B(c,e,o,p)):(r==="retained"||r==="unreadable")&&t.skipped.push(e)}catch(r){const f=`Pre-empty pass failed for bucket ${e}: ${d(n(r))}`;a(f,o)}}return t.skipped.length>0&&o?.onStackCleanupProgress?.(s,"retained-buckets",{retainedBuckets:[...t.skipped],message:A(s,t.skipped)}),m(t)}function v(i){return i.success?i.data.matched:[]}export{v as capturedSweepBuckets,b as preEmptyStackBuckets};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
|
2
|
+
/** Paginate ListStackResources with a bounded page limit and collect matching resource IDs. */
|
|
3
|
+
export declare function collectStackResources(cfnClient: CloudFormationClient, stackName: string, filter: (resource: {
|
|
4
|
+
ResourceType?: string;
|
|
5
|
+
ResourceStatus?: string;
|
|
6
|
+
}) => boolean, getId: (resource: {
|
|
7
|
+
PhysicalResourceId?: string;
|
|
8
|
+
LogicalResourceId?: string;
|
|
9
|
+
}) => string | undefined, abortSignal?: AbortSignal): Promise<string[]>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ListStackResourcesCommand as S}from"@aws-sdk/client-cloudformation";import{logger as k}from"@fjall/util/logger";import{composeSdkAbortSignal as l,isAborted as p}from"../../aws/organisations/types.js";import{STACK_CLEANUP_LOG as u}from"./logging.js";async function w(n,o,a,m,t){const r=[];let e,f=0;do{if(p(t))break;if(f++>=100){k.warn(u,"Reached 100 page limit listing stack resources",{stackName:o});break}const s=await n.send(new S({StackName:o,NextToken:e}),{abortSignal:l(t)});for(const c of s.StackResourceSummaries??[])if(a(c)){const i=m(c);i&&r.push(i)}e=s.NextToken}while(e);return r}export{w as collectStackResources};
|
|
@@ -1,43 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Stack-cleanup barrel — implementations live in `stackCleanup/<concern>.ts`.
|
|
3
|
+
* This path is the stable import surface for the production consumers
|
|
4
|
+
* (orchestration barrel, cascade/organisation destroy, trail migration,
|
|
5
|
+
* ApplicationStackService); keep every public symbol re-exported here.
|
|
3
6
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* - ROLLBACK_COMPLETE -- creation failed, rollback succeeded
|
|
7
|
-
* - DELETE_FAILED -- deletion already started
|
|
8
|
-
*
|
|
9
|
-
* Never touches UPDATE_ROLLBACK_FAILED (has live resources from previous deploy).
|
|
10
|
-
*
|
|
11
|
-
* Ported from cli/src/services/utils/stackCleanup.ts for deploy-core consumers
|
|
12
|
-
* (webapp worker, CLI via deploy-core).
|
|
13
|
-
*/
|
|
14
|
-
import { S3Client } from "@aws-sdk/client-s3";
|
|
15
|
-
import type { DeployCallbacks } from "../types/callbacks.js";
|
|
16
|
-
import { SAFE_CLEANUP_STATES, isCleanableState } from "../types/constants.js";
|
|
17
|
-
export { SAFE_CLEANUP_STATES, isCleanableState };
|
|
18
|
-
interface StackCleanupCredentials {
|
|
19
|
-
accessKeyId: string;
|
|
20
|
-
secretAccessKey: string;
|
|
21
|
-
sessionToken?: string;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Empty an S3 bucket by deleting all object versions and delete markers.
|
|
25
|
-
* Handles NoSuchBucket gracefully (bucket already gone).
|
|
26
|
-
* Exported for the org-trail migration's member-bucket decommission.
|
|
27
|
-
*/
|
|
28
|
-
export declare function emptyS3Bucket(s3Client: S3Client, bucketName: string, callbacks?: DeployCallbacks): Promise<void>;
|
|
29
|
-
/**
|
|
30
|
-
* Clean up a CloudFormation stack stuck in a failed state.
|
|
31
|
-
* Best-effort: all errors are caught and logged, never throws.
|
|
32
|
-
*
|
|
33
|
-
* Flow:
|
|
34
|
-
* 1. Check stack status -- skip if not in SAFE_CLEANUP_STATES
|
|
35
|
-
* 2. Find and empty S3 buckets blocking deletion
|
|
36
|
-
* 3. Delete the stack
|
|
37
|
-
* 4. Poll for DELETE_COMPLETE (5min timeout, 5s interval)
|
|
38
|
-
* 5. If DELETE_FAILED again, retry once with RetainResources for non-S3 failures
|
|
7
|
+
* Originally ported from cli/src/services/utils/stackCleanup.ts for
|
|
8
|
+
* deploy-core consumers (webapp worker, CLI via deploy-core).
|
|
39
9
|
*/
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
10
|
+
export { SAFE_CLEANUP_STATES, isCleanableState } from "../types/constants.js";
|
|
11
|
+
export { isQuarantineDetail, isRetainedBucketsDetail } from "../types/callbacks.js";
|
|
12
|
+
export { BUCKET_GONE_ERROR_NAMES, PRE_EMPTY_TAG_KEYS, emptyS3Bucket, isBucketGoneError, type EmptyBucketOutcome } from "./stackCleanup/bucketOps.js";
|
|
13
|
+
export { formatQuarantineSuspectedMessage, formatRetainedBucketsMessage } from "./stackCleanup/messages.js";
|
|
14
|
+
export { capturedSweepBuckets, preEmptyStackBuckets, type PreEmptyBucketsSummary } from "./stackCleanup/preEmpty.js";
|
|
15
|
+
export { cleanupFailedStack } from "./stackCleanup/failedStack.js";
|
|
16
|
+
export { sweepOrphanedDestroyBuckets, type OrphanSweepSummary } from "./stackCleanup/orphanSweep.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{SAFE_CLEANUP_STATES as r,isCleanableState as a}from"../types/constants.js";import{isQuarantineDetail as p,isRetainedBucketsDetail as s}from"../types/callbacks.js";import{BUCKET_GONE_ERROR_NAMES as u,PRE_EMPTY_TAG_KEYS as E,emptyS3Bucket as i,isBucketGoneError as m}from"./stackCleanup/bucketOps.js";import{formatQuarantineSuspectedMessage as n,formatRetainedBucketsMessage as f}from"./stackCleanup/messages.js";import{capturedSweepBuckets as B,preEmptyStackBuckets as _}from"./stackCleanup/preEmpty.js";import{cleanupFailedStack as d}from"./stackCleanup/failedStack.js";import{sweepOrphanedDestroyBuckets as R}from"./stackCleanup/orphanSweep.js";export{u as BUCKET_GONE_ERROR_NAMES,E as PRE_EMPTY_TAG_KEYS,r as SAFE_CLEANUP_STATES,B as capturedSweepBuckets,d as cleanupFailedStack,i as emptyS3Bucket,n as formatQuarantineSuspectedMessage,f as formatRetainedBucketsMessage,m as isBucketGoneError,a as isCleanableState,p as isQuarantineDetail,s as isRetainedBucketsDetail,_ as preEmptyStackBuckets,R as sweepOrphanedDestroyBuckets};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{DeleteBucketCommand as f,HeadBucketCommand as y,ListObjectVersionsCommand as
|
|
1
|
+
import{DeleteBucketCommand as f,HeadBucketCommand as y,ListObjectVersionsCommand as k}from"@aws-sdk/client-s3";import{DescribeTrailsCommand as g}from"@aws-sdk/client-cloudtrail";import{DescribeKeyCommand as w,ScheduleKeyDeletionCommand as S}from"@aws-sdk/client-kms";import{success as o,failure as a}from"@fjall/generator";import{getErrorMessage as s,maskSensitiveOutput as c}from"@fjall/util";import{logger as E}from"@fjall/util/logger";import{composeSdkAbortSignal as l,extractErrorName as m}from"../../aws/organisations/types.js";import{emptyS3Bucket as C,isBucketGoneError as b}from"../stackCleanup.js";const p=14,N=new Set(["NotFoundException","KMSInvalidStateException"]);async function D(t,e,n){try{const r=await t.send(new g({trailNameList:[e]}),{abortSignal:l(n)});return o((r.trailList??[]).length>0)}catch(r){return m(r)==="TrailNotFoundException"?o(!1):a(new Error(`Failed to probe trail ${e}: ${c(s(r))}`))}}async function h(t,e,n){try{return await t.send(new y({Bucket:e}),{abortSignal:l(n)}),o(!0)}catch(r){return b(r)?o(!1):a(new Error(`Failed to probe bucket ${e}: ${c(s(r))}`))}}async function K(t,e,n){try{const r=await t.send(new k({Bucket:e,MaxKeys:1}),{abortSignal:l(n)});return(r.Versions??[]).length===0&&(r.DeleteMarkers??[]).length===0}catch(r){return E.debug("memberTrailCleanup",`Emptiness probe failed for ${e}; treating as not proven empty`,{error:c(s(r))}),!1}}async function M(t,e,n){try{const i=(await t.send(new w({KeyId:e}),{abortSignal:l(n)})).KeyMetadata?.KeyState;if(i==="PendingDeletion"||i==="PendingReplicaDeletion")return o(void 0)}catch(r){return m(r)==="NotFoundException"?o(void 0):a(new Error(`Failed to probe CMK: ${c(s(r))}`))}try{return await t.send(new S({KeyId:e,PendingWindowInDays:p}),{abortSignal:l(n)}),o(void 0)}catch(r){return N.has(m(r))?o(void 0):a(new Error(`Failed to schedule CMK deletion: ${c(s(r))}`))}}async function O(t,e,n){const r=await D(t.cloudTrailClient,e.trailName,e.abortSignal);if(!r.success)return a(r.error);if(r.data)return o({outcome:"blocked-trail-still-exists"});const i=await h(t.s3Client,e.bucketName,e.abortSignal);if(!i.success)return a(i.error);if(i.data){if(!await K(t.s3Client,e.bucketName,e.abortSignal)&&e.acknowledgeTrailHistoryLoss!==!0)return o({outcome:"blocked-awaiting-acknowledgement",bucketName:e.bucketName});await C(t.s3Client,e.bucketName,n,e.abortSignal);try{await t.s3Client.send(new f({Bucket:e.bucketName}),{abortSignal:l(e.abortSignal)})}catch(u){if(!b(u))return a(new Error(`Failed to delete bucket ${e.bucketName}: ${c(s(u))}`))}}if(e.keyArn!==void 0&&e.keyArn!==""){const d=await M(t.kmsClient,e.keyArn,e.abortSignal);if(!d.success)return a(d.error)}return o({outcome:"decommissioned"})}export{O as decommissionMemberTrailStorage};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource a root-task unlock acts on. The kind only varies the message
|
|
3
|
+
* wording — both unlock engines share one remediation story (the SCP
|
|
4
|
+
* carve-out covers the five s3/sqs policy-management actions together).
|
|
5
|
+
*/
|
|
6
|
+
export interface UnlockResource {
|
|
7
|
+
kind: "bucket" | "queue";
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Failure copy for an AccessDenied hitting a root session mid-unlock.
|
|
12
|
+
* Coupled value at 2 sites (unlockBucket + unlockQueue): the remediation
|
|
13
|
+
* story must stay identical, so the copy lives here rather than per-engine.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatScpSuspectedFailure(action: string, resource: UnlockResource, accountId: string): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
function n(t,e,o){return`Access denied for the root session calling ${t} on ${e.kind} ${e.name} (account ${o}). If the organisation's SCPs predate the root-task unlock carve-out, DenyRootUserActions may still block the five s3/sqs policy-management actions \u2014 re-deploy the organisation to refresh its SCPs, then retry.`}export{n as formatScpSuspectedFailure};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { STSClient } from "@aws-sdk/client-sts";
|
|
2
|
+
import { type Result } from "@fjall/generator";
|
|
3
|
+
export interface UnlockBucketInput {
|
|
4
|
+
/** Member account holding the quarantined bucket. */
|
|
5
|
+
accountId: string;
|
|
6
|
+
bucketName: string;
|
|
7
|
+
/** Bucket region — also pins the regional STS endpoint requirement. */
|
|
8
|
+
region: string;
|
|
9
|
+
abortSignal?: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* "no-policy" means the bucket carries no policy at all — it is not
|
|
13
|
+
* policy-locked and there is nothing to unlock. Consumers phrase this
|
|
14
|
+
* differently from a real unlock.
|
|
15
|
+
*/
|
|
16
|
+
export type UnlockBucketReport = {
|
|
17
|
+
outcome: "unlocked";
|
|
18
|
+
capturedPolicy: string;
|
|
19
|
+
warning: string;
|
|
20
|
+
} | {
|
|
21
|
+
outcome: "no-policy";
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Remove a stranded deny policy from a quarantined bucket via an STS
|
|
25
|
+
* assume-root session bounded by the S3UnlockBucketPolicy root-task policy.
|
|
26
|
+
*
|
|
27
|
+
* Ordering is load-bearing: the stranded policy is captured with
|
|
28
|
+
* GetBucketPolicy BEFORE DeleteBucketPolicy destroys it — the captured JSON
|
|
29
|
+
* is the only forensic record of what the policy carried, and the operator
|
|
30
|
+
* needs it to re-put legitimate statements.
|
|
31
|
+
*
|
|
32
|
+
* The STS client must use management-account or delegated-admin credentials
|
|
33
|
+
* and carry an explicit region (assumeRootForTask enforces both). Session
|
|
34
|
+
* credentials are credential VALUES — they exist only inside this function
|
|
35
|
+
* and never reach a sink; error strings are masked.
|
|
36
|
+
*/
|
|
37
|
+
export declare function unlockBucket(stsClient: STSClient, input: UnlockBucketInput): Promise<Result<UnlockBucketReport>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{S3Client as w,GetBucketPolicyCommand as b,DeleteBucketPolicyCommand as h}from"@aws-sdk/client-s3";import{success as l,failure as t}from"@fjall/generator";import{getErrorMessage as k,maskSensitiveOutput as m}from"@fjall/util";import{composeSdkAbortSignal as y,extractErrorName as p,isAccessDenied as f}from"../../aws/organisations/types.js";import{assumeRootForTask as E,MAX_ROOT_SESSION_SECONDS as B}from"../../aws/sts/assumeRoot.js";import{formatScpSuspectedFailure as S}from"./scpRemediation.js";const P="arn:aws:iam::aws:policy/root-task/S3UnlockBucketPolicy";function N(n){return`The unlock deleted the ENTIRE bucket policy on ${n} \u2014 including any enforceSSL deny statements and legitimate access grants it carried, not just the stranded deny. Review the captured policy and re-put sane statements (TLS-only deny, intended access grants) before relying on the bucket.`}async function L(n,g){const{accountId:c,bucketName:e,region:i,abortSignal:a}=g;if(i==="")return t(new Error("A bucket region is required to unlock a bucket."));const u=await E(n,{targetAccountId:c,taskPolicyArn:P,durationSeconds:B,abortSignal:a});if(!u.success)return t(u.error);const d=new w({region:i,credentials:u.data.credentials});let o;try{o=(await d.send(new b({Bucket:e}),{abortSignal:y(a)})).Policy}catch(r){const s=p(r);return s==="NoSuchBucketPolicy"?l({outcome:"no-policy"}):s==="NoSuchBucket"?t(new Error(`Bucket ${e} was not found in region ${i} \u2014 check the bucket name and region.`)):f(s)?t(new Error(S("GetBucketPolicy",{kind:"bucket",name:e},c))):t(new Error(`Failed to capture the bucket policy on ${e}: ${m(k(r))}`))}if(o===void 0||o==="")return l({outcome:"no-policy"});try{await d.send(new h({Bucket:e}),{abortSignal:y(a)})}catch(r){return f(p(r))?t(new Error(S("DeleteBucketPolicy",{kind:"bucket",name:e},c))):t(new Error(`Captured the bucket policy on ${e} but failed to delete it: ${m(k(r))}`))}return l({outcome:"unlocked",capturedPolicy:o,warning:N(e)})}export{L as unlockBucket};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { STSClient } from "@aws-sdk/client-sts";
|
|
2
|
+
import { type Result } from "@fjall/generator";
|
|
3
|
+
export interface UnlockQueueInput {
|
|
4
|
+
/** Member account holding the policy-locked queue. */
|
|
5
|
+
accountId: string;
|
|
6
|
+
queueName: string;
|
|
7
|
+
/** Queue region — also pins the regional STS endpoint requirement. */
|
|
8
|
+
region: string;
|
|
9
|
+
abortSignal?: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* "no-policy" means the queue carries no resource policy at all — it is not
|
|
13
|
+
* policy-locked and there is nothing to unlock. Consumers phrase this
|
|
14
|
+
* differently from a real unlock.
|
|
15
|
+
*/
|
|
16
|
+
export type UnlockQueueReport = {
|
|
17
|
+
outcome: "unlocked";
|
|
18
|
+
capturedPolicy: string;
|
|
19
|
+
warning: string;
|
|
20
|
+
} | {
|
|
21
|
+
outcome: "no-policy";
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Remove a stranded deny policy from a locked SQS queue via an STS
|
|
25
|
+
* assume-root session bounded by the SQSUnlockQueuePolicy root-task policy.
|
|
26
|
+
*
|
|
27
|
+
* Ordering is load-bearing: the stranded policy is captured with
|
|
28
|
+
* GetQueueAttributes BEFORE SetQueueAttributes clears it — the captured JSON
|
|
29
|
+
* is the only forensic record of what the policy carried, and the operator
|
|
30
|
+
* needs it to re-put legitimate statements. SQS has no DeleteQueuePolicy
|
|
31
|
+
* API; setting Policy to the empty string is the documented removal path.
|
|
32
|
+
*
|
|
33
|
+
* The queue URL is composed deterministically from region/account/name
|
|
34
|
+
* because GetQueueUrl is not in SQSUnlockQueuePolicy. This assumes the
|
|
35
|
+
* standard aws partition URL shape (sqs.<region>.amazonaws.com) — GovCloud
|
|
36
|
+
* and CN partitions are out of scope for the unlock engines.
|
|
37
|
+
*
|
|
38
|
+
* The STS client must use management-account or delegated-admin credentials
|
|
39
|
+
* and carry an explicit region (assumeRootForTask enforces both). Session
|
|
40
|
+
* credentials are credential VALUES — they exist only inside this function
|
|
41
|
+
* and never reach a sink; error strings are masked.
|
|
42
|
+
*/
|
|
43
|
+
export declare function unlockQueue(stsClient: STSClient, input: UnlockQueueInput): Promise<Result<UnlockQueueReport>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{SQSClient as A,GetQueueAttributesCommand as Q,SetQueueAttributesCommand as N}from"@aws-sdk/client-sqs";import{success as d,failure as t}from"@fjall/generator";import{getErrorMessage as S,maskSensitiveOutput as p}from"@fjall/util";import{composeSdkAbortSignal as f,extractErrorName as w,isAccessDenied as g}from"../../aws/organisations/types.js";import{assumeRootForTask as q,MAX_ROOT_SESSION_SECONDS as b}from"../../aws/sts/assumeRoot.js";import{formatScpSuspectedFailure as y}from"./scpRemediation.js";const h="arn:aws:iam::aws:policy/root-task/SQSUnlockQueuePolicy",k=new Set(["QueueDoesNotExist","AWS.SimpleQueueService.NonExistentQueue"]);function O(i){return`The unlock cleared the ENTIRE queue policy on ${i} \u2014 including any legitimate access grants it carried (cross-account SendMessage permissions, S3/SNS/EventBridge event-source allow statements), not just the stranded deny. Review the captured policy and re-put sane statements before relying on the queue.`}async function v(i,E){const{accountId:o,queueName:e,region:n,abortSignal:s}=E;if(n==="")return t(new Error("A queue region is required to unlock a queue."));const a=await q(i,{targetAccountId:o,taskPolicyArn:h,durationSeconds:b,abortSignal:s});if(!a.success)return t(a.error);const c=new A({region:n,credentials:a.data.credentials}),l=`https://sqs.${n}.amazonaws.com/${o}/${e}`;let u;try{u=(await c.send(new Q({QueueUrl:l,AttributeNames:["Policy"]}),{abortSignal:f(s)})).Attributes?.Policy}catch(r){const m=w(r);return k.has(m)?t(new Error(`Queue ${e} was not found in region ${n} \u2014 check the queue name and region.`)):g(m)?t(new Error(y("GetQueueAttributes",{kind:"queue",name:e},o))):t(new Error(`Failed to capture the queue policy on ${e}: ${p(S(r))}`))}if(u===void 0||u==="")return d({outcome:"no-policy"});try{await c.send(new N({QueueUrl:l,Attributes:{Policy:""}}),{abortSignal:f(s)})}catch(r){return g(w(r))?t(new Error(y("SetQueueAttributes",{kind:"queue",name:e},o))):t(new Error(`Captured the queue policy on ${e} but failed to clear it: ${p(S(r))}`))}return d({outcome:"unlocked",capturedPolicy:u,warning:O(e)})}export{v as unlockQueue};
|