@go-to-k/cdkd 0.283.3 → 0.283.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { $ as configStringRefusal, $t as resolveStateBucketWithDefaultAndSource, A as green, An as NestedStackChildDirectDestroyError, At as BOOTSTRAP_MARKER_PREFIX, B as slowCcOperationTimeoutMs, Bt as runDockerForeground, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Ct as stringifyValue, D as bold, Dn as LocalStartServiceError, Dt as loadPublishableAssetManifest, E as formatResourceLine, En as LocalMigrateError, Et as createAssetRedirectResolver, F as clearOnUpdateRemoval, Fn as StackHasActiveImportsError, Ft as validateContainerRepoName, G as getAccountInfo, Gt as synthesisStatusMessage, H as isTerminationProtectionPropagationError, Hn as withErrorHandling, Ht as AssetManifestLoader, I as ProviderRegistry, In as StackTerminationProtectionError, It as buildDenyExternalAccessPolicy, J as normalizeAwsTagsToCfn, Jt as resolveApp, K as refStateLookupFromResource, Kt as getDefaultStateBucketName, L as findActionableSilentDrops, Lt as buildDockerImage, M as yellow, Mn as ProvisioningError, Mt as getBootstrapMarkerKey, N as IAMRoleProvider, Nn as ResourceTimeoutError, Nt as parseBootstrapMarker, O as cyan, Ot as rewriteTemplateAssetReferences, P as collectInlinePolicyNamesManagedBySiblings, Pn as ResourceUpdateNotSupportedError, Pt as validateAssetBucketName, Q as configBooleanRefusal, Qt as resolveStateBucketWithDefault, R as findSilentDropProperties, Rt as formatDockerLoginError, S as extractDeploymentEventError, St as AssetPublisher, T as renderStatefulReason, Tn as LocalInvokeBuildError$1, Tt as buildAssetRedirectMap, U as IntrinsicFunctionResolver, Ut as getDockerImageBySourceHash, V as disableInstanceApiTermination, Vn as normalizeAwsError, Vt as runDockerStreaming, W as cfnRefValueFromPhysicalId, Wt as Synthesizer, X as assertRegionMatch, Xt as resolveCaptureObservedState, Y as resolveExplicitPhysicalId, Yt as resolveAutoAssetStorage, Z as coerceCfnBoolean, Zt as resolveSkipPrefix, _ as createPreDeleteFinalSnapshot, _n as getAwsClients, _t as TemplateParser, a as DeploymentEventsStore, an as MIGRATE_TMP_PREFIX, at as s3BucketArn, b as unsupportedFinalSnapshotError, bt as rebuildClientForBucketRegion, c as replayFailedOperations, cn as expectedOwnerParam, ct as s3BucketRegionalDomainName, d as IMPLICIT_DELETE_DEPENDENCIES, dn as derivePartitionAndUrlSuffix, dt as DiffCalculator, en as resolveUseCdkBootstrapAssets, et as readConfigString, f as computeImplicitDeleteEdges, fn as AssemblyReader, ft as describeTypeWithThrottleRetry, g as ccRoutedFinalSnapshotError, gn as AwsClients, gt as DagBuilder, h as buildFinalSnapshotIdentifier, hn as resolveBucketRegion, ht as isThrottlingError, i as DeploymentEventsReader, in as CFN_TEMPLATE_URL_LIMIT, it as requireConfigString, j as red, jn as PartialFailureError, jt as ensureAssetStorage, k as gray, kn as MissingCdkCliError, kt as AssetModeResolver, l as replayRollback, ln as PARTITION_TABLE, lt as s3BucketWebsiteUrl, m as PRE_DELETE_SNAPSHOT_TYPES, mt as isRetryableTransientError, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as warnDeprecatedNoPrefixCliFlag, nt as requireConfigArray, o as planFailedOps, on as findLargeInlineResources, ot as s3BucketDomainName, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as processStackMessages, pt as withRetry, q as WAFv2WebACLProvider, qt as getLegacyStateBucketName, r as DeployEngine, rn as CFN_TEMPLATE_BODY_LIMIT, rt as requireConfigObject, s as planRollback, sn as uploadCfnTemplate, st as s3BucketDualStackDomainName, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as stateBucketExistenceConfirmed, tt as replayWarn, u as withResourceDeadline, un as canonicalizeRegion, ut as applyRoleArnIfSet, v as isFinalSnapshotError, vn as resetAwsClients, vt as LockManager, w as isStatefulRecreateTargetSync, wn as DeployCancelledError, wt as WorkGraph, x as makeCanonicalizePropertiesFn, xn as CdkdError, xt as shouldRetainResource, y as refusesFinalSnapshot, yn as setAwsClients, yt as S3StateBackend, z as CloudControlProvider, zt as getDockerCmd } from "./deploy-engine-DxpS3fJ9.js";
2
+ import { $ as configStringRefusal, $t as resolveSkipPrefix, A as green, At as rewriteTemplateAssetReferences, B as slowCcOperationTimeoutMs, Bt as formatDockerLoginError, C as MULTI_REGION_RECREATE_BLOCKED_TYPES, Cn as CdkdError, Ct as shouldRetainResource, D as bold, Dn as LocalInvokeBuildError$1, Dt as buildAssetRedirectMap, E as formatResourceLine, En as DeployCancelledError, Et as WorkGraph, F as clearOnUpdateRemoval, Fn as ResourceTimeoutError, Ft as parseBootstrapMarker, G as getAccountInfo, Gt as getDockerImageBySourceHash, H as isTerminationProtectionPropagationError, Ht as runDockerForeground, I as ProviderRegistry, In as ResourceUpdateNotSupportedError, It as validateAssetBucketName, J as normalizeAwsTagsToCfn, Jt as getDefaultStateBucketName, K as refStateLookupFromResource, Kt as Synthesizer, L as findActionableSilentDrops, Ln as StackHasActiveImportsError, Lt as validateContainerRepoName, M as yellow, Mn as NestedStackChildDirectDestroyError, Mt as BOOTSTRAP_MARKER_PREFIX, N as IAMRoleProvider, Nn as PartialFailureError, Nt as ensureAssetStorage, O as cyan, On as LocalMigrateError, Ot as createAssetRedirectResolver, P as collectInlinePolicyNamesManagedBySiblings, Pn as ProvisioningError, Pt as getBootstrapMarkerKey, Q as configBooleanRefusal, Qt as resolveCaptureObservedState, R as findSilentDropProperties, Rn as StackTerminationProtectionError, Rt as buildDenyExternalAccessPolicy, S as extractDeploymentEventError, St as rebuildClientForBucketRegion, T as renderStatefulReason, Tt as stringifyValue, U as IntrinsicFunctionResolver, Un as normalizeAwsError, Ut as runDockerStreaming, V as disableInstanceApiTermination, Vt as getDockerCmd, W as cfnRefValueFromPhysicalId, Wn as withErrorHandling, Wt as AssetManifestLoader, X as assertRegionMatch, Xt as resolveApp, Y as resolveExplicitPhysicalId, Yt as getLegacyStateBucketName, Z as coerceCfnBoolean, Zt as resolveAutoAssetStorage, _ as createPreDeleteFinalSnapshot, _n as resolveBucketRegion, _t as markNonRetryable, a as DeploymentEventsStore, an as CFN_TEMPLATE_BODY_LIMIT, at as s3BucketArn, b as unsupportedFinalSnapshotError, bn as resetAwsClients, bt as LockManager, c as replayFailedOperations, cn as findLargeInlineResources, ct as s3BucketRegionalDomainName, d as IMPLICIT_DELETE_DEPENDENCIES, dn as PARTITION_TABLE, dt as DiffCalculator, en as resolveStateBucketWithDefault, et as readConfigString, f as computeImplicitDeleteEdges, fn as canonicalizeRegion, ft as describeTypeWithThrottleRetry, g as ccRoutedFinalSnapshotError, gt as isThrottlingError, h as buildFinalSnapshotIdentifier, hn as processStackMessages, ht as isRetryableTransientError, i as DeploymentEventsReader, in as warnDeprecatedNoPrefixCliFlag, it as requireConfigString, j as red, jn as MissingCdkCliError, jt as AssetModeResolver, k as gray, kn as LocalStartServiceError, kt as loadPublishableAssetManifest, l as replayRollback, ln as uploadCfnTemplate, lt as s3BucketWebsiteUrl, m as PRE_DELETE_SNAPSHOT_TYPES, mn as AssemblyReader, mt as isMarkedNonRetryable, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveUseCdkBootstrapAssets, nt as requireConfigArray, o as planFailedOps, on as CFN_TEMPLATE_URL_LIMIT, ot as s3BucketDomainName, p as ATOMIC_FINAL_SNAPSHOT_TYPES, pn as derivePartitionAndUrlSuffix, pt as withRetry, q as WAFv2WebACLProvider, qt as synthesisStatusMessage, r as DeployEngine, rn as stateBucketExistenceConfirmed, rt as requireConfigObject, s as planRollback, sn as MIGRATE_TMP_PREFIX, st as s3BucketDualStackDomainName, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveStateBucketWithDefaultAndSource, tt as replayWarn, u as withResourceDeadline, un as expectedOwnerParam, ut as applyRoleArnIfSet, v as isFinalSnapshotError, vn as AwsClients, vt as DagBuilder, w as isStatefulRecreateTargetSync, wt as AssetPublisher, x as makeCanonicalizePropertiesFn, xn as setAwsClients, xt as S3StateBackend, y as refusesFinalSnapshot, yn as getAwsClients, yt as TemplateParser, z as CloudControlProvider, zt as buildDockerImage } from "./deploy-engine-BcwNPlSk.js";
3
3
  import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
