@go-to-k/cdkd 0.283.26 → 0.283.28
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/{asg-provider-CshsD-Pi.js → asg-provider-DzaowTd_.js} +2 -2
- package/dist/{asg-provider-CshsD-Pi.js.map → asg-provider-DzaowTd_.js.map} +1 -1
- package/dist/cli.js +868 -151
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-B6z9CCAL.js → deploy-engine-5rA6lMn8.js} +29 -21
- package/dist/deploy-engine-5rA6lMn8.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine-B6z9CCAL.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as coerceCfnBoolean, $t as
|
|
2
|
+
import { $ as coerceCfnBoolean, $t as resolveApp, A as cyan, An as LocalInvokeBuildError$1, At as createAssetRedirectResolver, B as findSilentDropProperties, Bn as StackHasActiveImportsError, Bt as validateContainerRepoName, C as makeCanonicalizePropertiesFn, Cn as resetAwsClients, Ct as S3StateBackend, D as renderStatefulReason, Dt as stringifyValue, E as isStatefulRecreateTargetSync, En as CdkdError, Et as AssetPublisher, F as IAMRoleProvider, Fn as NestedStackChildDirectDestroyError, Ft as BOOTSTRAP_MARKER_PREFIX, G as IntrinsicFunctionResolver, Gt as runDockerForeground, H as slowCcOperationTimeoutMs, Ht as buildDockerImage, I as collectInlinePolicyNamesManagedBySiblings, In as PartialFailureError, It as ensureAssetStorage, J as refStateLookupFromResource, Jn as isMarkedNonRetryable, Jt as getDockerImageBySourceHash, K as cfnRefValueFromPhysicalId, Kn as normalizeAwsError, Kt as runDockerStreaming, L as clearOnUpdateRemoval, Ln as ProvisioningError, Lt as getBootstrapMarkerKey, M as green, Mn as LocalStartServiceError, Mt as rewriteTemplateAssetReferences, N as red, Nt as escapeRegExp, O as formatResourceLine, Ot as WorkGraph, P as yellow, Pn as MissingCdkCliError, Pt as AssetModeResolver, Q as assertRegionMatch, Qt as getLegacyStateBucketName, R as ProviderRegistry, Rn as ResourceTimeoutError, Rt as parseBootstrapMarker, S as unsupportedFinalSnapshotError, Sn as getAwsClients, St as LockManager, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tt as shouldRetainResource, U as disableInstanceApiTermination, Ut as formatDockerLoginError, V as CloudControlProvider, Vn as StackTerminationProtectionError, Vt as buildDenyExternalAccessPolicy, W as isTerminationProtectionPropagationError, Wt as getDockerCmd, X as normalizeAwsTagsToCfn, Xn as isThrottlingError, Xt as synthesisStatusMessage, Y as WAFv2WebACLProvider, Yn as isRetryableTransientError, Yt as Synthesizer, Z as resolveExplicitPhysicalId, Zn as markNonRetryable, Zt as getDefaultStateBucketName, _ as buildFinalSnapshotIdentifier, _n as AssemblyReader, _t as INTRINSIC_KEYS, a as DeploymentEventsStore, an as resolveUseCdkBootstrapAssets, at as requireConfigObject, b as isFinalSnapshotError, bn as resolveBucketRegion, bt as DagBuilder, c as replayFailedOperations, cn as CFN_TEMPLATE_BODY_LIMIT, ct as redactSecretsForState, d as deleteSkipReason, dn as findLargeInlineResources, dt as s3BucketDomainName, en as resolveAutoAssetStorage, et as configBooleanRefusal, f as withResourceDeadline, fn as uploadCfnTemplate, ft as s3BucketDualStackDomainName, g as PRE_DELETE_SNAPSHOT_TYPES, gn as derivePartitionAndUrlSuffix, gt as DiffCalculator, h as ATOMIC_FINAL_SNAPSHOT_TYPES, hn as canonicalizeRegion, ht as applyRoleArnIfSet, i as DeploymentEventsReader, in as resolveStateBucketWithDefaultAndSource, it as requireConfigArray, j as gray, jn as LocalMigrateError, jt as loadPublishableAssetManifest, k as bold, kn as DeployCancelledError, kt as buildAssetRedirectMap, l as replayRollback, ln as CFN_TEMPLATE_URL_LIMIT, lt as scrubResourceRecord, m as computeImplicitDeleteEdges, mn as PARTITION_TABLE, mt as s3BucketWebsiteUrl, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as resolveSkipPrefix, nt as readConfigString, o as planFailedOps, on as stateBucketExistenceConfirmed, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as expectedOwnerParam, pt as s3BucketRegionalDomainName, q as getAccountInfo, qn as withErrorHandling, qt as AssetManifestLoader, r as DeployEngine, rn as resolveStateBucketWithDefault, rt as replayWarn, s as planRollback, sn as warnDeprecatedNoPrefixCliFlag, st as TEMPLATE_SOURCED_READBACK_RULES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveCaptureObservedState, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as MIGRATE_TMP_PREFIX, ut as s3BucketArn, v as ccRoutedFinalSnapshotError, vn as processStackMessages, vt as describeTypeWithThrottleRetry, w as extractDeploymentEventError, wn as setAwsClients, wt as rebuildClientForBucketRegion, x as refusesFinalSnapshot, xn as AwsClients, xt as TemplateParser, y as createPreDeleteFinalSnapshot, yt as withRetry, z as findActionableSilentDrops, zn as ResourceUpdateNotSupportedError, zt as validateAssetBucketName } from "./deploy-engine-5rA6lMn8.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-
|
|
4
|
+
import { t as ASGProvider } from "./asg-provider-DzaowTd_.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";
|
|
@@ -13989,6 +13989,209 @@ function isRecord$1(value) {
|
|
|
13989
13989
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13990
13990
|
}
|
|
13991
13991
|
|
|
13992
|
+
//#endregion
|
|
13993
|
+
//#region src/provisioning/dynamodb-warm-throughput.ts
|
|
13994
|
+
/**
|
|
13995
|
+
* The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
|
|
13996
|
+
* `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
|
|
13997
|
+
*
|
|
13998
|
+
* `WarmThroughput` is the same CloudFormation block on both types, and both
|
|
13999
|
+
* providers have to answer the same two questions about it before an
|
|
14000
|
+
* `UpdateTable` / `CreateTable` goes out:
|
|
14001
|
+
*
|
|
14002
|
+
* 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
|
|
14003
|
+
* stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
|
|
14004
|
+
* came back from an `Fn::Sub` — arrives as a STRING and must not be
|
|
14005
|
+
* forwarded verbatim into a numeric `Long` field.
|
|
14006
|
+
* 2. **Would sending it LOWER what AWS already reports?** Warm throughput
|
|
14007
|
+
* only ever rises with a table's traffic and AWS REJECTS a call that
|
|
14008
|
+
* lowers it (`decreasing WarmThroughput is not supported`, measured live
|
|
14009
|
+
* us-east-1 2026-08-13 for issue #1768).
|
|
14010
|
+
*
|
|
14011
|
+
* **Provenance, stated precisely because it is easy to over-claim.** Exactly
|
|
14012
|
+
* ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
|
|
14013
|
+
* (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
|
|
14014
|
+
* existed outside the change that created this file (issue #1857). So this is
|
|
14015
|
+
* the Table rule LIFTED — not two shipped rules reconciled, and no deployed
|
|
14016
|
+
* behaviour changed for either type when it moved here.
|
|
14017
|
+
*
|
|
14018
|
+
* What WAS compared is the lifted rule against the GlobalTable spelling
|
|
14019
|
+
* drafted alongside it, over the shapes both had to answer (a quoted numeric
|
|
14020
|
+
* string, a partially usable block, a mixed decrease/increase, an absent live
|
|
14021
|
+
* value, a zero, a negative, a non-numeric string, a whitespace string, an
|
|
14022
|
+
* explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
|
|
14023
|
+
* array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
|
|
14024
|
+
* agreed on all but a whitespace-only string, where a bare `Number(' ')` is
|
|
14025
|
+
* `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
|
|
14026
|
+
* since `' '` is not a capacity anyone declared and a warm throughput of 0
|
|
14027
|
+
* is not a request AWS can honour. That is a DRAFT reconciled against a
|
|
14028
|
+
* shipped rule, which is worth less than two shipped rules agreeing — the
|
|
14029
|
+
* reason to read the probe list as a design note rather than as field
|
|
14030
|
+
* evidence.
|
|
14031
|
+
*
|
|
14032
|
+
* Living here rather than in either provider is the point. A decrease guard is
|
|
14033
|
+
* three clauses that all FAIL OPEN for different reasons, and two files
|
|
14034
|
+
* spelling it independently is two chances for a later "fix" to change one of
|
|
14035
|
+
* them — at which point the sibling type silently keeps the old answer, and
|
|
14036
|
+
* nothing in the tree says which is intended. Same class as
|
|
14037
|
+
* `emr-configuration.ts`, and the same reason.
|
|
14038
|
+
*
|
|
14039
|
+
* Deliberately NOT here: everything only ONE provider has. The `Table` side's
|
|
14040
|
+
* `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
|
|
14041
|
+
* `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
|
|
14042
|
+
* and already-matches gates, which `GlobalTable` has no counterpart to (issue
|
|
14043
|
+
* #1742 strips the per-index `WarmThroughput` from BOTH of its drift
|
|
14044
|
+
* comparison sides unconditionally, so drift never asks the question there);
|
|
14045
|
+
* the `GlobalTable` side's `warmThroughputDiagnostic` builds a
|
|
14046
|
+
* `ThroughputDiagnostic` that only that provider's collector understands.
|
|
14047
|
+
* Moving a helper with one caller here would buy nothing and cost a hop.
|
|
14048
|
+
*
|
|
14049
|
+
* Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
|
|
14050
|
+
*/
|
|
14051
|
+
/**
|
|
14052
|
+
* The two `WarmThroughput` members, in the ONE order every message, every
|
|
14053
|
+
* comparison and every emitted block uses. A shared order is what makes two
|
|
14054
|
+
* blocks carrying the same numbers compare equal by `deepEqual` and serialize
|
|
14055
|
+
* to the same wire bytes regardless of the order the template wrote them in.
|
|
14056
|
+
*/
|
|
14057
|
+
const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
|
|
14058
|
+
/**
|
|
14059
|
+
* A CloudFormation-borne numeric property, or `undefined` when the value is
|
|
14060
|
+
* not a usable number.
|
|
14061
|
+
*
|
|
14062
|
+
* Plain `Number()` coercion is NOT good enough and the tree learned it three
|
|
14063
|
+
* separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
|
|
14064
|
+
* and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
|
|
14065
|
+
* EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
|
|
14066
|
+
* string would be forwarded as a request for zero units.
|
|
14067
|
+
*
|
|
14068
|
+
* A YAML-borne numeric STRING is still accepted, because that is a real
|
|
14069
|
+
* template shape and `'12000'` genuinely means 12000.
|
|
14070
|
+
*
|
|
14071
|
+
* EXPORTED, and not because warm throughput needs it exported. This exact rule
|
|
14072
|
+
* was hand-written three times — here, as `dynamodb-table-provider.ts`'s
|
|
14073
|
+
* `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
|
|
14074
|
+
* `toFiniteNumber` (a different spelling of the same total function) — which is
|
|
14075
|
+
* precisely the divergence this module exists to stop: the next "fix" to one of
|
|
14076
|
+
* them would have left the other two answering differently, with nothing in the
|
|
14077
|
+
* tree saying which was intended. It reads `ReadCapacityUnits` /
|
|
14078
|
+
* `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
|
|
14079
|
+
* `ReadUnitsPerSecond`; there is only ever one right answer for "is this
|
|
14080
|
+
* stringly-typed CFn value a number I can send?".
|
|
14081
|
+
*/
|
|
14082
|
+
function toFiniteNumber(value) {
|
|
14083
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
14084
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
14085
|
+
const n = Number(value);
|
|
14086
|
+
return Number.isFinite(n) ? n : void 0;
|
|
14087
|
+
}
|
|
14088
|
+
}
|
|
14089
|
+
/**
|
|
14090
|
+
* Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
|
|
14091
|
+
* fields accept, PER MEMBER.
|
|
14092
|
+
*
|
|
14093
|
+
* PER MEMBER, not whole-block, and that distinction is the point: a block
|
|
14094
|
+
* whose write half is an unresolved intrinsic still has a perfectly good read
|
|
14095
|
+
* half, and dropping both would silently discard a value the template really
|
|
14096
|
+
* did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
|
|
14097
|
+
* warning can say which half went missing instead of reporting the whole
|
|
14098
|
+
* property.
|
|
14099
|
+
*
|
|
14100
|
+
* A block with NO usable member yields `spec: undefined` — refused rather than
|
|
14101
|
+
* forwarded, because forwarding a malformed block surfaces as an opaque AWS
|
|
14102
|
+
* validation error naming neither cdkd nor the property. `droppedMembers` is
|
|
14103
|
+
* still populated in that case, so a REFUSAL message can name what it refused;
|
|
14104
|
+
* a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
|
|
14105
|
+
* array) has no member to name and reports none, which is what lets a caller
|
|
14106
|
+
* word "one half went missing" differently from "the block is unusable".
|
|
14107
|
+
*
|
|
14108
|
+
* Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
|
|
14109
|
+
* own message, so the wording stays consistent across that provider's send
|
|
14110
|
+
* sites without forcing one wording across both providers.
|
|
14111
|
+
*/
|
|
14112
|
+
function coerceWarmThroughput$1(raw) {
|
|
14113
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
|
|
14114
|
+
const bag = raw;
|
|
14115
|
+
const spec = {};
|
|
14116
|
+
const droppedMembers = [];
|
|
14117
|
+
for (const member of WARM_THROUGHPUT_MEMBERS) {
|
|
14118
|
+
if (bag[member] === void 0) continue;
|
|
14119
|
+
const coerced = toFiniteNumber(bag[member]);
|
|
14120
|
+
if (coerced === void 0) {
|
|
14121
|
+
droppedMembers.push(member);
|
|
14122
|
+
continue;
|
|
14123
|
+
}
|
|
14124
|
+
spec[member] = coerced;
|
|
14125
|
+
}
|
|
14126
|
+
if (Object.keys(spec).length === 0) return { droppedMembers };
|
|
14127
|
+
return {
|
|
14128
|
+
spec,
|
|
14129
|
+
droppedMembers
|
|
14130
|
+
};
|
|
14131
|
+
}
|
|
14132
|
+
/**
|
|
14133
|
+
* Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
|
|
14134
|
+
* reports.
|
|
14135
|
+
*
|
|
14136
|
+
* Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
|
|
14137
|
+
* reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
|
|
14138
|
+
*
|
|
14139
|
+
* ```
|
|
14140
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
|
|
14141
|
+
* ValidationException: One or more parameter values were invalid: Requested
|
|
14142
|
+
* ReadUnitsPerSecond for WarmThroughput for table is lower than current
|
|
14143
|
+
* WarmThroughput, decreasing WarmThroughput is not supported
|
|
14144
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
|
|
14145
|
+
* UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
|
|
14146
|
+
* UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
|
|
14147
|
+
* ```
|
|
14148
|
+
*
|
|
14149
|
+
* So the value is one AWS raises with the table's traffic and never lowers,
|
|
14150
|
+
* and a decrease is REJECTED rather than accepted-and-ignored — which is what
|
|
14151
|
+
* had to be measured before choosing, because the two produce different
|
|
14152
|
+
* correct answers. The caller SKIPS the call on a true here.
|
|
14153
|
+
*
|
|
14154
|
+
* Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
|
|
14155
|
+
* makes the verdict describe a request that is not the one being sent (a
|
|
14156
|
+
* dropped member is not part of the call and must not be part of the
|
|
14157
|
+
* comparison).
|
|
14158
|
+
*
|
|
14159
|
+
* Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
|
|
14160
|
+
* leave AWS as the authority — because a false positive here silently drops a
|
|
14161
|
+
* legitimate INCREASE, which is a real capacity change the user asked for,
|
|
14162
|
+
* while a false negative merely reproduces the pre-fix behaviour of an
|
|
14163
|
+
* AWS-side rejection that names the property:
|
|
14164
|
+
* - DECLARED members only. An absent member is not a request to lower
|
|
14165
|
+
* anything, so it takes no part in the verdict.
|
|
14166
|
+
* - MIXED is not a decrease. One member below live and the other above means
|
|
14167
|
+
* the call carries a genuine increase; AWS decides.
|
|
14168
|
+
* - An absent or unusable LIVE counterpart is not a decrease. Without a
|
|
14169
|
+
* number to compare against there is no evidence of one. Only the LIVE side
|
|
14170
|
+
* can reach that arm: the desired side is a COERCED spec, so a malformed
|
|
14171
|
+
* template value has already been dropped by {@link coerceWarmThroughput}.
|
|
14172
|
+
*
|
|
14173
|
+
* A decrease therefore requires every declared member to be at-or-below live
|
|
14174
|
+
* AND at least one to be strictly below.
|
|
14175
|
+
*
|
|
14176
|
+
* The skip this drives is right for EVERY `update()` caller — the deploy
|
|
14177
|
+
* engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
|
|
14178
|
+
* because none of them can make AWS lower the value, so none loses anything a
|
|
14179
|
+
* doomed call would have achieved.
|
|
14180
|
+
*/
|
|
14181
|
+
function isWarmThroughputDecrease(desired, live) {
|
|
14182
|
+
if (desired === void 0 || live === void 0) return false;
|
|
14183
|
+
let sawDecrease = false;
|
|
14184
|
+
for (const member of WARM_THROUGHPUT_MEMBERS) {
|
|
14185
|
+
if (desired[member] === void 0) continue;
|
|
14186
|
+
const wanted = toFiniteNumber(desired[member]);
|
|
14187
|
+
const current = toFiniteNumber(live[member]);
|
|
14188
|
+
if (wanted === void 0 || current === void 0) return false;
|
|
14189
|
+
if (wanted > current) return false;
|
|
14190
|
+
if (wanted < current) sawDecrease = true;
|
|
14191
|
+
}
|
|
14192
|
+
return sawDecrease;
|
|
14193
|
+
}
|
|
14194
|
+
|
|
13992
14195
|
//#endregion
|
|
13993
14196
|
//#region src/provisioning/providers/dynamodb-table-provider.ts
|
|
13994
14197
|
/**
|
|
@@ -14091,28 +14294,6 @@ function hasUsableTableCapacity(value) {
|
|
|
14091
14294
|
return true;
|
|
14092
14295
|
}
|
|
14093
14296
|
/**
|
|
14094
|
-
* A capacity member, or `undefined` when the value is not a usable number.
|
|
14095
|
-
*
|
|
14096
|
-
* `Number()` coercion is NOT good enough here and the first draft of this
|
|
14097
|
-
* helper shipped that bug: `Number(null)`, `Number('')`, `Number([])` and
|
|
14098
|
-
* `Number(false)` are all **0**, not `NaN` — so a live `0` would have compared
|
|
14099
|
-
* EQUAL to a desired `null` / `''` / `[]` / `false` and suppressed the call.
|
|
14100
|
-
* The blast radius on DynamoDB is small (a PROVISIONED index's capacity is
|
|
14101
|
-
* always >= 1), but the rule this encodes is copied to other providers, so it
|
|
14102
|
-
* is spelled correctly rather than relying on the type that happens to be
|
|
14103
|
-
* unreachable today.
|
|
14104
|
-
*
|
|
14105
|
-
* A YAML-borne numeric STRING is still accepted, because that is a real
|
|
14106
|
-
* template shape and `'5'` genuinely means 5.
|
|
14107
|
-
*/
|
|
14108
|
-
function capacityNumber(value) {
|
|
14109
|
-
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
14110
|
-
if (typeof value === "string" && value.trim() !== "") {
|
|
14111
|
-
const n = Number(value);
|
|
14112
|
-
return Number.isFinite(n) ? n : void 0;
|
|
14113
|
-
}
|
|
14114
|
-
}
|
|
14115
|
-
/**
|
|
14116
14297
|
* Does the capacity AWS currently holds for a GSI already equal the pair the
|
|
14117
14298
|
* template requests? (issue #1630)
|
|
14118
14299
|
*
|
|
@@ -14131,10 +14312,10 @@ function capacityNumber(value) {
|
|
|
14131
14312
|
*/
|
|
14132
14313
|
function liveCapacityAlreadyMatches(live, requested) {
|
|
14133
14314
|
if (live === void 0) return false;
|
|
14134
|
-
const liveRead =
|
|
14135
|
-
const liveWrite =
|
|
14136
|
-
const wantRead =
|
|
14137
|
-
const wantWrite =
|
|
14315
|
+
const liveRead = toFiniteNumber(live.ReadCapacityUnits);
|
|
14316
|
+
const liveWrite = toFiniteNumber(live.WriteCapacityUnits);
|
|
14317
|
+
const wantRead = toFiniteNumber(requested.ReadCapacityUnits);
|
|
14318
|
+
const wantWrite = toFiniteNumber(requested.WriteCapacityUnits);
|
|
14138
14319
|
if (liveRead === void 0 || liveWrite === void 0) return false;
|
|
14139
14320
|
if (wantRead === void 0 || wantWrite === void 0) return false;
|
|
14140
14321
|
return liveRead === wantRead && liveWrite === wantWrite;
|
|
@@ -14182,8 +14363,10 @@ const BILLING_FLIP_ACTIVE_WAIT_ATTEMPTS = 600;
|
|
|
14182
14363
|
* Still not `!== undefined`, and still ONE spelling for the write sites and the
|
|
14183
14364
|
* drift side both: a value this refuses is a value cdkd does not send, so the
|
|
14184
14365
|
* readback must not emit AWS's computed value as though the template had asked
|
|
14185
|
-
* for it. A YAML-borne numeric STRING is accepted,
|
|
14186
|
-
*
|
|
14366
|
+
* for it. A YAML-borne numeric STRING is accepted, by the shared coercion's own
|
|
14367
|
+
* number reader ({@link toFiniteNumber}, in `../dynamodb-warm-throughput.ts` —
|
|
14368
|
+
* the one this file's capacity paths read too), because `'12000'` genuinely
|
|
14369
|
+
* means 12000.
|
|
14187
14370
|
*/
|
|
14188
14371
|
function isSendableWarmThroughput(value) {
|
|
14189
14372
|
return coerceWarmThroughput(value) !== void 0;
|
|
@@ -14204,8 +14387,9 @@ function isSendableWarmThroughput(value) {
|
|
|
14204
14387
|
* over), and it makes the quoted form WORK instead of merely warning.
|
|
14205
14388
|
*
|
|
14206
14389
|
* Per MEMBER, so a bad one cannot take a good one with it, and never `NaN`:
|
|
14207
|
-
* {@link
|
|
14208
|
-
* everything else
|
|
14390
|
+
* the shared coercion's number reader is {@link toFiniteNumber} — a number or
|
|
14391
|
+
* a numeric string, everything else rejected — so a member that
|
|
14392
|
+
* does not resolve is OMITTED from the spec
|
|
14209
14393
|
* and reported in `dropped` for the caller to announce. AWS accepts a
|
|
14210
14394
|
* one-member `WarmThroughput` (measured us-east-1, 2026-08-13: an
|
|
14211
14395
|
* `UpdateTable` carrying only `ReadUnitsPerSecond` is a valid request shape —
|
|
@@ -14244,27 +14428,22 @@ function isSendableWarmThroughput(value) {
|
|
|
14244
14428
|
* the comparator's `deepEqual` is strict about that. It is the generic
|
|
14245
14429
|
* stringly-typed-CFn class (every numeric property has it), not something this
|
|
14246
14430
|
* coercion can reach from the write side.
|
|
14431
|
+
*
|
|
14432
|
+
* The RULE moved to `../dynamodb-warm-throughput.ts` when
|
|
14433
|
+
* `AWS::DynamoDB::GlobalTable` needed the same one (issue #1857); this is the
|
|
14434
|
+
* local adapter onto the shape this file's four call sites already read, so
|
|
14435
|
+
* the behaviour, the signature and the tests are unchanged. The shared
|
|
14436
|
+
* coercion always reports `droppedMembers`, including on a total refusal —
|
|
14437
|
+
* that arm is unreachable from here, since a `usable === 0` outcome maps to
|
|
14438
|
+
* `undefined` and the names go with it, which is exactly what this file did
|
|
14439
|
+
* before.
|
|
14247
14440
|
*/
|
|
14248
14441
|
function coerceWarmThroughput(value) {
|
|
14249
|
-
|
|
14250
|
-
|
|
14251
|
-
const spec = {};
|
|
14252
|
-
const dropped = [];
|
|
14253
|
-
let usable = 0;
|
|
14254
|
-
for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
|
|
14255
|
-
if (bag[member] === void 0) continue;
|
|
14256
|
-
const n = capacityNumber(bag[member]);
|
|
14257
|
-
if (n === void 0) {
|
|
14258
|
-
dropped.push(member);
|
|
14259
|
-
continue;
|
|
14260
|
-
}
|
|
14261
|
-
spec[member] = n;
|
|
14262
|
-
usable++;
|
|
14263
|
-
}
|
|
14264
|
-
if (usable === 0) return void 0;
|
|
14442
|
+
const { spec, droppedMembers } = coerceWarmThroughput$1(value);
|
|
14443
|
+
if (spec === void 0) return void 0;
|
|
14265
14444
|
return {
|
|
14266
14445
|
spec,
|
|
14267
|
-
dropped
|
|
14446
|
+
dropped: [...droppedMembers]
|
|
14268
14447
|
};
|
|
14269
14448
|
}
|
|
14270
14449
|
/**
|
|
@@ -14337,60 +14516,6 @@ function declaresWarmThroughput(properties) {
|
|
|
14337
14516
|
return properties !== void 0 && isSendableWarmThroughput(properties["WarmThroughput"]);
|
|
14338
14517
|
}
|
|
14339
14518
|
/**
|
|
14340
|
-
* Is the WarmThroughput this update would SEND a DECREASE of what AWS already
|
|
14341
|
-
* holds (issue #1768)?
|
|
14342
|
-
*
|
|
14343
|
-
* Measured live (us-east-1, 2026-08-13) against a table AWS reports
|
|
14344
|
-
* `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
|
|
14345
|
-
*
|
|
14346
|
-
* ```
|
|
14347
|
-
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
|
|
14348
|
-
* ValidationException: One or more parameter values were invalid: Requested
|
|
14349
|
-
* ReadUnitsPerSecond for WarmThroughput for table is lower than current
|
|
14350
|
-
* WarmThroughput, decreasing WarmThroughput is not supported
|
|
14351
|
-
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
|
|
14352
|
-
* UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
|
|
14353
|
-
* UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
|
|
14354
|
-
* ```
|
|
14355
|
-
*
|
|
14356
|
-
* So the value is one AWS raises with the table's traffic and never lowers,
|
|
14357
|
-
* and a decrease is REJECTED rather than accepted-and-ignored — which is what
|
|
14358
|
-
* the issue said to measure before choosing, because the two produce different
|
|
14359
|
-
* correct answers. The caller SKIPS the call on a true here.
|
|
14360
|
-
*
|
|
14361
|
-
* Rules, each one chosen so the skip is right for EVERY `update()` caller
|
|
14362
|
-
* (the deploy engine, `cdkd drift --revert`, and the rollback executor's two
|
|
14363
|
-
* revert arms — none of which can make AWS lower the value, so none of them
|
|
14364
|
-
* loses anything a doomed call would have achieved):
|
|
14365
|
-
*
|
|
14366
|
-
* - Only the members the desired side DECLARES are examined; an omitted member
|
|
14367
|
-
* is not sent and cannot be a decrease.
|
|
14368
|
-
* - EVERY declared member must be at-or-below its live counterpart AND at
|
|
14369
|
-
* least one strictly below. A MIXED request (one member up, one down) still
|
|
14370
|
-
* goes out: AWS rejects it by name, which is the pre-fix behavior and better
|
|
14371
|
-
* than silently dropping the increase the user asked for.
|
|
14372
|
-
* - Anything not resolvable to a finite number fails OPEN (the call is still
|
|
14373
|
-
* issued): the worst case is the pre-fix behavior, whereas a false positive
|
|
14374
|
-
* would silently drop a real increase. Only the LIVE side can actually reach
|
|
14375
|
-
* that arm now (PR review round 8): the desired side is a COERCED spec, so a
|
|
14376
|
-
* malformed template value or an unresolved intrinsic has already been
|
|
14377
|
-
* dropped by {@link coerceWarmThroughput} and cannot arrive here. The check
|
|
14378
|
-
* is kept for the live side, which is whatever `DescribeTable` returned.
|
|
14379
|
-
*/
|
|
14380
|
-
function isWarmThroughputDecrease(desired, live) {
|
|
14381
|
-
if (live === void 0) return false;
|
|
14382
|
-
let sawDecrease = false;
|
|
14383
|
-
for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
|
|
14384
|
-
if (desired[member] === void 0) continue;
|
|
14385
|
-
const wanted = capacityNumber(desired[member]);
|
|
14386
|
-
const current = capacityNumber(live[member]);
|
|
14387
|
-
if (wanted === void 0 || current === void 0) return false;
|
|
14388
|
-
if (wanted > current) return false;
|
|
14389
|
-
if (wanted < current) sawDecrease = true;
|
|
14390
|
-
}
|
|
14391
|
-
return sawDecrease;
|
|
14392
|
-
}
|
|
14393
|
-
/**
|
|
14394
14519
|
* Does the warm throughput AWS currently holds already equal what this update
|
|
14395
14520
|
* would request (issue #1768 PR review)?
|
|
14396
14521
|
*
|
|
@@ -14419,10 +14544,10 @@ function isWarmThroughputDecrease(desired, live) {
|
|
|
14419
14544
|
function warmThroughputAlreadyMatches(desired, live) {
|
|
14420
14545
|
if (live === void 0) return false;
|
|
14421
14546
|
let compared = 0;
|
|
14422
|
-
for (const member of
|
|
14547
|
+
for (const member of WARM_THROUGHPUT_MEMBERS) {
|
|
14423
14548
|
if (desired[member] === void 0) continue;
|
|
14424
|
-
const wanted =
|
|
14425
|
-
const current =
|
|
14549
|
+
const wanted = toFiniteNumber(desired[member]);
|
|
14550
|
+
const current = toFiniteNumber(live[member]);
|
|
14426
14551
|
if (wanted === void 0 || current === void 0) return false;
|
|
14427
14552
|
if (wanted !== current) return false;
|
|
14428
14553
|
compared++;
|
|
@@ -15529,7 +15654,7 @@ var DynamoDBTableProvider = class {
|
|
|
15529
15654
|
*/
|
|
15530
15655
|
skipZeroCapacityIndexUpdate(indexName, physicalId, requested) {
|
|
15531
15656
|
if (requested === void 0) return false;
|
|
15532
|
-
if (
|
|
15657
|
+
if (toFiniteNumber(requested.ReadCapacityUnits) !== 0 || toFiniteNumber(requested.WriteCapacityUnits) !== 0) return false;
|
|
15533
15658
|
this.logger.warn(`GSI ${indexName} on DynamoDB table ${physicalId}: the requested ProvisionedThroughput is {ReadCapacityUnits: 0, WriteCapacityUnits: 0}, which is AWS's on-demand placeholder rather than a capacity DynamoDB accepts (the minimum is 1), so no per-index throughput update was sent. This usually means the value came from a pre-#1767 cdkd state record — a \`cdkd drift --revert\` of such a record, or a rollback replaying it. Re-run \`cdkd deploy\` (or \`cdkd drift --accept\`) to refresh the record.`);
|
|
15534
15659
|
return true;
|
|
15535
15660
|
}
|
|
@@ -16425,6 +16550,35 @@ var DynamoDBGlobalTableProvider = class {
|
|
|
16425
16550
|
}
|
|
16426
16551
|
}
|
|
16427
16552
|
/**
|
|
16553
|
+
* The `WarmThroughput` block to put on ONE `GlobalSecondaryIndexUpdate`, or
|
|
16554
|
+
* `undefined` when it must not be sent (issue #1857).
|
|
16555
|
+
*
|
|
16556
|
+
* ONE helper for all three `UpdateTable` send sites — the billing-flip
|
|
16557
|
+
* ride-along, the `added` loop's `Create`, and the `modified` loop's
|
|
16558
|
+
* `Update` — because three copies of a decrease guard is three chances to
|
|
16559
|
+
* word one differently, and the whole point of {@link isWarmThroughputDecrease}
|
|
16560
|
+
* is that the answer does not depend on which site asked.
|
|
16561
|
+
*
|
|
16562
|
+
* The `desired` argument is already COERCED (`toSdkGlobalSecondaryIndexes`
|
|
16563
|
+
* did it), so an unusable block never reaches here at all: it was refused
|
|
16564
|
+
* and reported during translation, and arrives as `undefined`. What is left
|
|
16565
|
+
* for this helper is the one question translation cannot answer, because it
|
|
16566
|
+
* needs live AWS state: would this call LOWER what AWS reports?
|
|
16567
|
+
*
|
|
16568
|
+
* The refusal is a WARN and a dropped member, not a throw. A decrease is a
|
|
16569
|
+
* template that has fallen behind a value AWS grew on its own; failing the
|
|
16570
|
+
* deploy over it would block every unrelated change to the same table, and
|
|
16571
|
+
* there is no template edit that "fixes" it other than raising a number the
|
|
16572
|
+
* user never chose.
|
|
16573
|
+
*/
|
|
16574
|
+
warmThroughputForSend(indexName, desired, liveWarmByIndexName, logicalId, physicalId) {
|
|
16575
|
+
if (!desired) return void 0;
|
|
16576
|
+
const live = liveWarmByIndexName.get(indexName);
|
|
16577
|
+
if (!isWarmThroughputDecrease(desired, live)) return desired;
|
|
16578
|
+
const format = (spec) => WARM_THROUGHPUT_MEMBERS.filter((m) => spec?.[m] !== void 0).map((m) => `${m}=${spec?.[m]}`).join(", ");
|
|
16579
|
+
this.logger.warn(`DynamoDB GlobalTable ${logicalId}: GSI '${indexName}' on ${physicalId}: WarmThroughput was NOT sent — the template asks for ${format(desired)} while AWS reports ${format(live)}, and DynamoDB rejects a decrease ("decreasing WarmThroughput is not supported"). Warm throughput only rises with a table's traffic, so a template below the live value can never be applied; raise it to at least the live value, or remove it. Every other change to this index is applied normally.`);
|
|
16580
|
+
}
|
|
16581
|
+
/**
|
|
16428
16582
|
* Add a single replica region. Issues one `UpdateTableCommand` with
|
|
16429
16583
|
* `ReplicaUpdates: [{ Create: { RegionName, ... } }]` and polls
|
|
16430
16584
|
* `DescribeTable` until the replica's `ReplicaStatus` flips to ACTIVE.
|
|
@@ -16510,6 +16664,16 @@ var DynamoDBGlobalTableProvider = class {
|
|
|
16510
16664
|
return (props["Replicas"] ?? []).find((r) => r["Region"] === currentRegion)?.["Tags"];
|
|
16511
16665
|
};
|
|
16512
16666
|
if (tableArn) await this.applyTagDiff(tableArn, extractLocalTags(previousProperties), extractLocalTags(properties));
|
|
16667
|
+
const liveWarmByIndexName = /* @__PURE__ */ new Map();
|
|
16668
|
+
for (const liveGsi of describeResp.Table?.GlobalSecondaryIndexes ?? []) {
|
|
16669
|
+
const name = liveGsi.IndexName;
|
|
16670
|
+
const warm = liveGsi.WarmThroughput;
|
|
16671
|
+
if (typeof name !== "string" || !warm) continue;
|
|
16672
|
+
const spec = {};
|
|
16673
|
+
if (warm.ReadUnitsPerSecond !== void 0) spec.ReadUnitsPerSecond = warm.ReadUnitsPerSecond;
|
|
16674
|
+
if (warm.WriteUnitsPerSecond !== void 0) spec.WriteUnitsPerSecond = warm.WriteUnitsPerSecond;
|
|
16675
|
+
if (Object.keys(spec).length > 0) liveWarmByIndexName.set(name, spec);
|
|
16676
|
+
}
|
|
16513
16677
|
const recordedOldBilling = previousProperties["BillingMode"];
|
|
16514
16678
|
const recordedOldBillingAbsent = recordedOldBilling === void 0;
|
|
16515
16679
|
const recordedOldBillingUsable = typeof recordedOldBilling === "string" && recordedOldBilling.trim() !== "";
|
|
@@ -16659,11 +16823,11 @@ var DynamoDBGlobalTableProvider = class {
|
|
|
16659
16823
|
const gsi = byIndexName.get(indexName);
|
|
16660
16824
|
if (!gsi?.ProvisionedThroughput) continue;
|
|
16661
16825
|
const previousWarm = previousByIndexName.get(indexName)?.WarmThroughput;
|
|
16662
|
-
const
|
|
16826
|
+
const warmToSend = gsi.WarmThroughput !== void 0 && !deepEqual$1(gsi.WarmThroughput, previousWarm) ? this.warmThroughputForSend(indexName, gsi.WarmThroughput, liveWarmByIndexName, logicalId, physicalId) : void 0;
|
|
16663
16827
|
indexUpdates.push({ Update: {
|
|
16664
16828
|
IndexName: indexName,
|
|
16665
16829
|
ProvisionedThroughput: gsi.ProvisionedThroughput,
|
|
16666
|
-
...
|
|
16830
|
+
...warmToSend && { WarmThroughput: warmToSend }
|
|
16667
16831
|
} });
|
|
16668
16832
|
if (survivingIndexNames.has(indexName)) gsiHandledByBillingFlip.add(indexName);
|
|
16669
16833
|
}
|
|
@@ -16798,13 +16962,14 @@ var DynamoDBGlobalTableProvider = class {
|
|
|
16798
16962
|
}
|
|
16799
16963
|
for (const gsi of gsiDiff.added) {
|
|
16800
16964
|
if (!gsi.IndexName || !gsi.KeySchema || !gsi.Projection) continue;
|
|
16965
|
+
const addedWarmToSend = this.warmThroughputForSend(gsi.IndexName, gsi.WarmThroughput, liveWarmByIndexName, logicalId, physicalId);
|
|
16801
16966
|
const gsiUpdate = { Create: {
|
|
16802
16967
|
IndexName: gsi.IndexName,
|
|
16803
16968
|
KeySchema: gsi.KeySchema,
|
|
16804
16969
|
Projection: gsi.Projection,
|
|
16805
16970
|
...gsi.ProvisionedThroughput && { ProvisionedThroughput: gsi.ProvisionedThroughput },
|
|
16806
16971
|
...gsi.OnDemandThroughput && { OnDemandThroughput: gsi.OnDemandThroughput },
|
|
16807
|
-
...
|
|
16972
|
+
...addedWarmToSend && { WarmThroughput: addedWarmToSend }
|
|
16808
16973
|
} };
|
|
16809
16974
|
await this.dynamoDBClient.send(new UpdateTableCommand({
|
|
16810
16975
|
TableName: physicalId,
|
|
@@ -16831,9 +16996,14 @@ var DynamoDBGlobalTableProvider = class {
|
|
|
16831
16996
|
if (Object.keys(onDemand).length > 0) update.OnDemandThroughput = onDemand;
|
|
16832
16997
|
if (!billingFlipped && gsi.ProvisionedThroughput) update.ProvisionedThroughput = gsi.ProvisionedThroughput;
|
|
16833
16998
|
const previousSdk = previousSdkByName.get(gsi.IndexName);
|
|
16834
|
-
|
|
16999
|
+
let warmDecreaseRefused = false;
|
|
17000
|
+
if (gsi.WarmThroughput !== void 0 && !deepEqual$1(gsi.WarmThroughput, previousSdk?.WarmThroughput)) {
|
|
17001
|
+
const warmToSend = this.warmThroughputForSend(gsi.IndexName, gsi.WarmThroughput, liveWarmByIndexName, logicalId, physicalId);
|
|
17002
|
+
if (warmToSend) update.WarmThroughput = warmToSend;
|
|
17003
|
+
else warmDecreaseRefused = true;
|
|
17004
|
+
}
|
|
16835
17005
|
if (!update.ProvisionedThroughput && !update.OnDemandThroughput && !update.WarmThroughput) {
|
|
16836
|
-
if (!billingFlipped && unresolvedMembers.length === 0) this.logger.warn(`GSI '${gsi.IndexName}' on ${physicalId} changed in a way DynamoDB's UpdateTable cannot express (KeySchema / Projection are immutable on an existing index). Recreate the index under a new name to apply the change.`);
|
|
17006
|
+
if (!billingFlipped && unresolvedMembers.length === 0 && !warmDecreaseRefused) this.logger.warn(`GSI '${gsi.IndexName}' on ${physicalId} changed in a way DynamoDB's UpdateTable cannot express (KeySchema / Projection are immutable on an existing index). Recreate the index under a new name to apply the change.`);
|
|
16837
17007
|
continue;
|
|
16838
17008
|
}
|
|
16839
17009
|
const gsiUpdate = { Update: update };
|
|
@@ -17310,9 +17480,27 @@ var DynamoDBGlobalTableProvider = class {
|
|
|
17310
17480
|
try {
|
|
17311
17481
|
if (hasTransitionalIndex(preDeleteIndexes)) {
|
|
17312
17482
|
this.logger.debug(`Waiting for indexes on ${physicalId} to settle before DeleteTable (issue #1521)`);
|
|
17313
|
-
await this.waitForIndexesActive(physicalId, logicalId);
|
|
17483
|
+
await this.waitForIndexesActive(physicalId, logicalId, { proceedNote: DELETE_INDEX_WAIT_PROCEED_NOTE });
|
|
17314
17484
|
}
|
|
17315
|
-
|
|
17485
|
+
let deleteAttempts = 0;
|
|
17486
|
+
await withRetry(async () => {
|
|
17487
|
+
if (deleteAttempts++ > 0) {
|
|
17488
|
+
if (deleteAttempts === 2) {
|
|
17489
|
+
const remainingAttempts = 1 + DELETE_INDEX_BUSY_MAX_RETRIES - deleteAttempts;
|
|
17490
|
+
this.logger.warn(`DynamoDB GlobalTable ${logicalId}: AWS refused DeleteTable on ${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 (up to ${remainingAttempts} more attempts, each preceded by up to ${DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS} DescribeTable polls ~1s apart — a little over a minute of settling per attempt). 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.`);
|
|
17491
|
+
}
|
|
17492
|
+
await this.waitForIndexesActive(physicalId, logicalId, {
|
|
17493
|
+
maxAttempts: DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS,
|
|
17494
|
+
proceedNote: DELETE_INDEX_WAIT_PROCEED_NOTE
|
|
17495
|
+
});
|
|
17496
|
+
}
|
|
17497
|
+
await this.dynamoDBClient.send(new DeleteTableCommand({ TableName: physicalId }));
|
|
17498
|
+
}, logicalId, {
|
|
17499
|
+
maxRetries: DELETE_INDEX_BUSY_MAX_RETRIES,
|
|
17500
|
+
isRetryable: (message) => isIndexBusyDeleteError(message),
|
|
17501
|
+
logger: this.logger,
|
|
17502
|
+
sleep: (ms) => deleteTableRetryDelays.sleep ? deleteTableRetryDelays.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms))
|
|
17503
|
+
});
|
|
17316
17504
|
await this.waitForTableGone(physicalId, logicalId);
|
|
17317
17505
|
this.logger.debug(`Successfully deleted DynamoDB GlobalTable ${logicalId}`);
|
|
17318
17506
|
} catch (error) {
|
|
@@ -17928,22 +18116,49 @@ var DynamoDBGlobalTableProvider = class {
|
|
|
17928
18116
|
* throwing. A missed registration self-heals on the next deploy (the
|
|
17929
18117
|
* reconcile re-asserts anything not already present), whereas throwing
|
|
17930
18118
|
* here would fail a deploy whose actual resources are all correct. Index
|
|
17931
|
-
* backfill on a large table can take a long time, hence the 15-minute
|
|
17932
|
-
* The caller that follows it with a mutating call keeps AWS's
|
|
17933
|
-
* the backstop for the case where even that cap is not enough.
|
|
17934
|
-
|
|
17935
|
-
|
|
17936
|
-
|
|
18119
|
+
* backfill on a large table can take a long time, hence the 15-minute
|
|
18120
|
+
* DEFAULT cap. The caller that follows it with a mutating call keeps AWS's
|
|
18121
|
+
* own error as the backstop for the case where even that cap is not enough.
|
|
18122
|
+
*
|
|
18123
|
+
* `opts.maxAttempts` exists because the cap is only right for a wait that
|
|
18124
|
+
* runs ONCE: the #1830 delete retry re-runs this per attempt, so the caller's
|
|
18125
|
+
* wall clock is the product and the default would blow past the per-resource
|
|
18126
|
+
* deadline `destroy-runner.ts` enforces.
|
|
18127
|
+
*
|
|
18128
|
+
* `opts.proceedNote` covers the OTHER thing that is caller-specific: what it
|
|
18129
|
+
* costs when the wait ends WITHOUT confirming. Both arms that end that way —
|
|
18130
|
+
* the timeout, and a `DescribeTable` that failed for a reason other than
|
|
18131
|
+
* throttling or a gone table — stop waiting and let the caller proceed, so
|
|
18132
|
+
* one note serves both. The consequences genuinely differ: on the delete
|
|
18133
|
+
* path AWS refuses the `DeleteTable` and that refusal is the backstop, while
|
|
18134
|
+
* the auto-scaling caller has NO backstop — `reconcileAutoScalingTargets`
|
|
18135
|
+
* simply skips an index that is not ready and nothing downstream refuses.
|
|
18136
|
+
* The default sentence is the auto-scaling one; every delete-path caller
|
|
18137
|
+
* passes {@link DELETE_INDEX_WAIT_PROCEED_NOTE}.
|
|
18138
|
+
*/
|
|
18139
|
+
async waitForIndexesActive(tableName, logicalId, opts) {
|
|
18140
|
+
const maxAttempts = opts?.maxAttempts ?? 900;
|
|
18141
|
+
const proceedNote = opts?.proceedNote ?? "auto-scaling registration for a still-building index may have been skipped. The next deploy re-asserts it.";
|
|
17937
18142
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
17938
18143
|
try {
|
|
17939
18144
|
if (!hasTransitionalIndex((await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table?.GlobalSecondaryIndexes ?? [])) return;
|
|
17940
18145
|
} catch (err) {
|
|
17941
|
-
|
|
18146
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
18147
|
+
if (isThrottlingError(err)) {
|
|
18148
|
+
this.logger.debug(`DescribeTable throttled while waiting for indexes on ${tableName} (attempt ${attempt}/${maxAttempts}); still waiting: ${message}`);
|
|
18149
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
18150
|
+
continue;
|
|
18151
|
+
}
|
|
18152
|
+
if (err instanceof ResourceNotFoundException$1) {
|
|
18153
|
+
this.logger.debug(`Table ${tableName} (${logicalId}) no longer exists while waiting for indexes`);
|
|
18154
|
+
return;
|
|
18155
|
+
}
|
|
18156
|
+
this.logger.warn(`DescribeTable failed while waiting for indexes on ${tableName} (${logicalId}), so cdkd stopped waiting; ${proceedNote} DescribeTable error: ${message}`);
|
|
17942
18157
|
return;
|
|
17943
18158
|
}
|
|
17944
18159
|
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
17945
18160
|
}
|
|
17946
|
-
this.logger.warn(`Indexes on ${tableName} (${logicalId}) did not all reach ACTIVE within ${maxAttempts}
|
|
18161
|
+
this.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}`);
|
|
17947
18162
|
}
|
|
17948
18163
|
/**
|
|
17949
18164
|
* Wait until a specific replica's `ReplicaStatus` flips to ACTIVE.
|
|
@@ -18082,6 +18297,14 @@ function autoScalingResourceId(tableName, indexName) {
|
|
|
18082
18297
|
*/
|
|
18083
18298
|
const autoScalingRetryDelays = {};
|
|
18084
18299
|
/**
|
|
18300
|
+
* Test seam for the index-busy `DeleteTable` retry's backoff (issue #1830),
|
|
18301
|
+
* mirroring {@link autoScalingRetryDelays}. Production leaves `sleep`
|
|
18302
|
+
* undefined so `withRetry`'s real schedule applies; a test injects a no-op so
|
|
18303
|
+
* the ~47s budget does not have to be waited out. Kept SEPARATE from the
|
|
18304
|
+
* auto-scaling seam so a test can slow one path without silencing the other.
|
|
18305
|
+
*/
|
|
18306
|
+
const deleteTableRetryDelays = {};
|
|
18307
|
+
/**
|
|
18085
18308
|
* Resource ids per `DescribeScalableTargets` request. Kept at the API's
|
|
18086
18309
|
* documented per-page ceiling rather than a larger guess — the request is
|
|
18087
18310
|
* paginated anyway, and over-asking risks a `ValidationException` that would
|
|
@@ -18235,12 +18458,113 @@ const INDEX_TRANSITIONAL_STATUSES = /* @__PURE__ */ new Set([
|
|
|
18235
18458
|
function hasTransitionalIndex(indexes) {
|
|
18236
18459
|
return (indexes ?? []).some((gsi) => INDEX_TRANSITIONAL_STATUSES.has(gsi.IndexStatus ?? ""));
|
|
18237
18460
|
}
|
|
18238
|
-
/**
|
|
18239
|
-
|
|
18240
|
-
|
|
18241
|
-
|
|
18242
|
-
|
|
18243
|
-
|
|
18461
|
+
/**
|
|
18462
|
+
* AWS's refusal when `DeleteTable` lands while one of the table's GSIs is
|
|
18463
|
+
* mid-transition (issue #1830). Observed verbatim on a real destroy:
|
|
18464
|
+
*
|
|
18465
|
+
* Attempt to change a resource which is still in use: Cannot delete table
|
|
18466
|
+
* while indexes are being created, updated, or deleted.
|
|
18467
|
+
*
|
|
18468
|
+
* Matched on the leading clause rather than on the whole sentence: the
|
|
18469
|
+
* trailing status list is AWS wording that can gain a state without changing
|
|
18470
|
+
* what the message MEANS, and the leading clause is already specific enough
|
|
18471
|
+
* that nothing else in the DynamoDB surface produces it. Case-insensitive
|
|
18472
|
+
* because the text arrives inside a wrapper prefix whose casing is not ours.
|
|
18473
|
+
*
|
|
18474
|
+
* Deliberately NOT keyed on the exception NAME: AWS reports this as a plain
|
|
18475
|
+
* `ResourceInUseException`, which is the same name it uses for genuinely
|
|
18476
|
+
* terminal conflicts (deleting a table that is `CREATING`, creating one that
|
|
18477
|
+
* already exists). Retrying on the name would turn those into a 47s stall
|
|
18478
|
+
* before the same failure.
|
|
18479
|
+
*/
|
|
18480
|
+
const INDEX_BUSY_DELETE_MESSAGE = /cannot delete table while indexes are being/i;
|
|
18481
|
+
function isIndexBusyDeleteError(message) {
|
|
18482
|
+
return INDEX_BUSY_DELETE_MESSAGE.test(message);
|
|
18483
|
+
}
|
|
18484
|
+
/**
|
|
18485
|
+
* Retry budget for the index-busy `DeleteTable` refusal (issue #1830).
|
|
18486
|
+
*
|
|
18487
|
+
* Left at `withRetry`'s own default of 8 rather than raised. Named rather than
|
|
18488
|
+
* inlined because passing it at all is what opts this call out of
|
|
18489
|
+
* `withRetry`'s dense IAM-propagation schedule, which would be wrong for a
|
|
18490
|
+
* condition measured in seconds-to-minutes rather than sub-second.
|
|
18491
|
+
*
|
|
18492
|
+
* The count MULTIPLIES the per-attempt re-arm poll below, so the two have to
|
|
18493
|
+
* be read together — see {@link DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS} for the
|
|
18494
|
+
* arithmetic and for why the poll is not the 15-minute one.
|
|
18495
|
+
*/
|
|
18496
|
+
const DELETE_INDEX_BUSY_MAX_RETRIES = 8;
|
|
18497
|
+
/**
|
|
18498
|
+
* Seconds the index-busy `DeleteTable` retry spends re-arming BEFORE each
|
|
18499
|
+
* retry (issue #1830) — one `DescribeTable` per second, and it returns on the
|
|
18500
|
+
* first one that reports every index settled.
|
|
18501
|
+
*
|
|
18502
|
+
* Bounded well under
|
|
18503
|
+
* {@link DynamoDBGlobalTableProvider['waitForIndexesActive']}'s 15-minute
|
|
18504
|
+
* default because THAT default is sized for a wait that runs once, while this
|
|
18505
|
+
* one runs per retry: the caller's wall clock is
|
|
18506
|
+
* `DELETE_INDEX_BUSY_MAX_RETRIES x this + withRetry's ~47s of backoff`, and
|
|
18507
|
+
* `destroy-runner.ts` caps a single `delete()` at the per-resource deadline
|
|
18508
|
+
* (30 min by default; this provider declares no `getMinResourceTimeoutMs` to
|
|
18509
|
+
* lift it). At 900s the product was ~2h, so a genuinely stuck index produced a
|
|
18510
|
+
* 30-minute wait ending in a generic `ResourceTimeoutError` that never
|
|
18511
|
+
* mentions indexes. At 60s the worst case is ~8.8 min and the user gets AWS's
|
|
18512
|
+
* own actionable sentence instead.
|
|
18513
|
+
*
|
|
18514
|
+
* The FIRST attempt is unaffected — it does not re-arm at all, and the #1521
|
|
18515
|
+
* pre-delete gate ahead of the loop keeps its full 15-minute poll.
|
|
18516
|
+
*/
|
|
18517
|
+
const DELETE_INDEX_BUSY_REARM_MAX_ATTEMPTS = 60;
|
|
18518
|
+
/**
|
|
18519
|
+
* What proceeding costs when a DELETE-path index-settle wait ends WITHOUT
|
|
18520
|
+
* confirming — passed as
|
|
18521
|
+
* {@link DynamoDBGlobalTableProvider['waitForIndexesActive']}'s `proceedNote`
|
|
18522
|
+
* by BOTH delete-path callers (the #1521 pre-delete gate and the #1830 retry
|
|
18523
|
+
* re-arm).
|
|
18524
|
+
*
|
|
18525
|
+
* The default note that helper falls back to is the auto-scaling caller's, and
|
|
18526
|
+
* it is not merely unhelpful here but wrong: nothing on the delete path
|
|
18527
|
+
* re-asserts anything on a later deploy. Conversely the delete path really
|
|
18528
|
+
* does have a backstop — `DeleteTable` follows immediately and AWS refuses it
|
|
18529
|
+
* again if the index is still transitioning — which the auto-scaling caller
|
|
18530
|
+
* does not: `reconcileAutoScalingTargets` skips an index that is not ready and
|
|
18531
|
+
* nothing downstream notices. One sentence per caller, so neither claims the
|
|
18532
|
+
* other's safety net.
|
|
18533
|
+
*/
|
|
18534
|
+
const DELETE_INDEX_WAIT_PROCEED_NOTE = `the DeleteTable goes ahead anyway and AWS's own refusal stays the backstop.`;
|
|
18535
|
+
/**
|
|
18536
|
+
* Build the {@link ThroughputDiagnostic} for a `WarmThroughput` block that did
|
|
18537
|
+
* not survive coercion intact, or `undefined` when it did.
|
|
18538
|
+
*
|
|
18539
|
+
* Two distinct outcomes, worded differently because the remedy differs:
|
|
18540
|
+
* - PARTIAL — one member dropped, the other still sent. The call goes ahead;
|
|
18541
|
+
* the user needs to know which half silently did not.
|
|
18542
|
+
* - REFUSED — nothing sendable. No call is made for the property at all,
|
|
18543
|
+
* which is the point (issue #1857 defect 3): forwarding the malformed block
|
|
18544
|
+
* would surface as an AWS validation error that names neither cdkd nor the
|
|
18545
|
+
* property.
|
|
18546
|
+
*/
|
|
18547
|
+
function warmThroughputDiagnostic(raw, coercion, indexName) {
|
|
18548
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
18549
|
+
const scope = {
|
|
18550
|
+
kind: "unresolved-member",
|
|
18551
|
+
member: "WarmThroughput"
|
|
18552
|
+
};
|
|
18553
|
+
const dropped = coercion.droppedMembers.join(" / ");
|
|
18554
|
+
if (coercion.spec) {
|
|
18555
|
+
if (coercion.droppedMembers.length === 0) return void 0;
|
|
18556
|
+
const kept = WARM_THROUGHPUT_MEMBERS.filter((m) => coercion.spec?.[m] !== void 0).join(" / ");
|
|
18557
|
+
return {
|
|
18558
|
+
...scope,
|
|
18559
|
+
...indexName !== void 0 && { indexName },
|
|
18560
|
+
message: `WarmThroughput.${dropped} did not resolve to a number and was DROPPED; ${kept} is still sent. AWS types these as numbers, so a quoted "12000" is fine but an unresolved intrinsic or an object is not. The dropped member keeps its current AWS value until the template supplies a usable one.`
|
|
18561
|
+
};
|
|
18562
|
+
}
|
|
18563
|
+
return {
|
|
18564
|
+
...scope,
|
|
18565
|
+
...indexName !== void 0 && { indexName },
|
|
18566
|
+
message: `WarmThroughput is declared but no member resolved to a number${dropped ? ` (${dropped})` : ""}, so the whole block is REFUSED and not sent. Forwarding it would fail as an opaque AWS validation error naming neither cdkd nor the property. Set WarmThroughput.ReadUnitsPerSecond / WriteUnitsPerSecond to numbers, or remove the block. Nothing else about the index is affected.`
|
|
18567
|
+
};
|
|
18244
18568
|
}
|
|
18245
18569
|
/**
|
|
18246
18570
|
* Merge an explicitly-supplied, already-SDK-shaped throughput block over a
|
|
@@ -18535,7 +18859,10 @@ function toSdkGlobalSecondaryIndexes(properties, region, billingMode, source = "
|
|
|
18535
18859
|
KeySchema: gsi["KeySchema"],
|
|
18536
18860
|
Projection: gsi["Projection"]
|
|
18537
18861
|
};
|
|
18538
|
-
|
|
18862
|
+
const warmCoercion = coerceWarmThroughput$1(gsi["WarmThroughput"]);
|
|
18863
|
+
if (warmCoercion.spec) sdk.WarmThroughput = warmCoercion.spec;
|
|
18864
|
+
const warmDiagnostic = warmThroughputDiagnostic(gsi["WarmThroughput"], warmCoercion, typeof indexName === "string" ? indexName : void 0);
|
|
18865
|
+
if (warmDiagnostic) diagnostics?.push(warmDiagnostic);
|
|
18539
18866
|
const explicitProvisioned = asRecord$1(gsi["ProvisionedThroughput"]);
|
|
18540
18867
|
const explicitOnDemand = asRecord$1(gsi["OnDemandThroughput"]);
|
|
18541
18868
|
if (explicitProvisioned && billingMode !== "PROVISIONED") diagnostics?.push({
|
|
@@ -56744,6 +57071,307 @@ function createDeployCommand() {
|
|
|
56744
57071
|
return cmd;
|
|
56745
57072
|
}
|
|
56746
57073
|
|
|
57074
|
+
//#endregion
|
|
57075
|
+
//#region src/analyzer/outputs-diff.ts
|
|
57076
|
+
/**
|
|
57077
|
+
* A `${...}` placeholder `Fn::Sub` did NOT substitute.
|
|
57078
|
+
*
|
|
57079
|
+
* `resolveSub` catches a genuine `Ref` / `Fn::GetAtt` miss, WARNS, and keeps the
|
|
57080
|
+
* literal `${Foo}` in the output string — it neither throws nor leaves an
|
|
57081
|
+
* intrinsic object behind. So a half-substituted `Fn::Sub` reaches this module
|
|
57082
|
+
* looking like an ordinary resolved string, and comparing it against state
|
|
57083
|
+
* reports a phantom change whose printed value is `"arn:...${NewBucket}"`.
|
|
57084
|
+
*
|
|
57085
|
+
* Only consulted for a value whose TEMPLATE source actually contained an
|
|
57086
|
+
* `Fn::Sub` (see {@link templateUsesSub}). Applying it to every string
|
|
57087
|
+
* over-matches badly: an IAM policy body with `${aws:username}`, a UserData
|
|
57088
|
+
* snippet with a shell `${VAR}`, or any literal a user wrote would set
|
|
57089
|
+
* `resolutionFailed` and suppress the WHOLE Outputs section for that stack, on
|
|
57090
|
+
* every run, forever.
|
|
57091
|
+
*
|
|
57092
|
+
* Within `Fn::Sub`-sourced values one false positive remains and is accepted:
|
|
57093
|
+
* `${!Literal}` is `Fn::Sub`'s ESCAPE and resolves to the literal text
|
|
57094
|
+
* `${Literal}`, which post-resolution is indistinguishable from a placeholder
|
|
57095
|
+
* that failed to substitute. That case merely SUPPRESSES the section (the
|
|
57096
|
+
* pre-#1921 behavior) whereas the alternative is a phantom reported every run.
|
|
57097
|
+
*/
|
|
57098
|
+
const UNSUBSTITUTED_SUB_PLACEHOLDER = /\$\{[^}]*\}/;
|
|
57099
|
+
/**
|
|
57100
|
+
* True when `value` is, or anywhere CONTAINS, something the diff could not
|
|
57101
|
+
* fully resolve against current state.
|
|
57102
|
+
*
|
|
57103
|
+
* The deploy engine's twin check is `Object.values(bag).some(v => v ===
|
|
57104
|
+
* undefined)` — its resolver THROWS on failure and the catch stores `undefined`.
|
|
57105
|
+
* The diff's resolver is best-effort and fails in three further ways that all
|
|
57106
|
+
* have to count as "unresolved" here, because anything that slips through is
|
|
57107
|
+
* compared against state and reported as a change the apply will never make:
|
|
57108
|
+
*
|
|
57109
|
+
* 1. `undefined` — the SAME signal the deploy side keys on. `resolve` returns it
|
|
57110
|
+
* WITHOUT throwing for a constructible-but-unknown attribute
|
|
57111
|
+
* (`AWS::DynamoDB::Table.StreamArn`, `AWS::IAM::Policy.PolicyId`,
|
|
57112
|
+
* `AWS::EC2::SecurityGroup.VpcId`, ...). `JSON.stringify` drops an
|
|
57113
|
+
* `undefined`-valued key, so state can never hold one and such an output
|
|
57114
|
+
* would otherwise report a PERMANENT phantom `ADD` printing `new: undefined`.
|
|
57115
|
+
* 2. A symbol — `Ref: AWS::NoValue` resolves to a sentinel symbol, which
|
|
57116
|
+
* `resolveValue` strips only INSIDE arrays / objects; a top-level
|
|
57117
|
+
* `Fn::If` selecting `AWS::NoValue` returns it bare. Not persistable, so
|
|
57118
|
+
* again a permanent phantom.
|
|
57119
|
+
* 3. A surviving intrinsic OBJECT, or — only when the template source used
|
|
57120
|
+
* `Fn::Sub` — an unsubstituted placeholder STRING (see
|
|
57121
|
+
* {@link UNSUBSTITUTED_SUB_PLACEHOLDER}).
|
|
57122
|
+
*
|
|
57123
|
+
* DEEP rather than shallow: an unresolved leaf can sit anywhere inside an array
|
|
57124
|
+
* or object an outer intrinsic already built.
|
|
57125
|
+
*/
|
|
57126
|
+
function isUnresolvedValue(value, sourceUsedSub) {
|
|
57127
|
+
if (value === void 0) return true;
|
|
57128
|
+
if (typeof value === "symbol") return true;
|
|
57129
|
+
if (typeof value === "string") return sourceUsedSub && UNSUBSTITUTED_SUB_PLACEHOLDER.test(value);
|
|
57130
|
+
if (value === null || typeof value !== "object") return false;
|
|
57131
|
+
if (Array.isArray(value)) return value.some((v) => isUnresolvedValue(v, sourceUsedSub));
|
|
57132
|
+
const keys = Object.keys(value);
|
|
57133
|
+
if (keys.length === 1 && INTRINSIC_KEYS.has(keys[0])) return true;
|
|
57134
|
+
return Object.values(value).some((v) => isUnresolvedValue(v, sourceUsedSub));
|
|
57135
|
+
}
|
|
57136
|
+
/**
|
|
57137
|
+
* True when this output's RAW template value contains an `Fn::Sub` anywhere —
|
|
57138
|
+
* including nested inside an `Fn::Join` / `Fn::If`, since those resolve their
|
|
57139
|
+
* arguments and a laundered placeholder from an inner `Fn::Sub` surfaces in the
|
|
57140
|
+
* outer result.
|
|
57141
|
+
*/
|
|
57142
|
+
function templateUsesSub(templateValue) {
|
|
57143
|
+
if (templateValue === null || typeof templateValue !== "object") return false;
|
|
57144
|
+
if (Array.isArray(templateValue)) return templateValue.some(templateUsesSub);
|
|
57145
|
+
const record = templateValue;
|
|
57146
|
+
if (Object.prototype.hasOwnProperty.call(record, "Fn::Sub")) return true;
|
|
57147
|
+
return Object.values(record).some(templateUsesSub);
|
|
57148
|
+
}
|
|
57149
|
+
/**
|
|
57150
|
+
* True when `value` is a string carrying a SECRET-BEARING CloudFormation
|
|
57151
|
+
* dynamic reference — `{{resolve:secretsmanager:` or `{{resolve:ssm-secure:`,
|
|
57152
|
+
* the two spellings that are secret regardless of what they point at.
|
|
57153
|
+
*
|
|
57154
|
+
* The diff resolves with `skipDynamicReferences`, so a secret-bearing output
|
|
57155
|
+
* arrives here as its unresolved `{{resolve:...}}` expression — which is also
|
|
57156
|
+
* what post-GHSA state stores. A state record written by an OLDER binary can
|
|
57157
|
+
* still hold the resolved PLAINTEXT instead (that mismatch is exactly what
|
|
57158
|
+
* `cdkd scrub` exists to repair), and printing it is this module's problem
|
|
57159
|
+
* because it is the first code path that DISPLAYS a stored output value.
|
|
57160
|
+
*/
|
|
57161
|
+
function isSecretDynamicReference(value) {
|
|
57162
|
+
if (typeof value !== "string") return false;
|
|
57163
|
+
return value.includes("{{resolve:secretsmanager:") || value.includes("{{resolve:ssm-secure:");
|
|
57164
|
+
}
|
|
57165
|
+
/** Structural equality, mirroring the deploy engine's `outputMapsEqual` leaf rule. */
|
|
57166
|
+
function valuesEqual(a, b) {
|
|
57167
|
+
if (a === b) return true;
|
|
57168
|
+
if (a == null || b == null) return a === b;
|
|
57169
|
+
if (typeof a !== typeof b) return false;
|
|
57170
|
+
if (typeof a !== "object") return false;
|
|
57171
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
57172
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
57173
|
+
return a.every((v, i) => valuesEqual(v, b[i]));
|
|
57174
|
+
}
|
|
57175
|
+
const ao = a;
|
|
57176
|
+
const bo = b;
|
|
57177
|
+
const ak = Object.keys(ao);
|
|
57178
|
+
if (ak.length !== Object.keys(bo).length) return false;
|
|
57179
|
+
for (const k of ak) {
|
|
57180
|
+
if (!Object.prototype.hasOwnProperty.call(bo, k)) return false;
|
|
57181
|
+
if (!valuesEqual(ao[k], bo[k])) return false;
|
|
57182
|
+
}
|
|
57183
|
+
return true;
|
|
57184
|
+
}
|
|
57185
|
+
/**
|
|
57186
|
+
* Resolve a template's `Outputs` section into the exact bag shape the deploy
|
|
57187
|
+
* engine persists to `StackState.outputs` (issue #1921).
|
|
57188
|
+
*
|
|
57189
|
+
* This is the preview twin of `DeployEngine.resolveOutputs`, and the three
|
|
57190
|
+
* semantics below are load-bearing for parity — a preview that builds a
|
|
57191
|
+
* DIFFERENT bag than the apply persists reports a phantom change on every run,
|
|
57192
|
+
* which is this issue's own bug class inverted. `tests/unit/analyzer/
|
|
57193
|
+
* outputs-diff.test.ts` fences each one against drift on the deploy side.
|
|
57194
|
+
*
|
|
57195
|
+
* 1. An output whose `Condition` evaluates false is SKIPPED, not resolved —
|
|
57196
|
+
* CloudFormation simply does not create it (mirrors the resource side's
|
|
57197
|
+
* `filterResourcesByCondition`; an unknown condition name is kept).
|
|
57198
|
+
* 2. An `Export.Name` is stored as a SECOND key holding the same value, so
|
|
57199
|
+
* `Fn::ImportValue` can find it by export name. Both keys land in state, so
|
|
57200
|
+
* both must land here.
|
|
57201
|
+
* 3. Resolution is best-effort and never throws — a diff must never harden into
|
|
57202
|
+
* an error where the deploy would have succeeded.
|
|
57203
|
+
*
|
|
57204
|
+
* The parity claim is scoped to the deploy engine's NO-CHANGE branch, which is
|
|
57205
|
+
* the one this preview stands in for. Its changed-resources branch resolves
|
|
57206
|
+
* outputs with no `resolutionFailed` gate at all — correctly, because by then
|
|
57207
|
+
* every resource exists and resolution is expected to succeed, whereas a diff
|
|
57208
|
+
* runs BEFORE any of them are created.
|
|
57209
|
+
*
|
|
57210
|
+
* Deliberately does NOT redact secrets the way the deploy side does: this
|
|
57211
|
+
* resolver runs with `skipDynamicReferences`, so a `{{resolve:...}}` reference
|
|
57212
|
+
* is never resolved to plaintext in the first place and the bag already holds
|
|
57213
|
+
* the expression that state stores post-redaction.
|
|
57214
|
+
*/
|
|
57215
|
+
async function resolveTemplateOutputs(template, resolveFn, conditions) {
|
|
57216
|
+
const logger = getLogger().child("OutputsDiff");
|
|
57217
|
+
const outputs = {};
|
|
57218
|
+
const exportNames = /* @__PURE__ */ new Set();
|
|
57219
|
+
const failedKeys = /* @__PURE__ */ new Set();
|
|
57220
|
+
const secretSourceKeys = /* @__PURE__ */ new Set();
|
|
57221
|
+
let resolutionFailed = false;
|
|
57222
|
+
if (!template.Outputs) return {
|
|
57223
|
+
outputs,
|
|
57224
|
+
exportNames,
|
|
57225
|
+
failedKeys,
|
|
57226
|
+
secretSourceKeys,
|
|
57227
|
+
resolutionFailed
|
|
57228
|
+
};
|
|
57229
|
+
/**
|
|
57230
|
+
* Record a key the resolver DROPPED — plus the literal `Export.Name` alias the
|
|
57231
|
+
* deploy would have written alongside it. Both are absent from the bag, so
|
|
57232
|
+
* both would otherwise read as phantom REMOVEs and make the suppression
|
|
57233
|
+
* warning fire on the ordinary pending-resource case.
|
|
57234
|
+
*/
|
|
57235
|
+
const recordFailure = (outputKey, output) => {
|
|
57236
|
+
failedKeys.add(outputKey);
|
|
57237
|
+
if (typeof output.Export?.Name === "string") failedKeys.add(output.Export.Name);
|
|
57238
|
+
};
|
|
57239
|
+
for (const [outputKey, output] of Object.entries(template.Outputs)) {
|
|
57240
|
+
if (!isSecretDynamicReference(output.Value)) continue;
|
|
57241
|
+
secretSourceKeys.add(outputKey);
|
|
57242
|
+
if (typeof output.Export?.Name === "string") secretSourceKeys.add(output.Export.Name);
|
|
57243
|
+
}
|
|
57244
|
+
for (const [outputKey, output] of Object.entries(template.Outputs)) {
|
|
57245
|
+
if (output.Condition !== void 0 && conditions?.[output.Condition] === false) {
|
|
57246
|
+
logger.debug(`Skipping output ${outputKey} — condition ${output.Condition} is false`);
|
|
57247
|
+
continue;
|
|
57248
|
+
}
|
|
57249
|
+
const sourceUsedSub = templateUsesSub(output.Value);
|
|
57250
|
+
let value;
|
|
57251
|
+
try {
|
|
57252
|
+
value = await resolveFn(structuredClone(output.Value));
|
|
57253
|
+
} catch (error) {
|
|
57254
|
+
logger.debug(`Diff could not resolve output ${outputKey}: ${String(error)}`);
|
|
57255
|
+
resolutionFailed = true;
|
|
57256
|
+
recordFailure(outputKey, output);
|
|
57257
|
+
continue;
|
|
57258
|
+
}
|
|
57259
|
+
if (isUnresolvedValue(value, sourceUsedSub)) {
|
|
57260
|
+
logger.debug(`Diff left output ${outputKey} unresolved (references a pending resource)`);
|
|
57261
|
+
resolutionFailed = true;
|
|
57262
|
+
recordFailure(outputKey, output);
|
|
57263
|
+
continue;
|
|
57264
|
+
}
|
|
57265
|
+
outputs[outputKey] = value;
|
|
57266
|
+
if (output.Export?.Name) {
|
|
57267
|
+
let exportName = output.Export.Name;
|
|
57268
|
+
const exportSourceUsedSub = templateUsesSub(output.Export.Name);
|
|
57269
|
+
if (typeof exportName !== "string") try {
|
|
57270
|
+
exportName = await resolveFn(structuredClone(exportName));
|
|
57271
|
+
} catch (error) {
|
|
57272
|
+
logger.debug(`Diff could not resolve Export.Name of ${outputKey}: ${String(error)}`);
|
|
57273
|
+
resolutionFailed = true;
|
|
57274
|
+
continue;
|
|
57275
|
+
}
|
|
57276
|
+
if (typeof exportName === "string" && !isUnresolvedValue(exportName, exportSourceUsedSub)) {
|
|
57277
|
+
outputs[exportName] = value;
|
|
57278
|
+
exportNames.add(exportName);
|
|
57279
|
+
} else resolutionFailed = true;
|
|
57280
|
+
}
|
|
57281
|
+
}
|
|
57282
|
+
return {
|
|
57283
|
+
outputs,
|
|
57284
|
+
exportNames,
|
|
57285
|
+
failedKeys,
|
|
57286
|
+
secretSourceKeys,
|
|
57287
|
+
resolutionFailed
|
|
57288
|
+
};
|
|
57289
|
+
}
|
|
57290
|
+
/**
|
|
57291
|
+
* Compare the persisted Outputs bag against the one the next deploy would
|
|
57292
|
+
* write, key by key (issue #1921).
|
|
57293
|
+
*
|
|
57294
|
+
* Compares BAG KEYS rather than template output names on purpose. The bag is
|
|
57295
|
+
* what actually lands in `StackState.outputs` AND what
|
|
57296
|
+
* `ExportIndexStore.updateForStack` publishes, so a key-level comparison is
|
|
57297
|
+
* exactly the `outputMapsEqual` predicate the deploy engine gates its persist
|
|
57298
|
+
* on — no interpretive layer in between that could drift from apply semantics.
|
|
57299
|
+
* The practical payoff is that the motivating #875 case shows the user the
|
|
57300
|
+
* literal export name their downstream `Fn::ImportValue` needs.
|
|
57301
|
+
*
|
|
57302
|
+
* ## Withholding legacy secret plaintext
|
|
57303
|
+
*
|
|
57304
|
+
* This is the first code path that DISPLAYS a stored output value, and
|
|
57305
|
+
* `cdkd diff` is routinely run in CI — so a value that turns out to be secret
|
|
57306
|
+
* plaintext would land in build logs. `StackState.outputs` is only guaranteed
|
|
57307
|
+
* redacted for records written after the GHSA fix; an older binary persisted
|
|
57308
|
+
* the RESOLVED plaintext, which is the condition `cdkd scrub` repairs.
|
|
57309
|
+
*
|
|
57310
|
+
* Two independent signals identify such a record, and BOTH are needed:
|
|
57311
|
+
*
|
|
57312
|
+
* - the desired side is still a secret-bearing expression per
|
|
57313
|
+
* {@link isSecretDynamicReference} (this resolver runs with
|
|
57314
|
+
* `skipDynamicReferences`) while the stored side is not; and
|
|
57315
|
+
* - `secretSourceKeys` — the template itself declares the key's value as such a
|
|
57316
|
+
* reference. This one reaches cases the first cannot: a
|
|
57317
|
+
* condition-skipped secret output, or one deleted from the template, has NO
|
|
57318
|
+
* desired side at all and would otherwise print in full as a REMOVE row.
|
|
57319
|
+
*
|
|
57320
|
+
* A hit on either makes the WHOLE record suspect, not just that key: the
|
|
57321
|
+
* record was written by a pre-GHSA binary, so every value in it is unredacted.
|
|
57322
|
+
* Withholding is therefore record-level. The change is still REPORTED — only
|
|
57323
|
+
* the value is withheld — so `--fail` and the exports story are unaffected.
|
|
57324
|
+
*/
|
|
57325
|
+
function computeOutputsDiff(current, desired, exportNames, secretSourceKeys = /* @__PURE__ */ new Set()) {
|
|
57326
|
+
const changes = [];
|
|
57327
|
+
const currentBag = current ?? {};
|
|
57328
|
+
const legacyRecord = Object.entries(currentBag).some(([name, oldValue]) => {
|
|
57329
|
+
if (isSecretDynamicReference(oldValue)) return false;
|
|
57330
|
+
return isSecretDynamicReference(desired[name]) || secretSourceKeys.has(name);
|
|
57331
|
+
});
|
|
57332
|
+
const push = (change) => {
|
|
57333
|
+
if (legacyRecord && change.changeType !== "ADD") {
|
|
57334
|
+
const { oldValue: _dropped, ...rest } = change;
|
|
57335
|
+
changes.push({
|
|
57336
|
+
...rest,
|
|
57337
|
+
oldValueRedacted: true
|
|
57338
|
+
});
|
|
57339
|
+
return;
|
|
57340
|
+
}
|
|
57341
|
+
changes.push(change);
|
|
57342
|
+
};
|
|
57343
|
+
for (const [name, newValue] of Object.entries(desired)) {
|
|
57344
|
+
const isExport = exportNames.has(name);
|
|
57345
|
+
if (!Object.prototype.hasOwnProperty.call(currentBag, name)) {
|
|
57346
|
+
changes.push({
|
|
57347
|
+
name,
|
|
57348
|
+
changeType: "ADD",
|
|
57349
|
+
newValue,
|
|
57350
|
+
isExport
|
|
57351
|
+
});
|
|
57352
|
+
continue;
|
|
57353
|
+
}
|
|
57354
|
+
const oldValue = currentBag[name];
|
|
57355
|
+
if (!valuesEqual(oldValue, newValue)) push({
|
|
57356
|
+
name,
|
|
57357
|
+
changeType: "MODIFY",
|
|
57358
|
+
oldValue,
|
|
57359
|
+
newValue,
|
|
57360
|
+
isExport
|
|
57361
|
+
});
|
|
57362
|
+
}
|
|
57363
|
+
for (const [name, oldValue] of Object.entries(currentBag)) {
|
|
57364
|
+
if (Object.prototype.hasOwnProperty.call(desired, name)) continue;
|
|
57365
|
+
push({
|
|
57366
|
+
name,
|
|
57367
|
+
changeType: "REMOVE",
|
|
57368
|
+
oldValue,
|
|
57369
|
+
isExport: false
|
|
57370
|
+
});
|
|
57371
|
+
}
|
|
57372
|
+
return changes;
|
|
57373
|
+
}
|
|
57374
|
+
|
|
56747
57375
|
//#endregion
|
|
56748
57376
|
//#region src/cli/cfn-stack-states.ts
|
|
56749
57377
|
/**
|
|
@@ -57705,6 +58333,11 @@ async function loadStateOrEmpty(stackName, region, stateBackend) {
|
|
|
57705
58333
|
* resolver so changes buried inside intrinsics (e.g. `Fn::Join` literal
|
|
57706
58334
|
* args) are detected against resolved values in state.
|
|
57707
58335
|
*
|
|
58336
|
+
* Also computes the `Outputs` delta (issue #1921) — an Outputs-only change has
|
|
58337
|
+
* a byte-identical `Resources` section, so without it such a stack previews as
|
|
58338
|
+
* "No changes detected" while the apply persists new outputs and republishes
|
|
58339
|
+
* the exports index.
|
|
58340
|
+
*
|
|
57708
58341
|
* Pure with respect to AWS state mutation — only reads state (the resolver
|
|
57709
58342
|
* may read producer state for `Fn::ImportValue` / `Fn::GetStackOutput`).
|
|
57710
58343
|
*/
|
|
@@ -57749,7 +58382,16 @@ async function computeStackDiff(currentState, template, region, stackName, state
|
|
|
57749
58382
|
...conditions && { conditions },
|
|
57750
58383
|
skipDynamicReferences: true
|
|
57751
58384
|
});
|
|
57752
|
-
|
|
58385
|
+
const changes = await diffCalculator.calculateDiff(currentState, effectiveTemplate, resolveFn, canonicalizeProperties);
|
|
58386
|
+
const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions);
|
|
58387
|
+
let outputChanges = [];
|
|
58388
|
+
if (resolved.resolutionFailed) {
|
|
58389
|
+
if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stackName} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. It is usually an output referencing a resource this deploy has yet to create.`);
|
|
58390
|
+
} else outputChanges = computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys);
|
|
58391
|
+
return {
|
|
58392
|
+
changes,
|
|
58393
|
+
outputChanges
|
|
58394
|
+
};
|
|
57753
58395
|
}
|
|
57754
58396
|
/**
|
|
57755
58397
|
* Resolve a nested-stack child's input `Parameters` (declared on the parent's
|
|
@@ -57812,12 +58454,14 @@ async function resolveChildStackParameters(parentStackRow, parentTemplate, paren
|
|
|
57812
58454
|
async function buildDiffTree(args) {
|
|
57813
58455
|
const { stackName, displayName, region, template, nestedTemplates, recursive, stateBackend, diffCalculator, parameters, canonicalizeProperties, assetRedirect, cfnFallback } = args;
|
|
57814
58456
|
const state = await loadStateOrEmpty(stackName, region, stateBackend);
|
|
58457
|
+
const { changes, outputChanges } = await computeStackDiff(state, template, region, stackName, stateBackend, diffCalculator, parameters, canonicalizeProperties, cfnFallback);
|
|
57815
58458
|
const node = {
|
|
57816
58459
|
stackName,
|
|
57817
58460
|
displayName,
|
|
57818
58461
|
region,
|
|
57819
|
-
changes
|
|
58462
|
+
changes,
|
|
57820
58463
|
ccApiRoutes: collectCcApiRoutes(template, state),
|
|
58464
|
+
outputChanges,
|
|
57821
58465
|
children: []
|
|
57822
58466
|
};
|
|
57823
58467
|
if (!recursive) return node;
|
|
@@ -57862,12 +58506,14 @@ async function buildDiffTree(args) {
|
|
|
57862
58506
|
*/
|
|
57863
58507
|
async function buildDeletedSubtree(stackName, region, stateBackend, diffCalculator) {
|
|
57864
58508
|
const state = await loadStateOrEmpty(stackName, region, stateBackend);
|
|
58509
|
+
const { changes, outputChanges } = await computeStackDiff(state, EMPTY_TEMPLATE, region, stackName, stateBackend, diffCalculator);
|
|
57865
58510
|
const node = {
|
|
57866
58511
|
stackName,
|
|
57867
58512
|
displayName: stackName,
|
|
57868
58513
|
region,
|
|
57869
|
-
changes
|
|
58514
|
+
changes,
|
|
57870
58515
|
ccApiRoutes: /* @__PURE__ */ new Map(),
|
|
58516
|
+
outputChanges,
|
|
57871
58517
|
children: []
|
|
57872
58518
|
};
|
|
57873
58519
|
for (const [logicalId, resource] of Object.entries(state.resources)) {
|
|
@@ -57925,7 +58571,7 @@ function collectCcApiRoutes(template, state) {
|
|
|
57925
58571
|
/** True when this node has at least one real (non-`NO_CHANGE`) change. */
|
|
57926
58572
|
function nodeHasChanges(node) {
|
|
57927
58573
|
for (const change of node.changes.values()) if (change.changeType !== "NO_CHANGE") return true;
|
|
57928
|
-
return
|
|
58574
|
+
return node.outputChanges.length > 0;
|
|
57929
58575
|
}
|
|
57930
58576
|
/** True when this node OR any descendant has a real change (tree-wide drift detector for `--fail`). */
|
|
57931
58577
|
function treeHasChanges(node) {
|
|
@@ -57956,26 +58602,17 @@ function diffTreeToJson(node) {
|
|
|
57956
58602
|
stack: node.stackName,
|
|
57957
58603
|
region: node.region,
|
|
57958
58604
|
changes,
|
|
58605
|
+
outputChanges: node.outputChanges.map((change) => ({
|
|
58606
|
+
name: change.name,
|
|
58607
|
+
changeType: change.changeType,
|
|
58608
|
+
...change.changeType !== "ADD" && !change.oldValueRedacted ? { oldValue: change.oldValue } : {},
|
|
58609
|
+
...change.oldValueRedacted ? { oldValueRedacted: true } : {},
|
|
58610
|
+
...change.changeType !== "REMOVE" ? { newValue: change.newValue } : {},
|
|
58611
|
+
export: change.isExport
|
|
58612
|
+
})),
|
|
57959
58613
|
children: node.children.map(diffTreeToJson)
|
|
57960
58614
|
};
|
|
57961
58615
|
}
|
|
57962
|
-
const INTRINSIC_KEYS = /* @__PURE__ */ new Set([
|
|
57963
|
-
"Ref",
|
|
57964
|
-
"Fn::Sub",
|
|
57965
|
-
"Fn::GetAtt",
|
|
57966
|
-
"Fn::Join",
|
|
57967
|
-
"Fn::Select",
|
|
57968
|
-
"Fn::Split",
|
|
57969
|
-
"Fn::If",
|
|
57970
|
-
"Fn::ImportValue",
|
|
57971
|
-
"Fn::FindInMap",
|
|
57972
|
-
"Fn::Base64",
|
|
57973
|
-
"Fn::GetAZs",
|
|
57974
|
-
"Fn::Equals",
|
|
57975
|
-
"Fn::And",
|
|
57976
|
-
"Fn::Or",
|
|
57977
|
-
"Fn::Not"
|
|
57978
|
-
]);
|
|
57979
58616
|
function isIntrinsic(value) {
|
|
57980
58617
|
if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return false;
|
|
57981
58618
|
const keys = Object.keys(value);
|
|
@@ -58111,6 +58748,84 @@ function renderChangeLines(changes, logFn, ccApiRoutes) {
|
|
|
58111
58748
|
delete: deleteCount
|
|
58112
58749
|
};
|
|
58113
58750
|
}
|
|
58751
|
+
/** Stand-in printed instead of a withheld legacy-plaintext output value. */
|
|
58752
|
+
const REDACTED_LEGACY_PLAINTEXT = "<redacted: legacy plaintext in state — run `cdkd scrub`>";
|
|
58753
|
+
/**
|
|
58754
|
+
* Strip characters that let a template-controlled string manipulate the
|
|
58755
|
+
* TERMINAL rather than merely appear in it: C0 (incl. ESC, so no ANSI sequence
|
|
58756
|
+
* survives), DEL, C1 (the 8-bit CSI forms some terminals still honor), and the
|
|
58757
|
+
* bidi / format overrides that visually reorder a rendered line.
|
|
58758
|
+
*
|
|
58759
|
+
* Applied only on the human-render path. The `--json` payload is deliberately
|
|
58760
|
+
* left byte-faithful: it is a machine interface, an export NAME is data a
|
|
58761
|
+
* consumer may match on, and silently mutating it there would trade a real
|
|
58762
|
+
* correctness regression for a display concern belonging to whatever renders
|
|
58763
|
+
* the JSON. `JSON.stringify` already escapes everything below 0x20.
|
|
58764
|
+
*/
|
|
58765
|
+
function stripControlChars(value) {
|
|
58766
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
58767
|
+
}
|
|
58768
|
+
/**
|
|
58769
|
+
* The same guard for an already-JSON-SERIALIZED value: C1 and the bidi marks
|
|
58770
|
+
* only, deliberately NOT C0.
|
|
58771
|
+
*
|
|
58772
|
+
* `JSON.stringify` escapes every character below 0x20 that occurs INSIDE a
|
|
58773
|
+
* string, so the only C0 left on this path is the pretty-printer's own
|
|
58774
|
+
* structural newlines — stripping those collapses a multi-line value onto a
|
|
58775
|
+
* single line. What `JSON.stringify` passes through unchanged is the C1 range
|
|
58776
|
+
* and the bidi overrides, and those are what this removes.
|
|
58777
|
+
*/
|
|
58778
|
+
function stripDisplayOnlyChars(value) {
|
|
58779
|
+
return value.replace(/[\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
58780
|
+
}
|
|
58781
|
+
/**
|
|
58782
|
+
* Render one node's Outputs delta (issue #1921) via `logFn`, returning the
|
|
58783
|
+
* per-kind counts. Emits nothing when there is no delta, so an unchanged
|
|
58784
|
+
* Outputs section adds no noise to a resource-only diff.
|
|
58785
|
+
*
|
|
58786
|
+
* Rows are keyed by the PERSISTED bag key, which is what `StackState.outputs`
|
|
58787
|
+
* holds and what the exports index publishes. An output carrying an
|
|
58788
|
+
* `Export.Name` therefore shows TWO rows — its logical name and its export
|
|
58789
|
+
* name — because the deploy writes both keys. That is the useful half for the
|
|
58790
|
+
* motivating case: the `[export]`-tagged row IS the string a downstream
|
|
58791
|
+
* `Fn::ImportValue` resolves, so the user can match it against the consumer
|
|
58792
|
+
* that was failing with "export not found".
|
|
58793
|
+
*/
|
|
58794
|
+
function renderOutputChangeLines(outputChanges, logFn) {
|
|
58795
|
+
const counts = {
|
|
58796
|
+
add: 0,
|
|
58797
|
+
change: 0,
|
|
58798
|
+
remove: 0
|
|
58799
|
+
};
|
|
58800
|
+
if (outputChanges.length === 0) return counts;
|
|
58801
|
+
logFn("\n Outputs:");
|
|
58802
|
+
const indent = " ";
|
|
58803
|
+
const render = (value) => stripDisplayOnlyChars((JSON.stringify(value, null, 2) ?? "undefined").replace(/\n/g, `\n${indent}`));
|
|
58804
|
+
const renderOld = (change) => change.oldValueRedacted ? REDACTED_LEGACY_PLAINTEXT : render(change.oldValue);
|
|
58805
|
+
for (const change of outputChanges) {
|
|
58806
|
+
const exported = change.isExport ? " [export]" : "";
|
|
58807
|
+
const name = stripControlChars(change.name);
|
|
58808
|
+
switch (change.changeType) {
|
|
58809
|
+
case "ADD":
|
|
58810
|
+
counts.add++;
|
|
58811
|
+
logFn(` [+] ${name}${exported}`);
|
|
58812
|
+
logFn(` new: ${render(change.newValue)}`);
|
|
58813
|
+
break;
|
|
58814
|
+
case "MODIFY":
|
|
58815
|
+
counts.change++;
|
|
58816
|
+
logFn(` [~] ${name}${exported}`);
|
|
58817
|
+
logFn(` old: ${renderOld(change)}`);
|
|
58818
|
+
logFn(` new: ${render(change.newValue)}`);
|
|
58819
|
+
break;
|
|
58820
|
+
case "REMOVE":
|
|
58821
|
+
counts.remove++;
|
|
58822
|
+
logFn(` [-] ${name}${exported}`);
|
|
58823
|
+
logFn(` old: ${renderOld(change)}`);
|
|
58824
|
+
break;
|
|
58825
|
+
}
|
|
58826
|
+
}
|
|
58827
|
+
return counts;
|
|
58828
|
+
}
|
|
58114
58829
|
/**
|
|
58115
58830
|
* Render a diff tree (root + nested children, DFS) via `logFn`. Only nodes
|
|
58116
58831
|
* that actually have changes get a block — unchanged nested children are
|
|
@@ -58123,7 +58838,9 @@ function renderDiffTree(node, isRoot, logFn) {
|
|
|
58123
58838
|
if (nodeHasChanges(node)) {
|
|
58124
58839
|
logFn(isRoot ? `\nStack ${node.stackName}:` : `\nNested stack: ${node.displayName}`);
|
|
58125
58840
|
const { create, update, delete: del } = renderChangeLines(node.changes, logFn, node.ccApiRoutes);
|
|
58841
|
+
const outputs = renderOutputChangeLines(node.outputChanges, logFn);
|
|
58126
58842
|
logFn(`\n${create} to create, ${update} to update, ${del} to delete`);
|
|
58843
|
+
if (outputs.add + outputs.change + outputs.remove > 0) logFn(`${outputs.add} output(s) to add, ${outputs.change} to change, ${outputs.remove} to remove`);
|
|
58127
58844
|
}
|
|
58128
58845
|
for (const child of node.children) renderDiffTree(child, false, logFn);
|
|
58129
58846
|
}
|
|
@@ -79390,7 +80107,7 @@ function createMigrateCommand() {
|
|
|
79390
80107
|
*/
|
|
79391
80108
|
function buildProgram() {
|
|
79392
80109
|
const program = new Command();
|
|
79393
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.
|
|
80110
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.28");
|
|
79394
80111
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
79395
80112
|
const { profile } = actionCommand.optsWithGlobals();
|
|
79396
80113
|
if (profile !== void 0) process.env["AWS_PROFILE"] = profile;
|