@go-to-k/cdkd 0.283.33 → 0.283.35

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 getAccountInfo, $t as formatDockerLoginError, A as cyan, An as resolveBucketRegion, At as TemplateParser, B as stateKeySecretExposure, Bn as LocalInvokeBuildError$1, Bt as loadPublishableAssetManifest, C as makeCanonicalizePropertiesFn, Cn as expectedOwnerParam, Ct as s3BucketWebsiteUrl, D as renderStatefulReason, Dn as AssemblyReader, Dt as describeTypeWithThrottleRetry, E as isStatefulRecreateTargetSync, En as derivePartitionAndUrlSuffix, Et as INTRINSIC_KEYS, F as collectDeclaredOutputNames, Ft as AssetPublisher, G as findActionableSilentDrops, Gn as NestedStackChildDirectDestroyError, Gt as BOOTSTRAP_MARKER_PREFIX, H as collectInlinePolicyNamesManagedBySiblings, Hn as LocalStartServiceError, Ht as escapeRegExp, I as collectPublishedOutputNames, In as CdkdError, It as stringifyValue, J as slowCcOperationTimeoutMs, Jn as ResourceTimeoutError, Jt as parseBootstrapMarker, K as findSilentDropProperties, Kn as PartialFailureError, Kt as ensureAssetStorage, L as exportAliasCollisionScrubWarning, Lt as WorkGraph, M as green, Mn as getAwsClients, Mt as S3StateBackend, N as red, Nn as resetAwsClients, Nt as rebuildClientForBucketRegion, O as formatResourceLine, On as processStackMessages, Ot as withRetry, P as yellow, Pn as setAwsClients, Pt as shouldRetainResource, Q as cfnRefValueFromPhysicalId, Qt as buildDockerImage, R as isExportAliasCollision, Rt as buildAssetRedirectMap, S as unsupportedFinalSnapshotError, Sn as uploadCfnTemplate, St as s3BucketRegionalDomainName, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as canonicalizeRegion, Tt as DiffCalculator, U as clearOnUpdateRemoval, Ut as stripControlChars, V as IAMRoleProvider, Vn as LocalMigrateError, Vt as rewriteTemplateAssetReferences, W as ProviderRegistry, Wn as MissingCdkCliError, Wt as AssetModeResolver, X as isTerminationProtectionPropagationError, Xn as StackHasActiveImportsError, Xt as validateContainerRepoName, Y as disableInstanceApiTermination, Yn as ResourceUpdateNotSupportedError, Yt as validateAssetBucketName, Z as IntrinsicFunctionResolver, Zn as StackTerminationProtectionError, Zt as buildDenyExternalAccessPolicy, _ as buildFinalSnapshotIdentifier, _n as warnDeprecatedNoPrefixCliFlag, _t as redactSecretsForState, a as DeploymentEventsStore, an as Synthesizer, ar as isRetryableTransientError, at as coerceCfnBoolean, b as isFinalSnapshotError, bn as MIGRATE_TMP_PREFIX, bt as s3BucketDomainName, c as replayFailedOperations, cn as getLegacyStateBucketName, ct as readConfigString, d as deleteSkipReason, dn as resolveCaptureObservedState, dt as requireConfigObject, en as getDockerCmd, et as refStateLookupFromResource, f as withResourceDeadline, fn as resolveSkipPrefix, ft as requireConfigString, g as PRE_DELETE_SNAPSHOT_TYPES, gn as stateBucketExistenceConfirmed, gt as maskSecretsInText, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as resolveUseCdkBootstrapAssets, ht as TEMPLATE_SOURCED_RULES, i as DeploymentEventsReader, in as getDockerImageBySourceHash, ir as isMarkedNonRetryable, it as assertRegionMatch, j as gray, jn as AwsClients, jt as LockManager, k as bold, kt as DagBuilder, l as replayRollback, ln as resolveApp, lt as replayWarn, m as computeImplicitDeleteEdges, mn as resolveStateBucketWithDefaultAndSource, mt as STATE_SOURCED_READBACK_RULES, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as runDockerStreaming, nr as normalizeAwsError, nt as normalizeAwsTagsToCfn, o as planFailedOps, on as synthesisStatusMessage, or as isThrottlingError, ot as configBooleanRefusal, p as IMPLICIT_DELETE_DEPENDENCIES, pn as resolveStateBucketWithDefault, pt as STATE_SOURCED_CROSS_GENERATION_RULES, q as CloudControlProvider, qn as ProvisioningError, qt as getBootstrapMarkerKey, r as DeployEngine, rn as AssetManifestLoader, rr as withErrorHandling, rt as resolveExplicitPhysicalId, s as planRollback, sn as getDefaultStateBucketName, sr as markNonRetryable, st as configStringRefusal, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as runDockerForeground, tt as WAFv2WebACLProvider, u as UNSPECIFIED_SKIP_REASON, un as resolveAutoAssetStorage, ut as requireConfigArray, v as ccRoutedFinalSnapshotError, vn as CFN_TEMPLATE_BODY_LIMIT, vt as scrubResourceRecord, w as extractDeploymentEventError, wn as PARTITION_TABLE, wt as applyRoleArnIfSet, x as refusesFinalSnapshot, xn as findLargeInlineResources, xt as s3BucketDualStackDomainName, y as createPreDeleteFinalSnapshot, yn as CFN_TEMPLATE_URL_LIMIT, yt as s3BucketArn, z as secretBearingStateKeyWarning, zn as DeployCancelledError, zt as createAssetRedirectResolver } from "./deploy-engine-Cg54H4pJ.js";
2
+ import { $ as getAccountInfo, $t as formatDockerLoginError, A as cyan, An as resolveBucketRegion, At as TemplateParser, B as stateKeySecretExposure, Bn as LocalInvokeBuildError$1, Bt as loadPublishableAssetManifest, C as makeCanonicalizePropertiesFn, Cn as expectedOwnerParam, Ct as s3BucketWebsiteUrl, D as renderStatefulReason, Dn as AssemblyReader, Dt as describeTypeWithThrottleRetry, E as isStatefulRecreateTargetSync, En as derivePartitionAndUrlSuffix, Et as INTRINSIC_KEYS, F as collectDeclaredOutputNames, Ft as AssetPublisher, G as findActionableSilentDrops, Gn as NestedStackChildDirectDestroyError, Gt as BOOTSTRAP_MARKER_PREFIX, H as collectInlinePolicyNamesManagedBySiblings, Hn as LocalStartServiceError, Ht as escapeRegExp, I as collectPublishedOutputNames, In as CdkdError, It as stringifyValue, J as slowCcOperationTimeoutMs, Jn as ResourceTimeoutError, Jt as parseBootstrapMarker, K as findSilentDropProperties, Kn as PartialFailureError, Kt as ensureAssetStorage, L as exportAliasCollisionScrubWarning, Lt as WorkGraph, M as green, Mn as getAwsClients, Mt as S3StateBackend, N as red, Nn as resetAwsClients, Nt as rebuildClientForBucketRegion, O as formatResourceLine, On as processStackMessages, Ot as withRetry, P as yellow, Pn as setAwsClients, Pt as shouldRetainResource, Q as cfnRefValueFromPhysicalId, Qt as buildDockerImage, R as isExportAliasCollision, Rt as buildAssetRedirectMap, S as unsupportedFinalSnapshotError, Sn as uploadCfnTemplate, St as s3BucketRegionalDomainName, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as canonicalizeRegion, Tt as DiffCalculator, U as clearOnUpdateRemoval, Ut as stripControlChars, V as IAMRoleProvider, Vn as LocalMigrateError, Vt as rewriteTemplateAssetReferences, W as ProviderRegistry, Wn as MissingCdkCliError, Wt as AssetModeResolver, X as isTerminationProtectionPropagationError, Xn as StackHasActiveImportsError, Xt as validateContainerRepoName, Y as disableInstanceApiTermination, Yn as ResourceUpdateNotSupportedError, Yt as validateAssetBucketName, Z as IntrinsicFunctionResolver, Zn as StackTerminationProtectionError, Zt as buildDenyExternalAccessPolicy, _ as buildFinalSnapshotIdentifier, _n as warnDeprecatedNoPrefixCliFlag, _t as redactSecretsForState, a as DeploymentEventsStore, an as Synthesizer, ar as isRetryableTransientError, at as coerceCfnBoolean, b as isFinalSnapshotError, bn as MIGRATE_TMP_PREFIX, bt as s3BucketDomainName, c as replayFailedOperations, cn as getLegacyStateBucketName, ct as readConfigString, d as deleteSkipReason, dn as resolveCaptureObservedState, dt as requireConfigObject, en as getDockerCmd, et as refStateLookupFromResource, f as withResourceDeadline, fn as resolveSkipPrefix, ft as requireConfigString, g as PRE_DELETE_SNAPSHOT_TYPES, gn as stateBucketExistenceConfirmed, gt as maskSecretsInText, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as resolveUseCdkBootstrapAssets, ht as TEMPLATE_SOURCED_RULES, i as DeploymentEventsReader, in as getDockerImageBySourceHash, ir as isMarkedNonRetryable, it as assertRegionMatch, j as gray, jn as AwsClients, jt as LockManager, k as bold, kt as DagBuilder, l as replayRollback, ln as resolveApp, lt as replayWarn, m as computeImplicitDeleteEdges, mn as resolveStateBucketWithDefaultAndSource, mt as STATE_SOURCED_READBACK_RULES, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as runDockerStreaming, nr as normalizeAwsError, nt as normalizeAwsTagsToCfn, o as planFailedOps, on as synthesisStatusMessage, or as isThrottlingError, ot as configBooleanRefusal, p as IMPLICIT_DELETE_DEPENDENCIES, pn as resolveStateBucketWithDefault, pt as STATE_SOURCED_CROSS_GENERATION_RULES, q as CloudControlProvider, qn as ProvisioningError, qt as getBootstrapMarkerKey, r as DeployEngine, rn as AssetManifestLoader, rr as withErrorHandling, rt as resolveExplicitPhysicalId, s as planRollback, sn as getDefaultStateBucketName, sr as markNonRetryable, st as configStringRefusal, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as runDockerForeground, tt as WAFv2WebACLProvider, u as UNSPECIFIED_SKIP_REASON, un as resolveAutoAssetStorage, ut as requireConfigArray, v as ccRoutedFinalSnapshotError, vn as CFN_TEMPLATE_BODY_LIMIT, vt as scrubResourceRecord, w as extractDeploymentEventError, wn as PARTITION_TABLE, wt as applyRoleArnIfSet, x as refusesFinalSnapshot, xn as findLargeInlineResources, xt as s3BucketDualStackDomainName, y as createPreDeleteFinalSnapshot, yn as CFN_TEMPLATE_URL_LIMIT, yt as s3BucketArn, z as secretBearingStateKeyWarning, zn as DeployCancelledError, zt as createAssetRedirectResolver } from "./deploy-engine-fBYrdrmB.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-ID5-zAwf.js";
4
+ import { t as ASGProvider } from "./asg-provider-xC0UiCjt.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";
@@ -14282,27 +14282,15 @@ function hasTransitionalIndex(indexes) {
14282
14282
  * Deliberately NOT keyed on the exception NAME: AWS reports this as a plain
14283
14283
  * `ResourceInUseException`, which is the same name it uses for genuinely
14284
14284
  * terminal conflicts (deleting a table that is `CREATING`, creating one that
14285
- * already exists). Retrying on the name would turn those into a 47s stall
14286
- * before the same failure.
14285
+ * already exists). Retrying on the name would spend the WHOLE budget on them —
14286
+ * a full settle poll plus a backoff step per attempt, i.e. minutes — before
14287
+ * failing identically.
14287
14288
  */
