@go-to-k/cdkd 0.143.0 → 0.144.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/README.md +5 -2
- package/dist/cli.js +677 -351
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-Dff3_JMn.js → deploy-engine-DjnWyAAc.js} +32 -8
- package/dist/deploy-engine-DjnWyAAc.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine-Dff3_JMn.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { _ as withSkipPrefix, a as runDockerStreaming, c as getLogger, d as getLiveRenderer, f as PATTERN_B_NAME_PROPERTIES, g as generateResourceNameWithFallback, h as generateResourceName, i as runDockerForeground, n as formatDockerLoginError, p as PATTERN_B_RESOURCE_TYPES, r as getDockerCmd, u as runStackBuffered, v as withStackName } from "./docker-cmd-EtWSTAje.js";
|
|
3
|
-
import { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as MIGRATE_TMP_PREFIX, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, J as AssemblyReader, K as findLargeInlineResources, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, Q as CdkdError, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as CFN_TEMPLATE_BODY_LIMIT, V as resolveStateBucketWithDefaultAndSource, W as CFN_TEMPLATE_URL_LIMIT, X as resolveBucketRegion, _ as normalizeAwsTagsToCfn, _t as normalizeAwsError, a as withRetry, at as MissingCdkCliError, b as CloudControlProvider, c as cyan, ct as ResourceTimeoutError, d as red, dt as StackHasActiveImportsError, f as yellow, ft as StackTerminationProtectionError, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, j as stringifyValue, k as shouldRetainResource, l as gray, lt as ResourceUpdateNotSupportedError, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as LocalMigrateError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as PartialFailureError, p as IAMRoleProvider, q as uploadCfnTemplate, r as DeployEngine, rt as LocalStartServiceError, s as bold, st as ProvisioningError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as LocalInvokeBuildError, u as green, ut as RouteDiscoveryError, v as resolveExplicitPhysicalId, vt as withErrorHandling, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-
|
|
3
|
+
import { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as MIGRATE_TMP_PREFIX, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, J as AssemblyReader, K as findLargeInlineResources, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, Q as CdkdError, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as CFN_TEMPLATE_BODY_LIMIT, V as resolveStateBucketWithDefaultAndSource, W as CFN_TEMPLATE_URL_LIMIT, X as resolveBucketRegion, _ as normalizeAwsTagsToCfn, _t as normalizeAwsError, a as withRetry, at as MissingCdkCliError, b as CloudControlProvider, c as cyan, ct as ResourceTimeoutError, d as red, dt as StackHasActiveImportsError, f as yellow, ft as StackTerminationProtectionError, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, j as stringifyValue, k as shouldRetainResource, l as gray, lt as ResourceUpdateNotSupportedError, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as LocalMigrateError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as PartialFailureError, p as IAMRoleProvider, q as uploadCfnTemplate, r as DeployEngine, rt as LocalStartServiceError, s as bold, st as ProvisioningError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as LocalInvokeBuildError, u as green, ut as RouteDiscoveryError, v as resolveExplicitPhysicalId, vt as withErrorHandling, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-DjnWyAAc.js";
|
|
4
4
|
import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
6
|
import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
7
|
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
7
8
|
import { AddRoleToInstanceProfileCommand, AddUserToGroupCommand, AttachGroupPolicyCommand, AttachUserPolicyCommand, CreateGroupCommand, CreateInstanceProfileCommand, CreateLoginProfileCommand, CreateUserCommand, DeleteAccessKeyCommand, DeleteGroupCommand, DeleteGroupPolicyCommand, DeleteInstanceProfileCommand, DeleteLoginProfileCommand, DeleteRolePolicyCommand, DeleteUserCommand, DeleteUserPermissionsBoundaryCommand, DeleteUserPolicyCommand, DetachGroupPolicyCommand, DetachUserPolicyCommand, GetGroupCommand, GetGroupPolicyCommand, GetInstanceProfileCommand, GetRolePolicyCommand, GetUserCommand, GetUserPolicyCommand, IAMClient, ListAccessKeysCommand, ListAttachedGroupPoliciesCommand, ListAttachedUserPoliciesCommand, ListGroupPoliciesCommand, ListGroupsForUserCommand, ListInstanceProfilesCommand, ListUserPoliciesCommand, ListUserTagsCommand, ListUsersCommand, NoSuchEntityException, PutGroupPolicyCommand, PutRolePolicyCommand, PutUserPermissionsBoundaryCommand, PutUserPolicyCommand, RemoveRoleFromInstanceProfileCommand, RemoveUserFromGroupCommand, TagUserCommand, UntagUserCommand, UpdateLoginProfileCommand } from "@aws-sdk/client-iam";
|
|
@@ -20,6 +21,7 @@ import { CloudFrontClient, CreateCloudFrontOriginAccessIdentityCommand, CreateDi
|
|
|
20
21
|
import { CloudWatchClient, DeleteAlarmsCommand, DescribeAlarmsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$4, PutMetricAlarmCommand, TagResourceCommand as TagResourceCommand$6, UntagResourceCommand as UntagResourceCommand$6 } from "@aws-sdk/client-cloudwatch";
|
|
21
22
|
import { CloudWatchLogsClient, CreateLogGroupCommand, DeleteDataProtectionPolicyCommand, DeleteIndexPolicyCommand, DeleteLogGroupCommand, DeleteRetentionPolicyCommand, DescribeIndexPoliciesCommand, DescribeLogGroupsCommand, GetDataProtectionPolicyCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$5, PutBearerTokenAuthenticationCommand, PutDataProtectionPolicyCommand, PutIndexPolicyCommand, PutLogGroupDeletionProtectionCommand, PutRetentionPolicyCommand, ResourceAlreadyExistsException, ResourceNotFoundException as ResourceNotFoundException$4, TagResourceCommand as TagResourceCommand$7, UntagResourceCommand as UntagResourceCommand$7 } from "@aws-sdk/client-cloudwatch-logs";
|
|
22
23
|
import { BedrockAgentCoreControlClient, CreateAgentRuntimeCommand, DeleteAgentRuntimeCommand, GetAgentRuntimeCommand, ResourceNotFoundException as ResourceNotFoundException$5, UpdateAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore-control";
|
|
24
|
+
import * as fs from "node:fs";
|
|
23
25
|
import { cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
26
|
import * as path from "node:path";
|
|
25
27
|
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
@@ -30944,6 +30946,602 @@ function mapNotificationsToCfn(configurations) {
|
|
|
30944
30946
|
return result;
|
|
30945
30947
|
}
|
|
30946
30948
|
|
|
30949
|
+
//#endregion
|
|
30950
|
+
//#region src/cli/commands/destroy-runner.ts
|
|
30951
|
+
/**
|
|
30952
|
+
* Resource-type → state-property name pairs that gate AWS deletion
|
|
30953
|
+
* protection. Used by the `--remove-protection` confirmation prompt to
|
|
30954
|
+
* report a best-effort count of resources that will have protection
|
|
30955
|
+
* cleared. The actual flip-off is unconditional inside each provider's
|
|
30956
|
+
* `delete()` (idempotent — safe when AWS already has protection off),
|
|
30957
|
+
* so the count is informational only.
|
|
30958
|
+
*
|
|
30959
|
+
* Most types use a boolean flag — the value `true` is what we count.
|
|
30960
|
+
* Two types use a string-valued enum (Cognito UserPool's
|
|
30961
|
+
* `DeletionProtection` is `'ACTIVE' | 'INACTIVE'`, AutoScalingGroup's
|
|
30962
|
+
* `DeletionProtection` is `'none' | 'prevent-force-deletion' |
|
|
30963
|
+
* 'prevent-all-deletion'`). For those, the helper checks against a
|
|
30964
|
+
* per-type set of "active" values via `PROTECTION_ACTIVE_VALUES_BY_TYPE`.
|
|
30965
|
+
*
|
|
30966
|
+
* Exported for unit-test coverage of `countProtectedResources`.
|
|
30967
|
+
*/
|
|
30968
|
+
const PROTECTION_PROPERTY_BY_TYPE = {
|
|
30969
|
+
"AWS::Logs::LogGroup": "DeletionProtectionEnabled",
|
|
30970
|
+
"AWS::RDS::DBInstance": "DeletionProtection",
|
|
30971
|
+
"AWS::RDS::DBCluster": "DeletionProtection",
|
|
30972
|
+
"AWS::DocDB::DBCluster": "DeletionProtection",
|
|
30973
|
+
"AWS::Neptune::DBCluster": "DeletionProtection",
|
|
30974
|
+
"AWS::Neptune::DBInstance": "DeletionProtection",
|
|
30975
|
+
"AWS::DynamoDB::Table": "DeletionProtectionEnabled",
|
|
30976
|
+
"AWS::DynamoDB::GlobalTable": "DeletionProtectionEnabled",
|
|
30977
|
+
"AWS::EC2::Instance": "DisableApiTermination",
|
|
30978
|
+
"AWS::Cognito::UserPool": "DeletionProtection",
|
|
30979
|
+
"AWS::AutoScaling::AutoScalingGroup": "DeletionProtection"
|
|
30980
|
+
};
|
|
30981
|
+
/**
|
|
30982
|
+
* For string-valued protection enums, the set of values that count as
|
|
30983
|
+
* "currently protected". Types absent from this map use the default
|
|
30984
|
+
* (boolean `true`).
|
|
30985
|
+
*/
|
|
30986
|
+
const PROTECTION_ACTIVE_VALUES_BY_TYPE = {
|
|
30987
|
+
"AWS::Cognito::UserPool": new Set(["ACTIVE"]),
|
|
30988
|
+
"AWS::AutoScaling::AutoScalingGroup": new Set(["prevent-force-deletion", "prevent-all-deletion"])
|
|
30989
|
+
};
|
|
30990
|
+
/**
|
|
30991
|
+
* Count how many resources in a stack's recorded state appear to have
|
|
30992
|
+
* deletion protection enabled. Walks `properties` and `observedProperties`
|
|
30993
|
+
* for the property name registered against each resource type in
|
|
30994
|
+
* `PROTECTION_PROPERTY_BY_TYPE`. ELBv2 LoadBalancer protection lives in
|
|
30995
|
+
* `LoadBalancerAttributes` (a CFn `Array<{Key, Value}>`), so it's
|
|
30996
|
+
* handled separately via the `deletion_protection.enabled` key.
|
|
30997
|
+
*/
|
|
30998
|
+
function countProtectedResources(state) {
|
|
30999
|
+
let count = 0;
|
|
31000
|
+
for (const resource of Object.values(state.resources ?? {})) {
|
|
31001
|
+
const propName = PROTECTION_PROPERTY_BY_TYPE[resource.resourceType];
|
|
31002
|
+
if (propName) {
|
|
31003
|
+
const recorded = resource.properties?.[propName] ?? resource.observedProperties?.[propName];
|
|
31004
|
+
const activeValues = PROTECTION_ACTIVE_VALUES_BY_TYPE[resource.resourceType];
|
|
31005
|
+
if (activeValues) {
|
|
31006
|
+
if (activeValues.has(recorded)) count++;
|
|
31007
|
+
} else if (recorded === true) count++;
|
|
31008
|
+
continue;
|
|
31009
|
+
}
|
|
31010
|
+
if (resource.resourceType === "AWS::ElasticLoadBalancingV2::LoadBalancer") {
|
|
31011
|
+
if (((resource.properties?.["LoadBalancerAttributes"] ?? resource.observedProperties?.["LoadBalancerAttributes"])?.find((a) => a?.Key === "deletion_protection.enabled"))?.Value === "true") count++;
|
|
31012
|
+
}
|
|
31013
|
+
}
|
|
31014
|
+
return count;
|
|
31015
|
+
}
|
|
31016
|
+
/**
|
|
31017
|
+
* Run the destroy lifecycle for one stack against an already-loaded
|
|
31018
|
+
* `StackState`, reusing the caller's state backend / lock manager.
|
|
31019
|
+
*
|
|
31020
|
+
* Hoisted from `cdkd destroy` so the new `cdkd state destroy` subcommand
|
|
31021
|
+
* can call into the exact same per-stack pipeline without depending on
|
|
31022
|
+
* synth or the CDK app. The state-source split is the only meaningful
|
|
31023
|
+
* difference between the two commands — everything from "prompt the user"
|
|
31024
|
+
* onwards is identical.
|
|
31025
|
+
*
|
|
31026
|
+
* Side effects:
|
|
31027
|
+
* - Acquires (and releases) the stack's S3 lock.
|
|
31028
|
+
* - Switches `process.env.AWS_REGION` for the duration of the destroy when
|
|
31029
|
+
* the stack's recorded region differs from `baseRegion`. Restored in the
|
|
31030
|
+
* `finally` block.
|
|
31031
|
+
* - On full success, deletes the state file. On any failure, the state
|
|
31032
|
+
* file is preserved so the user can retry.
|
|
31033
|
+
*/
|
|
31034
|
+
async function runDestroyForStack(stackName, state, ctx) {
|
|
31035
|
+
const logger = getLogger();
|
|
31036
|
+
const result = {
|
|
31037
|
+
stackName,
|
|
31038
|
+
cancelled: false,
|
|
31039
|
+
skippedEmpty: false,
|
|
31040
|
+
deletedCount: 0,
|
|
31041
|
+
retainedCount: 0,
|
|
31042
|
+
errorCount: 0
|
|
31043
|
+
};
|
|
31044
|
+
const resourceCount = Object.keys(state.resources).length;
|
|
31045
|
+
const regionForState = state.region ?? ctx.baseRegion;
|
|
31046
|
+
if (resourceCount === 0) {
|
|
31047
|
+
logger.info(`Stack ${stackName} has no resources, cleaning up state...`);
|
|
31048
|
+
await ctx.stateBackend.deleteState(stackName, regionForState);
|
|
31049
|
+
logger.info(`${green("✓")} State deleted`);
|
|
31050
|
+
result.skippedEmpty = true;
|
|
31051
|
+
return result;
|
|
31052
|
+
}
|
|
31053
|
+
const needsStrongRefCheck = !!(state.outputs && Object.keys(state.outputs).length > 0);
|
|
31054
|
+
if (needsStrongRefCheck) {
|
|
31055
|
+
const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
|
|
31056
|
+
if (consumers.length > 0) throw new StackHasActiveImportsError(stackName, regionForState, consumers);
|
|
31057
|
+
}
|
|
31058
|
+
logger.info(`\nResources to be deleted (${resourceCount}):`);
|
|
31059
|
+
for (const [logicalId, resource] of Object.entries(state.resources)) logger.info(` - ${logicalId} (${resource.resourceType})`);
|
|
31060
|
+
const protectedCount = ctx.removeProtection ? countProtectedResources(state) : 0;
|
|
31061
|
+
if (!ctx.skipConfirmation) {
|
|
31062
|
+
const rl = readline.createInterface({
|
|
31063
|
+
input: process.stdin,
|
|
31064
|
+
output: process.stdout
|
|
31065
|
+
});
|
|
31066
|
+
const prompt = ctx.removeProtection ? `\nAbout to destroy ${resourceCount} resources from stack "${stackName}", REMOVING DELETION PROTECTION on ${protectedCount} of them. Continue? (y/N): ` : `\nAre you sure you want to destroy stack "${stackName}" and delete all ${resourceCount} resources? (Y/n): `;
|
|
31067
|
+
const answer = await rl.question(prompt);
|
|
31068
|
+
rl.close();
|
|
31069
|
+
const trimmed = answer.trim().toLowerCase();
|
|
31070
|
+
if (ctx.removeProtection) {
|
|
31071
|
+
if (trimmed !== "y" && trimmed !== "yes") {
|
|
31072
|
+
logger.info("Destroy cancelled");
|
|
31073
|
+
result.cancelled = true;
|
|
31074
|
+
return result;
|
|
31075
|
+
}
|
|
31076
|
+
} else if (trimmed === "n" || trimmed === "no") {
|
|
31077
|
+
logger.info("Destroy cancelled");
|
|
31078
|
+
result.cancelled = true;
|
|
31079
|
+
return result;
|
|
31080
|
+
}
|
|
31081
|
+
}
|
|
31082
|
+
const stackRegion = state.region;
|
|
31083
|
+
let destroyProviderRegistry = ctx.providerRegistry;
|
|
31084
|
+
let destroyAwsClients;
|
|
31085
|
+
if (stackRegion && stackRegion !== ctx.baseRegion) {
|
|
31086
|
+
logger.info(`Stack region: ${stackRegion}`);
|
|
31087
|
+
process.env["AWS_REGION"] = stackRegion;
|
|
31088
|
+
process.env["AWS_DEFAULT_REGION"] = stackRegion;
|
|
31089
|
+
destroyAwsClients = new AwsClients({
|
|
31090
|
+
region: stackRegion,
|
|
31091
|
+
...ctx.profile && { profile: ctx.profile }
|
|
31092
|
+
});
|
|
31093
|
+
setAwsClients(destroyAwsClients);
|
|
31094
|
+
destroyProviderRegistry = new ProviderRegistry();
|
|
31095
|
+
registerAllProviders(destroyProviderRegistry);
|
|
31096
|
+
destroyProviderRegistry.setCustomResourceResponseBucket(ctx.stateBucket);
|
|
31097
|
+
}
|
|
31098
|
+
logger.info(`\nAcquiring lock for stack ${stackName}...`);
|
|
31099
|
+
await ctx.lockManager.acquireLock(stackName, regionForState, void 0, "destroy");
|
|
31100
|
+
if (needsStrongRefCheck) {
|
|
31101
|
+
const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
|
|
31102
|
+
if (consumers.length > 0) {
|
|
31103
|
+
try {
|
|
31104
|
+
await ctx.lockManager.releaseLock(stackName, regionForState);
|
|
31105
|
+
} catch (releaseErr) {
|
|
31106
|
+
logger.warn(`Failed to release lock after strong-ref refusal: ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
|
|
31107
|
+
}
|
|
31108
|
+
throw new StackHasActiveImportsError(stackName, regionForState, consumers);
|
|
31109
|
+
}
|
|
31110
|
+
}
|
|
31111
|
+
const renderer = getLiveRenderer();
|
|
31112
|
+
renderer.start();
|
|
31113
|
+
try {
|
|
31114
|
+
logger.info("Building dependency graph...");
|
|
31115
|
+
const template = {
|
|
31116
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
31117
|
+
Resources: {}
|
|
31118
|
+
};
|
|
31119
|
+
for (const [logicalId, resource] of Object.entries(state.resources)) template.Resources[logicalId] = {
|
|
31120
|
+
Type: resource.resourceType,
|
|
31121
|
+
Properties: resource.properties || {},
|
|
31122
|
+
...resource.dependencies && resource.dependencies.length > 0 && { DependsOn: resource.dependencies }
|
|
31123
|
+
};
|
|
31124
|
+
const typeToLogicalIds = /* @__PURE__ */ new Map();
|
|
31125
|
+
for (const [logicalId, resource] of Object.entries(state.resources)) {
|
|
31126
|
+
const ids = typeToLogicalIds.get(resource.resourceType) ?? [];
|
|
31127
|
+
ids.push(logicalId);
|
|
31128
|
+
typeToLogicalIds.set(resource.resourceType, ids);
|
|
31129
|
+
}
|
|
31130
|
+
for (const [logicalId, resource] of Object.entries(state.resources)) {
|
|
31131
|
+
const mustDeleteAfter = IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
|
|
31132
|
+
if (!mustDeleteAfter) continue;
|
|
31133
|
+
for (const depType of mustDeleteAfter) {
|
|
31134
|
+
const depIds = typeToLogicalIds.get(depType);
|
|
31135
|
+
if (!depIds) continue;
|
|
31136
|
+
for (const depId of depIds) {
|
|
31137
|
+
const existing = template.Resources[depId]?.DependsOn ?? [];
|
|
31138
|
+
const depsArray = Array.isArray(existing) ? existing : [existing];
|
|
31139
|
+
if (!depsArray.includes(logicalId)) {
|
|
31140
|
+
template.Resources[depId] = {
|
|
31141
|
+
...template.Resources[depId],
|
|
31142
|
+
DependsOn: [...depsArray, logicalId]
|
|
31143
|
+
};
|
|
31144
|
+
logger.debug(`Implicit delete dependency: ${depId} (${depType}) must be deleted before ${logicalId} (${resource.resourceType})`);
|
|
31145
|
+
}
|
|
31146
|
+
}
|
|
31147
|
+
}
|
|
31148
|
+
}
|
|
31149
|
+
const dagBuilder = new DagBuilder();
|
|
31150
|
+
const graph = dagBuilder.buildGraph(template);
|
|
31151
|
+
const executionLevels = dagBuilder.getExecutionLevels(graph);
|
|
31152
|
+
logger.debug(`Dependency graph: ${executionLevels.length} level(s)`);
|
|
31153
|
+
for (let levelIndex = executionLevels.length - 1; levelIndex >= 0; levelIndex--) {
|
|
31154
|
+
const level = executionLevels[levelIndex];
|
|
31155
|
+
if (!level) continue;
|
|
31156
|
+
logger.debug(`Deletion level ${executionLevels.length - levelIndex}/${executionLevels.length} (${level.length} resources)`);
|
|
31157
|
+
const stackRegion = state.region ?? ctx.baseRegion;
|
|
31158
|
+
const deletePromises = level.map(async (logicalId) => {
|
|
31159
|
+
const resource = state.resources[logicalId];
|
|
31160
|
+
if (!resource) {
|
|
31161
|
+
logger.warn(`Resource ${logicalId} not found in state, skipping`);
|
|
31162
|
+
return;
|
|
31163
|
+
}
|
|
31164
|
+
if (shouldRetainResource(resource.deletionPolicy)) {
|
|
31165
|
+
logger.info(` ⊘ ${logicalId} (${resource.resourceType}) retained — DeletionPolicy: ${resource.deletionPolicy}`);
|
|
31166
|
+
result.retainedCount++;
|
|
31167
|
+
return;
|
|
31168
|
+
}
|
|
31169
|
+
const baseLabel = `Deleting ${logicalId} (${resource.resourceType})`;
|
|
31170
|
+
renderer.addTask(logicalId, baseLabel);
|
|
31171
|
+
try {
|
|
31172
|
+
const provider = destroyProviderRegistry.getProvider(resource.resourceType);
|
|
31173
|
+
const providerMinTimeoutMs = provider.getMinResourceTimeoutMs?.() ?? 0;
|
|
31174
|
+
const warnAfterMs = ctx.resourceWarnAfterByType?.[resource.resourceType] ?? ctx.resourceWarnAfterMs ?? 3e5;
|
|
31175
|
+
const globalTimeoutMs = ctx.resourceTimeoutMs ?? 18e5;
|
|
31176
|
+
const timeoutMs = ctx.resourceTimeoutByType?.[resource.resourceType] ?? Math.max(providerMinTimeoutMs, globalTimeoutMs);
|
|
31177
|
+
await withResourceDeadline(async () => {
|
|
31178
|
+
const maxAttempts = provider.disableOuterRetry ? 0 : 3;
|
|
31179
|
+
let lastDeleteError;
|
|
31180
|
+
for (let attempt = 0; attempt <= maxAttempts; attempt++) try {
|
|
31181
|
+
await provider.delete(logicalId, resource.physicalId, resource.resourceType, resource.properties, {
|
|
31182
|
+
...state.region !== void 0 && { expectedRegion: state.region },
|
|
31183
|
+
...ctx.removeProtection === true && { removeProtection: true }
|
|
31184
|
+
});
|
|
31185
|
+
lastDeleteError = null;
|
|
31186
|
+
break;
|
|
31187
|
+
} catch (retryError) {
|
|
31188
|
+
lastDeleteError = retryError;
|
|
31189
|
+
const msg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
31190
|
+
if (!(msg.includes("Too Many Requests") || msg.includes("has dependencies") || msg.includes("can't be deleted since") || msg.includes("DependencyViolation")) || attempt >= maxAttempts) break;
|
|
31191
|
+
const delay = 5e3 * Math.pow(2, attempt);
|
|
31192
|
+
logger.debug(` ⏳ Retrying delete ${logicalId} in ${delay / 1e3}s (attempt ${attempt + 1}/${maxAttempts})`);
|
|
31193
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
31194
|
+
}
|
|
31195
|
+
if (lastDeleteError) throw lastDeleteError;
|
|
31196
|
+
}, {
|
|
31197
|
+
warnAfterMs,
|
|
31198
|
+
timeoutMs,
|
|
31199
|
+
onWarn: (elapsedMs) => {
|
|
31200
|
+
const minutes = Math.max(1, Math.round(elapsedMs / 6e4));
|
|
31201
|
+
renderer.updateTaskLabel(logicalId, `${baseLabel} [taking longer than expected, ${minutes}m+]`);
|
|
31202
|
+
renderer.printAbove(() => {
|
|
31203
|
+
logger.warn(`${logicalId} (${resource.resourceType}) has been deleting for ${minutes}m — still waiting`);
|
|
31204
|
+
});
|
|
31205
|
+
},
|
|
31206
|
+
onTimeout: (elapsedMs) => new ResourceTimeoutError(logicalId, resource.resourceType, stackRegion, elapsedMs, "DELETE", timeoutMs)
|
|
31207
|
+
});
|
|
31208
|
+
renderer.removeTask(logicalId);
|
|
31209
|
+
logger.info(` ${red("✗")} ${bold(logicalId)} ${gray(`(${resource.resourceType})`)} ${red("deleted")}`);
|
|
31210
|
+
result.deletedCount++;
|
|
31211
|
+
} catch (error) {
|
|
31212
|
+
renderer.removeTask(logicalId);
|
|
31213
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
31214
|
+
if (msg.includes("does not exist") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException")) {
|
|
31215
|
+
logger.debug(` ${logicalId} already deleted, removing from state`);
|
|
31216
|
+
result.deletedCount++;
|
|
31217
|
+
} else if (error instanceof ResourceTimeoutError) {
|
|
31218
|
+
const wrapped = new ProvisioningError(error.message, resource.resourceType, logicalId, resource.physicalId, error);
|
|
31219
|
+
logger.error(` ✗ Failed to delete ${logicalId}:`, wrapped.message);
|
|
31220
|
+
result.errorCount++;
|
|
31221
|
+
} else {
|
|
31222
|
+
logger.error(` ✗ Failed to delete ${logicalId}:`, String(error));
|
|
31223
|
+
result.errorCount++;
|
|
31224
|
+
}
|
|
31225
|
+
} finally {
|
|
31226
|
+
renderer.removeTask(logicalId);
|
|
31227
|
+
}
|
|
31228
|
+
});
|
|
31229
|
+
await Promise.all(deletePromises);
|
|
31230
|
+
}
|
|
31231
|
+
if (result.errorCount === 0) {
|
|
31232
|
+
await ctx.stateBackend.deleteState(stackName, regionForState);
|
|
31233
|
+
logger.debug("State deleted");
|
|
31234
|
+
if (ctx.exportIndexStore) await ctx.exportIndexStore.removeStack(stackName, regionForState);
|
|
31235
|
+
} else logger.warn(`${result.errorCount} resource(s) failed to delete. State preserved.`);
|
|
31236
|
+
const retainedSuffix = result.retainedCount > 0 ? `, ${result.retainedCount} retained` : "";
|
|
31237
|
+
if (result.errorCount === 0) logger.info(`\n${green("✓")} ${bold(`Stack ${stackName} destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}, ${result.errorCount} errors)`);
|
|
31238
|
+
else logger.warn(`\n${yellow("⚠")} ${bold(`Stack ${stackName} partially destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}, ${red(result.errorCount)} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to clean up.`);
|
|
31239
|
+
} finally {
|
|
31240
|
+
renderer.stop();
|
|
31241
|
+
logger.debug("Releasing lock...");
|
|
31242
|
+
await ctx.lockManager.releaseLock(stackName, regionForState);
|
|
31243
|
+
if (destroyAwsClients) {
|
|
31244
|
+
destroyAwsClients.destroy();
|
|
31245
|
+
process.env["AWS_REGION"] = ctx.baseRegion;
|
|
31246
|
+
process.env["AWS_DEFAULT_REGION"] = ctx.baseRegion;
|
|
31247
|
+
setAwsClients(ctx.baseAwsClients);
|
|
31248
|
+
}
|
|
31249
|
+
}
|
|
31250
|
+
return result;
|
|
31251
|
+
}
|
|
31252
|
+
/**
|
|
31253
|
+
* Strong-reference scan: read every other stack's state.json from the
|
|
31254
|
+
* state bucket and check whether any of its `imports[]` entries names
|
|
31255
|
+
* `producerStack`. Returns the list of offending consumers (possibly
|
|
31256
|
+
* empty).
|
|
31257
|
+
*
|
|
31258
|
+
* NEVER trusts the persistent exports index — a stale index could miss
|
|
31259
|
+
* a freshly-recorded consumer and let a destructive destroy through.
|
|
31260
|
+
* The cost is one `listStacks` + N parallel GETs at destroy time only
|
|
31261
|
+
* (not the deploy hot path), which the user-facing UX rationalizes as
|
|
31262
|
+
* the "destroy is slow OK" trade-off (Issue #343).
|
|
31263
|
+
*/
|
|
31264
|
+
async function scanActiveConsumers(producerStack, producerRegion, ctx) {
|
|
31265
|
+
const refs = await ctx.stateBackend.listStacks();
|
|
31266
|
+
return (await Promise.all(refs.map(async (ref) => {
|
|
31267
|
+
const region = ref.region ?? ctx.baseRegion;
|
|
31268
|
+
if (ref.stackName === producerStack && region === producerRegion) return null;
|
|
31269
|
+
try {
|
|
31270
|
+
const imports = (await ctx.stateBackend.getState(ref.stackName, region))?.state.imports;
|
|
31271
|
+
if (!imports || imports.length === 0) return null;
|
|
31272
|
+
const matches = imports.filter((entry) => entry.sourceStack === producerStack && entry.sourceRegion === producerRegion);
|
|
31273
|
+
if (matches.length === 0) return null;
|
|
31274
|
+
return matches.map((entry) => ({
|
|
31275
|
+
consumerStack: ref.stackName,
|
|
31276
|
+
consumerRegion: region,
|
|
31277
|
+
exportName: entry.exportName
|
|
31278
|
+
}));
|
|
31279
|
+
} catch {
|
|
31280
|
+
return null;
|
|
31281
|
+
}
|
|
31282
|
+
}))).filter((r) => r !== null).flat();
|
|
31283
|
+
}
|
|
31284
|
+
|
|
31285
|
+
//#endregion
|
|
31286
|
+
//#region src/provisioning/nested-stack-context.ts
|
|
31287
|
+
const storage = new AsyncLocalStorage();
|
|
31288
|
+
/**
|
|
31289
|
+
* Run `fn` inside a NestedStackProvider context scope. Calls to
|
|
31290
|
+
* `getCurrentNestedStackContext()` from inside `fn` (and any awaited callees)
|
|
31291
|
+
* return `ctx`. Nested scopes shadow outer ones — the recursive provider
|
|
31292
|
+
* uses this to switch the "current parent" to the child before kicking off
|
|
31293
|
+
* the child's deploy / destroy, so grand-nested handling resolves against
|
|
31294
|
+
* the right parent.
|
|
31295
|
+
*/
|
|
31296
|
+
function withNestedStackContext(ctx, fn) {
|
|
31297
|
+
return storage.run(ctx, fn);
|
|
31298
|
+
}
|
|
31299
|
+
/**
|
|
31300
|
+
* Returns the current `NestedStackProviderContext`, or `undefined` when called
|
|
31301
|
+
* outside any `withNestedStackContext` scope (= cdkd is operating on a
|
|
31302
|
+
* top-level stack with no nested-stack work in flight).
|
|
31303
|
+
*
|
|
31304
|
+
* `NestedStackProvider.create / update / delete` MUST find a context here —
|
|
31305
|
+
* absence means a caller forgot to wrap the deploy / destroy entry point.
|
|
31306
|
+
*/
|
|
31307
|
+
function getCurrentNestedStackContext() {
|
|
31308
|
+
return storage.getStore();
|
|
31309
|
+
}
|
|
31310
|
+
|
|
31311
|
+
//#endregion
|
|
31312
|
+
//#region src/provisioning/providers/nested-stack-provider.ts
|
|
31313
|
+
/**
|
|
31314
|
+
* Provider for `AWS::CloudFormation::Stack` — cdkd's recursive nested-stack
|
|
31315
|
+
* adapter. Issue [#459](https://github.com/go-to-k/cdkd/issues/459); see
|
|
31316
|
+
* [docs/design/459-nested-stacks.md](../../../docs/design/459-nested-stacks.md)
|
|
31317
|
+
* for the full design.
|
|
31318
|
+
*
|
|
31319
|
+
* On `create` / `update`, the provider builds a child {@link DeployEngine}
|
|
31320
|
+
* against the same shared state backend / lock manager / provider registry,
|
|
31321
|
+
* deploys the child template recursively, and surfaces the child's outputs
|
|
31322
|
+
* as `attributes['Outputs.<Key>']` so the parent's
|
|
31323
|
+
* `Fn::GetAtt: [<NestedStack>, 'Outputs.<Key>']` references resolve via
|
|
31324
|
+
* the existing flat-key fast path in {@link IntrinsicFunctionResolver}.
|
|
31325
|
+
*
|
|
31326
|
+
* On `delete`, the provider loads the child's state and routes it through
|
|
31327
|
+
* {@link runDestroyForStack} for a regular reverse-DAG destroy — the same
|
|
31328
|
+
* code `cdkd destroy` uses on a top-level stack.
|
|
31329
|
+
*
|
|
31330
|
+
* The child's state file lives at
|
|
31331
|
+
* `cdkd/<parentStackName>~<NestedStackLogicalId>/<region>/state.json`
|
|
31332
|
+
* (the `~` separator is rare in CDK logical ids; verified safe against
|
|
31333
|
+
* CDK Stage paths which use `/`). The synthesized `physicalId` is a fake
|
|
31334
|
+
* ARN with `cdkd-local` partition so any downstream consumer that
|
|
31335
|
+
* accidentally uses it as a real AWS ARN fails loudly.
|
|
31336
|
+
*/
|
|
31337
|
+
var NestedStackProvider = class {
|
|
31338
|
+
logger = getLogger().child("NestedStackProvider");
|
|
31339
|
+
/**
|
|
31340
|
+
* Opt out of the deploy engine's outer transient-error retry loop. A
|
|
31341
|
+
* nested-stack `create` recursively spawns a child {@link DeployEngine}
|
|
31342
|
+
* that has its own retry / rollback machinery; an outer retry would
|
|
31343
|
+
* re-enter the entire child deploy on a transient error and produce
|
|
31344
|
+
* duplicate AWS resources before the second attempt's per-resource
|
|
31345
|
+
* state save settles. The child engine handles transient errors
|
|
31346
|
+
* internally — mirroring the same opt-out the Custom Resource provider
|
|
31347
|
+
* uses for the same reason.
|
|
31348
|
+
*/
|
|
31349
|
+
disableOuterRetry = true;
|
|
31350
|
+
/**
|
|
31351
|
+
* The CC API fallback path for `AWS::CloudFormation::Stack` would call
|
|
31352
|
+
* CloudFormation's own `CreateStack` — defeating cdkd's whole "no CFn"
|
|
31353
|
+
* approach for the nested children. Refuse the fallback so any future
|
|
31354
|
+
* regression that drops a real property from `handledProperties`
|
|
31355
|
+
* surfaces as an explicit "unhandled property" deploy error instead of
|
|
31356
|
+
* silently round-tripping through CloudFormation.
|
|
31357
|
+
*/
|
|
31358
|
+
disableCcApiFallback = true;
|
|
31359
|
+
/**
|
|
31360
|
+
* Properties this provider actually wires through to the child deploy.
|
|
31361
|
+
* `TemplateURL` is the asset-published S3 URL of the child template
|
|
31362
|
+
* (cdkd reads the local template file via `Metadata['aws:asset:path']`,
|
|
31363
|
+
* so the URL itself is informational here); `Parameters` is the typed
|
|
31364
|
+
* parameter map forwarded as `DeployEngineOptions.parameters` to the
|
|
31365
|
+
* child engine.
|
|
31366
|
+
*/
|
|
31367
|
+
handledProperties = new Map([["AWS::CloudFormation::Stack", new Set(["TemplateURL", "Parameters"])]]);
|
|
31368
|
+
/**
|
|
31369
|
+
* Every other property on `AWS::CloudFormation::Stack` is intentionally
|
|
31370
|
+
* not threaded through — cdkd does not go through CloudFormation, so
|
|
31371
|
+
* CFn-only inputs (rollback / capability / role / notification /
|
|
31372
|
+
* termination-protection / stack-update policy / per-stack timeout /
|
|
31373
|
+
* tags) have no equivalent. The synthesized `Ref` ARN is a placeholder,
|
|
31374
|
+
* not a real AWS resource — so `Tags` and `Description` similarly
|
|
31375
|
+
* have nothing to attach to. `StackName` is replaced by cdkd's derived
|
|
31376
|
+
* `<parent>~<logicalId>` key per design §3, and `TemplateBody` is
|
|
31377
|
+
* superseded by the local `Metadata['aws:asset:path']` lookup.
|
|
31378
|
+
*/
|
|
31379
|
+
unhandledByDesign = new Map([["AWS::CloudFormation::Stack", new Map([
|
|
31380
|
+
["TemplateBody", "CFn-only inline template — cdkd reads the child template from the synth output via Metadata['aws:asset:path'] instead of accepting it inline"],
|
|
31381
|
+
["Capabilities", "CFn-only IAM capability declaration — cdkd does not go through CloudFormation so capabilities have no equivalent"],
|
|
31382
|
+
["Description", "CFn-only informational — no semantic effect on the recursive deploy"],
|
|
31383
|
+
["DisableRollback", "CFn-only — cdkd controls rollback via the top-level deploy-engine --no-rollback flag, not per nested stack"],
|
|
31384
|
+
["EnableTerminationProtection", "CFn-only per-nested-stack flag — cdkd records stack-level terminationProtection at CDK synth time (parent only) and `cdkd destroy` consults that for refusal"],
|
|
31385
|
+
["NotificationARNs", "CFn-only SNS-on-stack-event surface — cdkd has no equivalent (issue #459 design §9)"],
|
|
31386
|
+
["RoleARN", "CFn-only role-assumption — cdkd uses the caller credentials directly, no per-resource role assumption"],
|
|
31387
|
+
["StackName", "cdkd derives the child stack name as `<parent>~<logicalId>` per design §3 (state-key uniqueness); a user-provided StackName has no effect"],
|
|
31388
|
+
["StackPolicyBody", "CFn-only stack-update policy — cdkd has no equivalent (per-resource diff replaces stack-level policy)"],
|
|
31389
|
+
["StackPolicyURL", "CFn-only stack-update policy URL — cdkd has no equivalent"],
|
|
31390
|
+
["StackStatusReason", "CFn-only read-only output — never a real input property"],
|
|
31391
|
+
["Tags", "CFn-only — cdkd does not tag the synthesized \"stack\" (the parent's synthesized ARN is a cdkd-local placeholder, not a real AWS resource)"],
|
|
31392
|
+
["TimeoutInMinutes", "CFn-only stack-create deadline — cdkd uses per-resource --resource-timeout instead (issue #459 design §9)"]
|
|
31393
|
+
])]]);
|
|
31394
|
+
async create(logicalId, _resourceType, properties) {
|
|
31395
|
+
const ctx = this.requireContext();
|
|
31396
|
+
this.requireDeployContext(ctx, "create");
|
|
31397
|
+
const childTemplatePath = ctx.nestedTemplates[logicalId];
|
|
31398
|
+
if (!childTemplatePath) throw new Error(`Nested template file not found for AWS::CloudFormation::Stack '${logicalId}' under parent '${ctx.parentStackName}'. Verify the synth output emits Metadata['aws:asset:path'] on this resource (CDK 2.x cdk.NestedStack does so by default).`);
|
|
31399
|
+
const childTemplate = this.readChildTemplate(childTemplatePath);
|
|
31400
|
+
const childStackName = this.deriveChildStackName(ctx.parentStackName, logicalId);
|
|
31401
|
+
const childRegion = ctx.parentRegion;
|
|
31402
|
+
const childParameters = this.extractParameters(properties);
|
|
31403
|
+
const grandchildTemplates = this.indexGrandchildTemplates(childTemplate, childTemplatePath);
|
|
31404
|
+
const resourceCount = Object.keys(childTemplate.Resources ?? {}).length;
|
|
31405
|
+
this.logger.info(`Deploying nested stack ${childStackName} (logicalId=${logicalId}, ${resourceCount} resource(s))`);
|
|
31406
|
+
await this.runChildDeploy(ctx, logicalId, childStackName, childRegion, childTemplate, childParameters, grandchildTemplates);
|
|
31407
|
+
const attributes = await this.readChildOutputsAsAttributes(ctx, childStackName, childRegion);
|
|
31408
|
+
return {
|
|
31409
|
+
physicalId: this.synthesizeArn(ctx.accountId, ctx.parentRegion, ctx.parentStackName, logicalId),
|
|
31410
|
+
attributes
|
|
31411
|
+
};
|
|
31412
|
+
}
|
|
31413
|
+
async update(logicalId, physicalId, _resourceType, properties, _previousProperties) {
|
|
31414
|
+
const ctx = this.requireContext();
|
|
31415
|
+
this.requireDeployContext(ctx, "update");
|
|
31416
|
+
const childTemplatePath = ctx.nestedTemplates[logicalId];
|
|
31417
|
+
if (!childTemplatePath) throw new Error(`Nested template file not found for AWS::CloudFormation::Stack '${logicalId}' on update.`);
|
|
31418
|
+
const childTemplate = this.readChildTemplate(childTemplatePath);
|
|
31419
|
+
const childStackName = this.deriveChildStackName(ctx.parentStackName, logicalId);
|
|
31420
|
+
const childRegion = ctx.parentRegion;
|
|
31421
|
+
const childParameters = this.extractParameters(properties);
|
|
31422
|
+
const grandchildTemplates = this.indexGrandchildTemplates(childTemplate, childTemplatePath);
|
|
31423
|
+
const resourceCount = Object.keys(childTemplate.Resources ?? {}).length;
|
|
31424
|
+
this.logger.info(`Updating nested stack ${childStackName} (logicalId=${logicalId}, ${resourceCount} resource(s))`);
|
|
31425
|
+
await this.runChildDeploy(ctx, logicalId, childStackName, childRegion, childTemplate, childParameters, grandchildTemplates);
|
|
31426
|
+
return {
|
|
31427
|
+
physicalId,
|
|
31428
|
+
wasReplaced: false,
|
|
31429
|
+
attributes: await this.readChildOutputsAsAttributes(ctx, childStackName, childRegion)
|
|
31430
|
+
};
|
|
31431
|
+
}
|
|
31432
|
+
async delete(logicalId, _physicalId, _resourceType, _properties, deleteContext) {
|
|
31433
|
+
const ctx = this.requireContext();
|
|
31434
|
+
const childStackName = this.deriveChildStackName(ctx.parentStackName, logicalId);
|
|
31435
|
+
const childRegion = ctx.parentRegion;
|
|
31436
|
+
const childStateData = await ctx.stateBackend.getState(childStackName, childRegion);
|
|
31437
|
+
if (!childStateData) {
|
|
31438
|
+
this.logger.debug(`Nested stack ${childStackName} has no state — treating delete as idempotent success.`);
|
|
31439
|
+
return;
|
|
31440
|
+
}
|
|
31441
|
+
const resourceCount = Object.keys(childStateData.state.resources).length;
|
|
31442
|
+
this.logger.info(`Destroying nested stack ${childStackName} (logicalId=${logicalId}, ${resourceCount} resource(s))`);
|
|
31443
|
+
await withNestedStackContext({
|
|
31444
|
+
...ctx,
|
|
31445
|
+
parentStackName: childStackName,
|
|
31446
|
+
parentRegion: childRegion,
|
|
31447
|
+
nestedTemplates: void 0
|
|
31448
|
+
}, () => runDestroyForStack(childStackName, childStateData.state, {
|
|
31449
|
+
stateBackend: ctx.stateBackend,
|
|
31450
|
+
lockManager: ctx.lockManager,
|
|
31451
|
+
providerRegistry: ctx.providerRegistry,
|
|
31452
|
+
baseAwsClients: ctx.awsClients,
|
|
31453
|
+
baseRegion: childRegion,
|
|
31454
|
+
stateBucket: ctx.stateBucket,
|
|
31455
|
+
skipConfirmation: true,
|
|
31456
|
+
...ctx.exportIndexStore && { exportIndexStore: ctx.exportIndexStore },
|
|
31457
|
+
...ctx.destroyOptions?.profile && { profile: ctx.destroyOptions.profile },
|
|
31458
|
+
...deleteContext?.removeProtection === true && { removeProtection: true },
|
|
31459
|
+
...ctx.destroyOptions?.resourceWarnAfterMs !== void 0 && { resourceWarnAfterMs: ctx.destroyOptions.resourceWarnAfterMs },
|
|
31460
|
+
...ctx.destroyOptions?.resourceTimeoutMs !== void 0 && { resourceTimeoutMs: ctx.destroyOptions.resourceTimeoutMs },
|
|
31461
|
+
...ctx.destroyOptions?.resourceWarnAfterByType && { resourceWarnAfterByType: ctx.destroyOptions.resourceWarnAfterByType },
|
|
31462
|
+
...ctx.destroyOptions?.resourceTimeoutByType && { resourceTimeoutByType: ctx.destroyOptions.resourceTimeoutByType }
|
|
31463
|
+
}));
|
|
31464
|
+
}
|
|
31465
|
+
async getAttribute(_physicalId, _resourceType, attributeName) {
|
|
31466
|
+
throw new Error(`AWS::CloudFormation::Stack: attribute '${attributeName}' is not in the recorded Outputs map. Only 'Outputs.<Key>' references to declared Output names on the child template are supported.`);
|
|
31467
|
+
}
|
|
31468
|
+
async runChildDeploy(parentCtx, logicalId, childStackName, childRegion, childTemplate, childParameters, grandchildTemplates) {
|
|
31469
|
+
const childEngine = new DeployEngine(parentCtx.stateBackend, parentCtx.lockManager, parentCtx.dagBuilder, parentCtx.diffCalculator, parentCtx.providerRegistry, {
|
|
31470
|
+
...parentCtx.options ?? {},
|
|
31471
|
+
parameters: childParameters,
|
|
31472
|
+
parentStackInfo: {
|
|
31473
|
+
parentStack: parentCtx.parentStackName,
|
|
31474
|
+
parentLogicalId: logicalId,
|
|
31475
|
+
parentRegion: parentCtx.parentRegion
|
|
31476
|
+
}
|
|
31477
|
+
}, childRegion, parentCtx.exportIndexStore);
|
|
31478
|
+
await withNestedStackContext({
|
|
31479
|
+
...parentCtx,
|
|
31480
|
+
parentStackName: childStackName,
|
|
31481
|
+
parentRegion: childRegion,
|
|
31482
|
+
nestedTemplates: grandchildTemplates
|
|
31483
|
+
}, () => childEngine.deploy(childStackName, childTemplate));
|
|
31484
|
+
}
|
|
31485
|
+
async readChildOutputsAsAttributes(ctx, childStackName, childRegion) {
|
|
31486
|
+
const childStateData = await ctx.stateBackend.getState(childStackName, childRegion);
|
|
31487
|
+
if (!childStateData) throw new Error(`Child stack state '${childStackName}' not found after deploy — NestedStackProvider invariant violated.`);
|
|
31488
|
+
return this.buildOutputsAttributes(childStateData.state.outputs ?? {});
|
|
31489
|
+
}
|
|
31490
|
+
requireContext() {
|
|
31491
|
+
const ctx = getCurrentNestedStackContext();
|
|
31492
|
+
if (!ctx) throw new Error("NestedStackProvider invoked outside withNestedStackContext() scope. The deploy / destroy CLI entry point must wrap its DeployEngine.deploy / runDestroyForStack call in withNestedStackContext(ctx, () => ...).");
|
|
31493
|
+
return ctx;
|
|
31494
|
+
}
|
|
31495
|
+
requireDeployContext(ctx, op) {
|
|
31496
|
+
if (!ctx.nestedTemplates || !ctx.dagBuilder || !ctx.diffCalculator) throw new Error(`NestedStackProvider.${op}: deploy-mode context fields (nestedTemplates / dagBuilder / diffCalculator) are missing. This usually means a destroy-mode entry point called into create/update by mistake.`);
|
|
31497
|
+
}
|
|
31498
|
+
deriveChildStackName(parentStackName, nestedLogicalId) {
|
|
31499
|
+
return `${parentStackName}~${nestedLogicalId}`;
|
|
31500
|
+
}
|
|
31501
|
+
synthesizeArn(accountId, region, parentStackName, logicalId) {
|
|
31502
|
+
return `arn:cdkd-local:${region}:${accountId}:nested-stack/${parentStackName}/${logicalId}`;
|
|
31503
|
+
}
|
|
31504
|
+
extractParameters(properties) {
|
|
31505
|
+
const params = properties["Parameters"];
|
|
31506
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) return {};
|
|
31507
|
+
const result = {};
|
|
31508
|
+
for (const [k, v] of Object.entries(params)) if (typeof v === "string") result[k] = v;
|
|
31509
|
+
else if (typeof v === "number" || typeof v === "boolean") result[k] = String(v);
|
|
31510
|
+
else throw new Error(`NestedStackProvider: child Parameter '${k}' resolved to a non-scalar value (type=${v === null ? "null" : typeof v}). Parameters must be scalars (string / number / boolean) by the time they reach the provider — an unresolved intrinsic here means IntrinsicFunctionResolver upstream did not handle the value, which is a bug. Surface the unresolved input rather than silently coercing to '[object Object]'.`);
|
|
31511
|
+
return result;
|
|
31512
|
+
}
|
|
31513
|
+
readChildTemplate(templatePath) {
|
|
31514
|
+
let raw;
|
|
31515
|
+
try {
|
|
31516
|
+
raw = fs.readFileSync(templatePath, "utf-8");
|
|
31517
|
+
} catch (err) {
|
|
31518
|
+
throw new Error(`Failed to read nested template at ${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
31519
|
+
}
|
|
31520
|
+
try {
|
|
31521
|
+
return JSON.parse(raw);
|
|
31522
|
+
} catch (err) {
|
|
31523
|
+
throw new Error(`Failed to parse nested template at ${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
31524
|
+
}
|
|
31525
|
+
}
|
|
31526
|
+
indexGrandchildTemplates(childTemplate, childTemplatePath) {
|
|
31527
|
+
const dir = path.dirname(childTemplatePath);
|
|
31528
|
+
const result = {};
|
|
31529
|
+
for (const [grandLogicalId, resource] of Object.entries(childTemplate.Resources ?? {})) {
|
|
31530
|
+
if (resource?.Type !== "AWS::CloudFormation::Stack") continue;
|
|
31531
|
+
const assetPath = resource.Metadata?.["aws:asset:path"];
|
|
31532
|
+
if (typeof assetPath !== "string" || assetPath.length === 0) continue;
|
|
31533
|
+
if (path.isAbsolute(assetPath)) throw new Error(`NestedStackProvider: nested-stack '${grandLogicalId}' has Metadata['aws:asset:path']='${assetPath}' which is absolute. CDK emits relative asset paths for nested templates; an absolute path indicates the synth output was hand-modified or generated by a non-CDK toolchain. Refusing to load.`);
|
|
31534
|
+
result[grandLogicalId] = path.join(dir, assetPath);
|
|
31535
|
+
}
|
|
31536
|
+
return result;
|
|
31537
|
+
}
|
|
31538
|
+
buildOutputsAttributes(outputs) {
|
|
31539
|
+
const attributes = {};
|
|
31540
|
+
for (const [key, value] of Object.entries(outputs)) attributes[`Outputs.${key}`] = value;
|
|
31541
|
+
return attributes;
|
|
31542
|
+
}
|
|
31543
|
+
};
|
|
31544
|
+
|
|
30947
31545
|
//#endregion
|
|
30948
31546
|
//#region src/provisioning/register-providers.ts
|
|
30949
31547
|
/**
|
|
@@ -31079,6 +31677,7 @@ function registerAllProviders(registry) {
|
|
|
31079
31677
|
registry.register("AWS::S3Tables::TableBucket", s3TablesProvider);
|
|
31080
31678
|
registry.register("AWS::S3Tables::Namespace", s3TablesProvider);
|
|
31081
31679
|
registry.register("AWS::S3Tables::Table", s3TablesProvider);
|
|
31680
|
+
registry.register("AWS::CloudFormation::Stack", new NestedStackProvider());
|
|
31082
31681
|
}
|
|
31083
31682
|
|
|
31084
31683
|
//#endregion
|
|
@@ -31356,7 +31955,7 @@ async function deployCommand(stacks, options) {
|
|
|
31356
31955
|
if (!await promptMigrationConfirm(pending, { yes: options.yes })) return;
|
|
31357
31956
|
}
|
|
31358
31957
|
}
|
|
31359
|
-
const
|
|
31958
|
+
const deployEngineOptions = {
|
|
31360
31959
|
concurrency: options.concurrency,
|
|
31361
31960
|
dryRun: options.dryRun,
|
|
31362
31961
|
noRollback: !options.rollback,
|
|
@@ -31365,7 +31964,23 @@ async function deployCommand(stacks, options) {
|
|
|
31365
31964
|
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
31366
31965
|
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
31367
31966
|
...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
|
|
31368
|
-
}
|
|
31967
|
+
};
|
|
31968
|
+
const stackDeployEngine = new DeployEngine(stackStateBackend, stackLockManager, dagBuilder, diffCalculator, stackProviderRegistry, deployEngineOptions, stackRegion, exportIndexStore);
|
|
31969
|
+
const deployResult = await withNestedStackContext({
|
|
31970
|
+
stateBackend: stackStateBackend,
|
|
31971
|
+
lockManager: stackLockManager,
|
|
31972
|
+
providerRegistry: stackProviderRegistry,
|
|
31973
|
+
parentStackName: stackInfo.stackName,
|
|
31974
|
+
parentRegion: stackRegion,
|
|
31975
|
+
accountId,
|
|
31976
|
+
awsClients: stackAwsClients,
|
|
31977
|
+
stateBucket,
|
|
31978
|
+
exportIndexStore,
|
|
31979
|
+
nestedTemplates: stackInfo.nestedTemplates ?? {},
|
|
31980
|
+
dagBuilder,
|
|
31981
|
+
diffCalculator,
|
|
31982
|
+
options: deployEngineOptions
|
|
31983
|
+
}, () => stackDeployEngine.deploy(stackInfo.stackName, stackInfo.template));
|
|
31369
31984
|
logger.info(`\n${bold("Deployment Summary:")}`);
|
|
31370
31985
|
logger.info(` Stack: ${bold(cyan(deployResult.stackName))}`);
|
|
31371
31986
|
logger.info(` Created: ${deployResult.created > 0 ? green(deployResult.created) : gray(deployResult.created)}`);
|
|
@@ -32475,342 +33090,6 @@ function createDriftCommand() {
|
|
|
32475
33090
|
return cmd;
|
|
32476
33091
|
}
|
|
32477
33092
|
|
|
32478
|
-
//#endregion
|
|
32479
|
-
//#region src/cli/commands/destroy-runner.ts
|
|
32480
|
-
/**
|
|
32481
|
-
* Resource-type → state-property name pairs that gate AWS deletion
|
|
32482
|
-
* protection. Used by the `--remove-protection` confirmation prompt to
|
|
32483
|
-
* report a best-effort count of resources that will have protection
|
|
32484
|
-
* cleared. The actual flip-off is unconditional inside each provider's
|
|
32485
|
-
* `delete()` (idempotent — safe when AWS already has protection off),
|
|
32486
|
-
* so the count is informational only.
|
|
32487
|
-
*
|
|
32488
|
-
* Most types use a boolean flag — the value `true` is what we count.
|
|
32489
|
-
* Two types use a string-valued enum (Cognito UserPool's
|
|
32490
|
-
* `DeletionProtection` is `'ACTIVE' | 'INACTIVE'`, AutoScalingGroup's
|
|
32491
|
-
* `DeletionProtection` is `'none' | 'prevent-force-deletion' |
|
|
32492
|
-
* 'prevent-all-deletion'`). For those, the helper checks against a
|
|
32493
|
-
* per-type set of "active" values via `PROTECTION_ACTIVE_VALUES_BY_TYPE`.
|
|
32494
|
-
*
|
|
32495
|
-
* Exported for unit-test coverage of `countProtectedResources`.
|
|
32496
|
-
*/
|
|
32497
|
-
const PROTECTION_PROPERTY_BY_TYPE = {
|
|
32498
|
-
"AWS::Logs::LogGroup": "DeletionProtectionEnabled",
|
|
32499
|
-
"AWS::RDS::DBInstance": "DeletionProtection",
|
|
32500
|
-
"AWS::RDS::DBCluster": "DeletionProtection",
|
|
32501
|
-
"AWS::DocDB::DBCluster": "DeletionProtection",
|
|
32502
|
-
"AWS::Neptune::DBCluster": "DeletionProtection",
|
|
32503
|
-
"AWS::Neptune::DBInstance": "DeletionProtection",
|
|
32504
|
-
"AWS::DynamoDB::Table": "DeletionProtectionEnabled",
|
|
32505
|
-
"AWS::DynamoDB::GlobalTable": "DeletionProtectionEnabled",
|
|
32506
|
-
"AWS::EC2::Instance": "DisableApiTermination",
|
|
32507
|
-
"AWS::Cognito::UserPool": "DeletionProtection",
|
|
32508
|
-
"AWS::AutoScaling::AutoScalingGroup": "DeletionProtection"
|
|
32509
|
-
};
|
|
32510
|
-
/**
|
|
32511
|
-
* For string-valued protection enums, the set of values that count as
|
|
32512
|
-
* "currently protected". Types absent from this map use the default
|
|
32513
|
-
* (boolean `true`).
|
|
32514
|
-
*/
|
|
32515
|
-
const PROTECTION_ACTIVE_VALUES_BY_TYPE = {
|
|
32516
|
-
"AWS::Cognito::UserPool": new Set(["ACTIVE"]),
|
|
32517
|
-
"AWS::AutoScaling::AutoScalingGroup": new Set(["prevent-force-deletion", "prevent-all-deletion"])
|
|
32518
|
-
};
|
|
32519
|
-
/**
|
|
32520
|
-
* Count how many resources in a stack's recorded state appear to have
|
|
32521
|
-
* deletion protection enabled. Walks `properties` and `observedProperties`
|
|
32522
|
-
* for the property name registered against each resource type in
|
|
32523
|
-
* `PROTECTION_PROPERTY_BY_TYPE`. ELBv2 LoadBalancer protection lives in
|
|
32524
|
-
* `LoadBalancerAttributes` (a CFn `Array<{Key, Value}>`), so it's
|
|
32525
|
-
* handled separately via the `deletion_protection.enabled` key.
|
|
32526
|
-
*/
|
|
32527
|
-
function countProtectedResources(state) {
|
|
32528
|
-
let count = 0;
|
|
32529
|
-
for (const resource of Object.values(state.resources ?? {})) {
|
|
32530
|
-
const propName = PROTECTION_PROPERTY_BY_TYPE[resource.resourceType];
|
|
32531
|
-
if (propName) {
|
|
32532
|
-
const recorded = resource.properties?.[propName] ?? resource.observedProperties?.[propName];
|
|
32533
|
-
const activeValues = PROTECTION_ACTIVE_VALUES_BY_TYPE[resource.resourceType];
|
|
32534
|
-
if (activeValues) {
|
|
32535
|
-
if (activeValues.has(recorded)) count++;
|
|
32536
|
-
} else if (recorded === true) count++;
|
|
32537
|
-
continue;
|
|
32538
|
-
}
|
|
32539
|
-
if (resource.resourceType === "AWS::ElasticLoadBalancingV2::LoadBalancer") {
|
|
32540
|
-
if (((resource.properties?.["LoadBalancerAttributes"] ?? resource.observedProperties?.["LoadBalancerAttributes"])?.find((a) => a?.Key === "deletion_protection.enabled"))?.Value === "true") count++;
|
|
32541
|
-
}
|
|
32542
|
-
}
|
|
32543
|
-
return count;
|
|
32544
|
-
}
|
|
32545
|
-
/**
|
|
32546
|
-
* Run the destroy lifecycle for one stack against an already-loaded
|
|
32547
|
-
* `StackState`, reusing the caller's state backend / lock manager.
|
|
32548
|
-
*
|
|
32549
|
-
* Hoisted from `cdkd destroy` so the new `cdkd state destroy` subcommand
|
|
32550
|
-
* can call into the exact same per-stack pipeline without depending on
|
|
32551
|
-
* synth or the CDK app. The state-source split is the only meaningful
|
|
32552
|
-
* difference between the two commands — everything from "prompt the user"
|
|
32553
|
-
* onwards is identical.
|
|
32554
|
-
*
|
|
32555
|
-
* Side effects:
|
|
32556
|
-
* - Acquires (and releases) the stack's S3 lock.
|
|
32557
|
-
* - Switches `process.env.AWS_REGION` for the duration of the destroy when
|
|
32558
|
-
* the stack's recorded region differs from `baseRegion`. Restored in the
|
|
32559
|
-
* `finally` block.
|
|
32560
|
-
* - On full success, deletes the state file. On any failure, the state
|
|
32561
|
-
* file is preserved so the user can retry.
|
|
32562
|
-
*/
|
|
32563
|
-
async function runDestroyForStack(stackName, state, ctx) {
|
|
32564
|
-
const logger = getLogger();
|
|
32565
|
-
const result = {
|
|
32566
|
-
stackName,
|
|
32567
|
-
cancelled: false,
|
|
32568
|
-
skippedEmpty: false,
|
|
32569
|
-
deletedCount: 0,
|
|
32570
|
-
retainedCount: 0,
|
|
32571
|
-
errorCount: 0
|
|
32572
|
-
};
|
|
32573
|
-
const resourceCount = Object.keys(state.resources).length;
|
|
32574
|
-
const regionForState = state.region ?? ctx.baseRegion;
|
|
32575
|
-
if (resourceCount === 0) {
|
|
32576
|
-
logger.info(`Stack ${stackName} has no resources, cleaning up state...`);
|
|
32577
|
-
await ctx.stateBackend.deleteState(stackName, regionForState);
|
|
32578
|
-
logger.info(`${green("✓")} State deleted`);
|
|
32579
|
-
result.skippedEmpty = true;
|
|
32580
|
-
return result;
|
|
32581
|
-
}
|
|
32582
|
-
const needsStrongRefCheck = !!(state.outputs && Object.keys(state.outputs).length > 0);
|
|
32583
|
-
if (needsStrongRefCheck) {
|
|
32584
|
-
const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
|
|
32585
|
-
if (consumers.length > 0) throw new StackHasActiveImportsError(stackName, regionForState, consumers);
|
|
32586
|
-
}
|
|
32587
|
-
logger.info(`\nResources to be deleted (${resourceCount}):`);
|
|
32588
|
-
for (const [logicalId, resource] of Object.entries(state.resources)) logger.info(` - ${logicalId} (${resource.resourceType})`);
|
|
32589
|
-
const protectedCount = ctx.removeProtection ? countProtectedResources(state) : 0;
|
|
32590
|
-
if (!ctx.skipConfirmation) {
|
|
32591
|
-
const rl = readline.createInterface({
|
|
32592
|
-
input: process.stdin,
|
|
32593
|
-
output: process.stdout
|
|
32594
|
-
});
|
|
32595
|
-
const prompt = ctx.removeProtection ? `\nAbout to destroy ${resourceCount} resources from stack "${stackName}", REMOVING DELETION PROTECTION on ${protectedCount} of them. Continue? (y/N): ` : `\nAre you sure you want to destroy stack "${stackName}" and delete all ${resourceCount} resources? (Y/n): `;
|
|
32596
|
-
const answer = await rl.question(prompt);
|
|
32597
|
-
rl.close();
|
|
32598
|
-
const trimmed = answer.trim().toLowerCase();
|
|
32599
|
-
if (ctx.removeProtection) {
|
|
32600
|
-
if (trimmed !== "y" && trimmed !== "yes") {
|
|
32601
|
-
logger.info("Destroy cancelled");
|
|
32602
|
-
result.cancelled = true;
|
|
32603
|
-
return result;
|
|
32604
|
-
}
|
|
32605
|
-
} else if (trimmed === "n" || trimmed === "no") {
|
|
32606
|
-
logger.info("Destroy cancelled");
|
|
32607
|
-
result.cancelled = true;
|
|
32608
|
-
return result;
|
|
32609
|
-
}
|
|
32610
|
-
}
|
|
32611
|
-
const stackRegion = state.region;
|
|
32612
|
-
let destroyProviderRegistry = ctx.providerRegistry;
|
|
32613
|
-
let destroyAwsClients;
|
|
32614
|
-
if (stackRegion && stackRegion !== ctx.baseRegion) {
|
|
32615
|
-
logger.info(`Stack region: ${stackRegion}`);
|
|
32616
|
-
process.env["AWS_REGION"] = stackRegion;
|
|
32617
|
-
process.env["AWS_DEFAULT_REGION"] = stackRegion;
|
|
32618
|
-
destroyAwsClients = new AwsClients({
|
|
32619
|
-
region: stackRegion,
|
|
32620
|
-
...ctx.profile && { profile: ctx.profile }
|
|
32621
|
-
});
|
|
32622
|
-
setAwsClients(destroyAwsClients);
|
|
32623
|
-
destroyProviderRegistry = new ProviderRegistry();
|
|
32624
|
-
registerAllProviders(destroyProviderRegistry);
|
|
32625
|
-
destroyProviderRegistry.setCustomResourceResponseBucket(ctx.stateBucket);
|
|
32626
|
-
}
|
|
32627
|
-
logger.info(`\nAcquiring lock for stack ${stackName}...`);
|
|
32628
|
-
await ctx.lockManager.acquireLock(stackName, regionForState, void 0, "destroy");
|
|
32629
|
-
if (needsStrongRefCheck) {
|
|
32630
|
-
const consumers = await scanActiveConsumers(stackName, regionForState, ctx);
|
|
32631
|
-
if (consumers.length > 0) {
|
|
32632
|
-
try {
|
|
32633
|
-
await ctx.lockManager.releaseLock(stackName, regionForState);
|
|
32634
|
-
} catch (releaseErr) {
|
|
32635
|
-
logger.warn(`Failed to release lock after strong-ref refusal: ${releaseErr instanceof Error ? releaseErr.message : String(releaseErr)}`);
|
|
32636
|
-
}
|
|
32637
|
-
throw new StackHasActiveImportsError(stackName, regionForState, consumers);
|
|
32638
|
-
}
|
|
32639
|
-
}
|
|
32640
|
-
const renderer = getLiveRenderer();
|
|
32641
|
-
renderer.start();
|
|
32642
|
-
try {
|
|
32643
|
-
logger.info("Building dependency graph...");
|
|
32644
|
-
const template = {
|
|
32645
|
-
AWSTemplateFormatVersion: "2010-09-09",
|
|
32646
|
-
Resources: {}
|
|
32647
|
-
};
|
|
32648
|
-
for (const [logicalId, resource] of Object.entries(state.resources)) template.Resources[logicalId] = {
|
|
32649
|
-
Type: resource.resourceType,
|
|
32650
|
-
Properties: resource.properties || {},
|
|
32651
|
-
...resource.dependencies && resource.dependencies.length > 0 && { DependsOn: resource.dependencies }
|
|
32652
|
-
};
|
|
32653
|
-
const typeToLogicalIds = /* @__PURE__ */ new Map();
|
|
32654
|
-
for (const [logicalId, resource] of Object.entries(state.resources)) {
|
|
32655
|
-
const ids = typeToLogicalIds.get(resource.resourceType) ?? [];
|
|
32656
|
-
ids.push(logicalId);
|
|
32657
|
-
typeToLogicalIds.set(resource.resourceType, ids);
|
|
32658
|
-
}
|
|
32659
|
-
for (const [logicalId, resource] of Object.entries(state.resources)) {
|
|
32660
|
-
const mustDeleteAfter = IMPLICIT_DELETE_DEPENDENCIES[resource.resourceType];
|
|
32661
|
-
if (!mustDeleteAfter) continue;
|
|
32662
|
-
for (const depType of mustDeleteAfter) {
|
|
32663
|
-
const depIds = typeToLogicalIds.get(depType);
|
|
32664
|
-
if (!depIds) continue;
|
|
32665
|
-
for (const depId of depIds) {
|
|
32666
|
-
const existing = template.Resources[depId]?.DependsOn ?? [];
|
|
32667
|
-
const depsArray = Array.isArray(existing) ? existing : [existing];
|
|
32668
|
-
if (!depsArray.includes(logicalId)) {
|
|
32669
|
-
template.Resources[depId] = {
|
|
32670
|
-
...template.Resources[depId],
|
|
32671
|
-
DependsOn: [...depsArray, logicalId]
|
|
32672
|
-
};
|
|
32673
|
-
logger.debug(`Implicit delete dependency: ${depId} (${depType}) must be deleted before ${logicalId} (${resource.resourceType})`);
|
|
32674
|
-
}
|
|
32675
|
-
}
|
|
32676
|
-
}
|
|
32677
|
-
}
|
|
32678
|
-
const dagBuilder = new DagBuilder();
|
|
32679
|
-
const graph = dagBuilder.buildGraph(template);
|
|
32680
|
-
const executionLevels = dagBuilder.getExecutionLevels(graph);
|
|
32681
|
-
logger.debug(`Dependency graph: ${executionLevels.length} level(s)`);
|
|
32682
|
-
for (let levelIndex = executionLevels.length - 1; levelIndex >= 0; levelIndex--) {
|
|
32683
|
-
const level = executionLevels[levelIndex];
|
|
32684
|
-
if (!level) continue;
|
|
32685
|
-
logger.debug(`Deletion level ${executionLevels.length - levelIndex}/${executionLevels.length} (${level.length} resources)`);
|
|
32686
|
-
const stackRegion = state.region ?? ctx.baseRegion;
|
|
32687
|
-
const deletePromises = level.map(async (logicalId) => {
|
|
32688
|
-
const resource = state.resources[logicalId];
|
|
32689
|
-
if (!resource) {
|
|
32690
|
-
logger.warn(`Resource ${logicalId} not found in state, skipping`);
|
|
32691
|
-
return;
|
|
32692
|
-
}
|
|
32693
|
-
if (shouldRetainResource(resource.deletionPolicy)) {
|
|
32694
|
-
logger.info(` ⊘ ${logicalId} (${resource.resourceType}) retained — DeletionPolicy: ${resource.deletionPolicy}`);
|
|
32695
|
-
result.retainedCount++;
|
|
32696
|
-
return;
|
|
32697
|
-
}
|
|
32698
|
-
const baseLabel = `Deleting ${logicalId} (${resource.resourceType})`;
|
|
32699
|
-
renderer.addTask(logicalId, baseLabel);
|
|
32700
|
-
try {
|
|
32701
|
-
const provider = destroyProviderRegistry.getProvider(resource.resourceType);
|
|
32702
|
-
const providerMinTimeoutMs = provider.getMinResourceTimeoutMs?.() ?? 0;
|
|
32703
|
-
const warnAfterMs = ctx.resourceWarnAfterByType?.[resource.resourceType] ?? ctx.resourceWarnAfterMs ?? 3e5;
|
|
32704
|
-
const globalTimeoutMs = ctx.resourceTimeoutMs ?? 18e5;
|
|
32705
|
-
const timeoutMs = ctx.resourceTimeoutByType?.[resource.resourceType] ?? Math.max(providerMinTimeoutMs, globalTimeoutMs);
|
|
32706
|
-
await withResourceDeadline(async () => {
|
|
32707
|
-
const maxAttempts = provider.disableOuterRetry ? 0 : 3;
|
|
32708
|
-
let lastDeleteError;
|
|
32709
|
-
for (let attempt = 0; attempt <= maxAttempts; attempt++) try {
|
|
32710
|
-
await provider.delete(logicalId, resource.physicalId, resource.resourceType, resource.properties, {
|
|
32711
|
-
...state.region !== void 0 && { expectedRegion: state.region },
|
|
32712
|
-
...ctx.removeProtection === true && { removeProtection: true }
|
|
32713
|
-
});
|
|
32714
|
-
lastDeleteError = null;
|
|
32715
|
-
break;
|
|
32716
|
-
} catch (retryError) {
|
|
32717
|
-
lastDeleteError = retryError;
|
|
32718
|
-
const msg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
32719
|
-
if (!(msg.includes("Too Many Requests") || msg.includes("has dependencies") || msg.includes("can't be deleted since") || msg.includes("DependencyViolation")) || attempt >= maxAttempts) break;
|
|
32720
|
-
const delay = 5e3 * Math.pow(2, attempt);
|
|
32721
|
-
logger.debug(` ⏳ Retrying delete ${logicalId} in ${delay / 1e3}s (attempt ${attempt + 1}/${maxAttempts})`);
|
|
32722
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
32723
|
-
}
|
|
32724
|
-
if (lastDeleteError) throw lastDeleteError;
|
|
32725
|
-
}, {
|
|
32726
|
-
warnAfterMs,
|
|
32727
|
-
timeoutMs,
|
|
32728
|
-
onWarn: (elapsedMs) => {
|
|
32729
|
-
const minutes = Math.max(1, Math.round(elapsedMs / 6e4));
|
|
32730
|
-
renderer.updateTaskLabel(logicalId, `${baseLabel} [taking longer than expected, ${minutes}m+]`);
|
|
32731
|
-
renderer.printAbove(() => {
|
|
32732
|
-
logger.warn(`${logicalId} (${resource.resourceType}) has been deleting for ${minutes}m — still waiting`);
|
|
32733
|
-
});
|
|
32734
|
-
},
|
|
32735
|
-
onTimeout: (elapsedMs) => new ResourceTimeoutError(logicalId, resource.resourceType, stackRegion, elapsedMs, "DELETE", timeoutMs)
|
|
32736
|
-
});
|
|
32737
|
-
renderer.removeTask(logicalId);
|
|
32738
|
-
logger.info(` ${red("✗")} ${bold(logicalId)} ${gray(`(${resource.resourceType})`)} ${red("deleted")}`);
|
|
32739
|
-
result.deletedCount++;
|
|
32740
|
-
} catch (error) {
|
|
32741
|
-
renderer.removeTask(logicalId);
|
|
32742
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
32743
|
-
if (msg.includes("does not exist") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException")) {
|
|
32744
|
-
logger.debug(` ${logicalId} already deleted, removing from state`);
|
|
32745
|
-
result.deletedCount++;
|
|
32746
|
-
} else if (error instanceof ResourceTimeoutError) {
|
|
32747
|
-
const wrapped = new ProvisioningError(error.message, resource.resourceType, logicalId, resource.physicalId, error);
|
|
32748
|
-
logger.error(` ✗ Failed to delete ${logicalId}:`, wrapped.message);
|
|
32749
|
-
result.errorCount++;
|
|
32750
|
-
} else {
|
|
32751
|
-
logger.error(` ✗ Failed to delete ${logicalId}:`, String(error));
|
|
32752
|
-
result.errorCount++;
|
|
32753
|
-
}
|
|
32754
|
-
} finally {
|
|
32755
|
-
renderer.removeTask(logicalId);
|
|
32756
|
-
}
|
|
32757
|
-
});
|
|
32758
|
-
await Promise.all(deletePromises);
|
|
32759
|
-
}
|
|
32760
|
-
if (result.errorCount === 0) {
|
|
32761
|
-
await ctx.stateBackend.deleteState(stackName, regionForState);
|
|
32762
|
-
logger.debug("State deleted");
|
|
32763
|
-
if (ctx.exportIndexStore) await ctx.exportIndexStore.removeStack(stackName, regionForState);
|
|
32764
|
-
} else logger.warn(`${result.errorCount} resource(s) failed to delete. State preserved.`);
|
|
32765
|
-
const retainedSuffix = result.retainedCount > 0 ? `, ${result.retainedCount} retained` : "";
|
|
32766
|
-
if (result.errorCount === 0) logger.info(`\n${green("✓")} ${bold(`Stack ${stackName} destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}, ${result.errorCount} errors)`);
|
|
32767
|
-
else logger.warn(`\n${yellow("⚠")} ${bold(`Stack ${stackName} partially destroyed`)} (${green(result.deletedCount)} deleted${retainedSuffix}, ${red(result.errorCount)} errors). State preserved — re-run 'cdkd destroy' / 'cdkd state destroy' to clean up.`);
|
|
32768
|
-
} finally {
|
|
32769
|
-
renderer.stop();
|
|
32770
|
-
logger.debug("Releasing lock...");
|
|
32771
|
-
await ctx.lockManager.releaseLock(stackName, regionForState);
|
|
32772
|
-
if (destroyAwsClients) {
|
|
32773
|
-
destroyAwsClients.destroy();
|
|
32774
|
-
process.env["AWS_REGION"] = ctx.baseRegion;
|
|
32775
|
-
process.env["AWS_DEFAULT_REGION"] = ctx.baseRegion;
|
|
32776
|
-
setAwsClients(ctx.baseAwsClients);
|
|
32777
|
-
}
|
|
32778
|
-
}
|
|
32779
|
-
return result;
|
|
32780
|
-
}
|
|
32781
|
-
/**
|
|
32782
|
-
* Strong-reference scan: read every other stack's state.json from the
|
|
32783
|
-
* state bucket and check whether any of its `imports[]` entries names
|
|
32784
|
-
* `producerStack`. Returns the list of offending consumers (possibly
|
|
32785
|
-
* empty).
|
|
32786
|
-
*
|
|
32787
|
-
* NEVER trusts the persistent exports index — a stale index could miss
|
|
32788
|
-
* a freshly-recorded consumer and let a destructive destroy through.
|
|
32789
|
-
* The cost is one `listStacks` + N parallel GETs at destroy time only
|
|
32790
|
-
* (not the deploy hot path), which the user-facing UX rationalizes as
|
|
32791
|
-
* the "destroy is slow OK" trade-off (Issue #343).
|
|
32792
|
-
*/
|
|
32793
|
-
async function scanActiveConsumers(producerStack, producerRegion, ctx) {
|
|
32794
|
-
const refs = await ctx.stateBackend.listStacks();
|
|
32795
|
-
return (await Promise.all(refs.map(async (ref) => {
|
|
32796
|
-
const region = ref.region ?? ctx.baseRegion;
|
|
32797
|
-
if (ref.stackName === producerStack && region === producerRegion) return null;
|
|
32798
|
-
try {
|
|
32799
|
-
const imports = (await ctx.stateBackend.getState(ref.stackName, region))?.state.imports;
|
|
32800
|
-
if (!imports || imports.length === 0) return null;
|
|
32801
|
-
const matches = imports.filter((entry) => entry.sourceStack === producerStack && entry.sourceRegion === producerRegion);
|
|
32802
|
-
if (matches.length === 0) return null;
|
|
32803
|
-
return matches.map((entry) => ({
|
|
32804
|
-
consumerStack: ref.stackName,
|
|
32805
|
-
consumerRegion: region,
|
|
32806
|
-
exportName: entry.exportName
|
|
32807
|
-
}));
|
|
32808
|
-
} catch {
|
|
32809
|
-
return null;
|
|
32810
|
-
}
|
|
32811
|
-
}))).filter((r) => r !== null).flat();
|
|
32812
|
-
}
|
|
32813
|
-
|
|
32814
33093
|
//#endregion
|
|
32815
33094
|
//#region src/cli/commands/destroy.ts
|
|
32816
33095
|
/**
|
|
@@ -32904,6 +33183,7 @@ async function destroyCommand(stackArgs, options) {
|
|
|
32904
33183
|
return;
|
|
32905
33184
|
}
|
|
32906
33185
|
logger.info(`Found ${stackNames.length} stack(s) to destroy: ${stackNames.join(", ")}`);
|
|
33186
|
+
const accountId = "unknown";
|
|
32907
33187
|
const stateRefsByName = /* @__PURE__ */ new Map();
|
|
32908
33188
|
for (const ref of allStateRefs) {
|
|
32909
33189
|
const arr = stateRefsByName.get(ref.stackName) ?? [];
|
|
@@ -32941,7 +33221,25 @@ async function destroyCommand(stackArgs, options) {
|
|
|
32941
33221
|
logger.warn(`No state found for stack ${stackName}, skipping`);
|
|
32942
33222
|
continue;
|
|
32943
33223
|
}
|
|
32944
|
-
const result = await
|
|
33224
|
+
const result = await withNestedStackContext({
|
|
33225
|
+
stateBackend,
|
|
33226
|
+
lockManager,
|
|
33227
|
+
providerRegistry,
|
|
33228
|
+
parentStackName: stackName,
|
|
33229
|
+
parentRegion: stackTargetRegion,
|
|
33230
|
+
accountId,
|
|
33231
|
+
awsClients,
|
|
33232
|
+
stateBucket,
|
|
33233
|
+
exportIndexStore,
|
|
33234
|
+
destroyOptions: {
|
|
33235
|
+
...options.profile && { profile: options.profile },
|
|
33236
|
+
...options.removeProtection === true && { removeProtection: true },
|
|
33237
|
+
...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
|
|
33238
|
+
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
33239
|
+
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
33240
|
+
...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
|
|
33241
|
+
}
|
|
33242
|
+
}, () => runDestroyForStack(stackName, stateResult.state, {
|
|
32945
33243
|
stateBackend,
|
|
32946
33244
|
lockManager,
|
|
32947
33245
|
providerRegistry,
|
|
@@ -32956,7 +33254,7 @@ async function destroyCommand(stackArgs, options) {
|
|
|
32956
33254
|
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
32957
33255
|
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
32958
33256
|
...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
|
|
32959
|
-
});
|
|
33257
|
+
}));
|
|
32960
33258
|
totalErrors += result.errorCount;
|
|
32961
33259
|
}
|
|
32962
33260
|
if (totalErrors > 0) throw new PartialFailureError(`Destroy completed with ${totalErrors} resource error(s). State preserved — inspect 'cdkd state show <stack>' and re-run 'cdkd destroy' to retry.`);
|
|
@@ -34690,7 +34988,25 @@ async function stateDestroyCommand(stackArgs, options) {
|
|
|
34690
34988
|
logger.warn(`No state found for stack ${stackName}${ref.region ? ` in ${ref.region}` : ""}, skipping`);
|
|
34691
34989
|
continue;
|
|
34692
34990
|
}
|
|
34693
|
-
const result = await
|
|
34991
|
+
const result = await withNestedStackContext({
|
|
34992
|
+
stateBackend: setup.stateBackend,
|
|
34993
|
+
lockManager: setup.lockManager,
|
|
34994
|
+
providerRegistry,
|
|
34995
|
+
parentStackName: stackName,
|
|
34996
|
+
parentRegion: ref.region ?? setup.region,
|
|
34997
|
+
accountId: "unknown",
|
|
34998
|
+
awsClients: setup.awsClients,
|
|
34999
|
+
stateBucket: setup.bucket,
|
|
35000
|
+
exportIndexStore: setup.exportIndexStore,
|
|
35001
|
+
destroyOptions: {
|
|
35002
|
+
...options.profile && { profile: options.profile },
|
|
35003
|
+
...options.removeProtection === true && { removeProtection: true },
|
|
35004
|
+
...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
|
|
35005
|
+
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
35006
|
+
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
35007
|
+
...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
|
|
35008
|
+
}
|
|
35009
|
+
}, () => runDestroyForStack(stackName, stateResult.state, {
|
|
34694
35010
|
stateBackend: setup.stateBackend,
|
|
34695
35011
|
lockManager: setup.lockManager,
|
|
34696
35012
|
providerRegistry,
|
|
@@ -34705,7 +35021,7 @@ async function stateDestroyCommand(stackArgs, options) {
|
|
|
34705
35021
|
...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
|
|
34706
35022
|
...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
|
|
34707
35023
|
...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
|
|
34708
|
-
});
|
|
35024
|
+
}));
|
|
34709
35025
|
totalErrors += result.errorCount;
|
|
34710
35026
|
}
|
|
34711
35027
|
}
|
|
@@ -52812,8 +53128,11 @@ function createLocalCommand() {
|
|
|
52812
53128
|
*
|
|
52813
53129
|
* - `AWS::CDK::Metadata` is a CDK sentinel; not a real AWS resource and
|
|
52814
53130
|
* CFn refuses to import it.
|
|
52815
|
-
* - `AWS::CloudFormation::Stack` is a nested stack reference
|
|
52816
|
-
*
|
|
53131
|
+
* - `AWS::CloudFormation::Stack` is a nested stack reference. Fresh
|
|
53132
|
+
* `cdkd deploy` of nested stacks IS supported (issue #459), but
|
|
53133
|
+
* moving an existing nested-stack hierarchy from cdkd back into
|
|
53134
|
+
* CloudFormation via `cdkd export` is deferred to the
|
|
53135
|
+
* [#464](https://github.com/go-to-k/cdkd/issues/464) follow-up.
|
|
52817
53136
|
* - `AWS::CloudFormation::CustomResource` is the CFn resource type CDK
|
|
52818
53137
|
* emits for `new cdk.CustomResource(...)` when no `resourceType` is
|
|
52819
53138
|
* passed. Functionally identical to `Custom::*` — Lambda-backed,
|
|
@@ -53270,7 +53589,10 @@ async function assertCfnStackAbsent(cfnClient, stackName) {
|
|
|
53270
53589
|
* `AWS::CloudFormation::Stack` (nested stacks) is intentionally NOT in
|
|
53271
53590
|
* this set: CFn would CREATE a duplicate nested stack rather than adopt
|
|
53272
53591
|
* the existing one, which would conflict with whatever the cdkd state
|
|
53273
|
-
* thought it owned. cdkd
|
|
53592
|
+
* thought it owned. Fresh `cdkd deploy` of nested stacks is supported
|
|
53593
|
+
* via the recursive `NestedStackProvider` (#459), but `cdkd export`
|
|
53594
|
+
* adoption back into CloudFormation is deferred to the
|
|
53595
|
+
* [#464](https://github.com/go-to-k/cdkd/issues/464) follow-up.
|
|
53274
53596
|
*
|
|
53275
53597
|
* Exported for unit testing.
|
|
53276
53598
|
*/
|
|
@@ -54222,10 +54544,14 @@ function compareSemver(a, b) {
|
|
|
54222
54544
|
* different from the metadata-transfer migration this command
|
|
54223
54545
|
* provides.
|
|
54224
54546
|
*
|
|
54225
|
-
* - `AWS::CloudFormation::Stack` — nested stacks. cdkd
|
|
54226
|
-
*
|
|
54227
|
-
* flattens nested stacks into separate generated apps
|
|
54228
|
-
* that doesn't round-trip cleanly
|
|
54547
|
+
* - `AWS::CloudFormation::Stack` — nested stacks. cdkd's recursive
|
|
54548
|
+
* `NestedStackProvider` handles fresh `cdkd deploy` (#459), but
|
|
54549
|
+
* `cdk migrate` flattens nested stacks into separate generated apps
|
|
54550
|
+
* in a way that doesn't round-trip cleanly into cdkd's parent~child
|
|
54551
|
+
* state-key layout, so the migrate command keeps rejecting them.
|
|
54552
|
+
* Adopting an existing CFn-managed nested-stack hierarchy is
|
|
54553
|
+
* deferred to issue
|
|
54554
|
+
* [#464](https://github.com/go-to-k/cdkd/issues/464).
|
|
54229
54555
|
*
|
|
54230
54556
|
* - `Custom::*` — any user-defined Custom Resource type prefix.
|
|
54231
54557
|
* Same rationale as `AWS::CloudFormation::CustomResource`.
|
|
@@ -55239,7 +55565,7 @@ function reorderArgs(argv) {
|
|
|
55239
55565
|
*/
|
|
55240
55566
|
async function main() {
|
|
55241
55567
|
const program = new Command();
|
|
55242
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
55568
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.144.0");
|
|
55243
55569
|
program.addCommand(createBootstrapCommand());
|
|
55244
55570
|
program.addCommand(createSynthCommand());
|
|
55245
55571
|
program.addCommand(createListCommand());
|