@fjall/deploy-core 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/.minified +1 -1
  2. package/dist/src/aws/index.d.ts +6 -2
  3. package/dist/src/aws/index.js +1 -1
  4. package/dist/src/aws/organisations/accountGlobals.d.ts +40 -0
  5. package/dist/src/aws/organisations/accountGlobals.js +1 -0
  6. package/dist/src/aws/organisations/accounts.js +1 -1
  7. package/dist/src/aws/organisations/importedAccounts.d.ts +16 -0
  8. package/dist/src/aws/organisations/importedAccounts.js +1 -0
  9. package/dist/src/aws/organisations/index.d.ts +3 -1
  10. package/dist/src/aws/organisations/index.js +1 -1
  11. package/dist/src/aws/organisations/rootAccess.d.ts +27 -0
  12. package/dist/src/aws/organisations/rootAccess.js +3 -0
  13. package/dist/src/aws/organisations/serviceAccess.d.ts +6 -0
  14. package/dist/src/aws/organisations/serviceAccess.js +1 -1
  15. package/dist/src/aws/organisations/types.d.ts +12 -0
  16. package/dist/src/aws/organisations/types.js +1 -1
  17. package/dist/src/aws/sts/assumeRoot.d.ts +46 -0
  18. package/dist/src/aws/sts/assumeRoot.js +1 -0
  19. package/dist/src/aws/targetReadiness.d.ts +70 -0
  20. package/dist/src/aws/targetReadiness.js +1 -0
  21. package/dist/src/aws/targetSetAdvisory.d.ts +24 -0
  22. package/dist/src/aws/targetSetAdvisory.js +1 -0
  23. package/dist/src/index.d.ts +13 -13
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/orchestration/applicationDeploy.js +1 -1
  26. package/dist/src/orchestration/applicationDestroy.js +1 -1
  27. package/dist/src/orchestration/cascadeDestroyHelpers.d.ts +12 -1
  28. package/dist/src/orchestration/cascadeDestroyHelpers.js +1 -1
  29. package/dist/src/orchestration/cascadeHelpers.d.ts +21 -6
  30. package/dist/src/orchestration/cascadeHelpers.js +1 -1
  31. package/dist/src/orchestration/contextHelpers.d.ts +17 -2
  32. package/dist/src/orchestration/contextHelpers.js +1 -1
  33. package/dist/src/orchestration/index.d.ts +8 -3
  34. package/dist/src/orchestration/index.js +1 -1
  35. package/dist/src/orchestration/organisationDeploy/cascadeExecution.d.ts +28 -0
  36. package/dist/src/orchestration/organisationDeploy/cascadeExecution.js +1 -0
  37. package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +40 -0
  38. package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -0
  39. package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.d.ts +8 -0
  40. package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -0
  41. package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +12 -0
  42. package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -0
  43. package/dist/src/orchestration/organisationDeploy/resolveCascadeAccounts.d.ts +15 -0
  44. package/dist/src/orchestration/organisationDeploy/resolveCascadeAccounts.js +1 -0
  45. package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.d.ts +11 -0
  46. package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -0
  47. package/dist/src/orchestration/organisationDeploy/trailReconciliation.d.ts +21 -0
  48. package/dist/src/orchestration/organisationDeploy/trailReconciliation.js +1 -0
  49. package/dist/src/orchestration/organisationDeploy.d.ts +1 -5
  50. package/dist/src/orchestration/organisationDeploy.js +1 -5
  51. package/dist/src/orchestration/organisationDestroy.d.ts +1 -1
  52. package/dist/src/orchestration/organisationDestroy.js +2 -2
  53. package/dist/src/orchestration/organisationSetup.d.ts +18 -2
  54. package/dist/src/orchestration/organisationSetup.js +1 -1
  55. package/dist/src/orchestration/stackCleanup/bucketOps.d.ts +54 -0
  56. package/dist/src/orchestration/stackCleanup/bucketOps.js +1 -0
  57. package/dist/src/orchestration/stackCleanup/failedStack.d.ts +34 -0
  58. package/dist/src/orchestration/stackCleanup/failedStack.js +1 -0
  59. package/dist/src/orchestration/stackCleanup/logging.d.ts +9 -0
  60. package/dist/src/orchestration/stackCleanup/logging.js +1 -0
  61. package/dist/src/orchestration/stackCleanup/messages.d.ts +16 -0
  62. package/dist/src/orchestration/stackCleanup/messages.js +1 -0
  63. package/dist/src/orchestration/stackCleanup/orphanSweep.d.ts +25 -0
  64. package/dist/src/orchestration/stackCleanup/orphanSweep.js +1 -0
  65. package/dist/src/orchestration/stackCleanup/preEmpty.d.ts +35 -0
  66. package/dist/src/orchestration/stackCleanup/preEmpty.js +1 -0
  67. package/dist/src/orchestration/stackCleanup/stackResources.d.ts +9 -0
  68. package/dist/src/orchestration/stackCleanup/stackResources.js +1 -0
  69. package/dist/src/orchestration/stackCleanup.d.ts +13 -40
  70. package/dist/src/orchestration/stackCleanup.js +1 -1
  71. package/dist/src/orchestration/trailMigration/memberTrailCleanup.js +1 -1
  72. package/dist/src/orchestration/unlock/scpRemediation.d.ts +15 -0
  73. package/dist/src/orchestration/unlock/scpRemediation.js +1 -0
  74. package/dist/src/orchestration/unlock/unlockBucket.d.ts +37 -0
  75. package/dist/src/orchestration/unlock/unlockBucket.js +1 -0
  76. package/dist/src/orchestration/unlock/unlockQueue.d.ts +43 -0
  77. package/dist/src/orchestration/unlock/unlockQueue.js +1 -0
  78. package/dist/src/services/application/ApplicationStackService.d.ts +9 -10
  79. package/dist/src/services/application/ApplicationStackService.js +1 -1
  80. package/dist/src/services/application/applicationStackHelpers.d.ts +13 -8
  81. package/dist/src/services/application/applicationStackHelpers.js +3 -3
  82. package/dist/src/steps/stepRegistry.js +1 -1
  83. package/dist/src/types/FjallState.d.ts +7 -0
  84. package/dist/src/types/FjallState.js +1 -1
  85. package/dist/src/types/callbacks.d.ts +43 -2
  86. package/dist/src/types/callbacks.js +1 -0
  87. package/dist/src/types/deploymentEventSchema.d.ts +9 -0
  88. package/dist/src/types/deploymentEventSchema.js +1 -1
  89. package/dist/src/types/index.d.ts +5 -10
  90. package/dist/src/types/index.js +1 -1
  91. package/dist/src/types/orgConfig.d.ts +8 -2
  92. package/dist/src/types/params.d.ts +12 -0
  93. package/dist/src/types/patternDetection.d.ts +0 -25
  94. package/dist/src/types/patternDetection.js +1 -1
  95. package/dist/src/types/stepDefinitions.d.ts +2 -0
  96. package/dist/src/types/stepDefinitions.js +1 -1
  97. package/package.json +6 -4