14288
14289
  const INDEX_BUSY_DELETE_MESSAGE = /cannot delete table while indexes are being/i;
14289
14290
  function isIndexBusyDeleteError(message) {
14290
14291
  return INDEX_BUSY_DELETE_MESSAGE.test(message);
14291
14292
  }
14292
14293
  /**
14293
- * Retry budget for the index-busy `DeleteTable` refusal (issue #1830).
14294
- *
14295
- * Left at `withRetry`'s own default of 8 rather than raised. Named rather than
14296
- * inlined because passing it at all is what opts this call out of
14297
- * `withRetry`'s dense IAM-propagation schedule, which would be wrong for a
14298
- * condition measured in seconds-to-minutes rather than sub-second.
14299
- *
14300
- * The count MULTIPLIES the per-attempt re-arm poll below, so the two have to
14301
- * be read together — see {@link DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS} for the
14302
- * arithmetic and for why the poll is not a 15-minute one.
14303
- */
14304
- const DELETE_INDEX_BUSY_MAX_RETRIES = 8;
14305
- /**
14306
14294
  * `DescribeTable` polls (~1s apart) the index-busy `DeleteTable` retry spends
14307
14295
  * re-arming BEFORE each retry (issue #1830) — it returns on the first one that
14308
14296
  * reports every index settled.
@@ -14310,30 +14298,57 @@ const DELETE_INDEX_BUSY_MAX_RETRIES = 8;
14310
14298
  * Bounded well under the 15-minute default `GlobalTable`'s pre-delete #1521
14311
14299
  * gate uses, because THAT default is sized for a wait that runs once while
14312
14300
  * this one runs per retry: the caller's wall clock is
14313
- * `DELETE_INDEX_BUSY_MAX_RETRIES x this + withRetry's ~47s of backoff`, and
14314
- * `destroy-runner.ts` runs the delete under a per-resource deadline (30 min by
14315
- * default; neither DynamoDB provider declares a `getMinResourceTimeoutMs` to
14316
- * lift it). At 900 polls the product was ~2h, so a genuinely stuck index
14317
- * produced a 30-minute wait ending in a generic `ResourceTimeoutError` that
14318
- * never mentions indexes. At 60 the LOOP's worst case is ~8.8 min and the user
14319
- * gets AWS's own actionable sentence instead.
14301
+ * `its retry budget x this x ~1.2s per poll + withRetry's backoff`, and `destroy-runner.ts` runs the delete under a per-resource
14302
+ * deadline (30 min by default; neither DynamoDB provider declares a
14303
+ * `getMinResourceTimeoutMs` to lift it). At 900 polls the product would be
14304
+ * ~4.2h, so a genuinely stuck index would produce a 30-minute wait ending in a
14305
+ * generic `ResourceTimeoutError` that never mentions indexes. At 60 the LOOP's
14306
+ * worst case is ~18.4 min on `AWS::DynamoDB::Table` and ~10.4 min on
14307
+ * `AWS::DynamoDB::GlobalTable` — see {@link TABLE_DELETE_INDEX_BUSY_MAX_RETRIES}
14308
+ * for every term of that product and for why the two budgets differ — and the
14309
+ * user gets AWS's own actionable sentence instead.
14320
14310
  *
14321
14311
  * **What that deadline actually wraps**, since the arithmetic depends on it:
14322
14312
  * NOT one `delete()` but `destroy-runner.ts`'s whole outer retry loop, which
14323
14313
  * calls `delete()` up to 4 times (`maxAttempts = 3`) inside the one deadline.
14324
- * Four times ~8.8 min would blow straight through 30 min. It does not, and the
14314
+ * Four times ~18.4 min would blow straight through 30 min. It does not, and the
14325
14315
  * reason is load-bearing rather than incidental: that outer loop only retries
14326
14316
  * when `isRetryableTransientError` says so, and this refusal matches no entry
14327
14317
  * in `RETRYABLE_ERROR_MESSAGE_PATTERNS` (verified against the real classifier —
14328
14318
  * both AWS's raw sentence and the `Failed to delete DynamoDB table ...` wrap
14329
14319
  * return false), so the outer loop runs `delete()` exactly ONCE for it and the
14330
14320
  * budget below is not re-multiplied. Adding this message to those patterns
14331
- * would silently make the worst case ~35 min — over the deadline.
14321
+ * would silently make the worst case ~74 min on `AWS::DynamoDB::Table` — well
14322
+ * over the deadline.
14332
14323
  *
14333
14324
  * The FIRST attempt is unaffected — it does not re-arm at all.
14334
14325
  */
14335
14326
  const DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS = 60;
