@go-to-k/cdkd 0.275.6 → 0.276.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.
@@ -18,6 +18,8 @@ import { CloudFrontClient, GetCloudFrontOriginAccessIdentityCommand } from "@aws
18
18
  import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
19
19
  import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
20
20
  import { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore-control";
21
+ import { CreateClusterSnapshotCommand, DescribeClusterSnapshotsCommand, DescribeClustersCommand, RedshiftClient } from "@aws-sdk/client-redshift";
22
+ import { CreateSnapshotCommand as CreateSnapshotCommand$1, DescribeReplicationGroupsCommand, DescribeSnapshotsCommand as DescribeSnapshotsCommand$1, ElastiCacheClient } from "@aws-sdk/client-elasticache";
21
23
  import { ACMClient } from "@aws-sdk/client-acm";
22
24
  import { LambdaMicrovmsClient } from "@aws-sdk/client-lambda-microvms";
23
25
  import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -33,8 +35,6 @@ import { CreateRepositoryCommand, DescribeImagesCommand as DescribeImagesCommand
33
35
  import { hostname } from "os";
34
36
  import graphlib from "graphlib";
35
37
  import { DescribeDBClustersCommand, DescribeDBInstancesCommand, RDSClient } from "@aws-sdk/client-rds";
36
- import { DescribeReplicationGroupsCommand, ElastiCacheClient } from "@aws-sdk/client-elasticache";
37
- import { DescribeClustersCommand, RedshiftClient } from "@aws-sdk/client-redshift";
38
38
  import { DescribeDomainCommand, OpenSearchClient } from "@aws-sdk/client-opensearch";
39
39
  import { CreateWebACLCommand, DeleteWebACLCommand, GetWebACLCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$11, TagResourceCommand as TagResourceCommand$13, UntagResourceCommand as UntagResourceCommand$13, UpdateWebACLCommand, WAFNonexistentItemException, WAFV2Client } from "@aws-sdk/client-wafv2";
40
40
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -627,6 +627,8 @@ var AwsClients = class {
627
627
  cloudWatchClient;
628
628
  cloudWatchLogsClient;
629
629
  bedrockAgentCoreControlClient;
630
+ redshiftClient;
631
+ elastiCacheClient;
630
632
  acmClient;
631
633
  lambdaMicrovmsClient;
632
634
  config;
@@ -935,6 +937,32 @@ var AwsClients = class {
935
937
  return this.getBedrockAgentCoreControlClient();
936
938
  }
937
939
  /**
940
+ * Get Redshift client
941
+ */
942
+ getRedshiftClient() {
943
+ if (!this.redshiftClient) this.redshiftClient = new RedshiftClient({ ...this.clientOptions });
944
+ return this.redshiftClient;
945
+ }
946
+ /**
947
+ * Convenience getter for Redshift client
948
+ */
949
+ get redshift() {
950
+ return this.getRedshiftClient();
951
+ }
952
+ /**
953
+ * Get ElastiCache client
954
+ */
955
+ getElastiCacheClient() {
956
+ if (!this.elastiCacheClient) this.elastiCacheClient = new ElastiCacheClient({ ...this.clientOptions });
957
+ return this.elastiCacheClient;
958
+ }
959
+ /**
960
+ * Convenience getter for ElastiCache client
961
+ */
962
+ get elastiCache() {
963
+ return this.getElastiCacheClient();
964
+ }
965
+ /**
938
966
  * Destroy all clients
939
967
  */
940
968
  destroy() {
@@ -956,6 +984,8 @@ var AwsClients = class {
956
984
  this.cloudWatchClient?.destroy();
957
985
  this.cloudWatchLogsClient?.destroy();
958
986
  this.bedrockAgentCoreControlClient?.destroy();
987
+ this.redshiftClient?.destroy();
988
+ this.elastiCacheClient?.destroy();
959
989
  this.acmClient?.destroy();
960
990
  this.lambdaMicrovmsClient?.destroy();
961
991
  }
@@ -7769,7 +7799,8 @@ const OTHER_TRANSIENT_ERROR_MESSAGE_PATTERNS = [
7769
7799
  "wait 60 seconds",
7770
7800
  "concurrent update operation",
7771
7801
  "because it is in use",
7772
- "Rate exceeded"
7802
+ "Rate exceeded",
7803
+ "There is an operation running on the Cluster"
7773
7804
  ];
7774
7805
  /**
7775
7806
  * Patterns that mark an AWS error as a transient/retryable failure.
@@ -12041,7 +12072,7 @@ var CloudControlProvider = class {
12041
12072
  if (context?.finalSnapshotIdentifier !== void 0) throw new ProvisioningError(`${logicalId} (${resourceType}) requires a final snapshot (DeletionPolicy: Snapshot), but the Cloud Control API delete route has no final-snapshot parameter. Re-run with --skip-final-snapshot after snapshotting manually, or retain the resource.`, resourceType, logicalId, physicalId);
12042
12073
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
12043
12074
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
12044
- const { ASGProvider } = await import("./asg-provider-zDD6EIRo.js").then((n) => n.n);
12075
+ const { ASGProvider } = await import("./asg-provider-DeLTuWlm.js").then((n) => n.n);
12045
12076
  await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context);
12046
12077
  return;
12047
12078
  }
@@ -17034,21 +17065,21 @@ function extractDeploymentEventError(err) {
17034
17065
  //#endregion
17035
17066
  //#region src/provisioning/final-snapshot.ts
17036
17067
  /**
17037
- * `DeletionPolicy: Snapshot` support (issue #1352).
17068
+ * `DeletionPolicy: Snapshot` support (issues #1352 / #1353).
17038
17069
  *
17039
17070
  * CloudFormation creates a final snapshot BEFORE deleting a resource whose
17040
17071
  * `DeletionPolicy` is `Snapshot`. cdkd historically treated the policy as
17041
17072
  * `Delete` (no snapshot — silent data loss vs CFn, live-A/B-confirmed on
17042
- * `AWS::EC2::Volume`). The destroy paths now honor the policy through the
17043
- * two mechanisms below; a Snapshot-tagged type supported by NEITHER is
17044
- * refused with an actionable error unless `--skip-final-snapshot` opts into
17045
- * the data loss.
17073
+ * `AWS::EC2::Volume`). The delete paths now honor the policy through the
17074
+ * two mechanisms below; a Snapshot-tagged type supported by NEITHER (or one
17075
+ * routed via Cloud Control, which has no snapshot notion) is refused with an
17076
+ * actionable error unless `--skip-final-snapshot` opts into the data loss.
17046
17077
  *
17047
17078
  * The full CFn-documented Snapshot-capable type list (CFn refuses the
17048
17079
  * attribute on anything else): AWS::EC2::Volume, AWS::ElastiCache::CacheCluster,
17049
17080
  * AWS::ElastiCache::ReplicationGroup, AWS::Neptune::DBCluster,
17050
17081
  * AWS::RDS::DBCluster, AWS::RDS::DBInstance, AWS::Redshift::Cluster,
17051
- * AWS::DocDB::DBCluster.
17082
+ * AWS::DocDB::DBCluster — all covered by one of the two mechanisms.
17052
17083
  */
17053
17084
  /**
17054
17085
  * Types whose delete API takes an atomic final-snapshot parameter. The
@@ -17065,44 +17096,61 @@ const ATOMIC_FINAL_SNAPSHOT_TYPES = /* @__PURE__ */ new Set([
17065
17096
  ]);
17066
17097
  /**
17067
17098
  * Types with no atomic delete parameter, where cdkd snapshots explicitly
17068
- * BEFORE the (routing-layer-agnostic) delete. `AWS::EC2::Volume` is
17069
- * CC-API-routed, so the pre-delete snapshot lives at the destroy call sites,
17070
- * not in a provider.
17071
- */
17072
- const PRE_DELETE_SNAPSHOT_TYPES = /* @__PURE__ */ new Set(["AWS::EC2::Volume"]);
17099
+ * BEFORE the (routing-layer-agnostic) delete via
17100
+ * {@link createPreDeleteFinalSnapshot}. All three are CC-API-routed (no SDK
17101
+ * provider), so the pre-delete snapshot lives at the delete call sites, not
17102
+ * in a provider: `AWS::EC2::Volume` (EC2 `CreateSnapshot`, issue #1352),
17103
+ * `AWS::Redshift::Cluster` (`CreateClusterSnapshot`, issue #1353), and
17104
+ * `AWS::ElastiCache::ReplicationGroup` (ElastiCache `CreateSnapshot`
17105
+ * against the replication group, issue #1353).
17106
+ */
17107
+ const PRE_DELETE_SNAPSHOT_TYPES = /* @__PURE__ */ new Set([
17108
+ "AWS::EC2::Volume",
17109
+ "AWS::Redshift::Cluster",
17110
+ "AWS::ElastiCache::ReplicationGroup"
17111
+ ]);
17112
+ /** Sanitize a physical id into the snapshot-identifier charset (shared by the
17113
+ * identifier builder and the pre-delete reuse-probe prefix match). */
17114
+ function sanitizeSnapshotBase(physicalId) {
17115
+ let base = physicalId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
17116
+ if (!/^[a-z]/.test(base)) base = `r${base}`;
17117
+ return base;
17118
+ }
17073
17119
  /**
17074
- * CFn-documented Snapshot-capable types cdkd cannot snapshot yet (issue
17075
- * #1353): named in the refusal error so users know the policy is valid,
17076
- * just unimplemented as opposed to a template mistake.
17120
+ * The `<sanitized-physical-id>-final-` prefix every cdkd final snapshot of a
17121
+ * given source shares the reuse-probe key for the name-keyed snapshot APIs
17122
+ * (Redshift / ElastiCache), the analogue of the EBS tag. Applies the same
17123
+ * per-service length cap as {@link buildFinalSnapshotIdentifier} so a
17124
+ * truncated generated name still matches its own prefix.
17077
17125
  */
17078
- const UNSUPPORTED_FINAL_SNAPSHOT_TYPES = /* @__PURE__ */ new Set(["AWS::Redshift::Cluster", "AWS::ElastiCache::ReplicationGroup"]);
17126
+ function finalSnapshotNamePrefix(physicalId, resourceType) {
17127
+ const maxLength = resourceType === "AWS::ElastiCache::CacheCluster" || resourceType === "AWS::ElastiCache::ReplicationGroup" ? 50 : 255;
17128
+ return `${sanitizeSnapshotBase(physicalId).slice(0, maxLength - 22).replace(/-+$/, "")}-final-`;
17129
+ }
17079
17130
  /**
17080
- * Build the final-snapshot identifier for an atomic-parameter delete.
17131
+ * Build the final-snapshot identifier for a snapshot-creating delete.
17081
17132
  *
17082
17133
  * Constraints are the intersection of the RDS / Neptune / DocDB snapshot
17083
17134
  * identifier rules and the ElastiCache snapshot name rules: start with a
17084
17135
  * letter, letters/digits/hyphens only, no consecutive or trailing hyphen.
17085
17136
  * The physical id already satisfies the source API's naming rules; it is
17086
17137
  * lowercased (RDS canonicalizes to lowercase anyway) and defensively
17087
- * re-sanitized. A UTC timestamp suffix keeps repeated destroys of a
17088
- * re-created same-name resource from colliding; within ONE destroy run the
17138
+ * re-sanitized. A UTC timestamp suffix keeps repeated deletes of a
17139
+ * re-created same-name resource from colliding; within ONE run the
17089
17140
  * identifier is generated once per resource, so delete retries reuse it.
17090
17141
  */
17091
17142
  function buildFinalSnapshotIdentifier(physicalId, resourceType, now = /* @__PURE__ */ new Date()) {
17092
17143
  const ts = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "").replace("T", "-").toLowerCase();
17093
- let base = physicalId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
17094
- if (!/^[a-z]/.test(base)) base = `r${base}`;
17095
- const maxLength = resourceType === "AWS::ElastiCache::CacheCluster" ? 50 : 255;
17096
- const suffix = `-final-${ts}`;
17097
- base = base.slice(0, maxLength - suffix.length).replace(/-+$/, "");
17098
- return `${base}${suffix}`;
17144
+ return `${finalSnapshotNamePrefix(physicalId, resourceType)}${ts}`;
17099
17145
  }
17100
17146
  /** Tag key marking a cdkd-created final snapshot of an EBS volume. */
17101
17147
  const EBS_FINAL_SNAPSHOT_TAG_KEY = "cdkd:final-snapshot-of";
17102
17148
  /** Test seam (matches the `describe-type.ts` / macro-expander pattern). */
17103
17149
  const finalSnapshotDelays = { sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) };
17104
- const EBS_SNAPSHOT_POLL_INTERVAL_MS = 5e3;
17105
- const EBS_SNAPSHOT_TIMEOUT_MS = 3600 * 1e3;
17150
+ const PRE_DELETE_SNAPSHOT_POLL_INTERVAL_MS = 5e3;
17151
+ const PRE_DELETE_SNAPSHOT_TIMEOUT_MS = 3600 * 1e3;
17152
+ /** Post-snapshot settle budget for a Redshift cluster before its delete. */
17153
+ const REDSHIFT_CLUSTER_SETTLE_TIMEOUT_MS = 1200 * 1e3;
17106
17154
  function errMsg(error) {
17107
17155
  return error instanceof Error ? error.message : String(error);
17108
17156
  }
@@ -17116,7 +17164,12 @@ function isVolumeNotFound(error) {
17116
17164
  *
17117
17165
  * Idempotent across destroy re-runs: an existing pending/completed snapshot
17118
17166
  * carrying the `cdkd:final-snapshot-of: <volumeId>` tag is reused instead of
17119
- * creating a second one.
17167
+ * creating a second one. Reusing a `completed` snapshot is safe HERE (and
17168
+ * only here) because an EBS volume id is AWS-generated and never reused, so
17169
+ * the tag can only ever point at THIS volume's data — unlike the name-keyed
17170
+ * Redshift / ElastiCache APIs, whose user-chosen ids repeat across
17171
+ * generations and therefore only resume an in-flight snapshot (see
17172
+ * {@link RESUMABLE_SNAPSHOT_STATUS}).
17120
17173
  *
17121
17174
  * @returns the snapshot id, or `null` when the volume no longer exists
17122
17175
  * (nothing to snapshot; the subsequent delete is idempotent too).
@@ -17159,7 +17212,7 @@ async function createEbsFinalSnapshot(client, volumeId, logicalId, logger) {
17159
17212
  }
17160
17213
  if (!snapshotId) throw new CdkdError(`CreateSnapshot for ${logicalId} (${volumeId}) returned no snapshot id. The volume was NOT deleted.`, "FINAL_SNAPSHOT_FAILED");
17161
17214
  logger.info(`Creating final snapshot ${snapshotId} for ${logicalId} (${volumeId}) — DeletionPolicy: Snapshot`);
17162
- const deadline = Date.now() + EBS_SNAPSHOT_TIMEOUT_MS;
17215
+ const deadline = Date.now() + PRE_DELETE_SNAPSHOT_TIMEOUT_MS;
17163
17216
  let notFoundPolls = 0;
17164
17217
  for (;;) {
17165
17218
  let state;
@@ -17176,7 +17229,7 @@ async function createEbsFinalSnapshot(client, volumeId, logicalId, logger) {
17176
17229
  }
17177
17230
  if (state === "error") throw new CdkdError(`Final snapshot ${snapshotId} for ${logicalId} (${volumeId}) entered 'error' state. The volume was NOT deleted.`, "FINAL_SNAPSHOT_FAILED");
17178
17231
  if (Date.now() >= deadline) throw new CdkdError(`Timed out waiting for final snapshot ${snapshotId} of ${logicalId} (${volumeId}) to complete. The volume was NOT deleted; the snapshot continues in AWS — re-run the destroy once it completes (the existing snapshot is reused, not duplicated).`, "FINAL_SNAPSHOT_TIMEOUT");
17179
- await finalSnapshotDelays.sleep(EBS_SNAPSHOT_POLL_INTERVAL_MS);
17232
+ await finalSnapshotDelays.sleep(PRE_DELETE_SNAPSHOT_POLL_INTERVAL_MS);
17180
17233
  }
17181
17234
  }
17182
17235
  /**
@@ -17201,10 +17254,284 @@ function ccRoutedFinalSnapshotError(logicalId, resourceType, skipFlagHint) {
17201
17254
  }
17202
17255
  /**
17203
17256
  * Build the refusal error for a Snapshot-tagged type cdkd cannot snapshot.
17204
- * Thrown by the destroy call sites BEFORE any delete is issued.
17257
+ * Thrown by the delete call sites BEFORE any delete is issued. Since #1353
17258
+ * every CFn-documented Snapshot-capable type is covered, so this only fires
17259
+ * for a type CloudFormation itself would refuse the attribute on (hand-
17260
+ * edited templates / imported state).
17205
17261
  */
17206
17262
  function unsupportedFinalSnapshotError(logicalId, resourceType, skipFlagHint) {
17207
- return new CdkdError(`${logicalId} (${resourceType}) has DeletionPolicy: Snapshot, but${UNSUPPORTED_FINAL_SNAPSHOT_TYPES.has(resourceType) ? " cdkd does not implement final snapshots for this type yet (issue #1353)." : ` cdkd does not implement final snapshots for this type (CloudFormation itself only supports Snapshot on: EC2 Volume, ElastiCache CacheCluster / ReplicationGroup, Neptune / RDS / DocDB clusters, RDS instances, Redshift clusters).`} Deleting it now would destroy its data WITHOUT the final snapshot the policy promises. Re-run with ${skipFlagHint} to delete WITHOUT a final snapshot (DATA LOSS), or retain the resource (DeletionPolicy: Retain) and delete it manually after snapshotting.`, "FINAL_SNAPSHOT_UNSUPPORTED");
17263
+ return new CdkdError(`${logicalId} (${resourceType}) has DeletionPolicy: Snapshot, but cdkd does not implement final snapshots for this type (CloudFormation itself only supports Snapshot on: EC2 Volume, ElastiCache CacheCluster / ReplicationGroup, Neptune / RDS / DocDB clusters, RDS instances, Redshift clusters). Deleting it now would destroy its data WITHOUT the final snapshot the policy promises. Re-run with ${skipFlagHint} to delete WITHOUT a final snapshot (DATA LOSS), or retain the resource (DeletionPolicy: Retain) and delete it manually after snapshotting.`, "FINAL_SNAPSHOT_UNSUPPORTED");
17264
+ }
17265
+ /**
17266
+ * Dispatch the pre-delete final snapshot for a `PRE_DELETE_SNAPSHOT_TYPES`
17267
+ * member: create (or resume) the snapshot and wait until it is ready, so the
17268
+ * caller's subsequent (routing-layer-agnostic) delete cannot lose data.
17269
+ *
17270
+ * @returns the snapshot id, or `null` when the source resource no longer
17271
+ * exists (nothing to snapshot; the subsequent delete is idempotent too).
17272
+ */
17273
+ async function createPreDeleteFinalSnapshot(resourceType, physicalId, logicalId, clients, logger) {
17274
+ switch (resourceType) {
17275
+ case "AWS::EC2::Volume": return createEbsFinalSnapshot(clients.ec2, physicalId, logicalId, logger);
17276
+ case "AWS::Redshift::Cluster": return createRedshiftFinalSnapshot(clients.redshift, physicalId, logicalId, logger);
17277
+ case "AWS::ElastiCache::ReplicationGroup": return createReplicationGroupFinalSnapshot(clients.elastiCache, physicalId, logicalId, logger);
17278
+ default: throw new CdkdError(`No pre-delete final-snapshot implementation for ${logicalId} (${resourceType}). The resource was NOT deleted.`, "FINAL_SNAPSHOT_FAILED");
17279
+ }
17280
+ }
17281
+ /**
17282
+ * Shared wait loop for the name-keyed snapshot APIs (Redshift / ElastiCache):
17283
+ * poll a status getter until the ready status, tolerating a bounded window of
17284
+ * eventual-consistency NotFound right after creation, and wrapping every
17285
+ * other poll failure as a typed FINAL_SNAPSHOT error (see the EBS loop's
17286
+ * comment for why the wrap is load-bearing).
17287
+ */
17288
+ async function waitForNamedSnapshot(opts) {
17289
+ const { snapshotId, logicalId, sourceId, noun, logger } = opts;
17290
+ logger.info(`${opts.reused ? "Resuming" : "Creating"} final snapshot ${snapshotId} for ${logicalId} (${sourceId}) — DeletionPolicy: Snapshot`);
17291
+ const deadline = Date.now() + PRE_DELETE_SNAPSHOT_TIMEOUT_MS;
17292
+ let notFoundPolls = 0;
17293
+ for (;;) {
17294
+ let status;
17295
+ try {
17296
+ status = await opts.pollStatus();
17297
+ notFoundPolls = 0;
17298
+ } catch (pollError) {
17299
+ if (opts.isTransientNotFound(pollError) && ++notFoundPolls <= 24) status = void 0;
17300
+ else throw new CdkdError(`Failed while waiting for final snapshot ${snapshotId} of ${logicalId} (${sourceId}): ${errMsg(pollError)}. The ${noun} was NOT deleted; re-run the delete (the snapshot is reused, not duplicated).`, "FINAL_SNAPSHOT_FAILED", pollError instanceof Error ? pollError : void 0);
17301
+ }
17302
+ if (status === opts.readyStatus) {
17303
+ logger.info(`Final snapshot ${snapshotId} ready for ${logicalId} (${sourceId})`);
17304
+ return snapshotId;
17305
+ }
17306
+ if (status !== void 0 && opts.failedStatuses.includes(status)) throw new CdkdError(`Final snapshot ${snapshotId} for ${logicalId} (${sourceId}) entered '${status}' state. The ${noun} was NOT deleted.`, "FINAL_SNAPSHOT_FAILED");
17307
+ if (Date.now() >= deadline) throw new CdkdError(`Timed out waiting for final snapshot ${snapshotId} of ${logicalId} (${sourceId}) to become ready. The ${noun} was NOT deleted; the snapshot continues in AWS — re-run the delete once it is ready (the existing snapshot is reused, not duplicated).`, "FINAL_SNAPSHOT_TIMEOUT");
17308
+ await finalSnapshotDelays.sleep(PRE_DELETE_SNAPSHOT_POLL_INTERVAL_MS);
17309
+ }
17310
+ }
17311
+ function errorNameOrMessageIncludes(error, token) {
17312
+ return (error instanceof Error ? error.name : "") === token || errMsg(error).includes(token);
17313
+ }
17314
+ /** Max reuse-probe pages to walk (bounded so a huge snapshot history cannot
17315
+ * stall a delete; a miss only costs one extra snapshot, never data). */
17316
+ const REUSE_PROBE_MAX_PAGES = 10;
17317
+ /**
17318
+ * Matcher for a snapshot cdkd itself generated for `physicalId`: the exact
17319
+ * `<sanitized-id>-final-<yyyymmdd>-<hhmmss>` shape, NOT a bare prefix. A bare
17320
+ * prefix would also match a USER's snapshot named e.g. `mydb-final-backup`
17321
+ * and silently adopt it as "the" final snapshot.
17322
+ */
17323
+ function generatedSnapshotNameMatcher(physicalId, resourceType) {
17324
+ const escaped = finalSnapshotNamePrefix(physicalId, resourceType).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17325
+ return new RegExp(`^${escaped}\\d{8}-\\d{6}$`);
17326
+ }
17327
+ /**
17328
+ * Is this snapshot resumable from an interrupted delete attempt?
17329
+ *
17330
+ * ONLY an in-flight (`creating`) snapshot qualifies. This is load-bearing,
17331
+ * not conservatism: Redshift / ElastiCache identifiers are USER-CHOSEN and
17332
+ * REUSABLE, and a manual snapshot outlives the resource it came from — so a
17333
+ * deploy -> destroy -> deploy -> destroy cycle of a fixed-name cluster would
17334
+ * find generation 1's `available` snapshot, skip `CreateSnapshot`, and delete
17335
+ * generation 2 with NO snapshot of its data. That is precisely the silent
17336
+ * data loss this whole feature exists to prevent (reviewer catch).
17337
+ *
17338
+ * A `creating` snapshot cannot span generations: the new generation can only
17339
+ * be created after the old resource is gone, by which time its snapshot has
17340
+ * settled to `available`. The cost of the narrower rule is one redundant
17341
+ * snapshot when a delete is re-run after its snapshot already completed —
17342
+ * cost, never data loss, and the timestamped identifier never collides.
17343
+ */
17344
+ const RESUMABLE_SNAPSHOT_STATUS = "creating";
17345
+ /**
17346
+ * Redshift `CreateClusterSnapshot` + wait to `available` (issue #1353).
17347
+ * Idempotent across delete re-runs: a creating/available manual snapshot of
17348
+ * this cluster whose identifier carries cdkd's `<cluster>-final-` prefix is
17349
+ * reused instead of creating a second one.
17350
+ */
17351
+ async function createRedshiftFinalSnapshot(client, clusterId, logicalId, logger) {
17352
+ const matches = generatedSnapshotNameMatcher(clusterId, "AWS::Redshift::Cluster");
17353
+ let snapshotId;
17354
+ try {
17355
+ let marker;
17356
+ for (let page = 0; page < REUSE_PROBE_MAX_PAGES && !snapshotId; page++) {
17357
+ const existing = await client.send(new DescribeClusterSnapshotsCommand({
17358
+ ClusterIdentifier: clusterId,
17359
+ SnapshotType: "manual",
17360
+ ...marker !== void 0 && { Marker: marker }
17361
+ }));
17362
+ snapshotId = existing.Snapshots?.find((s) => s.SnapshotIdentifier !== void 0 && matches.test(s.SnapshotIdentifier) && s.Status === RESUMABLE_SNAPSHOT_STATUS)?.SnapshotIdentifier;
17363
+ marker = existing.Marker;
17364
+ if (marker === void 0) break;
17365
+ }
17366
+ if (snapshotId) logger.debug(`Resuming in-flight final snapshot ${snapshotId} for ${logicalId} (${clusterId}) from a previous delete attempt`);
17367
+ } catch (probeError) {
17368
+ logger.debug(`Final-snapshot reuse probe failed for ${clusterId}: ${errMsg(probeError)}`);
17369
+ }
17370
+ const reused = snapshotId !== void 0;
17371
+ if (!snapshotId) {
17372
+ const newId = buildFinalSnapshotIdentifier(clusterId, "AWS::Redshift::Cluster");
17373
+ try {
17374
+ await client.send(new CreateClusterSnapshotCommand({
17375
+ SnapshotIdentifier: newId,
17376
+ ClusterIdentifier: clusterId
17377
+ }));
17378
+ snapshotId = newId;
17379
+ } catch (createError) {
17380
+ if (errorNameOrMessageIncludes(createError, "ClusterNotFoundFault")) {
17381
+ logger.debug(`Redshift cluster ${clusterId} no longer exists — skipping final snapshot for ${logicalId}`);
17382
+ return null;
17383
+ }
17384
+ if (errorNameOrMessageIncludes(createError, "InvalidClusterStateFault")) {
17385
+ logger.info(`Redshift cluster ${clusterId} is not in a snapshottable state (likely already deleting) — skipping the final snapshot for ${logicalId}: ${errMsg(createError)}`);
17386
+ return null;
17387
+ }
17388
+ throw new CdkdError(`Failed to create the final snapshot for ${logicalId} (${clusterId}) required by DeletionPolicy: Snapshot: ${errMsg(createError)}. The cluster was NOT deleted.`, "FINAL_SNAPSHOT_FAILED");
17389
+ }
17390
+ }
17391
+ const readySnapshotId = await waitForNamedSnapshot({
17392
+ snapshotId,
17393
+ logicalId,
17394
+ sourceId: clusterId,
17395
+ noun: "cluster",
17396
+ reused,
17397
+ readyStatus: "available",
17398
+ failedStatuses: [
17399
+ "failed",
17400
+ "cancelled",
17401
+ "deleted"
17402
+ ],
17403
+ pollStatus: async () => {
17404
+ return (await client.send(new DescribeClusterSnapshotsCommand({ SnapshotIdentifier: snapshotId }))).Snapshots?.[0]?.Status;
17405
+ },
17406
+ isTransientNotFound: (e) => errorNameOrMessageIncludes(e, "ClusterSnapshotNotFoundFault"),
17407
+ logger
17408
+ });
17409
+ await waitForRedshiftClusterSettled(client, clusterId, logicalId, logger);
17410
+ return readySnapshotId;
17411
+ }
17412
+ /**
17413
+ * Poll a Redshift cluster until it is out of the transient state a just-taken
17414
+ * snapshot leaves it in, so the caller's delete is not rejected. Bounded and
17415
+ * NEVER fatal: a NotFound means the cluster is already gone, and exhausting
17416
+ * the budget only logs — the delete (and its own retry) still runs.
17417
+ */
17418
+ async function waitForRedshiftClusterSettled(client, clusterId, logicalId, logger) {
17419
+ const deadline = Date.now() + REDSHIFT_CLUSTER_SETTLE_TIMEOUT_MS;
17420
+ for (;;) {
17421
+ let status;
17422
+ try {
17423
+ status = (await client.send(new DescribeClustersCommand({ ClusterIdentifier: clusterId }))).Clusters?.[0]?.ClusterStatus;
17424
+ } catch (pollError) {
17425
+ if (errorNameOrMessageIncludes(pollError, "ClusterNotFoundFault")) return;
17426
+ logger.debug(`Could not read Redshift cluster ${clusterId} while waiting for it to settle: ${errMsg(pollError)}`);
17427
+ return;
17428
+ }
17429
+ if (status === void 0 || status === "available") {
17430
+ await finalSnapshotDelays.sleep(PRE_DELETE_SNAPSHOT_POLL_INTERVAL_MS);
17431
+ return;
17432
+ }
17433
+ if (Date.now() >= deadline) {
17434
+ logger.info(`Redshift cluster ${clusterId} (${logicalId}) is still '${status}' after the post-snapshot settle wait — proceeding with the delete anyway.`);
17435
+ return;
17436
+ }
17437
+ logger.debug(`Waiting for Redshift cluster ${clusterId} to settle (status: ${status})`);
17438
+ await finalSnapshotDelays.sleep(PRE_DELETE_SNAPSHOT_POLL_INTERVAL_MS);
17439
+ }
17440
+ }
17441
+ /**
17442
+ * ElastiCache final snapshot for a replication group + wait to `available`
17443
+ * (issue #1353), then the caller's delete proceeds.
17444
+ *
17445
+ * **Cluster-mode matters** (found live 2026-08-03, not by the mocked unit
17446
+ * tests): `CreateSnapshot` accepts `ReplicationGroupId` ONLY for a
17447
+ * cluster-mode-ENABLED (sharded) group. For the cluster-mode-DISABLED
17448
+ * default — which is what a plain CDK / CFn replication group is — AWS
17449
+ * rejects it with "Cannot snapshot a replication group with cluster-mode
17450
+ * disabled. Please specify a cache cluster instead.", and the snapshot must
17451
+ * name the PRIMARY member cache cluster instead. Both shapes produce one
17452
+ * snapshot of the group's data; only the parameter differs.
17453
+ *
17454
+ * Idempotent across delete re-runs via the `<group>-final-` name prefix,
17455
+ * probed against whichever source this group's mode uses.
17456
+ *
17457
+ * Redis only — Memcached / snapshot-incapable node types surface AWS's
17458
+ * rejection wrapped, matching CloudFormation's DELETE_FAILED for the same
17459
+ * template.
17460
+ */
17461
+ async function createReplicationGroupFinalSnapshot(client, replicationGroupId, logicalId, logger) {
17462
+ let snapshotSource;
17463
+ try {
17464
+ const group = (await client.send(new DescribeReplicationGroupsCommand({ ReplicationGroupId: replicationGroupId }))).ReplicationGroups?.[0];
17465
+ if (group === void 0) {
17466
+ logger.debug(`Replication group ${replicationGroupId} no longer exists — skipping final snapshot for ${logicalId}`);
17467
+ return null;
17468
+ }
17469
+ if (group.ClusterEnabled === true) snapshotSource = { ReplicationGroupId: replicationGroupId };
17470
+ else {
17471
+ const member = group.NodeGroups?.flatMap((n) => n.NodeGroupMembers ?? []).find((m) => m.CurrentRole === "primary")?.CacheClusterId ?? group.MemberClusters?.[0];
17472
+ if (member === void 0) throw new CdkdError(`Replication group ${replicationGroupId} (${logicalId}) has cluster-mode disabled but reports no member cache cluster to snapshot. The replication group was NOT deleted.`, "FINAL_SNAPSHOT_FAILED");
17473
+ snapshotSource = { CacheClusterId: member };
17474
+ }
17475
+ } catch (describeError) {
17476
+ if (describeError instanceof CdkdError) throw describeError;
17477
+ if (errorNameOrMessageIncludes(describeError, "ReplicationGroupNotFoundFault")) {
17478
+ logger.debug(`Replication group ${replicationGroupId} no longer exists — skipping final snapshot for ${logicalId}`);
17479
+ return null;
17480
+ }
17481
+ throw new CdkdError(`Failed to read replication group ${replicationGroupId} (${logicalId}) to pick the final snapshot source required by DeletionPolicy: Snapshot: ${errMsg(describeError)}. The replication group was NOT deleted.`, "FINAL_SNAPSHOT_FAILED", describeError instanceof Error ? describeError : void 0);
17482
+ }
17483
+ const matches = generatedSnapshotNameMatcher(replicationGroupId, "AWS::ElastiCache::ReplicationGroup");
17484
+ let snapshotId;
17485
+ try {
17486
+ let marker;
17487
+ for (let page = 0; page < REUSE_PROBE_MAX_PAGES && !snapshotId; page++) {
17488
+ const existing = await client.send(new DescribeSnapshotsCommand$1({
17489
+ ...snapshotSource,
17490
+ ...marker !== void 0 && { Marker: marker }
17491
+ }));
17492
+ snapshotId = existing.Snapshots?.find((s) => s.SnapshotName !== void 0 && matches.test(s.SnapshotName) && s.SnapshotStatus === RESUMABLE_SNAPSHOT_STATUS)?.SnapshotName;
17493
+ marker = existing.Marker;
17494
+ if (marker === void 0) break;
17495
+ }
17496
+ if (snapshotId) logger.debug(`Resuming in-flight final snapshot ${snapshotId} for ${logicalId} (${replicationGroupId}) from a previous delete attempt`);
17497
+ } catch (probeError) {
17498
+ logger.debug(`Final-snapshot reuse probe failed for ${replicationGroupId}: ${errMsg(probeError)}`);
17499
+ }
17500
+ const reused = snapshotId !== void 0;
17501
+ if (!snapshotId) {
17502
+ const newId = buildFinalSnapshotIdentifier(replicationGroupId, "AWS::ElastiCache::ReplicationGroup");
17503
+ try {
17504
+ await client.send(new CreateSnapshotCommand$1({
17505
+ ...snapshotSource,
17506
+ SnapshotName: newId
17507
+ }));
17508
+ snapshotId = newId;
17509
+ } catch (createError) {
17510
+ if (errorNameOrMessageIncludes(createError, "ReplicationGroupNotFoundFault")) {
17511
+ logger.debug(`Replication group ${replicationGroupId} no longer exists — skipping final snapshot for ${logicalId}`);
17512
+ return null;
17513
+ }
17514
+ if (errorNameOrMessageIncludes(createError, "InvalidReplicationGroupStateFault")) {
17515
+ logger.info(`Replication group ${replicationGroupId} is not in a snapshottable state (likely already deleting) — skipping the final snapshot for ${logicalId}: ${errMsg(createError)}`);
17516
+ return null;
17517
+ }
17518
+ throw new CdkdError(`Failed to create the final snapshot for ${logicalId} (${replicationGroupId}) required by DeletionPolicy: Snapshot: ${errMsg(createError)}. The replication group was NOT deleted.`, "FINAL_SNAPSHOT_FAILED");
17519
+ }
17520
+ }
17521
+ return waitForNamedSnapshot({
17522
+ snapshotId,
17523
+ logicalId,
17524
+ sourceId: replicationGroupId,
17525
+ noun: "replication group",
17526
+ reused,
17527
+ readyStatus: "available",
17528
+ failedStatuses: ["failed"],
17529
+ pollStatus: async () => {
17530
+ return (await client.send(new DescribeSnapshotsCommand$1({ SnapshotName: snapshotId }))).Snapshots?.[0]?.SnapshotStatus;
17531
+ },
17532
+ isTransientNotFound: (e) => errorNameOrMessageIncludes(e, "SnapshotNotFoundFault"),
17533
+ logger
17534
+ });
17208
17535
  }
17209
17536
 
17210
17537
  //#endregion
@@ -17407,6 +17734,23 @@ async function withResourceDeadline(operation, opts) {
17407
17734
  //#endregion
17408
17735
  //#region src/deployment/rollback-executor.ts
17409
17736
  /**
17737
+ * `UpdateReplacePolicy: Snapshot` on a rollback's delete-of-the-NEW-resource
17738
+ * (issue #1354): honor it where it costs nothing — an atomic-final-snapshot
17739
+ * type on the SDK route gets a generated identifier threaded into the delete
17740
+ * context. Every other Snapshot shape (pre-delete types, cc-api routing)
17741
+ * keeps the plain delete DELIBERATELY: the rollback executor's delete-new is
17742
+ * load-bearing for same-name re-creation (orphaning would break the revert),
17743
+ * it has no snapshot-client plumbing by design (registry + region only), and
17744
+ * the new resource was created by the deploy being reverted. Recorded as a
17745
+ * scope decision on issue #1354.
17746
+ */
17747
+ function rollbackFinalSnapshotId(resourceType, record, fallbackProvisionedBy) {
17748
+ if (record.updateReplacePolicy !== "Snapshot") return void 0;
17749
+ if (!ATOMIC_FINAL_SNAPSHOT_TYPES.has(resourceType)) return void 0;
17750
+ if ((record.provisionedBy ?? fallbackProvisionedBy) === "cc-api") return void 0;
17751
+ return buildFinalSnapshotIdentifier(record.physicalId, resourceType);
17752
+ }
17753
+ /**
17410
17754
  * Retry schedule for a re-create that must wait out a name-release delay:
17411
17755
  * an async delete's late name release ("already exists") or the SQS 60s
17412
17756
  * same-name cooldown (issue #1206). 2s/4s/8s then capped at 10s over 8
@@ -17657,7 +18001,13 @@ async function replaySingle(op, stateResources, stackName, ctx, orphanLogicalIds
17657
18001
  resourceType: op.resourceType,
17658
18002
  provisionedBy: current.provisionedBy ?? op.provisionedBy
17659
18003
  });
17660
- await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, { expectedRegion: ctx.region });
18004
+ {
18005
+ const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
18006
+ await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
18007
+ expectedRegion: ctx.region,
18008
+ ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
18009
+ });
18010
+ }
17661
18011
  stateResources[op.logicalId] = prev;
17662
18012
  logger.info(` Rollback: ${op.logicalId} restored to the retained old resource`);
17663
18013
  await afterOp?.(op.logicalId);
@@ -17699,7 +18049,13 @@ async function replaySingle(op, stateResources, stackName, ctx, orphanLogicalIds
17699
18049
  } catch (createError) {
17700
18050
  if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
17701
18051
  logger.info(` Rollback: re-create collided with the new resource's name — deleting the new resource (${current.physicalId}) first...`);
17702
- await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, { expectedRegion: ctx.region });
18052
+ {
18053
+ const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
18054
+ await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
18055
+ expectedRegion: ctx.region,
18056
+ ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
18057
+ });
18058
+ }
17703
18059
  deletedNewFirst = true;
17704
18060
  delete stateResources[op.logicalId];
17705
18061
  await afterOp?.(op.logicalId);
@@ -17730,7 +18086,11 @@ async function replaySingle(op, stateResources, stackName, ctx, orphanLogicalIds
17730
18086
  };
17731
18087
  await afterOp?.(op.logicalId);
17732
18088
  if (!deletedNewFirst && !adoptedLiveNewResource) try {
17733
- await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, { expectedRegion: ctx.region });
18089
+ const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
18090
+ await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
18091
+ expectedRegion: ctx.region,
18092
+ ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
18093
+ });
17734
18094
  } catch (deleteError) {
17735
18095
  logger.warn(` Rollback: old ${op.logicalId} re-created, but deleting the new resource (${current.physicalId}) failed: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. Delete it manually — it is no longer tracked in state.`);
17736
18096
  result.warnings++;
@@ -17994,7 +18354,7 @@ const FLUSH_INTERVAL_MS = 2e3;
17994
18354
  const FLUSH_EVENT_THRESHOLD = 50;
17995
18355
  /** Build-time cdkd version, with a dev fallback for non-built contexts. */
17996
18356
  function getCdkdVersion() {
17997
- return "0.275.6";
18357
+ return "0.276.0";
17998
18358
  }
17999
18359
  /**
18000
18360
  * Generate a time-sortable unique run id, e.g.
@@ -19518,17 +19878,46 @@ var DeployEngine = class {
19518
19878
  throw new ProvisioningError(`Unrewritten asset reference on '${logicalId}' (${resourceType}): this region uses cdkd-owned asset storage, but the following resolved properties still point at the CDK bootstrap storage that 'cdk gc' may garbage-collect:\n${findings.map((f) => ` - ${f.path}: still references '${f.source}'`).join("\n")}\nThis is a template shape cdkd's asset-reference rewrite did not cover — deploying it would split-brain the stack (assets in cdkd storage, properties reading the CDK bucket). Please report this at https://github.com/go-to-k/cdkd/issues with the property shape. Workaround: deploy with --use-cdk-bootstrap-assets to pin the legacy destinations for this app.`, resourceType, logicalId);
19519
19879
  }
19520
19880
  /**
19881
+ * The `Snapshot`-policy gate every engine delete site runs BEFORE its
19882
+ * delete (issues #1352 / #1353 / #1354). Given the resource's effective
19883
+ * policy for THIS delete (`DeletionPolicy` on the destroy / removal paths,
19884
+ * `UpdateReplacePolicy` on the replacement paths):
19885
+ *
19886
+ * - not `Snapshot` (or `--skip-final-snapshot`) → no-op.
19887
+ * - atomic type, SDK-routed → returns the generated identifier for the
19888
+ * provider's atomic final-snapshot delete parameter.
19889
+ * - atomic type, cc-api-routed → refuses (Cloud Control has no
19890
+ * final-snapshot parameter; `CloudControlProvider.delete` also
19891
+ * fail-closes on the context field as defense-in-depth).
19892
+ * - `PRE_DELETE_SNAPSHOT_TYPES` (EC2 Volume / Redshift Cluster /
19893
+ * ElastiCache ReplicationGroup) → creates the snapshot and waits for
19894
+ * it here, then returns undefined (the subsequent delete is plain).
19895
+ * - anything else Snapshot-tagged → refuses.
19896
+ */
19897
+ async prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, policy) {
19898
+ if (policy !== "Snapshot" || this.options.skipFinalSnapshot === true) return void 0;
19899
+ if (ATOMIC_FINAL_SNAPSHOT_TYPES.has(resourceType) && currentResource.provisionedBy !== "cc-api") return buildFinalSnapshotIdentifier(currentResource.physicalId, resourceType);
19900
+ if (ATOMIC_FINAL_SNAPSHOT_TYPES.has(resourceType)) throw ccRoutedFinalSnapshotError(logicalId, resourceType, "--skip-final-snapshot");
19901
+ if (PRE_DELETE_SNAPSHOT_TYPES.has(resourceType)) {
19902
+ await createPreDeleteFinalSnapshot(resourceType, currentResource.physicalId, logicalId, this.options.finalSnapshotClients ?? getAwsClients(), this.logger);
19903
+ return;
19904
+ }
19905
+ throw unsupportedFinalSnapshotError(logicalId, resourceType, "--skip-final-snapshot");
19906
+ }
19907
+ /**
19521
19908
  * `--replace` delete-first fallback for a property-driven replacement of a
19522
19909
  * custom-named resource: delete the old name holder, then re-create it
19523
19910
  * under the same name. Shared by the create-first collision catch (issue
19524
19911
  * #960 follow-up) and the name-idempotent same-id guard (issue #1238) so
19525
19912
  * the two --replace escape hatches cannot drift apart.
19526
19913
  */
19527
- async replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps) {
19914
+ async replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateReplacePolicy) {
19915
+ const finalSnapshotIdentifier = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
19528
19916
  try {
19529
19917
  await oldDeleteProvider.delete(logicalId, currentResource.physicalId, resourceType, currentResource.properties, {
19530
19918
  expectedRegion: this.stackRegion,
19531
- forceDataDelete: this.options.forceStatefulRecreation === true
19919
+ forceDataDelete: this.options.forceStatefulRecreation === true,
19920
+ ...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
19532
19921
  });
19533
19922
  } catch (deleteError) {
19534
19923
  throw new Error(`Failed to delete old resource ${logicalId} (${currentResource.physicalId}) during the --replace delete-first fallback: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`);
@@ -19669,10 +20058,12 @@ var DeployEngine = class {
19669
20058
  if (updateReplacePolicy === "Retain") this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — ${recreateFlagName} will leak the old physical resource (${currentResource.physicalId}). The new resource shares the same name where applicable; if the type has user-supplied names (e.g. functionName, bucketName), the create will deterministically collide with the retained orphan.`);
19670
20059
  else {
19671
20060
  this.logger.info(` Destroying old ${logicalId} (${currentResource.physicalId}) before recreate...`);
20061
+ const recreateFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
19672
20062
  try {
19673
20063
  await oldDeleteProvider.delete(logicalId, currentResource.physicalId, resourceType, currentResource.properties, {
19674
20064
  expectedRegion: this.stackRegion,
19675
- forceDataDelete: this.options.forceStatefulRecreation === true
20065
+ forceDataDelete: this.options.forceStatefulRecreation === true,
20066
+ ...recreateFinalSnapshotId !== void 0 && { finalSnapshotIdentifier: recreateFinalSnapshotId }
19676
20067
  });
19677
20068
  this.logger.info(` ${green("✓")} Old resource deleted`);
19678
20069
  } catch (deleteError) {
@@ -19702,22 +20093,32 @@ var DeployEngine = class {
19702
20093
  if (this.options.replace !== true) throw new CdkdError(`${logicalId} (${resourceType}) requires replacement, but the create-first attempt collided with the existing resource: ${createMsg}. The resource has a user-supplied physical name, so the CloudFormation-style safe replacement order (create the new resource before deleting the old) cannot reuse the occupied name — CloudFormation refuses this shape with "cannot update a stack when a custom-named resource requires replacing". Either rename the resource in your CDK code (a fresh name lets the safe create-first order proceed), or re-run with \`cdkd deploy --replace\` to delete the old resource FIRST and recreate it under the same name (the resource is briefly unavailable while it is recreated).`, "NAMED_REPLACEMENT_COLLISION");
19703
20094
  this.logger.info(` Create-first collided with the custom-named resource and --replace is set — deleting old ${logicalId} (${currentResource.physicalId}) first...`);
19704
20095
  deletedOldFirst = true;
19705
- createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps);
20096
+ createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateReplacePolicy);
19706
20097
  }
19707
20098
  if (!deletedOldFirst && createResult.physicalId === currentResource.physicalId) {
19708
20099
  if (updateReplacePolicy === "Retain") throw new CdkdError(`${logicalId} (${resourceType}) requires replacement, but its Create API is name-idempotent: the create-first attempt returned the existing resource (${currentResource.physicalId}) instead of creating a new one, and UpdateReplacePolicy: Retain pins that resource in place. Rename the resource in your CDK code — with Retain, the old resource keeps the name, so a same-name replacement can never proceed.`, "NAMED_REPLACEMENT_IDEMPOTENT_CREATE");
19709
20100
  if (this.options.replace !== true) throw new CdkdError(`${logicalId} (${resourceType}) requires replacement, but its Create API is name-idempotent: the create-first attempt returned the EXISTING resource (${currentResource.physicalId}) instead of creating a new one, so deleting the "old" resource would silently destroy the resource the deploy just reported as created. The resource has a user-supplied physical name; either change or remove the explicit name in your CDK code (a fresh or generated name lets the safe create-first order proceed), or re-run with \`cdkd deploy --replace\` to delete the old resource FIRST and recreate it under the same name (the resource is briefly unavailable while it is recreated). Note: this branch is also reached when the old resource was deleted out-of-band and the physical id is name-derived — there the create was a genuine fresh create; \`--replace\` converges that case too.`, "NAMED_REPLACEMENT_IDEMPOTENT_CREATE");
19710
20101
  this.logger.info(` Create-first returned the existing resource (name-idempotent Create API) and --replace is set — deleting old ${logicalId} (${currentResource.physicalId}) first...`);
19711
20102
  deletedOldFirst = true;
19712
- createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps);
20103
+ createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateReplacePolicy);
19713
20104
  }
19714
20105
  if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
19715
20106
  else {
19716
20107
  this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
20108
+ let cleanupFinalSnapshotId;
20109
+ let snapshotBlockedDelete = false;
19717
20110
  try {
20111
+ cleanupFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
20112
+ } catch (snapshotError) {
20113
+ if (snapshotError instanceof CdkdError && snapshotError.code === "FINAL_SNAPSHOT_UNSUPPORTED") throw snapshotError;
20114
+ snapshotBlockedDelete = true;
20115
+ this.logger.warn(` ⚠ Final snapshot for old ${logicalId} (${currentResource.physicalId}) failed: ${snapshotError instanceof Error ? snapshotError.message : String(snapshotError)}. The old resource was NOT deleted (UpdateReplacePolicy: Snapshot) — delete it manually once you have a snapshot; it is no longer tracked in state.`);
20116
+ }
20117
+ if (!snapshotBlockedDelete) try {
19718
20118
  await oldDeleteProvider.delete(logicalId, currentResource.physicalId, resourceType, currentResource.properties, {
19719
20119
  expectedRegion: this.stackRegion,
19720
- forceDataDelete: this.options.forceStatefulRecreation === true
20120
+ forceDataDelete: this.options.forceStatefulRecreation === true,
20121
+ ...cleanupFinalSnapshotId !== void 0 && { finalSnapshotIdentifier: cleanupFinalSnapshotId }
19721
20122
  });
19722
20123
  this.logger.info(` ${green("✓")} Old resource deleted`);
19723
20124
  } catch (deleteError) {
@@ -19763,10 +20164,12 @@ var DeployEngine = class {
19763
20164
  if (statefulReason && this.options.forceStatefulRecreation !== true) throw new CdkdError(`--replace would DELETE + CREATE the stateful resource ${logicalId} (${resourceType}) — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.`, "STATEFUL_REPLACE_BLOCKED");
19764
20165
  }
19765
20166
  this.logger.info(`UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE → CREATE)`);
20167
+ const fallbackFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, template?.Resources?.[logicalId]?.UpdateReplacePolicy ?? currentResource.updateReplacePolicy);
19766
20168
  try {
19767
20169
  await updateProvider.delete(logicalId, currentResource.physicalId, resourceType, currentProps, {
19768
20170
  expectedRegion: this.stackRegion,
19769
- forceDataDelete: this.options.forceStatefulRecreation === true
20171
+ forceDataDelete: this.options.forceStatefulRecreation === true,
20172
+ ...fallbackFinalSnapshotId !== void 0 && { finalSnapshotIdentifier: fallbackFinalSnapshotId }
19770
20173
  });
19771
20174
  } catch (deleteError) {
19772
20175
  const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
@@ -19818,11 +20221,7 @@ var DeployEngine = class {
19818
20221
  delete stateResources[logicalId];
19819
20222
  break;
19820
20223
  }
19821
- let finalSnapshotIdentifier;
19822
- if (deletionPolicy === "Snapshot" && this.options.skipFinalSnapshot !== true) if (ATOMIC_FINAL_SNAPSHOT_TYPES.has(resourceType) && currentResource.provisionedBy !== "cc-api") finalSnapshotIdentifier = buildFinalSnapshotIdentifier(currentResource.physicalId, resourceType);
19823
- else if (ATOMIC_FINAL_SNAPSHOT_TYPES.has(resourceType)) throw ccRoutedFinalSnapshotError(logicalId, resourceType, "--skip-final-snapshot");
19824
- else if (PRE_DELETE_SNAPSHOT_TYPES.has(resourceType)) await createEbsFinalSnapshot(this.options.finalSnapshotEc2 ?? getAwsClients().ec2, currentResource.physicalId, logicalId, this.logger);
19825
- else throw unsupportedFinalSnapshotError(logicalId, resourceType, "--skip-final-snapshot");
20224
+ const finalSnapshotIdentifier = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, deletionPolicy);
19826
20225
  const deleteProvider = this.providerRegistry.getProviderFor({
19827
20226
  resourceType,
19828
20227
  provisionedBy: currentResource.provisionedBy
@@ -20064,5 +20463,5 @@ var DeployEngine = class {
20064
20463
  };
20065
20464
 
20066
20465
  //#endregion
20067
- export { TemplateParser as $, CdkdError as $t, yellow as A, resolveAutoAssetStorage as At, IntrinsicFunctionResolver as B, MIGRATE_TMP_PREFIX as Bt, renderStatefulReason as C, AssetManifestLoader as Ct, gray as D, getDefaultStateBucketName as Dt, cyan as E, synthesisStatusMessage as Et, findActionableSilentDrops as F, resolveUseCdkBootstrapAssets as Ft, resolveExplicitPhysicalId as G, processStackMessages as Gt, refStateLookupFromResource as H, uploadCfnTemplate as Ht, CloudControlProvider as I, stateBucketExistenceConfirmed as It, DiffCalculator as J, AwsClients as Jt, assertRegionMatch as K, clearBucketRegionCache as Kt, slowCcOperationTimeoutMs as L, warnDeprecatedNoPrefixCliFlag as Lt, collectInlinePolicyNamesManagedBySiblings as M, resolveSkipPrefix as Mt, clearOnUpdateRemoval as N, resolveStateBucketWithDefault as Nt, green as O, getLegacyStateBucketName as Ot, ProviderRegistry as P, resolveStateBucketWithDefaultAndSource as Pt, DagBuilder as Q, AssetError as Qt, disableInstanceApiTermination as R, CFN_TEMPLATE_BODY_LIMIT as Rt, isStatefulRecreateTargetSync as S, runDockerStreaming as St, bold as T, Synthesizer as Tt, WAFv2WebACLProvider as U, expectedOwnerParam as Ut, cfnRefValueFromPhysicalId as V, findLargeInlineResources as Vt, normalizeAwsTagsToCfn as W, AssemblyReader as Wt, withRetry as X, resetAwsClients as Xt, describeTypeWithThrottleRetry as Y, getAwsClients as Yt, isRetryableTransientError as Z, setAwsClients as Zt, createEbsFinalSnapshot as _, formatError as _n, validateContainerRepoName as _t, DeploymentEventsStore as a, LocalStartServiceError as an, stringifyValue as at, extractDeploymentEventError as b, withErrorHandling as bn, getDockerCmd as bt, replayFailedOperations as c, NestedStackChildDirectDestroyError as cn, createAssetRedirectResolver as ct, IMPLICIT_DELETE_DEPENDENCIES as d, ResourceTimeoutError as dn, AssetModeResolver as dt, ConfigError as en, LockManager as et, computeImplicitDeleteEdges as f, ResourceUpdateNotSupportedError as fn, BOOTSTRAP_MARKER_PREFIX as ft, ccRoutedFinalSnapshotError as g, SynthesisError as gn, validateAssetBucketName as gt, buildFinalSnapshotIdentifier as h, StateError as hn, parseBootstrapMarker as ht, DeploymentEventsReader as i, LocalMigrateError as in, AssetPublisher as it, IAMRoleProvider as j, resolveCaptureObservedState as jt, red as k, resolveApp as kt, replayRollback as l, PartialFailureError as ln, loadPublishableAssetManifest as lt, PRE_DELETE_SNAPSHOT_TYPES as m, StackTerminationProtectionError as mn, getBootstrapMarkerKey as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, DeployCancelledError as nn, rebuildClientForBucketRegion as nt, planFailedOps as o, LockError as on, WorkGraph as ot, ATOMIC_FINAL_SNAPSHOT_TYPES as p, StackHasActiveImportsError as pn, ensureAssetStorage as pt, applyRoleArnIfSet as q, resolveBucketRegion as qt, DeployEngine as r, LocalInvokeBuildError as rn, shouldRetainResource as rt, planRollback as s, MissingCdkCliError as sn, buildAssetRedirectMap as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, DependencyError as tn, S3StateBackend as tt, withResourceDeadline as u, ProvisioningError as un, rewriteTemplateAssetReferences as ut, isFinalSnapshotError as v, isCdkdError as vn, buildDockerImage as vt, formatResourceLine as w, getDockerImageBySourceHash as wt, MULTI_REGION_RECREATE_BLOCKED_TYPES as x, __exportAll as xn, runDockerForeground as xt, unsupportedFinalSnapshotError as y, normalizeAwsError as yn, formatDockerLoginError as yt, isTerminationProtectionPropagationError as z, CFN_TEMPLATE_URL_LIMIT as zt };
20068
- //# sourceMappingURL=deploy-engine-D5j6_ZH6.js.map
20466
+ export { TemplateParser as $, CdkdError as $t, yellow as A, resolveAutoAssetStorage as At, IntrinsicFunctionResolver as B, MIGRATE_TMP_PREFIX as Bt, renderStatefulReason as C, AssetManifestLoader as Ct, gray as D, getDefaultStateBucketName as Dt, cyan as E, synthesisStatusMessage as Et, findActionableSilentDrops as F, resolveUseCdkBootstrapAssets as Ft, resolveExplicitPhysicalId as G, processStackMessages as Gt, refStateLookupFromResource as H, uploadCfnTemplate as Ht, CloudControlProvider as I, stateBucketExistenceConfirmed as It, DiffCalculator as J, AwsClients as Jt, assertRegionMatch as K, clearBucketRegionCache as Kt, slowCcOperationTimeoutMs as L, warnDeprecatedNoPrefixCliFlag as Lt, collectInlinePolicyNamesManagedBySiblings as M, resolveSkipPrefix as Mt, clearOnUpdateRemoval as N, resolveStateBucketWithDefault as Nt, green as O, getLegacyStateBucketName as Ot, ProviderRegistry as P, resolveStateBucketWithDefaultAndSource as Pt, DagBuilder as Q, AssetError as Qt, disableInstanceApiTermination as R, CFN_TEMPLATE_BODY_LIMIT as Rt, isStatefulRecreateTargetSync as S, runDockerStreaming as St, bold as T, Synthesizer as Tt, WAFv2WebACLProvider as U, expectedOwnerParam as Ut, cfnRefValueFromPhysicalId as V, findLargeInlineResources as Vt, normalizeAwsTagsToCfn as W, AssemblyReader as Wt, withRetry as X, resetAwsClients as Xt, describeTypeWithThrottleRetry as Y, getAwsClients as Yt, isRetryableTransientError as Z, setAwsClients as Zt, createPreDeleteFinalSnapshot as _, formatError as _n, validateContainerRepoName as _t, DeploymentEventsStore as a, LocalStartServiceError as an, stringifyValue as at, extractDeploymentEventError as b, withErrorHandling as bn, getDockerCmd as bt, replayFailedOperations as c, NestedStackChildDirectDestroyError as cn, createAssetRedirectResolver as ct, IMPLICIT_DELETE_DEPENDENCIES as d, ResourceTimeoutError as dn, AssetModeResolver as dt, ConfigError as en, LockManager as et, computeImplicitDeleteEdges as f, ResourceUpdateNotSupportedError as fn, BOOTSTRAP_MARKER_PREFIX as ft, ccRoutedFinalSnapshotError as g, SynthesisError as gn, validateAssetBucketName as gt, buildFinalSnapshotIdentifier as h, StateError as hn, parseBootstrapMarker as ht, DeploymentEventsReader as i, LocalMigrateError as in, AssetPublisher as it, IAMRoleProvider as j, resolveCaptureObservedState as jt, red as k, resolveApp as kt, replayRollback as l, PartialFailureError as ln, loadPublishableAssetManifest as lt, PRE_DELETE_SNAPSHOT_TYPES as m, StackTerminationProtectionError as mn, getBootstrapMarkerKey as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, DeployCancelledError as nn, rebuildClientForBucketRegion as nt, planFailedOps as o, LockError as on, WorkGraph as ot, ATOMIC_FINAL_SNAPSHOT_TYPES as p, StackHasActiveImportsError as pn, ensureAssetStorage as pt, applyRoleArnIfSet as q, resolveBucketRegion as qt, DeployEngine as r, LocalInvokeBuildError as rn, shouldRetainResource as rt, planRollback as s, MissingCdkCliError as sn, buildAssetRedirectMap as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, DependencyError as tn, S3StateBackend as tt, withResourceDeadline as u, ProvisioningError as un, rewriteTemplateAssetReferences as ut, isFinalSnapshotError as v, isCdkdError as vn, buildDockerImage as vt, formatResourceLine as w, getDockerImageBySourceHash as wt, MULTI_REGION_RECREATE_BLOCKED_TYPES as x, __exportAll as xn, runDockerForeground as xt, unsupportedFinalSnapshotError as y, normalizeAwsError as yn, formatDockerLoginError as yt, isTerminationProtectionPropagationError as z, CFN_TEMPLATE_URL_LIMIT as zt };
20467
+ //# sourceMappingURL=deploy-engine-j0TmnJYJ.js.map