@@ -1,3 +1,3 @@
1
- import{success as O,failure as P}from"@fjall/generator";import{maskSensitiveOutput as g,getErrorMessage as $,mapSettledWithConcurrency as M}from"@fjall/util";import{stubCallerIdentity as _}from"../types/deployment/index.js";import{ORGANISATION_TYPES as k,getOrganisationStackName as b}from"../types/operations.js";import{CdkContextBuilder as v}from"../services/supporting/CdkContextBuilder.js";import{buildParamsContext as L,synthOrFail as x,forwardOutput as G,forwardResourceProgress as Y}from"./contextHelpers.js";import{destroyCascadeAccount as T}from"./cascadeDestroyHelpers.js";import{partitionAccounts as j,buildRegionList as U,buildAccountRegionPairs as B,CASCADE_MAX_CONCURRENCY as F}from"./cascadeHelpers.js";import{projectScalarSummary as W}from"./cascadeSummary.js";import{STEP_IDS as X}from"../types/stepDefinitions.js";const D=X.ORG_DESTROY,I="Destroying organisation infrastructure";async function et(r,e,i){const h=Date.now(),{callbacks:t}=r,l=r.orgConfig?.providerAccounts??[],f=r.orgConfig?.primaryRegion??e.awsProvider.getRegion(),m=U(r.orgConfig),{platformAccount:u,memberAccounts:p}=j(l),N=r.options?.cascade!==!1;t.onLog?.(`Destroying organisation infrastructure (${l.length} accounts, ${m.length} region(s))`,"info");const n=[],y=[],R=[];let w;if(N){t.onCascadeStart?.();const E=Date.now();if(p.length>0){t.onCascadePhaseStart?.("accounts");const a=B(p,m),d=await M(a,F,({account:s,region:c})=>T(r,e,i,s,"account",c,t));for(let s=0;s<d.length;s++){const c=d[s],o=a[s];if(!(!c||!o))if(c.status==="fulfilled")if(c.value.success)y.push(`Account-${o.account.name}-${o.region}`),R.push({accountId:o.account.id,accountName:o.account.name,region:o.region,result:"succeeded",durationMs:c.value.duration});else{const C=g(c.value.error??"Unknown error");R.push({accountId:o.account.id,accountName:o.account.name,region:o.region,result:"failed",durationMs:c.value.duration,error:C}),n.push({accountId:o.account.id,error:C})}else{const C=g($(c.reason));R.push({accountId:o.account.id,accountName:o.account.name,region:o.region,result:"failed",durationMs:0,error:C}),n.push({accountId:o.account.id,error:C})}}}if(u){t.onCascadePhaseStart?.("platform");const a=await T(r,e,i,u,"platform",f,t);if(a.success)y.push("Platform"),w={accountId:u.id,result:"succeeded",durationMs:a.duration};else{const d=g(a.error??"Platform destroy failed");n.push({accountId:u.id,error:d}),w={accountId:u.id,result:"failed",durationMs:a.duration,error:d}}}const S={members:R,...w!==void 0?{platform:w}:{},domainsDeployed:!1,errors:n,totalDurationMs:Date.now()-E};if(t.onCascadeComplete?.(W(S)),t.onCascadeLedger?.(S),n.length>0){const a=n.map(s=>` ${s.accountId}: ${s.error}`).join(`
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?.(g(d.message),"warn")}}if(n.length>0)return t.onLog?.("Skipping organisation root stack destroy due to cascade failures","warn"),O({target:i.target,deploymentType:"organisation",stacksDestroyed:y,durationMs:Date.now()-h,warnings:n.map(S=>g(`${S.accountId}: ${S.error}`))});t.onStepStart?.(D,I,0,1);const A=await q(r,e,i,f,t);if(A.success)y.push("Organisation"),t.onStepComplete?.(D,I,"completed",0,1);else return t.onStepComplete?.(D,I,"error",0,1),P(A.error);return O({target:i.target,deploymentType:"organisation",stacksDestroyed:y,durationMs:Date.now()-h})}async function q(r,e,i,h,t){const l=v.buildDeploymentContext({deployType:"organisation",target:i.target,path:i.path,region:h,callerIdentity:_(e.awsProvider.getAccountId()),...L({orgConfig:r.orgConfig,identity:r.identity})},{verbose:r.options?.verbose},r.orgConfig),f=b(k.ORGANISATION);t.onLog?.("Synthesising organisation infrastructure\u2026","info");const m=await x(e,l,t,"Organisation synth failed");if(!m.success)return m;t.onLog?.(`Destroying ${f} stack\u2026`,"info");const u=await e.cdkService.runCdkDestroy(l,f,G(t),Y(t),e.awsProvider,!0);if(!u.success){const p=new Error(g(`Organisation destroy failed: ${u.error}`));return t.onError?.(p),P(p)}return O(void 0)}export{et as destroyOrganisation};
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
- export type OrgSetupPhase = "create-organisation" | "enable-policies" | "enable-service-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";
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 13 phases sequentially. Non-fatal phase failures are recorded
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 A,failure as h}from"@fjall/generator";import{OrganizationsClient as K}from"@aws-sdk/client-organizations";import{RAMClient as W}from"@aws-sdk/client-ram";import{CloudFormationClient as B}from"@aws-sdk/client-cloudformation";import{EC2Client as $}from"@aws-sdk/client-ec2";import{BackupClient as z}from"@aws-sdk/client-backup";import{CostExplorerClient as G}from"@aws-sdk/client-cost-explorer";import{SSOAdminClient as q}from"@aws-sdk/client-sso-admin";import{ensureOrganisationExists as H}from"../aws/organisations/organisation.js";import{enablePolicyTypes as J}from"../aws/organisations/policies.js";import{enableServiceAccess as Q}from"../aws/organisations/serviceAccess.js";import{enableRamSharing as V}from"../aws/organisations/ram.js";import{activateTrustedAccess as X}from"../aws/organisations/trustedAccess.js";import{enableIpamDelegatedAdmin as Y}from"../aws/organisations/ipam.js";import{updateBackupGlobalSettings as Z}from"../aws/organisations/backup.js";import{listAccounts as U,createAccount as _}from"../aws/organisations/accounts.js";import{ensureOrganisationalUnitsExist as b,placeAccountsInOUs as k,buildAccountToOUMap as ee}from"../aws/organisations/organisationalUnits.js";import{activateCostAllocationTags as te}from"../aws/organisations/costAllocation.js";import{checkIdentityCentreStatus as ne}from"../aws/organisations/identityCentre.js";import{registerSecurityDelegates as oe}from"../aws/organisations/delegatedAdmin.js";import{isOULeaf as re}from"../aws/organisations/types.js";async function Ne(t,n,e,a){const o=[],i=[],r=[],m=[];let s;const c=t.getClient(K),R=t.getClient(W),x=t.getClient(B),T=t.getClient($),M=t.getClient(z),L=t.getClient(G),F=t.getClient(q);e?.onPhaseStart?.("create-organisation"),e?.onProgress?.("Ensuring AWS Organisation exists");const f=await H(c);if(!f.success)return e?.onError?.("create-organisation",f.error),e?.onPhaseComplete?.("create-organisation","error"),h(f.error);const{orgId:j,rootId:w,managementAccountId:D}=f.data;if(e?.onPhaseComplete?.("create-organisation","completed"),o.push("create-organisation"),await p("enable-policies",()=>(e?.onProgress?.("Enabling organisation policy types"),J(c,w,{abortSignal:a})),o,r,e),await p("enable-service-access",()=>(e?.onProgress?.("Enabling AWS service access"),Q(c)),o,r,e),await p("enable-ram-sharing",()=>(e?.onProgress?.("Enabling RAM sharing"),V(R)),o,r,e),await p("activate-trusted-access",()=>(e?.onProgress?.("Activating CloudFormation trusted access"),X(x)),o,r,e),n.platformAccountId){const u=n.platformAccountId;await p("enable-ipam",()=>(e?.onProgress?.("Enabling IPAM delegated administrator"),Y(T,u)),o,r,e)}await p("configure-backup",()=>(e?.onProgress?.("Updating backup global settings"),Z(M)),o,r,e),e?.onPhaseStart?.("create-accounts"),e?.onProgress?.("Checking for missing accounts");const P=await se(c,n.accounts,m,e,a);let I=[];P.success?(I=P.data,o.push("create-accounts"),e?.onPhaseComplete?.("create-accounts","completed")):C("create-accounts",P.error,r,e);let l={};e?.onPhaseStart?.("create-organisational-units"),e?.onProgress?.("Ensuring organisational units exist");const y=await b(c,w,n.organisationalUnits);y.success?(l=y.data,o.push("create-organisational-units"),e?.onPhaseComplete?.("create-organisational-units","completed")):C("create-organisational-units",y.error,r,e);const d=Array.isArray(n.organisationalUnits)?void 0:n.organisationalUnits,S=n.accountPlacements??[],E=S.length===0&&d!==void 0?ie(d,I,D):S;if(Object.keys(l).length===0)g("place-accounts",i,e);else if(n.accountPlacements===void 0&&d===void 0){const u=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:u.message}),e?.onPhaseStart?.("place-accounts"),e?.onError?.("place-accounts",u),e?.onPhaseComplete?.("place-accounts","error")}else if(E.length===0)g("place-accounts",i,e);else{const u=d?ee(d,l):void 0;await p("place-accounts",()=>(e?.onProgress?.("Placing accounts in organisational units"),k(c,l,E,u)),o,r,e)}const O=n.costAllocationTags??[];if(O.length>0?await p("activate-cost-tags",()=>(e?.onProgress?.("Activating cost allocation tags"),te(L,O.map(u=>({TagKey:u})))),o,r,e):g("activate-cost-tags",i,e),n.skipIdentityCentre)g("check-identity-centre",i,e);else{e?.onPhaseStart?.("check-identity-centre"),e?.onProgress?.("Checking Identity Centre status");const u=await ne(F);u.success?(s=u.data.enabled?"enabled":"not-enabled",o.push("check-identity-centre"),e?.onPhaseComplete?.("check-identity-centre","completed")):C("check-identity-centre",u.error,r,e)}const v=n.securityDelegateAccountId;return v?await p("register-security-delegates",()=>(e?.onProgress?.("Registering security service delegated administrators"),oe(c,v)),o,r,e):g("register-security-delegates",i,e),A({organisationId:j,createdAccounts:m,identityCentreStatus:s,phasesCompleted:o,phasesSkipped:i,errors:r})}function C(t,n,e,a){e.push({phase:t,error:n.message}),a?.onError?.(t,n),a?.onPhaseComplete?.(t,"error")}function g(t,n,e){n.push(t),e?.onPhaseStart?.(t),e?.onPhaseComplete?.(t,"skipped")}async function p(t,n,e,a,o){o?.onPhaseStart?.(t);const i=await n();i.success?(e.push(t),o?.onPhaseComplete?.(t,"completed")):C(t,i.error,a,o)}async function se(t,n,e,a,o){const i=await U(t);if(!i.success)return h(i.error);const r=new Set(i.data.map(s=>s.Name?.toLowerCase()).filter(s=>s!==void 0));for(const s of n){if(r.has(s.name.toLowerCase()))continue;a?.onProgress?.(`Account "${s.name}" is not yet a member of this AWS Organisation. Fjall will create a new account (with a new account ID). If "${s.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 c=await _(t,s.name,s.email,void 0,void 0,o);if(!c.success)return h(c.error);e.push({name:c.data.accountName,accountId:c.data.accountId})}if(e.length===0)return A(i.data);const m=await U(t);return m.success?A(m.data):h(m.error)}function N(t){const n=[];for(const[e,a]of Object.entries(t))if(re(a))for(const o of a)n.push({accountName:o,placementKey:e});else n.push(...N(a));return n}function ie(t,n,e){const a=N(t),o=new Map;for(const r of n){const m=r.Name?.toLowerCase();m&&r.Id&&o.set(m,r)}const i=[];for(const{accountName:r,placementKey:m}of a){const s=o.get(r.toLowerCase());!s?.Id||!s.Name||e!==void 0&&s.Id===e||i.push({id:s.Id,name:s.Name,environment:m})}return i}export{Ne as runOrganisationSetup};
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
- * Utility functions for cleaning up CloudFormation stacks stuck in failed states.
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
- * 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
- * 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 declare function cleanupFailedStack(stackName: string, region: string, credentials: StackCleanupCredentials, options?: {
41
- timeoutMs?: number;
42
- pollMs?: number;
43
- }, callbacks?: DeployCallbacks): Promise<void>;
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{CloudFormationClient as $,DescribeStacksCommand as k,DeleteStackCommand as C,ListStackResourcesCommand as _}from"@aws-sdk/client-cloudformation";import{S3Client as m,ListObjectVersionsCommand as h,DeleteObjectsCommand as A}from"@aws-sdk/client-s3";import{NodeHttpHandler as f}from"@smithy/node-http-handler";import{logger as o}from"@fjall/util/logger";import{getErrorMessage as S,maskSensitiveOutput as w,sleep as M}from"@fjall/util";import{STACK_NOT_FOUND_PATTERN as g,SAFE_CLEANUP_STATES as P,isCleanableState as T}from"../types/constants.js";const L=1e3;async function R(e,t,n){let d,s;n?.onStackCleanupProgress?.(t,"emptying-bucket");const u=1e3;let l=0;for(;l++<u;){let r;try{r=await e.send(new h({Bucket:t,KeyMarker:d,VersionIdMarker:s}))}catch(c){if(c instanceof Error&&(c.name==="NoSuchBucket"||c.message?.includes("NoSuchBucket"))){o.debug("stackCleanup",`Bucket ${t} no longer exists, skipping`);return}const E=`Unexpected error emptying bucket ${t}: ${w(S(c))}`;o.warn("stackCleanup",E),n?.onLog?.(E,"warn");return}const i=[...r.Versions??[],...r.DeleteMarkers??[]];if(i.length===0)break;for(let c=0;c<i.length;c+=L){const E=i.slice(c,c+L);try{await e.send(new A({Bucket:t,Delete:{Objects:E.map(a=>({Key:a.Key,VersionId:a.VersionId})),Quiet:!0}}))}catch(a){const p=`Failed to delete batch from ${t}: ${w(S(a))}`;o.warn("stackCleanup",p),n?.onLog?.(p,"warn")}}if(!r.IsTruncated)break;d=r.NextKeyMarker,s=r.NextVersionIdMarker}if(l>u){const r=`Bucket ${t} reached ${u} page limit \u2014 some objects may remain`;o.warn("stackCleanup",r),n?.onLog?.(r,"warn")}o.debug("stackCleanup",`Emptied bucket ${t}`)}async function y(e,t,n,d){const s=[];let u,r=0;do{if(r++>=100){o.warn("stackCleanup","Reached 100 page limit listing stack resources",{stackName:t});break}const i=await e.send(new _({StackName:t,NextToken:u}));for(const c of i.StackResourceSummaries??[])if(n(c)){const E=d(c);E&&s.push(E)}u=i.NextToken}while(u);return s}async function F(e,t){return y(e,t,n=>n.ResourceType==="AWS::S3::Bucket"&&n.ResourceStatus==="DELETE_FAILED",n=>n.PhysicalResourceId)}async function H(e,t,n,d,s){const u=d?.timeoutMs??3e5,l=d?.pollMs??5e3;try{const r=new $({region:t,credentials:n,requestHandler:new f({requestTimeout:15e3})});let i;try{i=(await r.send(new k({StackName:e}))).Stacks?.[0]?.StackStatus}catch(a){if(a instanceof Error&&a.message?.includes(g)){o.debug("stackCleanup",`Stack ${e} does not exist, no cleanup needed`);return}o.warn("stackCleanup",`Failed to check stack status: ${w(S(a))}`,{stackName:e,region:t});return}if(!i||!T(i)){o.debug("stackCleanup",`Stack ${e} status ${i??"unknown"} is not cleanable, skipping`);return}o.warn("stackCleanup",`Cleaning up ${e} stack in ${i} state`,{region:t}),s?.onStackCleanupProgress?.(e,"deleting-stack");const c=new m({region:t,credentials:n,requestHandler:new f({requestTimeout:15e3})});try{const a=await F(r,e);for(const p of a)o.warn("stackCleanup",`Emptying bucket ${p}`,{region:t}),await R(c,p,s)}catch(a){const p=`Failed to empty S3 buckets: ${w(S(a))}`;o.warn("stackCleanup",p,{stackName:e,region:t}),s?.onLog?.(p,"warn")}await r.send(new C({StackName:e})),s?.onStackCleanupProgress?.(e,"waiting");const E=await D(r,e,u,l);if(E==="DELETE_COMPLETE"){o.warn("stackCleanup",`${e} stack deleted successfully`,{region:t}),s?.onStackCleanupProgress?.(e,"complete");return}if(E==="DELETE_FAILED"){o.warn("stackCleanup",`${e} still in DELETE_FAILED, retrying with RetainResources`,{region:t});const a=await I(r,e);if(a.length===0)o.warn("stackCleanup",`${e} in DELETE_FAILED but no non-bucket resources to retain \u2014 cannot retry`,{region:t}),s?.onStackCleanupProgress?.(e,"error");else{await r.send(new C({StackName:e,RetainResources:a}));const p=await D(r,e,u,l);p==="DELETE_COMPLETE"?(o.warn("stackCleanup",`${e} stack deleted on retry (retained: ${a.join(", ")})`,{region:t}),s?.onStackCleanupProgress?.(e,"complete")):(o.warn("stackCleanup",`${e} stack still not deleted after retry: ${p}`,{region:t}),s?.onStackCleanupProgress?.(e,"error"))}}}catch(r){o.warn("stackCleanup",`Stack cleanup failed: ${w(S(r))}`,{stackName:e,region:t}),s?.onStackCleanupProgress?.(e,"error")}}async function D(e,t,n,d){const s=Date.now();for(;Date.now()-s<n;){await M(d);try{const l=(await e.send(new k({StackName:t}))).Stacks?.[0]?.StackStatus;if(!l||l==="DELETE_COMPLETE")return"DELETE_COMPLETE";if(l==="DELETE_FAILED")return"DELETE_FAILED";o.debug("stackCleanup",`Waiting for ${t}: ${l}`)}catch(u){if(u instanceof Error&&u.message?.includes(g))return"DELETE_COMPLETE";throw o.debug("stackCleanup",`Unexpected error polling ${t}: ${S(u)}`),u}}return"TIMEOUT"}async function I(e,t){return y(e,t,n=>n.ResourceStatus==="DELETE_FAILED"&&n.ResourceType!=="AWS::S3::Bucket",n=>n.LogicalResourceId)}export{P as SAFE_CLEANUP_STATES,H as cleanupFailedStack,R as emptyS3Bucket,T as isCleanableState};
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 E}from"@aws-sdk/client-s3";import{DescribeTrailsCommand as S}from"@aws-sdk/client-cloudtrail";import{DescribeKeyCommand as k,ScheduleKeyDeletionCommand as w}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 g}from"@fjall/util/logger";import{SDK_TIMEOUT_MS as N,extractErrorName as u}from"../../aws/organisations/types.js";import{emptyS3Bucket as C}from"../stackCleanup.js";const p=14,b=new Set(["NoSuchBucket","NotFound","404"]),h=new Set(["NotFoundException","KMSInvalidStateException"]);function d(t){const e=AbortSignal.timeout(N);return t!==void 0?AbortSignal.any([t,e]):e}async function D(t,e,n){try{const r=await t.send(new S({trailNameList:[e]}),{abortSignal:d(n)});return o((r.trailList??[]).length>0)}catch(r){return u(r)==="TrailNotFoundException"?o(!1):a(new Error(`Failed to probe trail ${e}: ${c(s(r))}`))}}async function K(t,e,n){try{return await t.send(new y({Bucket:e}),{abortSignal:d(n)}),o(!0)}catch(r){return b.has(u(r))?o(!1):a(new Error(`Failed to probe bucket ${e}: ${c(s(r))}`))}}async function M(t,e,n){try{const r=await t.send(new E({Bucket:e,MaxKeys:1}),{abortSignal:d(n)});return(r.Versions??[]).length===0&&(r.DeleteMarkers??[]).length===0}catch(r){return g.debug("memberTrailCleanup",`Emptiness probe failed for ${e}; treating as not proven empty`,{error:c(s(r))}),!1}}async function _(t,e,n){try{const i=(await t.send(new k({KeyId:e}),{abortSignal:d(n)})).KeyMetadata?.KeyState;if(i==="PendingDeletion"||i==="PendingReplicaDeletion")return o(void 0)}catch(r){return u(r)==="NotFoundException"?o(void 0):a(new Error(`Failed to probe CMK: ${c(s(r))}`))}try{return await t.send(new w({KeyId:e,PendingWindowInDays:p}),{abortSignal:d(n)}),o(void 0)}catch(r){return h.has(u(r))?o(void 0):a(new Error(`Failed to schedule CMK deletion: ${c(s(r))}`))}}async function R(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 K(t.s3Client,e.bucketName,e.abortSignal);if(!i.success)return a(i.error);if(i.data){if(!await M(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);try{await t.s3Client.send(new f({Bucket:e.bucketName}),{abortSignal:d(e.abortSignal)})}catch(m){if(!b.has(u(m)))return a(new Error(`Failed to delete bucket ${e.bucketName}: ${c(s(m))}`))}}if(e.keyArn!==void 0&&e.keyArn!==""){const l=await _(t.kmsClient,e.keyArn,e.abortSignal);if(!l.success)return a(l.error)}return o({outcome:"decommissioned"})}export{R as decommissionMemberTrailStorage};
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};