@go-to-k/cdkd 0.283.9 → 0.283.11
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-D5ZBSgh2.js → asg-provider-C7weaxqp.js} +2 -2
- package/dist/{asg-provider-D5ZBSgh2.js.map → asg-provider-C7weaxqp.js.map} +1 -1
- package/dist/cli.js +1179 -241
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-CWt7h7Nc.js → deploy-engine-DculbwzR.js} +26 -7
- package/dist/deploy-engine-DculbwzR.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-CWt7h7Nc.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 resolveStateBucketWithDefault, A as cyan, An as MissingCdkCliError, At as AssetModeResolver, B as findSilentDropProperties, Bt as getDockerCmd, C as makeCanonicalizePropertiesFn, Ct as stringifyValue, D as renderStatefulReason, Dn as LocalMigrateError, Dt as loadPublishableAssetManifest, E as isStatefulRecreateTargetSync, En as LocalInvokeBuildError$1, Et as createAssetRedirectResolver, F as IAMRoleProvider, Fn as ResourceUpdateNotSupportedError, Ft as validateAssetBucketName, G as IntrinsicFunctionResolver, Gn as isRetryableTransientError, Gt as Synthesizer, H as slowCcOperationTimeoutMs, Hn as normalizeAwsError, Ht as runDockerStreaming, I as collectInlinePolicyNamesManagedBySiblings, In as StackHasActiveImportsError, It as validateContainerRepoName, J as refStateLookupFromResource, Jt as getLegacyStateBucketName, K as cfnRefValueFromPhysicalId, Kn as isThrottlingError, Kt as synthesisStatusMessage, L as clearOnUpdateRemoval, Ln as StackTerminationProtectionError, Lt as buildDenyExternalAccessPolicy, M as green, Mn as PartialFailureError, Mt as ensureAssetStorage, N as red, Nn as ProvisioningError, Nt as getBootstrapMarkerKey, O as formatResourceLine, On as LocalStartServiceError, Ot as rewriteTemplateAssetReferences, P as yellow, Pn as ResourceTimeoutError, Pt as parseBootstrapMarker, Q as assertRegionMatch, Qt as resolveSkipPrefix, R as ProviderRegistry, Rt as buildDockerImage, S as unsupportedFinalSnapshotError, Sn as CdkdError, St as AssetPublisher, T as MULTI_REGION_RECREATE_BLOCKED_TYPES, Tn as DeployCancelledError, Tt as buildAssetRedirectMap, U as disableInstanceApiTermination, Un as withErrorHandling, Ut as AssetManifestLoader, V as CloudControlProvider, Vt as runDockerForeground, W as isTerminationProtectionPropagationError, Wn as isMarkedNonRetryable, Wt as getDockerImageBySourceHash, X as normalizeAwsTagsToCfn, Xt as resolveAutoAssetStorage, Y as WAFv2WebACLProvider, Yt as resolveApp, Z as resolveExplicitPhysicalId, Zt as resolveCaptureObservedState, _ as buildFinalSnapshotIdentifier, _n as AwsClients, _t as TemplateParser, a as DeploymentEventsStore, an as CFN_TEMPLATE_URL_LIMIT, at as requireConfigObject, b as isFinalSnapshotError, bn as setAwsClients, bt as rebuildClientForBucketRegion, c as replayFailedOperations, cn as uploadCfnTemplate, ct as s3BucketDomainName, d as deleteSkipReason, dn as canonicalizeRegion, dt as s3BucketWebsiteUrl, en as resolveStateBucketWithDefaultAndSource, et as configBooleanRefusal, f as withResourceDeadline, fn as derivePartitionAndUrlSuffix, ft as applyRoleArnIfSet, g as PRE_DELETE_SNAPSHOT_TYPES, gn as resolveBucketRegion, gt as DagBuilder, h as ATOMIC_FINAL_SNAPSHOT_TYPES, ht as withRetry, i as DeploymentEventsReader, in as CFN_TEMPLATE_BODY_LIMIT, it as requireConfigArray, j as gray, jn as NestedStackChildDirectDestroyError, jt as BOOTSTRAP_MARKER_PREFIX, k as bold, kt as escapeRegExp, l as replayRollback, ln as expectedOwnerParam, lt as s3BucketDualStackDomainName, m as computeImplicitDeleteEdges, mn as processStackMessages, mt as describeTypeWithThrottleRetry, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as stateBucketExistenceConfirmed, nt as readConfigString, o as planFailedOps, on as MIGRATE_TMP_PREFIX, ot as requireConfigString, p as IMPLICIT_DELETE_DEPENDENCIES, pn as AssemblyReader, pt as DiffCalculator, q as getAccountInfo, qn as markNonRetryable, qt as getDefaultStateBucketName, r as DeployEngine, rn as warnDeprecatedNoPrefixCliFlag, rt as replayWarn, s as planRollback, sn as findLargeInlineResources, st as s3BucketArn, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as resolveUseCdkBootstrapAssets, tt as configStringRefusal, u as UNSPECIFIED_SKIP_REASON, un as PARTITION_TABLE, ut as s3BucketRegionalDomainName, v as ccRoutedFinalSnapshotError, vn as getAwsClients, vt as LockManager, w as extractDeploymentEventError, wt as WorkGraph, x as refusesFinalSnapshot, xt as shouldRetainResource, y as createPreDeleteFinalSnapshot, yn as resetAwsClients, yt as S3StateBackend, z as findActionableSilentDrops, zt as formatDockerLoginError } from "./deploy-engine-DculbwzR.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-C7weaxqp.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";
|
|
@@ -14107,10 +14107,149 @@ const BILLING_FLIP_ACTIVE_WAIT_ATTEMPTS = 600;
|
|
|
14107
14107
|
* though the template had asked, and `--revert` can never clear it because the
|
|
14108
14108
|
* write gate skips the falsy value. Keep this the ONLY spelling of the rule.
|
|
14109
14109
|
*
|
|
14110
|
-
*
|
|
14110
|
+
* It USED to be bare truthiness, "because that is what the wire has always
|
|
14111
|
+
* done". PR review of issue #1768 showed that is not a rule, it is the absence
|
|
14112
|
+
* of one: `WarmThroughput: {}` and `WarmThroughput: 'nonsense'` are both truthy,
|
|
14113
|
+
* so they went out as `UpdateTable{WarmThroughput: {}}` /
|
|
14114
|
+
* `{WarmThroughput: 'nonsense'}` — a call that can only be rejected, and one
|
|
14115
|
+
* cdkd would repeat on every deploy. Harmless-looking at the table level (one
|
|
14116
|
+
* doomed call, loudly); newly REACHABLE per index once issue #1768's per-index
|
|
14117
|
+
* send path landed, where the same value is emitted once per index. So the rule
|
|
14118
|
+
* is now: at least ONE of the two user-settable members must resolve to a
|
|
14119
|
+
* finite number.
|
|
14120
|
+
*
|
|
14121
|
+
* Still not `!== undefined`, and still ONE spelling for the write sites and the
|
|
14122
|
+
* drift side both: a value this refuses is a value cdkd does not send, so the
|
|
14123
|
+
* readback must not emit AWS's computed value as though the template had asked
|
|
14124
|
+
* for it. A YAML-borne numeric STRING is accepted, via {@link capacityNumber},
|
|
14125
|
+
* because `'12000'` genuinely means 12000.
|
|
14111
14126
|
*/
|
|
14112
14127
|
function isSendableWarmThroughput(value) {
|
|
14113
|
-
return
|
|
14128
|
+
return coerceWarmThroughput(value) !== void 0;
|
|
14129
|
+
}
|
|
14130
|
+
/**
|
|
14131
|
+
* Turn a template-declared `WarmThroughput` into the NUMERIC spec cdkd puts on
|
|
14132
|
+
* the wire, or `undefined` when nothing in it is usable (PR review round 5).
|
|
14133
|
+
*
|
|
14134
|
+
* The send sites used to forward the declared value VERBATIM while
|
|
14135
|
+
* {@link isSendableWarmThroughput} accepted a numeric STRING, so the predicate
|
|
14136
|
+
* blessed the one shape it exists to stop: `{ReadUnitsPerSecond: '12000'}` went
|
|
14137
|
+
* out as `"WarmThroughput":{"ReadUnitsPerSecond":"12000"}` — a string in a Long
|
|
14138
|
+
* field, which DynamoDB rejects — and the drift side then answered "declared"
|
|
14139
|
+
* for it, so the readback emitted AWS's numeric value and the table drifted on
|
|
14140
|
+
* every run. Coercing rather than dropping string support is the better answer
|
|
14141
|
+
* for a stringly-typed CFn template (`'12000'` genuinely means 12000, and
|
|
14142
|
+
* `create()`'s table-level `ProvisionedThroughput` already coerces one line
|
|
14143
|
+
* over), and it makes the quoted form WORK instead of merely warning.
|
|
14144
|
+
*
|
|
14145
|
+
* Per MEMBER, so a bad one cannot take a good one with it, and never `NaN`:
|
|
14146
|
+
* {@link capacityNumber} accepts a number or a numeric string and rejects
|
|
14147
|
+
* everything else, so a member that does not resolve is OMITTED from the spec
|
|
14148
|
+
* and reported in `dropped` for the caller to announce. AWS accepts a
|
|
14149
|
+
* one-member `WarmThroughput` (measured us-east-1, 2026-08-13: an
|
|
14150
|
+
* `UpdateTable` carrying only `ReadUnitsPerSecond` is a valid request shape —
|
|
14151
|
+
* it was refused for being a DECREASE, not for its shape), so a partial send
|
|
14152
|
+
* is a real request rather than a malformed one.
|
|
14153
|
+
*
|
|
14154
|
+
* {@link isSendableWarmThroughput} is defined AS this function's success, so
|
|
14155
|
+
* the drift-side gate and the write-side coercion cannot answer differently —
|
|
14156
|
+
* structurally, not by hand. That identity is exact at the BLOCK level and
|
|
14157
|
+
* ONLY there (PR review round 6, where the earlier wording was measured and
|
|
14158
|
+
* found broader than the truth): both sides ask "is any member usable", so a
|
|
14159
|
+
* block cdkd sends is a block drift compares, and a block cdkd refuses is one
|
|
14160
|
+
* drift ignores.
|
|
14161
|
+
*
|
|
14162
|
+
* PER MEMBER it does NOT hold, and the residual is real:
|
|
14163
|
+
* `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: {Ref: 'Unset'}}` sends only
|
|
14164
|
+
* `ReadUnitsPerSecond`, while `readCurrentState` emits AWS's computed value for
|
|
14165
|
+
* BOTH members and `getDriftUnknownPaths` leaves the path compared — so AWS's
|
|
14166
|
+
* `WriteUnitsPerSecond`, which cdkd never sent, is compared against the
|
|
14167
|
+
* template forever. It fails OPEN — the difference is REPORTED by `cdkd drift`,
|
|
14168
|
+
* never hidden — and {@link DynamoDBTableProvider.coerceWarmThroughputForSend}
|
|
14169
|
+
* names the dropped member when the value is APPLIED.
|
|
14170
|
+
*
|
|
14171
|
+
* How loud, precisely (measured, PR review round 7 — an earlier version of this
|
|
14172
|
+
* sentence said "on every deploy" and was falsified by the code two hundred
|
|
14173
|
+
* lines down): every warn in this provider sits behind a CHANGE gate, so the
|
|
14174
|
+
* drop is announced on the deploy that INTRODUCES it and is silent on every
|
|
14175
|
+
* repeat deploy of the same value, while the drift report persists. Loud once,
|
|
14176
|
+
* then a standing drift entry — not a per-deploy nag, and not silent either.
|
|
14177
|
+
* Closing the residual means emitting per-member, which is a `readCurrentState`
|
|
14178
|
+
* shape change with its own drift-baseline migration — deliberately not folded
|
|
14179
|
+
* into this issue.
|
|
14180
|
+
*
|
|
14181
|
+
* NOT a drift fix either way: state records what the TEMPLATE said, so a
|
|
14182
|
+
* `properties` baseline still holds `'12000'` against a numeric readback and
|
|
14183
|
+
* the comparator's `deepEqual` is strict about that. It is the generic
|
|
14184
|
+
* stringly-typed-CFn class (every numeric property has it), not something this
|
|
14185
|
+
* coercion can reach from the write side.
|
|
14186
|
+
*/
|
|
14187
|
+
function coerceWarmThroughput(value) {
|
|
14188
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
14189
|
+
const bag = value;
|
|
14190
|
+
const spec = {};
|
|
14191
|
+
const dropped = [];
|
|
14192
|
+
let usable = 0;
|
|
14193
|
+
for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
|
|
14194
|
+
if (bag[member] === void 0) continue;
|
|
14195
|
+
const n = capacityNumber(bag[member]);
|
|
14196
|
+
if (n === void 0) {
|
|
14197
|
+
dropped.push(member);
|
|
14198
|
+
continue;
|
|
14199
|
+
}
|
|
14200
|
+
spec[member] = n;
|
|
14201
|
+
usable++;
|
|
14202
|
+
}
|
|
14203
|
+
if (usable === 0) return void 0;
|
|
14204
|
+
return {
|
|
14205
|
+
spec,
|
|
14206
|
+
dropped
|
|
14207
|
+
};
|
|
14208
|
+
}
|
|
14209
|
+
/**
|
|
14210
|
+
* Is this a `WarmThroughput` the user WROTE that the tightened send rule then
|
|
14211
|
+
* refuses (PR review of issue #1768)?
|
|
14212
|
+
*
|
|
14213
|
+
* The set of values whose OUTCOME this issue changed, and the exact set the
|
|
14214
|
+
* write sites must warn about. Tightening {@link isSendableWarmThroughput}
|
|
14215
|
+
* closed a doomed-call class, and silently: a template declaring
|
|
14216
|
+
* `{ ReadUnitsPerSecnd: 20000 }` (a member typo), `'nonsense'`, `{}`, or an
|
|
14217
|
+
* unresolved intrinsic used to reach AWS and be REJECTED BY NAME, and now
|
|
14218
|
+
* vanishes with no warning, no debug line, and a green deploy — the "loud
|
|
14219
|
+
* failure for a quiet lie" trade the adopted-index arm in `applyGsiUpdates`
|
|
14220
|
+
* refuses, and out of step with every other skip in this file
|
|
14221
|
+
* ({@link DynamoDBTableProvider.skipZeroCapacityIndexUpdate},
|
|
14222
|
+
* {@link DynamoDBTableProvider.skipWarmThroughputDecrease}), which all warn.
|
|
14223
|
+
*
|
|
14224
|
+
* ABSENT is silent, because that is the ordinary case — the overwhelming
|
|
14225
|
+
* majority of templates declare no warm throughput at all and must not be
|
|
14226
|
+
* nagged. FALSY-but-present (`null`, `''`, `false`, `0`) is silent too: those
|
|
14227
|
+
* were skipped silently BEFORE this issue as well (`Boolean(value)` was the
|
|
14228
|
+
* whole rule), so they are not a behavior this PR changed and warning about
|
|
14229
|
+
* them would be a new noise source, not a restored signal.
|
|
14230
|
+
*/
|
|
14231
|
+
function isRefusedWarmThroughput(value) {
|
|
14232
|
+
if (value === void 0) return false;
|
|
14233
|
+
if (!value) return false;
|
|
14234
|
+
return !isSendableWarmThroughput(value);
|
|
14235
|
+
}
|
|
14236
|
+
/**
|
|
14237
|
+
* Does the DESIRED bag carry enough for the drift side to answer "what did the
|
|
14238
|
+
* template declare"?
|
|
14239
|
+
*
|
|
14240
|
+
* An ABSENT or EMPTY bag answers NO: `drift.ts` passes `properties ?? {}`, and
|
|
14241
|
+
* other callers pass nothing at all, so `{}` means "nothing recorded" rather
|
|
14242
|
+
* than "this key is absent" (the contract on {@link ResourceProvider.getDriftUnknownPaths}).
|
|
14243
|
+
* Every gate below falls back to the pre-gate behavior on a NO, since dropping
|
|
14244
|
+
* a key on the strength of a bag that was never populated is unrecoverable
|
|
14245
|
+
* phantom drift in the other direction.
|
|
14246
|
+
*
|
|
14247
|
+
* Named once and shared by the table-level `WarmThroughput` gate (issue #1760)
|
|
14248
|
+
* and the per-index gates (issue #1767) so the two cannot answer the
|
|
14249
|
+
* "uninformative bag" question differently.
|
|
14250
|
+
*/
|
|
14251
|
+
function desiredBagIsInformative(properties) {
|
|
14252
|
+
return properties !== void 0 && Object.keys(properties).length > 0;
|
|
14114
14253
|
}
|
|
14115
14254
|
/**
|
|
14116
14255
|
* Does this desired-property bag DECLARE a table-level `WarmThroughput`?
|
|
@@ -14133,8 +14272,267 @@ function isSendableWarmThroughput(value) {
|
|
|
14133
14272
|
* phantom drift, while the residual is a loud, per-resource revert failure.
|
|
14134
14273
|
*/
|
|
14135
14274
|
function declaresWarmThroughput(properties) {
|
|
14136
|
-
if (
|
|
14137
|
-
return isSendableWarmThroughput(properties["WarmThroughput"]);
|
|
14275
|
+
if (!desiredBagIsInformative(properties)) return true;
|
|
14276
|
+
return properties !== void 0 && isSendableWarmThroughput(properties["WarmThroughput"]);
|
|
14277
|
+
}
|
|
14278
|
+
/**
|
|
14279
|
+
* Is the WarmThroughput this update would SEND a DECREASE of what AWS already
|
|
14280
|
+
* holds (issue #1768)?
|
|
14281
|
+
*
|
|
14282
|
+
* Measured live (us-east-1, 2026-08-13) against a table AWS reports
|
|
14283
|
+
* `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
|
|
14284
|
+
*
|
|
14285
|
+
* ```
|
|
14286
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
|
|
14287
|
+
* ValidationException: One or more parameter values were invalid: Requested
|
|
14288
|
+
* ReadUnitsPerSecond for WarmThroughput for table is lower than current
|
|
14289
|
+
* WarmThroughput, decreasing WarmThroughput is not supported
|
|
14290
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
|
|
14291
|
+
* UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
|
|
14292
|
+
* UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
|
|
14293
|
+
* ```
|
|
14294
|
+
*
|
|
14295
|
+
* So the value is one AWS raises with the table's traffic and never lowers,
|
|
14296
|
+
* and a decrease is REJECTED rather than accepted-and-ignored — which is what
|
|
14297
|
+
* the issue said to measure before choosing, because the two produce different
|
|
14298
|
+
* correct answers. The caller SKIPS the call on a true here.
|
|
14299
|
+
*
|
|
14300
|
+
* Rules, each one chosen so the skip is right for EVERY `update()` caller
|
|
14301
|
+
* (the deploy engine, `cdkd drift --revert`, and the rollback executor's two
|
|
14302
|
+
* revert arms — none of which can make AWS lower the value, so none of them
|
|
14303
|
+
* loses anything a doomed call would have achieved):
|
|
14304
|
+
*
|
|
14305
|
+
* - Only the members the desired side DECLARES are examined; an omitted member
|
|
14306
|
+
* is not sent and cannot be a decrease.
|
|
14307
|
+
* - EVERY declared member must be at-or-below its live counterpart AND at
|
|
14308
|
+
* least one strictly below. A MIXED request (one member up, one down) still
|
|
14309
|
+
* goes out: AWS rejects it by name, which is the pre-fix behavior and better
|
|
14310
|
+
* than silently dropping the increase the user asked for.
|
|
14311
|
+
* - Anything not resolvable to a finite number fails OPEN (the call is still
|
|
14312
|
+
* issued): the worst case is the pre-fix behavior, whereas a false positive
|
|
14313
|
+
* would silently drop a real increase. Only the LIVE side can actually reach
|
|
14314
|
+
* that arm now (PR review round 8): the desired side is a COERCED spec, so a
|
|
14315
|
+
* malformed template value or an unresolved intrinsic has already been
|
|
14316
|
+
* dropped by {@link coerceWarmThroughput} and cannot arrive here. The check
|
|
14317
|
+
* is kept for the live side, which is whatever `DescribeTable` returned.
|
|
14318
|
+
*/
|
|
14319
|
+
function isWarmThroughputDecrease(desired, live) {
|
|
14320
|
+
if (live === void 0) return false;
|
|
14321
|
+
let sawDecrease = false;
|
|
14322
|
+
for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
|
|
14323
|
+
if (desired[member] === void 0) continue;
|
|
14324
|
+
const wanted = capacityNumber(desired[member]);
|
|
14325
|
+
const current = capacityNumber(live[member]);
|
|
14326
|
+
if (wanted === void 0 || current === void 0) return false;
|
|
14327
|
+
if (wanted > current) return false;
|
|
14328
|
+
if (wanted < current) sawDecrease = true;
|
|
14329
|
+
}
|
|
14330
|
+
return sawDecrease;
|
|
14331
|
+
}
|
|
14332
|
+
/**
|
|
14333
|
+
* Does the warm throughput AWS currently holds already equal what this update
|
|
14334
|
+
* would request (issue #1768 PR review)?
|
|
14335
|
+
*
|
|
14336
|
+
* The warm-throughput twin of {@link liveCapacityAlreadyMatches}, and compared
|
|
14337
|
+
* the same way and for the same reason: the live side is a
|
|
14338
|
+
* `...WarmThroughputDescription` carrying an AWS-managed `Status` alongside the
|
|
14339
|
+
* two units, so a structural compare against the two-member desired object
|
|
14340
|
+
* could never match and the suppression would be dead code that only LOOKED
|
|
14341
|
+
* safe.
|
|
14342
|
+
*
|
|
14343
|
+
* Only the members the desired side DECLARES are compared — an omitted member
|
|
14344
|
+
* is not part of the request, so it cannot make the request differ. Anything
|
|
14345
|
+
* unresolvable fails OPEN (the call is still issued): the worst case is a
|
|
14346
|
+
* redundant `UpdateTable`, whereas a false MATCH silently drops a
|
|
14347
|
+
* warm-throughput raise the user asked for. As in
|
|
14348
|
+
* {@link isWarmThroughputDecrease}, only the LIVE side can reach that arm now
|
|
14349
|
+
* that the desired side is a coerced spec.
|
|
14350
|
+
*
|
|
14351
|
+
* Deliberately NOT applied to the TABLE-level branch, which still sends an
|
|
14352
|
+
* equal re-assert: that is measured-accepted by AWS (us-east-1, 2026-08-13 —
|
|
14353
|
+
* re-asserting `{12000, 4000}` returns a normal `TableDescription`), it is ONE
|
|
14354
|
+
* call rather than one per index, and its own change gate already requires the
|
|
14355
|
+
* value to differ from the recorded previous. The per-index path is where the
|
|
14356
|
+
* redundancy multiplies and where the carried #1767 residual reaches it.
|
|
14357
|
+
*/
|
|
14358
|
+
function warmThroughputAlreadyMatches(desired, live) {
|
|
14359
|
+
if (live === void 0) return false;
|
|
14360
|
+
let compared = 0;
|
|
14361
|
+
for (const member of ["ReadUnitsPerSecond", "WriteUnitsPerSecond"]) {
|
|
14362
|
+
if (desired[member] === void 0) continue;
|
|
14363
|
+
const wanted = capacityNumber(desired[member]);
|
|
14364
|
+
const current = capacityNumber(live[member]);
|
|
14365
|
+
if (wanted === void 0 || current === void 0) return false;
|
|
14366
|
+
if (wanted !== current) return false;
|
|
14367
|
+
compared++;
|
|
14368
|
+
}
|
|
14369
|
+
return compared > 0;
|
|
14370
|
+
}
|
|
14371
|
+
/**
|
|
14372
|
+
* Reverse-map one live secondary-index description to its CloudFormation
|
|
14373
|
+
* shape (issue #1767).
|
|
14374
|
+
*
|
|
14375
|
+
* `DescribeTable` describes an index with a superset of the CFn members —
|
|
14376
|
+
* measured live, us-east-1 2026-08-13, on a `PAY_PER_REQUEST` table with one
|
|
14377
|
+
* GSI and no capacity settings:
|
|
14378
|
+
*
|
|
14379
|
+
* ```json
|
|
14380
|
+
* { "IndexName": "gsi1",
|
|
14381
|
+
* "KeySchema": [{ "AttributeName": "gsipk", "KeyType": "HASH" }],
|
|
14382
|
+
* "Projection": { "ProjectionType": "ALL" },
|
|
14383
|
+
* "IndexStatus": "CREATING", "Backfilling": true,
|
|
14384
|
+
* "ProvisionedThroughput": { "NumberOfDecreasesToday": 0, "ReadCapacityUnits": 0, "WriteCapacityUnits": 0 },
|
|
14385
|
+
* "IndexSizeBytes": 0, "ItemCount": 0,
|
|
14386
|
+
* "IndexArn": "arn:aws:dynamodb:...:table/.../index/gsi1",
|
|
14387
|
+
* "WarmThroughput": { "ReadUnitsPerSecond": 12000, "WriteUnitsPerSecond": 4000, "Status": "UPDATING" } }
|
|
14388
|
+
* ```
|
|
14389
|
+
*
|
|
14390
|
+
* NONE of `IndexStatus` / `Backfilling` / `IndexSizeBytes` / `ItemCount` /
|
|
14391
|
+
* `IndexArn` is a CFn property, the `ProvisionedThroughput` block is AWS's
|
|
14392
|
+
* `{0, 0}` on-demand placeholder (the #1571 trap), and the per-index
|
|
14393
|
+
* `WarmThroughput` is the AWS-computed value the table-level gate already
|
|
14394
|
+
* refuses to surface (issue #1760). Forwarding the description verbatim put
|
|
14395
|
+
* all of them into the drift comparison, and — unlike a top-level key the
|
|
14396
|
+
* baseline lacks — they are members of an ARRAY the baseline DOES carry, which
|
|
14397
|
+
* `deepEqual` compares positionally, so every one of them participated.
|
|
14398
|
+
*
|
|
14399
|
+
* The mapper is therefore an ALLOW-LIST rather than a deny-list: a member AWS
|
|
14400
|
+
* adds later is dropped by construction instead of silently joining the
|
|
14401
|
+
* comparison.
|
|
14402
|
+
*
|
|
14403
|
+
* `desired` is the DESIRED bag's entry for the SAME `IndexName` (matched by
|
|
14404
|
+
* name, never by position — the list is an unordered set, see
|
|
14405
|
+
* {@link DynamoDBTableProvider.getDriftUnorderedPaths}). The three throughput
|
|
14406
|
+
* blocks are emitted only when that entry declares them, the #1760 shape one
|
|
14407
|
+
* nesting level down; `bagInformative` false means the caller supplied no bag
|
|
14408
|
+
* to decide with and every block is emitted, cleaned of its AWS-managed
|
|
14409
|
+
* members.
|
|
14410
|
+
*
|
|
14411
|
+
* Known residual, unchanged by this fix: CFn's per-index
|
|
14412
|
+
* `ContributorInsightsSpecification` has no `DescribeTable` counterpart (it
|
|
14413
|
+
* needs a per-index `DescribeContributorInsights` call), so a template
|
|
14414
|
+
* declaring one still cannot converge against a `properties` baseline. It is
|
|
14415
|
+
* ALSO dropped on the write side, since `create()` forwards the CFn blob to
|
|
14416
|
+
* `CreateTable` and the SDK serializer discards the unknown member — still
|
|
14417
|
+
* true after round 6 taught that path to coerce `WarmThroughput`, because the
|
|
14418
|
+
* entry is rebuilt with a SPREAD and every other member, known or not, rides
|
|
14419
|
+
* along unchanged. Filed as issue
|
|
14420
|
+
* [#1782](https://github.com/go-to-k/cdkd/issues/1782).
|
|
14421
|
+
*/
|
|
14422
|
+
function reverseMapSecondaryIndex(live, desired, bagInformative) {
|
|
14423
|
+
const out = {};
|
|
14424
|
+
if (live.IndexName !== void 0) out["IndexName"] = live.IndexName;
|
|
14425
|
+
if (live.KeySchema !== void 0) out["KeySchema"] = live.KeySchema.map((element) => ({ ...element }));
|
|
14426
|
+
if (live.Projection !== void 0) {
|
|
14427
|
+
const projection = {};
|
|
14428
|
+
if (live.Projection.ProjectionType !== void 0) projection["ProjectionType"] = live.Projection.ProjectionType;
|
|
14429
|
+
if (live.Projection.NonKeyAttributes !== void 0) projection["NonKeyAttributes"] = [...live.Projection.NonKeyAttributes];
|
|
14430
|
+
if (Object.keys(projection).length > 0) out["Projection"] = projection;
|
|
14431
|
+
}
|
|
14432
|
+
if ("ProvisionedThroughput" in live && indexDeclares(desired, "ProvisionedThroughput", bagInformative) && live.ProvisionedThroughput) {
|
|
14433
|
+
const provisioned = live.ProvisionedThroughput;
|
|
14434
|
+
const pt = {};
|
|
14435
|
+
if (provisioned.ReadCapacityUnits !== void 0) pt["ReadCapacityUnits"] = provisioned.ReadCapacityUnits;
|
|
14436
|
+
if (provisioned.WriteCapacityUnits !== void 0) pt["WriteCapacityUnits"] = provisioned.WriteCapacityUnits;
|
|
14437
|
+
if (Object.keys(pt).length > 0) out["ProvisionedThroughput"] = pt;
|
|
14438
|
+
}
|
|
14439
|
+
if ("OnDemandThroughput" in live && indexDeclares(desired, "OnDemandThroughput", bagInformative) && live.OnDemandThroughput) {
|
|
14440
|
+
const onDemand = live.OnDemandThroughput;
|
|
14441
|
+
const odt = {};
|
|
14442
|
+
if (onDemand.MaxReadRequestUnits !== void 0) odt["MaxReadRequestUnits"] = onDemand.MaxReadRequestUnits;
|
|
14443
|
+
if (onDemand.MaxWriteRequestUnits !== void 0) odt["MaxWriteRequestUnits"] = onDemand.MaxWriteRequestUnits;
|
|
14444
|
+
if (Object.keys(odt).length > 0) out["OnDemandThroughput"] = odt;
|
|
14445
|
+
}
|
|
14446
|
+
if ("WarmThroughput" in live && indexDeclares(desired, "WarmThroughput", bagInformative) && live.WarmThroughput) {
|
|
14447
|
+
const warm = live.WarmThroughput;
|
|
14448
|
+
const wt = {};
|
|
14449
|
+
if (warm.ReadUnitsPerSecond !== void 0) wt["ReadUnitsPerSecond"] = warm.ReadUnitsPerSecond;
|
|
14450
|
+
if (warm.WriteUnitsPerSecond !== void 0) wt["WriteUnitsPerSecond"] = warm.WriteUnitsPerSecond;
|
|
14451
|
+
if (Object.keys(wt).length > 0) out["WarmThroughput"] = wt;
|
|
14452
|
+
}
|
|
14453
|
+
return out;
|
|
14454
|
+
}
|
|
14455
|
+
/**
|
|
14456
|
+
* Does the DESIRED entry for one index declare this block (issue #1767)?
|
|
14457
|
+
*
|
|
14458
|
+
* The question is always "would cdkd SEND this block", because that is what
|
|
14459
|
+
* makes AWS's readback value explainable by the template — so each block is
|
|
14460
|
+
* answered by the write rule that actually governs it, never by a second
|
|
14461
|
+
* spelling of one:
|
|
14462
|
+
*
|
|
14463
|
+
* - `WarmThroughput` routes through {@link isSendableWarmThroughput}, the
|
|
14464
|
+
* predicate EVERY per-index write path calls — the `Create` / `Update` GSI
|
|
14465
|
+
* actions in `applyGsiUpdates` AND `create()`'s `CreateTable` forward, which
|
|
14466
|
+
* maps each entry through
|
|
14467
|
+
* {@link DynamoDBTableProvider.coerceIndexWarmThroughputForCreate}. It is
|
|
14468
|
+
* TIGHTER than truthiness (at least one member resolving to a finite
|
|
14469
|
+
* number), so `WarmThroughput: {}` is neither sent nor emitted on any path.
|
|
14470
|
+
* An earlier version of this bullet carved out the TABLE-create path as an
|
|
14471
|
+
* exception, and that exception was a DEFECT rather than a design: it also
|
|
14472
|
+
* put a quoted `'12000'` on the wire as a string in a Long field, so one
|
|
14473
|
+
* template failed on a fresh create and succeeded on a later GSI add. Fixed
|
|
14474
|
+
* in PR review round 6; the carve-out is gone because the divergence is.
|
|
14475
|
+
* - `ProvisionedThroughput` / `OnDemandThroughput` keep TRUTHINESS, which is
|
|
14476
|
+
* what their write path does: those two members ARE still forwarded verbatim
|
|
14477
|
+
* (the create-path mapper rewrites only `WarmThroughput`), and
|
|
14478
|
+
* `applyGsiUpdates` gates on `gsi.ProvisionedThroughput` alone. Widening
|
|
14479
|
+
* `isSendableWarmThroughput` over them would be wrong twice over — it reads
|
|
14480
|
+
* members those blocks do not have (`ReadUnitsPerSecond` vs
|
|
14481
|
+
* `ReadCapacityUnits`), so every declared capacity would read as undeclared.
|
|
14482
|
+
*
|
|
14483
|
+
* An UNINFORMATIVE bag answers TRUE for every block, keeping the pre-#1767
|
|
14484
|
+
* behavior for a caller that supplied nothing to decide with. A bag that IS
|
|
14485
|
+
* informative but names no entry for this index answers FALSE: the index
|
|
14486
|
+
* exists in AWS and not in the template, so nothing cdkd sent can explain any
|
|
14487
|
+
* of its throughput blocks.
|
|
14488
|
+
*/
|
|
14489
|
+
function indexDeclares(desired, key, bagInformative) {
|
|
14490
|
+
if (!bagInformative) return true;
|
|
14491
|
+
const value = desired?.[key];
|
|
14492
|
+
return key === "WarmThroughput" ? isSendableWarmThroughput(value) : Boolean(value);
|
|
14493
|
+
}
|
|
14494
|
+
/**
|
|
14495
|
+
* Is this value an index list that DECLARES at least one index (issue #1767)?
|
|
14496
|
+
*
|
|
14497
|
+
* The ONE spelling of that question, shared by {@link desiredIndexEntriesByName}
|
|
14498
|
+
* (which decides what each live index is matched against) and by
|
|
14499
|
+
* {@link DynamoDBTableProvider.getDriftUnknownPaths} (which decides whether the
|
|
14500
|
+
* list is compared at all). Spelled independently they disagreed: a plain
|
|
14501
|
+
* truthiness test reads `[]` and an unresolved `{Fn::If: [...]}` as DECLARED —
|
|
14502
|
+
* so the list stayed in the comparison while the mapper, which needs an ARRAY
|
|
14503
|
+
* to match names against, treated the same value as declaring nothing and
|
|
14504
|
+
* stripped every throughput block from the readback. That is a one-sided
|
|
14505
|
+
* difference manufactured by the pair, which is the failure this file's
|
|
14506
|
+
* `isSendableWarmThroughput` header exists to prevent.
|
|
14507
|
+
*
|
|
14508
|
+
* Consequence worth stating: a template declaring `GlobalSecondaryIndexes: []`
|
|
14509
|
+
* now has that path IGNORED by the comparator, so an index created out of band
|
|
14510
|
+
* on such a table is not reported. That is the same answer the readback gives
|
|
14511
|
+
* (nothing the template declared can explain that index), and consistency
|
|
14512
|
+
* between the two is worth more than detection on a shape where the two used to
|
|
14513
|
+
* contradict each other.
|
|
14514
|
+
*/
|
|
14515
|
+
function declaresIndexList(value) {
|
|
14516
|
+
return Array.isArray(value) && value.length > 0;
|
|
14517
|
+
}
|
|
14518
|
+
/**
|
|
14519
|
+
* Index the DESIRED bag's secondary-index list by `IndexName` (issue #1767).
|
|
14520
|
+
*
|
|
14521
|
+
* Returns an empty map for anything {@link declaresIndexList} rejects — an
|
|
14522
|
+
* empty array, an unresolved intrinsic, a mis-nested template value — which
|
|
14523
|
+
* routes every live index through {@link indexDeclares}'s "declares nothing"
|
|
14524
|
+
* arm. Sharing that predicate with `getDriftUnknownPaths` is what keeps the
|
|
14525
|
+
* emission and the comparison from answering differently for the same bag.
|
|
14526
|
+
*/
|
|
14527
|
+
function desiredIndexEntriesByName(value) {
|
|
14528
|
+
const byName = /* @__PURE__ */ new Map();
|
|
14529
|
+
if (!declaresIndexList(value)) return byName;
|
|
14530
|
+
for (const entry of value) {
|
|
14531
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
14532
|
+
const name = entry["IndexName"];
|
|
14533
|
+
if (typeof name === "string") byName.set(name, entry);
|
|
14534
|
+
}
|
|
14535
|
+
return byName;
|
|
14138
14536
|
}
|
|
14139
14537
|
var DynamoDBTableProvider = class {
|
|
14140
14538
|
dynamoDBClient;
|
|
@@ -14192,12 +14590,17 @@ var DynamoDBTableProvider = class {
|
|
|
14192
14590
|
};
|
|
14193
14591
|
}
|
|
14194
14592
|
if (properties["OnDemandThroughput"]) createParams.OnDemandThroughput = properties["OnDemandThroughput"];
|
|
14195
|
-
|
|
14593
|
+
const createWarmThroughput = this.coerceWarmThroughputForSend(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
|
|
14594
|
+
if (createWarmThroughput) createParams.WarmThroughput = createWarmThroughput;
|
|
14595
|
+
else this.warnRefusedWarmThroughput(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
|
|
14196
14596
|
if (properties["StreamSpecification"]) createParams.StreamSpecification = {
|
|
14197
14597
|
StreamEnabled: true,
|
|
14198
14598
|
StreamViewType: properties["StreamSpecification"]["StreamViewType"]
|
|
14199
14599
|
};
|
|
14200
|
-
if (properties["GlobalSecondaryIndexes"])
|
|
14600
|
+
if (properties["GlobalSecondaryIndexes"]) {
|
|
14601
|
+
const declaredGsis = properties["GlobalSecondaryIndexes"];
|
|
14602
|
+
createParams.GlobalSecondaryIndexes = Array.isArray(declaredGsis) ? declaredGsis.map((entry) => this.coerceIndexWarmThroughputForCreate(logicalId, entry)) : declaredGsis;
|
|
14603
|
+
}
|
|
14201
14604
|
if (properties["LocalSecondaryIndexes"]) createParams.LocalSecondaryIndexes = properties["LocalSecondaryIndexes"];
|
|
14202
14605
|
const sse = mapSSESpecification(properties["SSESpecification"]);
|
|
14203
14606
|
if (sse && Object.keys(sse).length > 0) createParams.SSESpecification = sse;
|
|
@@ -14355,10 +14758,12 @@ var DynamoDBTableProvider = class {
|
|
|
14355
14758
|
}
|
|
14356
14759
|
}
|
|
14357
14760
|
if (JSON.stringify(properties["WarmThroughput"]) !== JSON.stringify(previousProperties["WarmThroughput"])) {
|
|
14358
|
-
|
|
14761
|
+
this.warnRefusedWarmThroughput(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
|
|
14762
|
+
const warmThroughputToSend = this.coerceWarmThroughputForSend(`AWS::DynamoDB::Table ${logicalId}`, properties["WarmThroughput"]);
|
|
14763
|
+
if (warmThroughputToSend !== void 0 && !this.skipWarmThroughputDecrease(logicalId, physicalId, warmThroughputToSend, table?.WarmThroughput)) {
|
|
14359
14764
|
await this.dynamoDBClient.send(new UpdateTableCommand({
|
|
14360
14765
|
TableName: physicalId,
|
|
14361
|
-
WarmThroughput:
|
|
14766
|
+
WarmThroughput: warmThroughputToSend
|
|
14362
14767
|
}));
|
|
14363
14768
|
await this.waitForTableActiveAfterUpdate(physicalId);
|
|
14364
14769
|
this.logger.debug(`Updated WarmThroughput on DynamoDB table ${physicalId}`);
|
|
@@ -14822,6 +15227,32 @@ var DynamoDBTableProvider = class {
|
|
|
14822
15227
|
* between GSI mutations because a freshly-created index keeps backfilling
|
|
14823
15228
|
* after the table itself returns to ACTIVE, and AWS rejects the next GSI op
|
|
14824
15229
|
* until the prior index settles.
|
|
15230
|
+
*
|
|
15231
|
+
* `WarmThroughput.Status` is deliberately NOT part of the predicate, and that
|
|
15232
|
+
* is MEASURED rather than assumed (PR review of issue #1768 raised it as a
|
|
15233
|
+
* suspected race, since the per-index warm-throughput send path this provider
|
|
15234
|
+
* gained has no other reason to wait). Live, us-east-1 2026-08-13, on a
|
|
15235
|
+
* PAY_PER_REQUEST table with two ACTIVE GSIs: raising `gsi1`'s warm
|
|
15236
|
+
* throughput 12000/4000 -> 14000/5000 left
|
|
15237
|
+
*
|
|
15238
|
+
* ```
|
|
15239
|
+
* TableStatus: ACTIVE | gsi1: IndexStatus ACTIVE, WarmThroughput.Status UPDATING
|
|
15240
|
+
* | gsi2: IndexStatus ACTIVE, WarmThroughput.Status ACTIVE
|
|
15241
|
+
* ```
|
|
15242
|
+
*
|
|
15243
|
+
* for 90+ seconds — so this predicate IS satisfied while a warm update is
|
|
15244
|
+
* still settling, exactly as the review described. What does not follow is
|
|
15245
|
+
* the failure: with `gsi1` in that state, all three next-op shapes were
|
|
15246
|
+
* ACCEPTED, no `ResourceInUseException` —
|
|
15247
|
+
*
|
|
15248
|
+
* - a warm-throughput `Update` on the OTHER index (`gsi2`);
|
|
15249
|
+
* - a warm-throughput `Update` on the SAME index (`gsi1`);
|
|
15250
|
+
* - a GSI `Create` (`gsi3`), the op AWS gates most strictly.
|
|
15251
|
+
*
|
|
15252
|
+
* So warm throughput settles OUT OF BAND of the index lifecycle, and adding
|
|
15253
|
+
* it to this predicate would only make every GSI op wait on a status that
|
|
15254
|
+
* blocks nothing — minutes of wait budget spent per op, with a real risk of
|
|
15255
|
+
* exhausting it on a table whose warm status lingers.
|
|
14825
15256
|
*/
|
|
14826
15257
|
async waitForTableAndIndexesActive(tableName, maxAttempts = 1800) {
|
|
14827
15258
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
@@ -14872,32 +15303,223 @@ var DynamoDBTableProvider = class {
|
|
|
14872
15303
|
if (!gsi.KeySchema) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} is missing KeySchema`, resourceType, logicalId, physicalId);
|
|
14873
15304
|
if (live !== void 0 && live.IndexStatus !== "DELETING") {
|
|
14874
15305
|
this.logger.warn(`GSI ${name} on DynamoDB table ${physicalId} already exists in AWS but is absent from cdkd's recorded previous state, so its Create is being skipped. This usually means an earlier deploy created the index and then failed before state was written.`);
|
|
14875
|
-
|
|
14876
|
-
|
|
14877
|
-
|
|
14878
|
-
|
|
15306
|
+
const adopted = { IndexName: name };
|
|
15307
|
+
let adoptedHasMember = false;
|
|
15308
|
+
if (liveCapacityComparable && gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && !liveCapacityAlreadyMatches(live.ProvisionedThroughput, gsi.ProvisionedThroughput) && !this.skipZeroCapacityIndexUpdate(name, physicalId, gsi.ProvisionedThroughput)) {
|
|
15309
|
+
adopted.ProvisionedThroughput = gsi.ProvisionedThroughput;
|
|
15310
|
+
adoptedHasMember = true;
|
|
15311
|
+
}
|
|
15312
|
+
const adoptedWarm = this.warmThroughputOpFor(name, physicalId, gsi, void 0, live);
|
|
15313
|
+
if (adoptedWarm) {
|
|
15314
|
+
adopted.WarmThroughput = adoptedWarm;
|
|
15315
|
+
adoptedHasMember = true;
|
|
15316
|
+
}
|
|
15317
|
+
if (adoptedHasMember) ops.push({ Update: adopted });
|
|
14879
15318
|
this.logger.warn(`GSI ${name} was adopted from AWS rather than created, so cdkd could not verify its KeySchema / Projection match the template. Run \`cdkd drift ${physicalId}\` to confirm, and \`cdkd drift --accept\` if AWS is the source of truth.`);
|
|
14880
15319
|
continue;
|
|
14881
15320
|
}
|
|
15321
|
+
this.warnRefusedWarmThroughput(`GSI ${name} on DynamoDB table ${physicalId}`, gsi.WarmThroughput);
|
|
15322
|
+
const createWarm = this.coerceWarmThroughputForSend(`GSI ${name} on DynamoDB table ${physicalId}`, gsi.WarmThroughput);
|
|
14882
15323
|
ops.push({ Create: {
|
|
14883
15324
|
IndexName: name,
|
|
14884
15325
|
KeySchema: gsi.KeySchema,
|
|
14885
15326
|
Projection: gsi.Projection,
|
|
14886
15327
|
...gsi.ProvisionedThroughput ? { ProvisionedThroughput: gsi.ProvisionedThroughput } : {},
|
|
14887
|
-
...gsi.OnDemandThroughput ? { OnDemandThroughput: gsi.OnDemandThroughput } : {}
|
|
15328
|
+
...gsi.OnDemandThroughput ? { OnDemandThroughput: gsi.OnDemandThroughput } : {},
|
|
15329
|
+
...createWarm ? { WarmThroughput: createWarm } : {}
|
|
14888
15330
|
} });
|
|
14889
15331
|
} else {
|
|
14890
15332
|
const before = prevByName.get(name);
|
|
14891
15333
|
if (JSON.stringify(before.KeySchema) !== JSON.stringify(gsi.KeySchema) || JSON.stringify(before.Projection) !== JSON.stringify(gsi.Projection)) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} changed its KeySchema or Projection, which DynamoDB cannot modify in place. Rename the index (so it is dropped and re-created) or replace the table.`, resourceType, logicalId, physicalId);
|
|
14892
|
-
|
|
14893
|
-
|
|
14894
|
-
|
|
14895
|
-
ProvisionedThroughput
|
|
14896
|
-
|
|
15334
|
+
const update = { IndexName: name };
|
|
15335
|
+
let updateHasMember = false;
|
|
15336
|
+
if (gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && JSON.stringify(before.ProvisionedThroughput) !== JSON.stringify(gsi.ProvisionedThroughput)) {
|
|
15337
|
+
if (liveCapacityComparable && liveCapacityAlreadyMatches(liveIndexByName?.get(name)?.ProvisionedThroughput, gsi.ProvisionedThroughput)) this.logger.debug(`GSI ${name} on DynamoDB table ${physicalId} already carries the requested capacity in AWS; skipping its throughput Update`);
|
|
15338
|
+
else if (!this.skipZeroCapacityIndexUpdate(name, physicalId, gsi.ProvisionedThroughput)) {
|
|
15339
|
+
update.ProvisionedThroughput = gsi.ProvisionedThroughput;
|
|
15340
|
+
updateHasMember = true;
|
|
15341
|
+
}
|
|
15342
|
+
}
|
|
15343
|
+
const warm = this.warmThroughputOpFor(name, physicalId, gsi, before, liveIndexByName?.get(name));
|
|
15344
|
+
if (warm) {
|
|
15345
|
+
update.WarmThroughput = warm;
|
|
15346
|
+
updateHasMember = true;
|
|
15347
|
+
}
|
|
15348
|
+
if (updateHasMember) ops.push({ Update: update });
|
|
14897
15349
|
}
|
|
14898
15350
|
await this.runGsiOps(physicalId, ops, desiredAttributeDefinitions);
|
|
14899
15351
|
}
|
|
14900
15352
|
/**
|
|
15353
|
+
* The NUMERIC `WarmThroughput` spec to put on the wire, announcing any member
|
|
15354
|
+
* that had to be dropped (PR review round 5).
|
|
15355
|
+
*
|
|
15356
|
+
* The single send-side entry point for all four write sites, so a template's
|
|
15357
|
+
* quoted `'12000'` reaches AWS as `12000` rather than as a string in a Long
|
|
15358
|
+
* field. Returns `undefined` for a value {@link isSendableWarmThroughput}
|
|
15359
|
+
* refuses — the caller's existing refusal warning covers that case, and the
|
|
15360
|
+
* two messages are disjoint by construction: this one fires only when
|
|
15361
|
+
* something DID resolve.
|
|
15362
|
+
*/
|
|
15363
|
+
coerceWarmThroughputForSend(scope, value) {
|
|
15364
|
+
const coerced = coerceWarmThroughput(value);
|
|
15365
|
+
if (coerced === void 0) return void 0;
|
|
15366
|
+
if (coerced.dropped.length > 0) this.logger.warn(`${scope}: WarmThroughput member(s) ${coerced.dropped.join(", ")} in ${JSON.stringify(value)} are not a number DynamoDB accepts, so they were dropped from the request, which leaves ${JSON.stringify(coerced.spec)}. Check for an unresolved intrinsic or a non-numeric value.`);
|
|
15367
|
+
return coerced.spec;
|
|
15368
|
+
}
|
|
15369
|
+
/**
|
|
15370
|
+
* One `CreateTable` index entry with its `WarmThroughput` coerced (PR review
|
|
15371
|
+
* round 6).
|
|
15372
|
+
*
|
|
15373
|
+
* `create()` forwards the declared `GlobalSecondaryIndexes` array to
|
|
15374
|
+
* `CreateTable`, so it is a SEND SITE for the same per-index value the
|
|
15375
|
+
* update path coerces — and it was the one the round-5 sweep missed. Entries
|
|
15376
|
+
* are rebuilt rather than mutated: the input bag belongs to the caller (the
|
|
15377
|
+
* resolved template, which the engine also records into state), and a
|
|
15378
|
+
* provider that edits it in place would change what state reports cdkd sent.
|
|
15379
|
+
*
|
|
15380
|
+
* An entry that is not a plain object, or that declares no `WarmThroughput`,
|
|
15381
|
+
* is returned UNCHANGED — including the identity of the object, so the
|
|
15382
|
+
* common case allocates nothing.
|
|
15383
|
+
*/
|
|
15384
|
+
coerceIndexWarmThroughputForCreate(logicalId, entry) {
|
|
15385
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return entry;
|
|
15386
|
+
const declared = entry["WarmThroughput"];
|
|
15387
|
+
if (declared === void 0) return entry;
|
|
15388
|
+
const scope = `GSI ${entry.IndexName ?? "<unnamed>"} on AWS::DynamoDB::Table ${logicalId}`;
|
|
15389
|
+
const coerced = this.coerceWarmThroughputForSend(scope, declared);
|
|
15390
|
+
if (coerced === void 0) {
|
|
15391
|
+
this.warnRefusedWarmThroughput(scope, declared);
|
|
15392
|
+
const { WarmThroughput: _dropped, ...rest } = entry;
|
|
15393
|
+
return rest;
|
|
15394
|
+
}
|
|
15395
|
+
return {
|
|
15396
|
+
...entry,
|
|
15397
|
+
WarmThroughput: coerced
|
|
15398
|
+
};
|
|
15399
|
+
}
|
|
15400
|
+
/**
|
|
15401
|
+
* Announce a `WarmThroughput` the send rule REFUSES (PR review of issue
|
|
15402
|
+
* #1768) — the missing half of tightening {@link isSendableWarmThroughput}.
|
|
15403
|
+
*
|
|
15404
|
+
* Called at all FOUR write sites (`create()`, the table-level `update()`
|
|
15405
|
+
* branch, the GSI `Create` action, and {@link warmThroughputOpFor}) because
|
|
15406
|
+
* the value vanishes at each of them independently. `scope` names the
|
|
15407
|
+
* resource or the index so a table with several GSIs says WHICH one.
|
|
15408
|
+
*
|
|
15409
|
+
* Silent for an ABSENT or FALSY value — see {@link isRefusedWarmThroughput}
|
|
15410
|
+
* for why those two are not this warning's business.
|
|
15411
|
+
*/
|
|
15412
|
+
warnRefusedWarmThroughput(scope, value) {
|
|
15413
|
+
if (!isRefusedWarmThroughput(value)) return;
|
|
15414
|
+
this.logger.warn(`${scope}: the declared WarmThroughput ${JSON.stringify(value)} carries no usable ReadUnitsPerSecond or WriteUnitsPerSecond, so it was NOT sent — DynamoDB accepts only those two members and rejects a request without either. Check for a misspelled member name or an unresolved intrinsic. Nothing else on this resource was affected.`);
|
|
15415
|
+
}
|
|
15416
|
+
/**
|
|
15417
|
+
* Should this per-index capacity `Update` be SKIPPED because the value is
|
|
15418
|
+
* AWS's on-demand `{0, 0}` placeholder rather than a capacity anyone asked
|
|
15419
|
+
* for (issue #1767 review)?
|
|
15420
|
+
*
|
|
15421
|
+
* Newly reachable BECAUSE of the #1767 readback change, on the transition
|
|
15422
|
+
* residual `getDriftUnknownPaths` deliberately carries. A pre-#1767
|
|
15423
|
+
* `observedProperties` baseline holds the whole `DescribeTable` index
|
|
15424
|
+
* description, including the `{NumberOfDecreasesToday: 0, ReadCapacityUnits:
|
|
15425
|
+
* 0, WriteCapacityUnits: 0}` block AWS reports for every index of a
|
|
15426
|
+
* PAY_PER_REQUEST table (the #1571 trap). `cdkd drift --revert` hands that
|
|
15427
|
+
* blob to `update()` as the DESIRED side against the trimmed readback as the
|
|
15428
|
+
* previous side, so the values differ, `liveCapacityComparable` is false on
|
|
15429
|
+
* PAY_PER_REQUEST (which disables the #1630 idempotency skip), and the op
|
|
15430
|
+
* that goes out asks AWS for capacity 0 — which AWS rejects, since the
|
|
15431
|
+
* minimum is 1. Before #1767 both sides carried the same blob, compared
|
|
15432
|
+
* equal, and no revert was ever offered.
|
|
15433
|
+
*
|
|
15434
|
+
* Deliberately NOT gated on the live billing mode. A `{0, 0}` request is
|
|
15435
|
+
* refused by AWS in EITHER mode, so gating would leave the same doomed call
|
|
15436
|
+
* reachable for a PROVISIONED table whose stale baseline carries the
|
|
15437
|
+
* placeholder. Scoped tightly to BOTH members resolving to 0: any other
|
|
15438
|
+
* capacity — including a half-declared or malformed one — still goes out and
|
|
15439
|
+
* is answered by AWS, the fail-OPEN direction this file uses everywhere.
|
|
15440
|
+
*
|
|
15441
|
+
* CALLER-BLIND, decided rather than overlooked (PR review). A TEMPLATE-borne
|
|
15442
|
+
* `{0, 0}` warn-skips here while `create()` fails loudly on the same value,
|
|
15443
|
+
* which reads like the "loud failure for a quiet lie" trade the adopted-index
|
|
15444
|
+
* arm below forbids. It stands for three reasons:
|
|
15445
|
+
*
|
|
15446
|
+
* - The discriminator that EXISTS does not separate the callers that matter.
|
|
15447
|
+
* `ResourceProvider.update` DOES take an optional `UpdateContext` — an
|
|
15448
|
+
* earlier draft of this comment claimed the signature carries no context
|
|
15449
|
+
* at all, which is simply false (`s3-bucket-provider.ts` consumes it
|
|
15450
|
+
* today) — but its one field, `desiredFromAwsReadback`, is set ONLY by
|
|
15451
|
+
* `cdkd drift --revert` (`src/cli/commands/drift.ts`). The rollback
|
|
15452
|
+
* executor's revert arms deliberately pass NO context: their desired bag
|
|
15453
|
+
* is `previousState.properties`, a TEMPLATE recorded earlier, so they are
|
|
15454
|
+
* indistinguishable from an ordinary template deploy — and THAT is the
|
|
15455
|
+
* pair this decision turns on. Knowing the value came from a readback
|
|
15456
|
+
* would not license throwing on the other two.
|
|
15457
|
+
* - With the caller unknowable, the repo's rule for the UPDATE path is
|
|
15458
|
+
* WARN-never-throw (issues #1545 / #1552): the desired bag here can BE a
|
|
15459
|
+
* historical cdkd state record, and a refusal would make the table
|
|
15460
|
+
* un-updatable and un-rollbackable with no template-side remedy.
|
|
15461
|
+
* - The lie is bounded and self-surfacing. AWS never accepted `{0, 0}` at
|
|
15462
|
+
* create either, so no live table can be holding it; and where the value
|
|
15463
|
+
* would actually matter — a PROVISIONED table, whose indexes hold a real
|
|
15464
|
+
* capacity — recording `{0, 0}` diverges from the readback and `cdkd drift`
|
|
15465
|
+
* REPORTS it. On a PAY_PER_REQUEST table the recorded `{0, 0}` matches what
|
|
15466
|
+
* `DescribeTable` reports for every index anyway, so there is no divergence
|
|
15467
|
+
* to hide.
|
|
15468
|
+
*/
|
|
15469
|
+
skipZeroCapacityIndexUpdate(indexName, physicalId, requested) {
|
|
15470
|
+
if (requested === void 0) return false;
|
|
15471
|
+
if (capacityNumber(requested.ReadCapacityUnits) !== 0 || capacityNumber(requested.WriteCapacityUnits) !== 0) return false;
|
|
15472
|
+
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.`);
|
|
15473
|
+
return true;
|
|
15474
|
+
}
|
|
15475
|
+
/**
|
|
15476
|
+
* The per-index `WarmThroughput` this update should send, or `undefined`
|
|
15477
|
+
* (issue #1768, one nesting level down from the table-level branch).
|
|
15478
|
+
*
|
|
15479
|
+
* `applyGsiUpdates` used to send only `ProvisionedThroughput` /
|
|
15480
|
+
* `OnDemandThroughput` on both of its arms, while `readCurrentState` emits a
|
|
15481
|
+
* declared per-index `WarmThroughput` — so a template declaring one had it
|
|
15482
|
+
* silently dropped on every index add / change, `cdkd drift` reported the
|
|
15483
|
+
* difference forever, and `--revert` emitted no op at all and exited 0
|
|
15484
|
+
* claiming success. That silent-success shape is worse than the loud
|
|
15485
|
+
* table-level dead end #1768 fixed.
|
|
15486
|
+
*
|
|
15487
|
+
* The rules mirror the table-level branch exactly:
|
|
15488
|
+
* - only a value the write side would SEND at all
|
|
15489
|
+
* ({@link isSendableWarmThroughput});
|
|
15490
|
+
* - only when it CHANGED against the recorded previous entry (an absent
|
|
15491
|
+
* previous — the adopted-index arm — is treated as changed, since nothing
|
|
15492
|
+
* was recorded to compare with);
|
|
15493
|
+
* - never a DECREASE. Measured live (us-east-1, 2026-08-13) on a GSI AWS
|
|
15494
|
+
* reports `{12000, 4000}` for: a `WarmThroughput`-only `Update` action
|
|
15495
|
+
* with `{12000, 4000}` is ACCEPTED (so the action shape needs no
|
|
15496
|
+
* `ProvisionedThroughput` companion), while `{6000, 2000}` is rejected
|
|
15497
|
+
* with `Requested ReadUnitsPerSecond for WarmThroughput for index gsi1 is
|
|
15498
|
+
* lower than current WarmThroughput, decreasing WarmThroughput is not
|
|
15499
|
+
* supported`.
|
|
15500
|
+
*/
|
|
15501
|
+
warmThroughputOpFor(indexName, physicalId, desired, previous, live) {
|
|
15502
|
+
const requested = desired.WarmThroughput;
|
|
15503
|
+
if (previous !== void 0 && JSON.stringify(previous.WarmThroughput) === JSON.stringify(requested)) return;
|
|
15504
|
+
const sendable = this.coerceWarmThroughputForSend(`GSI ${indexName} on DynamoDB table ${physicalId}`, requested);
|
|
15505
|
+
if (sendable === void 0) {
|
|
15506
|
+
this.warnRefusedWarmThroughput(`GSI ${indexName} on DynamoDB table ${physicalId}`, requested);
|
|
15507
|
+
return;
|
|
15508
|
+
}
|
|
15509
|
+
if (warmThroughputAlreadyMatches(sendable, live?.WarmThroughput)) {
|
|
15510
|
+
this.logger.debug(`GSI ${indexName} on DynamoDB table ${physicalId} already carries the requested warm throughput in AWS; skipping its WarmThroughput Update`);
|
|
15511
|
+
return;
|
|
15512
|
+
}
|
|
15513
|
+
if (isWarmThroughputDecrease(sendable, live?.WarmThroughput)) {
|
|
15514
|
+
this.logger.warn(`GSI ${indexName} on DynamoDB table ${physicalId}: the requested WarmThroughput ${JSON.stringify(sendable)} is lower than the ${JSON.stringify({
|
|
15515
|
+
ReadUnitsPerSecond: live?.WarmThroughput?.ReadUnitsPerSecond,
|
|
15516
|
+
WriteUnitsPerSecond: live?.WarmThroughput?.WriteUnitsPerSecond
|
|
15517
|
+
})} DynamoDB currently holds for that index. Warm throughput only ever RISES with an index's traffic and cannot be lowered — AWS rejects the UpdateTable with "decreasing WarmThroughput is not supported" — so it was NOT sent and every other change on this table still applied. On a deploy this warning may be the only signal you get: a successful deploy re-captures AWS's current value as the drift baseline. If your template declares this index's WarmThroughput, set it to the value AWS holds (or remove it, which AWS treats as keeping the last-set value); if it does not, the value came from a cdkd state record and 'cdkd deploy' (or 'cdkd drift --accept') refreshes it.`);
|
|
15518
|
+
return;
|
|
15519
|
+
}
|
|
15520
|
+
return sendable;
|
|
15521
|
+
}
|
|
15522
|
+
/**
|
|
14901
15523
|
* Issue a list of `GlobalSecondaryIndexUpdates` ops, one per `UpdateTable`,
|
|
14902
15524
|
* waiting for the table AND every index to return to ACTIVE between each.
|
|
14903
15525
|
*
|
|
@@ -14947,6 +15569,33 @@ var DynamoDBTableProvider = class {
|
|
|
14947
15569
|
}
|
|
14948
15570
|
}
|
|
14949
15571
|
/**
|
|
15572
|
+
* Should this update SKIP its `WarmThroughput` call because the value would
|
|
15573
|
+
* DECREASE what AWS already holds (issue #1768)?
|
|
15574
|
+
*
|
|
15575
|
+
* Thin logging wrapper over the pure {@link isWarmThroughputDecrease}, kept
|
|
15576
|
+
* on the class so the warning is emitted at exactly the one site that skips.
|
|
15577
|
+
*
|
|
15578
|
+
* The warning is what makes the skip honest, and its WORDING is checked
|
|
15579
|
+
* against the code rather than assumed (PR review): the first version
|
|
15580
|
+
* promised `cdkd drift` would "keep reporting the difference", which is TRUE
|
|
15581
|
+
* on the `drift --revert` path — nothing is recorded, so the observed
|
|
15582
|
+
* baseline still holds the declared value — and FALSE on the DEPLOY path.
|
|
15583
|
+
* A successful deploy re-captures `observedProperties` through this same
|
|
15584
|
+
* `readCurrentState` (`deploy-engine.ts`'s `kickOffObservedCapture`), which
|
|
15585
|
+
* for a DECLARING template emits AWS's CURRENT value, so the very deploy
|
|
15586
|
+
* that warned equalises the comparison and the next `cdkd drift` reports the
|
|
15587
|
+
* resource clean. The message therefore says the warning may be the only
|
|
15588
|
+
* signal, and names both numbers so the template edit needs no second look.
|
|
15589
|
+
*/
|
|
15590
|
+
skipWarmThroughputDecrease(logicalId, physicalId, desired, live) {
|
|
15591
|
+
if (!isWarmThroughputDecrease(desired, live)) return false;
|
|
15592
|
+
this.logger.warn(`AWS::DynamoDB::Table ${logicalId}: the requested WarmThroughput ${JSON.stringify(desired)} is lower than the ${JSON.stringify({
|
|
15593
|
+
ReadUnitsPerSecond: live?.ReadUnitsPerSecond,
|
|
15594
|
+
WriteUnitsPerSecond: live?.WriteUnitsPerSecond
|
|
15595
|
+
})} DynamoDB currently holds for table ${physicalId}. Warm throughput only ever RISES with a table's traffic and cannot be lowered — AWS rejects the UpdateTable with "decreasing WarmThroughput is not supported" — so it was NOT sent and every other change on this resource still applied. On a deploy this warning may be the only signal you get: a successful deploy re-captures AWS's current value as the drift baseline, so the next 'cdkd drift' reports this table clean (a 'cdkd drift --revert' records nothing and keeps reporting it). To make cdkd and AWS agree, set WarmThroughput to the value AWS holds (or remove the property, which AWS treats as keeping the last-set value).`);
|
|
15596
|
+
return true;
|
|
15597
|
+
}
|
|
15598
|
+
/**
|
|
14950
15599
|
* `AttributeDefinitions` is a SET, keyed by `AttributeName`: CFn accepts the
|
|
14951
15600
|
* members in any order and `DescribeTable` returns them in an order matching
|
|
14952
15601
|
* neither the request nor the template. Measured live (us-east-1,
|
|
@@ -14966,6 +15615,29 @@ var DynamoDBTableProvider = class {
|
|
|
14966
15615
|
* `KeySchema` is deliberately NOT declared, at the table level or the index
|
|
14967
15616
|
* level: it is order-SIGNIFICANT (HASH before RANGE), so sorting it would
|
|
14968
15617
|
* HIDE a real key change rather than remove a phantom one.
|
|
15618
|
+
*
|
|
15619
|
+
* `GlobalSecondaryIndexes` / `LocalSecondaryIndexes` are NOT declared either,
|
|
15620
|
+
* and that is a MECHANISM limit rather than a judgement about the lists —
|
|
15621
|
+
* both really are sets keyed by `IndexName` (issue #1767 proposes declaring
|
|
15622
|
+
* them). Every entry here is a SUBTREE declaration, and unlike
|
|
15623
|
+
* `getDriftUnknownPaths` this walk DESCENDS INTO ARRAY ELEMENTS giving each
|
|
15624
|
+
* the parent's path (`drift-normalize.ts`), so a `'GlobalSecondaryIndexes'`
|
|
15625
|
+
* entry also reaches `GlobalSecondaryIndexes.KeySchema` and would sort the
|
|
15626
|
+
* per-index key schema — reversing the sentence above at the index level
|
|
15627
|
+
* only. Issue #1767 calls the sort and the member reverse-map separable;
|
|
15628
|
+
* this change ships the reverse-map, and the ordering half needed a
|
|
15629
|
+
* leaf-only form of the declaration first (issue
|
|
15630
|
+
* [#1783](https://github.com/go-to-k/cdkd/issues/1783)). **That form now
|
|
15631
|
+
* EXISTS** — `LEAF_ONLY_PATH_SUFFIX` (`[]`) landed in
|
|
15632
|
+
* [#1799](https://github.com/go-to-k/cdkd/pull/1799), so
|
|
15633
|
+
* `'GlobalSecondaryIndexes[]'` would claim the list alone without reaching
|
|
15634
|
+
* the per-index `KeySchema`. It is deliberately NOT declared here yet:
|
|
15635
|
+
* adopting it is its own change with its own real-AWS verification, tracked
|
|
15636
|
+
* as issue [#1812](https://github.com/go-to-k/cdkd/issues/1812). Consequence
|
|
15637
|
+
* until then, stated so it is not mistaken for solved: an index list AWS
|
|
15638
|
+
* returns in a different ORDER than the template declared is still phantom
|
|
15639
|
+
* drift against a `properties` baseline. It is not reachable on the ordinary
|
|
15640
|
+
* `observedProperties` path, where both sides come from this same readback.
|
|
14969
15641
|
*/
|
|
14970
15642
|
getDriftUnorderedPaths(resourceType) {
|
|
14971
15643
|
if (resourceType !== "AWS::DynamoDB::Table") return [];
|
|
@@ -15009,11 +15681,72 @@ var DynamoDBTableProvider = class {
|
|
|
15009
15681
|
* `TlsConfig` (issue #1602): an absent / empty bag falls back to the
|
|
15010
15682
|
* type-level answer of COMPARING, since hiding a real drift is the worse
|
|
15011
15683
|
* failure.
|
|
15684
|
+
*
|
|
15685
|
+
* `GlobalSecondaryIndexes` / `LocalSecondaryIndexes` join it under the SAME
|
|
15686
|
+
* rule for the SAME reason (issue #1767), one nesting level up: the readback
|
|
15687
|
+
* now emits the reverse-mapped CFn shape, so every `observedProperties`
|
|
15688
|
+
* baseline an earlier binary wrote — which carries `IndexStatus` /
|
|
15689
|
+
* `ItemCount` / `IndexArn` / the on-demand `{0, 0}` placeholder / the
|
|
15690
|
+
* computed per-index `WarmThroughput` — no longer equals it. Ignoring the
|
|
15691
|
+
* path on both sides when the template declares NO index list covers the
|
|
15692
|
+
* transition and the steady state for that population, exactly as the
|
|
15693
|
+
* `WarmThroughput` arm does: an index list the template never declared is
|
|
15694
|
+
* AWS-authored (created out of band, or by a sibling), which is the #1498
|
|
15695
|
+
* class.
|
|
15696
|
+
*
|
|
15697
|
+
* Known residual, deliberately NOT covered, because covering it costs more
|
|
15698
|
+
* than it buys: a table whose template DOES declare indexes keeps comparing,
|
|
15699
|
+
* so its already-written observed baseline reports a one-sided
|
|
15700
|
+
* `GlobalSecondaryIndexes` difference until the next deploy re-captures it
|
|
15701
|
+
* (or `cdkd drift --accept` does). `cdkd drift --revert` is the third thing a
|
|
15702
|
+
* user may reach for on that report, and for the shapes measured here it
|
|
15703
|
+
* applies NOTHING to AWS — which is a property of three separate skips, not
|
|
15704
|
+
* "by construction", and the distinction is load-bearing because this PR
|
|
15705
|
+
* briefly broke it. The index names match, so no Create / Delete is derived;
|
|
15706
|
+
* the stale blob's `{0, 0}` capacity is refused by
|
|
15707
|
+
* {@link skipZeroCapacityIndexUpdate}; and its per-index `WarmThroughput` —
|
|
15708
|
+
* which the same PR taught `applyGsiUpdates` to SEND (issue #1768), turning
|
|
15709
|
+
* an earlier "by construction" wording false the moment it landed — is
|
|
15710
|
+
* refused by {@link warmThroughputAlreadyMatches}, since a stale blob carries
|
|
15711
|
+
* exactly what AWS holds. Remove any of the three and the residual stops
|
|
15712
|
+
* being harmless: the revert starts issuing one redundant `UpdateTable` plus
|
|
15713
|
+
* a full index-ACTIVE wait per GSI, or a doomed one.
|
|
15714
|
+
*
|
|
15715
|
+
* "Applies nothing" is NOT the same as "reaches those skips" (PR review round
|
|
15716
|
+
* 5). `applyGsiUpdates` compares `KeySchema` / `Projection` between the
|
|
15717
|
+
* recorded previous and the desired entry BEFORE any of them and THROWS on a
|
|
15718
|
+
* difference, because DynamoDB cannot modify either in place. On this path
|
|
15719
|
+
* that compare is a trimmed readback against a stale full description, and
|
|
15720
|
+
* both members survive the #1767 reverse-map unchanged — `KeySchema`
|
|
15721
|
+
* verbatim, `Projection` rebuilt member-for-member — so for every shape
|
|
15722
|
+
* exercised here it matches and the throw does not fire. It is named because
|
|
15723
|
+
* it is the outcome an enumeration of skips would otherwise hide: a stale
|
|
15724
|
+
* blob whose `Projection` AWS has since re-normalized would fail the revert
|
|
15725
|
+
* loudly rather than no-op it. Suppressing the residual instead would mean ignoring the
|
|
15726
|
+
* whole array for the population that HAS indexes — i.e. never detecting an
|
|
15727
|
+
* out-of-band index add / remove / capacity change again, permanently, to
|
|
15728
|
+
* remove a one-time report. No PATH can express the middle ground:
|
|
15729
|
+
* `isIgnoredPath` is never asked about a path that crosses an array
|
|
15730
|
+
* (`diffAt` compares arrays wholesale via `deepEqual`), so a per-MEMBER
|
|
15731
|
+
* ignore path does not exist — the same wall issue #1742 records for the
|
|
15732
|
+
* `AWS::DynamoDB::GlobalTable` twin. **A non-path seam now EXISTS**:
|
|
15733
|
+
* `ResourceProvider.canonicalizeDriftProperties`, applied to BOTH comparison
|
|
15734
|
+
* sides, landed in [#1799](https://github.com/go-to-k/cdkd/pull/1799) (which
|
|
15735
|
+
* closed issue [#1784](https://github.com/go-to-k/cdkd/issues/1784)) and can
|
|
15736
|
+
* strip the AWS-managed member from each bag — converging an already-written
|
|
15737
|
+
* record with a post-fix readback, with no ignore-path and no lost
|
|
15738
|
+
* detection. It is deliberately NOT adopted here yet: doing so is its own
|
|
15739
|
+
* change with its own real-AWS verification, tracked as issue
|
|
15740
|
+
* [#1812](https://github.com/go-to-k/cdkd/issues/1812).
|
|
15012
15741
|
*/
|
|
15013
15742
|
getDriftUnknownPaths(resourceType, properties) {
|
|
15014
15743
|
if (resourceType !== "AWS::DynamoDB::Table") return [];
|
|
15015
|
-
|
|
15016
|
-
|
|
15744
|
+
const paths = [];
|
|
15745
|
+
if (!declaresWarmThroughput(properties)) paths.push("WarmThroughput");
|
|
15746
|
+
if (desiredBagIsInformative(properties)) {
|
|
15747
|
+
for (const key of ["GlobalSecondaryIndexes", "LocalSecondaryIndexes"]) if (properties !== void 0 && !declaresIndexList(properties[key])) paths.push(key);
|
|
15748
|
+
}
|
|
15749
|
+
return paths;
|
|
15017
15750
|
}
|
|
15018
15751
|
/**
|
|
15019
15752
|
* Read the AWS-current DynamoDB table configuration in CFn-property shape.
|
|
@@ -15027,18 +15760,18 @@ var DynamoDBTableProvider = class {
|
|
|
15027
15760
|
* - StreamSpecification's CFn shape includes only `StreamViewType`; the
|
|
15028
15761
|
* API response carries `StreamEnabled` too. We surface both since the
|
|
15029
15762
|
* drift comparator only descends into keys present in state.
|
|
15030
|
-
* - GSI / LSI in the API response include `IndexStatus`, `
|
|
15031
|
-
* `IndexArn` and sizing fields cdkd never sets
|
|
15032
|
-
* VERBATIM
|
|
15033
|
-
* NOT, and issue #1767 quotes that sentence as the bug:
|
|
15034
|
-
* `calculateResourceDrift`'s state-keys-only walk, which
|
|
15035
|
-
* absent TOP-LEVEL key. These are members of an array
|
|
15036
|
-
* baseline DOES carry, and arrays compare positionally,
|
|
15037
|
-
* AWS-managed member
|
|
15038
|
-
* an in-use table with
|
|
15039
|
-
* `observedProperties` on the
|
|
15040
|
-
* reverse-
|
|
15041
|
-
*
|
|
15763
|
+
* - GSI / LSI in the API response include `IndexStatus`, `Backfilling`,
|
|
15764
|
+
* `ItemCount`, `IndexArn` and sizing fields cdkd never sets. They used to
|
|
15765
|
+
* be forwarded VERBATIM under a comment claiming "the comparator filters
|
|
15766
|
+
* them" — it does NOT, and issue #1767 quotes that sentence as the bug:
|
|
15767
|
+
* the filter is `calculateResourceDrift`'s state-keys-only walk, which
|
|
15768
|
+
* only reaches an absent TOP-LEVEL key. These are members of an array
|
|
15769
|
+
* under a key the baseline DOES carry, and arrays compare positionally,
|
|
15770
|
+
* so every AWS-managed member participated — and `ItemCount` MOVES as
|
|
15771
|
+
* data lands, so an in-use table with any index drifted against its own
|
|
15772
|
+
* `observedProperties` on the ORDINARY path, on a schedule set by its own
|
|
15773
|
+
* write traffic. Each entry is now reverse-mapped to its CFn shape by
|
|
15774
|
+
* {@link reverseMapSecondaryIndex}.
|
|
15042
15775
|
*
|
|
15043
15776
|
* Returns `undefined` when the table is gone (`ResourceNotFoundException`).
|
|
15044
15777
|
*
|
|
@@ -15050,8 +15783,10 @@ var DynamoDBTableProvider = class {
|
|
|
15050
15783
|
* template carries them).
|
|
15051
15784
|
*
|
|
15052
15785
|
* `properties` is the DESIRED bag (the state-recorded template intent every
|
|
15053
|
-
* caller passes)
|
|
15054
|
-
*
|
|
15786
|
+
* caller passes). It is consulted for the table-level `WarmThroughput` — see
|
|
15787
|
+
* {@link getDriftUnknownPaths} for the measurement and the reasoning — and,
|
|
15788
|
+
* since issue #1767, for the per-index throughput blocks of each secondary
|
|
15789
|
+
* index, matched by `IndexName`.
|
|
15055
15790
|
*/
|
|
15056
15791
|
async readCurrentState(physicalId, _logicalId, _resourceType, properties) {
|
|
15057
15792
|
try {
|
|
@@ -15082,8 +15817,15 @@ var DynamoDBTableProvider = class {
|
|
|
15082
15817
|
StreamEnabled: true,
|
|
15083
15818
|
StreamViewType: table.StreamSpecification.StreamViewType
|
|
15084
15819
|
};
|
|
15085
|
-
|
|
15086
|
-
if (table.
|
|
15820
|
+
const bagInformative = desiredBagIsInformative(properties);
|
|
15821
|
+
if (table.GlobalSecondaryIndexes && table.GlobalSecondaryIndexes.length > 0) {
|
|
15822
|
+
const desiredByName = desiredIndexEntriesByName(properties?.["GlobalSecondaryIndexes"]);
|
|
15823
|
+
result["GlobalSecondaryIndexes"] = table.GlobalSecondaryIndexes.map((live) => reverseMapSecondaryIndex(live, live.IndexName === void 0 ? void 0 : desiredByName.get(live.IndexName), bagInformative));
|
|
15824
|
+
}
|
|
15825
|
+
if (table.LocalSecondaryIndexes && table.LocalSecondaryIndexes.length > 0) {
|
|
15826
|
+
const desiredByName = desiredIndexEntriesByName(properties?.["LocalSecondaryIndexes"]);
|
|
15827
|
+
result["LocalSecondaryIndexes"] = table.LocalSecondaryIndexes.map((live) => reverseMapSecondaryIndex(live, live.IndexName === void 0 ? void 0 : desiredByName.get(live.IndexName), bagInformative));
|
|
15828
|
+
}
|
|
15087
15829
|
if (table.SSEDescription?.Status === "ENABLED") {
|
|
15088
15830
|
const sse = { SSEEnabled: true };
|
|
15089
15831
|
if (table.SSEDescription.KMSMasterKeyArn !== void 0) sse["KMSMasterKeyId"] = table.SSEDescription.KMSMasterKeyArn;
|
|
@@ -65044,6 +65786,363 @@ function createEventsPruneCommand() {
|
|
|
65044
65786
|
})).option("--older-than <duration>", "Delete runs older than this duration (e.g. 24h, 90m)").option("--all", "Delete every recorded run and the index (full purge)", false).action(withErrorHandling((stack, _options, command) => eventsPruneCommand(stack, command.optsWithGlobals())));
|
|
65045
65787
|
}
|
|
65046
65788
|
|
|
65789
|
+
//#endregion
|
|
65790
|
+
//#region src/utils/ecr-uri.ts
|
|
65791
|
+
/**
|
|
65792
|
+
* The ECR registry-host FORM TABLE — the ONE place the host forms AWS serves are
|
|
65793
|
+
* spelled (issue #1793).
|
|
65794
|
+
*
|
|
65795
|
+
* The forms used to be spelled TWICE, here and in `src/cli/commands/gc.ts`,
|
|
65796
|
+
* and the two already disagreed: gc carried the `-fips` and `on.aws` forms
|
|
65797
|
+
* while this module matched only the plain one, so a genuine FIPS or dual-stack
|
|
65798
|
+
* registry was not recognized here AT ALL and `cdkd local invoke` /
|
|
65799
|
+
* `run-task` classified it as a public image (anonymous pull, no
|
|
65800
|
+
* `docker login`).
|
|
65801
|
+
*
|
|
65802
|
+
* **What is shared is this TABLE, not the whole matcher** — read that precisely,
|
|
65803
|
+
* because the unification is deliberately PARTIAL. gc builds its pattern from
|
|
65804
|
+
* the table through {@link ecrRegistryHostPattern}; this module's own
|
|
65805
|
+
* {@link ECR_URI_HOST_REGEX} re-spells the alternation from the table's LABELS
|
|
65806
|
+
* column and cannot use that builder at all (its doc says why). So the shared
|
|
65807
|
+
* fact is which label runs AWS serves and which of them carry a FIXED suffix —
|
|
65808
|
+
* which is exactly what had drifted — while each matcher keeps its own
|
|
65809
|
+
* acceptance rule, deliberately: gc matches the suffix against the union over
|
|
65810
|
+
* every partition and {@link parseEcrRegistryHost} pairs it WITH the region (see
|
|
65811
|
+
* `gc.ts`'s `AWS_URL_SUFFIXES` doc for why the two strictnesses must not merge).
|
|
65812
|
+
*
|
|
65813
|
+
* A THIRD, looser spelling of "this is an ECR registry host" still lives outside
|
|
65814
|
+
* this table — `isCdkAssetImageUri`'s `host.includes('.dkr.ecr.')` in
|
|
65815
|
+
* `src/local/ecs-task-resolver.ts`, which recognizes only the plain form and is
|
|
65816
|
+
* case-SENSITIVE. It is tracked as issue #1846 rather than folded in here: it
|
|
65817
|
+
* must keep tolerating an UNRESOLVED host
|
|
65818
|
+
* (`${AWS::AccountId}.dkr.ecr.${AWS::Region}.${AWS::URLSuffix}`), so it cannot
|
|
65819
|
+
* simply become a call to either matcher.
|
|
65820
|
+
*
|
|
65821
|
+
* Every row is read off the AWS-published `ecr` endpoint list
|
|
65822
|
+
* (https://docs.aws.amazon.com/general/latest/gr/ecr.html) plus the docker
|
|
65823
|
+
* push / pull examples in the ECR user guide's "Making requests to Amazon ECR
|
|
65824
|
+
* registries", rather than inferred:
|
|
65825
|
+
*
|
|
65826
|
+
* - `<acct>.dkr.ecr.<region>.<urlSuffix>` — the IPv4-only registry endpoint,
|
|
65827
|
+
* every partition.
|
|
65828
|
+
* - `<acct>.dkr.ecr-fips.<region>.<urlSuffix>` — the FIPS IPv4 endpoint, served
|
|
65829
|
+
* in `us-east-1` / `us-east-2` / `us-west-1` / `us-west-2` /
|
|
65830
|
+
* `us-gov-east-1` / `us-gov-west-1` — i.e. only in partitions whose suffix is
|
|
65831
|
+
* `amazonaws.com`, which is why pairing it with the region's own partition
|
|
65832
|
+
* suffix is exactly right rather than merely convenient.
|
|
65833
|
+
* - `<acct>.dkr-ecr.<region>.on.aws` — the dual-stack (IPv4 + IPv6) endpoint.
|
|
65834
|
+
* - `<acct>.dkr-ecr-fips.<region>.on.aws` — the dual-stack FIPS endpoint, in the
|
|
65835
|
+
* same six regions as the IPv4 FIPS form. This one was missing from BOTH
|
|
65836
|
+
* copies of the grammar; unifying them is what surfaced it.
|
|
65837
|
+
*
|
|
65838
|
+
* The two dual-stack forms carry a FIXED suffix, so their check is that literal
|
|
65839
|
+
* rather than the region's partition suffix — the TIGHTEST available check for
|
|
65840
|
+
* them, not a relaxation: `on.aws` is an AWS-owned domain, so unlike a captured
|
|
65841
|
+
* suffix it cannot be substituted by a host someone else owns. They are
|
|
65842
|
+
* accepted for ANY region rather than only the partitions AWS documents them
|
|
65843
|
+
* in, deliberately: under-accepting is precisely the issue #1764 failure (a
|
|
65844
|
+
* cdkd table lagging AWS made a GENUINE registry classify as public), and the
|
|
65845
|
+
* suffix being AWS-owned means the widening cannot point a `docker login`
|
|
65846
|
+
* anywhere but AWS.
|
|
65847
|
+
*/
|
|
65848
|
+
const ECR_REGISTRY_HOST_FORMS = [
|
|
65849
|
+
{ labels: "dkr.ecr" },
|
|
65850
|
+
{ labels: "dkr.ecr-fips" },
|
|
65851
|
+
{
|
|
65852
|
+
labels: "dkr-ecr",
|
|
65853
|
+
fixedUrlSuffix: "on.aws"
|
|
65854
|
+
},
|
|
65855
|
+
{
|
|
65856
|
+
labels: "dkr-ecr-fips",
|
|
65857
|
+
fixedUrlSuffix: "on.aws"
|
|
65858
|
+
}
|
|
65859
|
+
];
|
|
65860
|
+
/**
|
|
65861
|
+
* {@link ECR_REGISTRY_HOST_FORMS} ordered LONGEST LABELS FIRST, so
|
|
65862
|
+
* `dkr.ecr-fips` is tried before its own `dkr.ecr` prefix instead of relying on
|
|
65863
|
+
* backtracking.
|
|
65864
|
+
*/
|
|
65865
|
+
const ECR_HOST_FORMS_LONGEST_FIRST = [...ECR_REGISTRY_HOST_FORMS].sort((a, b) => b.labels.length - a.labels.length);
|
|
65866
|
+
/**
|
|
65867
|
+
* {@link ECR_REGISTRY_HOST_FORMS} keyed by its (already lower-case) labels, for
|
|
65868
|
+
* resolving which suffix rule the matched form wants.
|
|
65869
|
+
*/
|
|
65870
|
+
const ECR_HOST_FORM_BY_LABELS = new Map(ECR_REGISTRY_HOST_FORMS.map((form) => [form.labels, form]));
|
|
65871
|
+
/**
|
|
65872
|
+
* The host half of an ECR image URI as a regex SOURCE string, built from
|
|
65873
|
+
* {@link ECR_REGISTRY_HOST_FORMS} (issue #1793).
|
|
65874
|
+
*
|
|
65875
|
+
* ONE consumer: `cdkd gc`, which passes loose non-capturing sub-patterns and
|
|
65876
|
+
* matches the suffix against the union over every partition.
|
|
65877
|
+
*
|
|
65878
|
+
* **This module does NOT call it**, and that is structural rather than an
|
|
65879
|
+
* oversight — {@link ECR_URI_HOST_REGEX} re-spells the alternation from the
|
|
65880
|
+
* table's LABELS column instead. The strict matcher needs the suffix CAPTURED,
|
|
65881
|
+
* at a FIXED group index, to pair it with the region; this builder inlines a
|
|
65882
|
+
* `fixedUrlSuffix` as an UNCAPTURED literal, so the capturing-group count would
|
|
65883
|
+
* differ per alternative and the group index of the suffix would depend on which
|
|
65884
|
+
* form matched. Making the fixed suffix capturing to even the count is not the
|
|
65885
|
+
* fix either: it would hand gc a group it does not want, ahead of the tag /
|
|
65886
|
+
* digest groups it reads BY POSITION. So the sharable unit is the table, not the
|
|
65887
|
+
* pattern — and the cross-matcher test in `tests/unit/cli/gc.test.ts` is driven
|
|
65888
|
+
* off the table, so a row added there must work on BOTH sides or the suite reds.
|
|
65889
|
+
*
|
|
65890
|
+
* Every group this function ADDS is non-capturing, and that is load-bearing
|
|
65891
|
+
* rather than stylistic, for the same by-position reason: `gc.ts` reads its ECR
|
|
65892
|
+
* tag / digest by GROUP INDEX, and a capturing group here would silently shift
|
|
65893
|
+
* them. The caller's own three sub-patterns are interpolated verbatim, so
|
|
65894
|
+
* whether THOSE capture is the caller's business.
|
|
65895
|
+
*/
|
|
65896
|
+
function ecrRegistryHostPattern(segments) {
|
|
65897
|
+
const forms = ECR_HOST_FORMS_LONGEST_FIRST.map((form) => {
|
|
65898
|
+
const suffix = form.fixedUrlSuffix === void 0 ? segments.partitionUrlSuffix : escapeRegExp(form.fixedUrlSuffix);
|
|
65899
|
+
return `${escapeRegExp(form.labels)}\\.${segments.region}\\.${suffix}`;
|
|
65900
|
+
});
|
|
65901
|
+
return `${segments.accountId}\\.(?:${forms.join("|")})`;
|
|
65902
|
+
}
|
|
65903
|
+
/**
|
|
65904
|
+
* Matching the HOST half of an ECR image URI:
|
|
65905
|
+
* `<acct>.<labels>.<region>.<urlSuffix>/`.
|
|
65906
|
+
*
|
|
65907
|
+
* The suffix is CAPTURED rather than spelled out (issue #1758): the previous
|
|
65908
|
+
* pattern hardcoded `amazonaws.com` with an optional `.cn` tail, so a `us-iso*`
|
|
65909
|
+
* registry (`c2s.ic.gov` / `sc2s.sgov.gov`) never matched and the caller
|
|
65910
|
+
* silently classified a real ECR image as a user-managed one — skipping the
|
|
65911
|
+
* `docker login` it needs.
|
|
65912
|
+
*
|
|
65913
|
+
* The LABELS are captured too, because which suffix is expected depends on
|
|
65914
|
+
* WHICH form matched (issue #1793) — the dual-stack forms carry a fixed
|
|
65915
|
+
* `on.aws` rather than the region's partition suffix.
|
|
65916
|
+
*
|
|
65917
|
+
* Built from {@link ECR_REGISTRY_HOST_FORMS}'s LABELS column rather than through
|
|
65918
|
+
* {@link ecrRegistryHostPattern}: the builder cannot serve this matcher's fixed
|
|
65919
|
+
* capture-group layout (see its doc). The table is what the two matchers share.
|
|
65920
|
+
*
|
|
65921
|
+
* The `i` flag makes the labels case-INSENSITIVE (issue #1792). DNS is
|
|
65922
|
+
* case-insensitive, so `<acct>.DKR.ECR.<region>.<suffix>` names the same host,
|
|
65923
|
+
* but the labels were spelled as case-sensitive literals — and unlike the
|
|
65924
|
+
* issue #1786 defect one layer over, the host then did not match the SHAPE at
|
|
65925
|
+
* all, so `parseEcrRegistryHost` returned `undefined` AND
|
|
65926
|
+
* {@link looksLikeEcrHostWithForeignSuffix} returned `false`, i.e. not even the
|
|
65927
|
+
* issue #1764 diagnostic fired.
|
|
65928
|
+
*
|
|
65929
|
+
* Widening the shape is the right fix rather than a refusal because DOCKER
|
|
65930
|
+
* accepts an upper-cased registry host, so cdkd is not made to `docker login`
|
|
65931
|
+
* for a URI docker will then reject. `distribution/reference`'s grammar spells
|
|
65932
|
+
* `domain-component` as `([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])`
|
|
65933
|
+
* while the repository PATH is `alpha-numeric := [a-z0-9]+`, and
|
|
65934
|
+
* `ParseNormalizedNamed` raises `repository name must be lowercase` for the
|
|
65935
|
+
* REMOTE NAME only — `splitDockerDomain` performs no case folding on the
|
|
65936
|
+
* domain. This change never touches the repository path.
|
|
65937
|
+
*
|
|
65938
|
+
* What docker does NOT fold is its CREDENTIAL STORE, which is keyed on the
|
|
65939
|
+
* hostname verbatim (measured in issue #1801: a pull of an upper-cased host
|
|
65940
|
+
* sent no credentials at all, while the lower-cased spelling authenticated).
|
|
65941
|
+
* That is why the fix here is only the SHAPE match: the login / pull spelling
|
|
65942
|
+
* is reconciled one layer up by `ecr-puller.ts`'s `canonicalizeImageUriHost`,
|
|
65943
|
+
* which every docker-facing consumer already routes through. Issue #1817
|
|
65944
|
+
* tracks moving that fold in here.
|
|
65945
|
+
*
|
|
65946
|
+
* **Every verdict issues #1792 / #1793 changed, in full** — `p` is
|
|
65947
|
+
* {@link parseEcrRegistryHost}, `d` is
|
|
65948
|
+
* {@link looksLikeEcrHostWithForeignSuffix} (the issue #1764 diagnostic), `S`
|
|
65949
|
+
* is the region's own partition suffix and `F` a foreign one. The table is
|
|
65950
|
+
* spelled out because the DIAGNOSTIC-ONLY rows are the easy ones to omit: `p`
|
|
65951
|
+
* is unchanged there, so a summary that tracks only the parse verdict reads as
|
|
65952
|
+
* "no change" while a warning that never used to fire now does.
|
|
65953
|
+
*
|
|
65954
|
+
* ```text
|
|
65955
|
+
* host before after what changed
|
|
65956
|
+
* <a>.dkr.ecr.<r>.S p=ok d=f p=ok d=f nothing
|
|
65957
|
+
* <a>.dkr.ecr.<r>.F p=und d=TRUE p=und d=TRUE nothing
|
|
65958
|
+
* <a>.dkr.ecr.<r>.on.aws p=und d=TRUE p=und d=TRUE nothing (*)
|
|
65959
|
+
* <a>.DKR.ECR.<r>.S p=und d=f p=ok d=f #1792 PARSE
|
|
65960
|
+
* <a>.DKR.ECR.<r>.F p=und d=f p=und d=TRUE #1792 diag only
|
|
65961
|
+
* <a>.dkr.ecr-fips.<r>.S p=und d=f p=ok d=f #1793 PARSE
|
|
65962
|
+
* <a>.dkr.ecr-fips.<r>.F p=und d=f p=und d=TRUE #1793 diag only
|
|
65963
|
+
* <a>.dkr-ecr.<r>.on.aws p=und d=f p=ok d=f #1793 PARSE
|
|
65964
|
+
* <a>.dkr-ecr-fips.<r>.on.aws p=und d=f p=ok d=f #1793 PARSE
|
|
65965
|
+
* <a>.dkr-ecr.<r>.S p=und d=f p=und d=TRUE #1793 diag only
|
|
65966
|
+
* <a>.dkr-ecr-fips.<r>.S p=und d=f p=und d=TRUE #1793 diag only
|
|
65967
|
+
* <a>.dkr.ecr-fips.<r>.on.aws p=und d=f p=und d=TRUE #1793 diag only
|
|
65968
|
+
* ```
|
|
65969
|
+
*
|
|
65970
|
+
* (*) `dkr.ecr` + `on.aws` is the one mispairing that ALREADY reported `true`
|
|
65971
|
+
* before the change: the pre-#1793 pattern matched the plain labels against a
|
|
65972
|
+
* wildcard `([^/]+)` suffix, so `on.aws` was simply a suffix that is not
|
|
65973
|
+
* `amazonaws.com`. Every other row that flips `d` was previously refused at the
|
|
65974
|
+
* SHAPE, which is why both verdicts were quiet.
|
|
65975
|
+
*
|
|
65976
|
+
* The five `d`-only flips are an improvement in kind — a host AWS does not serve
|
|
65977
|
+
* now says so instead of passing silently as a public image — but the caller's
|
|
65978
|
+
* MESSAGE was written for the #1764 partition-gap case and now over-claims for a
|
|
65979
|
+
* form/suffix mispairing (`amazonaws.com` IS `us-east-1`'s partition suffix).
|
|
65980
|
+
* `src/local/ecs-task-resolver.ts` owns that wording; recorded on issue #1846.
|
|
65981
|
+
*/
|
|
65982
|
+
const ECR_URI_HOST_REGEX = new RegExp(`^(\\d{12})\\.(${ECR_HOST_FORMS_LONGEST_FIRST.map((form) => escapeRegExp(form.labels)).join("|")})\\.([^.]+)\\.([^/]+)/`, "i");
|
|
65983
|
+
/**
|
|
65984
|
+
* A region segment cdkd is willing to treat as a region id.
|
|
65985
|
+
*
|
|
65986
|
+
* Case is not the only way the `startsWith` partition classification can be
|
|
65987
|
+
* side-stepped: any leading junk defeats it the same way, and the captured
|
|
65988
|
+
* segment does not stay inert — it becomes an `ECRClient({region})` and is
|
|
65989
|
+
* interpolated into the fallback `docker login` endpoint. Measured before this
|
|
65990
|
+
* guard: `…dkr.ecr. us-iso-east-1.amazonaws.com/…` (leading space) and a
|
|
65991
|
+
* combining-mark form both parsed, yielding regions `" us-iso-east-1"` and
|
|
65992
|
+
* `"us-i̇so-east-1"`. Neither leaks credentials — an ACCEPTED suffix is always
|
|
65993
|
+
* an AWS-owned domain — but a region id is ASCII alphanumeric plus `-`, so
|
|
65994
|
+
* anything else is a malformed host and refusing it keeps the classification
|
|
65995
|
+
* honest.
|
|
65996
|
+
*
|
|
65997
|
+
* TESTED AGAINST THE RAW SEGMENT, BEFORE `toLowerCase()`, and that order is the
|
|
65998
|
+
* whole point: `String.prototype.toLowerCase` performs full Unicode case
|
|
65999
|
+
* folding, so the Kelvin sign U+212A folds to a plain ASCII `k`. Checking the
|
|
66000
|
+
* FOLDED segment therefore accepts `us-eKst-1` and yields the region
|
|
66001
|
+
* `us-ekst-1` — a region the host does not name — which is precisely the
|
|
66002
|
+
* class of substitution this guard exists to refuse. Matching `[A-Za-z0-9-]`
|
|
66003
|
+
* on the raw capture keeps case-insensitivity (the point of issue #1786) while
|
|
66004
|
+
* admitting only characters that fold to themselves.
|
|
66005
|
+
*
|
|
66006
|
+
* ONE DELIBERATE SIDE EFFECT, called out because it is a withdrawal rather than
|
|
66007
|
+
* an addition: a malformed-region host is now classified as an ordinary public
|
|
66008
|
+
* image with NO cdkd-side signal at all — neither the ECR verdict nor the #1764
|
|
66009
|
+
* foreign-suffix diagnostic. Both arms change, not just the foreign one:
|
|
66010
|
+
* `<acct>.dkr.ecr.us_east_1.example.com/…` used to log the partition-gap
|
|
66011
|
+
* warning and now logs nothing, and `<acct>.dkr.ecr.us_east_1.amazonaws.com/…`
|
|
66012
|
+
* used to be ACCEPTED as ECR and is now refused. That is the correct verdict
|
|
66013
|
+
* rather than a regression — the diagnostic's whole subject is "this suffix does
|
|
66014
|
+
* not belong to its region's PARTITION", and a segment that is not a region id
|
|
66015
|
+
* has no partition to belong to, so reporting it would send the reader hunting
|
|
66016
|
+
* for a table entry that could never exist — but it does mean a typo'd region
|
|
66017
|
+
* gets no cdkd-side hint, only docker's own pull failure. Both arms are pinned
|
|
66018
|
+
* by tests so a future widening of the guard cannot flip them silently.
|
|
66019
|
+
*/
|
|
66020
|
+
const CANONICAL_REGION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
66021
|
+
/**
|
|
66022
|
+
* A URL suffix cdkd is willing to compare against the partition table.
|
|
66023
|
+
*
|
|
66024
|
+
* The SAME raw-capture rule as the region, and for the same reason one layer
|
|
66025
|
+
* over: the suffix is folded before the comparison, so a code point folding
|
|
66026
|
+
* into ASCII can impersonate a partition's suffix. This became reachable the
|
|
66027
|
+
* moment issue #1764 landed `aws-isoe`'s `cloud.adc-e.uk` — that suffix
|
|
66028
|
+
* contains a `k`, so `…dkr.ecr.eu-isoe-west-1.cloud.adc-e.uK/…` (Kelvin
|
|
66029
|
+
* sign) folded onto it and was ACCEPTED. Not a credential leak, since UTS-46
|
|
66030
|
+
* maps U+212A to `k` too and the host resolves to the real AWS domain, but it
|
|
66031
|
+
* falsifies the invariant the region guard's comment above states, so both
|
|
66032
|
+
* captures are held to the same rule rather than only the one that was
|
|
66033
|
+
* measured first.
|
|
66034
|
+
*
|
|
66035
|
+
* Dots are admitted because a suffix is a dotted DNS name; every other
|
|
66036
|
+
* character is refused, so the accepted set stays exactly the partition
|
|
66037
|
+
* table's own literals.
|
|
66038
|
+
*/
|
|
66039
|
+
const CANONICAL_URL_SUFFIX = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
|
|
66040
|
+
/**
|
|
66041
|
+
* The ONE case-normalization boundary of this module (issue #1786).
|
|
66042
|
+
*
|
|
66043
|
+
* DNS is case-INSENSITIVE, so `US-ISO-EAST-1.amazonaws.com` and
|
|
66044
|
+
* `us-iso-east-1.amazonaws.com` name the same host — but both halves of the
|
|
66045
|
+
* #1758 suffix check were case-SENSITIVE, in OPPOSITE directions:
|
|
66046
|
+
*
|
|
66047
|
+
* - an upper-cased REGION fails every `startsWith` prefix test in
|
|
66048
|
+
* `derivePartitionAndUrlSuffix`, so it falls through to the commercial
|
|
66049
|
+
* partition whose suffix is `amazonaws.com` — which is exactly what an ISO
|
|
66050
|
+
* look-alike host carries, so the strict check ACCEPTED it (the bypass);
|
|
66051
|
+
* - an upper-cased region or SUFFIX on a GENUINE host stopped matching its own
|
|
66052
|
+
* partition's suffix, so a real ECR registry was REJECTED and the caller
|
|
66053
|
+
* silently classified it as a user-managed image — the #1758 regression, one
|
|
66054
|
+
* layer up.
|
|
66055
|
+
*
|
|
66056
|
+
* The normalization lives HERE rather than inside `derivePartitionAndUrlSuffix`
|
|
66057
|
+
* because this is the boundary where an untrusted, DNS-shaped string enters
|
|
66058
|
+
* cdkd, and keeping it in ONE function means both exported entry points below —
|
|
66059
|
+
* and the suffix COMPARISON itself — cannot drift apart.
|
|
66060
|
+
*
|
|
66061
|
+
* This does NOT duplicate `canonicalizeRegion`, which issue #1795 added inside
|
|
66062
|
+
* `derivePartitionAndUrlSuffix` so the mapping folds its own input. The two
|
|
66063
|
+
* answer different questions and both are needed: that one makes the partition
|
|
66064
|
+
* LOOKUP case-insensitive for every caller, while these guards decide whether
|
|
66065
|
+
* the captured segments are a region id and a URL suffix AT ALL. Folding alone
|
|
66066
|
+
* cannot do that — folding is precisely what turns `us-eKst-1` into the
|
|
66067
|
+
* plausible `us-ekst-1` (see the guards' own comments), so a fold-only helper
|
|
66068
|
+
* makes the substitution easier to reach, not harder. Double-folding is a
|
|
66069
|
+
* no-op, which is why the redundant `toLowerCase()` calls below are left in
|
|
66070
|
+
* rather than removed: they keep this function the single answer for the
|
|
66071
|
+
* comparison even if the helper's behaviour changes again.
|
|
66072
|
+
*
|
|
66073
|
+
* The derived suffix is lower-cased too. Every entry in the partition table is
|
|
66074
|
+
* a lower-case literal today, so that is a no-op; it is there so this function
|
|
66075
|
+
* stays the single answer even if that table ever gains a mixed-case entry.
|
|
66076
|
+
*/
|
|
66077
|
+
function matchEcrRegistryHost(imageUri) {
|
|
66078
|
+
const m = ECR_URI_HOST_REGEX.exec(imageUri);
|
|
66079
|
+
if (!m) return void 0;
|
|
66080
|
+
if (!CANONICAL_REGION_SEGMENT.test(m[3])) return void 0;
|
|
66081
|
+
if (!CANONICAL_URL_SUFFIX.test(m[4])) return void 0;
|
|
66082
|
+
const form = ECR_HOST_FORM_BY_LABELS.get(m[2].toLowerCase());
|
|
66083
|
+
if (!form) return void 0;
|
|
66084
|
+
const region = m[3].toLowerCase();
|
|
66085
|
+
return {
|
|
66086
|
+
accountId: m[1],
|
|
66087
|
+
region,
|
|
66088
|
+
suffix: m[4].toLowerCase(),
|
|
66089
|
+
expectedSuffix: (form.fixedUrlSuffix ?? derivePartitionAndUrlSuffix(region).urlSuffix).toLowerCase()
|
|
66090
|
+
};
|
|
66091
|
+
}
|
|
66092
|
+
/**
|
|
66093
|
+
* The account + region of an ECR image URI, or `undefined` when the URI is not
|
|
66094
|
+
* an ECR registry host — either because it does not have one of the shapes
|
|
66095
|
+
* {@link ECR_REGISTRY_HOST_FORMS} lists, or because its suffix is not the one
|
|
66096
|
+
* the matched form carries for the region it names.
|
|
66097
|
+
*
|
|
66098
|
+
* Lives in `src/utils/` rather than beside its first caller because it has TWO
|
|
66099
|
+
* consumers in different layers — `src/local/ecr-puller.ts` (which needs the
|
|
66100
|
+
* `:tag` tail too) and `src/local/ecs-task-resolver.ts` (which classifies an
|
|
66101
|
+
* image that may carry a digest or no tag at all). Before issue #1758 they each
|
|
66102
|
+
* carried their own copy of the hardcoded commercial pattern, and fixing only
|
|
66103
|
+
* the pull path left `cdkd local run-task` broken outside the commercial
|
|
66104
|
+
* partition; ONE definition is what stops them drifting apart again.
|
|
66105
|
+
*
|
|
66106
|
+
* The module is deliberately free of AWS SDK imports, which is what lets
|
|
66107
|
+
* `ecs-task-resolver.ts` consume it without breaking the invariant that module
|
|
66108
|
+
* documents about itself (it resolves secrets through a separate module for
|
|
66109
|
+
* exactly that reason).
|
|
66110
|
+
*
|
|
66111
|
+
* The returned `region` is LOWER-CASED (issue #1786) — it is consumed as an SDK
|
|
66112
|
+
* client region by `ecr-puller.ts` and as the `region` of a `kind: 'ecr'` image
|
|
66113
|
+
* by `ecs-task-resolver.ts`, and the canonical spelling of an AWS region id is
|
|
66114
|
+
* lower case.
|
|
66115
|
+
*/
|
|
66116
|
+
function parseEcrRegistryHost(imageUri) {
|
|
66117
|
+
const m = matchEcrRegistryHost(imageUri);
|
|
66118
|
+
if (!m) return void 0;
|
|
66119
|
+
if (m.suffix !== m.expectedSuffix) return void 0;
|
|
66120
|
+
return {
|
|
66121
|
+
accountId: m.accountId,
|
|
66122
|
+
region: m.region
|
|
66123
|
+
};
|
|
66124
|
+
}
|
|
66125
|
+
/**
|
|
66126
|
+
* True when the URI has one of the ECR HOST SHAPES but carries a suffix that
|
|
66127
|
+
* form does not serve for the region it names — i.e. exactly the case
|
|
66128
|
+
* {@link parseEcrRegistryHost} rejects for a reason the caller may want to
|
|
66129
|
+
* report.
|
|
66130
|
+
*
|
|
66131
|
+
* Since issue #1793 that covers a form / suffix MISPAIRING too, not only a
|
|
66132
|
+
* foreign suffix: `<acct>.dkr-ecr.<region>.amazonaws.com` and
|
|
66133
|
+
* `<acct>.dkr.ecr.<region>.on.aws` each spell one form's labels with the
|
|
66134
|
+
* other's suffix, and AWS serves neither.
|
|
66135
|
+
*
|
|
66136
|
+
* The two rejections are worth telling apart at a call site that degrades
|
|
66137
|
+
* silently: a genuinely public image and a registry in a partition
|
|
66138
|
+
* `derivePartitionAndUrlSuffix` does not know yet (issue #1764) both come back
|
|
66139
|
+
* `undefined`, and only the second is a cdkd gap rather than a user choice.
|
|
66140
|
+
*/
|
|
66141
|
+
function looksLikeEcrHostWithForeignSuffix(imageUri) {
|
|
66142
|
+
const m = matchEcrRegistryHost(imageUri);
|
|
66143
|
+
return m !== void 0 && m.suffix !== m.expectedSuffix;
|
|
66144
|
+
}
|
|
66145
|
+
|
|
65047
66146
|
//#endregion
|
|
65048
66147
|
//#region src/cli/commands/gc.ts
|
|
65049
66148
|
/**
|
|
@@ -65103,10 +66202,6 @@ function parseOlderThan(value) {
|
|
|
65103
66202
|
const multiplier = match[2] === "d" ? 1440 * 60 * 1e3 : 3600 * 1e3;
|
|
65104
66203
|
return Math.round(num * multiplier);
|
|
65105
66204
|
}
|
|
65106
|
-
/** Escape a literal string for embedding in a RegExp. */
|
|
65107
|
-
function escapeRegExp(value) {
|
|
65108
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
65109
|
-
}
|
|
65110
66205
|
/**
|
|
65111
66206
|
* Characters that terminate an S3 key extracted from a URL-shaped string.
|
|
65112
66207
|
* Whitespace / quotes never appear in cdkd's content-addressed asset keys
|
|
@@ -65144,30 +66239,50 @@ const GC_EXTRA_URL_SUFFIXES = [];
|
|
|
65144
66239
|
*/
|
|
65145
66240
|
const URL_SUFFIX_ALTERNATION = `(?:${[...[.../* @__PURE__ */ new Set([...PARTITION_TABLE.map((row) => row.urlSuffix), ...GC_EXTRA_URL_SUFFIXES])]].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|")})`;
|
|
65146
66241
|
/**
|
|
65147
|
-
* The HOST half of an ECR registry,
|
|
65148
|
-
*
|
|
65149
|
-
*
|
|
65150
|
-
*
|
|
65151
|
-
*
|
|
65152
|
-
*
|
|
65153
|
-
*
|
|
65154
|
-
*
|
|
65155
|
-
*
|
|
65156
|
-
*
|
|
65157
|
-
*
|
|
65158
|
-
*
|
|
66242
|
+
* The HOST half of an ECR registry, built from the ONE authoritative FORM TABLE
|
|
66243
|
+
* in `src/utils/ecr-uri.ts` (issue #1793) instead of spelled a second time here.
|
|
66244
|
+
*
|
|
66245
|
+
* That module owns which segment spellings AWS serves — the plain
|
|
66246
|
+
* `<acct>.dkr.ecr.<region>.<urlSuffix>`, its FIPS sibling
|
|
66247
|
+
* `<acct>.dkr.ecr-fips.<region>.<urlSuffix>`, and the two dual-stack forms
|
|
66248
|
+
* `<acct>.dkr-ecr[-fips].<region>.on.aws` — while each matcher keeps its own
|
|
66249
|
+
* suffix-ACCEPTANCE rule on top: gc passes the union over every partition
|
|
66250
|
+
* ({@link URL_SUFFIX_ALTERNATION}), whereas `parseEcrRegistryHost` pairs the
|
|
66251
|
+
* captured suffix WITH the region. See the {@link AWS_URL_SUFFIXES} doc for why
|
|
66252
|
+
* the two strictnesses are deliberate and must not be merged.
|
|
66253
|
+
*
|
|
66254
|
+
* gc is the ONLY consumer of {@link ecrRegistryHostPattern}: the strict matcher
|
|
66255
|
+
* there needs the suffix captured at a fixed group index and so re-spells the
|
|
66256
|
+
* alternation off the same table's labels column. What the two share is the
|
|
66257
|
+
* TABLE, not the pattern — enough to stop the FORMS drifting, which is what had
|
|
66258
|
+
* gone wrong, and the cross-matcher test in `tests/unit/cli/gc.test.ts` is driven
|
|
66259
|
+
* off the table so a row added there must work on both sides.
|
|
66260
|
+
*
|
|
66261
|
+
* Sharing the table immediately brought a form gc was MISSING:
|
|
66262
|
+
* `<acct>.dkr-ecr-fips.<region>.on.aws`, the dual-stack FIPS endpoint. A form
|
|
66263
|
+
* missing here reads a live image as unreferenced and DELETES it, so this is the
|
|
66264
|
+
* irreversible direction — which is the whole argument for one definition.
|
|
66265
|
+
*
|
|
66266
|
+
* `on.aws` still does not reach the S3 matchers, and now cannot: it is an
|
|
66267
|
+
* ECR-only endpoint, so folding it into the shared suffix set would also make
|
|
66268
|
+
* `https://<assetBucket>.s3.<region>.on.aws/<key>` a recognized S3 reference —
|
|
66269
|
+
* and in the form table a fixed suffix travels WITH its own form rather than
|
|
66270
|
+
* sitting in a shared set, so that separation is mechanical instead of
|
|
66271
|
+
* remembered.
|
|
65159
66272
|
*
|
|
65160
66273
|
* Account and region are matched loosely on purpose (see the extractor doc):
|
|
65161
66274
|
* collecting a reference from another account's or region's URI can only KEEP
|
|
65162
|
-
* more, never delete more — which is also why the
|
|
65163
|
-
* carrying even though cdkd's own publisher writes the plain one.
|
|
65164
|
-
* tracks deriving these host forms from ONE place shared with
|
|
65165
|
-
* `src/utils/ecr-uri.ts` instead of two hand-kept copies — that module matches
|
|
65166
|
-
* only the plain `dkr.ecr` form today, so the two already disagree.
|
|
66275
|
+
* more, never delete more — which is also why the extra forms are worth
|
|
66276
|
+
* carrying even though cdkd's own publisher writes the plain one.
|
|
65167
66277
|
*
|
|
65168
|
-
* Every group is non-capturing, for the {@link URL_SUFFIX_ALTERNATION} reason
|
|
66278
|
+
* Every group is non-capturing, for the {@link URL_SUFFIX_ALTERNATION} reason;
|
|
66279
|
+
* {@link ecrRegistryHostPattern} guarantees it for the groups IT adds.
|
|
65169
66280
|
*/
|
|
65170
|
-
const ECR_REGISTRY_HOST =
|
|
66281
|
+
const ECR_REGISTRY_HOST = ecrRegistryHostPattern({
|
|
66282
|
+
accountId: "\\d{12}",
|
|
66283
|
+
region: "[a-z0-9-]+",
|
|
66284
|
+
partitionUrlSuffix: URL_SUFFIX_ALTERNATION
|
|
66285
|
+
});
|
|
65171
66286
|
/**
|
|
65172
66287
|
* cdkd's publishers write content-addressed keys (`<sha256>.<ext>`). A
|
|
65173
66288
|
* second, name-independent pass collects every such token from every
|
|
@@ -65225,7 +66340,7 @@ function buildReferenceExtractors(marker) {
|
|
|
65225
66340
|
const s3UriRe = new RegExp(`s3://${bucket}/(${KEY_TERMINATORS}+)`, "g");
|
|
65226
66341
|
const virtualHostedRe = new RegExp(`https://${bucket}\\.s3[^/\\s]*\\.${URL_SUFFIX_ALTERNATION}/(${KEY_TERMINATORS}+)`, "g");
|
|
65227
66342
|
const pathStyleRe = new RegExp(`https://s3[^/\\s]*\\.${URL_SUFFIX_ALTERNATION}/${bucket}/(${KEY_TERMINATORS}+)`, "g");
|
|
65228
|
-
const ecrRe = new RegExp(`${ECR_REGISTRY_HOST}/${repo}(?::([A-Za-z0-9_][A-Za-z0-9._-]{0,127}))?(?:@(sha256:[0-9a-f]{64}))?`, "
|
|
66343
|
+
const ecrRe = new RegExp(`${ECR_REGISTRY_HOST}/${repo}(?::([A-Za-z0-9_][A-Za-z0-9._-]{0,127}))?(?:@(sha256:[0-9a-f]{64}))?`, "gi");
|
|
65229
66344
|
const extractOnce = (value, refs) => {
|
|
65230
66345
|
for (const re of [
|
|
65231
66346
|
s3UriRe,
|
|
@@ -65234,7 +66349,7 @@ function buildReferenceExtractors(marker) {
|
|
|
65234
66349
|
]) for (const match of value.matchAll(re)) if (match[1]) refs.s3Keys.add(match[1]);
|
|
65235
66350
|
for (const match of value.matchAll(ecrRe)) {
|
|
65236
66351
|
if (match[1]) refs.imageTags.add(match[1]);
|
|
65237
|
-
if (match[2]) refs.imageDigests.add(match[2]);
|
|
66352
|
+
if (match[2]) refs.imageDigests.add(match[2].toLowerCase());
|
|
65238
66353
|
}
|
|
65239
66354
|
for (const match of value.matchAll(CONTENT_HASH_KEY_RE)) refs.s3Keys.add(match[0]);
|
|
65240
66355
|
};
|
|
@@ -68441,183 +69556,6 @@ function notFoundError$1(target, stack, resources) {
|
|
|
68441
69556
|
return new LocalInvokeResolutionError(msg.trimEnd());
|
|
68442
69557
|
}
|
|
68443
69558
|
|
|
68444
|
-
//#endregion
|
|
68445
|
-
//#region src/utils/ecr-uri.ts
|
|
68446
|
-
/**
|
|
68447
|
-
* Matching the HOST half of an ECR image URI:
|
|
68448
|
-
* `<acct>.dkr.ecr.<region>.<urlSuffix>/`.
|
|
68449
|
-
*
|
|
68450
|
-
* The suffix is CAPTURED rather than spelled out (issue #1758): the previous
|
|
68451
|
-
* pattern hardcoded `amazonaws.com` with an optional `.cn` tail, so a `us-iso*`
|
|
68452
|
-
* registry (`c2s.ic.gov` / `sc2s.sgov.gov`) never matched and the caller
|
|
68453
|
-
* silently classified a real ECR image as a user-managed one — skipping the
|
|
68454
|
-
* `docker login` it needs.
|
|
68455
|
-
*
|
|
68456
|
-
* KNOWN BOUND (issue #1792): the `dkr.ecr` LABELS are still matched
|
|
68457
|
-
* case-sensitively, so `<acct>.DKR.ECR.<region>.<suffix>/` does not match at
|
|
68458
|
-
* all. Audited deliberately while fixing issue #1786 rather than missed — that
|
|
68459
|
-
* issue's normalization covers the captured region + suffix, i.e. the strict
|
|
68460
|
-
* CHECK, whereas widening the shape MATCHER makes cdkd `docker login` to a
|
|
68461
|
-
* host shape it previously skipped, which is a behavior expansion wanting its
|
|
68462
|
-
* own decision. The failure direction here is the safe one: NO credentials are
|
|
68463
|
-
* sent on any of the four call sites. Traced — `local run-task` and
|
|
68464
|
-
* `local invoke-agentcore` fall through to an anonymous `docker pull` that
|
|
68465
|
-
* fails, while `local invoke` and `local start-api` REFUSE before any pull.
|
|
68466
|
-
*/
|
|
68467
|
-
const ECR_URI_HOST_REGEX = /^(\d{12})\.dkr\.ecr\.([^.]+)\.([^/]+)\//;
|
|
68468
|
-
/**
|
|
68469
|
-
* A region segment cdkd is willing to treat as a region id.
|
|
68470
|
-
*
|
|
68471
|
-
* Case is not the only way the `startsWith` partition classification can be
|
|
68472
|
-
* side-stepped: any leading junk defeats it the same way, and the captured
|
|
68473
|
-
* segment does not stay inert — it becomes an `ECRClient({region})` and is
|
|
68474
|
-
* interpolated into the fallback `docker login` endpoint. Measured before this
|
|
68475
|
-
* guard: `…dkr.ecr. us-iso-east-1.amazonaws.com/…` (leading space) and a
|
|
68476
|
-
* combining-mark form both parsed, yielding regions `" us-iso-east-1"` and
|
|
68477
|
-
* `"us-i̇so-east-1"`. Neither leaks credentials — an ACCEPTED suffix is always
|
|
68478
|
-
* an AWS-owned domain — but a region id is ASCII alphanumeric plus `-`, so
|
|
68479
|
-
* anything else is a malformed host and refusing it keeps the classification
|
|
68480
|
-
* honest.
|
|
68481
|
-
*
|
|
68482
|
-
* TESTED AGAINST THE RAW SEGMENT, BEFORE `toLowerCase()`, and that order is the
|
|
68483
|
-
* whole point: `String.prototype.toLowerCase` performs full Unicode case
|
|
68484
|
-
* folding, so the Kelvin sign U+212A folds to a plain ASCII `k`. Checking the
|
|
68485
|
-
* FOLDED segment therefore accepts `us-eKst-1` and yields the region
|
|
68486
|
-
* `us-ekst-1` — a region the host does not name — which is precisely the
|
|
68487
|
-
* class of substitution this guard exists to refuse. Matching `[A-Za-z0-9-]`
|
|
68488
|
-
* on the raw capture keeps case-insensitivity (the point of issue #1786) while
|
|
68489
|
-
* admitting only characters that fold to themselves.
|
|
68490
|
-
*
|
|
68491
|
-
* ONE DELIBERATE SIDE EFFECT, called out because it is a withdrawal rather than
|
|
68492
|
-
* an addition: a malformed-region host is now classified as an ordinary public
|
|
68493
|
-
* image with NO cdkd-side signal at all — neither the ECR verdict nor the #1764
|
|
68494
|
-
* foreign-suffix diagnostic. Both arms change, not just the foreign one:
|
|
68495
|
-
* `<acct>.dkr.ecr.us_east_1.example.com/…` used to log the partition-gap
|
|
68496
|
-
* warning and now logs nothing, and `<acct>.dkr.ecr.us_east_1.amazonaws.com/…`
|
|
68497
|
-
* used to be ACCEPTED as ECR and is now refused. That is the correct verdict
|
|
68498
|
-
* rather than a regression — the diagnostic's whole subject is "this suffix does
|
|
68499
|
-
* not belong to its region's PARTITION", and a segment that is not a region id
|
|
68500
|
-
* has no partition to belong to, so reporting it would send the reader hunting
|
|
68501
|
-
* for a table entry that could never exist — but it does mean a typo'd region
|
|
68502
|
-
* gets no cdkd-side hint, only docker's own pull failure. Both arms are pinned
|
|
68503
|
-
* by tests so a future widening of the guard cannot flip them silently.
|
|
68504
|
-
*/
|
|
68505
|
-
const CANONICAL_REGION_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
68506
|
-
/**
|
|
68507
|
-
* A URL suffix cdkd is willing to compare against the partition table.
|
|
68508
|
-
*
|
|
68509
|
-
* The SAME raw-capture rule as the region, and for the same reason one layer
|
|
68510
|
-
* over: the suffix is folded before the comparison, so a code point folding
|
|
68511
|
-
* into ASCII can impersonate a partition's suffix. This became reachable the
|
|
68512
|
-
* moment issue #1764 landed `aws-isoe`'s `cloud.adc-e.uk` — that suffix
|
|
68513
|
-
* contains a `k`, so `…dkr.ecr.eu-isoe-west-1.cloud.adc-e.uK/…` (Kelvin
|
|
68514
|
-
* sign) folded onto it and was ACCEPTED. Not a credential leak, since UTS-46
|
|
68515
|
-
* maps U+212A to `k` too and the host resolves to the real AWS domain, but it
|
|
68516
|
-
* falsifies the invariant the region guard's comment above states, so both
|
|
68517
|
-
* captures are held to the same rule rather than only the one that was
|
|
68518
|
-
* measured first.
|
|
68519
|
-
*
|
|
68520
|
-
* Dots are admitted because a suffix is a dotted DNS name; every other
|
|
68521
|
-
* character is refused, so the accepted set stays exactly the partition
|
|
68522
|
-
* table's own literals.
|
|
68523
|
-
*/
|
|
68524
|
-
const CANONICAL_URL_SUFFIX = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
|
|
68525
|
-
/**
|
|
68526
|
-
* The ONE case-normalization boundary of this module (issue #1786).
|
|
68527
|
-
*
|
|
68528
|
-
* DNS is case-INSENSITIVE, so `US-ISO-EAST-1.amazonaws.com` and
|
|
68529
|
-
* `us-iso-east-1.amazonaws.com` name the same host — but both halves of the
|
|
68530
|
-
* #1758 suffix check were case-SENSITIVE, in OPPOSITE directions:
|
|
68531
|
-
*
|
|
68532
|
-
* - an upper-cased REGION fails every `startsWith` prefix test in
|
|
68533
|
-
* `derivePartitionAndUrlSuffix`, so it falls through to the commercial
|
|
68534
|
-
* partition whose suffix is `amazonaws.com` — which is exactly what an ISO
|
|
68535
|
-
* look-alike host carries, so the strict check ACCEPTED it (the bypass);
|
|
68536
|
-
* - an upper-cased region or SUFFIX on a GENUINE host stopped matching its own
|
|
68537
|
-
* partition's suffix, so a real ECR registry was REJECTED and the caller
|
|
68538
|
-
* silently classified it as a user-managed image — the #1758 regression, one
|
|
68539
|
-
* layer up.
|
|
68540
|
-
*
|
|
68541
|
-
* The normalization lives HERE rather than inside `derivePartitionAndUrlSuffix`
|
|
68542
|
-
* because this is the boundary where an untrusted, DNS-shaped string enters
|
|
68543
|
-
* cdkd, and keeping it in ONE function means both exported entry points below —
|
|
68544
|
-
* and the suffix COMPARISON itself — cannot drift apart.
|
|
68545
|
-
*
|
|
68546
|
-
* This does NOT duplicate `canonicalizeRegion`, which issue #1795 added inside
|
|
68547
|
-
* `derivePartitionAndUrlSuffix` so the mapping folds its own input. The two
|
|
68548
|
-
* answer different questions and both are needed: that one makes the partition
|
|
68549
|
-
* LOOKUP case-insensitive for every caller, while these guards decide whether
|
|
68550
|
-
* the captured segments are a region id and a URL suffix AT ALL. Folding alone
|
|
68551
|
-
* cannot do that — folding is precisely what turns `us-eKst-1` into the
|
|
68552
|
-
* plausible `us-ekst-1` (see the guards' own comments), so a fold-only helper
|
|
68553
|
-
* makes the substitution easier to reach, not harder. Double-folding is a
|
|
68554
|
-
* no-op, which is why the redundant `toLowerCase()` calls below are left in
|
|
68555
|
-
* rather than removed: they keep this function the single answer for the
|
|
68556
|
-
* comparison even if the helper's behaviour changes again.
|
|
68557
|
-
*
|
|
68558
|
-
* The derived suffix is lower-cased too. Every entry in the partition table is
|
|
68559
|
-
* a lower-case literal today, so that is a no-op; it is there so this function
|
|
68560
|
-
* stays the single answer even if that table ever gains a mixed-case entry.
|
|
68561
|
-
*/
|
|
68562
|
-
function matchEcrRegistryHost(imageUri) {
|
|
68563
|
-
const m = ECR_URI_HOST_REGEX.exec(imageUri);
|
|
68564
|
-
if (!m) return void 0;
|
|
68565
|
-
if (!CANONICAL_REGION_SEGMENT.test(m[2])) return void 0;
|
|
68566
|
-
if (!CANONICAL_URL_SUFFIX.test(m[3])) return void 0;
|
|
68567
|
-
const region = m[2].toLowerCase();
|
|
68568
|
-
return {
|
|
68569
|
-
accountId: m[1],
|
|
68570
|
-
region,
|
|
68571
|
-
suffix: m[3].toLowerCase(),
|
|
68572
|
-
expectedSuffix: derivePartitionAndUrlSuffix(region).urlSuffix.toLowerCase()
|
|
68573
|
-
};
|
|
68574
|
-
}
|
|
68575
|
-
/**
|
|
68576
|
-
* The account + region of an ECR image URI, or `undefined` when the URI is not
|
|
68577
|
-
* an ECR registry host for the region it names.
|
|
68578
|
-
*
|
|
68579
|
-
* Lives in `src/utils/` rather than beside its first caller because it has TWO
|
|
68580
|
-
* consumers in different layers — `src/local/ecr-puller.ts` (which needs the
|
|
68581
|
-
* `:tag` tail too) and `src/local/ecs-task-resolver.ts` (which classifies an
|
|
68582
|
-
* image that may carry a digest or no tag at all). Before issue #1758 they each
|
|
68583
|
-
* carried their own copy of the hardcoded commercial pattern, and fixing only
|
|
68584
|
-
* the pull path left `cdkd local run-task` broken outside the commercial
|
|
68585
|
-
* partition; ONE definition is what stops them drifting apart again.
|
|
68586
|
-
*
|
|
68587
|
-
* The module is deliberately free of AWS SDK imports, which is what lets
|
|
68588
|
-
* `ecs-task-resolver.ts` consume it without breaking the invariant that module
|
|
68589
|
-
* documents about itself (it resolves secrets through a separate module for
|
|
68590
|
-
* exactly that reason).
|
|
68591
|
-
*
|
|
68592
|
-
* The returned `region` is LOWER-CASED (issue #1786) — it is consumed as an SDK
|
|
68593
|
-
* client region by `ecr-puller.ts` and as the `region` of a `kind: 'ecr'` image
|
|
68594
|
-
* by `ecs-task-resolver.ts`, and the canonical spelling of an AWS region id is
|
|
68595
|
-
* lower case.
|
|
68596
|
-
*/
|
|
68597
|
-
function parseEcrRegistryHost(imageUri) {
|
|
68598
|
-
const m = matchEcrRegistryHost(imageUri);
|
|
68599
|
-
if (!m) return void 0;
|
|
68600
|
-
if (m.suffix !== m.expectedSuffix) return void 0;
|
|
68601
|
-
return {
|
|
68602
|
-
accountId: m.accountId,
|
|
68603
|
-
region: m.region
|
|
68604
|
-
};
|
|
68605
|
-
}
|
|
68606
|
-
/**
|
|
68607
|
-
* True when the URI has the ECR HOST SHAPE but its suffix does not belong to
|
|
68608
|
-
* the region it names — i.e. exactly the case {@link parseEcrRegistryHost}
|
|
68609
|
-
* rejects for a reason the caller may want to report.
|
|
68610
|
-
*
|
|
68611
|
-
* The two rejections are worth telling apart at a call site that degrades
|
|
68612
|
-
* silently: a genuinely public image and a registry in a partition
|
|
68613
|
-
* `derivePartitionAndUrlSuffix` does not know yet (issue #1764) both come back
|
|
68614
|
-
* `undefined`, and only the second is a cdkd gap rather than a user choice.
|
|
68615
|
-
*/
|
|
68616
|
-
function looksLikeEcrHostWithForeignSuffix(imageUri) {
|
|
68617
|
-
const m = matchEcrRegistryHost(imageUri);
|
|
68618
|
-
return m !== void 0 && m.suffix !== m.expectedSuffix;
|
|
68619
|
-
}
|
|
68620
|
-
|
|
68621
69559
|
//#endregion
|
|
68622
69560
|
//#region src/local/ecs-task-resolver.ts
|
|
68623
69561
|
function detectEcsImageResolutionNeeds(stack) {
|
|
@@ -77320,7 +78258,7 @@ function createMigrateCommand() {
|
|
|
77320
78258
|
*/
|
|
77321
78259
|
function buildProgram() {
|
|
77322
78260
|
const program = new Command();
|
|
77323
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.
|
|
78261
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.283.11");
|
|
77324
78262
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
77325
78263
|
const { profile } = actionCommand.optsWithGlobals();
|
|
77326
78264
|
if (profile !== void 0) process.env["AWS_PROFILE"] = profile;
|