14336
14327
  /**
14328
+ * Sleep between two `DescribeTable` polls of {@link waitForIndexesSettled}.
14329
+ *
14330
+ * Named rather than inlined because it is the unit every poll budget on the
14331
+ * DynamoDB DELETE path is denominated in — a poll count only becomes a wall
14332
+ * clock through this, and the deadline arguments at
14333
+ * {@link TABLE_DELETE_INDEX_BUSY_MAX_RETRIES} multiply it. Each of those counts
14334
+ * READS it, which is what makes the claim checkable rather than a convention:
14335
+ * this module's 60-poll re-arm; `GlobalTable`'s 900-poll #1521 pre-delete gate
14336
+ * (and the same 900-poll default its auto-scaling caller takes, since both go
14337
+ * through `waitForIndexesActive` -> {@link waitForIndexesSettled});
14338
+ * `GlobalTable`'s `waitForReplicaGone` and `waitForTableGone` at 600 polls
14339
+ * each; and the `Table` provider's 60-poll `waitForTableActiveAfterUpdate`.
14340
+ *
14341
+ * Deliberately NOT claimed for the whole file: `waitForReplicaActive` keeps its
14342
+ * own `1000`, and is left alone because it runs on the CREATE / UPDATE path,
14343
+ * which no arithmetic here prices.
14344
+ *
14345
+ * It is a FLOOR on the per-poll cost, never the whole of it — each poll also
14346
+ * pays a `DescribeTable` round trip, which is why the live run's 60-poll
14347
+ * re-arm took ~72s and not 60s, and why the arithmetic above prices a poll at
14348
+ * ~1.2s.
14349
+ */
14350
+ const INDEX_SETTLE_POLL_INTERVAL_MS = 1e3;
14351
+ /**
14337
14352
  * What proceeding costs when a DELETE-path index-settle wait ends WITHOUT
14338
14353
  * confirming — passed as {@link waitForIndexesSettled}'s `proceedNote` by every
14339
14354
  * delete-path caller (`GlobalTable`'s #1521 pre-delete gate, and the #1830 /
@@ -14371,7 +14386,7 @@ async function waitForIndexesSettled(opts) {
14371
14386
  const message = err instanceof Error ? err.message : String(err);
14372
14387
  if (isThrottlingError(err)) {
14373
14388
  logger.debug(`DescribeTable throttled while waiting for indexes on ${tableName} (attempt ${attempt}/${maxAttempts}); still waiting: ${message}`);
14374
- await new Promise((resolve) => setTimeout(resolve, 1e3));
14389
+ await new Promise((resolve) => setTimeout(resolve, INDEX_SETTLE_POLL_INTERVAL_MS));
14375
14390
  continue;
14376
14391
  }
14377
14392
  if (err instanceof ResourceNotFoundException$1) {
@@ -14383,9 +14398,10 @@ async function waitForIndexesSettled(opts) {
14383
14398
  logger.warn(`DescribeTable failed (${errorName}) while waiting for indexes on ${tableName} (${logicalId}), so cdkd stopped waiting; ${proceedNote} Re-run with --verbose for AWS's own message.`);
14384
14399
  return;
14385
14400
  }
14386
- await new Promise((resolve) => setTimeout(resolve, 1e3));
14401
+ await new Promise((resolve) => setTimeout(resolve, INDEX_SETTLE_POLL_INTERVAL_MS));
14387
14402
  }
14388
- logger.warn(`Indexes on ${tableName} (${logicalId}) did not all reach ACTIVE within ${maxAttempts} DescribeTable polls (~1s apart, so a little over ${maxAttempts}s of wall clock); ${proceedNote}`);
14403
+ const pollSeconds = INDEX_SETTLE_POLL_INTERVAL_MS / 1e3;
14404
+ logger.warn(`Indexes on ${tableName} (${logicalId}) did not all reach ACTIVE within ${maxAttempts} DescribeTable polls (~${pollSeconds}s apart, so a little over ${maxAttempts * pollSeconds}s of wall clock); ${proceedNote}`);
14389
14405
  }
14390
14406
  /**
14391
14407
  * The ONE line the retry prints at default verbosity, for both providers.
@@ -14395,15 +14411,50 @@ async function waitForIndexesSettled(opts) {
14395
14411
  * and the bounded budget above can end in a timeout that has to be diagnosable
14396
14412
  * from an ordinary run's output.
14397
14413
  *
14398
- * `attemptNumber` is the attempt now STARTING (2 for the first retry). The
14399
- * remaining-attempt count is DERIVED from it and never spelled out: `withRetry`
14400
- * runs `1 + maxRetries` attempts in total, and writing the number by hand is
14401
- * how this line came to promise 8 further attempts when 7 were left.
14414
+ * `attemptNumber` is the attempt now STARTING (2 for the first retry), and
14415
+ * `maxRetries` is the CALLER's budget — the two types no longer share one (see
14416
+ * {@link TABLE_DELETE_INDEX_BUSY_MAX_RETRIES}), so a module-level constant here
14417
+ * would print the other type's promise. The remaining-attempt count is DERIVED
14418
+ * from the pair and never spelled out: `withRetry` runs `1 + maxRetries`
14419
+ * attempts in total, and writing the number by hand is how this line came to
14420
+ * promise 8 further attempts when 7 were left. Both figures are CLAMPED — the
14421
+ * attempt count at zero, the minutes at one, since the announced attempt
14422
+ * re-arms too: the function is exported, and an `attemptNumber` past the budget
14423
+ * otherwise printed "up to 0 more attempts" and then went negative.
14402
14424
  *
14403
14425
  * The per-attempt settle budget is attached to "this attempt plus the N more"
14404
14426
  * rather than to the N alone, because the re-arm runs before the attempt this
14405
- * very line announces too — 8 re-arms for 7 remaining attempts. Saying "N more
14406
- * attempts, each preceded by ..." made one sentence quote two different counts.
14427
+ * very line announces too — 14 re-arms for 13 remaining attempts on the `Table`
14428
+ * budget. Saying "N more attempts, each preceded by ..." made one sentence
14429
+ * quote two different counts.
14430
+ *
14431
+ * It also states how long the loop will keep GOING, as a floor (issue #1950).
14432
+ * At the `Table` budget the loop can run for the better part of twenty minutes,
14433
+ * and a user watching a destroy sit on one table for that long needs to tell
14434
+ * SETTLING from STUCK — an attempt count alone does not say how long it will
14435
+ * take.
14436
+ *
14437
+ * The wording is "will not give up for at least ~N more minutes", NOT "can keep
14438
+ * retrying for at least ~N". The re-arm returns on the first `DescribeTable`
14439
+ * that reports the indexes settled, so a table clearing in 90s prints this line
14440
+ * and then finishes immediately: read as a floor on the WAIT it would push the
14441
+ * user toward Ctrl-C, which is the opposite of what the line is for. It is a
14442
+ * floor on cdkd's PATIENCE, which is what the reader needs to decide whether to
14443
+ * keep waiting.
14444
+ *
14445
+ * A floor rather than an estimate because {@link INDEX_SETTLE_POLL_INTERVAL_MS}
14446
+ * is only the sleep: each poll also pays a `DescribeTable` round trip and
14447
+ * `withRetry` sleeps its backoff between attempts, so the true patience is
14448
+ * longer than the figure printed.
14449
+ *
14450
+ * The floor is conditioned on those waits RUNNING, which the sentence says out
14451
+ * loud rather than assuming. {@link waitForIndexesSettled} returns early on any
14452
+ * non-throttle `DescribeTable` failure (a describe-denied principal that can
14453
+ * still delete, say) and returns at once whenever the GSI-status proxy
14454
+ * disagrees with AWS's real refusal predicate — the gap named above. In those
14455
+ * runs every remaining retry pays only backoff, and cdkd gives up in ~1.6 min
14456
+ * having announced ~14. Unconditional, the figure would be plainly false
14457
+ * there.
14407
14458
  *
14408
14459
  * It must NOT carry AWS's own sentence: the `dynamodb-globaltable` integ greps
14409
14460
  * the destroy log for `Cannot delete table while indexes are being` to prove
@@ -14411,8 +14462,10 @@ async function waitForIndexesSettled(opts) {
14411
14462
  * the refusal having happened.
14412
14463
  */
14413
14464
  function indexBusyRetryWarning(opts) {
14414
- const remainingAttempts = 9 - opts.attemptNumber;
14415
- return `DynamoDB ${opts.typeLabel} ${opts.logicalId}: AWS refused DeleteTable on ${opts.physicalId} because a global secondary index is still being created, updated or deleted — application auto-scaling can start an index capacity change at any moment, including during this destroy. Waiting for the index to settle and retrying (this attempt plus up to ${remainingAttempts} more attempts; each one first waits up to ${60} DescribeTable polls ~1s apart — a little over a minute of settling — before its DeleteTable). Nothing is wrong with the table; a large index backfill can outlast this budget, in which case the destroy fails with AWS's own message and re-running it succeeds once the index is ACTIVE.`;
14465
+ const remainingAttempts = Math.max(0, 1 + opts.maxRetries - opts.attemptNumber);
14466
+ const settleFloorMinutes = Math.max(1, Math.round((remainingAttempts + 1) * 60 * INDEX_SETTLE_POLL_INTERVAL_MS / 6e4));
14467
+ const pollSeconds = INDEX_SETTLE_POLL_INTERVAL_MS / 1e3;
14468
+ return `DynamoDB ${opts.typeLabel} ${opts.logicalId}: AWS refused DeleteTable on ${opts.physicalId} because a global secondary index is still being created, updated or deleted — application auto-scaling can start an index capacity change at any moment, including during this destroy. Waiting for the index to settle and retrying (this attempt plus up to ${remainingAttempts} more attempts; each one first waits up to ${60} DescribeTable polls ~${pollSeconds}s apart before its DeleteTable, so cdkd will not give up for at least ~${settleFloorMinutes} more minutes while those settle waits run). Nothing is wrong with the table; a large index backfill can outlast this budget, in which case the destroy fails with AWS's own message and re-running it succeeds once the index is ACTIVE.`;
14416
14469
  }
