@fjall/deploy-core 2.12.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/cloudtrail/orgTrailDelivery.d.ts +44 -0
- package/dist/src/aws/cloudtrail/orgTrailDelivery.js +1 -0
- 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.d.ts +3 -1
- package/dist/src/aws/organisations/accounts.js +1 -1
- package/dist/src/aws/organisations/backup.d.ts +2 -1
- package/dist/src/aws/organisations/backup.js +2 -2
- 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/organisationalUnits.d.ts +1 -1
- package/dist/src/aws/organisations/policies.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 +18 -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/events/index.d.ts +2 -0
- package/dist/src/events/index.js +1 -1
- package/dist/src/index.d.ts +18 -14
- package/dist/src/index.js +1 -1
- package/dist/src/orchestration/accountsConfig.d.ts +11 -0
- package/dist/src/orchestration/accountsConfig.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 +36 -5
- package/dist/src/orchestration/cascadeHelpers.js +1 -1
- package/dist/src/orchestration/contextHelpers.d.ts +20 -2
- package/dist/src/orchestration/contextHelpers.js +1 -1
- package/dist/src/orchestration/index.d.ts +13 -4
- 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 +23 -3
- package/dist/src/orchestration/organisationSetup.js +1 -1
- package/dist/src/orchestration/reconcileProviderAccounts.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 -33
- package/dist/src/orchestration/stackCleanup.js +1 -1
- package/dist/src/orchestration/trailMigration/memberTrailCleanup.d.ts +43 -0
- package/dist/src/orchestration/trailMigration/memberTrailCleanup.js +1 -0
- package/dist/src/orchestration/trailMigration/trailMigration.d.ts +64 -0
- package/dist/src/orchestration/trailMigration/trailMigration.js +1 -0
- 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/services/infrastructure/CdkArgumentBuilder.js +1 -1
- package/dist/src/services/infrastructure/CdkServiceTypes.d.ts +1 -0
- package/dist/src/services/infrastructure/cdkServiceHelpers.js +1 -1
- package/dist/src/services/supporting/CdkContextBuilder.d.ts +1 -0
- package/dist/src/services/supporting/CdkContextBuilder.js +1 -1
- 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/callbackKeys.d.ts +1 -1
- package/dist/src/types/callbackKeys.js +1 -1
- package/dist/src/types/callbacks.d.ts +58 -3
- package/dist/src/types/callbacks.js +1 -0
- package/dist/src/types/deployment/DeploymentTypes.d.ts +1 -0
- package/dist/src/types/deploymentEventSchema.d.ts +28 -3
- package/dist/src/types/deploymentEventSchema.js +1 -1
- package/dist/src/types/events.d.ts +12 -0
- package/dist/src/types/events.js +1 -0
- package/dist/src/types/index.d.ts +7 -11
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/orgConfig.d.ts +8 -2
- package/dist/src/types/params.d.ts +18 -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/dist/src/util/index.d.ts +1 -0
- package/dist/src/util/index.js +1 -1
- package/dist/src/util/sleepAbortable.d.ts +8 -0
- package/dist/src/util/sleepAbortable.js +1 -0
- package/package.json +8 -4
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Result } from "@fjall/generator";
|
|
2
|
+
import type { DeployCallbacks } from "../../types/callbacks.js";
|
|
3
|
+
import type { DeployServices } from "../serviceFactory.js";
|
|
4
|
+
export declare const INFRA_STEPS: {
|
|
5
|
+
readonly CONNECT: {
|
|
6
|
+
readonly id: "connect";
|
|
7
|
+
readonly name: "Connect securely";
|
|
8
|
+
};
|
|
9
|
+
readonly PREPARE: {
|
|
10
|
+
readonly id: "prepare-environment";
|
|
11
|
+
readonly name: "Prepare environment";
|
|
12
|
+
};
|
|
13
|
+
readonly DEPLOY: {
|
|
14
|
+
readonly id: "deploy";
|
|
15
|
+
readonly name: "Deploy infrastructure";
|
|
16
|
+
};
|
|
17
|
+
readonly MONITORING: {
|
|
18
|
+
readonly id: "monitoring";
|
|
19
|
+
readonly name: "Enable monitoring";
|
|
20
|
+
};
|
|
21
|
+
readonly ORG_DEPLOY: {
|
|
22
|
+
readonly id: "organisation-deploy";
|
|
23
|
+
readonly name: "Deploying organisation";
|
|
24
|
+
};
|
|
25
|
+
readonly CASCADE_PLATFORM: {
|
|
26
|
+
readonly id: "cascade-platform";
|
|
27
|
+
readonly name: "Deploying platform";
|
|
28
|
+
};
|
|
29
|
+
readonly CASCADE_ACCOUNTS: {
|
|
30
|
+
readonly id: "cascade-accounts";
|
|
31
|
+
readonly name: "Deploying accounts";
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
export declare const INFRA_STEP_TOTAL = 4;
|
|
35
|
+
/** Complete the single-component PREPARE step as errored. */
|
|
36
|
+
export declare function failPrepareStep(callbacks: DeployCallbacks): void;
|
|
37
|
+
/** Mask a failure message, surface it via onError, and return the failure. */
|
|
38
|
+
export declare function maskAndFail(callbacks: DeployCallbacks, message: string): Result<never>;
|
|
39
|
+
/** Best-effort stack-output read: log at debug on failure, never fail. */
|
|
40
|
+
export declare function readStackOutputsBestEffort(services: DeployServices, callbacks: DeployCallbacks, stackName: string, failureLogMessage: string): Promise<Record<string, string> | undefined>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{failure as P}from"@fjall/generator";import{collectStackOutputs as R}from"../contextHelpers.js";import{maskSensitiveOutput as S}from"@fjall/util";import{INFRA_STEP_NAME as r,STEP_IDS as t,STEP_NAMES as A}from"../../types/stepDefinitions.js";const n={CONNECT:{id:t.CONNECT,name:r.CONNECT},PREPARE:{id:t.PREPARE_ENVIRONMENT,name:r.PREPARE},DEPLOY:{id:t.DEPLOY,name:r.DEPLOY},MONITORING:{id:t.MONITORING,name:r.MONITORING},ORG_DEPLOY:{id:t.ORG_DEPLOY,name:A.ORG_DEPLOY},CASCADE_PLATFORM:{id:t.CASCADE_PLATFORM,name:A.CASCADE_PLATFORM},CASCADE_ACCOUNTS:{id:t.CASCADE_ACCOUNTS,name:A.CASCADE_ACCOUNTS}},i=4;function u(E){E.onStepComplete?.(n.PREPARE.id,n.PREPARE.name,"error",1,i)}function p(E,o){const e=new Error(S(o));return E.onError?.(e),P(e)}async function c(E,o,e,C){const O=await E.cfnService.getStackOutputs(e);return O.success||o.onLog?.(C,"debug"),R(O)}export{n as INFRA_STEPS,i as INFRA_STEP_TOTAL,u as failPrepareStep,p as maskAndFail,c as readStackOutputsBestEffort};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Result } from "@fjall/generator";
|
|
2
|
+
import type { DeployParams, DeployResult } from "../../types/params.js";
|
|
3
|
+
import type { OrganisationOperation } from "../../types/operations.js";
|
|
4
|
+
import type { DeployServices } from "../serviceFactory.js";
|
|
5
|
+
/**
|
|
6
|
+
* Full organisation deployment with cascade to platform + member accounts.
|
|
7
|
+
*/
|
|
8
|
+
export declare function deployOrgWithCascade(params: DeployParams, services: DeployServices, operation: OrganisationOperation, startTime: number): Promise<Result<DeployResult>>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import{join as W}from"node:path";import{success as _,failure as Y}from"@fjall/generator";import{ORGANISATION_TYPES as B,getOrganisationStackName as K}from"../../types/operations.js";import{synthOrFail as q,bootstrapOrFail as z,forwardOutput as J,forwardResourceProgress as Q}from"../contextHelpers.js";import{partitionAccounts as U,probeCascadeRoles as V}from"../cascadeHelpers.js";import{accountTier as X,maskSensitiveOutput as l}from"@fjall/util";import{buildOrgContext as Z,resolveOrgDetails as v}from"./orgContext.js";import{INFRA_STEPS as L,maskAndFail as R,readStackOutputsBestEffort as x}from"./infraSteps.js";import{resolveCascadeAccounts as ee}from"./resolveCascadeAccounts.js";import{executeCascade as te}from"./cascadeExecution.js";import{reconcileOrgTrailOutputs as oe}from"./trailReconciliation.js";async function ge(i,t,O,H){const{callbacks:e,options:$}=i,{providerAccounts:u,effectiveOrgConfig:M,defaultRegion:j}=await ee(i,t),g=await v(t);if(!g.success)return R(e,g.error.message);const G=u.find(o=>X(o)==="organisation"),d=Z(i,t,O,"organisation",g.data,void 0,G?.trailLifecycle),m=$?.cascade!==!1,{platformAccount:p,memberAccounts:k}=U(u),P=m&&p!==void 0?1:0,D=m&&k.length>0?1:0,n=2+P+D,I=m?[...p!==void 0?[p]:[],...k]:[];if(I.length>0){const o=await V(t,I,e,i.abortSignal),r=p!==void 0?o.find(a=>a.accountId===p.id):void 0;if(r!==void 0)return R(e,`Pre-flight cascade role check failed for the platform account ${r.accountName} (${r.accountId}): ${r.error}`);for(const a of o)e.onProgress?.({type:"warning",message:l(`Pre-flight cascade role check failed for ${a.accountName} (${a.accountId}) \u2014 its cascade deploy is expected to fail: ${a.error}`)})}e.onCascadeAccountsReconciled?.({hasPlatformAccount:P>0,hasMemberAccounts:D>0});const{id:h,name:S}=L.PREPARE;e.onStepStart?.(h,S,0,n),e.onLog?.("Synthesising organisation infrastructure\u2026","info");const E=await q(t,d,e,"CDK synthesis failed");if(!E.success)return e.onStepComplete?.(h,S,"error",0,n),E;const N=await z(t,d,e);if(!N.success)return e.onStepComplete?.(h,S,"error",0,n),N;e.onStepComplete?.(h,S,"completed",0,n);const{id:C,name:A}=L.ORG_DEPLOY,s=K(B.ORGANISATION);let F=!0;const f=await t.hashService.getTemplateHashes(W(d.path,"cdk.out"));if(f.success){const o=await t.hashService.compareWithState(f.data,d.path);o.success?F=o.data.stackChanges.get(s)??!0:e.onLog?.(l(`Org root change detection failed \u2014 deploying to be safe: ${o.error.message}`),"warn")}else e.onLog?.(l(`Org root template hashing failed \u2014 deploying to be safe: ${f.error.message}`),"warn");const b=F||$?.force===!0||!await t.cfnService.stackExists(s);e.onOrgChangesDetected?.({hasOrgChanges:b});let w;if(b){e.onStepStart?.(C,A,1,n);const o=await t.cdkService.runCdkDeploy(d,s,J(e),Q(e),t.awsProvider);if(!o.success)return e.onStepComplete?.(C,A,"error",1,n),R(e,o.error);w=await x(t,e,s,"Failed to read org stack outputs (non-critical)");const r=f.success?f.data.get(s):void 0;if(r!==void 0){const a=await t.hashService.updateStateAfterDeploy(d.path,new Map([[s,r]]));a.success||e.onLog?.(`Warning: failed to update state file \u2014 next deploy may re-deploy the org root: ${l(a.error.message)}`,"warn")}e.onStepComplete?.(C,A,"completed",1,n)}else e.onLog?.("Organisation root: no infrastructure changes \u2014 skipping deploy","info"),w=await x(t,e,s,"Failed to read org stack outputs (non-critical)");const c=[],y=[];let T=b;if(m&&u.length>0){const{anyCascadeDeployHappened:o}=await te(i,t,O,{providerAccounts:u,effectiveOrgConfig:M,totalSteps:n,cascadeErrors:c,allCascadeOutputs:y});if(o&&(T=!0),await oe(i,t,{orgOutputs:w,allCascadeOutputs:y,orgDetails:g.data,providerAccounts:u,defaultRegion:j}),c.length>0){const r=c.map(a=>` ${a.accountId}: ${a.error}`).join(`
|
|
2
|
+
`);e.onLog?.(l(`Cascade failed for ${c.length} target(s):
|
|
3
|
+
${r}`),"warn")}}if(c.length>0){const o=c.map(a=>l(`${a.accountId}: ${a.error}`)).join(`
|
|
4
|
+
`),r=new Error(`Organisation root deployed, but the cascade failed for ${c.length} target(s):
|
|
5
|
+
${o}`);return e.onError?.(r),Y(r)}return _({target:O.target,deploymentType:"organisation",outputs:w,...y.length>0?{cascadeOutputs:y}:{},...T?{}:{noChanges:!0},durationMs:Date.now()-H})}export{ge as deployOrgWithCascade};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Result } from "@fjall/generator";
|
|
2
|
+
import type { ProviderAccount } from "@fjall/util/config";
|
|
3
|
+
import type { DeployParams } from "../../types/params.js";
|
|
4
|
+
import type { OrganisationOperation } from "../../types/operations.js";
|
|
5
|
+
import type { DeployServices } from "../serviceFactory.js";
|
|
6
|
+
export interface OrgDetailsForSynth {
|
|
7
|
+
orgId: string;
|
|
8
|
+
rootId: string;
|
|
9
|
+
managementAccountId: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildOrgContext(params: DeployParams, services: DeployServices, operation: OrganisationOperation, deployType: "organisation" | "platform" | "account", orgDetails: OrgDetailsForSynth, accountName?: string, trailLifecycle?: ProviderAccount["trailLifecycle"]): import("../../types/deployment/DeploymentTypes.js").DeploymentContext;
|
|
12
|
+
export declare function resolveOrgDetails(services: DeployServices): Promise<Result<OrgDetailsForSynth>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{success as a,failure as c}from"@fjall/generator";import{OrganizationsClient as s}from"@aws-sdk/client-organizations";import{CdkContextBuilder as u}from"../../services/supporting/CdkContextBuilder.js";import{stubCallerIdentity as m}from"../../types/deployment/index.js";import{ensureOrganisationExists as f}from"../../aws/organisations/organisation.js";import{buildParamsContext as l}from"../contextHelpers.js";function b(t,n,o,i,r,d,g){const e=n.awsProvider.getRegion();return u.buildDeploymentContext({deployType:i,target:o.target,path:o.path,region:e,accountName:d,callerIdentity:m(n.awsProvider.getAccountId()),orgId:r.orgId,rootId:r.rootId,managementAccountId:r.managementAccountId,...l({orgConfig:t.orgConfig,identity:t.identity,skipOidc:t.options?.skipOidc,...i!=="organisation"?{region:e,primaryRegion:t.orgConfig?.primaryRegion}:{},trailLifecycle:g})},{verbose:t.options?.verbose,infraOnly:t.options?.infraOnly},t.orgConfig)}async function v(t){const n=t.awsProvider.getClient(s),o=await f(n);return o.success?a({orgId:o.data.orgId,rootId:o.data.rootId,managementAccountId:o.data.managementAccountId}):c(o.error)}export{b as buildOrgContext,v as resolveOrgDetails};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ProviderAccount } from "@fjall/util/config";
|
|
2
|
+
import type { DeployParams } from "../../types/params.js";
|
|
3
|
+
import type { OrgConfig } from "../../types/orgConfig.js";
|
|
4
|
+
import type { DeployServices } from "../serviceFactory.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the deployable provider-account set for an organisation cascade:
|
|
7
|
+
* reconcile from AWS Organizations when the orgConfig carries no deployable
|
|
8
|
+
* accounts, stamp a home region onto every account, and compose the
|
|
9
|
+
* effective orgConfig the cascade synth context needs.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveCascadeAccounts(params: DeployParams, services: DeployServices): Promise<{
|
|
12
|
+
providerAccounts: ProviderAccount[];
|
|
13
|
+
effectiveOrgConfig: OrgConfig | undefined;
|
|
14
|
+
defaultRegion: string;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{cascadeHomeRegion as u}from"../cascadeHelpers.js";import{reconcileProviderAccounts as l,mergeReconciledProviderAccounts as d}from"../reconcileProviderAccounts.js";import{accountTier as f,maskSensitiveOutput as s}from"@fjall/util";async function v(e,g){const{callbacks:c}=e;let o=e.orgConfig?.providerAccounts??[];if(o.length===0||o.every(n=>f(n)==="organisation")){const n=await l(g,e.workingDirectory);if(n.success){const{providerAccounts:i,missingAccountNames:t}=n.data;i.length>0&&(o=d(e.orgConfig,i).providerAccounts,c.onLog?.(`Reconciled ${i.length} account(s) from AWS Organizations`,"info")),t.length>0&&(c.onCascadeMissingAccounts?.(t),c.onProgress?.({type:"warning",message:s(`Accounts declared in ACCOUNTS but not yet in AWS Organizations (cascade will skip): ${t.join(", ")}`)}))}else c.onProgress?.({type:"warning",message:s(`Could not reconcile accounts from AWS Organizations \u2014 cascade may skip accounts: ${n.error.message}`)})}const r=u(e.orgConfig);o=o.map(n=>n.region!==void 0?n:{...n,region:r});const a=e.orgConfig!==void 0?{...e.orgConfig,providerAccounts:o}:o.length>0?{providerAccounts:o}:void 0;return{providerAccounts:o,effectiveOrgConfig:a,defaultRegion:r}}export{v as resolveCascadeAccounts};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Result } from "@fjall/generator";
|
|
2
|
+
import type { DeployParams, DeployResult } from "../../types/params.js";
|
|
3
|
+
import type { OrganisationOperation } from "../../types/operations.js";
|
|
4
|
+
import type { DeployServices } from "../serviceFactory.js";
|
|
5
|
+
/**
|
|
6
|
+
* Deploy a single organisation component (platform or account).
|
|
7
|
+
*
|
|
8
|
+
* Emits 4 named steps from INFRASTRUCTURE_STEP_NAMES: Connect securely →
|
|
9
|
+
* Prepare environment → Deploy infrastructure → Enable monitoring.
|
|
10
|
+
*/
|
|
11
|
+
export declare function deploySingleComponent(params: DeployParams, services: DeployServices, operation: OrganisationOperation, deployType: "platform" | "account", startTime: number): Promise<Result<DeployResult>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{success as A}from"@fjall/generator";import{BackupClient as N}from"@aws-sdk/client-backup";import{IAMClient as b}from"@aws-sdk/client-iam";import{getOrganisationStackName as w}from"../../types/operations.js";import{accountHasDisasterRecovery as D,describeBackupVaultExists as k}from"../../aws/organisations/backup.js";import{describeAccountGlobalsExist as y,buildMissingAccountGlobalsAdvisory as I}from"../../aws/organisations/accountGlobals.js";import{targetsNonPrimaryRegion as L,synthOrFail as T,bootstrapOrFail as h,forwardOutput as M,forwardResourceProgress as v}from"../contextHelpers.js";import{accountTier as F}from"@fjall/util";import{buildOrgContext as G,resolveOrgDetails as Y}from"./orgContext.js";import{INFRA_STEPS as o,INFRA_STEP_TOTAL as i,failPrepareStep as s,maskAndFail as u,readStackOutputsBestEffort as x}from"./infraSteps.js";async function W(n,r,a,c,E){const{callbacks:t}=n;t.onStepComplete?.(o.CONNECT.id,o.CONNECT.name,"completed",0,i),t.onStepStart?.(o.PREPARE.id,o.PREPARE.name,1,i);const p=r.awsProvider.getRegion(),l=n.orgConfig?.primaryRegion;if(l!==void 0&&L(p,l)){const e=await y(r.awsProvider.getClient(b),n.abortSignal);if(!e.success)return s(t),u(t,e.error.message);if(!e.data)return s(t),u(t,I({target:a.target,deployRegion:p,primaryRegion:l,...n.orgConfig!==void 0?{providerAccounts:n.orgConfig.providerAccounts}:{}}))}const g=await Y(r);if(!g.success)return s(t),u(t,g.error.message);const f=c==="account"?n.orgConfig?.providerAccounts.find(e=>e.name===a.target):n.orgConfig?.providerAccounts.find(e=>F(e)==="platform"),d=G(n,r,a,c,g.data,c==="account"?a.target:void 0,f?.trailLifecycle),m=n.orgConfig?.disasterRecoveryRegion;if(c==="account"&&(m!==void 0&&m!=="")&&(f===void 0||D(f.environment,m))){const e=await k(r.awsProvider.getClient(N));if(!e.success)return s(t),u(t,e.error.message);d.fjallAdoptBackupVault=e.data}t.onLog?.(`Synthesising ${c} infrastructure\u2026`,"info");const R=await T(r,d,t,"CDK synthesis failed");if(!R.success)return s(t),R;const P=await h(r,d,t);if(!P.success)return s(t),P;t.onStepComplete?.(o.PREPARE.id,o.PREPARE.name,"completed",1,i);const C=w(a.type);t.onStepStart?.(o.DEPLOY.id,o.DEPLOY.name,2,i);const O=await r.cdkService.runCdkDeploy(d,C,M(t),v(t),r.awsProvider);if(!O.success)return t.onStepComplete?.(o.DEPLOY.id,o.DEPLOY.name,"error",2,i),u(t,O.error);t.onStepComplete?.(o.DEPLOY.id,o.DEPLOY.name,"completed",2,i);const S=await x(r,t,C,"Failed to read stack outputs (non-critical)");return t.onStepStart?.(o.MONITORING.id,o.MONITORING.name,3,i),t.onStepComplete?.(o.MONITORING.id,o.MONITORING.name,"completed",3,i),A({target:a.target,deploymentType:"organisation",outputs:S,durationMs:Date.now()-E})}export{W as deploySingleComponent};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProviderAccount } from "@fjall/util/config";
|
|
2
|
+
import type { DeployParams } from "../../types/params.js";
|
|
3
|
+
import type { DeployServices } from "../serviceFactory.js";
|
|
4
|
+
import type { OrgDetailsForSynth } from "./orgContext.js";
|
|
5
|
+
/**
|
|
6
|
+
* Org-trail migration reconciliation: two-invocation convergence — this
|
|
7
|
+
* run's cheap probes advance lifecycle states (persisted by the consumer
|
|
8
|
+
* via onTrailLifecycleChanged); the NEXT deploy applies them. Gated on the
|
|
9
|
+
* org root actually owning an organisation trail; failures warn and never
|
|
10
|
+
* fail the deploy.
|
|
11
|
+
*/
|
|
12
|
+
export declare function reconcileOrgTrailOutputs(params: DeployParams, services: DeployServices, args: {
|
|
13
|
+
orgOutputs: Record<string, string> | undefined;
|
|
14
|
+
allCascadeOutputs: Array<{
|
|
15
|
+
accountId: string;
|
|
16
|
+
outputs: Record<string, string>;
|
|
17
|
+
}>;
|
|
18
|
+
orgDetails: OrgDetailsForSynth;
|
|
19
|
+
providerAccounts: ProviderAccount[];
|
|
20
|
+
defaultRegion: string;
|
|
21
|
+
}): Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{success as g,failure as O}from"@fjall/generator";import{S3Client as c}from"@aws-sdk/client-s3";import{CloudTrailClient as m}from"@aws-sdk/client-cloudtrail";import{KMSClient as f}from"@aws-sdk/client-kms";import{assumeCascadeRole as w}from"../contextHelpers.js";import{reconcileTrailMigration as I,ORG_TRAIL_BUCKET_OUTPUT_KEY as S,TRAIL_BUCKET_OUTPUT_KEY as P}from"../trailMigration/trailMigration.js";import{getErrorMessage as R,maskSensitiveOutput as _}from"@fjall/util";async function K(n,e,s){const{callbacks:u}=n,{orgOutputs:o,allCascadeOutputs:C,orgDetails:a,providerAccounts:p}=s,l=o?.[S];if(l!==void 0&&l!==""){const r=new Map;for(const t of C)(r.get(t.accountId)===void 0||P in t.outputs)&&r.set(t.accountId,t.outputs);o!==void 0&&r.set(a.managementAccountId,o);const T=async(t,d)=>{if(t.id===a.managementAccountId)return g({cloudTrailClient:e.awsProvider.getClient(m),s3Client:e.awsProvider.getClient(c),kmsClient:e.awsProvider.getClient(f)});const i=await w(e.awsProvider,t.id,d,`fjall-trail-migration-${t.name}`,n.abortSignal);return i.success?g({cloudTrailClient:i.data.provider.getClient(m),s3Client:i.data.provider.getClient(c),kmsClient:i.data.provider.getClient(f)}):O(i.error)};try{await I({managementS3Client:e.awsProvider.getClient(c),getMemberClients:T},{orgId:a.orgId,orgTrailBucketName:l,accounts:p,cascadeOutputs:r,defaultRegion:s.defaultRegion,...n.abortSignal!==void 0?{abortSignal:n.abortSignal}:{}},u)}catch(t){u.onLog?.(_(`Org-trail migration reconciliation failed (non-fatal): ${R(t)}`),"warn")}}}export{K as reconcileOrgTrailOutputs};
|
|
@@ -2,6 +2,7 @@ import { type Result } from "@fjall/generator";
|
|
|
2
2
|
import type { DeployParams, DeployResult } from "../types/params.js";
|
|
3
3
|
import type { OrganisationOperation } from "../types/operations.js";
|
|
4
4
|
import type { DeployServices } from "./serviceFactory.js";
|
|
5
|
+
export type { OrgDetailsForSynth } from "./organisationDeploy/orgContext.js";
|
|
5
6
|
/**
|
|
6
7
|
* Organisation deployment orchestration.
|
|
7
8
|
*
|
|
@@ -14,8 +15,3 @@ import type { DeployServices } from "./serviceFactory.js";
|
|
|
14
15
|
* responsibility. deploy-core receives credentials and deploys.
|
|
15
16
|
*/
|
|
16
17
|
export declare function deployOrganisation(params: DeployParams, services: DeployServices, operation: OrganisationOperation): Promise<Result<DeployResult>>;
|
|
17
|
-
export interface OrgDetailsForSynth {
|
|
18
|
-
orgId: string;
|
|
19
|
-
rootId: string;
|
|
20
|
-
managementAccountId: string;
|
|
21
|
-
}
|
|
@@ -1,5 +1 @@
|
|
|
1
|
-
import{join as we}from"node:path";import{success as re,failure as h}from"@fjall/generator";import{OrganizationsClient as Ie}from"@aws-sdk/client-organizations";import{BackupClient as ye}from"@aws-sdk/client-backup";import{ORGANISATION_TYPES as K,getOrganisationStackName as me}from"../types/operations.js";import{CdkContextBuilder as Ne}from"../services/supporting/CdkContextBuilder.js";import{stubCallerIdentity as De}from"../types/deployment/index.js";import{ensureOrganisationExists as ke}from"../aws/organisations/organisation.js";import{accountHasDisasterRecovery as be,describeBackupVaultExists as Te}from"../aws/organisations/backup.js";import{buildParamsContext as Le,collectStackOutputs as ae,synthOrFail as ge,bootstrapOrFail as fe,forwardOutput as Ce,forwardResourceProgress as Se}from"./contextHelpers.js";import{partitionAccounts as Ae,deployCascadeAccount as Ee,readPlatformIpamPoolIds as Me,deployDomains as _e,buildRegionList as $e,buildAccountRegionPairs as Fe,CASCADE_MAX_CONCURRENCY as Ge}from"./cascadeHelpers.js";import{projectScalarSummary as Oe}from"./cascadeSummary.js";import{reconcileProviderAccounts as Ye,mergeReconciledProviderAccounts as xe}from"./reconcileProviderAccounts.js";import{accountTier as ve,maskSensitiveOutput as i,mapSettledWithConcurrency as Ue}from"@fjall/util";import{INFRA_STEP_NAME as X,STEP_IDS as T,STEP_NAMES as se}from"../types/stepDefinitions.js";async function at(o,t,s){const g=Date.now();switch(s.type){case K.ORGANISATION:return We(o,t,s,g);case K.PLATFORM:return he(o,t,s,"platform",g);case K.ACCOUNT:return he(o,t,s,"account",g);default:{const e=s.type;return h(new Error(`Unsupported organisation type: ${String(e)}`))}}}function Re(o,t,s,g,e,r){return Ne.buildDeploymentContext({deployType:g,target:s.target,path:s.path,region:t.awsProvider.getRegion(),accountName:r,callerIdentity:De(t.awsProvider.getAccountId()),orgId:e.orgId,rootId:e.rootId,managementAccountId:e.managementAccountId,...Le({orgConfig:o.orgConfig,identity:o.identity,skipOidc:o.options?.skipOidc})},{verbose:o.options?.verbose,infraOnly:o.options?.infraOnly},o.orgConfig)}async function Pe(o){const t=o.awsProvider.getClient(Ie),s=await ke(t);return s.success?re({orgId:s.data.orgId,rootId:s.data.rootId,managementAccountId:s.data.managementAccountId}):h(s.error)}const n={CONNECT:{id:T.CONNECT,name:X.CONNECT},PREPARE:{id:T.PREPARE_ENVIRONMENT,name:X.PREPARE},DEPLOY:{id:T.DEPLOY,name:X.DEPLOY},MONITORING:{id:T.MONITORING,name:X.MONITORING},ORG_DEPLOY:{id:T.ORG_DEPLOY,name:se.ORG_DEPLOY},CASCADE_PLATFORM:{id:T.CASCADE_PLATFORM,name:se.CASCADE_PLATFORM},CASCADE_ACCOUNTS:{id:T.CASCADE_ACCOUNTS,name:se.CASCADE_ACCOUNTS}},S=4;async function he(o,t,s,g,e){const{callbacks:r}=o;r.onStepComplete?.(n.CONNECT.id,n.CONNECT.name,"completed",0,S),r.onStepStart?.(n.PREPARE.id,n.PREPARE.name,1,S);const p=await Pe(t);if(!p.success){r.onStepComplete?.(n.PREPARE.id,n.PREPARE.name,"error",1,S);const l=new Error(i(p.error.message));return r.onError?.(l),h(l)}const $=Re(o,t,s,g,p.data,g==="account"?s.target:void 0),L=o.orgConfig?.disasterRecoveryRegion,W=L!==void 0&&L!=="",M=g==="account"?o.orgConfig?.providerAccounts.find(l=>l.name===s.target):void 0;if(g==="account"&&W&&(M===void 0||be(M.environment,L))){const l=await Te(t.awsProvider.getClient(ye));if(!l.success){r.onStepComplete?.(n.PREPARE.id,n.PREPARE.name,"error",1,S);const N=new Error(i(l.error.message));return r.onError?.(N),h(N)}$.fjallAdoptBackupVault=l.data}r.onLog?.(`Synthesising ${g} infrastructure\u2026`,"info");const _=await ge(t,$,r,"CDK synthesis failed");if(!_.success)return r.onStepComplete?.(n.PREPARE.id,n.PREPARE.name,"error",1,S),_;const j=await fe(t,$,r);if(!j.success)return r.onStepComplete?.(n.PREPARE.id,n.PREPARE.name,"error",1,S),j;r.onStepComplete?.(n.PREPARE.id,n.PREPARE.name,"completed",1,S);const H=me(s.type);r.onStepStart?.(n.DEPLOY.id,n.DEPLOY.name,2,S);const F=await t.cdkService.runCdkDeploy($,H,Ce(r),Se(r),t.awsProvider);if(!F.success){r.onStepComplete?.(n.DEPLOY.id,n.DEPLOY.name,"error",2,S);const l=new Error(i(F.error));return r.onError?.(l),h(l)}r.onStepComplete?.(n.DEPLOY.id,n.DEPLOY.name,"completed",2,S);const G=await t.cfnService.getStackOutputs(H);G.success||r.onLog?.("Failed to read stack outputs (non-critical)","debug");const d=ae(G);return r.onStepStart?.(n.MONITORING.id,n.MONITORING.name,3,S),r.onStepComplete?.(n.MONITORING.id,n.MONITORING.name,"completed",3,S),re({target:s.target,deploymentType:"organisation",outputs:d,durationMs:Date.now()-e})}async function We(o,t,s,g){const{callbacks:e,options:r}=o;let p=o.orgConfig?.providerAccounts??[];if(p.length===0||p.every(a=>ve(a)==="organisation")){const a=await Ye(t,o.workingDirectory);if(a.success){const{providerAccounts:c,missingAccountNames:C}=a.data;c.length>0&&(p=xe(o.orgConfig,c).providerAccounts,e.onLog?.(`Reconciled ${c.length} account(s) from AWS Organizations`,"info")),C.length>0&&(e.onCascadeMissingAccounts?.(C),e.onProgress?.({type:"warning",message:i(`Accounts declared in ACCOUNTS but not yet in AWS Organizations (cascade will skip): ${C.join(", ")}`)}))}else e.onProgress?.({type:"warning",message:i(`Could not reconcile accounts from AWS Organizations \u2014 cascade may skip accounts: ${a.error.message}`)})}const L=o.orgConfig?.primaryRegion??t.awsProvider.getRegion();p=p.map(a=>a.region!==void 0?a:{...a,region:L});const W=o.orgConfig!==void 0?{...o.orgConfig,providerAccounts:p}:p.length>0?{providerAccounts:p}:void 0,M=await Pe(t);if(!M.success){const a=new Error(i(M.error.message));return e.onError?.(a),h(a)}const y=Re(o,t,s,"organisation",M.data),_=r?.cascade!==!1,{platformAccount:j,memberAccounts:H}=Ae(p),F=_&&j!==void 0?1:0,G=_&&H.length>0?1:0,d=2+F+G;e.onCascadeAccountsReconciled?.({hasPlatformAccount:F>0,hasMemberAccounts:G>0});const{id:l,name:N}=n.PREPARE;e.onStepStart?.(l,N,0,d),e.onLog?.("Synthesising organisation infrastructure\u2026","info");const ce=await ge(t,y,e,"CDK synthesis failed");if(!ce.success)return e.onStepComplete?.(l,N,"error",0,d),ce;const ie=await fe(t,y,e);if(!ie.success)return e.onStepComplete?.(l,N,"error",0,d),ie;e.onStepComplete?.(l,N,"completed",0,d);const{id:J,name:Q}=n.ORG_DEPLOY,D=me(K.ORGANISATION);let de=!0;const Y=await t.hashService.getTemplateHashes(we(y.path,"cdk.out"));if(Y.success){const a=await t.hashService.compareWithState(Y.data,y.path);a.success?de=a.data.stackChanges.get(D)??!0:e.onLog?.(i(`Org root change detection failed \u2014 deploying to be safe: ${a.error.message}`),"warn")}else e.onLog?.(i(`Org root template hashing failed \u2014 deploying to be safe: ${Y.error.message}`),"warn");const Z=de||r?.force===!0||!await t.cfnService.stackExists(D);e.onOrgChangesDetected?.({hasOrgChanges:Z});let ee;if(Z){e.onStepStart?.(J,Q,1,d);const a=await t.cdkService.runCdkDeploy(y,D,Ce(e),Se(e),t.awsProvider);if(!a.success){e.onStepComplete?.(J,Q,"error",1,d);const R=new Error(i(a.error));return e.onError?.(R),h(R)}const c=await t.cfnService.getStackOutputs(D);c.success||e.onLog?.("Failed to read org stack outputs (non-critical)","debug"),ee=ae(c);const C=Y.success?Y.data.get(D):void 0;if(C!==void 0){const R=await t.hashService.updateStateAfterDeploy(y.path,new Map([[D,C]]));R.success||e.onLog?.(`Warning: failed to update state file \u2014 next deploy may re-deploy the org root: ${i(R.error.message)}`,"warn")}e.onStepComplete?.(J,Q,"completed",1,d)}else{e.onLog?.("Organisation root: no infrastructure changes \u2014 skipping deploy","info");const a=await t.cfnService.getStackOutputs(D);a.success||e.onLog?.("Failed to read org stack outputs (non-critical)","debug"),ee=ae(a)}const f=[],V=[];let z=Z;if(_&&p.length>0){e.onCascadeStart?.();const a=Date.now();let c=2,C=!1,R,te=!1;const B=[],ue=u=>({members:B,...R!==void 0?{platform:R}:{},domainsDeployed:te,errors:f,totalDurationMs:u}),{platformAccount:A,memberAccounts:q}=Ae(p);if(A){const{id:u,name:m}=n.CASCADE_PLATFORM;e.onStepStart?.(u,m,c,d),e.onCascadePhaseStart?.("platform");let E;const oe=Date.now();try{E=await Ee(o,t,s,A,"platform",e,{orgConfig:W})}catch(P){const v=i(P instanceof Error?P.message:String(P));f.push({accountId:A.id,error:v}),e.onCascadePhaseComplete?.("platform"),e.onStepComplete?.(u,m,"error",c,d),E=h(new Error(v))}const x=Date.now()-oe;if(E.success){C=!0;const P=E.data.skipped===!0;P||(z=!0),E.data.outputs&&V.push({accountId:A.id,outputs:E.data.outputs}),e.onCascadePhaseComplete?.("platform"),e.onStepComplete?.(u,m,P?"skipped":"completed",c,d),R={accountId:A.id,result:P?"skipped":"succeeded",durationMs:x}}else f.some(P=>P.accountId===A.id)||(f.push({accountId:A.id,error:i(E.error.message)}),e.onCascadePhaseComplete?.("platform"),e.onStepComplete?.(u,m,"error",c,d)),R={accountId:A.id,result:"failed",durationMs:x,error:i(E.error.message)};c++}let le=new Map;if(C&&A&&(le=await Me(t,A,e)),o.domainProvider){const u=await _e(o.domainProvider,e);te=u.domainsDeployed>0,te&&(z=!0);for(const m of u.errors)f.push({accountId:"domains",error:i(m)})}if(A!==void 0&&!C&&q.length>0){const{id:u,name:m}=n.CASCADE_ACCOUNTS;e.onStepStart?.(u,m,c,d),e.onStepComplete?.(u,m,"skipped",c,d),e.onLog?.("Skipping account cascade \u2014 platform deployment failed; platform is a prerequisite for member accounts.","warn")}else if(q.length>0){const{id:u,name:m}=n.CASCADE_ACCOUNTS;e.onStepStart?.(u,m,c,d),e.onCascadePhaseStart?.("accounts");const E=$e(o.orgConfig),oe=E[0]??L,x=Fe(q,E);(await Ue(x,Ge,async({account:w,region:U})=>{const k=U.replace(/-/g,""),O=le.get(`${w.id}-${k}`),b=Date.now();return{result:await Ee(o,t,s,w,"account",e,{ipamPoolId:O,orgConfig:W,region:U,skipAccountGlobals:U!==oe}),durationMs:Date.now()-b}})).forEach((w,U)=>{const k=x[U];if(!k)return;const O=k.account;if(w.status==="rejected"){const I=i(w.reason instanceof Error?w.reason.message:String(w.reason));B.push({accountId:O.id,accountName:O.name,region:k.region,result:"failed",durationMs:0,error:I}),f.push({accountId:O.id,error:I});return}const{result:b,durationMs:ne}=w.value;if(b.success){const I=b.data.skipped===!0;I||(z=!0),b.data.outputs&&V.push({accountId:O.id,outputs:b.data.outputs}),B.push({accountId:O.id,accountName:O.name,region:k.region,result:I?"skipped":"succeeded",durationMs:ne})}else{const I=i(b.error.message);B.push({accountId:O.id,accountName:O.name,region:k.region,result:"failed",durationMs:ne,error:I}),f.push({accountId:O.id,error:I})}});const v=Oe(ue(0));e.onCascadePhaseComplete?.("accounts"),e.onStepComplete?.(u,m,v.accountsFailed>0?"error":v.accountsSkipped===q.length?"skipped":"completed",c,d)}const pe=ue(Date.now()-a);if(e.onCascadeComplete?.(Oe(pe)),e.onCascadeLedger?.(pe),f.length>0){const u=f.map(m=>` ${m.accountId}: ${m.error}`).join(`
|
|
2
|
-
`);e.onLog?.(i(`Cascade failed for ${f.length} target(s):
|
|
3
|
-
${u}`),"warn")}}if(f.length>0){const a=f.map(C=>i(`${C.accountId}: ${C.error}`)).join(`
|
|
4
|
-
`),c=new Error(`Organisation root deployed, but the cascade failed for ${f.length} target(s):
|
|
5
|
-
${a}`);return e.onError?.(c),h(c)}return re({target:s.target,deploymentType:"organisation",outputs:ee,...V.length>0?{cascadeOutputs:V}:{},...z?{}:{noChanges:!0},durationMs:Date.now()-g})}export{at as deployOrganisation};
|
|
1
|
+
import{failure as a}from"@fjall/generator";import{ORGANISATION_TYPES as o}from"../types/operations.js";import{deploySingleComponent as i}from"./organisationDeploy/singleComponentDeploy.js";import{deployOrgWithCascade as c}from"./organisationDeploy/orgCascadeDeploy.js";async function l(r,e,t){const n=Date.now();switch(t.type){case o.ORGANISATION:return c(r,e,t,n);case o.PLATFORM:return i(r,e,t,"platform",n);case o.ACCOUNT:return i(r,e,t,"account",n);default:{const u=t.type;return a(new Error(`Unsupported organisation type: ${String(u)}`))}}}export{l as deployOrganisation};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Receives pre-authenticated credentials and destroys all organisation
|
|
5
5
|
* infrastructure in cascade order:
|
|
6
6
|
* 1. Member accounts across ALL regions in parallel
|
|
7
|
-
* 2. Platform account in
|
|
7
|
+
* 2. Platform account in its home region
|
|
8
8
|
* 3. Organisation root stack
|
|
9
9
|
*
|
|
10
10
|
* Auth, verification, and interactive prompts are the caller's job
|
|
@@ -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,8 +53,12 @@ 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.
|
|
59
|
+
*
|
|
60
|
+
* @param abortSignal Optional shutdown signal — short-circuits the long
|
|
61
|
+
* account-creation and policy-enable polls so a SIGTERM-driven worker
|
|
62
|
+
* shutdown is not stalled by in-flight sleeps
|
|
43
63
|
*/
|
|
44
|
-
export declare function runOrganisationSetup(awsProvider: AwsProvider, config: OrgSetupConfig, callbacks?: OrgSetupCallbacks): Promise<Result<OrgSetupResult>>;
|
|
64
|
+
export declare function runOrganisationSetup(awsProvider: AwsProvider, config: OrgSetupConfig, callbacks?: OrgSetupCallbacks, abortSignal?: AbortSignal): Promise<Result<OrgSetupResult>>;
|
|
@@ -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};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{z as
|
|
1
|
+
import{z as u}from"zod";import{success as T,failure as i}from"@fjall/generator";import{environmentToTier as v,stageFromWireEnvironment as y,normaliseError as g}from"@fjall/util";import{OrganizationsClient as E}from"@aws-sdk/client-organizations";import{listAccounts as P}from"../aws/organisations/accounts.js";import{parseAccountsConfiguration as S,flattenAccountsToEnvironments as h}from"./accountsConfig.js";const w=6e4,O=u.object({Id:u.string().min(1),Name:u.string().min(1)});async function b(s,c){let n;const r=new Promise((o,e)=>{n=setTimeout(()=>e(new Error(`Timed out parsing ACCOUNTS configuration after ${w}ms`)),w)});let t;try{t=await Promise.race([S(c),r])}catch(o){return i(g(o))}finally{n!==void 0&&clearTimeout(n)}if(!t.success)return i(new Error(`Failed to parse ACCOUNTS configuration: ${t.error.message}`));if(t.data===null)return i(new Error("ACCOUNTS configuration file not found"));const C=h(t.data),f=new Map;for(const{accountName:o,environment:e}of C)f.set(o.toLowerCase(),{environment:e,displayName:o});let d;try{d=s.awsProvider.getClient(E)}catch(o){return i(g(o))}const a=await P(d);if(!a.success)return i(a.error);const l=new Map;for(const o of a.data){const e=O.safeParse(o);e.success&&l.set(e.data.Name.toLowerCase(),{id:e.data.Id,name:e.data.Name})}const p=[],A=[];for(const[o,{environment:e,displayName:N}]of f){const m=l.get(o);if(!m){A.push(N);continue}v(e)!=="organisation"&&p.push({id:m.id,name:m.name,tier:v(e),environment:y(e)})}return T({providerAccounts:p,missingAccountNames:A})}function z(s,c){const n=new Map;for(const r of s?.providerAccounts??[])n.set(r.id,r);for(const r of c)n.has(r.id)||n.set(r.id,r);return{...s??{},providerAccounts:Array.from(n.values())}}export{z as mergeReconciledProviderAccounts,b as reconcileProviderAccounts};
|
|
@@ -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[];
|