@fjall/deploy-core 0.89.4 → 0.89.6
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/LICENSE +50 -21
- package/README.md +25 -0
- package/dist/.minified +1 -0
- package/dist/src/__test-utils__/awsMockHelpers.d.ts +20 -0
- package/dist/src/__test-utils__/awsMockHelpers.js +1 -0
- package/dist/src/__test-utils__/index.d.ts +1 -0
- package/dist/src/__test-utils__/index.js +1 -0
- package/dist/src/aws/AwsProvider.d.ts +2 -2
- package/dist/src/aws/AwsProvider.js +0 -1
- package/dist/src/aws/SimpleAwsProvider.d.ts +2 -3
- package/dist/src/aws/SimpleAwsProvider.js +1 -73
- package/dist/src/aws/index.d.ts +4 -2
- package/dist/src/aws/index.js +1 -3
- package/dist/src/aws/organisations/accounts.js +10 -10
- package/dist/src/aws/organisations/backup.js +4 -2
- package/dist/src/aws/organisations/costAllocation.js +4 -2
- package/dist/src/aws/organisations/delegatedAdmin.d.ts +9 -0
- package/dist/src/aws/organisations/delegatedAdmin.js +43 -0
- package/dist/src/aws/organisations/identityCentre.d.ts +1 -1
- package/dist/src/aws/organisations/identityCentre.js +6 -2
- package/dist/src/aws/organisations/index.d.ts +4 -3
- package/dist/src/aws/organisations/index.js +1 -12
- package/dist/src/aws/organisations/ipam.js +4 -2
- package/dist/src/aws/organisations/organisation.js +27 -18
- package/dist/src/aws/organisations/organisationalUnits.d.ts +26 -6
- package/dist/src/aws/organisations/organisationalUnits.js +149 -35
- package/dist/src/aws/organisations/policies.js +4 -3
- package/dist/src/aws/organisations/ram.js +6 -2
- package/dist/src/aws/organisations/serviceAccess.js +12 -6
- package/dist/src/aws/organisations/trustedAccess.js +6 -2
- package/dist/src/aws/organisations/types.d.ts +23 -1
- package/dist/src/aws/organisations/types.js +1 -16
- package/dist/src/aws/utils/__tests__/cloudformationTestHelpers.d.ts +6 -0
- package/dist/src/aws/utils/__tests__/cloudformationTestHelpers.js +1 -0
- package/dist/src/aws/utils/cloudformationEventHelpers.d.ts +48 -0
- package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -0
- package/dist/src/aws/utils/cloudformationEventTypes.d.ts +45 -0
- package/dist/src/aws/utils/cloudformationEventTypes.js +1 -0
- package/dist/src/aws/utils/cloudformationEvents.d.ts +8 -54
- package/dist/src/aws/utils/cloudformationEvents.js +1 -596
- package/dist/src/aws/utils/index.d.ts +5 -0
- package/dist/src/aws/utils/index.js +1 -0
- package/dist/src/aws/utils/stackStatus.js +1 -90
- package/dist/src/events/index.d.ts +13 -0
- package/dist/src/events/index.js +1 -0
- package/dist/src/index.d.ts +34 -17
- package/dist/src/index.js +41 -21
- package/dist/src/orchestration/__tests__/cascadeTestHelpers.d.ts +12 -0
- package/dist/src/orchestration/__tests__/cascadeTestHelpers.js +78 -0
- package/dist/src/orchestration/activeDeploymentGuard.d.ts +10 -0
- package/dist/src/orchestration/activeDeploymentGuard.js +39 -0
- package/dist/src/orchestration/applicationDeploy.js +46 -224
- package/dist/src/orchestration/applicationDeployHelpers.d.ts +39 -0
- package/dist/src/orchestration/applicationDeployHelpers.js +223 -0
- package/dist/src/orchestration/applicationDestroy.d.ts +14 -0
- package/dist/src/orchestration/applicationDestroy.js +131 -0
- package/dist/src/orchestration/builders/dockerBuilder.d.ts +17 -0
- package/dist/src/orchestration/builders/dockerBuilder.js +98 -0
- package/dist/src/orchestration/builders/frameworkRegistry.d.ts +23 -0
- package/dist/src/orchestration/builders/frameworkRegistry.js +1 -0
- package/dist/src/orchestration/builders/index.d.ts +4 -0
- package/dist/src/orchestration/builders/index.js +1 -0
- package/dist/src/orchestration/builders/openNextBuilder.d.ts +21 -0
- package/dist/src/orchestration/builders/openNextBuilder.js +144 -0
- package/dist/src/orchestration/cascadeDestroyHelpers.d.ts +30 -0
- package/dist/src/orchestration/cascadeDestroyHelpers.js +1 -0
- package/dist/src/orchestration/cascadeHelpers.d.ts +46 -0
- package/dist/src/orchestration/cascadeHelpers.js +160 -0
- package/dist/src/orchestration/contextHelpers.d.ts +47 -2
- package/dist/src/orchestration/contextHelpers.js +94 -1
- package/dist/src/orchestration/destroy.d.ts +13 -0
- package/dist/src/orchestration/destroy.js +67 -0
- package/dist/src/orchestration/detectionPipeline.d.ts +2 -11
- package/dist/src/orchestration/detectionPipeline.js +29 -10
- package/dist/src/orchestration/dockerBuildHelper.d.ts +10 -0
- package/dist/src/orchestration/dockerBuildHelper.js +49 -0
- package/dist/src/orchestration/dockerInterface.d.ts +4 -2
- package/dist/src/orchestration/index.d.ts +8 -1
- package/dist/src/orchestration/index.js +1 -3
- package/dist/src/orchestration/manifestSecretParser.d.ts +11 -0
- package/dist/src/orchestration/manifestSecretParser.js +1 -0
- package/dist/src/orchestration/openNextBuild.d.ts +28 -0
- package/dist/src/orchestration/openNextBuild.js +243 -0
- package/dist/src/orchestration/organisationDeploy.js +130 -228
- package/dist/src/orchestration/organisationDestroy.d.ts +24 -0
- package/dist/src/orchestration/organisationDestroy.js +189 -0
- package/dist/src/orchestration/organisationSetup.d.ts +6 -4
- package/dist/src/orchestration/organisationSetup.js +28 -8
- package/dist/src/orchestration/resolveOperation.js +68 -6
- package/dist/src/orchestration/serviceFactory.d.ts +4 -0
- package/dist/src/orchestration/serviceFactory.js +1 -16
- package/dist/src/orchestration/spawnHelpers.d.ts +47 -0
- package/dist/src/orchestration/spawnHelpers.js +1 -0
- package/dist/src/orchestration/stackCleanup.d.ts +39 -0
- package/dist/src/orchestration/stackCleanup.js +1 -0
- package/dist/src/orchestration/welcomeImageHelper.d.ts +15 -0
- package/dist/src/orchestration/welcomeImageHelper.js +64 -0
- package/dist/src/services/application/ApplicationStackService.d.ts +21 -30
- package/dist/src/services/application/ApplicationStackService.js +16 -234
- package/dist/src/services/application/applicationStackHelpers.d.ts +46 -0
- package/dist/src/services/application/applicationStackHelpers.js +248 -0
- package/dist/src/services/application/index.d.ts +1 -0
- package/dist/src/services/application/index.js +1 -1
- package/dist/src/services/index.d.ts +6 -0
- package/dist/src/services/index.js +1 -0
- package/dist/src/services/infrastructure/CdkArgumentBuilder.js +1 -67
- package/dist/src/services/infrastructure/CdkCommandRunner.d.ts +10 -2
- package/dist/src/services/infrastructure/CdkCommandRunner.js +18 -15
- package/dist/src/services/infrastructure/CdkErrorFormatter.js +16 -194
- package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -41
- package/dist/src/services/infrastructure/CdkOutputAnalyser.js +1 -1
- package/dist/src/services/infrastructure/CdkOutputParser.js +2 -33
- package/dist/src/services/infrastructure/CdkProcessManager.d.ts +5 -0
- package/dist/src/services/infrastructure/CdkProcessManager.js +81 -47
- package/dist/src/services/infrastructure/CdkService.d.ts +7 -52
- package/dist/src/services/infrastructure/CdkService.js +41 -82
- package/dist/src/services/infrastructure/CdkServiceTypes.d.ts +50 -0
- package/dist/src/services/infrastructure/CdkServiceTypes.js +0 -0
- package/dist/src/services/infrastructure/CloudFormationService.js +9 -10
- package/dist/src/services/infrastructure/ICdkProcessManager.d.ts +27 -0
- package/dist/src/services/infrastructure/ICdkProcessManager.js +1 -0
- package/dist/src/services/infrastructure/__tests__/cloudFormationTestHelpers.d.ts +9 -0
- package/dist/src/services/infrastructure/__tests__/cloudFormationTestHelpers.js +1 -0
- package/dist/src/services/infrastructure/cdkServiceHelpers.d.ts +9 -0
- package/dist/src/services/infrastructure/cdkServiceHelpers.js +1 -0
- package/dist/src/services/infrastructure/constructMapEnrichment.d.ts +7 -0
- package/dist/src/services/infrastructure/constructMapEnrichment.js +1 -0
- package/dist/src/services/infrastructure/index.d.ts +3 -1
- package/dist/src/services/infrastructure/index.js +1 -7
- package/dist/src/services/supporting/TemplateHashService.js +1 -1
- package/dist/src/services/supporting/helpers.js +1 -81
- package/dist/src/services/supporting/index.js +1 -3
- package/dist/src/steps/index.d.ts +1 -0
- package/dist/src/steps/index.js +1 -0
- package/dist/src/steps/stepRegistry.d.ts +71 -0
- package/dist/src/steps/stepRegistry.js +505 -0
- package/dist/src/types/FjallState.js +1 -118
- package/dist/src/types/ProgressEvent.js +1 -48
- package/dist/src/types/application/ApplicationServiceTypes.js +1 -30
- package/dist/src/types/application/index.js +1 -1
- package/dist/src/types/callbacks.d.ts +76 -4
- package/dist/src/types/callbacks.js +0 -1
- package/dist/src/types/constants.d.ts +2 -0
- package/dist/src/types/constants.js +1 -6
- package/dist/src/types/credentials.d.ts +1 -1
- package/dist/src/types/credentials.js +0 -1
- package/dist/src/types/deployment/DeploymentServiceTypes.d.ts +5 -2
- package/dist/src/types/deployment/DeploymentServiceTypes.js +1 -1
- package/dist/src/types/deployment/DeploymentTypes.d.ts +1 -0
- package/dist/src/types/deployment/DeploymentTypes.js +0 -1
- package/dist/src/types/deployment/cloudformation.js +0 -1
- package/dist/src/types/deployment/index.d.ts +3 -1
- package/dist/src/types/deployment/index.js +1 -1
- package/dist/src/types/deployment/parallel.js +1 -10
- package/dist/src/types/deploymentEventSchema.d.ts +158 -0
- package/dist/src/types/deploymentEventSchema.js +1 -0
- package/dist/src/types/detection.d.ts +22 -0
- package/dist/src/types/detection.js +1 -0
- package/dist/src/types/entitlements.d.ts +31 -0
- package/dist/src/types/entitlements.js +0 -0
- package/dist/src/types/errors/CdkError.js +1 -20
- package/dist/src/types/errors/ServiceError.d.ts +2 -1
- package/dist/src/types/errors/ServiceError.js +1 -119
- package/dist/src/types/errors/index.d.ts +2 -0
- package/dist/src/types/errors/index.js +1 -0
- package/dist/src/types/events.d.ts +3 -9
- package/dist/src/types/events.js +0 -5
- package/dist/src/types/frameworkBuilder.d.ts +96 -0
- package/dist/src/types/frameworkBuilder.js +8 -0
- package/dist/src/types/index.d.ts +19 -4
- package/dist/src/types/index.js +1 -9
- package/dist/src/types/operations.d.ts +3 -2
- package/dist/src/types/operations.js +1 -285
- package/dist/src/types/orgConfig.d.ts +2 -10
- package/dist/src/types/orgConfig.js +0 -11
- package/dist/src/types/params.d.ts +61 -0
- package/dist/src/types/patternDetection.d.ts +14 -16
- package/dist/src/types/patternDetection.js +14 -18
- package/dist/src/types/patternTypes.d.ts +19 -0
- package/dist/src/types/patternTypes.js +1 -0
- package/dist/src/types/stepDefinitions.d.ts +163 -0
- package/dist/src/types/stepDefinitions.js +98 -0
- package/dist/src/types/validation.js +0 -1
- package/dist/src/util/dockerfileDetection.d.ts +5 -0
- package/dist/src/util/dockerfileDetection.js +1 -0
- package/dist/src/util/index.d.ts +4 -3
- package/dist/src/util/index.js +1 -3
- package/dist/src/util/sequencedCallbacks.d.ts +44 -0
- package/dist/src/util/sequencedCallbacks.js +1 -0
- package/package.json +49 -8
- package/dist/src/aws/utils/CloudFormationFailureAnalyser.d.ts +0 -32
- package/dist/src/aws/utils/CloudFormationFailureAnalyser.js +0 -228
- package/dist/src/aws/utils/errors.d.ts +0 -26
- package/dist/src/aws/utils/errors.js +0 -59
- package/dist/src/util/fsHelpers.d.ts +0 -4
- package/dist/src/util/fsHelpers.js +0 -16
- package/dist/src/util/securityHelpers.d.ts +0 -31
- package/dist/src/util/securityHelpers.js +0 -124
- package/dist/src/util/singleton.d.ts +0 -2
- package/dist/src/util/singleton.js +0 -9
- package/dist/src/util/sleep.d.ts +0 -4
- package/dist/src/util/sleep.js +0 -4
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Organisation destroy orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Receives pre-authenticated credentials and destroys all organisation
|
|
5
|
+
* infrastructure in cascade order:
|
|
6
|
+
* 1. Member accounts across ALL regions in parallel
|
|
7
|
+
* 2. Platform account in primary region
|
|
8
|
+
* 3. Organisation root stack
|
|
9
|
+
*
|
|
10
|
+
* Auth, verification, and interactive prompts are the caller's job
|
|
11
|
+
* (per engine/consumer boundary).
|
|
12
|
+
*/
|
|
13
|
+
import { success, failure } from "@fjall/generator";
|
|
14
|
+
import { maskSensitiveOutput, getErrorMessage } from "@fjall/util";
|
|
15
|
+
import { stubCallerIdentity } from "../types/deployment/index.js";
|
|
16
|
+
import { ORGANISATION_TYPES, getOrganisationStackName } from "../types/operations.js";
|
|
17
|
+
import { CdkContextBuilder } from "../services/supporting/CdkContextBuilder.js";
|
|
18
|
+
import { buildParamsContext, synthOrFail, forwardOutput, forwardResourceProgress } from "./contextHelpers.js";
|
|
19
|
+
import { destroyCascadeAccount } from "./cascadeDestroyHelpers.js";
|
|
20
|
+
import { partitionAccounts } from "./cascadeHelpers.js";
|
|
21
|
+
import { DEFAULT_REGION } from "../aws/utils/regions.js";
|
|
22
|
+
import { STEP_IDS } from "../types/stepDefinitions.js";
|
|
23
|
+
const ORG_DESTROY_STEP_ID = STEP_IDS.ORG_DESTROY;
|
|
24
|
+
const ORG_DESTROY_STEP_NAME = "Destroying organisation infrastructure";
|
|
25
|
+
/**
|
|
26
|
+
* Destroy organisation infrastructure with cascade.
|
|
27
|
+
*
|
|
28
|
+
* The cascade ordering is: members (parallel, multi-region) -> platform -> org stack.
|
|
29
|
+
* If any member or platform destruction fails, the org stack is NOT destroyed
|
|
30
|
+
* (to avoid orphaning resources).
|
|
31
|
+
*/
|
|
32
|
+
export async function destroyOrganisation(params, services, operation) {
|
|
33
|
+
const startTime = Date.now();
|
|
34
|
+
const { callbacks } = params;
|
|
35
|
+
const providerAccounts = params.orgConfig?.providerAccounts ?? [];
|
|
36
|
+
const primaryRegion = params.orgConfig?.primaryRegion ?? services.awsProvider.getRegion();
|
|
37
|
+
const allRegions = buildRegionList(params);
|
|
38
|
+
const { platformAccount, memberAccounts } = partitionAccounts(providerAccounts);
|
|
39
|
+
const cascadeEnabled = params.options?.cascade !== false;
|
|
40
|
+
callbacks.onLog?.(`Destroying organisation infrastructure (${providerAccounts.length} accounts, ${allRegions.length} region(s))`, "info");
|
|
41
|
+
const cascadeErrors = [];
|
|
42
|
+
const stacksDestroyed = [];
|
|
43
|
+
if (cascadeEnabled) {
|
|
44
|
+
callbacks.onCascadeStart?.();
|
|
45
|
+
// Phase 1: Destroy member accounts across all regions in parallel
|
|
46
|
+
if (memberAccounts.length > 0) {
|
|
47
|
+
callbacks.onCascadePhaseStart?.("accounts");
|
|
48
|
+
const pairs = buildAccountRegionPairs(memberAccounts, allRegions);
|
|
49
|
+
const memberResults = await Promise.allSettled(pairs.map(({ account, region }) => destroyCascadeAccount(params, services, operation, account, "account", region, callbacks)));
|
|
50
|
+
for (let i = 0; i < memberResults.length; i++) {
|
|
51
|
+
const result = memberResults[i];
|
|
52
|
+
const pair = pairs[i];
|
|
53
|
+
if (!result || !pair)
|
|
54
|
+
continue;
|
|
55
|
+
if (result.status === "fulfilled") {
|
|
56
|
+
if (result.value.success) {
|
|
57
|
+
stacksDestroyed.push(`Account-${pair.account.name}-${pair.region}`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
cascadeErrors.push({
|
|
61
|
+
accountId: pair.account.id,
|
|
62
|
+
error: result.value.error ?? "Unknown error"
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
cascadeErrors.push({
|
|
68
|
+
accountId: pair.account.id,
|
|
69
|
+
error: getErrorMessage(result.reason)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Phase 2: Destroy platform account (primary region only)
|
|
75
|
+
if (platformAccount) {
|
|
76
|
+
callbacks.onCascadePhaseStart?.("platform");
|
|
77
|
+
const platformResult = await destroyCascadeAccount(params, services, operation, platformAccount, "platform", primaryRegion, callbacks);
|
|
78
|
+
if (platformResult.success) {
|
|
79
|
+
stacksDestroyed.push("Platform");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
cascadeErrors.push({
|
|
83
|
+
accountId: platformAccount.id,
|
|
84
|
+
error: platformResult.error ?? "Platform destroy failed"
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Emit cascade completion
|
|
89
|
+
callbacks.onCascadeComplete?.({
|
|
90
|
+
platformDeployed: false,
|
|
91
|
+
domainsDeployed: false,
|
|
92
|
+
accountsDeployed: 0,
|
|
93
|
+
accountsFailed: cascadeErrors.length,
|
|
94
|
+
errors: cascadeErrors
|
|
95
|
+
});
|
|
96
|
+
if (cascadeErrors.length > 0) {
|
|
97
|
+
const errorSummary = cascadeErrors
|
|
98
|
+
.map((e) => ` ${e.accountId}: ${e.error}`)
|
|
99
|
+
.join("\n");
|
|
100
|
+
const cascadeError = new Error(`Cascade destroy completed with ${cascadeErrors.length} failure(s):\n${errorSummary}`);
|
|
101
|
+
callbacks.onError?.(cascadeError);
|
|
102
|
+
callbacks.onLog?.(maskSensitiveOutput(cascadeError.message), "warn");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Phase 3: Destroy the organisation root stack
|
|
106
|
+
// Gate: if any cascade failure, skip org stack destroy to avoid orphaning resources
|
|
107
|
+
if (cascadeErrors.length > 0) {
|
|
108
|
+
const msg = "Skipping organisation root stack destroy due to cascade failures";
|
|
109
|
+
callbacks.onLog?.(msg, "warn");
|
|
110
|
+
return success({
|
|
111
|
+
target: operation.target,
|
|
112
|
+
deploymentType: "organisation",
|
|
113
|
+
stacksDestroyed,
|
|
114
|
+
durationMs: Date.now() - startTime,
|
|
115
|
+
warnings: cascadeErrors.map((e) => maskSensitiveOutput(`${e.accountId}: ${e.error}`))
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Destroy org root stack
|
|
119
|
+
callbacks.onStepStart?.(ORG_DESTROY_STEP_ID, ORG_DESTROY_STEP_NAME, 0, 1);
|
|
120
|
+
const orgResult = await destroyOrgRootStack(params, services, operation, primaryRegion, callbacks);
|
|
121
|
+
if (orgResult.success) {
|
|
122
|
+
stacksDestroyed.push("Organisation");
|
|
123
|
+
callbacks.onStepComplete?.(ORG_DESTROY_STEP_ID, ORG_DESTROY_STEP_NAME, "completed", 0, 1);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
callbacks.onStepComplete?.(ORG_DESTROY_STEP_ID, ORG_DESTROY_STEP_NAME, "error", 0, 1);
|
|
127
|
+
return failure(orgResult.error);
|
|
128
|
+
}
|
|
129
|
+
return success({
|
|
130
|
+
target: operation.target,
|
|
131
|
+
deploymentType: "organisation",
|
|
132
|
+
stacksDestroyed,
|
|
133
|
+
durationMs: Date.now() - startTime
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Destroy the organisation root stack via CDK.
|
|
138
|
+
*/
|
|
139
|
+
async function destroyOrgRootStack(params, services, operation, region, callbacks) {
|
|
140
|
+
const context = CdkContextBuilder.buildDeploymentContext({
|
|
141
|
+
deployType: "organisation",
|
|
142
|
+
target: operation.target,
|
|
143
|
+
path: operation.path,
|
|
144
|
+
region,
|
|
145
|
+
callerIdentity: stubCallerIdentity(services.awsProvider.getAccountId()),
|
|
146
|
+
...buildParamsContext({
|
|
147
|
+
orgConfig: params.orgConfig,
|
|
148
|
+
identity: params.identity
|
|
149
|
+
})
|
|
150
|
+
}, { verbose: params.options?.verbose }, params.orgConfig);
|
|
151
|
+
const stackName = getOrganisationStackName(ORGANISATION_TYPES.ORGANISATION);
|
|
152
|
+
callbacks.onLog?.("Synthesising organisation infrastructure…", "info");
|
|
153
|
+
const synthResult = await synthOrFail(services, context, callbacks, "Organisation synth failed");
|
|
154
|
+
if (!synthResult.success)
|
|
155
|
+
return synthResult;
|
|
156
|
+
callbacks.onLog?.(`Destroying ${stackName} stack…`, "info");
|
|
157
|
+
const destroyResult = await services.cdkService.runCdkDestroy(context, stackName, forwardOutput(callbacks), forwardResourceProgress(callbacks), services.awsProvider, true);
|
|
158
|
+
if (!destroyResult.success) {
|
|
159
|
+
const error = new Error(maskSensitiveOutput(`Organisation destroy failed: ${destroyResult.error}`));
|
|
160
|
+
callbacks.onError?.(error);
|
|
161
|
+
return failure(error);
|
|
162
|
+
}
|
|
163
|
+
return success(undefined);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build the full list of regions to destroy across (primary + secondary + DR).
|
|
167
|
+
*/
|
|
168
|
+
function buildRegionList(params) {
|
|
169
|
+
const primaryRegion = params.orgConfig?.primaryRegion ?? DEFAULT_REGION;
|
|
170
|
+
const secondaryRegions = params.orgConfig?.secondaryRegions ?? [];
|
|
171
|
+
const allRegions = [primaryRegion, ...secondaryRegions];
|
|
172
|
+
const drRegion = params.orgConfig?.disasterRecoveryRegion;
|
|
173
|
+
if (drRegion && !allRegions.includes(drRegion)) {
|
|
174
|
+
allRegions.push(drRegion);
|
|
175
|
+
}
|
|
176
|
+
return allRegions;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Build all account x region pairs for parallel destruction.
|
|
180
|
+
*/
|
|
181
|
+
function buildAccountRegionPairs(accounts, regions) {
|
|
182
|
+
const pairs = [];
|
|
183
|
+
for (const region of regions) {
|
|
184
|
+
for (const account of accounts) {
|
|
185
|
+
pairs.push({ account, region });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return pairs;
|
|
189
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Result } from "@fjall/generator";
|
|
2
2
|
import type { AwsProvider } from "../aws/AwsProvider.js";
|
|
3
|
-
|
|
3
|
+
import type { OUTree } 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
|
export interface OrgSetupCallbacks {
|
|
5
6
|
onPhaseStart?(phase: OrgSetupPhase): void;
|
|
6
7
|
onPhaseComplete?(phase: OrgSetupPhase, result: "completed" | "skipped" | "error"): void;
|
|
@@ -12,11 +13,12 @@ export interface OrgSetupConfig {
|
|
|
12
13
|
name: string;
|
|
13
14
|
email: string;
|
|
14
15
|
}>;
|
|
15
|
-
platformAccountId
|
|
16
|
-
organisationalUnits: string[];
|
|
16
|
+
platformAccountId?: string;
|
|
17
|
+
organisationalUnits: string[] | OUTree;
|
|
17
18
|
accountPlacements?: Record<string, string>;
|
|
18
19
|
costAllocationTags?: string[];
|
|
19
20
|
skipIdentityCentre?: boolean;
|
|
21
|
+
securityDelegateAccountId?: string;
|
|
20
22
|
}
|
|
21
23
|
export interface OrgSetupResult {
|
|
22
24
|
organisationId: string;
|
|
@@ -35,7 +37,7 @@ export interface OrgSetupResult {
|
|
|
35
37
|
/**
|
|
36
38
|
* Orchestrate the full AWS Organisation setup sequence.
|
|
37
39
|
*
|
|
38
|
-
* Runs
|
|
40
|
+
* Runs up to 13 phases sequentially. Non-fatal phase failures are recorded
|
|
39
41
|
* and execution continues. The only fatal failure is phase 1
|
|
40
42
|
* (create-organisation) since all subsequent phases depend on the org ID.
|
|
41
43
|
*/
|
|
@@ -14,13 +14,14 @@ import { activateTrustedAccess } from "../aws/organisations/trustedAccess.js";
|
|
|
14
14
|
import { enableIpamDelegatedAdmin } from "../aws/organisations/ipam.js";
|
|
15
15
|
import { updateBackupGlobalSettings } from "../aws/organisations/backup.js";
|
|
16
16
|
import { listAccounts, createAccount } from "../aws/organisations/accounts.js";
|
|
17
|
-
import { ensureOrganisationalUnitsExist, placeAccountsInOUs } from "../aws/organisations/organisationalUnits.js";
|
|
17
|
+
import { ensureOrganisationalUnitsExist, placeAccountsInOUs, buildAccountToOUMap } from "../aws/organisations/organisationalUnits.js";
|
|
18
18
|
import { activateCostAllocationTags } from "../aws/organisations/costAllocation.js";
|
|
19
19
|
import { checkIdentityCentreStatus } from "../aws/organisations/identityCentre.js";
|
|
20
|
+
import { registerSecurityDelegates } from "../aws/organisations/delegatedAdmin.js";
|
|
20
21
|
/**
|
|
21
22
|
* Orchestrate the full AWS Organisation setup sequence.
|
|
22
23
|
*
|
|
23
|
-
* Runs
|
|
24
|
+
* Runs up to 13 phases sequentially. Non-fatal phase failures are recorded
|
|
24
25
|
* and execution continues. The only fatal failure is phase 1
|
|
25
26
|
* (create-organisation) since all subsequent phases depend on the org ID.
|
|
26
27
|
*/
|
|
@@ -69,11 +70,14 @@ export async function runOrganisationSetup(awsProvider, config, callbacks) {
|
|
|
69
70
|
callbacks?.onProgress?.("Activating CloudFormation trusted access");
|
|
70
71
|
return activateTrustedAccess(cfnClient);
|
|
71
72
|
}, phasesCompleted, errors, callbacks);
|
|
72
|
-
// Phase 6: Enable IPAM delegated admin
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
// Phase 6: Enable IPAM delegated admin (requires a platform account)
|
|
74
|
+
if (config.platformAccountId) {
|
|
75
|
+
const platformId = config.platformAccountId;
|
|
76
|
+
await executePhase("enable-ipam", () => {
|
|
77
|
+
callbacks?.onProgress?.("Enabling IPAM delegated administrator");
|
|
78
|
+
return enableIpamDelegatedAdmin(ec2Client, platformId);
|
|
79
|
+
}, phasesCompleted, errors, callbacks);
|
|
80
|
+
}
|
|
77
81
|
// Phase 7: Configure backup settings
|
|
78
82
|
await executePhase("configure-backup", () => {
|
|
79
83
|
callbacks?.onProgress?.("Updating backup global settings");
|
|
@@ -116,9 +120,12 @@ export async function runOrganisationSetup(awsProvider, config, callbacks) {
|
|
|
116
120
|
// Phase 10: Place accounts in OUs
|
|
117
121
|
if (Object.keys(ouMap).length > 0 && config.accountPlacements) {
|
|
118
122
|
const accountInfos = buildAccountInfos(config.accountPlacements);
|
|
123
|
+
const accountToOU = !Array.isArray(config.organisationalUnits)
|
|
124
|
+
? buildAccountToOUMap(config.organisationalUnits, ouMap)
|
|
125
|
+
: undefined;
|
|
119
126
|
await executePhase("place-accounts", () => {
|
|
120
127
|
callbacks?.onProgress?.("Placing accounts in organisational units");
|
|
121
|
-
return placeAccountsInOUs(orgsClient, ouMap, accountInfos);
|
|
128
|
+
return placeAccountsInOUs(orgsClient, ouMap, accountInfos, accountToOU);
|
|
122
129
|
}, phasesCompleted, errors, callbacks);
|
|
123
130
|
}
|
|
124
131
|
else {
|
|
@@ -163,6 +170,19 @@ export async function runOrganisationSetup(awsProvider, config, callbacks) {
|
|
|
163
170
|
callbacks?.onPhaseComplete?.("check-identity-centre", "completed");
|
|
164
171
|
}
|
|
165
172
|
}
|
|
173
|
+
// Phase 13: Register security delegated administrators
|
|
174
|
+
const delegateAccountId = config.securityDelegateAccountId;
|
|
175
|
+
if (delegateAccountId) {
|
|
176
|
+
await executePhase("register-security-delegates", () => {
|
|
177
|
+
callbacks?.onProgress?.("Registering security service delegated administrators");
|
|
178
|
+
return registerSecurityDelegates(orgsClient, delegateAccountId);
|
|
179
|
+
}, phasesCompleted, errors, callbacks);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
phasesSkipped.push("register-security-delegates");
|
|
183
|
+
callbacks?.onPhaseStart?.("register-security-delegates");
|
|
184
|
+
callbacks?.onPhaseComplete?.("register-security-delegates", "skipped");
|
|
185
|
+
}
|
|
166
186
|
return success({
|
|
167
187
|
organisationId: orgId,
|
|
168
188
|
createdAccounts,
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { join, relative } from "path";
|
|
2
|
+
import { readdir } from "fs/promises";
|
|
2
3
|
import { success, failure } from "@fjall/generator";
|
|
4
|
+
import { logger } from "@fjall/util/logger";
|
|
3
5
|
import { ORGANISATION_TYPES } from "../types/operations.js";
|
|
4
|
-
import { fileExists } from "
|
|
6
|
+
import { fileExists } from "@fjall/util/fsHelpers";
|
|
5
7
|
const ORGANISATION_TYPE_VALUES = new Set(Object.values(ORGANISATION_TYPES));
|
|
8
|
+
function isOrganisationType(value) {
|
|
9
|
+
return ORGANISATION_TYPE_VALUES.has(value);
|
|
10
|
+
}
|
|
6
11
|
const TARGET_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
7
12
|
/**
|
|
8
13
|
* Determine the deployment operation type from the target string and filesystem.
|
|
@@ -12,11 +17,39 @@ const TARGET_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
|
12
17
|
* - Otherwise → failure
|
|
13
18
|
*/
|
|
14
19
|
export async function resolveOperation(target, workingDirectory) {
|
|
20
|
+
logger.debug("resolveOperation", "called", { target, workingDirectory });
|
|
21
|
+
const workingDirExists = await fileExists(workingDirectory);
|
|
22
|
+
logger.debug("resolveOperation", "workingDirectory exists", {
|
|
23
|
+
exists: workingDirExists
|
|
24
|
+
});
|
|
25
|
+
const fjallDir = join(workingDirectory, "fjall");
|
|
26
|
+
const fjallDirExists = await fileExists(fjallDir);
|
|
27
|
+
logger.debug("resolveOperation", "fjall/ dir check", {
|
|
28
|
+
fjallDir,
|
|
29
|
+
exists: fjallDirExists
|
|
30
|
+
});
|
|
31
|
+
if (fjallDirExists && logger.isDebugEnabled()) {
|
|
32
|
+
try {
|
|
33
|
+
const entries = await readdir(fjallDir);
|
|
34
|
+
logger.debug("resolveOperation", "fjall/ contents", { entries });
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
logger.debug("resolveOperation", "fjall/ readdir failed", {
|
|
38
|
+
error: String(err)
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
15
42
|
// Check for organisation-level deployment
|
|
16
43
|
const normalisedTarget = target.toLowerCase();
|
|
17
|
-
if (
|
|
44
|
+
if (isOrganisationType(normalisedTarget)) {
|
|
18
45
|
const orgPath = join(workingDirectory, "fjall", normalisedTarget);
|
|
19
|
-
|
|
46
|
+
const orgPathExists = await fileExists(orgPath);
|
|
47
|
+
logger.debug("resolveOperation", "org type match", {
|
|
48
|
+
normalisedTarget,
|
|
49
|
+
orgPath,
|
|
50
|
+
exists: orgPathExists
|
|
51
|
+
});
|
|
52
|
+
if (!orgPathExists) {
|
|
20
53
|
return failure(new Error(`Organisation target "${target}" resolved to fjall/${normalisedTarget}/ but directory not found`));
|
|
21
54
|
}
|
|
22
55
|
return success({
|
|
@@ -27,9 +60,19 @@ export async function resolveOperation(target, workingDirectory) {
|
|
|
27
60
|
});
|
|
28
61
|
}
|
|
29
62
|
// Check for account-prefixed targets (e.g., "account-prod")
|
|
30
|
-
if (normalisedTarget.startsWith("account")) {
|
|
63
|
+
if (normalisedTarget.startsWith("account-")) {
|
|
64
|
+
const suffix = normalisedTarget.slice("account-".length);
|
|
65
|
+
if (!TARGET_PATTERN.test(suffix)) {
|
|
66
|
+
return failure(new Error(`Invalid account target "${target}": suffix must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens`));
|
|
67
|
+
}
|
|
31
68
|
const orgPath = join(workingDirectory, "fjall", ORGANISATION_TYPES.ACCOUNT);
|
|
32
|
-
|
|
69
|
+
const orgPathExists = await fileExists(orgPath);
|
|
70
|
+
logger.debug("resolveOperation", "account-prefixed target", {
|
|
71
|
+
suffix,
|
|
72
|
+
orgPath,
|
|
73
|
+
exists: orgPathExists
|
|
74
|
+
});
|
|
75
|
+
if (!orgPathExists) {
|
|
33
76
|
return failure(new Error(`Organisation target "${target}" resolved to fjall/${ORGANISATION_TYPES.ACCOUNT}/ but directory not found`));
|
|
34
77
|
}
|
|
35
78
|
return success({
|
|
@@ -41,6 +84,10 @@ export async function resolveOperation(target, workingDirectory) {
|
|
|
41
84
|
}
|
|
42
85
|
// Validate target before joining into filesystem path
|
|
43
86
|
if (!TARGET_PATTERN.test(target)) {
|
|
87
|
+
logger.debug("resolveOperation", "target failed pattern validation", {
|
|
88
|
+
target,
|
|
89
|
+
pattern: TARGET_PATTERN.source
|
|
90
|
+
});
|
|
44
91
|
return failure(new Error(`Invalid target "${target}": must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens`));
|
|
45
92
|
}
|
|
46
93
|
// Check for application deployment
|
|
@@ -50,12 +97,27 @@ export async function resolveOperation(target, workingDirectory) {
|
|
|
50
97
|
if (rel.startsWith("..")) {
|
|
51
98
|
return failure(new Error(`Invalid target "${target}": resolved path escapes working directory`));
|
|
52
99
|
}
|
|
53
|
-
|
|
100
|
+
const appPathExists = await fileExists(appPath);
|
|
101
|
+
logger.debug("resolveOperation", "app path check", {
|
|
102
|
+
appPath,
|
|
103
|
+
exists: appPathExists,
|
|
104
|
+
relative: rel
|
|
105
|
+
});
|
|
106
|
+
if (appPathExists) {
|
|
107
|
+
logger.debug("resolveOperation", "resolved as application", {
|
|
108
|
+
appName: target,
|
|
109
|
+
path: appPath
|
|
110
|
+
});
|
|
54
111
|
return success({
|
|
55
112
|
kind: "application",
|
|
56
113
|
appName: target,
|
|
57
114
|
path: appPath
|
|
58
115
|
});
|
|
59
116
|
}
|
|
117
|
+
logger.debug("resolveOperation", "FAILED — no match", {
|
|
118
|
+
target,
|
|
119
|
+
workingDirectory,
|
|
120
|
+
checkedPath: appPath
|
|
121
|
+
});
|
|
60
122
|
return failure(new Error(`Target "${target}" is not a recognised organisation type and no directory found at fjall/${target}`));
|
|
61
123
|
}
|
|
@@ -3,6 +3,7 @@ import { CdkService } from "../services/infrastructure/CdkService.js";
|
|
|
3
3
|
import { CloudFormationService } from "../services/infrastructure/CloudFormationService.js";
|
|
4
4
|
import { ApplicationStackService } from "../services/application/ApplicationStackService.js";
|
|
5
5
|
import { TemplateHashService } from "../services/supporting/TemplateHashService.js";
|
|
6
|
+
import { FrameworkRegistry } from "./builders/frameworkRegistry.js";
|
|
6
7
|
import type { DeployParams } from "../types/params.js";
|
|
7
8
|
export interface DeployServices {
|
|
8
9
|
awsProvider: SimpleAwsProvider;
|
|
@@ -10,6 +11,9 @@ export interface DeployServices {
|
|
|
10
11
|
cfnService: CloudFormationService;
|
|
11
12
|
stackService: ApplicationStackService;
|
|
12
13
|
hashService: TemplateHashService;
|
|
14
|
+
frameworkRegistry: FrameworkRegistry;
|
|
15
|
+
/** Deregister process signal handlers and clean up child processes. */
|
|
16
|
+
dispose(): void;
|
|
13
17
|
}
|
|
14
18
|
/** CDK needs AWS credentials as env vars, so exportToEnv() is called immediately. */
|
|
15
19
|
export declare function createDeployServices(params: DeployParams): DeployServices;
|
|
@@ -1,16 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { CdkService } from "../services/infrastructure/CdkService.js";
|
|
3
|
-
import { CloudFormationService } from "../services/infrastructure/CloudFormationService.js";
|
|
4
|
-
import { ApplicationStackService } from "../services/application/ApplicationStackService.js";
|
|
5
|
-
import { TemplateHashService } from "../services/supporting/TemplateHashService.js";
|
|
6
|
-
/** CDK needs AWS credentials as env vars, so exportToEnv() is called immediately. */
|
|
7
|
-
export function createDeployServices(params) {
|
|
8
|
-
const awsProvider = new SimpleAwsProvider(params.awsCredentials);
|
|
9
|
-
awsProvider.exportToEnv();
|
|
10
|
-
const cdkService = new CdkService();
|
|
11
|
-
const cfnService = new CloudFormationService(awsProvider);
|
|
12
|
-
const stackService = new ApplicationStackService(cdkService, cfnService);
|
|
13
|
-
stackService.setAwsContext(awsProvider);
|
|
14
|
-
const hashService = new TemplateHashService();
|
|
15
|
-
return { awsProvider, cdkService, cfnService, stackService, hashService };
|
|
16
|
-
}
|
|
1
|
+
import{SimpleAwsProvider as a}from"../aws/SimpleAwsProvider.js";import{CdkService as p}from"../services/infrastructure/CdkService.js";import{CdkArgumentBuilder as d}from"../services/infrastructure/CdkArgumentBuilder.js";import{CdkProcessManager as f}from"../services/infrastructure/CdkProcessManager.js";import{CloudFormationService as v}from"../services/infrastructure/CloudFormationService.js";import{ApplicationStackService as w}from"../services/application/ApplicationStackService.js";import{TemplateHashService as S}from"../services/supporting/TemplateHashService.js";import{FrameworkRegistry as l}from"./builders/frameworkRegistry.js";function x(i){const e=new a(i.awsCredentials);e.exportToEnv();const c=new d,r=new f(c),o=new p({processManager:r}),t=new v(e),n=new w(o,t,e),s=new S,m=l.createDefault();return{awsProvider:e,cdkService:o,cfnService:t,stackService:n,hashService:s,frameworkRegistry:m,dispose(){r.dispose()}}}export{x as createDeployServices};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic process-spawning utilities with timeout handling,
|
|
3
|
+
* stream cleanup, and discriminated result types.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from openNextBuild.ts so other builders can reuse
|
|
6
|
+
* the same timeout/stream/ENOENT patterns.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
/**
|
|
10
|
+
* Result from a spawned process with timeout handling.
|
|
11
|
+
*/
|
|
12
|
+
export type SpawnResult = {
|
|
13
|
+
type: "success";
|
|
14
|
+
code: number;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "timeout";
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: "enoent";
|
|
23
|
+
error: Error;
|
|
24
|
+
} | {
|
|
25
|
+
type: "spawn_error";
|
|
26
|
+
error: Error;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Safely destroy process streams to prevent resource leaks.
|
|
30
|
+
*/
|
|
31
|
+
export declare function destroyProcessStreams(childProcess: ReturnType<typeof spawn>): void;
|
|
32
|
+
/**
|
|
33
|
+
* Type guard for ENOENT errors (command not found).
|
|
34
|
+
*/
|
|
35
|
+
export declare function isEnoentError(err: unknown): err is NodeJS.ErrnoException;
|
|
36
|
+
/**
|
|
37
|
+
* Spawn a child process with timeout handling and stream collection.
|
|
38
|
+
*/
|
|
39
|
+
export declare function spawnWithTimeout(options: {
|
|
40
|
+
command: string;
|
|
41
|
+
args: string[];
|
|
42
|
+
cwd: string;
|
|
43
|
+
env: Record<string, string | undefined>;
|
|
44
|
+
timeout: number;
|
|
45
|
+
onStdoutData?: (data: string) => void;
|
|
46
|
+
onStderrData?: (data: string) => void;
|
|
47
|
+
}): Promise<SpawnResult>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{spawn as S}from"child_process";import{logger as a}from"@fjall/util/logger";function w(o){try{o.stdout?.destroy()}catch(r){a.debug("spawnHelpers","stdout destroy failed",{error:String(r)})}try{o.stderr?.destroy()}catch(r){a.debug("spawnHelpers","stderr destroy failed",{error:String(r)})}}function E(o){return o instanceof Error&&"code"in o&&o.code==="ENOENT"}function k(o){const{command:r,args:p,cwd:l,env:m,timeout:f,onStdoutData:y,onStderrData:g}=o;return new Promise(h=>{let u=!1;const n=t=>{u||(u=!0,h(t))},e=S(r,p,{cwd:l,shell:!1,stdio:["ignore","pipe","pipe"],env:m}),i=[],d=[];e.stdout?.on("data",t=>{const s=t.toString();i.push(s),y?.(s)}),e.stderr?.on("data",t=>{const s=t.toString();d.push(s),g?.(s)});const c=setTimeout(()=>{w(e),e.kill("SIGTERM"),n({type:"timeout",stdout:i.join(""),stderr:d.join("")})},f);e.on("close",t=>{clearTimeout(c),n({type:"success",code:t??1,stdout:i.join(""),stderr:d.join("")})}),e.on("error",t=>{clearTimeout(c),E(t)?n({type:"enoent",error:t}):n({type:"spawn_error",error:t})})})}export{w as destroyProcessStreams,E as isEnoentError,k as spawnWithTimeout};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for cleaning up 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
|
+
* Ported from cli/src/services/utils/stackCleanup.ts for deploy-core consumers
|
|
12
|
+
* (webapp worker, CLI via deploy-core).
|
|
13
|
+
*/
|
|
14
|
+
import type { DeployCallbacks } from "../types/callbacks.js";
|
|
15
|
+
/** Stack states safe to auto-delete -- never had a successful deployment. */
|
|
16
|
+
export declare const SAFE_CLEANUP_STATES: Set<string>;
|
|
17
|
+
/** Check whether a stack status is safe for automatic cleanup. */
|
|
18
|
+
export declare function isCleanableState(status: string): boolean;
|
|
19
|
+
interface StackCleanupCredentials {
|
|
20
|
+
accessKeyId: string;
|
|
21
|
+
secretAccessKey: string;
|
|
22
|
+
sessionToken?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Clean up a CloudFormation stack stuck in a failed state.
|
|
26
|
+
* Best-effort: all errors are caught and logged, never throws.
|
|
27
|
+
*
|
|
28
|
+
* Flow:
|
|
29
|
+
* 1. Check stack status -- skip if not in SAFE_CLEANUP_STATES
|
|
30
|
+
* 2. Find and empty S3 buckets blocking deletion
|
|
31
|
+
* 3. Delete the stack
|
|
32
|
+
* 4. Poll for DELETE_COMPLETE (5min timeout, 5s interval)
|
|
33
|
+
* 5. If DELETE_FAILED again, retry once with RetainResources for non-S3 failures
|
|
34
|
+
*/
|
|
35
|
+
export declare function cleanupFailedStack(stackName: string, region: string, credentials: StackCleanupCredentials, options?: {
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
pollMs?: number;
|
|
38
|
+
}, callbacks?: DeployCallbacks): Promise<void>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CloudFormationClient as y,DescribeStacksCommand as w,DeleteStackCommand as C,ListStackResourcesCommand as D}from"@aws-sdk/client-cloudformation";import{S3Client as _,ListObjectVersionsCommand as $,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,sleep as h}from"@fjall/util";import{STACK_NOT_FOUND_PATTERN as k}from"../types/constants.js";const L=1e3,m=new Set(["ROLLBACK_FAILED","ROLLBACK_COMPLETE","DELETE_FAILED"]);function M(e){return m.has(e)}async function P(e,t,n){let d,s;n?.onStackCleanupProgress?.(t,"emptying-bucket");const c=1e3;let l=0;for(;l++<c;){let r;try{r=await e.send(new $({Bucket:t,KeyMarker:d,VersionIdMarker:s}))}catch(u){if(u instanceof Error&&(u.name==="NoSuchBucket"||u.message?.includes("NoSuchBucket"))){o.debug("stackCleanup",`Bucket ${t} no longer exists, skipping`);return}const p=`Unexpected error emptying bucket ${t}: ${S(u)}`;o.warn("stackCleanup",p),n?.onLog?.(p,"warn");return}const i=[...r.Versions??[],...r.DeleteMarkers??[]];if(i.length===0)break;for(let u=0;u<i.length;u+=L){const p=i.slice(u,u+L);try{await e.send(new A({Bucket:t,Delete:{Objects:p.map(a=>({Key:a.Key,VersionId:a.VersionId})),Quiet:!0}}))}catch(a){const E=`Failed to delete batch from ${t}: ${S(a)}`;o.warn("stackCleanup",E),n?.onLog?.(E,"warn")}}if(!r.IsTruncated)break;d=r.NextKeyMarker,s=r.NextVersionIdMarker}if(l>c){const r=`Bucket ${t} reached ${c} page limit \u2014 some objects may remain`;o.warn("stackCleanup",r),n?.onLog?.(r,"warn")}o.debug("stackCleanup",`Emptied bucket ${t}`)}async function g(e,t,n,d){const s=[];let c,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 D({StackName:t,NextToken:c}));for(const u of i.StackResourceSummaries??[])if(n(u)){const p=d(u);p&&s.push(p)}c=i.NextToken}while(c);return s}async function R(e,t){return g(e,t,n=>n.ResourceType==="AWS::S3::Bucket"&&n.ResourceStatus==="DELETE_FAILED",n=>n.PhysicalResourceId)}async function V(e,t,n,d,s){const c=d?.timeoutMs??3e5,l=d?.pollMs??5e3;try{const r=new y({region:t,credentials:n,requestHandler:new f({requestTimeout:15e3})});let i;try{i=(await r.send(new w({StackName:e}))).Stacks?.[0]?.StackStatus}catch(a){if(a instanceof Error&&a.message?.includes(k)){o.debug("stackCleanup",`Stack ${e} does not exist, no cleanup needed`);return}o.warn("stackCleanup",`Failed to check stack status: ${S(a)}`,{stackName:e,region:t});return}if(!i||!M(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 u=new _({region:t,credentials:n,requestHandler:new f({requestTimeout:15e3})});try{const a=await R(r,e);for(const E of a)o.warn("stackCleanup",`Emptying bucket ${E}`,{region:t}),await P(u,E,s)}catch(a){const E=`Failed to empty S3 buckets: ${S(a)}`;o.warn("stackCleanup",E,{stackName:e,region:t}),s?.onLog?.(E,"warn")}await r.send(new C({StackName:e})),s?.onStackCleanupProgress?.(e,"waiting");const p=await T(r,e,c,l);if(p==="DELETE_COMPLETE"){o.warn("stackCleanup",`${e} stack deleted successfully`,{region:t}),s?.onStackCleanupProgress?.(e,"complete");return}if(p==="DELETE_FAILED"){o.warn("stackCleanup",`${e} still in DELETE_FAILED, retrying with RetainResources`,{region:t});const a=await F(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 E=await T(r,e,c,l);E==="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: ${E}`,{region:t}),s?.onStackCleanupProgress?.(e,"error"))}}}catch(r){o.warn("stackCleanup",`Stack cleanup failed: ${S(r)}`,{stackName:e,region:t}),s?.onStackCleanupProgress?.(e,"error")}}async function T(e,t,n,d){const s=Date.now();for(;Date.now()-s<n;){await h(d);try{const l=(await e.send(new w({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(c){if(c instanceof Error&&c.message?.includes(k))return"DELETE_COMPLETE";throw o.debug("stackCleanup",`Unexpected error polling ${t}: ${S(c)}`),c}}return"TIMEOUT"}async function F(e,t){return g(e,t,n=>n.ResourceStatus==="DELETE_FAILED"&&n.ResourceType!=="AWS::S3::Bucket",n=>n.LogicalResourceId)}export{m as SAFE_CLEANUP_STATES,V as cleanupFailedStack,M as isCleanableState};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Result } from "@fjall/generator";
|
|
2
|
+
import type { DeployParams } from "../types/params.js";
|
|
3
|
+
import type { DeployCallbacks } from "../types/callbacks.js";
|
|
4
|
+
import type { ApplicationOperation } from "../types/operations.js";
|
|
5
|
+
import type { DeployServices } from "./serviceFactory.js";
|
|
6
|
+
/**
|
|
7
|
+
* Initialise ECR with the welcome image and tag for ECS services.
|
|
8
|
+
* Runs before Compute stack for apps without a Dockerfile.
|
|
9
|
+
*
|
|
10
|
+
* Two phases:
|
|
11
|
+
* 1. ECR init — ensures the welcome image exists in the private ECR repo
|
|
12
|
+
* 2. Image tagging — copies :latest/:backend to :<serviceName>-latest
|
|
13
|
+
* so ECS task definitions can pull the correct tag
|
|
14
|
+
*/
|
|
15
|
+
export declare function runWelcomeImageSetup(params: DeployParams, services: DeployServices, operation: ApplicationOperation, callbacks: DeployCallbacks): Promise<Result<void>>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { success, failure } from "@fjall/generator";
|
|
2
|
+
import { maskSensitiveOutput } from "@fjall/util";
|
|
3
|
+
import { STEP_IDS } from "../types/stepDefinitions.js";
|
|
4
|
+
const TAG_IMAGES_STEP_NAME = "Tagging container images";
|
|
5
|
+
/**
|
|
6
|
+
* Initialise ECR with the welcome image and tag for ECS services.
|
|
7
|
+
* Runs before Compute stack for apps without a Dockerfile.
|
|
8
|
+
*
|
|
9
|
+
* Two phases:
|
|
10
|
+
* 1. ECR init — ensures the welcome image exists in the private ECR repo
|
|
11
|
+
* 2. Image tagging — copies :latest/:backend to :<serviceName>-latest
|
|
12
|
+
* so ECS task definitions can pull the correct tag
|
|
13
|
+
*/
|
|
14
|
+
export async function runWelcomeImageSetup(params, services, operation, callbacks) {
|
|
15
|
+
const dockerProvider = params.dockerProvider;
|
|
16
|
+
if (!dockerProvider)
|
|
17
|
+
return success(undefined);
|
|
18
|
+
const accountId = services.awsProvider.getAccountId();
|
|
19
|
+
if (!accountId) {
|
|
20
|
+
callbacks.onLog?.("Skipping ECR initialisation — account ID not available", "warn");
|
|
21
|
+
return success(undefined);
|
|
22
|
+
}
|
|
23
|
+
const region = services.awsProvider.getRegion();
|
|
24
|
+
// Phase 1: Initialise ECR repository with welcome image
|
|
25
|
+
callbacks.onStepStart?.(STEP_IDS.DOCKER_OPERATIONS, "Initialising container repository");
|
|
26
|
+
callbacks.onLog?.("Initialising ECR repository with welcome image…", "info");
|
|
27
|
+
const ecrResult = await dockerProvider.initialiseECR({
|
|
28
|
+
appName: operation.appName,
|
|
29
|
+
region,
|
|
30
|
+
accountId
|
|
31
|
+
});
|
|
32
|
+
if (!ecrResult.success) {
|
|
33
|
+
callbacks.onLog?.(maskSensitiveOutput(`ECR initialisation warning: ${ecrResult.error.message}`), "warn");
|
|
34
|
+
}
|
|
35
|
+
callbacks.onStepComplete?.(STEP_IDS.DOCKER_OPERATIONS, "Initialising container repository", "completed");
|
|
36
|
+
// Phase 2: Tag images for ECS services
|
|
37
|
+
if (dockerProvider.tagImages) {
|
|
38
|
+
callbacks.onStepStart?.(STEP_IDS.TAG_ECR_IMAGES, TAG_IMAGES_STEP_NAME);
|
|
39
|
+
callbacks.onLog?.("Tagging ECR images for ECS services…", "info");
|
|
40
|
+
const tagProgress = (message) => {
|
|
41
|
+
callbacks.onLog?.(maskSensitiveOutput(message), "info");
|
|
42
|
+
};
|
|
43
|
+
const tagResult = await dockerProvider.tagImages({
|
|
44
|
+
appName: operation.appName,
|
|
45
|
+
appPath: operation.path,
|
|
46
|
+
region,
|
|
47
|
+
accountId
|
|
48
|
+
}, tagProgress);
|
|
49
|
+
if (!tagResult.success) {
|
|
50
|
+
const error = new Error(maskSensitiveOutput(tagResult.error.message));
|
|
51
|
+
callbacks.onError?.(error);
|
|
52
|
+
callbacks.onStepComplete?.(STEP_IDS.TAG_ECR_IMAGES, TAG_IMAGES_STEP_NAME, "error");
|
|
53
|
+
return failure(error);
|
|
54
|
+
}
|
|
55
|
+
callbacks.onLog?.(`Tagged ${tagResult.data.taggedServices.length} service(s): ${tagResult.data.taggedServices.join(", ")}`, "info");
|
|
56
|
+
// Emit sub-step events for each tagged service so the detail panel shows per-service progress
|
|
57
|
+
for (const service of tagResult.data.taggedServices) {
|
|
58
|
+
callbacks.onStepStart?.(`tag-ecr-images-${service}`, `Tagged ${service}`);
|
|
59
|
+
callbacks.onStepComplete?.(`tag-ecr-images-${service}`, `Tagged ${service}`, "completed");
|
|
60
|
+
}
|
|
61
|
+
callbacks.onStepComplete?.(STEP_IDS.TAG_ECR_IMAGES, TAG_IMAGES_STEP_NAME, "completed");
|
|
62
|
+
}
|
|
63
|
+
return success(undefined);
|
|
64
|
+
}
|