14417
14470
  /**
14418
14471
  * Issue the caller's `DeleteTable`, retrying ONLY the transient index-busy
@@ -14430,11 +14483,19 @@ function indexBusyRetryWarning(opts) {
14430
14483
  * index backfill outlasts any fixed backoff grid, while a settle poll returns
14431
14484
  * on its first `DescribeTable` once the index is ACTIVE.
14432
14485
  *
14486
+ * `maxRetries` is the CALLER's, not this module's: the two DynamoDB types have
14487
+ * different amounts of the per-resource deadline left by the time they reach
14488
+ * this loop, so they carry different budgets (see
14489
+ * {@link TABLE_DELETE_INDEX_BUSY_MAX_RETRIES} for both derivations). Passing it
14490
+ * rather than reading a module constant is also what keeps the two providers'
14491
+ * budgets visible at their call sites.
14492
+ *
14433
14493
  * `sleepSeam` is read per SLEEP rather than once when the options bag is built,
14434
14494
  * so a test that installs the no-op from INSIDE the first refusal — i.e. after
14435
14495
  * `delete()` has already built the bag — still gets it. A value captured at
14436
14496
  * construction time would be `undefined` there and would silently pay
14437
- * `withRetry`'s real ~47s schedule. Passing `sleep` unconditionally is safe:
14497
+ * `withRetry`'s real backoff schedule (95s of sleep at the `Table` budget, 47s
14498
+ * at the `GlobalTable` one, on top of a full settle poll per attempt). Passing `sleep` unconditionally is safe:
14438
14499
  * `withRetry`'s dense-schedule detection keys on `maxRetries` /
14439
14500
  * `initialDelayMs` / `maxDelayMs` / `isRetryable`, all of which this caller
14440
14501
  * already sets.
@@ -14447,13 +14508,14 @@ async function deleteTableWithIndexBusyRetry(opts) {
14447
14508
  typeLabel: opts.typeLabel,
14448
14509
  logicalId: opts.logicalId,
14449
14510
  physicalId: opts.physicalId,
14450
- attemptNumber: deleteAttempts
14511
+ attemptNumber: deleteAttempts,
14512
+ maxRetries: opts.maxRetries
14451
14513
  }));
14452
14514
  await opts.reArm();
14453
14515
  }
14454
14516
  await opts.deleteTable();
14455
14517
  }, opts.logicalId, {
14456
- maxRetries: 8,
14518
+ maxRetries: opts.maxRetries,
14457
14519
  isRetryable: (message) => isIndexBusyDeleteError(message),
14458
14520
  logger: opts.logger,
14459
14521
  sleep: (ms) => opts.sleepSeam.sleep ? opts.sleepSeam.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms))
@@ -14992,7 +15054,8 @@ function desiredIndexEntriesByName(value) {
14992
15054
  * Test seam for the index-busy `DeleteTable` retry's backoff (issue #1931),
14993
15055
  * mirroring the sibling provider's export of the same name. Production leaves
14994
15056
  * `sleep` undefined so `withRetry`'s real schedule applies; a test injects a
14995
- * no-op so the ~47s budget does not have to be waited out.
15057
+ * no-op so the ~95s of backoff at this type's budget
15058
+ * (`TABLE_DELETE_INDEX_BUSY_MAX_RETRIES`) does not have to be waited out.
14996
15059
  *
14997
15060
  * One seam PER PROVIDER rather than one on the shared module: a `Table` test
14998
15061
  * that silences this backoff must not also silence the `GlobalTable` one, since
@@ -15210,7 +15273,7 @@ var DynamoDBTableProvider = class {
15210
15273
  }
15211
15274
  await this.dynamoDBClient.send(new UpdateTableCommand(updateInput));
15212
15275
  if (billingMode !== liveBillingMode) await this.waitForTableAndIndexesActive(physicalId, BILLING_FLIP_ACTIVE_WAIT_ATTEMPTS);
15213
- else await this.waitForTableActiveAfterUpdate(physicalId, TABLE_ACTIVE_WAIT_ATTEMPTS);
15276
+ else await this.waitForTableActiveAfterUpdate(physicalId, 60);
15214
15277
  this.logger.debug(`Updated BillingMode/ProvisionedThroughput/TableClass on DynamoDB table ${physicalId}`);
15215
15278
  }
15216
15279
  if (JSON.stringify(properties["OnDemandThroughput"]) !== JSON.stringify(previousProperties["OnDemandThroughput"])) {
@@ -15338,6 +15401,7 @@ var DynamoDBTableProvider = class {
15338
15401
  logicalId,
15339
15402
  physicalId,
15340
15403
  typeLabel: "table",
15404
+ maxRetries: 14,
15341
15405
  logger: this.logger,
15342
15406
  deleteTable: async () => {
15343
15407
  await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: physicalId }));
@@ -15697,10 +15761,10 @@ var DynamoDBTableProvider = class {
15697
15761
  * blanket raise would turn every genuinely wedged capacity edit into a
15698
15762
  * 10-minute hang.
15699
15763
  */
15700
- async waitForTableActiveAfterUpdate(tableName, maxAttempts = TABLE_ACTIVE_WAIT_ATTEMPTS) {
15764
+ async waitForTableActiveAfterUpdate(tableName, maxAttempts = 60) {
15701
15765
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
15702
15766
  if ((await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.TableStatus === "ACTIVE") return;
15703
- await new Promise((resolve) => setTimeout(resolve, 1e3));
15767
+ await new Promise((resolve) => setTimeout(resolve, INDEX_SETTLE_POLL_INTERVAL_MS));
15704
15768
  }
15705
15769
  throw new Error(`Table ${tableName} did not reach ACTIVE status within ${maxAttempts} seconds after UpdateTable`);
15706
15770
  }
@@ -17783,6 +17847,7 @@ var DynamoDBGlobalTableProvider = class {
17783
17847
  logicalId,
17784
17848
  physicalId,
17785
17849
  typeLabel: "GlobalTable",
17850
+ maxRetries: 8,
17786
17851
  logger: this.logger,
17787
17852
  deleteTable: async () => {
17788
17853
  await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: physicalId }));