4
- import { t as ASGProvider } from "./asg-provider-CtmogXk9.js";
4
+ import { t as ASGProvider } from "./asg-provider-B9eSuRPc.js";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, 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";
@@ -2619,6 +2619,38 @@ function collectCrossStackRefs(node, onRef) {
2619
2619
  //#endregion
2620
2620
  //#region src/provisioning/providers/iam-policy-provider.ts
2621
2621
  /**
2622
+ * The short `ResourceDeleteResult.reason` the no-policy-name DELETE arm
2623
+ * reports (issue [#1770](https://github.com/go-to-k/cdkd/issues/1770)).
2624
+ *
2625
+ * Rendered inline on the destroy status line, so it is the SHORT form; the full
2626
+ * remediation sentence goes out as the `logger.warn` beside it. It says "in
2627
+ * state" rather than "in physicalId" because the arm is only reached once BOTH
2628
+ * sources have been exhausted — the physicalId and `properties['PolicyName']`.
2629
+ */
2630
+ const POLICY_NAME_SKIP_REASON = "no policy name in state — no delete issued";
2631
+ /**
2632
+ * The short `ResourceDeleteResult.reason` for a record that names no principal
2633
+ * to detach the inline policy from (issue
2634
+ * [#1770](https://github.com/go-to-k/cdkd/issues/1770) review).
2635
+ *
2636
+ * Distinct from {@link POLICY_NAME_SKIP_REASON}: the policy may be perfectly
2637
+ * well named and it is the ATTACHMENT that is unknown, so pointing the user at
2638
+ * the physicalId would send them to the wrong half of the record.
2639
+ */
2640
+ const POLICY_NO_TARGET_SKIP_REASON = "no Roles/Groups/Users in state — no delete issued";
2641
+ /**
2642
+ * The deploy-side caveat the skip warning in this file carries (issue
2643
+ * [#1762](https://github.com/go-to-k/cdkd/issues/1762)).
2644
+ *
2645
+ * "Repair state.json and re-run" is only true on DESTROY, where the skip KEEPS
2646
+ * the record. The same arm is ALSO reached from `deploy-engine.ts` and
2647
+ * `rollback-executor.ts`, which discard the delete result and DROP the record —
2648
+ * there the id is gone, so re-running cannot help and the resource has to be
2649
+ * removed by hand. Mirrors the caveat `compositeIdFormatMessage` already
2650
+ * carries for the composite-id family.
2651
+ */
2652
+ const DEPLOY_SKIP_CAVEAT$3 = "NOTE this arm is ALSO reached from cdkd deploy (the DELETE of a resource removed from the template, plus the replacement / rollback deletes), which DROP the state record and report success (https://github.com/go-to-k/cdkd/issues/1762) — there, remove the resource by hand.";
2653
+ /**
2622
2654
  * AWS IAM Policy Provider
2623
2655
  *
2624
2656
  * Implements resource provisioning for AWS::IAM::Policy using the IAM SDK.
@@ -2780,21 +2812,34 @@ var IAMPolicyProvider = class {
2780
2812
  */
2781
2813
  async delete(logicalId, physicalId, resourceType, properties, context) {
2782
2814
  this.logger.debug(`Deleting IAM policy ${logicalId}: ${physicalId}`);
2783
- const policyName = physicalId.includes(":") ? physicalId.split(":")[0] : physicalId;
2815
+ const policyNameFromPhysicalId = physicalId.includes(":") ? physicalId.split(":")[0] : physicalId;
2816
+ const policyNameFromProperties = typeof properties?.["PolicyName"] === "string" ? properties["PolicyName"] : void 0;
2817
+ const policyName = policyNameFromPhysicalId || policyNameFromProperties;
2818
+ const roles = properties?.["Roles"];
2819
+ const groups = properties?.["Groups"];
2820
+ const users = properties?.["Users"];
2821
+ const legacyRoleFromPhysicalId = !roles && !groups && !users && physicalId.includes(":") ? physicalId.split(":")[1] : void 0;
2784
2822
  if (!policyName) {
2785
- this.logger.warn(`Invalid physical ID format: ${physicalId}, skipping deletion`);
2786
- return;
2823
+ this.logger.warn(`Invalid physical ID format: ${physicalId}, and no PolicyName in the state record's properties — skipping deletion. No AWS call is issued, so the inline policy is LEFT ATTACHED to its roles / groups / users, UNLESS the role / group / user it is attached to is itself part of this stack (deleting that principal removes its inline policies, and then only the cdkd record is stale — clear it with 'cdkd state orphan <stack>'). Otherwise repair the physicalId in state.json and re-run, or delete the inline policy by hand. ${DEPLOY_SKIP_CAVEAT$3}`);
2824
+ return {
2825
+ outcome: "skipped",
2826
+ reason: POLICY_NAME_SKIP_REASON
2827
+ };
2828
+ }
2829
+ if (!roles && !groups && !users && !legacyRoleFromPhysicalId) {
2830
+ this.logger.warn(`No Roles, Groups or Users in the state record for IAM policy ${logicalId} (physicalId "${physicalId}"), skipping deletion — an inline policy exists only as an attachment, so with no principal named there is no delete to issue and the policy is LEFT ATTACHED wherever it is, UNLESS the role / group / user it is attached to is itself part of this stack (deleting that principal removes its inline policies, and then only the cdkd record is stale — clear it with 'cdkd state orphan <stack>'). Otherwise restore Roles / Groups / Users in state.json and re-run, or delete the inline policy by hand. ${DEPLOY_SKIP_CAVEAT$3}`);
2831
+ return {
2832
+ outcome: "skipped",
2833
+ reason: POLICY_NO_TARGET_SKIP_REASON
2834
+ };
2787
2835
  }
2788
2836
  const onNotFound = async (target) => {
2789
2837
  assertRegionMatch(await this.iamClient.config.region(), context?.expectedRegion, resourceType, logicalId, `${physicalId} (${target})`);
2790
2838
  };
2791
2839
  try {
2792
- const roles = properties?.["Roles"];
2793
- const groups = properties?.["Groups"];
2794
- const users = properties?.["Users"];
2795
- if (!roles && !groups && !users && physicalId.includes(":")) {
2796
- const firstRole = physicalId.split(":")[1];
2797
- if (firstRole) try {
2840
+ if (legacyRoleFromPhysicalId) {
2841
+ const firstRole = legacyRoleFromPhysicalId;
2842
+ try {
2798
2843
  await this.iamClient.send(new DeleteRolePolicyCommand({
2799
2844
  RoleName: firstRole,
2800
2845
  PolicyName: policyName
@@ -3032,7 +3077,8 @@ var IAMManagedPolicyProvider = class {
3032
3077
  this.logger.debug(`${reason} changed, replacing managed policy: ${physicalId} (${reason} mutation)`);
3033
3078
  const createResult = await this.create(logicalId, resourceType, properties);
3034
3079
  try {
3035
- await this.delete(logicalId, physicalId, resourceType, previousProperties);
3080
+ const deleteResult = await this.delete(logicalId, physicalId, resourceType, previousProperties);
3081
+ if (deleteResult?.outcome === "skipped") this.logger.warn(`Skipped deleting old managed policy ${physicalId} during replacement: ${deleteResult.reason}. The old policy may be orphaned and require manual cleanup.`);
3036
3082
  } catch (error) {
3037
3083
  this.logger.warn(`Failed to delete old managed policy ${physicalId} during replacement: ${String(error)}. The old policy may be orphaned and require manual cleanup.`);
3038
3084
  }
@@ -3892,6 +3938,36 @@ var IAMAccessKeyProvider = class {
3892
3938
  //#endregion
3893
3939
  //#region src/provisioning/providers/iam-user-group-provider.ts
3894
3940
  /**
3941
+ * The short `ResourceDeleteResult.reason` the no-properties
3942
+ * `AWS::IAM::UserToGroupAddition` DELETE arm reports (issue
3943
+ * [#1770](https://github.com/go-to-k/cdkd/issues/1770)).
3944
+ *
3945
+ * Rendered inline on the destroy status line, so it is the SHORT form; the full
3946
+ * remediation sentence goes out as the `logger.warn` beside it. It names the
3947
+ * MEMBERSHIP rather than the resource because that is what survives — the
3948
+ * resource itself is metadata, the users staying in the group is the residue.
3949
+ */
3950
+ const MEMBERSHIP_NO_PROPERTIES_SKIP_REASON = "no properties in state — group membership not removed";
3951
+ /**
3952
+ * Sibling of {@link MEMBERSHIP_NO_PROPERTIES_SKIP_REASON} for the arm where the
3953
+ * record has properties but is missing `GroupName` or `Users` — both REQUIRED
3954
+ * by the CloudFormation schema, so their absence is corruption, not an
3955
+ * empty-but-valid membership list.
3956
+ */
3957
+ const MEMBERSHIP_MISSING_FIELDS_SKIP_REASON = "GroupName/Users missing from state — membership not removed";
3958
+ /**
3959
+ * The deploy-side caveat both `UserToGroupAddition` skip warnings carry (issue
3960
+ * [#1762](https://github.com/go-to-k/cdkd/issues/1762)).
3961
+ *
3962
+ * "Repair state.json and re-run" is only true on DESTROY, where the skip KEEPS
3963
+ * the record. The same arms are ALSO reached from `deploy-engine.ts` and
3964
+ * `rollback-executor.ts`, which discard the delete result and DROP the record —
3965
+ * there the id is gone, so re-running cannot help and the memberships have to
3966
+ * be removed by hand. Mirrors the caveat `compositeIdFormatMessage` already
3967
+ * carries for the composite-id family.
3968
+ */
3969
+ const DEPLOY_SKIP_CAVEAT$2 = "NOTE this arm is ALSO reached from cdkd deploy (the DELETE of a resource removed from the template, plus the replacement / rollback deletes), which DROP the state record and report success (https://github.com/go-to-k/cdkd/issues/1762) — there, remove the memberships by hand.";
3970
+ /**
3895
3971
  * AWS IAM User / Group / UserToGroupAddition Provider
3896
3972
  *
3897
3973
  * Implements resource provisioning for:
@@ -4530,14 +4606,20 @@ var IAMUserGroupProvider = class {
4530
4606
  async deleteUserToGroupAddition(logicalId, physicalId, resourceType, properties, _context) {
4531
4607
  this.logger.debug(`Deleting IAM UserToGroupAddition ${logicalId}`);
4532
4608
  if (!properties) {
4533
- this.logger.debug(`No properties for UserToGroupAddition ${logicalId}, skipping deletion`);
4534
- return;
4609
+ this.logger.warn(`No properties for UserToGroupAddition ${logicalId}, skipping deletion — GroupName and Users are both required to call RemoveUserFromGroup, so no AWS call is issued and the group memberships are LEFT IN PLACE, UNLESS the group or the users are themselves part of this stack (their own deletes remove exactly these memberships, and then only the cdkd record is stale — clear it with 'cdkd state orphan <stack>'). Otherwise restore the record's properties in state.json and re-run, or remove the users from the group by hand. ${DEPLOY_SKIP_CAVEAT$2}`);
4610
+ return {
4611
+ outcome: "skipped",
4612
+ reason: MEMBERSHIP_NO_PROPERTIES_SKIP_REASON
4613
+ };
4535
4614
  }
4536
4615
  const groupName = properties["GroupName"];
4537
4616
  const users = properties["Users"];
4538
4617
  if (!groupName || !users) {
4539
- this.logger.debug(`Missing GroupName or Users for ${logicalId}, skipping deletion`);
4540
- return;
4618
+ this.logger.warn(`Missing GroupName or Users for ${logicalId}, skipping deletion — both are required to call RemoveUserFromGroup, so no AWS call is issued and the group memberships are LEFT IN PLACE, UNLESS the group or the users are themselves part of this stack (their own deletes remove exactly these memberships, and then only the cdkd record is stale — clear it with 'cdkd state orphan <stack>'). Otherwise restore them in state.json and re-run, or remove the users from the group by hand. ${DEPLOY_SKIP_CAVEAT$2}`);
4619
+ return {
4620
+ outcome: "skipped",
4621
+ reason: MEMBERSHIP_MISSING_FIELDS_SKIP_REASON
4622
+ };
4541
4623
  }
4542
4624
  try {
4543
4625
  for (const userName of users) try {
@@ -10399,11 +10481,16 @@ var SNSSubscriptionProvider = class {
10399
10481
  */
10400
10482
  async update(logicalId, physicalId, resourceType, properties, _previousProperties) {
10401
10483
  this.logger.debug(`Updating SNS subscription ${logicalId}: ${physicalId}`);
10484
+ let deleteResult = void 0;
10402
10485
  try {
10403
- await this.delete(logicalId, physicalId, resourceType);
10486
+ deleteResult = await this.delete(logicalId, physicalId, resourceType);
10404
10487
  } catch (error) {
10405
10488
  this.logger.warn(`Failed to delete old subscription ${physicalId} during update: ${String(error)}`);
10406
10489
  }
10490
+ if (deleteResult?.outcome === "skipped") {
10491
+ this.logger.warn(`Cannot replace SNS subscription ${logicalId}: the old subscription ${physicalId} was not deleted — ${deleteResult.reason}`);
10492
+ throw markNonRetryable(new ProvisioningError(`Cannot replace SNS subscription ${logicalId}: cdkd could not delete the old subscription, and creating the replacement would leave both subscribed to the topic and deliver every message twice. See the warning naming ${logicalId} for the recorded physical id and the cause. Repair the state record for ${logicalId}, or remove the old subscription in AWS if it is still live, then re-run.`, resourceType, logicalId, physicalId));
10493
+ }
10407
10494
  const createResult = await this.create(logicalId, resourceType, properties);
10408
10495
  return {
10409
10496
  physicalId: createResult.physicalId,
@@ -10439,6 +10526,27 @@ var SNSSubscriptionProvider = class {
10439
10526
  }
10440
10527
  /**
10441
10528
  * Log the CFn-parity skip for a pending-confirmation subscription delete.
10529
+ *
10530
+ * **Do NOT convert either caller into a `ResourceDeleteResult` skip** (the
10531
+ * issue [#1770](https://github.com/go-to-k/cdkd/issues/1770) sweep) — the
10532
+ * note lives here rather than only at `update()` because this is where a
10533
+ * future converter looks. Both arms mean the resource is GONE as far as the
10534
+ * stack is concerned: CloudFormation removes a pending-confirmation
10535
+ * subscription from the stack without unsubscribing, and the pending record
10536
+ * expires on its own, so `deleted` is the honest outcome and a skip would
10537
+ * preserve state and fail an otherwise-clean destroy for no reason.
10538
+ *
10539
+ * There is a second consequence, and it is why the warning is this loud:
10540
+ * `update()` ABORTS the DELETE-first replacement on a skip (issue
10541
+ * [#1778](https://github.com/go-to-k/cdkd/issues/1778)). Reporting a skip
10542
+ * here would therefore make every deploy of a subscription whose state
10543
+ * physicalId is the `PendingConfirmation` placeholder — reachable via
10544
+ * `cdkd import --resource <id>=PendingConfirmation` — throw permanently,
10545
+ * with no flag to force it, and `cdkd drift --revert` would fail the same
10546
+ * way. Exempting that placeholder from the abort was the alternative and
10547
+ * was rejected: it would duplicate this method's knowledge of the
10548
+ * placeholder inside `update()`, where a later change to the accepted set
10549
+ * would silently un-fence the abort.
10442
10550
  */
10443
10551
  logPendingConfirmationSkip(logicalId, physicalId) {
10444
10552
  this.logger.warn(`SNS subscription ${logicalId} (${physicalId}) is pending confirmation and cannot be unsubscribed; skipping deletion to match CloudFormation behavior (the pending record expires automatically within 3 days)`);
@@ -11721,6 +11829,36 @@ var LambdaFunctionProvider = class {
11721
11829
  //#endregion
11722
11830
  //#region src/provisioning/providers/lambda-permission-provider.ts
11723
11831
  /**
11832
+ * The short `ResourceDeleteResult.reason` the missing-`FunctionName` DELETE arm
11833
+ * reports (issue [#1770](https://github.com/go-to-k/cdkd/issues/1770)).
11834
+ *
11835
+ * Rendered inline on the destroy status line, so it is the SHORT form; the full
11836
+ * remediation sentence goes out as the `logger.warn` beside it.
11837
+ */
11838
+ const PERMISSION_FUNCTION_NAME_SKIP_REASON = "FunctionName missing from state — no delete issued";
11839
+ /**
11840
+ * The short `ResourceDeleteResult.reason` for a composite physicalId whose
11841
+ * trailing StatementId segment is empty (`"<functionArn>|"`).
11842
+ *
11843
+ * Its own reason rather than {@link PERMISSION_FUNCTION_NAME_SKIP_REASON}: the
11844
+ * function may be perfectly well named, and pointing the user at FunctionName
11845
+ * would send them to the wrong half of the record.
11846
+ */
11847
+ const PERMISSION_STATEMENT_ID_SKIP_REASON = "empty StatementId in state physicalId — no delete issued";
11848
+ /**
11849
+ * The deploy-side caveat the skip warning in this file carries (issue
11850
+ * [#1762](https://github.com/go-to-k/cdkd/issues/1762)).
11851
+ *
11852
+ * "Repair state.json and re-run" is only true on DESTROY, where the skip KEEPS
11853
+ * the record. The same arm is ALSO reached from `deploy-engine.ts` and
11854
+ * `rollback-executor.ts`, which discard the delete result and DROP the record —
11855
+ * there the id is gone, so re-running cannot help and the resource has to be
11856
+ * removed by hand. Mirrors the caveat `compositeIdFormatMessage` already
11857
+ * carries for the composite-id family; a remedy that is impossible on the path
11858
+ * the user is actually on is worse than no remedy.
11859
+ */
11860
+ const DEPLOY_SKIP_CAVEAT$1 = "NOTE this arm is ALSO reached from cdkd deploy (the DELETE of a resource removed from the template, plus the replacement / rollback deletes), which DROP the state record and report success (https://github.com/go-to-k/cdkd/issues/1762) — there, remove the resource by hand.";
11861
+ /**
11724
11862
  * AWS Lambda Permission Provider
11725
11863
  *
11726
11864
  * Implements resource provisioning for AWS::Lambda::Permission using the Lambda SDK.
@@ -11823,13 +11961,36 @@ var LambdaPermissionProvider = class {
11823
11961
  */
11824
11962
  async delete(logicalId, physicalId, resourceType, properties, context) {
11825
11963
  this.logger.debug(`Deleting Lambda permission ${logicalId}: ${physicalId}`);
11826
- const functionName = properties?.["FunctionName"];
11964
+ const physicalIdSegments = physicalId.split("|");
11965
+ const isComposite = physicalIdSegments.length > 1;
11966
+ const statementId = isComposite ? physicalIdSegments[physicalIdSegments.length - 1] : physicalId;
11967
+ const functionNameFromProperties = typeof properties?.["FunctionName"] === "string" ? properties["FunctionName"] : void 0;
11968
+ let functionNameFromPhysicalId;
11969
+ const leadingSegment = isComposite ? physicalIdSegments[0] : "";
11970
+ if (leadingSegment !== "") {
11971
+ if (!leadingSegment.startsWith("arn:")) functionNameFromPhysicalId = leadingSegment;
11972
+ else if (leadingSegment.includes(":function:")) {
11973
+ const arnRegion = leadingSegment.split(":")[3];
11974
+ const clientRegion = await this.lambdaClient.config.region();
11975
+ if (arnRegion === clientRegion) functionNameFromPhysicalId = leadingSegment;
11976
+ else this.logger.debug(`Lambda permission ${logicalId}: physicalId ARN region ${arnRegion} does not match the client region ${clientRegion}, so it cannot be used as a FunctionName`);
11977
+ }
11978
+ }
11979
+ if (statementId === "") {
11980
+ this.logger.warn(`Lambda permission ${logicalId} has no StatementId in its physicalId ("${physicalId}"), skipping deletion — no AWS call is issued, so the permission statement is LEFT IN PLACE on the function's resource policy, UNLESS the function itself is part of this stack (deleting it removes its whole resource policy, and then only the cdkd record is stale — clear it with 'cdkd state orphan <stack>'). Otherwise repair the physicalId in state.json and re-run, or remove the statement by hand ('aws lambda remove-permission'). ${DEPLOY_SKIP_CAVEAT$1}`);
11981
+ return {
11982
+ outcome: "skipped",
11983
+ reason: PERMISSION_STATEMENT_ID_SKIP_REASON
11984
+ };
11985
+ }
11986
+ const functionName = functionNameFromProperties || functionNameFromPhysicalId;
11827
11987
  if (!functionName) {
11828
- this.logger.warn(`FunctionName not available for Lambda permission ${logicalId}, skipping deletion`);
11829
- return;
11988
+ this.logger.warn(`FunctionName not available for Lambda permission ${logicalId} (neither the state record's FunctionName nor a function ARN in the physicalId), skipping deletion — no AWS call is issued, so the permission statement is LEFT IN PLACE on the function's resource policy, UNLESS the function itself is part of this stack (deleting it removes its whole resource policy, and then only the cdkd record is stale — clear it with 'cdkd state orphan <stack>'). Otherwise repair the record's FunctionName in state.json and re-run, or remove the statement by hand ('aws lambda remove-permission'). ${DEPLOY_SKIP_CAVEAT$1}`);
11989
+ return {
11990
+ outcome: "skipped",
11991
+ reason: PERMISSION_FUNCTION_NAME_SKIP_REASON
11992
+ };
11830
11993
  }
11831
- let statementId = physicalId;
11832
- if (physicalId.includes("|")) statementId = physicalId.split("|").pop();
11833
11994
  try {
11834
11995
  await this.lambdaClient.send(new RemovePermissionCommand({
11835
11996
  FunctionName: functionName,
@@ -12652,6 +12813,36 @@ var LambdaEventSourceMappingProvider = class {
12652
12813
  //#endregion
12653
12814
  //#region src/provisioning/providers/lambda-layer-provider.ts
12654
12815
  /**
12816
+ * The short `ResourceDeleteResult.reason` the two malformed-`LayerVersionArn`
12817
+ * DELETE arms report (issue
12818
+ * [#1770](https://github.com/go-to-k/cdkd/issues/1770)).
12819
+ *
12820
+ * Rendered inline on the destroy status line
12821
+ * (`⚠ MyLayer (AWS::Lambda::LayerVersion) skipped (<reason>)`), so it is the
12822
+ * SHORT form — the full remediation sentence goes out as the `logger.warn`
12823
+ * beside it. Exported so the wording is pinned by a test rather than retyped.
12824
+ */
12825
+ const LAYER_ARN_SKIP_REASON = "malformed LayerVersionArn in state — no delete issued";
12826
+ /**
12827
+ * Sibling of {@link LAYER_ARN_SKIP_REASON} for the arm where the ARN has the
12828
+ * right shape but its trailing version segment is not a number. Kept distinct
12829
+ * because the two point at different halves of the id, and the destroy line is
12830
+ * all the user sees.
12831
+ */
12832
+ const LAYER_VERSION_SKIP_REASON = "unparsable version in state LayerVersionArn — no delete issued";
12833
+ /**
12834
+ * The deploy-side caveat both skip warnings in this file carry (issue
12835
+ * [#1762](https://github.com/go-to-k/cdkd/issues/1762)).
12836
+ *
12837
+ * "Repair state.json and re-run" is only true on DESTROY, where the skip KEEPS
12838
+ * the record. The same arms are ALSO reached from `deploy-engine.ts` and
12839
+ * `rollback-executor.ts`, which discard the delete result and DROP the record —
12840
+ * there the id is gone, so re-running cannot help and the resource has to be
12841
+ * removed by hand. Mirrors the caveat `compositeIdFormatMessage` already
12842
+ * carries for the composite-id family.
12843
+ */
12844
+ const DEPLOY_SKIP_CAVEAT = "NOTE this arm is ALSO reached from cdkd deploy (the DELETE of a resource removed from the template, plus the replacement / rollback deletes), which DROP the state record and report success (https://github.com/go-to-k/cdkd/issues/1762) — there, remove the resource by hand.";
12845
+ /**
12655
12846
  * AWS Lambda LayerVersion Provider
12656
12847
  *
12657
12848
  * Implements resource provisioning for AWS::Lambda::LayerVersion using the Lambda SDK.
@@ -12741,14 +12932,20 @@ var LambdaLayerVersionProvider = class {
12741
12932
  this.logger.debug(`Deleting Lambda layer version ${logicalId}: ${physicalId}`);
12742
12933
  const arnParts = physicalId.split(":");
12743
12934
  if (arnParts.length < 8) {
12744
- this.logger.warn(`Invalid LayerVersionArn format: ${physicalId}, skipping deletion`);
12745
- return;
12935
+ this.logger.warn(`Invalid LayerVersionArn format: ${physicalId}, skipping deletion — no AWS call is issued, so the layer version is LEFT IN PLACE and still counts against the account's storage quota. Repair the physicalId in state.json and re-run, or delete the layer version by hand. ${DEPLOY_SKIP_CAVEAT}`);
12936
+ return {
12937
+ outcome: "skipped",
12938
+ reason: LAYER_ARN_SKIP_REASON
12939
+ };
12746
12940
  }
12747
12941
  const layerName = arnParts[6];
12748
12942
  const versionNumber = parseInt(arnParts[7], 10);
12749
12943
  if (isNaN(versionNumber)) {
12750
- this.logger.warn(`Could not parse version number from ARN: ${physicalId}, skipping deletion`);
12751
- return;
12944
+ this.logger.warn(`Could not parse version number from ARN: ${physicalId}, skipping deletion — no AWS call is issued, so the layer version is LEFT IN PLACE and still counts against the account's storage quota. Repair the physicalId in state.json and re-run, or delete the layer version by hand. ${DEPLOY_SKIP_CAVEAT}`);
12945
+ return {
12946
+ outcome: "skipped",
12947
+ reason: LAYER_VERSION_SKIP_REASON
12948
+ };
12752
12949
  }
12753
12950
  try {
12754
12951
  await this.lambdaClient.send(new DeleteLayerVersionCommand({
@@ -53511,7 +53708,7 @@ async function runDestroyForStack(stackName, state, ctx) {
53511
53708
  } catch (retryError) {
53512
53709
  lastDeleteError = retryError;
53513
53710
  const msg = retryError instanceof Error ? retryError.message : String(retryError);
53514
- if (!(isRetryableTransientError(retryError, msg) || msg.includes("Too Many Requests")) || attempt >= maxAttempts) break;
53711
+ if (!(!isMarkedNonRetryable(retryError) && (isRetryableTransientError(retryError, msg) || msg.includes("Too Many Requests"))) || attempt >= maxAttempts) break;
53515
53712
  const delay = 5e3 * Math.pow(2, attempt);
53516
53713
  logger.debug(` ⏳ Retrying delete ${logicalId} in ${delay / 1e3}s (attempt ${attempt + 1}/${maxAttempts})`);
53517
53714
  await new Promise((resolve) => setTimeout(resolve, delay));
@@ -54209,7 +54406,8 @@ var ACMCertificateProvider = class {
54209
54406
  this.logger.debug(`${changedImmutable} changed, replacing ACM certificate: ${physicalId}`);
54210
54407
  const createResult = await this.create(logicalId, resourceType, properties);
54211
54408
  try {
54212
- await this.delete(logicalId, physicalId, resourceType, previousProperties);
54409
+ const deleteResult = await this.delete(logicalId, physicalId, resourceType, previousProperties);
54410
+ if (deleteResult?.outcome === "skipped") this.logger.warn(`Skipped deleting old ACM certificate ${physicalId} during replacement: ${deleteResult.reason}. The old certificate may be orphaned and require manual cleanup.`);
54213
54411
  } catch (error) {
54214
54412
  this.logger.warn(`Failed to delete old ACM certificate ${physicalId} during replacement: ${String(error)}. The old certificate may be orphaned and require manual cleanup.`);
54215
54413
  }
@@ -59595,6 +59793,27 @@ const COMPOSITE_ID_SPLITTERS = {
59595
59793
  propertiesOverlay: { VpcId: vpcId }
59596
59794
  };
59597
59795
  },
59796
+ "AWS::EC2::VPCCidrBlock": (physicalId, properties) => {
59797
+ if (!physicalId.trim()) throw new Error("empty physical id for AWS::EC2::VPCCidrBlock (expected an association id)");
59798
+ const parts = physicalId.split("|").map((p) => p.trim());
59799
+ if (parts.length > 2) throw new Error(`expected a bare '<Id>' or '<Id>|<VpcId>', got ${parts.length} parts: '${physicalId}'`);
59800
+ const id = parts[0];
59801
+ if (!id) throw new Error(`empty part in '<Id>|<VpcId>': '${physicalId}'`);
59802
+ if (!id.startsWith(VPC_CIDR_ASSOCIATION_ID_PREFIX)) throw new Error(`'${physicalId}' does not start with an association id: CloudFormation identifies an AWS::EC2::VPCCidrBlock by [Id, VpcId], so the first segment must be a '${VPC_CIDR_ASSOCIATION_ID_PREFIX}…' id. State entry may be corrupt, or the segments may be reversed; re-deploy the resource to refresh state`);
59803
+ let vpcId;
59804
+ if (parts.length === 2) {
59805
+ vpcId = parts[1];
59806
+ if (!vpcId) throw new Error(`empty part in '<Id>|<VpcId>': '${physicalId}'`);
59807
+ } else vpcId = readStringProperty(properties, "VpcId", "AWS::EC2::VPCCidrBlock");
59808
+ if (!VPC_ID_PATTERN.test(vpcId)) throw new Error(`'${vpcId}' is not a VPC id (expected 'vpc-…', not an association id): '${physicalId}' may have its segments reversed`);
59809
+ return {
59810
+ resourceIdentifier: {
59811
+ Id: id,
59812
+ VpcId: vpcId
59813
+ },
59814
+ propertiesOverlay: { VpcId: vpcId }
59815
+ };
59816
+ },
59598
59817
  "AWS::EC2::Route": (physicalId, properties) => {
59599
59818
  const parts = physicalId.split("|");
59600
59819
  if (parts.length !== 2) throw new Error(`expected 2 parts ('<routeTableId>|<destination>'), got ${parts.length}: '${physicalId}'`);
@@ -59724,6 +59943,26 @@ const EIP_ALLOCATION_ID_PREFIX = "eipalloc-";
59724
59943
  * validate an address AWS itself minted.
59725
59944
  */
59726
59945
  const EIP_PUBLIC_IP_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
59946
+ /**
59947
+ * The prefix every `AWS::EC2::VPCCidrBlock` association id carries. The same
59948
+ * kind of shape discriminator as {@link EIP_ALLOCATION_ID_PREFIX}: a VPC id and
59949
+ * an association id BOTH start with `vpc-`, so the longer prefix is what tells
59950
+ * the two segments apart.
59951
+ */
59952
+ const VPC_CIDR_ASSOCIATION_ID_PREFIX = "vpc-cidr-assoc-";
59953
+ /**
59954
+ * The other half of that discriminator. ANCHORED, so an association id
59955
+ * (`vpc-cidr-assoc-…`, which also begins `vpc-`) does NOT match — a reversed
59956
+ * pair is exactly what this exists to catch.
59957
+ *
59958
+ * The character class is `[0-9a-z]` rather than hex, and the LENGTH is not
59959
+ * pinned. EC2 ids are hex today, but `cdkd export` is all-or-nothing, so a
59960
+ * false REFUSAL here blocks an entire export — and the discrimination does not
59961
+ * depend on the alphabet at all: it is the `-` in `cidr-assoc`, which no
59962
+ * character class here admits. Widening the class therefore costs nothing and
59963
+ * removes a false-refusal vector.
59964
+ */
59965
+ const VPC_ID_PATTERN = /^vpc-[0-9a-z]+$/;
59727
59966
  const GATEWAY_ATTACHMENT_TYPE_IGW = "IGW";
59728
59967
  const GATEWAY_ATTACHMENT_TYPE_VGW = "VGW";
59729
59968
  /**
@@ -60319,8 +60558,8 @@ async function exportCommand(stackArg, options) {
60319
60558
  });
60320
60559
  if (blocked.length > 0) {
60321
60560
  logger.error("The following resources block migration:");
60322
- for (const b of blocked) logger.error(` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
60323
- throw new Error(`${blocked.length} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`);
60561
+ for (const line of groupBlockedReasons(blocked)) logger.error(line);
60562
+ throw new Error(`${new Set(blocked.map((b) => b.logicalId)).size} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`);
60324
60563
  }
60325
60564
  const owner = `${process.env["USER"] || "unknown"}@${process.env["HOSTNAME"] || "host"}:${process.pid}`;
60326
60565
  if (!options.dryRun) {
@@ -60927,12 +61166,25 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
60927
61166
  });
60928
61167
  continue;
60929
61168
  }
61169
+ const propertiesOverlay = resolved.propertiesOverlay ?? resolved.resourceIdentifier;
61170
+ const unrepresentable = unrepresentableOverlayFields(raw, propertiesOverlay);
61171
+ if (unrepresentable.length > 0) {
61172
+ for (const u of unrepresentable) blocked.push({
61173
+ logicalId,
61174
+ resourceType,
61175
+ reason: unrepresentableOverlayMessage({
61176
+ logicalId,
61177
+ resourceType
61178
+ }, u.field, u.current, u.value)
61179
+ });
61180
+ continue;
61181
+ }
60930
61182
  phase1Imports.push({
60931
61183
  logicalId,
60932
61184
  resourceType,
60933
61185
  physicalId: stateEntry.physicalId,
60934
61186
  resourceIdentifier: resolved.resourceIdentifier,
60935
- propertiesOverlay: resolved.propertiesOverlay ?? resolved.resourceIdentifier
61187
+ propertiesOverlay
60936
61188
  });
60937
61189
  }
60938
61190
  return {
@@ -60944,6 +61196,28 @@ async function buildImportPlan(state, template, cfnClient, parentStackName, opti
60944
61196
  };
60945
61197
  }
60946
61198
  /**
61199
+ * Render the `blocked` list one bullet per RESOURCE, folding a resource's
61200
+ * several reasons into it.
61201
+ *
61202
+ * The pre-flight pushes one entry per offending FIELD (issue #1787), so a
61203
+ * multi-field overlay such as `AWS::S3Tables::Namespace` contributes two rows
61204
+ * for one resource — which made the count ("1 resource(s)") disagree with the
61205
+ * bullets printed under it. Grouping keeps the two consistent without losing a
61206
+ * reason.
61207
+ */
61208
+ function groupBlockedReasons(blocked) {
61209
+ const byResource = /* @__PURE__ */ new Map();
61210
+ for (const b of blocked) {
61211
+ const entry = byResource.get(b.logicalId) ?? {
61212
+ resourceType: b.resourceType,
61213
+ reasons: []
61214
+ };
61215
+ entry.reasons.push(b.reason);
61216
+ byResource.set(b.logicalId, entry);
61217
+ }
61218
+ return [...byResource].map(([logicalId, { resourceType, reasons }]) => reasons.length === 1 ? ` - ${logicalId} (${resourceType}): ${reasons[0]}` : ` - ${logicalId} (${resourceType}):\n${reasons.map((r) => ` - ${r}`).join("\n")}`);
61219
+ }
61220
+ /**
60947
61221
  * Build the `ResourceIdentifier` map CloudFormation IMPORT expects in
60948
61222
  * `ResourcesToImport[].ResourceIdentifier` for the given resource type
60949
61223
  * and cdkd state's physical ID.
@@ -61202,14 +61476,14 @@ function injectDeletionPolicyForImport(template) {
61202
61476
  }
61203
61477
  return injected;
61204
61478
  }
61205
- function filterTemplateForImport(template, plan) {
61479
+ function filterTemplateForImport(template, plan, warnOnRewrite = true) {
61206
61480
  const allow = new Map(plan.map((p) => [p.logicalId, p]));
61207
61481
  const original = template["Resources"];
61208
61482
  const filteredResources = {};
61209
61483
  for (const [logicalId, resource] of Object.entries(original)) {
61210
61484
  const entry = allow.get(logicalId);
61211
61485
  if (!entry) continue;
61212
- filteredResources[logicalId] = overlayResourceIdentifierOnProperties(resource, entry);
61486
+ filteredResources[logicalId] = overlayResourceIdentifierOnProperties(resource, entry, warnOnRewrite);
61213
61487
  }
61214
61488
  const result = {
61215
61489
  ...template,
@@ -61236,8 +61510,10 @@ function filterTemplateForImport(template, plan) {
61236
61510
  * migration" value proposition. Closes [#319].
61237
61511
  *
61238
61512
  * **Post-v0.95 behavior** (this function): override only when synth
61239
- * already carries a *literal-string* value for the field AND it differs
61240
- * from `ResourceIdentifier`. Three cases:
61513
+ * already carries a *literal* value for the field AND it differs
61514
+ * from `ResourceIdentifier`. Four cases (the fourth added by issue #1787,
61515
+ * which also widened "literal" past `typeof === 'string'` — see
61516
+ * {@link classifyOverlayCurrentValue} for the full classification):
61241
61517
  *
61242
61518
  * - **Absent** (auto-generated names — user did NOT declare a physical
61243
61519
  * name in CDK code): `Properties[field]` stays absent. Matches
@@ -61274,12 +61550,19 @@ function filterTemplateForImport(template, plan) {
61274
61550
  * names` flip are no longer in this case — Properties.RoleName matches
61275
61551
  * the AWS name without override.
61276
61552
  *
61553
+ * - **Unrepresentable** (a list carrying an intrinsic / a nested list / an
61554
+ * empty list): REFUSED with a message naming the resource, the property and
61555
+ * the scalar to declare instead. Preserving ANY of the three reproduces the
61556
+ * opaque `CreateChangeSet` rejection; what makes OVERRIDING wrong differs per
61557
+ * shape, so {@link refusalReason} says which rather than asserting one cause
61558
+ * for all — only a list actually carrying an object element has an intrinsic
61559
+ * to discard. Either way the command stops before submitting anything
61560
+ * (issue #1787).
61561
+ *
61277
61562
  * For literal-match (Properties already has the right value), the
61278
- * override is a no-op, so the check is effectively
61279
- * `Properties[field] !== overlayValue && typeof Properties[field] ===
61280
- * 'string'`.
61563
+ * override is a no-op.
61281
61564
  */
61282
- function overlayResourceIdentifierOnProperties(resource, entry) {
61565
+ function overlayResourceIdentifierOnProperties(resource, entry, warnOnRewrite = true) {
61283
61566
  if (!resource || typeof resource !== "object" || Array.isArray(resource)) return resource;
61284
61567
  const r = resource;
61285
61568
  const existingProperties = r["Properties"];
@@ -61287,7 +61570,13 @@ function overlayResourceIdentifierOnProperties(resource, entry) {
61287
61570
  const overlay = entry.propertiesOverlay ?? entry.resourceIdentifier;
61288
61571
  for (const [field, value] of Object.entries(overlay)) {
61289
61572
  const current = properties[field];
61290
- if (typeof current === "string" && current !== value) properties[field] = value;
61573
+ const kind = classifyOverlayCurrentValue(current);
61574
+ if (kind === "intrinsic" || kind === "absent") continue;
61575
+ if (kind === "unrepresentable") throw new Error(unrepresentableOverlayMessage(entry, field, current, value));
61576
+ if (current !== value) {
61577
+ if (warnOnRewrite && typeof current !== "string") getLogger().warn(`${entry.logicalId} (${entry.resourceType}): rewriting the identifier property '${field}' from ${describeOverlayValueShape(current)} to the scalar '${value}' recorded in cdkd state. CloudFormation types this property as a string; the exported template will carry the scalar.`);
61578
+ properties[field] = value;
61579
+ }
61291
61580
  }
61292
61581
  return {
61293
61582
  ...r,
@@ -61295,6 +61584,133 @@ function overlayResourceIdentifierOnProperties(resource, entry) {
61295
61584
  };
61296
61585
  }
61297
61586
  /**
61587
+ * Why a given `unrepresentable` value is refused rather than rewritten.
61588
+ *
61589
+ * THREE shapes reach `unrepresentable`, and they are refused for two different
61590
+ * reasons — an earlier revision of this message asserted the intrinsic reason
61591
+ * for all of them, which is false for two of the three. The distinction is not
61592
+ * cosmetic: a message that names a cause the value does not have sends the user
61593
+ * looking for an intrinsic that is not there.
61594
+ */
61595
+ function refusalReason(current) {
61596
+ if (Array.isArray(current) && current.length === 0) return "cdkd will not invent a scalar where the template declared no value";
61597
+ return Array.isArray(current) && current.some((e) => e !== null && typeof e === "object" && !Array.isArray(e)) ? "cdkd will not rewrite it, because doing so would discard the intrinsic it carries" : "cdkd will not replace a value it cannot represent as the scalar the schema declares";
61598
+ }
61599
+ /**
61600
+ * The shared message for a refused overlay value — used by the pre-flight in
61601
+ * {@link buildImportPlan} (which BLOCKS the resource) and by
61602
+ * {@link overlayResourceIdentifierOnProperties}'s defense-in-depth throw, so
61603
+ * the two cannot drift into describing the same defect differently.
61604
+ */
61605
+ function unrepresentableOverlayMessage(entry, field, current, value) {
61606
+ return `${entry.logicalId} (${entry.resourceType}): the identifier property '${field}' is ${describeOverlayValueShape(current)}, but CloudFormation types it as a string. ${refusalReason(current)}. Declare '${field}' as the scalar '${value}' (or drop the addPropertyOverride that made it a list) and re-run 'cdkd export'.`;
61607
+ }
61608
+ /**
61609
+ * Every overlay field of `entry` whose value in `resource`'s `Properties` is
61610
+ * {@link classifyOverlayCurrentValue}-`unrepresentable`.
61611
+ *
61612
+ * Exists so the refusal happens during PLANNING rather than during template
61613
+ * preprocessing. `overlayResourceIdentifierOnProperties` is called from three
61614
+ * places, and two of them are past the point of no return: `--dry-run` returns
61615
+ * BEFORE any overlay call (so a dry run would report a clean plan for a
61616
+ * template the real run aborts on), and the nested-stack path calls it inside
61617
+ * the per-stack IMPORT loop — where a throw on stack N leaves stacks 1..N-1
61618
+ * already imported, outside the loop's own "stacks imported so far" recovery
61619
+ * handler. Running the same classification in `buildImportPlan` puts the
61620
+ * verdict where every other unfixable-resource verdict already lives: reported
61621
+ * for EVERY offender at once, before the lock, before dry-run's return, and
61622
+ * before any AWS write.
61623
+ */
61624
+ function unrepresentableOverlayFields(resource, overlay) {
61625
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) return [];
61626
+ const props = resource["Properties"];
61627
+ if (!props || typeof props !== "object" || Array.isArray(props)) return [];
61628
+ const bag = props;
61629
+ const out = [];
61630
+ for (const [field, value] of Object.entries(overlay)) if (classifyOverlayCurrentValue(bag[field]) === "unrepresentable") out.push({
61631
+ field,
61632
+ current: bag[field],
61633
+ value
61634
+ });
61635
+ return out;
61636
+ }
61637
+ /**
61638
+ * Classify what the synth template currently carries at an identifier field,
61639
+ * for {@link overlayResourceIdentifierOnProperties} (issue #1787).
61640
+ *
61641
+ * The pre-#1787 rule was `typeof current === 'string'`, which is correct for
61642
+ * the two cases it was designed for (an absent key, and a `Ref` / `Fn::GetAtt`
61643
+ * intrinsic that must be preserved — issue #319) and WRONG for a third,
61644
+ * reachable case: a field the template carries as a non-string LITERAL. The
61645
+ * measured instance is `AWS::S3Tables::Namespace`, whose CFn registry type is
61646
+ * `{"type": "string"}` and whose CDK L1 emits a string, but
61647
+ * `addPropertyOverride('Namespace', ['analytics'])` synthesizes
61648
+ * `"Namespace": ["analytics"]` — which cdkd deploys, because
61649
+ * `s3-tables-provider.ts` accepts both wire shapes. The array is not a literal
61650
+ * string, so the overlay left it alone and it reached `CreateChangeSet`, where
61651
+ * CFn answered with an opaque rejection.
61652
+ *
61653
+ * The classification, and why each arm is what it is:
61654
+ *
61655
+ * - `absent` (`undefined` only) — unchanged. An auto-generated name the user
61656
+ * never declared; CFn accepts the IMPORT on `ResourceIdentifier` alone. An
61657
+ * explicit `null` is deliberately NOT here: the key IS present, the shallow
61658
+ * Properties copy keeps it, and `"Namespace": null` then reaches
61659
+ * `CreateChangeSet` — the same opaque-rejection class as the array, through
61660
+ * the same `addPropertyOverride` hatch. It is a literal, and overwritten.
61661
+ * - `intrinsic` (a non-array object) — unchanged. `{Ref: Parent}` /
61662
+ * `{Fn::GetAtt: [...]}` resolve during changeset processing to exactly this
61663
+ * resource's `ResourceIdentifier` value, so clobbering them would only make
61664
+ * the post-export `cdk diff` dirty (issue #319).
61665
+ * - `literal` (string / number / boolean, or an array of those) — OVERRIDDEN
61666
+ * with the overlay value. This is not a guess: the overlay value comes from
61667
+ * the cdkd-recorded physicalId, which is the authority on what the resource
61668
+ * IS, while the template side is only what CDK happened to synthesize. So a
61669
+ * single-element `['analytics']` and a bare `analytics` both become the
61670
+ * scalar CFn's schema declares, exactly as the pre-existing prefixed-name
61671
+ * literal-mismatch case already does.
61672
+ * - `unrepresentable` (an array containing an object / array / null, or an
61673
+ * EMPTY array) — REFUSED. Preserving any of them ships the same opaque CFn
61674
+ * rejection this change exists to remove; the reason OVERRIDING is wrong
61675
+ * differs by shape, and {@link refusalReason} splits it three ways rather
61676
+ * than asserting one cause:
61677
+ * - a list carrying an OBJECT element — overriding discards an intrinsic
61678
+ * the user wrote;
61679
+ * - a NESTED list, or one holding `null` — no intrinsic to protect, it is
61680
+ * simply not representable as the declared scalar;
61681
+ * - an EMPTY list — nothing to reconcile against the identifier, and
61682
+ * materializing a scalar where the template declared "no value" is the
61683
+ * invention this classification exists to avoid.
61684
+ * Saying "would discard an intrinsic" for all three was the defect the
61685
+ * #1787 review round removed; do not re-collapse it.
61686
+ */
61687
+ function classifyOverlayCurrentValue(current) {
61688
+ if (current === void 0) return "absent";
61689
+ if (current === null) return "literal";
61690
+ if (Array.isArray(current)) {
61691
+ if (current.length === 0) return "unrepresentable";
61692
+ return current.every((e) => typeof e === "string" || typeof e === "number" || typeof e === "boolean") ? "literal" : "unrepresentable";
61693
+ }
61694
+ if (typeof current === "object") return "intrinsic";
61695
+ return "literal";
61696
+ }
61697
+ /**
61698
+ * Human-readable shape of an overlay value, for the refusal error and the
61699
+ * rewrite warning. Bounded: the value is user-supplied and lands in an error
61700
+ * message that a failed export persists, so a large list is summarized rather
61701
+ * than serialized whole.
61702
+ */
61703
+ function describeOverlayValueShape(current) {
61704
+ if (Array.isArray(current)) {
61705
+ if (current.length === 0) return "an empty list";
61706
+ const preview = JSON.stringify(current);
61707
+ const shown = preview.length > 120 ? `${preview.slice(0, 120)}…` : preview;
61708
+ return `a ${current.length}-element list (${shown})`;
61709
+ }
61710
+ if (current === null) return "an explicit null";
61711
+ return `a ${typeof current} (${JSON.stringify(current)})`;
61712
+ }
61713
+ /**
61298
61714
  * Build the phase-2 UPDATE template: full synth template with the same
61299
61715
  * conditional overlay applied to every phase-1 import. Phase-1 and
61300
61716
  * phase-2 must stay symmetric — when `overlayResourceIdentifierOnProperties`
@@ -61329,7 +61745,7 @@ function applyImportOverlayForPhase2(template, phase1Imports) {
61329
61745
  const resourcesMap = resources;
61330
61746
  for (const entry of phase1Imports) {
61331
61747
  const r = resourcesMap[entry.logicalId];
61332
- if (r !== void 0) resourcesMap[entry.logicalId] = overlayResourceIdentifierOnProperties(r, entry);
61748
+ if (r !== void 0) resourcesMap[entry.logicalId] = overlayResourceIdentifierOnProperties(r, entry, false);
61333
61749
  }
61334
61750
  return result;
61335
61751
  }
@@ -62001,8 +62417,8 @@ async function runPerStackImportLoop(args) {
62001
62417
  skipImportSupportPreflight: options.skipImportSupportPreflight === true
62002
62418
  });
62003
62419
  if (plan.blocked.length > 0) {
62004
- const lines = plan.blocked.map((b) => ` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
62005
- throw new Error(`Stack '${meta.cdkdStackName}' has ${plan.blocked.length} resource(s) that block migration:\n${lines.join("\n")}`);
62420
+ const lines = groupBlockedReasons(plan.blocked);
62421
+ throw new Error(`Stack '${meta.cdkdStackName}' has ${new Set(plan.blocked.map((b) => b.logicalId)).size} resource(s) that block migration:\n${lines.join("\n")}`);
62006
62422
  }
62007
62423
  perStackPlans.push({
62008
62424
  cdkdName: meta.cdkdStackName,
@@ -62119,7 +62535,7 @@ async function runPerStackImportLoop(args) {
62119
62535
  }
62120
62536
  const rewrittenNestedRows = /* @__PURE__ */ new Map();
62121
62537
  if (plan.nestedStackRows.length > 0) {
62122
- const phase1BTemplate = filterTemplateForImport(plan.template, plan.phase1Imports);
62538
+ const phase1BTemplate = filterTemplateForImport(plan.template, plan.phase1Imports, false);
62123
62539
  injectDeletionPolicyForImport(phase1BTemplate);
62124
62540
  const phase1BResources = [];
62125
62541
  for (const row of plan.nestedStackRows) {
@@ -76590,7 +77006,7 @@ function createMigrateCommand() {
76590
77006
  */
76591
77007
  function buildProgram() {
76592
77008
  const program = new Command();
76593
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.3");
77009
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.5");
76594
77010
  program.hook("preAction", (_thisCommand, actionCommand) => {
76595
77011
  const { profile } = actionCommand.optsWithGlobals();
76596
77012
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;