@@ -18456,10 +18521,10 @@ var DynamoDBGlobalTableProvider = class {
18456
18521
  * Delete replica update. Replica deletion typically takes 1–5 min;
18457
18522
  * cap at 10 min.
18458
18523
  */
18459
- async waitForReplicaGone(tableName, region, logicalId, maxAttempts = 600) {
18524
+ async waitForReplicaGone(tableName, region, logicalId, maxAttempts = REPLICA_GONE_WAIT_ATTEMPTS) {
18460
18525
  for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
18461
18526
  if (!(await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.Replicas?.find((r) => r.RegionName === region)) return;
18462
- await new Promise((resolve) => setTimeout(resolve, 1e3));
18527
+ await new Promise((resolve) => setTimeout(resolve, INDEX_SETTLE_POLL_INTERVAL_MS));
18463
18528
  } catch (err) {
18464
18529
  if (err instanceof ResourceNotFoundException$1) return;
18465
18530
  throw err;
@@ -18480,7 +18545,7 @@ var DynamoDBGlobalTableProvider = class {
18480
18545
  async waitForTableGone(tableName, logicalId, maxAttempts = 600) {
18481
18546
  for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
18482
18547
  await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }));
18483
- await new Promise((resolve) => setTimeout(resolve, 1e3));
18548
+ await new Promise((resolve) => setTimeout(resolve, INDEX_SETTLE_POLL_INTERVAL_MS));
18484
18549
  } catch (err) {
18485
18550
  if (err instanceof ResourceNotFoundException$1) return;
18486
18551
  throw err;
@@ -18575,10 +18640,33 @@ function autoScalingResourceId(tableName, indexName) {
18575
18640
  */
18576
18641
  const autoScalingRetryDelays = {};
18577
18642
  /**
18643
+ * Polls `waitForReplicaGone` spends waiting for a replica to disappear.
18644
+ *
18645
+ * Named rather than inlined because it is a TERM of the per-resource deadline
18646
+ * arithmetic on this type's delete path (see
18647
+ * `GLOBAL_TABLE_DELETE_INDEX_BUSY_MAX_RETRIES`), which prices it through
18648
+ * {@link INDEX_SETTLE_POLL_INTERVAL_MS} — the sleep this loop now reads rather
18649
+ * than spelling its own `1000`, so the two cannot drift while that arithmetic
18650
+ * claims they agree.
18651
+ */
18652
+ const REPLICA_GONE_WAIT_ATTEMPTS = 600;
18653
+ /**
18654
+ * Polls `waitForTableGone` spends confirming the table is actually gone.
18655
+ *
18656
+ * EXPORTED, unlike its replica sibling, because the delete-path budget fence
18657
+ * reads it: this wait runs AFTER the index-busy retry loop and only when that
18658
+ * loop SUCCEEDS, which is what keeps it out of the loop's own worst case and
18659
+ * puts it into the late-success one (both spelled out on
18660
+ * `GLOBAL_TABLE_DELETE_INDEX_BUSY_MAX_RETRIES`).
18661
+ */
18662
+ const TABLE_GONE_WAIT_ATTEMPTS = 600;
18663
+ /**
18578
18664
  * Test seam for the index-busy `DeleteTable` retry's backoff (issue #1830),
18579
18665
  * mirroring {@link autoScalingRetryDelays}. Production leaves `sleep`
18580
18666
  * undefined so `withRetry`'s real schedule applies; a test injects a no-op so
18581
- * the ~47s budget does not have to be waited out. Kept SEPARATE from the
18667
+ * the ~47s of backoff at this type's budget
18668
+ * (`GLOBAL_TABLE_DELETE_INDEX_BUSY_MAX_RETRIES`) does not have to be waited
18669
+ * out. Kept SEPARATE from the
18582
18670
  * auto-scaling seam so a test can slow one path without silencing the other.
18583
18671
  */
18584
18672
  const deleteTableRetryDelays = {};
@@ -38843,6 +38931,46 @@ const MFA_FACTOR_SMS = "SMS_MFA";
38843
38931
  const MFA_FACTOR_SOFTWARE_TOKEN = "SOFTWARE_TOKEN_MFA";
38844
38932
  const MFA_FACTOR_EMAIL_OTP = "EMAIL_OTP";
38845
38933
  /**
38934
+ * CFn path every `MfaConfiguration` shape refusal in this provider reports.
38935
+ * One constant so the three sites that READ the property -- `create`,
38936
+ * `update`, and `narrowMfaConfiguration` (the diff / effectiveProperties
38937
+ * side) -- cannot word the same fault differently. `buildMfaConfigRequest`
38938
+ * performs no read of its own; it is handed the already-guarded value.
38939
+ */
38940
+ const MFA_CONFIGURATION_PATH = "AWS::Cognito::UserPool MfaConfiguration";
38941
+ function classifyDeclaredMfaConfiguration(raw) {
38942
+ if (raw === void 0) return "absent";
38943
+ if (typeof raw !== "string") return "refused";
38944
+ return raw.trim() === "" ? "blank" : "usable";
38945
+ }
38946
+ /**
38947
+ * Read the template's `MfaConfiguration` through the shape guard AND fold a
38948
+ * TRIMMED-BLANK value to absence.
38949
+ *
38950
+ * The fold is the load-bearing half. `requireConfigString` short-circuits on
38951
+ * `fallback === '' && typeof value === 'string'` (config-shape.ts), so against
38952
+ * the blank fallback this site uses it returns ANY string verbatim --
38953
+ * including `' '`. That value is truthy, so it rode the
38954
+ * `if (mfaConfiguration && ...)` gates onto `CreateUserPool` / `UpdateUserPool`
38955
+ * and the `|| default` in `buildMfaConfigRequest` onto `SetUserPoolMfaConfig`,
38956
+ * where AWS rejects the enum and the deploy fails -- while the blank-value
38957
+ * warning promised "the pool deploys with the default MFA configuration
38958
+ * instead". Two spellings of blank behaved differently and the message
38959
+ * described only one of them.
38960
+ *
38961
+ * Folding here rather than widening the guard keeps the change local to the
38962
+ * one site whose fallback is blank, and makes the wire behavior match what the
38963
+ * warning says: a blank declares nothing, so the default applies.
38964
+ *
38965
+ * Every read site MUST go through this, including `narrowMfaConfiguration` --
38966
+ * a diff side that folded differently from the wire is the phantom drift this
38967
+ * whole mechanism exists to remove.
38968
+ */
38969
+ function readDeclaredMfaConfiguration(raw, options) {
38970
+ const value = requireConfigString(raw, "", MFA_CONFIGURATION_PATH, options);
38971
+ return value.trim() === "" ? "" : value;
38972
+ }
38973
+ /**
38846
38974
  * Read `EnabledMfas` as the pair (recognized list, "the template asked for a
38847
38975
  * factor at all").
38848
38976
  *
@@ -38866,14 +38994,16 @@ function readEnabledMfas(properties) {
38866
38994
  return {
38867
38995
  factors,
38868
38996
  declaresFactor: factors.length > 0,
38869
- malformed: false
38997
+ malformed: false,
38998
+ blank: false
38870
38999
  };
38871
39000
  }
38872
39001
  const malformed = raw !== void 0 && raw !== null && raw !== "";
38873
39002
  return {
38874
39003
  factors: void 0,
38875
39004
  declaresFactor: malformed,
38876
- malformed
39005
+ malformed,
39006
+ blank: raw === ""
38877
39007
  };
38878
39008
  }
38879
39009
  /**
@@ -38912,9 +39042,17 @@ function hasMfaConfigProps(properties) {
38912
39042
  * enables an MFA FACTOR — `OPTIONAL` if it does, `OFF` (CloudFormation's own
38913
39043
  * default) if it does not. The body comment on that decision explains why both
38914
39044
  * halves of the factor test are load-bearing.
39045
+ *
39046
+ * `declaredMfaConfiguration` is the caller's ALREADY-GUARDED read of
39047
+ * `properties['MfaConfiguration']` (issue #1925 item 2), with `''` meaning
39048
+ * "the template declared none, or declared a value the shape guard refused".
39049
+ * The read happens at the caller so it runs ONCE per deploy path — the value
39050
+ * is also needed for the `CreateUserPool` / `UpdateUserPool` forward — and so
39051
+ * the create path can REFUSE a malformed value while the update path only
39052
+ * warns, which is the split `requireConfigString`'s `onUnusable` encodes.
38915
39053
  */
38916
- function buildMfaConfigRequest(physicalId, properties, logger) {
38917
- const { factors: enabledMfas, declaresFactor, malformed: enabledMfasMalformed } = readEnabledMfas(properties);
39054
+ function buildMfaConfigRequest(physicalId, properties, logger, declaredMfaConfiguration = "") {
39055
+ const { factors: enabledMfas, declaresFactor, malformed: enabledMfasMalformed, blank: enabledMfasBlank } = readEnabledMfas(properties);
38918
39056
  const emailMessage = properties["EmailAuthenticationMessage"] || void 0;
38919
39057
  const emailSubject = properties["EmailAuthenticationSubject"] || void 0;
38920
39058
  const webAuthnRpId = properties["WebAuthnRelyingPartyID"] || void 0;
@@ -38934,17 +39072,83 @@ function buildMfaConfigRequest(physicalId, properties, logger) {
38934
39072
  ...webAuthnUserVerification !== void 0 ? { UserVerification: webAuthnUserVerification } : {}
38935
39073
  };
38936
39074
  const enablesMfaFactor = declaresFactor || request.EmailMfaConfiguration !== void 0;
38937
- request.MfaConfiguration = properties["MfaConfiguration"] ?? (enablesMfaFactor ? "OPTIONAL" : "OFF");
39075
+ request.MfaConfiguration = declaredMfaConfiguration || (enablesMfaFactor ? "OPTIONAL" : "OFF");
39076
+ if (enabledMfasBlank) logger?.warn(`UserPool ${physicalId}: EnabledMfas is an empty string, which declares no MFA factor and is treated as absent — a mangled Fn::Join or an empty String parameter collapses to this shape. Nothing is enabled from it and MfaConfiguration is ${request.MfaConfiguration}; declare the factor names as a list (e.g. ["${MFA_FACTOR_SOFTWARE_TOKEN}"]) if a factor was intended.`);
39077
+ const anyFactorBlock = request.SmsMfaConfiguration !== void 0 || request.SoftwareTokenMfaConfiguration !== void 0 || request.EmailMfaConfiguration !== void 0;
39078
+ if (request.MfaConfiguration === "OFF" && anyFactorBlock) logger?.warn(`UserPool ${physicalId}: MfaConfiguration is pinned to OFF while an MFA factor block is configured. The factor configuration is still sent, but the pool deploys with MFA DISABLED. Remove MfaConfiguration (or set it to ON / OPTIONAL) to enable the declared factor.`);
38938
39079
  const dropped = unrecognizedFactors.map((f) => JSON.stringify(f) ?? String(f));
38939
39080
  if (enabledMfasMalformed) dropped.push(`${JSON.stringify(properties["EnabledMfas"])} (not a list)`);
38940
39081
  if (dropped.length > 0) {
38941
- const anyFactorBlock = request.SmsMfaConfiguration !== void 0 || request.SoftwareTokenMfaConfiguration !== void 0 || request.EmailMfaConfiguration !== void 0;
38942
39082
  const consequence = request.MfaConfiguration === "OFF" ? `MfaConfiguration is OFF, so this pool deploys with MFA DISABLED` : anyFactorBlock ? `MfaConfiguration is ${request.MfaConfiguration} and another factor block IS sent, so these entries are silently ignored rather than failing the call on their own` : `MfaConfiguration is ${request.MfaConfiguration} with no factor enabled, so AWS rejects this call rather than deploying the pool with MFA disabled`;
38943
39083
  logger?.warn(`UserPool ${physicalId}: EnabledMfas entries ${dropped.join(", ")} do not map to an MFA factor block (known: ${MFA_FACTOR_SMS}, ${MFA_FACTOR_SOFTWARE_TOKEN}, ${MFA_FACTOR_EMAIL_OTP}); no factor is enabled from them. ${consequence}.`);
38944
39084
  }
38945
39085
  return request;
38946
39086
  }
38947
39087
  /**
39088
+ * The `MfaConfiguration` cdkd actually SENDS for this bag, or `undefined` when
39089
+ * no call carries the field at all (nothing declared and no MFA-routed
39090
+ * property, so AWS applies its own default of OFF).
39091
+ *
39092
+ * Exactly one call ever carries it: `SetUserPoolMfaConfig` owns it whenever any
39093
+ * MFA-routed property is present -- and always sets it, because that API is a
39094
+ * full replace -- otherwise the `CreateUserPool` / `UpdateUserPool` forward
39095
+ * carries it, and only when it is non-blank.
39096
+ *
39097
+ * Delegates to {@link buildMfaConfigRequest} rather than re-deriving the
39098
+ * OPTIONAL/OFF default, so the recorded value and the wire value cannot
39099
+ * disagree. That function is pure and synchronous, which is what lets
39100
+ * `canonicalizeDesiredProperties` -- which runs inside the diff -- call it too.
39101
+ * The empty `physicalId` and absent logger are unused on this path: every
39102
+ * message it can emit is behind `logger?.`, so passing none makes the call
39103
+ * silent, which is required of the diff side.
39104
+ */
39105
+ function resolveSentMfaConfiguration(properties, declaredMfaConfiguration) {
39106
+ const request = buildMfaConfigRequest("", properties, void 0, declaredMfaConfiguration);
39107
+ if (request) return request.MfaConfiguration;
39108
+ return declaredMfaConfiguration || void 0;
39109
+ }
39110
+ /**
39111
+ * Replace a DECLARED-but-unusable `MfaConfiguration` with the value cdkd
39112
+ * actually sends, or drop the key when nothing is sent. Returns the input
39113
+ * object unchanged when nothing applies, so the common case compares
39114
+ * byte-for-byte as before.
39115
+ *
39116
+ * ONE helper feeding BOTH sides of the decision, per `.claude/rules/
39117
+ * providers.md`: the provisioning path returns it as `effectiveProperties` so
39118
+ * STATE describes what AWS holds, and `canonicalizeDesiredProperties` applies
39119
+ * it to the DESIRED bag so the next diff compares the same thing. Shipping
39120
+ * only the first half is worse than shipping neither -- the template would keep
39121
+ * declaring the malformed value, and every later deploy would read the
39122
+ * narrowing back as a user-made change.
39123
+ *
39124
+ * Without this, all three substitution arms (the replay-CREATE downgrade, the
39125
+ * update-path warn-and-default, and the blank-string default) recorded the
39126
+ * declared `null` / `''` while AWS held `OPTIONAL` / `OFF` -- permanent phantom
39127
+ * drift that `cdkd drift --revert` re-issues forever with nothing to apply.
39128
+ *
39129
+ * A value that is merely ABSENT is deliberately NOT filled in. Reaching for
39130
+ * `effectiveProperties` is licensed only where the provider KNOWS it
39131
+ * substituted something it was told, and an omitted property was never a
39132
+ * declaration to contradict; writing the resolved default into the DESIRED
39133
+ * baseline there would also disable the #1160 absent-field removal derivation,
39134
+ * which reads that side.
39135
+ */
39136
+ function narrowMfaConfiguration(properties) {
39137
+ const raw = properties["MfaConfiguration"];
39138
+ if (raw === void 0) return properties;
39139
+ const sent = resolveSentMfaConfiguration(properties, readDeclaredMfaConfiguration(raw, { onUnusable: () => {} }));
39140
+ if (sent === raw) return properties;
39141
+ if (sent === void 0) {
39142
+ const withoutMfaConfiguration = { ...properties };
39143
+ delete withoutMfaConfiguration["MfaConfiguration"];
39144
+ return withoutMfaConfiguration;
39145
+ }
39146
+ return {
39147
+ ...properties,
39148
+ MfaConfiguration: sent
39149
+ };
39150
+ }
39151
+ /**
38948
39152
  * AWS Cognito User Pool Provider
38949
39153
  *
38950
39154
  * Implements resource provisioning for AWS::Cognito::UserPool using the Cognito SDK.
@@ -38988,6 +39192,50 @@ var CognitoUserPoolProvider = class {
38988
39192
  "WebAuthnUserVerification"
38989
39193
  ])]]);
38990
39194
  unhandledByDesign = /* @__PURE__ */ new Map([["AWS::Cognito::UserPool", /* @__PURE__ */ new Map([["WebAuthnFactorConfiguration", "No SDK wire path: @aws-sdk/client-cognito-identity-provider has no field accepting SINGLE_FACTOR | MULTI_FACTOR_WITH_USER_VERIFICATION (not on CreateUserPool/UpdateUserPool, nor SetUserPoolMfaConfig.WebAuthnConfiguration which only carries RelyingPartyId/UserVerification); CC-API-registry-only property"]])]]);
39195
+ /**
39196
+ * Warn when `MfaConfiguration` is declared as a BLANK string (issue #1925
39197
+ * item 2, review round 2).
39198
+ *
39199
+ * `requireConfigString` accepts any string against a blank fallback, so `''`
39200
+ * passes the shape guard and is then substituted by the OPTIONAL/OFF default
39201
+ * downstream. That is the exact silent-default class the guard exists to
39202
+ * kill, reached one shape further in: on main a WebAuthn-only pool declaring
39203
+ * `MfaConfiguration: ''` failed LOUDLY with an AWS enum rejection, and with
39204
+ * the guard alone it would deploy MFA OFF with no message anywhere. A
39205
+ * collapsed `Fn::Join` or an empty `String` parameter produces exactly this.
39206
+ *
39207
+ * The twin of the blank-`EnabledMfas` warning one property over, and warned
39208
+ * for the same reason it is: the value stays absence ON THE WIRE (the
39209
+ * substitution is what CloudFormation's own default does), so only the
39210
+ * silence is removed.
39211
+ *
39212
+ * `.trim()` matches the fold in {@link readDeclaredMfaConfiguration}, and the
39213
+ * two must agree: `requireConfigString` does NOT treat whitespace-only as
39214
+ * blank against the blank fallback this site uses, so before that fold a
39215
+ * `' '` was warned about here and then sent to AWS verbatim -- this message
39216
+ * describing a default that never applied.
39217
+ */
39218
+ warnOnBlankMfaConfiguration(raw) {
39219
+ if (typeof raw !== "string" || raw.trim() !== "") return;
39220
+ this.logger.warn("AWS::Cognito::UserPool MfaConfiguration is an empty string, which declares no MFA mode and is treated as absent -- a collapsed Fn::Join or an empty String parameter produces this shape. The pool deploys with the default MFA configuration instead; declare ON / OPTIONAL / OFF explicitly, or omit the property, to say which was meant.");
39221
+ }
39222
+ /**
39223
+ * The DIFF-side half of the `MfaConfiguration` substitution record.
39224
+ *
39225
+ * Applied by `DiffCalculator` to BOTH comparison sides, so a template that
39226
+ * declares a malformed / blank `MfaConfiguration` compares equal to the state
39227
+ * record holding the value cdkd actually sent for it. Without this half, the
39228
+ * `effectiveProperties` above would make every later deploy read the
39229
+ * substitution back as a user-made change and re-run the update forever.
39230
+ *
39231
+ * Shares ONE helper with the provisioning path rather than re-deriving the
39232
+ * rule, per `.claude/rules/providers.md` -- state and template narrowed by
39233
+ * different code is how this fix would become the bug.
39234
+ */
39235
+ canonicalizeDesiredProperties(resourceType, properties) {
39236
+ if (resourceType !== "AWS::Cognito::UserPool") return properties;
39237
+ return narrowMfaConfiguration(properties);
39238
+ }
38991
39239
  getClient() {
38992
39240
  if (!this.cognitoClient) this.cognitoClient = new CognitoIdentityProviderClient(this.providerRegion ? { region: this.providerRegion } : {});
38993
39241
  return this.cognitoClient;
@@ -39008,9 +39256,16 @@ var CognitoUserPoolProvider = class {
39008
39256
  /**
39009
39257
  * Create a Cognito User Pool
39010
39258
  */
39011
- async create(logicalId, resourceType, properties) {
39259
+ async create(logicalId, resourceType, properties, context) {
39012
39260
  this.logger.debug(`Creating Cognito User Pool ${logicalId}`);
39013
39261
  const poolName = properties["UserPoolName"] || generateResourceName(logicalId, { maxLength: 128 });
39262
+ let mfaConfiguration;
39263
+ try {
39264
+ mfaConfiguration = readDeclaredMfaConfiguration(properties["MfaConfiguration"], replayWarn(this.logger, context));
39265
+ this.warnOnBlankMfaConfiguration(properties["MfaConfiguration"]);
39266
+ } catch (error) {
39267
+ throw new ProvisioningError(error instanceof Error ? error.message : String(error), resourceType, logicalId, poolName, error instanceof Error ? error : void 0);
39268
+ }
39014
39269
  let createdUserPoolId;
39015
39270
  try {
39016
39271
  const createParams = { PoolName: poolName };
@@ -39022,7 +39277,7 @@ var CognitoUserPoolProvider = class {
39022
39277
  }
39023
39278
  if (properties["Schema"]) createParams.Schema = properties["Schema"];
39024
39279
  if (properties["LambdaConfig"]) createParams.LambdaConfig = properties["LambdaConfig"];
39025
- if (properties["MfaConfiguration"] && !hasMfaConfigProps(properties)) createParams.MfaConfiguration = properties["MfaConfiguration"];
39280
+ if (mfaConfiguration && !hasMfaConfigProps(properties)) createParams.MfaConfiguration = mfaConfiguration;
39026
39281
  if (properties["UserPoolTags"]) createParams.UserPoolTags = properties["UserPoolTags"];
39027
39282
  if (properties["AdminCreateUserConfig"]) createParams.AdminCreateUserConfig = properties["AdminCreateUserConfig"];
39028
39283
  if (properties["AccountRecoverySetting"]) createParams.AccountRecoverySetting = properties["AccountRecoverySetting"];
@@ -39049,8 +39304,9 @@ var CognitoUserPoolProvider = class {
39049
39304
  const { urlSuffix } = derivePartitionAndUrlSuffix(region);
39050
39305
  const providerName = `cognito-idp.${region}.${urlSuffix}/${userPoolId}`;
39051
39306
  const providerUrl = `https://cognito-idp.${region}.${urlSuffix}/${userPoolId}`;
39052
- await this.applyMfaConfig(userPoolId, properties);
39307
+ await this.applyMfaConfig(userPoolId, properties, mfaConfiguration);
39053
39308
  this.logger.debug(`Successfully created Cognito User Pool ${logicalId}: ${userPoolId}`);
39309
+ const effectiveProperties = narrowMfaConfiguration(properties);
39054
39310
  return {
39055
39311
  physicalId: userPoolId,
39056
39312
  attributes: {
@@ -39058,7 +39314,8 @@ var CognitoUserPoolProvider = class {
39058
39314
  ProviderName: providerName,
39059
39315
  ProviderURL: providerUrl,
39060
39316
  UserPoolId: userPoolId
39061
- }
39317
+ },
39318
+ ...effectiveProperties !== properties ? { effectiveProperties } : {}
39062
39319
  };
39063
39320
  } catch (error) {
39064
39321
  if (createdUserPoolId) try {
@@ -39078,10 +39335,109 @@ var CognitoUserPoolProvider = class {
39078
39335
  * control-plane writes on a freshly-created pool can briefly conflict
39079
39336
  * (mirrors DynamoDBTableProvider.retryOnTransientControlPlane).
39080
39337
  */
39081
- async applyMfaConfig(physicalId, properties) {
39082
- const request = buildMfaConfigRequest(physicalId, properties, this.logger);
39338
+ async applyMfaConfig(physicalId, properties, declaredMfaConfiguration, options = {}) {
39339
+ const request = buildMfaConfigRequest(physicalId, properties, this.logger, declaredMfaConfiguration);
39083
39340
  if (!request) return;
39084
39341
  await this.retryOnTransientControlPlane(() => this.getClient().send(new SetUserPoolMfaConfigCommand(request)), `SetUserPoolMfaConfig(${physicalId})`);
39342
+ this.reportMfaConfigurationOff(physicalId, request, declaredMfaConfiguration, options);
39343
+ }
39344
+ /**
39345
+ * Say so when this deploy sends `MfaConfiguration=OFF` without the template
39346
+ * having asked for OFF (issue #1925, third item).
39347
+ *
39348
+ * Everything here keys on the BUILT request, so there is exactly one
39349
+ * definition of "this deploy resolves to OFF", and on `declaredMfaConfiguration
39350
+ * === ''`, i.e. the template did not pin a usable value. `create()` passes
39351
+ * neither a live value nor a `declaredKind`, so it reports nothing -- correct,
39352
+ * since a fresh pool has no value to downgrade.
39353
+ *
39354
+ * Three outcomes rather than one, because the live value can be KNOWN,
39355
+ * known-and-harmless, or UNAVAILABLE, and only the first is a downgrade that
39356
+ * can be named:
39357
+ *
39358
+ * - live `ON` / `OPTIONAL` -- a real downgrade; name the value being lost.
39359
+ * - live `OFF` -- not a downgrade at all; say nothing. This is the arm the
39360
+ * live-value test protects, and it is load-bearing in the SILENT
39361
+ * direction: without it every already-OFF pool and every failed probe
39362
+ * warned, the latter reading `live value was undefined`.
39363
+ * - live UNKNOWN (the probe failed) -- the case that made this necessary.
39364
+ * A malformed but TRUTHY value used to reach `UpdateUserPool`, AWS
39365
+ * rejected the enum, MFA stayed as it was and the deploy failed; now the
39366
+ * guard substitutes and the deploy exits 0 with MFA off. On a role lacking
39367
+ * `cognito-idp:GetUserPoolMfaConfig` -- exactly the role this arm exists
39368
+ * for -- the probe returns nothing, so without this the ONLY path that got
39369
+ * quieter than main would say nothing at all.
39370
+ */
39371
+ reportMfaConfigurationOff(physicalId, request, declaredMfaConfiguration, options) {
39372
+ if (declaredMfaConfiguration !== "" || request.MfaConfiguration !== "OFF") return;
39373
+ const kind = options.declaredKind ?? "absent";
39374
+ const cause = kind === "refused" ? "the declared MfaConfiguration was refused as malformed (see the warning above), so this update sent MfaConfiguration=OFF instead" : kind === "blank" ? "the declared MfaConfiguration is blank, so it is treated as absent and this update sent MfaConfiguration=OFF" : "the template declares no MfaConfiguration, so this update sent MfaConfiguration=OFF (CloudFormation's own default)";
39375
+ const remedy = (value) => kind === "absent" ? `Declare MfaConfiguration: ${value} in the template to keep it.` : `Repair MfaConfiguration to a literal ${value} to keep it.`;
39376
+ if (options.liveMfaConfiguration === "ON" || options.liveMfaConfiguration === "OPTIONAL") {
39377
+ this.logger.warn(`UserPool ${physicalId}: ${cause} to a pool whose live value was ${options.liveMfaConfiguration} — SetUserPoolMfaConfig is a full replace, so MFA is now OFF. ${remedy(options.liveMfaConfiguration)}`);
39378
+ return;
39379
+ }
39380
+ if (options.liveReadFailed !== true) return;
39381
+ const declaredClause = kind === "absent" ? `the template declares no MfaConfiguration` : `the declared MfaConfiguration could not be used`;
39382
+ this.logger.warn(`UserPool ${physicalId}: ${declaredClause}, so this update sent MfaConfiguration=OFF — and the pool's previous MFA setting could not be read, so cdkd cannot say whether that turned MFA off. Grant cognito-idp:GetUserPoolMfaConfig to the deploy role to have this reported precisely. ${remedy("ON or OPTIONAL")}`);
39383
+ }
39384
+ /**
39385
+ * Read the pool's live `MfaConfiguration` for the undeclared-downgrade
39386
+ * announcement (issue #1925, third item), or `undefined` when it cannot be
39387
+ * determined.
39388
+ *
39389
+ * `SetUserPoolMfaConfig` is a full replace and this provider re-issues it on
39390
+ * every update carrying an MFA-routed property, unconditioned on the MFA
39391
+ * configuration having CHANGED. So a WebAuthn-only template applied to a pool
39392
+ * whose live `MfaConfiguration` is `ON` / `OPTIONAL` — console drift, or a
39393
+ * `cdkd import` of an MFA-enabled pool — writes `OFF` on the next unrelated
39394
+ * property change. That is defensible template-is-truth / CloudFormation
39395
+ * parity and is NOT changed here; only its silence is.
39396
+ *
39397
+ * **Called BEFORE `UpdateUserPool`, defensively — NOT because AWS resets the
39398
+ * field.** MEASURED us-east-1 2026-08-18: a pool at `ON` with software-token
39399
+ * enabled keeps `ON` through an `UpdateUserPool` that omits
39400
+ * `MfaConfiguration`, confirmed by both `get-user-pool-mfa-config` and
39401
+ * `describe-user-pool`, with controls proving the call really ran (a
39402
+ * `--auto-verified-attributes` change in the same request applied) and that
39403
+ * the field is writable here rather than ignored (an explicit
39404
+ * `--mfa-configuration OFF` did reset it). So AWS's blanket "unspecified
39405
+ * parameters are set to their default value" holds field by field —
39406
+ * `AutoVerifiedAttributes` DOES reset, `MfaConfiguration` does not — and
39407
+ * there is no MFA-OFF window to design around.
39408
+ *
39409
+ * The earlier ordering was reached from that doc sentence alone and is kept
39410
+ * only because it is strictly safer and already tested: reading first cannot
39411
+ * report a value this same call clobbered, whatever AWS does later. Do not
39412
+ * re-derive a redesign from the doc sentence — it has been measured.
39413
+ *
39414
+ * Best-effort by construction: the read exists only to ANNOUNCE, so a
39415
+ * failure must not fail the update the user asked for.
39416
+ *
39417
+ * **This adds `cognito-idp:GetUserPoolMfaConfig` to the permissions a deploy
39418
+ * role wants.** The action was previously needed only by `readCurrentState`
39419
+ * (drift / import), so a role scoped to deploying could legitimately lack it.
39420
+ *
39421
+ * A failure is REPORTED rather than swallowed, but not here: this method's
39422
+ * gate is a deliberate SUPERSET of the announcement condition (see the call
39423
+ * site in `update()`), so warning at the point of failure fired on templates
39424
+ * that structurally cannot downgrade -- an `EnabledMfas` with no
39425
+ * `MfaConfiguration` resolves to OPTIONAL, and a least-privileged role saw
39426
+ * "cdkd cannot say whether it turns MFA off" on every deploy of it. The flag
39427
+ * returned here is consumed by `reportMfaConfigurationOff`, which speaks only
39428
+ * where the request actually resolves to OFF.
39429
+ */
39430
+ async readLiveMfaConfiguration(physicalId) {
39431
+ try {
39432
+ const live = await this.getClient().send(new GetUserPoolMfaConfigCommand({ UserPoolId: physicalId }));
39433
+ return live.MfaConfiguration !== void 0 ? {
39434
+ value: live.MfaConfiguration,
39435
+ failed: false
39436
+ } : { failed: false };
39437
+ } catch (error) {
39438
+ this.logger.debug(`GetUserPoolMfaConfig failed for UserPool ${physicalId} (${error instanceof Error ? error.name : typeof error}): ${error instanceof Error ? error.message : String(error)}`);
39439
+ return { failed: true };
39440
+ }
39085
39441
  }
39086
39442
  /**
39087
39443
  * Retry a Cognito control-plane call on transient "settling" errors. A
@@ -39111,6 +39467,9 @@ var CognitoUserPoolProvider = class {
39111
39467
  */
39112
39468
  async update(logicalId, physicalId, resourceType, properties, previousProperties) {
39113
39469
  this.logger.debug(`Updating Cognito User Pool ${logicalId}: ${physicalId}`);
39470
+ const mfaConfiguration = readDeclaredMfaConfiguration(properties["MfaConfiguration"], { onUnusable: (message) => this.logger.warn(message) });
39471
+ this.warnOnBlankMfaConfiguration(properties["MfaConfiguration"]);
39472
+ const liveMfa = mfaConfiguration === "" && hasMfaConfigProps(properties) ? await this.readLiveMfaConfiguration(physicalId) : void 0;
39114
39473
  try {
39115
39474
  const updateParams = { UserPoolId: physicalId };
39116
39475
  if (properties["Policies"]) {
@@ -39119,7 +39478,7 @@ var CognitoUserPoolProvider = class {
39119
39478
  }
39120
39479
  if (properties["LambdaConfig"]) updateParams.LambdaConfig = properties["LambdaConfig"];
39121
39480
  if (properties["AutoVerifiedAttributes"]) updateParams.AutoVerifiedAttributes = properties["AutoVerifiedAttributes"];
39122
- if (properties["MfaConfiguration"]) updateParams.MfaConfiguration = properties["MfaConfiguration"];
39481
+ if (mfaConfiguration && !hasMfaConfigProps(properties)) updateParams.MfaConfiguration = mfaConfiguration;
39123
39482
  if (properties["AdminCreateUserConfig"]) updateParams.AdminCreateUserConfig = properties["AdminCreateUserConfig"];
39124
39483
  if (properties["AccountRecoverySetting"]) updateParams.AccountRecoverySetting = properties["AccountRecoverySetting"];
39125
39484
  if (properties["UserPoolTags"]) updateParams.UserPoolTags = properties["UserPoolTags"];
@@ -39137,13 +39496,18 @@ var CognitoUserPoolProvider = class {
39137
39496
  if (properties["UserPoolTier"]) updateParams.UserPoolTier = properties["UserPoolTier"];
39138
39497
  await this.getClient().send(new UpdateUserPoolCommand(updateParams));
39139
39498
  await this.reconcileSchemaCustomAttributes(logicalId, physicalId, resourceType, properties["Schema"], previousProperties["Schema"]);
39140
- await this.applyMfaConfig(physicalId, properties);
39499
+ await this.applyMfaConfig(physicalId, properties, mfaConfiguration, {
39500
+ ...liveMfa?.value !== void 0 ? { liveMfaConfiguration: liveMfa.value } : {},
39501
+ ...liveMfa?.failed === true ? { liveReadFailed: true } : {},
39502
+ declaredKind: classifyDeclaredMfaConfiguration(properties["MfaConfiguration"])
39503
+ });
39141
39504
  this.logger.debug(`Successfully updated Cognito User Pool ${logicalId}`);
39142
39505
  const userPool = (await this.getClient().send(new DescribeUserPoolCommand({ UserPoolId: physicalId }))).UserPool;
39143
39506
  const region = await this.getClient().config.region();
39144
39507
  const { urlSuffix } = derivePartitionAndUrlSuffix(region);
39145
39508
  const providerName = `cognito-idp.${region}.${urlSuffix}/${physicalId}`;
39146
39509
  const providerUrl = `https://cognito-idp.${region}.${urlSuffix}/${physicalId}`;
39510
+ const effectiveProperties = narrowMfaConfiguration(properties);
39147
39511
  return {
39148
39512
  physicalId,
39149
39513
  wasReplaced: false,
@@ -39152,7 +39516,8 @@ var CognitoUserPoolProvider = class {
39152
39516
  ProviderName: providerName,
39153
39517
  ProviderURL: providerUrl,
39154
39518
  UserPoolId: physicalId
39155
- }
39519
+ },
39520
+ ...effectiveProperties !== properties ? { effectiveProperties } : {}
39156
39521
  };
39157
39522
  } catch (error) {
39158
39523
  if (error instanceof ResourceUpdateNotSupportedError || error instanceof ProvisioningError) throw error;
@@ -80881,7 +81246,7 @@ function createMigrateCommand() {
80881
81246
  */
80882
81247
  function buildProgram() {
80883
81248
  const program = new Command();
80884
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.33");
81249
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.35");
80885
81250
  program.hook("preAction", (_thisCommand, actionCommand) => {
80886
81251
  const { profile } = actionCommand.optsWithGlobals();
80887
81252
  if (profile !== void 0) process.env["AWS_PROFILE"] = profile;