@go-to-k/cdkd 0.290.26 → 0.290.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{asg-provider-7KM2pV3Q.js → asg-provider-DiZsq-4k.js} +3 -3
- package/dist/{asg-provider-7KM2pV3Q.js.map → asg-provider-DiZsq-4k.js.map} +1 -1
- package/dist/cli.js +2 -2
- package/dist/{deploy-engine-CbIc5Y73.js → deploy-engine-CcFbq2q8.js} +62 -11
- package/dist/deploy-engine-CcFbq2q8.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/{logger-CkHVgzLx.js → logger-6LP1UiFA.js} +18 -2
- package/dist/logger-6LP1UiFA.js.map +1 -0
- package/dist/{program-BgDlhE-S.js → program-C1iVGcxJ.js} +722 -126
- package/dist/{program-BgDlhE-S.js.map → program-C1iVGcxJ.js.map} +1 -1
- package/dist/{version-yD49aCCv.js → version-B2-UuEte.js} +2 -2
- package/dist/{version-yD49aCCv.js.map → version-B2-UuEte.js.map} +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine-CbIc5Y73.js.map +0 -1
- package/dist/logger-CkHVgzLx.js.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { $ as secretBearing, $n as recordMaskOnlyValue, $r as
|
|
2
|
-
import { C as
|
|
1
|
+
import { $ as secretBearing, $n as recordMaskOnlyValue, $r as AssetManifestLoader, $t as s3BucketArn, A as extractDeploymentEventError, Ai as getAwsClients, An as refuseMalformedState, Ar as BOOTSTRAP_MARKER_PREFIX, At as isImpossibleEmptyStoredAttribute, B as toCfnInteger, Bi as IntrinsicResolutionRefusalError, Bn as STATE_SOURCED_BASELINE_RULES, Br as buildDockerImage, Bt as coerceCfnBoolean, C as buildFinalSnapshotIdentifier, Ci as canonicalizeRegion, Cn as producerRecordKey, Cr as stringifyValue, Ct as isTerminationProtectionPropagationError, D as refusesFinalSnapshot, Dn as refuseMalformedOutputsForDestroy, Dr as loadPublishableAssetManifest, Dt as cfnRefValueFromPhysicalId, E as isFinalSnapshotError, Ei as processStackMessages, En as refuseMalformedOutputs, Er as createAssetRedirectResolver, Et as carriesFetchableDynamicReference, F as isStatefulRecreateTargetSync, Fn as findSilentDropProperties, Fr as parseBootstrapMarker, Ft as isSettledInstanceState, G as gray, Gi as NestedStackChildDirectDestroyError, Gn as createSecretMasker, Gr as formatDockerLoginError, Gt as readConfigString, H as formatResourceLine, Hi as LocalStartServiceError, Hn as STATE_SOURCED_READBACK_RULES, Hr as describeDockerExecFailure, Ht as configBooleanRefusal, I as renderStatefulReason, Ii as CrossAccountSecretRefusalError, In as describeTypeWithThrottleRetry, Ir as readBootstrapMarkerBody, It as WAFv2WebACLProvider, J as yellow, Ji as ResourceTimeoutError, Jn as identityKeyFor, Jr as redactDockerArgvValues, Jt as requireConfigObject, K as green, Ki as PartialFailureError, Kn as dynamicReferenceTokens, Kr as getDockerCmd, Kt as replayWarn, L as WARM_THROUGHPUT_MEMBERS, Ln as withRetry, Lr as validateAssetBucketName, Lt as normalizeAwsTagsToCfn, M as planOrphanAdoption, Mi as setAwsClients, Mn as repairMalformedResourcePropertiesForReadOnly, Mr as ensureAssetStorage, Mt as isUnboundTemplateParameter, N as MULTI_REGION_RECREATE_BLOCKED_TYPES, Nn as repairMalformedResourcesForReadOnly, Nr as getBootstrapMarkerKey, Nt as parameterTypeMayLoseSecretIdentity, O as unsupportedFinalSnapshotError, Oi as resolveBucketRegion, On as refuseMalformedResourcePropertiesForOrphan, Or as rewriteTemplateAssetReferences, Ot as coerceParameterTypedValue, P as isStatefulRecreateTargetForReplace, Pi as CdkdError, Pn as findActionableSilentDrops, Pr as isCrossRegionRedirect, Pt as refStateLookupFromResource, Q as isExportAliasCollision, Qn as maskSecretsInText, Qr as stripControlChars, Qt as withSharedDrainBudget, R as coerceWarmThroughput$1, Ri as DeployCancelledError, Rn as DagBuilder, Rr as validateContainerRepoName, Rt as resolveExplicitPhysicalId, S as PRE_DELETE_SNAPSHOT_TYPES, Si as PARTITION_TABLE, Sn as malformedStateRefusalMessage, Sr as AssetPublisher, St as disableInstanceApiTermination, T as createPreDeleteFinalSnapshot, Ti as AssemblyReader, Tn as refuseMalformedNestedChildOutputs, Tr as buildAssetRedirectMap, Tt as carriesDynamicReference, U as bold, Un as TEMPLATE_SOURCED_RULES, Ur as describeDockerFailure, Ut as configIntegerRefusal, V as toFiniteNumber, Vi as LocalInvokeBuildError$1, Vn as STATE_SOURCED_CROSS_GENERATION_RULES, Vr as describeDockerCapturedOutput, Vt as coerceCfnInteger, W as cyan, Wi as MalformedProducerRecordRefusalError, Wn as carriesSecretMask, Wr as dockerSpawnEnvWithSensitive, Wt as configStringRefusal, X as collectPublishedOutputNames, Xi as StackHasActiveImportsError, Xn as markSameGenerationBag, Xr as runDockerStreaming, Xt as classifyReplaySecretRegion, Y as collectDeclaredOutputNames, Yi as ResourceUpdateNotSupportedError, Yn as isSingleDynamicReferenceToken, Yr as runDockerForeground, Yt as requireConfigString, Z as exportAliasCollisionScrubWarning, Zi as StackTerminationProtectionError, Zn as maskSecretsInError, Zr as escapeRegExp, Zt as producerRegionsFromState, _ as isSecretBearingReferenceString, _i as findLargeInlineResources, _n as malformedOutputsRefusalMessage, _r as importableOutputs, _t as isWaitAbandonedError, a as DeploymentEventsStore, aa as isRetryableTransientError, ai as resolveApp, an as AWS_CREDENTIAL_ENV_KEYS, ar as rebuildClientForBucketRegion, at as clearOnUpdateRemoval, b as bindingSkippedOutputs, bi as decodeListingKey, bn as malformedResourcePropertiesWarning, br as orphansCarriedFrom, bt as deleteIndeterminateGuards, c as replayFailedOperations, ca as markNonRetryable, ci as resolveSkipPrefix, cn as envCredentialsAreAssumedRole, cr as buildLockContentionMessage, ct as createMaskedRetryLogger, d as updatePartialReason, da as describeAwsFailure, di as resolveUseCdkBootstrapAssets, dn as STATE_RESOURCES_MALFORMED, dr as shellQuote, dt as beginCommandInterruptScope, ei as getDockerImageBySourceHash, en as s3BucketDomainName, er as recoverMaskedOutput, et as secretBearingStateKeyWarning, f as withResourceDeadline, fa as safeStringify, fi as stateBucketExistenceConfirmed, fn as hasReadableOutputs, fr as CUSTOM_RESOURCE_RESPONSE_PREFIX, ft as endCommandInterruptScope, g as bagHoldsSecretExpression, gi as MIGRATE_TMP_PREFIX, gn as malformedLocalOutputsWarning, gr as importableOutputKeys, gt as CloudControlProvider, h as computeImplicitDeleteEdges, hi as CFN_TEMPLATE_URL_LIMIT, hn as malformedExportSourceWarning, hr as hasReadableExportSet, ht as startInterruptWatch, i as DeploymentEventsReader, ia as isMarkedNonRetryable, ii as getLegacyStateBucketName, in as applyRoleArnIfSet, ir as S3StateBackend, it as collectInlinePolicyNamesManagedBySiblings, j as makeSiblingClaimReader, ji as resetAwsClients, jn as repairMalformedOutputsForReadOnly, jr as assertAssetBucketRegion, jt as isNamelessDynamicReferenceError, k as makeCanonicalizePropertiesFn, ki as AwsClients, kn as refuseMalformedResourcesForDestroy, kr as AssetModeResolver, kt as getAccountInfo, l as replayRollback, la as markRedactedCause, li as resolveStateBucketWithDefault, ln as DiffCalculator, lr as forceQuitRecoveryClause, lt as maskDeep, m as IMPLICIT_DELETE_DEPENDENCIES, mi as CFN_TEMPLATE_BODY_LIMIT, mn as malformedExportNamesWarning, mr as exportNamesCarriedFrom, mt as isInterruptedWaitError, n as DEFAULT_RESOURCE_WARN_AFTER_MS, na as normalizeAwsError, ni as synthesisStatusMessage, nn as s3BucketRegionalDomainName, nr as scrubResourceRecord, nt as getCurrentResourceSecrets, o as planFailedOps, oa as isThrottlingError, oi as resolveAutoAssetStorage, on as applyCallerIdentityCredentials, or as UNREPRODUCIBLE_LOCK_CLAUSE, ot as ProviderRegistry, p as maskingRetryLogger, pi as warnDeprecatedNoPrefixCliFlag, pn as hasReadableResources, pr as DEFAULT_STATE_PREFIX, pt as interruptWatchListenerCount, q as red, qi as ProvisioningError, qn as errorCauseChain, qr as partitionSensitiveEnv, qt as requireConfigArray, r as DeployEngine, ra as withErrorHandling, ri as getDefaultStateBucketName, rn as s3BucketWebsiteUrl, rr as LockManager, rt as IAMRoleProvider, s as planRollback, sa as isTransientServerError, si as resolveCaptureObservedState, sn as callerEnvCredentials, sr as buildForceUnlockCommand, st as wouldReturnToSdkProvider, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as Synthesizer, tn as s3BucketDualStackDomainName, tr as redactSecretsForState, tt as secretSafeKeyDisplay, u as updatePartialMessage, ua as retryClassificationText, ui as resolveStateBucketWithDefaultAndSource, un as INTRINSIC_KEYS, ur as formatLockExpiry, ut as maskerOrIdentity, v as keptWholeReasonText, vi as uploadCfnTemplate, vn as malformedOutputsWarning, vr as isReadableBag, vt as slowCcOperationTimeoutMs, w as ccRoutedFinalSnapshotError, wi as derivePartitionAndUrlSuffix, wn as refuseDivergentRecordRegionForDestroy, wr as WorkGraph, wt as IntrinsicFunctionResolver, x as ATOMIC_FINAL_SNAPSHOT_TYPES, xi as expectedOwnerParam, xn as malformedResourcesWarning, xr as shouldRetainResource, xt as deleteSkipReason, y as mergeNoChangeOutputs, yi as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, yn as malformedRenderedContainersWarning, yr as orphansAfterRollback, yt as UNSPECIFIED_SKIP_REASON, z as isWarmThroughputDecrease, zi as DynamicReferenceRegionAmbiguousError, zn as TemplateParser, zr as buildDenyExternalAccessPolicy, zt as assertRegionMatch } from "./deploy-engine-CcFbq2q8.js";
|
|
2
|
+
import { C as displayIdent, S as UNRENDERABLE, T as truncateCodePoints, _ as withSkipPrefix, b as SECRET_REF_MAX_CODE_POINTS, c as PATTERN_B_NAME_OPTIONS, f as explicitNamePropertyFor, h as getCurrentStackName, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as generateResourceNameWithFallback, n as getLogger, o as runStackBuffered, p as generateResourceName, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES, v as withStackName, w as displaySafe, x as STACK_REF_MAX_CODE_POINTS } from "./logger-6LP1UiFA.js";
|
|
3
3
|
import { n as awsClientDefaults } from "./aws-client-defaults-BoIxyCir.js";
|
|
4
|
-
import { t as getCdkdVersion } from "./version-
|
|
5
|
-
import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-
|
|
4
|
+
import { t as getCdkdVersion } from "./version-B2-UuEte.js";
|
|
5
|
+
import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-DiZsq-4k.js";
|
|
6
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
7
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
8
|
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, NotFound, 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";
|
|
@@ -17849,6 +17849,257 @@ const PROVISIONED_CAPACITY_MEMBERS = ["ReadCapacityUnits", "WriteCapacityUnits"]
|
|
|
17849
17849
|
*/
|
|
17850
17850
|
const ON_DEMAND_CEILING_MEMBERS = ["MaxReadRequestUnits", "MaxWriteRequestUnits"];
|
|
17851
17851
|
/**
|
|
17852
|
+
* Narrow ONE `OnDemandThroughput` block to exactly what cdkd puts on the wire,
|
|
17853
|
+
* and report the members that were DROPPED (issue
|
|
17854
|
+
* [#3287](https://github.com/go-to-k/cdkd/issues/3287)).
|
|
17855
|
+
*
|
|
17856
|
+
* The ONE spelling of that transform, and that is the whole reason it is a
|
|
17857
|
+
* module-level PURE function rather than a method: two callers need the same
|
|
17858
|
+
* answer and one of them may not log.
|
|
17859
|
+
*
|
|
17860
|
+
* - {@link DynamoDBTableProvider.coerceOnDemandCeilingsForSend} — the WIRE, at
|
|
17861
|
+
* all six send sites, which announces each `dropped` member;
|
|
17862
|
+
* - {@link DynamoDBTableProvider.indexCeilingForSend}'s per-index change
|
|
17863
|
+
* detector, which compares the desired and recorded blocks and must not warn
|
|
17864
|
+
* (the warning is the send's, one line further down, and a detector that
|
|
17865
|
+
* announced would fire on a comparison that emits no op at all).
|
|
17866
|
+
*
|
|
17867
|
+
* Behaviour is BYTE-identical to what `coerceOnDemandCeilingsForSend` did
|
|
17868
|
+
* inline before issue [#3287](https://github.com/go-to-k/cdkd/issues/3287),
|
|
17869
|
+
* including the two identity properties its callers
|
|
17870
|
+
* depend on: a block that is not a plain object comes back AS ITSELF (AWS names
|
|
17871
|
+
* the shape), and a block needing no rewrite comes back by IDENTITY, so
|
|
17872
|
+
* {@link DynamoDBTableProvider.coerceIndexThroughputForCreate} can still tell
|
|
17873
|
+
* "unchanged" from "rewritten" without a structural compare. A member name the
|
|
17874
|
+
* grammar does not know is preserved untouched, the same fail-open direction.
|
|
17875
|
+
*
|
|
17876
|
+
* `dropped` carries the RAW declared value beside each member name because the
|
|
17877
|
+
* warning interpolates it, and only the masking caller may do that.
|
|
17878
|
+
*/
|
|
17879
|
+
function narrowOnDemandCeilings(declared) {
|
|
17880
|
+
if (!isPlainCapacityBlock(declared)) return {
|
|
17881
|
+
block: declared,
|
|
17882
|
+
dropped: []
|
|
17883
|
+
};
|
|
17884
|
+
const block = declared;
|
|
17885
|
+
let rewritten;
|
|
17886
|
+
const dropped = [];
|
|
17887
|
+
for (const member of ON_DEMAND_CEILING_MEMBERS) {
|
|
17888
|
+
if (!(member in block)) continue;
|
|
17889
|
+
const coerced = coerceCfnInteger(block[member]);
|
|
17890
|
+
if (coerced === block[member] && coerced !== void 0) continue;
|
|
17891
|
+
rewritten ??= { ...block };
|
|
17892
|
+
if (coerced === void 0) {
|
|
17893
|
+
dropped.push({
|
|
17894
|
+
member,
|
|
17895
|
+
raw: block[member]
|
|
17896
|
+
});
|
|
17897
|
+
delete rewritten[member];
|
|
17898
|
+
} else rewritten[member] = coerced;
|
|
17899
|
+
}
|
|
17900
|
+
return {
|
|
17901
|
+
block: rewritten ?? block,
|
|
17902
|
+
dropped
|
|
17903
|
+
};
|
|
17904
|
+
}
|
|
17905
|
+
/**
|
|
17906
|
+
* Does the ceiling AWS currently holds for a GSI already cover every member
|
|
17907
|
+
* cdkd is about to send? (issue
|
|
17908
|
+
* [#3287](https://github.com/go-to-k/cdkd/issues/3287))
|
|
17909
|
+
*
|
|
17910
|
+
* The `OnDemandThroughput` twin of {@link liveCapacityAlreadyMatches}, and it
|
|
17911
|
+
* exists for the same #1630 reason: cdkd writes state only after `update()`
|
|
17912
|
+
* RETURNS, so a ceiling op that already LANDED before a later step threw is
|
|
17913
|
+
* unrecorded and the next deploy re-emits it. Consulting what AWS holds is what
|
|
17914
|
+
* lets the deploy converge instead.
|
|
17915
|
+
*
|
|
17916
|
+
* Compared PER MEMBER over the members being SENT, deliberately, and it differs
|
|
17917
|
+
* from the capacity sibling in exactly that: omitting a ceiling member KEEPS
|
|
17918
|
+
* whatever maximum the table already carries (only an explicit `-1` removes
|
|
17919
|
+
* one), so a member cdkd is not sending says nothing about whether the call is
|
|
17920
|
+
* redundant. A live member the request does not carry is therefore ignored.
|
|
17921
|
+
*
|
|
17922
|
+
* Anything unreadable on EITHER side fails OPEN (the op is still emitted): an
|
|
17923
|
+
* absent live block — which is also what `DescribeTable` reports for a
|
|
17924
|
+
* PROVISIONED table, where the numbers do not exist — an absent live member, a
|
|
17925
|
+
* value that is not a number. That is the correct direction: the worst case is
|
|
17926
|
+
* the pre-fix behaviour of emitting the op, whereas a false MATCH would
|
|
17927
|
+
* silently drop a ceiling change the user asked for.
|
|
17928
|
+
*
|
|
17929
|
+
* **What AWS does with a repeated identical ceiling is UNMEASURED**, and this
|
|
17930
|
+
* guard is written so that it does not matter: it withholds only a call that
|
|
17931
|
+
* would change nothing. If DynamoDB rejects the repeat the way it rejects a
|
|
17932
|
+
* repeated provisioned capacity ("The provisioned throughput for the index X
|
|
17933
|
+
* will not change"), the guard is what keeps the deploy from wedging; if it
|
|
17934
|
+
* accepts it, the guard saves a call and a full index-ACTIVE wait. Measuring it
|
|
17935
|
+
* would sharpen the doc, not the code.
|
|
17936
|
+
*/
|
|
17937
|
+
function liveCeilingAlreadyMatches(live, requested) {
|
|
17938
|
+
if (live === void 0) return false;
|
|
17939
|
+
const liveBag = live;
|
|
17940
|
+
let compared = 0;
|
|
17941
|
+
for (const member of ON_DEMAND_CEILING_MEMBERS) {
|
|
17942
|
+
if (!(member in requested)) continue;
|
|
17943
|
+
const want = toFiniteNumber(requested[member]);
|
|
17944
|
+
const have = toFiniteNumber(liveBag[member]);
|
|
17945
|
+
if (want === void 0 || have === void 0 || want !== have) return false;
|
|
17946
|
+
compared += 1;
|
|
17947
|
+
}
|
|
17948
|
+
return compared > 0;
|
|
17949
|
+
}
|
|
17950
|
+
/**
|
|
17951
|
+
* The sentinel DynamoDB accepts to CLEAR an on-demand request-unit maximum
|
|
17952
|
+
* (issue [#3373](https://github.com/go-to-k/cdkd/issues/3373)).
|
|
17953
|
+
*
|
|
17954
|
+
* LIVE-VERIFIED at BOTH positions this file sends from, which is what makes it
|
|
17955
|
+
* implementable at both at once rather than at one and not the other:
|
|
17956
|
+
*
|
|
17957
|
+
* - `UpdateTable.OnDemandThroughput` — AWS's own SDK model doc for the block,
|
|
17958
|
+
* and the measurement recorded on issue
|
|
17959
|
+
* [#1434](https://github.com/go-to-k/cdkd/issues/1434) (us-east-1 +
|
|
17960
|
+
* us-west-2, 2026-08-09), which probed the SINGLE-member shape this file
|
|
17961
|
+
* also sends: `{MaxWriteRequestUnits: -1}` against a live `{100, 100}` was
|
|
17962
|
+
* accepted and read back as `{MaxReadRequestUnits: 100}`;
|
|
17963
|
+
* - `UpdateTable.GlobalSecondaryIndexUpdates[].Update.OnDemandThroughput` —
|
|
17964
|
+
* the position issue #3373's body called UNMEASURED. It is not, and the
|
|
17965
|
+
* evidence is in TWO places, named separately because a reviewer checked
|
|
17966
|
+
* and one of them is not where an earlier draft of this said it was:
|
|
17967
|
+
* issue [#1423](https://github.com/go-to-k/cdkd/issues/1423)'s comment
|
|
17968
|
+
* thread carries the `{-1, -1}` transcript (us-east-1, 2026-08-09,
|
|
17969
|
+
* throwaway table) — ACCEPTED, with `DescribeTable` then reporting the
|
|
17970
|
+
* block ABSENT — and PR
|
|
17971
|
+
* [#1433](https://github.com/go-to-k/cdkd/pull/1433)'s body carries the
|
|
17972
|
+
* MIXED `keep 50 / reset -1` probe at the same position, re-run precisely
|
|
17973
|
+
* because only `{-1, -1}` had been verified. `dynamodb-globaltable-provider.ts`
|
|
17974
|
+
* has shipped on both since. #3373's dup-check dismissed those issues
|
|
17975
|
+
* because `AWS::DynamoDB::GlobalTable` spells its per-GSI ceilings
|
|
17976
|
+
* `Write` / `ReadOnDemandThroughputSettings` — true of the TEMPLATE and
|
|
17977
|
+
* irrelevant to the WIRE, where both providers send the same
|
|
17978
|
+
* `UpdateGlobalSecondaryIndexAction.OnDemandThroughput`.
|
|
17979
|
+
*
|
|
17980
|
+
* The read-back is ABSENCE, never `-1`, at both positions — so nothing on the
|
|
17981
|
+
* drift / `readCurrentState` side may expect this value.
|
|
17982
|
+
*/
|
|
17983
|
+
const ON_DEMAND_LIMIT_RESET$1 = -1;
|
|
17984
|
+
/**
|
|
17985
|
+
* Which `OnDemandThroughput` members this update must explicitly REMOVE, as a
|
|
17986
|
+
* list of member names (issue #3373).
|
|
17987
|
+
*
|
|
17988
|
+
* An absent member KEEPS whatever maximum the table already carries, so a
|
|
17989
|
+
* template edit that drops a ceiling used to deploy GREEN, be recorded as
|
|
17990
|
+
* applied, and leave the live maximum in force forever — the #1160
|
|
17991
|
+
* absent-field-reset class, one level down inside a nested block.
|
|
17992
|
+
*
|
|
17993
|
+
* PER MEMBER, never per BLOCK: read and write maxima are independent template
|
|
17994
|
+
* values, so a whole-block branch would drop the single-member removal, which
|
|
17995
|
+
* is the likelier user edit (the `AWS::DynamoDB::GlobalTable` twin paid for
|
|
17996
|
+
* that once already).
|
|
17997
|
+
*
|
|
17998
|
+
* FOUR conditions, all necessary, each closing a different way this goes
|
|
17999
|
+
* wrong:
|
|
18000
|
+
*
|
|
18001
|
+
* 1. **the PREVIOUS side declared it AND it survived narrowing.**
|
|
18002
|
+
* `previousProperties` is a cdkd STATE record and the record is RAW — it
|
|
18003
|
+
* holds the spelling the TEMPLATE declared, including a member
|
|
18004
|
+
* {@link narrowOnDemandCeilings} REFUSED and cdkd therefore never sent. A
|
|
18005
|
+
* raw presence test would read such a member as "previously applied".
|
|
18006
|
+
* 2. **the DESIRED side does not declare it AT ALL, raw.** This is issue
|
|
18007
|
+
* [#1440](https://github.com/go-to-k/cdkd/issues/1440) exactly: a member
|
|
18008
|
+
* the grammar rejected is the template TRYING to SET a ceiling, and taking
|
|
18009
|
+
* the reset branch for it would silently CLEAR the ceiling being set. The
|
|
18010
|
+
* narrowed desired side cannot tell those apart; the raw one can.
|
|
18011
|
+
* 3. **AWS actually HOLDS the member right now.** It proves there is a maximum
|
|
18012
|
+
* to remove, and it makes the whole rule fail CLOSED when cdkd holds no
|
|
18013
|
+
* live snapshot — the correct direction for a send that DESTROYS a live
|
|
18014
|
+
* value.
|
|
18015
|
+
* 4. **this update leaves the table on PAY_PER_REQUEST** (`ceilingsSendable`).
|
|
18016
|
+
* Condition 3 does NOT subsume this, and believing it did was a BLOCKER on
|
|
18017
|
+
* PR go-to-k/cdkd#3401 (spec review, M0). The live snapshot is the ONE
|
|
18018
|
+
* `DescribeTable` at the top of `update()` and is never refreshed, while
|
|
18019
|
+
* both removal arms run AFTER the BillingMode flip — so on a template that
|
|
18020
|
+
* flips a live on-demand table to PROVISIONED **and** drops the ceiling,
|
|
18021
|
+
* the pre-flight refusals both decline (neither side DECLARES a ceiling),
|
|
18022
|
+
* the flip lands, and the stale snapshot then says the member is live: cdkd
|
|
18023
|
+
* would send `-1` to a now-PROVISIONED table, AWS would reject it, and a
|
|
18024
|
+
* previously-green no-op becomes a HALF-APPLIED deploy — the exact class
|
|
18025
|
+
* the #3392 pre-flight exists to prevent. `dynamodb-globaltable-provider.ts`
|
|
18026
|
+
* carries the same guard at both of its positions
|
|
18027
|
+
* (`onDemandCeilingLive` / `!billingFlipped`), named in PR
|
|
18028
|
+
* go-to-k/cdkd#1433's body as "skipped on a billing flip".
|
|
18029
|
+
*
|
|
18030
|
+
* What the skip COSTS was asked and MEASURED, not assumed (the
|
|
18031
|
+
* go-to-k/cdkd#3401 code review raised it; issue
|
|
18032
|
+
* [#1441](https://github.com/go-to-k/cdkd/issues/1441) had already
|
|
18033
|
+
* settled it). The worry is a round trip: deploy A sets a ceiling on a
|
|
18034
|
+
* PAY_PER_REQUEST table, deploy B flips to PROVISIONED and drops it (the
|
|
18035
|
+
* reset suppressed here), deploy C flips back declaring nothing — and
|
|
18036
|
+
* since deploy B's template is now the previous side, condition 1 can
|
|
18037
|
+
* never fire again, so a RETAINED ceiling would be unclearable. #1441's
|
|
18038
|
+
* probe (us-east-1, 2026-08-10, transcript on the issue) ran exactly that
|
|
18039
|
+
* sequence at the TABLE level: `DescribeTable` reports the block ABSENT
|
|
18040
|
+
* after the flip, and it does NOT come back on the flip home. Nothing is
|
|
18041
|
+
* stranded. The PER-INDEX round trip is not separately measured — issue
|
|
18042
|
+
* [#3403](https://github.com/go-to-k/cdkd/issues/3403) — but it fails in
|
|
18043
|
+
* the SAFE direction (a lingering maximum, never a destroyed one).
|
|
18044
|
+
*
|
|
18045
|
+
* Every member-presence test here is `Object.hasOwn`, not `in` (the
|
|
18046
|
+
* go-to-k/cdkd#3401 security review). `in` walks the prototype chain, and these
|
|
18047
|
+
* three tests gate a DESTRUCTIVE send off a cdkd STATE record — the own-key
|
|
18048
|
+
* convention `drift.ts`'s `hasOwnKey` / `ownValue` already follow for
|
|
18049
|
+
* state-derived bags (issue go-to-k/cdkd#2899). Not demonstrably reachable
|
|
18050
|
+
* today; the point is that the guard on a destroying path should not depend on
|
|
18051
|
+
* that staying true.
|
|
18052
|
+
*
|
|
18053
|
+
* `declared` present but NOT a plain block (a string, an array, an unresolved
|
|
18054
|
+
* intrinsic) yields NO removals: that value is forwarded VERBATIM for AWS to
|
|
18055
|
+
* reject by shape, and merging a sentinel into it is not expressible.
|
|
18056
|
+
*
|
|
18057
|
+
* And a declared block cdkd cannot READ AT ALL yields none either — a plain
|
|
18058
|
+
* object with no member surviving {@link narrowOnDemandCeilings}, i.e. only
|
|
18059
|
+
* rejected spellings or only names the grammar does not know (`{Ref: 'Unset'}`,
|
|
18060
|
+
* a mis-nested value). That is condition 2 one level up: such a block is not a
|
|
18061
|
+
* statement that the sibling member was REMOVED, it is a template cdkd could
|
|
18062
|
+
* not read, and clearing a live maximum on the strength of it is the same
|
|
18063
|
+
* destruction #1440 describes. An ABSENT desired side is the opposite and is
|
|
18064
|
+
* the headline case — nothing declared means nothing kept.
|
|
18065
|
+
*/
|
|
18066
|
+
function onDemandCeilingRemovals(declared, previous, live, ceilingsSendable) {
|
|
18067
|
+
if (!ceilingsSendable) return [];
|
|
18068
|
+
if (live === void 0) return [];
|
|
18069
|
+
if (declared !== void 0 && !isPlainCapacityBlock(declared)) return [];
|
|
18070
|
+
const previousBlock = narrowOnDemandCeilings(previous).block;
|
|
18071
|
+
if (!isPlainCapacityBlock(previousBlock)) return [];
|
|
18072
|
+
const declaredBlock = isPlainCapacityBlock(declared) ? declared : void 0;
|
|
18073
|
+
if (declaredBlock !== void 0) {
|
|
18074
|
+
const declaredSurvivors = narrowOnDemandCeilings(declaredBlock).block;
|
|
18075
|
+
if (!isPlainCapacityBlock(declaredSurvivors) || !ON_DEMAND_CEILING_MEMBERS.some((member) => Object.hasOwn(declaredSurvivors, member))) return [];
|
|
18076
|
+
}
|
|
18077
|
+
const liveBag = live;
|
|
18078
|
+
const removals = [];
|
|
18079
|
+
for (const member of ON_DEMAND_CEILING_MEMBERS) {
|
|
18080
|
+
if (!Object.hasOwn(previousBlock, member)) continue;
|
|
18081
|
+
if (declaredBlock !== void 0 && Object.hasOwn(declaredBlock, member)) continue;
|
|
18082
|
+
if (toFiniteNumber(liveBag[member]) === void 0) continue;
|
|
18083
|
+
removals.push(member);
|
|
18084
|
+
}
|
|
18085
|
+
return removals;
|
|
18086
|
+
}
|
|
18087
|
+
/**
|
|
18088
|
+
* Merge the {@link onDemandCeilingRemovals} sentinels into a block bound for
|
|
18089
|
+
* the wire (issue #3373).
|
|
18090
|
+
*
|
|
18091
|
+
* A COPY, never a mutation of `block`: {@link narrowOnDemandCeilings} returns
|
|
18092
|
+
* its input BY IDENTITY when nothing needed rewriting, so mutating here would
|
|
18093
|
+
* write `-1` into the caller's own `properties` bag — which the engine then
|
|
18094
|
+
* records as state.
|
|
18095
|
+
*/
|
|
18096
|
+
function withOnDemandCeilingRemovals(block, removals) {
|
|
18097
|
+
if (removals.length === 0) return block;
|
|
18098
|
+
const merged = { ...isPlainCapacityBlock(block) ? block : {} };
|
|
18099
|
+
for (const member of removals) merged[member] = ON_DEMAND_LIMIT_RESET$1;
|
|
18100
|
+
return merged;
|
|
18101
|
+
}
|
|
18102
|
+
/**
|
|
17852
18103
|
* The capacity both TABLE-level forwarders substitute for a member the
|
|
17853
18104
|
* template does not declare at all — `create()`'s `CreateTable` and the
|
|
17854
18105
|
* BillingMode flip's `UpdateTable`. It is the pre-issue-#3147 `?? 5`, kept
|
|
@@ -18313,15 +18564,27 @@ function reverseMapSecondaryIndex(live, desired, bagInformative) {
|
|
|
18313
18564
|
* [#3255](https://github.com/go-to-k/cdkd/issues/3255), across ALL FOUR
|
|
18314
18565
|
* per-index send sites (`create()`'s mapper plus `applyGsiUpdates`'
|
|
18315
18566
|
* adopted-repair, `Create` and same-name `Update` actions);
|
|
18316
|
-
* `OnDemandThroughput`'s from
|
|
18317
|
-
* [#3265](https://github.com/go-to-k/cdkd/issues/3265)
|
|
18318
|
-
* (
|
|
18319
|
-
*
|
|
18567
|
+
* `OnDemandThroughput`'s from issues
|
|
18568
|
+
* [#3265](https://github.com/go-to-k/cdkd/issues/3265) and
|
|
18569
|
+
* [#3287](https://github.com/go-to-k/cdkd/issues/3287), across its own SIX
|
|
18570
|
+
* (the two TABLE-level forwards, `create()`'s mapper, and all three
|
|
18571
|
+
* `applyGsiUpdates` actions) — ever SUPPRESSES the block: each
|
|
18320
18572
|
* rewrites members inside a block that is sent either way, so "would cdkd
|
|
18321
18573
|
* send this block" is unchanged by them, even where both members were
|
|
18322
18574
|
* dropped and an empty block goes out. That is the reason
|
|
18323
18575
|
* {@link DynamoDBTableProvider.coerceOnDemandCeilingsForSend} drops a
|
|
18324
|
-
* MEMBER and never the block.
|
|
18576
|
+
* MEMBER and never the block.
|
|
18577
|
+
*
|
|
18578
|
+
* **The two per-index UPDATE arms added by #3287 are the ONE qualification,
|
|
18579
|
+
* and it does not reach this predicate.**
|
|
18580
|
+
* {@link DynamoDBTableProvider.indexCeilingForSend} withholds the MEMBER
|
|
18581
|
+
* from an `UpdateGlobalSecondaryIndexAction` when nothing survived, because
|
|
18582
|
+
* an action carrying only `IndexName` is rejected outright — where the
|
|
18583
|
+
* TABLE-level arm sends the empty block and lets DynamoDB answer. That is a
|
|
18584
|
+
* statement about one OP, not about whether the template declared a block
|
|
18585
|
+
* cdkd forwards: the same bag still reaches `CreateTable` / `UpdateTable`
|
|
18586
|
+
* wherever those arms run, so the predicate's answer is unchanged.
|
|
18587
|
+
* Widening
|
|
18325
18588
|
* `isSendableWarmThroughput` over them would be wrong twice over — it reads
|
|
18326
18589
|
* members those blocks do not have (`ReadUnitsPerSecond` vs
|
|
18327
18590
|
* `ReadCapacityUnits`), so every declared capacity would read as undeclared.
|
|
@@ -18601,7 +18864,17 @@ var DynamoDBTableProvider = class {
|
|
|
18601
18864
|
const billingOrThroughputChanged = billingMode !== prevBillingMode || JSON.stringify(properties["ProvisionedThroughput"]) !== JSON.stringify(previousProperties["ProvisionedThroughput"]);
|
|
18602
18865
|
if (billingOrThroughputChanged && billingMode === "PROVISIONED" && (prevBillingMode === "PAY_PER_REQUEST" || liveBillingMode === "PAY_PER_REQUEST") && properties["OnDemandThroughput"] !== void 0) {
|
|
18603
18866
|
const absentNote = properties["BillingMode"] === void 0 ? " (BillingMode is absent from the template, which CloudFormation resets to PROVISIONED)" : "";
|
|
18604
|
-
throw new ProvisioningError(`AWS::DynamoDB::Table ${logicalId}: the template flips BillingMode to PROVISIONED${absentNote} while still declaring OnDemandThroughput, which AWS accepts only on a PAY_PER_REQUEST table.
|
|
18867
|
+
throw new ProvisioningError(`AWS::DynamoDB::Table ${logicalId}: the template flips BillingMode to PROVISIONED${absentNote} while still declaring OnDemandThroughput, which AWS accepts only on a PAY_PER_REQUEST table. No BillingMode flip and no index change were applied. Remove OnDemandThroughput, or keep BillingMode: PAY_PER_REQUEST.`, resourceType, logicalId);
|
|
18868
|
+
}
|
|
18869
|
+
const effectiveBillingMode = billingOrThroughputChanged ? billingMode : liveBillingMode;
|
|
18870
|
+
if (effectiveBillingMode === "PROVISIONED") {
|
|
18871
|
+
const ceilingIndexNames = (Array.isArray(properties["GlobalSecondaryIndexes"]) ? properties["GlobalSecondaryIndexes"] : []).filter((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry)).filter((entry) => Boolean(entry["OnDemandThroughput"])).map((entry) => typeof entry["IndexName"] === "string" ? maskSecrets(entry["IndexName"]) : "<unnamed>");
|
|
18872
|
+
if (ceilingIndexNames.length > 0) {
|
|
18873
|
+
const modeNote = billingOrThroughputChanged ? "" : " (the table is already PROVISIONED in AWS and this update sends no BillingMode change)";
|
|
18874
|
+
const message = `AWS::DynamoDB::Table ${logicalId}: GlobalSecondaryIndexes ${ceilingIndexNames.join(", ")} declare OnDemandThroughput while the table's billing mode is PROVISIONED${modeNote}, which AWS accepts only on a PAY_PER_REQUEST table. Remove the per-index OnDemandThroughput, or set BillingMode: PAY_PER_REQUEST.`;
|
|
18875
|
+
if (context?.replayingState === true || context?.desiredFromAwsReadback === true) warn(`${message} Downgraded to a warning because this update's desired properties did not come from the template, so there is no template-side remedy; the per-index ceiling will be sent and AWS will reject it.`);
|
|
18876
|
+
else throw new ProvisioningError(`${message} No BillingMode flip and no index change were applied.`, resourceType, logicalId, physicalId);
|
|
18877
|
+
}
|
|
18605
18878
|
}
|
|
18606
18879
|
const liveIndexes = table?.GlobalSecondaryIndexes ?? [];
|
|
18607
18880
|
const desiredIndexByName = /* @__PURE__ */ new Map();
|
|
@@ -18632,8 +18905,8 @@ var DynamoDBTableProvider = class {
|
|
|
18632
18905
|
if (tableCapacityUnusable) reasons.push(`the template declares no usable table-level ProvisionedThroughput`);
|
|
18633
18906
|
warn(`AWS::DynamoDB::Table ${logicalId}: this deploy removes global secondary index(es) ${removable.map((n) => maskSecrets(n)).join(", ")} and flips BillingMode to PROVISIONED. The removal would normally be applied BEFORE the flip, but ${reasons.join(" and ")}, so AWS rejects the flip either way. Nothing was removed.`);
|
|
18634
18907
|
} else if (removable.length > 0) {
|
|
18635
|
-
|
|
18636
|
-
await this.runGsiOps(physicalId, removable.map((name) => ({ Delete: { IndexName: name } })), void 0);
|
|
18908
|
+
debug(`Deleting GSI(s) ${removable.join(", ")} on DynamoDB table ${physicalId} before the BillingMode flip to PROVISIONED`);
|
|
18909
|
+
await this.runGsiOps(physicalId, removable.map((name) => ({ Delete: { IndexName: name } })), void 0, maskSecrets);
|
|
18637
18910
|
for (const name of removable) preFlipDeletedIndexNames.add(name);
|
|
18638
18911
|
}
|
|
18639
18912
|
}
|
|
@@ -18685,10 +18958,13 @@ var DynamoDBTableProvider = class {
|
|
|
18685
18958
|
this.logger.debug(`Updated BillingMode/ProvisionedThroughput/TableClass on DynamoDB table ${physicalId}`);
|
|
18686
18959
|
}
|
|
18687
18960
|
if (JSON.stringify(properties["OnDemandThroughput"]) !== JSON.stringify(previousProperties["OnDemandThroughput"])) {
|
|
18688
|
-
|
|
18961
|
+
const ceilingRemovals = onDemandCeilingRemovals(properties["OnDemandThroughput"], previousProperties["OnDemandThroughput"], table?.OnDemandThroughput, effectiveBillingMode === "PAY_PER_REQUEST");
|
|
18962
|
+
if (properties["OnDemandThroughput"] || ceilingRemovals.length > 0) {
|
|
18963
|
+
const declaredForSend = properties["OnDemandThroughput"] ? this.coerceOnDemandCeilingsForSend(`AWS::DynamoDB::Table ${logicalId}`, properties["OnDemandThroughput"], maskSecrets) : {};
|
|
18964
|
+
if (ceilingRemovals.length > 0) debug(`AWS::DynamoDB::Table ${logicalId}: removing the OnDemandThroughput ${ceilingRemovals.join(" / ")} the template no longer declares (sending ${ON_DEMAND_LIMIT_RESET$1}, DynamoDB's reset sentinel)`);
|
|
18689
18965
|
await this.dynamoDBClient.send(new UpdateTableCommand({
|
|
18690
18966
|
TableName: physicalId,
|
|
18691
|
-
OnDemandThroughput:
|
|
18967
|
+
OnDemandThroughput: withOnDemandCeilingRemovals(declaredForSend, ceilingRemovals)
|
|
18692
18968
|
}));
|
|
18693
18969
|
await this.waitForTableActiveAfterUpdate(physicalId);
|
|
18694
18970
|
this.logger.debug(`Updated OnDemandThroughput on DynamoDB table ${physicalId}`);
|
|
@@ -18757,7 +19033,7 @@ var DynamoDBTableProvider = class {
|
|
|
18757
19033
|
this.logger.debug(`Disabled DynamoDB Stream on table ${physicalId}`);
|
|
18758
19034
|
}
|
|
18759
19035
|
}
|
|
18760
|
-
if (JSON.stringify(properties["GlobalSecondaryIndexes"]) !== JSON.stringify(previousProperties["GlobalSecondaryIndexes"])) await this.applyGsiUpdates(physicalId, resourceType, logicalId, previousProperties["GlobalSecondaryIndexes"], properties["GlobalSecondaryIndexes"], properties["AttributeDefinitions"], gsiHandledByBillingFlip, currentLiveIndexNames, currentLiveIndexByName, liveCapacityComparable, maskSecrets);
|
|
19036
|
+
if (JSON.stringify(properties["GlobalSecondaryIndexes"]) !== JSON.stringify(previousProperties["GlobalSecondaryIndexes"])) await this.applyGsiUpdates(physicalId, resourceType, logicalId, previousProperties["GlobalSecondaryIndexes"], properties["GlobalSecondaryIndexes"], properties["AttributeDefinitions"], gsiHandledByBillingFlip, currentLiveIndexNames, currentLiveIndexByName, liveCapacityComparable, effectiveBillingMode === "PAY_PER_REQUEST", maskSecrets);
|
|
18761
19037
|
if (JSON.stringify(properties["PointInTimeRecoverySpecification"]) !== JSON.stringify(previousProperties["PointInTimeRecoverySpecification"])) await this.applyPointInTimeRecovery(physicalId, properties["PointInTimeRecoverySpecification"], previousProperties["PointInTimeRecoverySpecification"], maskSecrets);
|
|
18762
19038
|
if (JSON.stringify(properties["TimeToLiveSpecification"]) !== JSON.stringify(previousProperties["TimeToLiveSpecification"])) {
|
|
18763
19039
|
this.assertNoActiveTtlAttributeNameChange(logicalId, resourceType, physicalId, properties["TimeToLiveSpecification"], previousProperties["TimeToLiveSpecification"]);
|
|
@@ -18951,7 +19227,7 @@ var DynamoDBTableProvider = class {
|
|
|
18951
19227
|
* change", so a dropped block must be turned into an explicit
|
|
18952
19228
|
* `PointInTimeRecoveryEnabled: false`).
|
|
18953
19229
|
*/
|
|
18954
|
-
async applyPointInTimeRecovery(tableName, spec, previousSpec, maskSecrets
|
|
19230
|
+
async applyPointInTimeRecovery(tableName, spec, previousSpec, maskSecrets) {
|
|
18955
19231
|
let enabled;
|
|
18956
19232
|
let recoveryPeriodInDays;
|
|
18957
19233
|
if (spec !== void 0 && spec !== null) {
|
|
@@ -19126,7 +19402,7 @@ var DynamoDBTableProvider = class {
|
|
|
19126
19402
|
* pre-existing WarmThroughput "no clean remap" stance; precision changes flow
|
|
19127
19403
|
* through on the create / first-enable path.
|
|
19128
19404
|
*/
|
|
19129
|
-
async applyKinesisStreamingDestination(tableName, spec, previousSpec, maskSecrets
|
|
19405
|
+
async applyKinesisStreamingDestination(tableName, spec, previousSpec, maskSecrets) {
|
|
19130
19406
|
const newArn = this.extractKinesisStreamArn(spec);
|
|
19131
19407
|
const prevArn = this.extractKinesisStreamArn(previousSpec);
|
|
19132
19408
|
if (newArn === prevArn) {
|
|
@@ -19316,8 +19592,9 @@ var DynamoDBTableProvider = class {
|
|
|
19316
19592
|
* a remove-then-add pair, so this method throws on such a change rather than
|
|
19317
19593
|
* silently dropping it.
|
|
19318
19594
|
*/
|
|
19319
|
-
async applyGsiUpdates(physicalId, resourceType, logicalId, previousGsis, desiredGsis, desiredAttributeDefinitions, handledByBillingFlip
|
|
19595
|
+
async applyGsiUpdates(physicalId, resourceType, logicalId, previousGsis, desiredGsis, desiredAttributeDefinitions, handledByBillingFlip, liveIndexNames, liveIndexByName, liveCapacityComparable, onDemandCeilingsSendable, maskSecrets) {
|
|
19320
19596
|
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19597
|
+
const debug = (message) => this.logger.debug(maskSecrets(message));
|
|
19321
19598
|
const prev = previousGsis ?? [];
|
|
19322
19599
|
const desired = desiredGsis ?? [];
|
|
19323
19600
|
const prevByName = new Map(prev.filter((g) => g.IndexName).map((g) => [g.IndexName, g]));
|
|
@@ -19326,7 +19603,7 @@ var DynamoDBTableProvider = class {
|
|
|
19326
19603
|
for (const name of prevByName.keys()) {
|
|
19327
19604
|
if (desiredByName.has(name)) continue;
|
|
19328
19605
|
if (liveIndexNames !== void 0 && !liveIndexNames.has(name)) {
|
|
19329
|
-
this.
|
|
19606
|
+
debug(`${this.indexScopeAt(name, physicalId, maskSecrets)} is recorded in cdkd state but not live; skipping its Delete (already removed)`);
|
|
19330
19607
|
continue;
|
|
19331
19608
|
}
|
|
19332
19609
|
ops.push({ Delete: { IndexName: name } });
|
|
@@ -19335,7 +19612,7 @@ var DynamoDBTableProvider = class {
|
|
|
19335
19612
|
const live = liveIndexByName?.get(name);
|
|
19336
19613
|
if (!gsi.KeySchema) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} is missing KeySchema`, resourceType, logicalId, physicalId);
|
|
19337
19614
|
if (live !== void 0 && live.IndexStatus !== "DELETING") {
|
|
19338
|
-
warn(
|
|
19615
|
+
warn(`${this.indexScopeAt(name, physicalId, maskSecrets)} 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.`);
|
|
19339
19616
|
const adopted = { IndexName: name };
|
|
19340
19617
|
let adoptedHasMember = false;
|
|
19341
19618
|
if (liveCapacityComparable && gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && !liveCapacityAlreadyMatches(live.ProvisionedThroughput, gsi.ProvisionedThroughput) && !this.skipZeroCapacityIndexUpdate(name, physicalId, gsi.ProvisionedThroughput, maskSecrets)) {
|
|
@@ -19347,8 +19624,13 @@ var DynamoDBTableProvider = class {
|
|
|
19347
19624
|
adopted.WarmThroughput = adoptedWarm;
|
|
19348
19625
|
adoptedHasMember = true;
|
|
19349
19626
|
}
|
|
19627
|
+
const adoptedCeiling = this.indexCeilingForSend(name, physicalId, gsi, void 0, live, onDemandCeilingsSendable, maskSecrets);
|
|
19628
|
+
if (adoptedCeiling) {
|
|
19629
|
+
adopted.OnDemandThroughput = adoptedCeiling;
|
|
19630
|
+
adoptedHasMember = true;
|
|
19631
|
+
}
|
|
19350
19632
|
if (adoptedHasMember) ops.push({ Update: adopted });
|
|
19351
|
-
warn(
|
|
19633
|
+
warn(`${this.indexScopeAt(name, physicalId, maskSecrets)} 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.`);
|
|
19352
19634
|
continue;
|
|
19353
19635
|
}
|
|
19354
19636
|
this.warnRefusedWarmThroughput(this.indexScopeAt(name, physicalId, maskSecrets), gsi.WarmThroughput, maskSecrets);
|
|
@@ -19367,7 +19649,7 @@ var DynamoDBTableProvider = class {
|
|
|
19367
19649
|
const update = { IndexName: name };
|
|
19368
19650
|
let updateHasMember = false;
|
|
19369
19651
|
if (gsi.ProvisionedThroughput && !handledByBillingFlip.has(name) && JSON.stringify(before.ProvisionedThroughput) !== JSON.stringify(gsi.ProvisionedThroughput)) {
|
|
19370
|
-
if (liveCapacityComparable && liveCapacityAlreadyMatches(liveIndexByName?.get(name)?.ProvisionedThroughput, gsi.ProvisionedThroughput)) this.
|
|
19652
|
+
if (liveCapacityComparable && liveCapacityAlreadyMatches(liveIndexByName?.get(name)?.ProvisionedThroughput, gsi.ProvisionedThroughput)) debug(`${this.indexScopeAt(name, physicalId, maskSecrets)} already carries the requested capacity in AWS; skipping its throughput Update`);
|
|
19371
19653
|
else if (!this.skipZeroCapacityIndexUpdate(name, physicalId, gsi.ProvisionedThroughput, maskSecrets)) {
|
|
19372
19654
|
update.ProvisionedThroughput = this.indexCapacityForSend(this.indexScopeAt(name, physicalId, maskSecrets), gsi.ProvisionedThroughput, maskSecrets);
|
|
19373
19655
|
updateHasMember = true;
|
|
@@ -19378,9 +19660,14 @@ var DynamoDBTableProvider = class {
|
|
|
19378
19660
|
update.WarmThroughput = warm;
|
|
19379
19661
|
updateHasMember = true;
|
|
19380
19662
|
}
|
|
19663
|
+
const ceiling = this.indexCeilingForSend(name, physicalId, gsi, before, liveIndexByName?.get(name), onDemandCeilingsSendable, maskSecrets);
|
|
19664
|
+
if (ceiling) {
|
|
19665
|
+
update.OnDemandThroughput = ceiling;
|
|
19666
|
+
updateHasMember = true;
|
|
19667
|
+
}
|
|
19381
19668
|
if (updateHasMember) ops.push({ Update: update });
|
|
19382
19669
|
}
|
|
19383
|
-
await this.runGsiOps(physicalId, ops, desiredAttributeDefinitions);
|
|
19670
|
+
await this.runGsiOps(physicalId, ops, desiredAttributeDefinitions, maskSecrets);
|
|
19384
19671
|
}
|
|
19385
19672
|
/**
|
|
19386
19673
|
* The NUMERIC `WarmThroughput` spec to put on the wire, announcing any member
|
|
@@ -19393,7 +19680,7 @@ var DynamoDBTableProvider = class {
|
|
|
19393
19680
|
* two messages are disjoint by construction: this one fires only when
|
|
19394
19681
|
* something DID resolve.
|
|
19395
19682
|
*/
|
|
19396
|
-
coerceWarmThroughputForSend(scope, value, maskSecrets
|
|
19683
|
+
coerceWarmThroughputForSend(scope, value, maskSecrets) {
|
|
19397
19684
|
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19398
19685
|
const coerced = coerceWarmThroughput(value);
|
|
19399
19686
|
if (coerced === void 0) return void 0;
|
|
@@ -19467,6 +19754,155 @@ var DynamoDBTableProvider = class {
|
|
|
19467
19754
|
return this.coerceIndexCapacityBlock(scope, declared, maskSecrets) ?? declared;
|
|
19468
19755
|
}
|
|
19469
19756
|
/**
|
|
19757
|
+
* The per-index `OnDemandThroughput` an `UpdateGlobalSecondaryIndexAction`
|
|
19758
|
+
* should carry, or `undefined` when no op is warranted (issue
|
|
19759
|
+
* [#3287](https://github.com/go-to-k/cdkd/issues/3287)).
|
|
19760
|
+
*
|
|
19761
|
+
* The whole of that issue: `UpdateGlobalSecondaryIndexAction` declares the
|
|
19762
|
+
* member, the SDK has always accepted it, and NEITHER update arm ever set it
|
|
19763
|
+
* — while the `Create` action arm has carried it since issue #3265. So one
|
|
19764
|
+
* template applied a per-index ceiling when the index was ADDED and silently
|
|
19765
|
+
* discarded a later edit to it, recording the new value as applied so no
|
|
19766
|
+
* later deploy converged. Routed through
|
|
19767
|
+
* {@link coerceOnDemandCeilingsForSend} like the other five sites, so one
|
|
19768
|
+
* template value cannot be answered differently at two of them.
|
|
19769
|
+
*
|
|
19770
|
+
* FIVE decisions, in the order the op is decided, plus one deliberate NON-decision:
|
|
19771
|
+
*
|
|
19772
|
+
* - **The declaration gate is TRUTHINESS**, matching `indexDeclares`' gate
|
|
19773
|
+
* for this block and the `Create` arm's `gsi.OnDemandThroughput` test. A
|
|
19774
|
+
* tighter gate here would make the #1767 drift-side predicate answer
|
|
19775
|
+
* "declared" for a block this arm skips.
|
|
19776
|
+
* - **The BILLING MODE is deliberately NOT gated here**, and that is a
|
|
19777
|
+
* decision with a filed issue rather than an oversight. DynamoDB accepts
|
|
19778
|
+
* `OnDemandThroughput` only on a PAY_PER_REQUEST table, and the ceiling
|
|
19779
|
+
* rides the SAME action as the capacity — so on a PROVISIONED one the op
|
|
19780
|
+
* is rejected and takes an otherwise-valid capacity edit with it. A gate
|
|
19781
|
+
* WITHHOLDING the member was written for the go-to-k/cdkd#3380 review's M1
|
|
19782
|
+
* and REVERTED on the next round: a mid-update warn-and-skip lets the
|
|
19783
|
+
* deploy succeed, so the engine records the un-sent ceiling as applied and
|
|
19784
|
+
* the deploy that FIXES the billing mode then compares equal and never
|
|
19785
|
+
* sends it — issue #3287's own defect, reintroduced by its own guard. The
|
|
19786
|
+
* file's table-level twin states that reasoning at its own refusal.
|
|
19787
|
+
*
|
|
19788
|
+
* **The refusal it pointed at has SHIPPED** (issue
|
|
19789
|
+
* [#3392](https://github.com/go-to-k/cdkd/issues/3392)): `update()` walks
|
|
19790
|
+
* `GlobalSecondaryIndexes[]` before any call and throws when the EFFECTIVE
|
|
19791
|
+
* billing mode is PROVISIONED, which covers all THREE per-index send sites
|
|
19792
|
+
* at once (this arm, the same-name `Update` arm's caller, and the `Create`
|
|
19793
|
+
* action, which has carried the same defect since #3265) because none of
|
|
19794
|
+
* them is reached. So this arm's job is unchanged and stays deliberately
|
|
19795
|
+
* mode-blind: a gate here would be a SECOND spelling of one rule, and the
|
|
19796
|
+
* mid-update spelling is the one that was measured wrong.
|
|
19797
|
+
*
|
|
19798
|
+
* On the REPLAY path the pre-flight downgrades to a warning and this arm
|
|
19799
|
+
* still runs, so the remaining behaviour is what #3287's round 3 recorded:
|
|
19800
|
+
* the member goes out, AWS rejects the `UpdateTable`, and the co-resident
|
|
19801
|
+
* capacity / warm edits fail with it. Loud and recording nothing beats
|
|
19802
|
+
* silent and lost.
|
|
19803
|
+
* - **The change detector compares the NARROWED blocks WHEN NOTHING WAS
|
|
19804
|
+
* DROPPED, and the RAW ones otherwise** — the one place it differs from its
|
|
19805
|
+
* `ProvisionedThroughput` sibling two arms up. `'200'` and `200` are the
|
|
19806
|
+
* SAME ceiling once coerced, so a purely narrowed detector saves an
|
|
19807
|
+
* `UpdateGlobalSecondaryIndexAction` — and a full index-ACTIVE wait — on a
|
|
19808
|
+
* re-spelling. But narrowing ALONE conceals a case the raw compare reports
|
|
19809
|
+
* (the go-to-k/cdkd#3380 spec review's Drift 2): two DIFFERENT rejected
|
|
19810
|
+
* spellings (`' 25 '` against a recorded `' 30 '`) narrow to the same
|
|
19811
|
+
* block, so the arm would return before {@link
|
|
19812
|
+
* DynamoDBTableProvider.coerceOnDemandCeilingsForSend} ever warns and the
|
|
19813
|
+
* user would stop being told what to fix — exactly what
|
|
19814
|
+
* `.claude/rules/provider-diff-record-folds.md` refuses and what keeps the
|
|
19815
|
+
* TABLE-level detector raw. So the discriminator is
|
|
19816
|
+
* {@link narrowOnDemandCeilings}'s OWN `dropped` report: any drop on either
|
|
19817
|
+
* side falls back to the RAW compare, which keeps the announcement alive
|
|
19818
|
+
* while the narrowed compare still absorbs a lossless re-spelling.
|
|
19819
|
+
* - **A block with no SURVIVING GRAMMAR MEMBER emits NO op, and WARNS.** The TABLE-level
|
|
19820
|
+
* arm sends the empty block and lets DynamoDB answer; here the action can
|
|
19821
|
+
* legitimately carry nothing but `IndexName`, which AWS rejects outright,
|
|
19822
|
+
* so an empty ceiling would take a capacity edit down with it or fire a
|
|
19823
|
+
* doomed call on its own. The test is per MEMBER and not
|
|
19824
|
+
* `Object.keys(...).length === 0` (the go-to-k/cdkd#3380 review's m2):
|
|
19825
|
+
* {@link narrowOnDemandCeilings} preserves an unknown member name by
|
|
19826
|
+
* design, so a typo'd `MaxReadRequestUnit` leaves a non-empty object that
|
|
19827
|
+
* the SDK serializes to `{}` — the same rejected action, reached past a
|
|
19828
|
+
* key-count gate. The fail-open "let AWS name it" reasoning does NOT reach
|
|
19829
|
+
* that shape here, because AWS never sees the name to name. The warning is
|
|
19830
|
+
* this arm's OWN, not {@link warnUnusableOnDemandCeiling}'s (round 2's C3):
|
|
19831
|
+
* an unknown member is never DROPPED by the narrowing — its name is
|
|
19832
|
+
* preserved — so the per-member announcement says nothing about it, and
|
|
19833
|
+
* withholding silently would lose the ceiling where the pre-gate code at
|
|
19834
|
+
* least failed loudly at AWS.
|
|
19835
|
+
* - **A block that is not a plain object is forwarded VERBATIM**, the
|
|
19836
|
+
* fail-open direction every forwarder in this file takes: a STRING, a
|
|
19837
|
+
* NUMBER or an ARRAY is AWS's to reject by name — and unlike the unknown
|
|
19838
|
+
* member above, AWS really does see it and rejects it by shape.
|
|
19839
|
+
* - **The live ceiling is consulted for idempotency**
|
|
19840
|
+
* ({@link liveCeilingAlreadyMatches}), the #1630 arm the capacity sibling
|
|
19841
|
+
* already has, and it needs NO billing-mode gate: `DescribeTable` reports
|
|
19842
|
+
* `OnDemandThroughput` only on a PAY_PER_REQUEST table, so on a
|
|
19843
|
+
* PROVISIONED one the live block is simply absent and the guard fails
|
|
19844
|
+
* OPEN — the correct direction, since the worst case is the op AWS then
|
|
19845
|
+
* rejects by name (issue #3392) rather than a silently dropped change.
|
|
19846
|
+
* There is no `{0, 0}` analogue of the #1571 capacity trap.
|
|
19847
|
+
*
|
|
19848
|
+
* It DOES remove a ceiling, since issue
|
|
19849
|
+
* [#3373](https://github.com/go-to-k/cdkd/issues/3373) — and the removal is
|
|
19850
|
+
* the one thing here that runs AHEAD of the truthiness gate, because the
|
|
19851
|
+
* whole point is a desired side that declares nothing. An absent member KEEPS
|
|
19852
|
+
* whatever maximum the index already carries, so before #3373 a template edit
|
|
19853
|
+
* dropping a ceiling deployed GREEN, was recorded as applied, and left the
|
|
19854
|
+
* live maximum in force forever.
|
|
19855
|
+
*
|
|
19856
|
+
* {@link onDemandCeilingRemovals} owns every condition and
|
|
19857
|
+
* {@link ON_DEMAND_LIMIT_RESET} carries the measurement for THIS action's
|
|
19858
|
+
* position (issue #1423's probe transcript, not an extrapolation from the
|
|
19859
|
+
* table-level field's docs). Two properties of that rule matter most here:
|
|
19860
|
+
* the previous side is read through {@link narrowOnDemandCeilings} so a
|
|
19861
|
+
* member cdkd REFUSED cannot read as "previously applied"; the reset fires
|
|
19862
|
+
* only for a member AWS is OBSERVED to hold, which makes it fail CLOSED with
|
|
19863
|
+
* no live snapshot; and it is suppressed outright when this update FLIPS the
|
|
19864
|
+
* table to PROVISIONED (`onDemandCeilingsSendable`). Do not read the
|
|
19865
|
+
* observed-live condition as covering the flip — it does not, and believing
|
|
19866
|
+
* it did was a merge blocker: the live snapshot is taken before the flip and
|
|
19867
|
+
* never refreshed.
|
|
19868
|
+
*
|
|
19869
|
+
* The TABLE-level arm in `update()` takes the same rule in the same change,
|
|
19870
|
+
* because implementing it at ONE of the two positions would make the same
|
|
19871
|
+
* property behave differently at the table and at an index of the same table.
|
|
19872
|
+
*/
|
|
19873
|
+
indexCeilingForSend(name, physicalId, gsi, before, live, onDemandCeilingsSendable, maskSecrets) {
|
|
19874
|
+
const removals = onDemandCeilingRemovals(gsi.OnDemandThroughput, before?.OnDemandThroughput, live?.OnDemandThroughput, onDemandCeilingsSendable);
|
|
19875
|
+
if (!gsi.OnDemandThroughput) {
|
|
19876
|
+
if (removals.length === 0) return void 0;
|
|
19877
|
+
const removalScope = this.indexScopeAt(name, physicalId, maskSecrets);
|
|
19878
|
+
this.logger.debug(maskSecrets(`${removalScope}: removing the OnDemandThroughput ${removals.join(" / ")} the template no longer declares (sending ${ON_DEMAND_LIMIT_RESET$1}, DynamoDB's reset sentinel)`));
|
|
19879
|
+
return withOnDemandCeilingRemovals({}, removals);
|
|
19880
|
+
}
|
|
19881
|
+
if (before !== void 0) {
|
|
19882
|
+
const desired = narrowOnDemandCeilings(gsi.OnDemandThroughput);
|
|
19883
|
+
const previous = narrowOnDemandCeilings(before.OnDemandThroughput);
|
|
19884
|
+
const [desiredSide, previousSide] = desired.dropped.length > 0 || previous.dropped.length > 0 ? [gsi.OnDemandThroughput, before.OnDemandThroughput] : [desired.block, previous.block];
|
|
19885
|
+
if (JSON.stringify(desiredSide) === JSON.stringify(previousSide)) return void 0;
|
|
19886
|
+
}
|
|
19887
|
+
const scope = this.indexScopeAt(name, physicalId, maskSecrets);
|
|
19888
|
+
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19889
|
+
const debug = (message) => this.logger.debug(maskSecrets(message));
|
|
19890
|
+
const coerced = this.coerceOnDemandCeilingsForSend(scope, gsi.OnDemandThroughput, maskSecrets);
|
|
19891
|
+
if (!isPlainCapacityBlock(coerced)) return coerced;
|
|
19892
|
+
if (removals.length > 0) debug(`${scope}: removing the OnDemandThroughput ${removals.join(" / ")} the template no longer declares (sending ${ON_DEMAND_LIMIT_RESET$1}, DynamoDB's reset sentinel)`);
|
|
19893
|
+
const send = withOnDemandCeilingRemovals(coerced, removals);
|
|
19894
|
+
if (!ON_DEMAND_CEILING_MEMBERS.some((member) => member in send)) {
|
|
19895
|
+
if (narrowOnDemandCeilings(gsi.OnDemandThroughput).dropped.length > 0) debug(`${scope}: every declared OnDemandThroughput member was unusable, so no ceiling op was sent`);
|
|
19896
|
+
else warn(`${scope}: declares an OnDemandThroughput with no member DynamoDB accepts (${ON_DEMAND_CEILING_MEMBERS.join(" / ")}), so no ceiling op was sent. Check the member names.`);
|
|
19897
|
+
return;
|
|
19898
|
+
}
|
|
19899
|
+
if (liveCeilingAlreadyMatches(live?.OnDemandThroughput, send)) {
|
|
19900
|
+
debug(`${scope}: already carries the requested OnDemandThroughput in AWS; skipping its ceiling Update`);
|
|
19901
|
+
return;
|
|
19902
|
+
}
|
|
19903
|
+
return send;
|
|
19904
|
+
}
|
|
19905
|
+
/**
|
|
19470
19906
|
* One `CreateTable` index entry with ALL THREE of its throughput blocks
|
|
19471
19907
|
* coerced — `WarmThroughput` (PR review round 6), `ProvisionedThroughput`
|
|
19472
19908
|
* (issue [#3255](https://github.com/go-to-k/cdkd/issues/3255)) and
|
|
@@ -19499,7 +19935,7 @@ var DynamoDBTableProvider = class {
|
|
|
19499
19935
|
* value, which is pre-existing behaviour and costs one spread per declared
|
|
19500
19936
|
* block.
|
|
19501
19937
|
*/
|
|
19502
|
-
coerceIndexThroughputForCreate(logicalId, entry, maskSecrets
|
|
19938
|
+
coerceIndexThroughputForCreate(logicalId, entry, maskSecrets) {
|
|
19503
19939
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return entry;
|
|
19504
19940
|
const bag = entry;
|
|
19505
19941
|
const declaredWarm = bag["WarmThroughput"];
|
|
@@ -19585,7 +20021,7 @@ var DynamoDBTableProvider = class {
|
|
|
19585
20021
|
* same fail-open reason — a misspelled `ReadCapacityUnit` reaches AWS and is
|
|
19586
20022
|
* named there rather than vanishing here.
|
|
19587
20023
|
*/
|
|
19588
|
-
coerceIndexCapacityBlock(scope, declared, maskSecrets
|
|
20024
|
+
coerceIndexCapacityBlock(scope, declared, maskSecrets) {
|
|
19589
20025
|
if (!isPlainCapacityBlock(declared)) return void 0;
|
|
19590
20026
|
const block = declared;
|
|
19591
20027
|
let rewritten;
|
|
@@ -19606,7 +20042,7 @@ var DynamoDBTableProvider = class {
|
|
|
19606
20042
|
* through CloudFormation's DynamoDB Integer grammar (issue
|
|
19607
20043
|
* [#3265](https://github.com/go-to-k/cdkd/issues/3265)).
|
|
19608
20044
|
*
|
|
19609
|
-
* Called from ALL
|
|
20045
|
+
* Called from ALL SIX send sites, which is the whole point — the block was
|
|
19610
20046
|
* forwarded VERBATIM at every one of them, so a CFn-legal
|
|
19611
20047
|
* `MaxReadRequestUnits: '100'` reached AWS as the STRING `"100"` in a `Long`
|
|
19612
20048
|
* field and the request failed, while a `' 100 '` / `'0x64'` / `'1e2'`
|
|
@@ -19620,15 +20056,19 @@ var DynamoDBTableProvider = class {
|
|
|
19620
20056
|
* same blind spot that hid this forwarder from issue #3147's sweep and
|
|
19621
20057
|
* from #3255's round 6, one property over);
|
|
19622
20058
|
* 3. `update()`'s TABLE-level `UpdateTable.OnDemandThroughput`;
|
|
19623
|
-
* 4. `applyGsiUpdates`' per-index `Create` action
|
|
19624
|
-
*
|
|
19625
|
-
*
|
|
19626
|
-
*
|
|
19627
|
-
*
|
|
19628
|
-
*
|
|
19629
|
-
*
|
|
19630
|
-
*
|
|
19631
|
-
*
|
|
20059
|
+
* 4. `applyGsiUpdates`' per-index `Create` action;
|
|
20060
|
+
* 5. `applyGsiUpdates`' same-name `Update` action, and
|
|
20061
|
+
* 6. its adopted-index repair `Update` — both through
|
|
20062
|
+
* {@link indexCeilingForSend}, added by issue
|
|
20063
|
+
* [#3287](https://github.com/go-to-k/cdkd/issues/3287).
|
|
20064
|
+
*
|
|
20065
|
+
* Sites 5 and 6 were the MISSING SEND rather than a grammar defect: until
|
|
20066
|
+
* #3287 `UpdateGlobalSecondaryIndexAction.OnDemandThroughput` was declared by
|
|
20067
|
+
* the SDK and set by neither arm, and `updateHasMember` was set only by the
|
|
20068
|
+
* capacity and warm arms — so a template whose only edit was a live index's
|
|
20069
|
+
* ceiling fired no op at all, the engine recorded the new value, and the next
|
|
20070
|
+
* deploy compared EQUAL. The edit was lost permanently, with nothing refused
|
|
20071
|
+
* and nothing dropped to warn about.
|
|
19632
20072
|
*
|
|
19633
20073
|
* THREE decisions, each with a plausible alternative:
|
|
19634
20074
|
*
|
|
@@ -19684,9 +20124,16 @@ var DynamoDBTableProvider = class {
|
|
|
19684
20124
|
* fail. Closing it needs `effectiveProperties` AND its
|
|
19685
20125
|
* `canonicalizeDesiredProperties` twin (the rule requires the pair; shipping
|
|
19686
20126
|
* the first alone re-issues a no-op `UpdateTable` on every later deploy), and
|
|
19687
|
-
* this provider implements neither today.
|
|
19688
|
-
*
|
|
19689
|
-
*
|
|
20127
|
+
* this provider implements neither today. That pair was WRITTEN and
|
|
20128
|
+
* WITHDRAWN: `observedProperties` is captured from the RAW template bag in
|
|
20129
|
+
* `deploy-engine.ts`, so a folded record leaves the observed side carrying a
|
|
20130
|
+
* per-index ceiling the record omits, and `drift-calculator.ts` compares
|
|
20131
|
+
* `GlobalSecondaryIndexes` as one positional deep-equal array — the fold
|
|
20132
|
+
* MANUFACTURES the phantom drift it exists to remove. Agreeing the two is a
|
|
20133
|
+
* deploy-engine change across three call sites. Tracked as issue
|
|
20134
|
+
* [#3286](https://github.com/go-to-k/cdkd/issues/3286), which carries the
|
|
20135
|
+
* evidence; the warning names the residual so a user is not left to discover
|
|
20136
|
+
* it from a `cdkd drift` line. The
|
|
19690
20137
|
* `AWS::DynamoDB::GlobalTable` sibling answers the SAME way for the same
|
|
19691
20138
|
* spelling — `collectTableOnDemandCeilings` coerces through
|
|
19692
20139
|
* `coerceCfnInteger` and a rejected ceiling is reported and LEFT UNSENT, with
|
|
@@ -19694,20 +20141,9 @@ var DynamoDBTableProvider = class {
|
|
|
19694
20141
|
* capacity answer, not its ceiling one) — so the two types do not diverge.
|
|
19695
20142
|
*/
|
|
19696
20143
|
coerceOnDemandCeilingsForSend(scope, declared, maskSecrets) {
|
|
19697
|
-
|
|
19698
|
-
const
|
|
19699
|
-
|
|
19700
|
-
for (const member of ON_DEMAND_CEILING_MEMBERS) {
|
|
19701
|
-
if (!(member in block)) continue;
|
|
19702
|
-
const coerced = coerceCfnInteger(block[member]);
|
|
19703
|
-
if (coerced === block[member]) continue;
|
|
19704
|
-
rewritten ??= { ...block };
|
|
19705
|
-
if (coerced === void 0) {
|
|
19706
|
-
this.warnUnusableOnDemandCeiling(scope, member, block[member], maskSecrets);
|
|
19707
|
-
delete rewritten[member];
|
|
19708
|
-
} else rewritten[member] = coerced;
|
|
19709
|
-
}
|
|
19710
|
-
return rewritten ?? block;
|
|
20144
|
+
const { block, dropped } = narrowOnDemandCeilings(declared);
|
|
20145
|
+
for (const { member, raw } of dropped) this.warnUnusableOnDemandCeiling(scope, member, raw, maskSecrets);
|
|
20146
|
+
return block;
|
|
19711
20147
|
}
|
|
19712
20148
|
/**
|
|
19713
20149
|
* Announce an `OnDemandThroughput` member a forwarder DROPPED (issue
|
|
@@ -19793,7 +20229,7 @@ var DynamoDBTableProvider = class {
|
|
|
19793
20229
|
* table that does not exist. {@link tableCapacityForSend}'s doc carries why
|
|
19794
20230
|
* this type differs.
|
|
19795
20231
|
*/
|
|
19796
|
-
warnUnusableProvisionedCapacity(scope, member, raw, maskSecrets
|
|
20232
|
+
warnUnusableProvisionedCapacity(scope, member, raw, maskSecrets) {
|
|
19797
20233
|
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19798
20234
|
warn(`${scope}: ProvisionedThroughput.${member} ${JSON.stringify(maskLeafValue$1(raw, maskSecrets))} is not an Integer CloudFormation accepts (an optional sign and decimal digits only — no surrounding whitespace, hex, exponent or decimal point), so it was NOT sent and NO capacity was substituted for it. DynamoDB requires both members wherever a PROVISIONED ProvisionedThroughput block is declared — the table itself, and every GlobalSecondaryIndexes[] entry of a PROVISIONED table — and will reject the request naming this one; CloudFormation refuses the same template at properties validation. Check for an unresolved intrinsic, or spell the value as a decimal integer.`);
|
|
19799
20235
|
}
|
|
@@ -19809,7 +20245,7 @@ var DynamoDBTableProvider = class {
|
|
|
19809
20245
|
* Silent for an ABSENT or FALSY value — see {@link isRefusedWarmThroughput}
|
|
19810
20246
|
* for why those two are not this warning's business.
|
|
19811
20247
|
*/
|
|
19812
|
-
warnRefusedWarmThroughput(scope, value, maskSecrets
|
|
20248
|
+
warnRefusedWarmThroughput(scope, value, maskSecrets) {
|
|
19813
20249
|
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19814
20250
|
if (!isRefusedWarmThroughput(value)) return;
|
|
19815
20251
|
warn(`${scope}: the declared WarmThroughput ${JSON.stringify(maskLeafValue$1(value, maskSecrets))} 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.`);
|
|
@@ -19867,19 +20303,19 @@ var DynamoDBTableProvider = class {
|
|
|
19867
20303
|
* `DescribeTable` reports for every index anyway, so there is no divergence
|
|
19868
20304
|
* to hide.
|
|
19869
20305
|
*/
|
|
19870
|
-
skipZeroCapacityIndexUpdate(indexName, physicalId, requested, maskSecrets
|
|
20306
|
+
skipZeroCapacityIndexUpdate(indexName, physicalId, requested, maskSecrets) {
|
|
19871
20307
|
if (requested === void 0) return false;
|
|
19872
20308
|
if (toFiniteNumber(requested.ReadCapacityUnits) !== 0 || toFiniteNumber(requested.WriteCapacityUnits) !== 0) return false;
|
|
19873
20309
|
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19874
|
-
warn(
|
|
20310
|
+
warn(`${this.indexScopeAt(indexName, physicalId, maskSecrets)}: 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.`);
|
|
19875
20311
|
return true;
|
|
19876
20312
|
}
|
|
19877
20313
|
/**
|
|
19878
20314
|
* The per-index `WarmThroughput` this update should send, or `undefined`
|
|
19879
20315
|
* (issue #1768, one nesting level down from the table-level branch).
|
|
19880
20316
|
*
|
|
19881
|
-
* `applyGsiUpdates` used to send only `ProvisionedThroughput`
|
|
19882
|
-
*
|
|
20317
|
+
* `applyGsiUpdates` used to send only `ProvisionedThroughput` on both of its
|
|
20318
|
+
* arms, while `readCurrentState` emits a
|
|
19883
20319
|
* declared per-index `WarmThroughput` — so a template declaring one had it
|
|
19884
20320
|
* silently dropped on every index add / change, `cdkd drift` reported the
|
|
19885
20321
|
* difference forever, and `--revert` emitted no op at all and exited 0
|
|
@@ -19900,23 +20336,23 @@ var DynamoDBTableProvider = class {
|
|
|
19900
20336
|
* lower than current WarmThroughput, decreasing WarmThroughput is not
|
|
19901
20337
|
* supported`.
|
|
19902
20338
|
*/
|
|
19903
|
-
warmThroughputOpFor(indexName, physicalId, desired, previous, live, maskSecrets
|
|
20339
|
+
warmThroughputOpFor(indexName, physicalId, desired, previous, live, maskSecrets) {
|
|
19904
20340
|
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19905
20341
|
const debug = (message) => this.logger.debug(maskSecrets(message));
|
|
19906
|
-
const
|
|
20342
|
+
const scope = this.indexScopeAt(indexName, physicalId, maskSecrets);
|
|
19907
20343
|
const requested = desired.WarmThroughput;
|
|
19908
20344
|
if (previous !== void 0 && JSON.stringify(previous.WarmThroughput) === JSON.stringify(requested)) return;
|
|
19909
|
-
const sendable = this.coerceWarmThroughputForSend(
|
|
20345
|
+
const sendable = this.coerceWarmThroughputForSend(scope, requested, maskSecrets);
|
|
19910
20346
|
if (sendable === void 0) {
|
|
19911
|
-
this.warnRefusedWarmThroughput(
|
|
20347
|
+
this.warnRefusedWarmThroughput(scope, requested, maskSecrets);
|
|
19912
20348
|
return;
|
|
19913
20349
|
}
|
|
19914
20350
|
if (warmThroughputAlreadyMatches(sendable, live?.WarmThroughput)) {
|
|
19915
|
-
debug(
|
|
20351
|
+
debug(`${scope} already carries the requested warm throughput in AWS; skipping its WarmThroughput Update`);
|
|
19916
20352
|
return;
|
|
19917
20353
|
}
|
|
19918
20354
|
if (isWarmThroughputDecrease(sendable, live?.WarmThroughput)) {
|
|
19919
|
-
warn(
|
|
20355
|
+
warn(`${scope}: the requested WarmThroughput ${JSON.stringify(maskDeep(sendable, maskSecrets))} is lower than the ${JSON.stringify(maskDeep({
|
|
19920
20356
|
ReadUnitsPerSecond: live?.WarmThroughput?.ReadUnitsPerSecond,
|
|
19921
20357
|
WriteUnitsPerSecond: live?.WarmThroughput?.WriteUnitsPerSecond
|
|
19922
20358
|
}, maskSecrets))} 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.`);
|
|
@@ -19933,7 +20369,8 @@ var DynamoDBTableProvider = class {
|
|
|
19933
20369
|
* of it — the wait is what makes the next op (there, the BillingMode flip)
|
|
19934
20370
|
* legal.
|
|
19935
20371
|
*/
|
|
19936
|
-
async runGsiOps(physicalId, ops, desiredAttributeDefinitions) {
|
|
20372
|
+
async runGsiOps(physicalId, ops, desiredAttributeDefinitions, maskSecrets) {
|
|
20373
|
+
const debug = (message) => this.logger.debug(maskSecrets(message));
|
|
19937
20374
|
for (const op of ops) {
|
|
19938
20375
|
const input = {
|
|
19939
20376
|
TableName: physicalId,
|
|
@@ -19942,8 +20379,7 @@ var DynamoDBTableProvider = class {
|
|
|
19942
20379
|
if (op.Create && desiredAttributeDefinitions) input.AttributeDefinitions = desiredAttributeDefinitions;
|
|
19943
20380
|
await this.dynamoDBClient.send(new UpdateTableCommand(input));
|
|
19944
20381
|
await this.waitForTableAndIndexesActive(physicalId);
|
|
19945
|
-
|
|
19946
|
-
this.logger.debug(`${verb} GSI ${op.Create?.IndexName ?? op.Delete?.IndexName ?? op.Update?.IndexName} on DynamoDB table ${physicalId}`);
|
|
20382
|
+
debug(`${op.Create ? "created" : op.Delete ? "deleted" : "updated"} ${this.indexScopeAt(op.Create?.IndexName ?? op.Delete?.IndexName ?? op.Update?.IndexName, physicalId, maskSecrets)}`);
|
|
19947
20383
|
}
|
|
19948
20384
|
}
|
|
19949
20385
|
/**
|
|
@@ -19992,7 +20428,7 @@ var DynamoDBTableProvider = class {
|
|
|
19992
20428
|
* resource clean. The message therefore says the warning may be the only
|
|
19993
20429
|
* signal, and names both numbers so the template edit needs no second look.
|
|
19994
20430
|
*/
|
|
19995
|
-
skipWarmThroughputDecrease(logicalId, physicalId, desired, live, maskSecrets
|
|
20431
|
+
skipWarmThroughputDecrease(logicalId, physicalId, desired, live, maskSecrets) {
|
|
19996
20432
|
const warn = (message) => this.logger.warn(maskSecrets(message));
|
|
19997
20433
|
if (!isWarmThroughputDecrease(desired, live)) return false;
|
|
19998
20434
|
warn(`AWS::DynamoDB::Table ${logicalId}: the requested WarmThroughput ${JSON.stringify(maskDeep(desired, maskSecrets))} is lower than the ${JSON.stringify(maskDeep({
|
|
@@ -79924,11 +80360,12 @@ async function stateOrphanCommand(stackArgs, options) {
|
|
|
79924
80360
|
if (!options.force) {
|
|
79925
80361
|
for (const target of targets) if (await setup.lockManager.isLocked(stackName, target.region)) {
|
|
79926
80362
|
const where = target.region ? displaySafe(target.region, { asciiOnly: true }) || "<unrenderable>" : "legacy";
|
|
79927
|
-
|
|
80363
|
+
const recoveryCommand = buildForceUnlockCommand(stackName, target.region, {
|
|
79928
80364
|
profile: options.profile,
|
|
79929
80365
|
stateBucket: setup.bucket,
|
|
79930
80366
|
statePrefix: options.statePrefix
|
|
79931
|
-
})
|
|
80367
|
+
});
|
|
80368
|
+
throw new Error(`Stack '${displaySafe(stackName, { asciiOnly: true })}' (${where}) is locked. ` + (recoveryCommand ? `Run: ${recoveryCommand} first, or pass --force to remove anyway.` : `${UNREPRODUCIBLE_LOCK_CLAUSE} Or pass --force to remove anyway.`));
|
|
79932
80369
|
}
|
|
79933
80370
|
}
|
|
79934
80371
|
if (!options.yes && !options.force) {
|
|
@@ -87997,11 +88434,11 @@ async function pullEcrImage(imageUri, options) {
|
|
|
87997
88434
|
const cached = ASSUMED_ROLE_CACHE.get(cacheKey);
|
|
87998
88435
|
if (cached && isCredentialFresh(cached)) {
|
|
87999
88436
|
assumed = cached;
|
|
88000
|
-
logger.debug(`Reusing cached AssumeRole credentials for ${options.ecrRoleArn}`);
|
|
88437
|
+
logger.debug(`Reusing cached AssumeRole credentials for ${displayIdent(options.ecrRoleArn, { maxCodePoints: 612 })}`);
|
|
88001
88438
|
} else {
|
|
88002
88439
|
assumed = await assumeRoleForEcr(options.ecrRoleArn, callerRegion, logger);
|
|
88003
88440
|
ASSUMED_ROLE_CACHE.set(cacheKey, assumed);
|
|
88004
|
-
logger.info(`Assumed role ${options.ecrRoleArn} for ECR pull (account=${parsed.accountId}, region=${parsed.region})`);
|
|
88441
|
+
logger.info(`Assumed role ${displayIdent(options.ecrRoleArn, { maxCodePoints: 612 })} for ECR pull (account=${parsed.accountId}, region=${parsed.region})`);
|
|
88005
88442
|
}
|
|
88006
88443
|
} else if (crossAccount) logger.info(`Cross-account ECR pull: image account ${parsed.accountId} != caller ${callerAccount}. Using the caller's credentials; pass --ecr-role-arn <arn> if AWS rejects with AccessDenied.`);
|
|
88007
88444
|
if (crossRegion) logger.info(`Cross-region ECR pull: image region ${parsed.region} != caller ${callerRegion ?? "(unset)"}. Authenticating against the image region directly.`);
|
|
@@ -88032,7 +88469,7 @@ async function pullEcrImage(imageUri, options) {
|
|
|
88032
88469
|
* mirrors the convention used by `src/utils/role-arn.ts`.
|
|
88033
88470
|
*/
|
|
88034
88471
|
async function assumeRoleForEcr(roleArn, callerRegion, logger) {
|
|
88035
|
-
logger.debug(`Assuming role ${roleArn} for ECR pull...`);
|
|
88472
|
+
logger.debug(`Assuming role ${displayIdent(roleArn, { maxCodePoints: 612 })} for ECR pull...`);
|
|
88036
88473
|
const sts = new STSClient({
|
|
88037
88474
|
...awsClientDefaults(),
|
|
88038
88475
|
...callerRegion && { region: callerRegion }
|
|
@@ -88043,7 +88480,7 @@ async function assumeRoleForEcr(roleArn, callerRegion, logger) {
|
|
|
88043
88480
|
RoleSessionName: `cdkd-local-ecr-${Date.now()}`,
|
|
88044
88481
|
DurationSeconds: 3600
|
|
88045
88482
|
}))).Credentials;
|
|
88046
|
-
if (!creds || !creds.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalInvokeBuildError$1(`AssumeRole(${roleArn}) returned no usable credentials. Verify the role's trust policy allows your identity to assume it.`);
|
|
88483
|
+
if (!creds || !creds.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalInvokeBuildError$1(`AssumeRole(${displayIdent(roleArn, { maxCodePoints: 612 })}) returned no usable credentials. Verify the role's trust policy allows your identity to assume it.`);
|
|
88047
88484
|
return {
|
|
88048
88485
|
accessKeyId: creds.AccessKeyId,
|
|
88049
88486
|
secretAccessKey: creds.SecretAccessKey,
|
|
@@ -88053,7 +88490,7 @@ async function assumeRoleForEcr(roleArn, callerRegion, logger) {
|
|
|
88053
88490
|
} catch (err) {
|
|
88054
88491
|
if (err instanceof LocalInvokeBuildError$1) throw err;
|
|
88055
88492
|
const reason = err instanceof Error ? err.message : String(err);
|
|
88056
|
-
throw new LocalInvokeBuildError$1(`Failed to assume role ${roleArn} for ECR pull: ${reason}. Verify the role exists and its trust policy permits the caller's identity to assume it.`);
|
|
88493
|
+
throw new LocalInvokeBuildError$1(`Failed to assume role ${displayIdent(roleArn, { maxCodePoints: 612 })} for ECR pull: ${displaySafe(reason)}. Verify the role exists and its trust policy permits the caller's identity to assume it.`);
|
|
88057
88494
|
} finally {
|
|
88058
88495
|
sts.destroy();
|
|
88059
88496
|
}
|
|
@@ -88371,7 +88808,7 @@ const CONTAINER_AWS_CREDENTIALS_PATH = "/cdkd-aws/credentials";
|
|
|
88371
88808
|
*/
|
|
88372
88809
|
async function writeProfileCredentialsFile(profileName, creds) {
|
|
88373
88810
|
if (profileName === "") throw new Error("writeProfileCredentialsFile: profile name must not be empty.");
|
|
88374
|
-
if (/[\r\n[\]]/.test(profileName)) throw new Error(`writeProfileCredentialsFile: profile name
|
|
88811
|
+
if (/[\r\n[\]]/.test(profileName)) throw new Error(`writeProfileCredentialsFile: profile name ${displayIdent(profileName)} contains a forbidden character (any of CR, LF, '[', ']' would corrupt the INI file or the docker -e env var).`);
|
|
88375
88812
|
const dir = await mkdtemp(path.join(tmpdir(), "cdkd-profile-creds-"));
|
|
88376
88813
|
const hostPath = path.join(dir, "credentials");
|
|
88377
88814
|
const lines = [
|
|
@@ -88380,7 +88817,15 @@ async function writeProfileCredentialsFile(profileName, creds) {
|
|
|
88380
88817
|
`aws_secret_access_key = ${creds.secretAccessKey}`
|
|
88381
88818
|
];
|
|
88382
88819
|
if (creds.sessionToken) lines.push(`aws_session_token = ${creds.sessionToken}`);
|
|
88383
|
-
|
|
88820
|
+
try {
|
|
88821
|
+
await writeFile(hostPath, lines.join("\n") + "\n", { mode: 384 });
|
|
88822
|
+
} catch (err) {
|
|
88823
|
+
await rm(dir, {
|
|
88824
|
+
recursive: true,
|
|
88825
|
+
force: true
|
|
88826
|
+
}).catch(() => {});
|
|
88827
|
+
throw err;
|
|
88828
|
+
}
|
|
88384
88829
|
return {
|
|
88385
88830
|
hostPath,
|
|
88386
88831
|
containerPath: CONTAINER_AWS_CREDENTIALS_PATH,
|
|
@@ -90393,7 +90838,11 @@ async function resolveProfileCredentials(profile) {
|
|
|
90393
90838
|
try {
|
|
90394
90839
|
const credsProvider = sts.config.credentials;
|
|
90395
90840
|
const creds = typeof credsProvider === "function" ? await credsProvider() : credsProvider;
|
|
90396
|
-
if (!creds || !creds.accessKeyId || !creds.secretAccessKey)
|
|
90841
|
+
if (!creds || !creds.accessKeyId || !creds.secretAccessKey) {
|
|
90842
|
+
const shownProfile = displayIdent(profile);
|
|
90843
|
+
const ssoHint = isPasteableIdent(profile) ? "run `aws sso login --profile " + profile + "`" : "run `aws sso login` for that profile -- cdkd is not writing the command out, because it cannot vouch that the name as rendered is the one your shell would act on";
|
|
90844
|
+
throw new Error(`--profile ${shownProfile}: credential provider chain resolved without usable credentials. For SSO profiles, ${ssoHint}. For regular profiles, check \`~/.aws/credentials\` / \`~/.aws/config\`.`);
|
|
90845
|
+
}
|
|
90397
90846
|
return {
|
|
90398
90847
|
accessKeyId: creds.accessKeyId,
|
|
90399
90848
|
secretAccessKey: creds.secretAccessKey,
|
|
@@ -90420,7 +90869,7 @@ async function assumeLambdaExecutionRole$1(roleArn, region) {
|
|
|
90420
90869
|
RoleSessionName: `cdkd-local-start-api-${Date.now()}`,
|
|
90421
90870
|
DurationSeconds: 3600
|
|
90422
90871
|
}))).Credentials;
|
|
90423
|
-
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
90872
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${displayIdent(roleArn, { maxCodePoints: 612 })}) returned no usable credentials.`);
|
|
90424
90873
|
return {
|
|
90425
90874
|
accessKeyId: creds.AccessKeyId,
|
|
90426
90875
|
secretAccessKey: creds.SecretAccessKey,
|
|
@@ -90982,7 +91431,7 @@ async function resolveEcsSecrets(entries, options = {}) {
|
|
|
90982
91431
|
try {
|
|
90983
91432
|
return await Promise.all(entries.map(async (entry) => {
|
|
90984
91433
|
const value = await resolveOne(entry, secretsClient, ssmClient);
|
|
90985
|
-
logger.debug(`Resolved secret ${entry.containerName}.${entry.name} (${entry.valueFrom})`);
|
|
91434
|
+
logger.debug(`Resolved secret ${displayIdent(entry.containerName)}.${displayIdent(entry.name)} (${displayIdent(entry.valueFrom, { maxCodePoints: SECRET_REF_MAX_CODE_POINTS })})`);
|
|
90986
91435
|
return {
|
|
90987
91436
|
...entry,
|
|
90988
91437
|
value
|
|
@@ -90999,7 +91448,7 @@ async function resolveOne(entry, secretsClient, ssmClient) {
|
|
|
90999
91448
|
switch (shape.kind) {
|
|
91000
91449
|
case "secrets-manager": return resolveSecretsManager(entry, shape, secretsClient);
|
|
91001
91450
|
case "ssm": return resolveSsm(entry, shape, ssmClient);
|
|
91002
|
-
case "unknown": throw new EcsSecretsResolutionError(`Container '${entry.containerName}' secret '${entry.name}' references an unsupported ValueFrom shape
|
|
91451
|
+
case "unknown": throw new EcsSecretsResolutionError(`Container '${displayIdent(entry.containerName)}' secret '${displayIdent(entry.name)}' references an unsupported ValueFrom shape ${displayIdent(arn, { maxCodePoints: SECRET_REF_MAX_CODE_POINTS })}. Expected Secrets Manager ARN (optionally with :<json-key>::) or SSM Parameter ARN.`);
|
|
91003
91452
|
}
|
|
91004
91453
|
}
|
|
91005
91454
|
/**
|
|
@@ -91032,20 +91481,20 @@ async function resolveSecretsManager(entry, shape, client) {
|
|
|
91032
91481
|
try {
|
|
91033
91482
|
secretString = (await client.send(new GetSecretValueCommand({ SecretId: shape.baseArn }))).SecretString;
|
|
91034
91483
|
} catch (err) {
|
|
91035
|
-
throw new EcsSecretsResolutionError(`Failed to resolve Secrets Manager secret for container '${entry.containerName}' / env '${entry.name}' (${shape.baseArn}): ${err instanceof Error ? err.message : String(err)}`);
|
|
91484
|
+
throw new EcsSecretsResolutionError(`Failed to resolve Secrets Manager secret for container '${displayIdent(entry.containerName)}' / env '${displayIdent(entry.name)}' (${displayIdent(shape.baseArn, { maxCodePoints: SECRET_REF_MAX_CODE_POINTS })}): ${displaySafe(err instanceof Error ? err.message : String(err))}`);
|
|
91036
91485
|
}
|
|
91037
|
-
if (secretString === void 0) throw new EcsSecretsResolutionError(`Secrets Manager returned no SecretString for container '${entry.containerName}' / env '${entry.name}' (${shape.baseArn}). Binary secrets are not supported.`);
|
|
91486
|
+
if (secretString === void 0) throw new EcsSecretsResolutionError(`Secrets Manager returned no SecretString for container '${displayIdent(entry.containerName)}' / env '${displayIdent(entry.name)}' (${displayIdent(shape.baseArn, { maxCodePoints: SECRET_REF_MAX_CODE_POINTS })}). Binary secrets are not supported.`);
|
|
91038
91487
|
if (shape.jsonKey === void 0) return secretString;
|
|
91039
91488
|
let parsed;
|
|
91040
91489
|
try {
|
|
91041
91490
|
parsed = JSON.parse(secretString);
|
|
91042
91491
|
} catch (err) {
|
|
91043
91492
|
const kind = err instanceof Error ? err.name : "unknown";
|
|
91044
|
-
throw new EcsSecretsResolutionError(`Container '${entry.containerName}' secret '${entry.name}' specified json-key '${shape.jsonKey}' but the secret value is not valid JSON (${kind}). The parser detail is withheld because it would echo the secret plaintext.`);
|
|
91493
|
+
throw new EcsSecretsResolutionError(`Container '${displayIdent(entry.containerName)}' secret '${displayIdent(entry.name)}' specified json-key '${displaySafe(shape.jsonKey)}' but the secret value is not valid JSON (${displayIdent(kind)}). The parser detail is withheld because it would echo the secret plaintext.`);
|
|
91045
91494
|
}
|
|
91046
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new EcsSecretsResolutionError(`Container '${entry.containerName}' secret '${entry.name}' specified json-key '${shape.jsonKey}' but the secret root is not a JSON object.`);
|
|
91495
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new EcsSecretsResolutionError(`Container '${displayIdent(entry.containerName)}' secret '${displayIdent(entry.name)}' specified json-key '${displaySafe(shape.jsonKey)}' but the secret root is not a JSON object.`);
|
|
91047
91496
|
const value = parsed[shape.jsonKey];
|
|
91048
|
-
if (value === void 0) throw new EcsSecretsResolutionError(`Container '${entry.containerName}' secret '${entry.name}' specified json-key '${shape.jsonKey}' but no such key exists in the secret JSON.`);
|
|
91497
|
+
if (value === void 0) throw new EcsSecretsResolutionError(`Container '${displayIdent(entry.containerName)}' secret '${displayIdent(entry.name)}' specified json-key '${displaySafe(shape.jsonKey)}' but no such key exists in the secret JSON.`);
|
|
91049
91498
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
91050
91499
|
}
|
|
91051
91500
|
async function resolveSsm(entry, shape, client) {
|
|
@@ -91054,11 +91503,11 @@ async function resolveSsm(entry, shape, client) {
|
|
|
91054
91503
|
Name: shape.name,
|
|
91055
91504
|
WithDecryption: true
|
|
91056
91505
|
}))).Parameter?.Value;
|
|
91057
|
-
if (value === void 0) throw new EcsSecretsResolutionError(`SSM parameter '${shape.name}' returned no Value for container '${entry.containerName}' / env '${entry.name}'.`);
|
|
91506
|
+
if (value === void 0) throw new EcsSecretsResolutionError(`SSM parameter '${displayIdent(shape.name, { maxCodePoints: SECRET_REF_MAX_CODE_POINTS })}' returned no Value for container '${displayIdent(entry.containerName)}' / env '${displayIdent(entry.name)}'.`);
|
|
91058
91507
|
return value;
|
|
91059
91508
|
} catch (err) {
|
|
91060
91509
|
if (err instanceof EcsSecretsResolutionError) throw err;
|
|
91061
|
-
throw new EcsSecretsResolutionError(`Failed to resolve SSM parameter for container '${entry.containerName}' / env '${entry.name}' (${shape.name}): ${err instanceof Error ? err.message : String(err)}`);
|
|
91510
|
+
throw new EcsSecretsResolutionError(`Failed to resolve SSM parameter for container '${displayIdent(entry.containerName)}' / env '${displayIdent(entry.name)}' (${displayIdent(shape.name, { maxCodePoints: SECRET_REF_MAX_CODE_POINTS })}): ${displaySafe(err instanceof Error ? err.message : String(err))}`);
|
|
91062
91511
|
}
|
|
91063
91512
|
}
|
|
91064
91513
|
|
|
@@ -91662,8 +92111,9 @@ async function localRunTaskCommand(target, options) {
|
|
|
91662
92111
|
const state = createEcsRunState();
|
|
91663
92112
|
let sigintHandler;
|
|
91664
92113
|
let sigintCount = 0;
|
|
92114
|
+
let detachedSuccessfully = false;
|
|
91665
92115
|
let stateProvider;
|
|
91666
|
-
let
|
|
92116
|
+
let channels;
|
|
91667
92117
|
let cleanupPromise;
|
|
91668
92118
|
const cleanup = async () => {
|
|
91669
92119
|
if (!cleanupPromise) cleanupPromise = (async () => {
|
|
@@ -91672,10 +92122,10 @@ async function localRunTaskCommand(target, options) {
|
|
|
91672
92122
|
} catch (err) {
|
|
91673
92123
|
getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
91674
92124
|
}
|
|
91675
|
-
if (profileCredsFile) try {
|
|
91676
|
-
await profileCredsFile.dispose();
|
|
92125
|
+
if (channels?.profileCredsFile) try {
|
|
92126
|
+
await channels.profileCredsFile.dispose();
|
|
91677
92127
|
} catch (err) {
|
|
91678
|
-
getLogger().debug(`Failed to remove profile credentials tmpdir ${profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
92128
|
+
getLogger().debug(`Failed to remove profile credentials tmpdir ${channels.profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
91679
92129
|
}
|
|
91680
92130
|
})();
|
|
91681
92131
|
await cleanupPromise;
|
|
@@ -91741,33 +92191,20 @@ async function localRunTaskCommand(target, options) {
|
|
|
91741
92191
|
resolvedRoleArn = options.assumeTaskRole;
|
|
91742
92192
|
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
91743
92193
|
}
|
|
91744
|
-
|
|
91745
|
-
if (options.profile && sidecarCredentials && !assumedCredentials) profileCredsFile = await writeProfileCredentialsFile(options.profile, sidecarCredentials);
|
|
92194
|
+
channels = await resolveTaskCredentialChannels(options, assumedCredentials);
|
|
91746
92195
|
const envOverrides = readEnvOverridesFile$2(options.envVars);
|
|
91747
|
-
const runOpts = {
|
|
91748
|
-
|
|
91749
|
-
|
|
91750
|
-
|
|
91751
|
-
keepRunning: options.keepRunning,
|
|
91752
|
-
detach: options.detach
|
|
91753
|
-
};
|
|
91754
|
-
if (envOverrides) runOpts.envOverrides = envOverrides;
|
|
91755
|
-
if (sidecarCredentials) runOpts.taskCredentials = sidecarCredentials;
|
|
91756
|
-
if (resolvedRoleArn) runOpts.taskRoleArn = resolvedRoleArn;
|
|
91757
|
-
if (options.platform) runOpts.platformOverride = options.platform;
|
|
91758
|
-
if (options.region) runOpts.region = options.region;
|
|
91759
|
-
if (options.ecrRoleArn) runOpts.ecrRoleArn = options.ecrRoleArn;
|
|
91760
|
-
if (profileCredsFile) runOpts.profileCredentialsFile = {
|
|
91761
|
-
hostPath: profileCredsFile.hostPath,
|
|
91762
|
-
containerPath: profileCredsFile.containerPath,
|
|
91763
|
-
profileName: profileCredsFile.profileName
|
|
91764
|
-
};
|
|
92196
|
+
const runOpts = buildRunEcsTaskOptions(options, channels, {
|
|
92197
|
+
...envOverrides !== void 0 && { envOverrides },
|
|
92198
|
+
...resolvedRoleArn !== void 0 && { resolvedRoleArn }
|
|
92199
|
+
});
|
|
91765
92200
|
const hostGatewayExtraHosts = await resolveHostGatewayExtraHosts();
|
|
91766
92201
|
if (hostGatewayExtraHosts.length > 0) runOpts.hostGatewayExtraHosts = hostGatewayExtraHosts;
|
|
91767
92202
|
const result = await runEcsTask(task, runOpts, state);
|
|
91768
92203
|
if (options.detach) {
|
|
91769
92204
|
logger.info("Task containers started in detached mode; cdkd is exiting.");
|
|
91770
92205
|
logger.info(`Use 'docker ps -a --filter network=${result.state.network?.networkName ?? "<network>"}' to inspect; tear down with 'docker rm -f' and 'docker network rm'.`);
|
|
92206
|
+
if (channels.profileCredsFile) logger.info(`The AWS credentials file mounted into the containers is NOT removed in detached mode: delete ${displayIdent(channels.profileCredsFile.hostPath)} once you tear them down.`);
|
|
92207
|
+
detachedSuccessfully = true;
|
|
91771
92208
|
sigintCount = 99;
|
|
91772
92209
|
return;
|
|
91773
92210
|
}
|
|
@@ -91776,7 +92213,7 @@ async function localRunTaskCommand(target, options) {
|
|
|
91776
92213
|
} finally {
|
|
91777
92214
|
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
91778
92215
|
if (stateProvider) stateProvider.dispose();
|
|
91779
|
-
if (!
|
|
92216
|
+
if (!detachedSuccessfully) await cleanup();
|
|
91780
92217
|
}
|
|
91781
92218
|
}
|
|
91782
92219
|
/**
|
|
@@ -91795,7 +92232,7 @@ async function resolvePlaceholderAccount(arn, region) {
|
|
|
91795
92232
|
});
|
|
91796
92233
|
try {
|
|
91797
92234
|
const account = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
91798
|
-
if (!account) throw new Error(`--assume-task-role: GetCallerIdentity returned no Account; cannot resolve placeholder ARN
|
|
92235
|
+
if (!account) throw new Error(`--assume-task-role: GetCallerIdentity returned no Account; cannot resolve placeholder ARN ${displayIdent(arn, { maxCodePoints: 612 })}. Pass the ARN explicitly: --assume-task-role <arn>`);
|
|
91799
92236
|
return arn.split(TASK_ROLE_ACCOUNT_PLACEHOLDER).join(account);
|
|
91800
92237
|
} finally {
|
|
91801
92238
|
sts.destroy();
|
|
@@ -91817,7 +92254,7 @@ async function assumeTaskRole(roleArn, region) {
|
|
|
91817
92254
|
RoleSessionName: `cdkd-local-run-task-${Date.now()}`,
|
|
91818
92255
|
DurationSeconds: 3600
|
|
91819
92256
|
}))).Credentials;
|
|
91820
|
-
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
92257
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${displayIdent(roleArn, { maxCodePoints: 612 })}) returned no usable credentials.`);
|
|
91821
92258
|
return {
|
|
91822
92259
|
accessKeyId: creds.AccessKeyId,
|
|
91823
92260
|
secretAccessKey: creds.SecretAccessKey,
|
|
@@ -92004,6 +92441,106 @@ async function resolveSidecarCredentials(options, assumedCredentials) {
|
|
|
92004
92441
|
if (assumedCredentials) return assumedCredentials;
|
|
92005
92442
|
if (options.profile) return resolveProfileCredentials(options.profile);
|
|
92006
92443
|
}
|
|
92444
|
+
/**
|
|
92445
|
+
* Resolve BOTH credential channels a locally-run ECS task receives, and decide
|
|
92446
|
+
* whether the second one exists at all.
|
|
92447
|
+
*
|
|
92448
|
+
* 1. `sidecarCredentials` — what the AWS-published
|
|
92449
|
+
* `amazon-ecs-local-container-endpoints` sidecar serves at
|
|
92450
|
+
* `169.254.170.2/role/<role>`; {@link resolveSidecarCredentials} owns the
|
|
92451
|
+
* precedence (issue #658).
|
|
92452
|
+
* 2. `profileCredsFile` — the ECS analogue of PR #670 (Lambda-container
|
|
92453
|
+
* fix-back finding #1). When `--profile <p>` is set AND `--assume-task-role`
|
|
92454
|
+
* did NOT produce credentials for this task, a host-side AWS shared
|
|
92455
|
+
* credentials file is written under `[<options.profile>]` and bind-mounted
|
|
92456
|
+
* read-only into every user container, so handler code calling
|
|
92457
|
+
* `fromIni({ profile: '<options.profile>' })` resolves to the same creds the
|
|
92458
|
+
* sidecar serves. Without it the SDK looks for `[<options.profile>]` in
|
|
92459
|
+
* `~/.aws/credentials` INSIDE the container and fails.
|
|
92460
|
+
*
|
|
92461
|
+
* Gating on `!assumedCredentials` preserves the documented precedence
|
|
92462
|
+
* (assume-task-role > profile-file > sidecar): when `--assume-task-role`
|
|
92463
|
+
* won, the sidecar's `/role/<arn>` endpoint already serves the assumed creds
|
|
92464
|
+
* and the file's env vars must NOT override them.
|
|
92465
|
+
*
|
|
92466
|
+
* ONE helper rather than two, and that is the substance of issue
|
|
92467
|
+
* go-to-k/cdkd#3378 rather than a tidying choice. Both of these lived inline in
|
|
92468
|
+
* `localRunTaskCommand`'s body, which meant the gate's annotation below was
|
|
92469
|
+
* fenced only for EXISTING (`local-surface-env-identity.test.ts` checks that an
|
|
92470
|
+
* annotation is there and carries a reason — it cannot check that the reason is
|
|
92471
|
+
* TRUE). And the reason is not a property of the `if` at all: it is a property
|
|
92472
|
+
* of the COMPOSITION, that `sidecarCredentials` on the `!assumedCredentials`
|
|
92473
|
+
* path can only have come from {@link resolveSidecarCredentials}'s `--profile`
|
|
92474
|
+
* arm. Extracting the three-line gate ALONE would have re-tested the same three
|
|
92475
|
+
* booleans and proved nothing; a test over this helper writes a real file and
|
|
92476
|
+
* can read the bytes back. `resolveSidecarCredentials`'s own doc already set
|
|
92477
|
+
* the precedent for the seam over a command-level harness — that harness would
|
|
92478
|
+
* have to mock the whole Synth + Docker + AWS pipeline, and a mock is exactly
|
|
92479
|
+
* what cannot answer "which identity's bytes landed in the file".
|
|
92480
|
+
*
|
|
92481
|
+
* cdkd-local-env-identity: the `!assumedCredentials` gate means
|
|
92482
|
+
* `resolveSidecarCredentials` reached its `--profile` arm, so this is
|
|
92483
|
+
* `resolveProfileCredentials` through
|
|
92484
|
+
* `awsClientDefaults({ ignoreAssumedRole: true })` — the caller's own chain,
|
|
92485
|
+
* never the `--role-arn` role — and never an `--assume-task-role` STS result,
|
|
92486
|
+
* which wins earlier and is served by the metadata sidecar instead.
|
|
92487
|
+
*/
|
|
92488
|
+
async function resolveTaskCredentialChannels(options, assumedCredentials) {
|
|
92489
|
+
const sidecarCredentials = await resolveSidecarCredentials(options, assumedCredentials);
|
|
92490
|
+
if (options.profile && sidecarCredentials && !assumedCredentials) return {
|
|
92491
|
+
sidecarCredentials,
|
|
92492
|
+
profileCredsFile: await writeProfileCredentialsFile(options.profile, sidecarCredentials)
|
|
92493
|
+
};
|
|
92494
|
+
return {
|
|
92495
|
+
sidecarCredentials,
|
|
92496
|
+
profileCredsFile: void 0
|
|
92497
|
+
};
|
|
92498
|
+
}
|
|
92499
|
+
/**
|
|
92500
|
+
* Build the `RunEcsTaskOptions` bag from the options and the resolved credential
|
|
92501
|
+
* channels.
|
|
92502
|
+
*
|
|
92503
|
+
* Extracted for the WIRING, which is the half
|
|
92504
|
+
* {@link resolveTaskCredentialChannels} could not cover (go-to-k/cdkd#3390 round
|
|
92505
|
+
* 2, on go-to-k/cdkd#3394). That helper decides WHETHER a credentials file
|
|
92506
|
+
* exists and whose bytes are in it; this one decides whether the answer reaches
|
|
92507
|
+
* the runner at all. Between them sat a single assignment, and mutating it to
|
|
92508
|
+
* `undefined` left 114 tests green across five files: no file is bind-mounted,
|
|
92509
|
+
* so every handler calling `fromIni({ profile })` inside the task container
|
|
92510
|
+
* fails -- the exact defect PR go-to-k/cdkd#670 shipped this path to fix. "A
|
|
92511
|
+
* probed callee says nothing about its WIRING" is the rule, and this is the
|
|
92512
|
+
* seam that lets a unit test say something about it.
|
|
92513
|
+
*
|
|
92514
|
+
* PURE, and it takes the whole `channels` object rather than a pre-destructured
|
|
92515
|
+
* file so the test subject is the same value the resolver returns -- a helper
|
|
92516
|
+
* taking `profileCredsFile` alone would move the untested assignment rather
|
|
92517
|
+
* than remove it.
|
|
92518
|
+
*
|
|
92519
|
+
* The file is COPIED field by field rather than passed through: `dispose` stays
|
|
92520
|
+
* with the command, which owns the lifetime, and `RunEcsTaskOptions` deliberately
|
|
92521
|
+
* declares only the three fields the runner mounts with.
|
|
92522
|
+
*/
|
|
92523
|
+
function buildRunEcsTaskOptions(options, channels, extra = {}) {
|
|
92524
|
+
const runOpts = {
|
|
92525
|
+
cluster: options.cluster,
|
|
92526
|
+
containerHost: options.containerHost,
|
|
92527
|
+
skipPull: options.pull === false,
|
|
92528
|
+
keepRunning: options.keepRunning,
|
|
92529
|
+
detach: options.detach
|
|
92530
|
+
};
|
|
92531
|
+
if (extra.envOverrides) runOpts.envOverrides = extra.envOverrides;
|
|
92532
|
+
if (channels.sidecarCredentials) runOpts.taskCredentials = channels.sidecarCredentials;
|
|
92533
|
+
if (extra.resolvedRoleArn) runOpts.taskRoleArn = extra.resolvedRoleArn;
|
|
92534
|
+
if (options.platform) runOpts.platformOverride = options.platform;
|
|
92535
|
+
if (options.region) runOpts.region = options.region;
|
|
92536
|
+
if (options.ecrRoleArn) runOpts.ecrRoleArn = options.ecrRoleArn;
|
|
92537
|
+
if (channels.profileCredsFile) runOpts.profileCredentialsFile = {
|
|
92538
|
+
hostPath: channels.profileCredsFile.hostPath,
|
|
92539
|
+
containerPath: channels.profileCredsFile.containerPath,
|
|
92540
|
+
profileName: channels.profileCredsFile.profileName
|
|
92541
|
+
};
|
|
92542
|
+
return runOpts;
|
|
92543
|
+
}
|
|
92007
92544
|
function createLocalRunTaskCommand() {
|
|
92008
92545
|
const cmd = new Command("run-task").description("Run an AWS::ECS::TaskDefinition locally — pulls/builds images, sets up a per-task docker network with the AWS-published metadata-endpoints sidecar, and starts every container in dependsOn order. Target accepts a CDK display path (MyStack/MyService/TaskDef) or stack-qualified logical ID (MyStack:MyServiceTaskDefXYZ1234). Single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::TaskDefinition to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed function role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries (#455). Issues sts:AssumeRole via the default credential chain and uses the temporary credentials for ecr:GetAuthorizationToken + docker pull. Required when the caller does not have direct cross-account access to the target repository. A same-account pull in ANY region does not need this flag: the ECR client is built for the image URI's own region, so crossing a region costs nothing extra (issue #2536).")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--keep-running", "Don't docker rm -f the user containers on task exit (network + sidecar are still torn down). Use when you want to docker exec into a stopped container for post-mortems.").default(false)).addOption(new Option("--detach", "Start the containers in the background and exit (skip log streaming + auto teardown). Useful in CI smoke tests; caller manages container lifecycle.").default(false)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt references to same-stack AWS::ECR::Repository resources with the deployed URI. Off by default — only the AWS pseudo-parameter tier (${AWS::AccountId} / ${AWS::Region}) is resolved without this flag.").default(false)).addOption(new Option("--from-cfn-stack [cfn-stack-name]", "Read a deployed CloudFormation stack via ListStackResources and substitute Ref / Fn::ImportValue in container env vars / secrets / image URIs with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (`cdk deploy`). Bare form uses the cdkd stack name; pass an explicit value when the CFn stack name differs. Mutually exclusive with --from-state. Fn::GetAtt is warn-and-dropped in v1 (CFn ListStackResources does not return per-attribute values).")).addOption(new Option("--stack-region <region>", "Region of the state record to read. Used with --from-state when the same stack name has state in multiple regions, and with --from-cfn-stack as the CFn client region (cdkd does not have a separate --cfn-stack-region flag).").argParser(parseStackRegion)).action(withErrorHandling(localRunTaskCommand));
|
|
92009
92546
|
[
|
|
@@ -92874,7 +93411,13 @@ async function resolveHostCredentialsForSigV4(options, resolved, loaded, region)
|
|
|
92874
93411
|
if (assumeRoleArn) try {
|
|
92875
93412
|
return await assumeAgentCoreExecutionRole(assumeRoleArn, region);
|
|
92876
93413
|
} catch (err) {
|
|
92877
|
-
logger.warn(
|
|
93414
|
+
logger.warn(assumeRoleFallbackWarning({
|
|
93415
|
+
assumeRoleArn,
|
|
93416
|
+
purpose: "--sigv4 signing",
|
|
93417
|
+
error: err,
|
|
93418
|
+
profile: options.profile,
|
|
93419
|
+
fallbackWithoutProfile: "shell credentials"
|
|
93420
|
+
}));
|
|
92878
93421
|
}
|
|
92879
93422
|
if (options.profile) {
|
|
92880
93423
|
const creds = await resolveProfileCredentials(options.profile);
|
|
@@ -92989,7 +93532,13 @@ async function resolveAgentCoreCodeImageFromS3(resolved, code, s3Source, options
|
|
|
92989
93532
|
if (assumeRoleArn) try {
|
|
92990
93533
|
credentials = await assumeAgentCoreExecutionRole(assumeRoleArn, region);
|
|
92991
93534
|
} catch (err) {
|
|
92992
|
-
logger.warn(
|
|
93535
|
+
logger.warn(assumeRoleFallbackWarning({
|
|
93536
|
+
assumeRoleArn,
|
|
93537
|
+
purpose: "the fromS3 bundle download",
|
|
93538
|
+
error: err,
|
|
93539
|
+
profile: options.profile,
|
|
93540
|
+
fallbackWithoutProfile: "the default credentials"
|
|
93541
|
+
}));
|
|
92993
93542
|
}
|
|
92994
93543
|
const bundle = await downloadAndExtractS3Bundle(location, {
|
|
92995
93544
|
...region !== void 0 && { region },
|
|
@@ -93187,7 +93736,7 @@ async function applyAgentCoreCredentialEnv(dockerEnv, args) {
|
|
|
93187
93736
|
if (stsRegion) dockerEnv["AWS_REGION"] = stsRegion;
|
|
93188
93737
|
assumeSucceeded = true;
|
|
93189
93738
|
} catch (err) {
|
|
93190
|
-
logger.warn(`--assume-role: STS AssumeRole(${args.assumeRoleArn}) failed: ${err instanceof Error ? err.message : String(err)}. Falling back to the developer's shell credentials.`);
|
|
93739
|
+
logger.warn(`--assume-role: STS AssumeRole(${displayIdent(args.assumeRoleArn, { maxCodePoints: 612 })}) failed: ${displaySafe(err instanceof Error ? err.message : String(err))}. Falling back to the developer's shell credentials.`);
|
|
93191
93740
|
}
|
|
93192
93741
|
}
|
|
93193
93742
|
if (!assumeSucceeded) {
|
|
@@ -93200,6 +93749,53 @@ async function applyAgentCoreCredentialEnv(dockerEnv, args) {
|
|
|
93200
93749
|
}
|
|
93201
93750
|
}
|
|
93202
93751
|
/**
|
|
93752
|
+
* The `--assume-role: STS AssumeRole(...) failed` warning, for the two sites
|
|
93753
|
+
* that emit the FALLBACK form of it — the `--sigv4` signing path and the fromS3 bundle download.
|
|
93754
|
+
*
|
|
93755
|
+
* ONE builder rather than two near-identical template literals, and that is
|
|
93756
|
+
* go-to-k/cdkd#3390's test review rather than tidiness. The two were twins, and
|
|
93757
|
+
* a twin is where a sanitizing rule goes half-applied: dropping `displayIdent`
|
|
93758
|
+
* from the fromS3 copy alone reddened only the source-shape fence, because the
|
|
93759
|
+
* `--sigv4` copy was the one with a behavioural case. As one PURE function both
|
|
93760
|
+
* sites inherit whatever this is tested to do, and there is one subject to test
|
|
93761
|
+
* rather than a second one somebody has to remember exists.
|
|
93762
|
+
*
|
|
93763
|
+
* TWO untrusted values, and both take `displayIdent` (issue
|
|
93764
|
+
* [#3377](https://github.com/go-to-k/cdkd/issues/3377) for the first, this PR's
|
|
93765
|
+
* security review for the second):
|
|
93766
|
+
*
|
|
93767
|
+
* - `profile` is user-supplied argv, and `ConsoleLogger.formatMessage`
|
|
93768
|
+
* sanitizes only a call's EXTRA ARGS, never the message itself — so the
|
|
93769
|
+
* message text is the last place it can be made safe. A MESSAGE, not a
|
|
93770
|
+
* command to paste, so `displayIdent` and not `isPasteableIdent`;
|
|
93771
|
+
* `resolveProfileCredentials` in `local-start-api.ts` is the site that needs
|
|
93772
|
+
* both spellings and shows the difference.
|
|
93773
|
+
* - `assumeRoleArn` is the WIDER boundary of the two, which is why it is here
|
|
93774
|
+
* at all: nothing validates it before `assumeAgentCoreExecutionRole` (whose
|
|
93775
|
+
* own throw lands in the very `catch` that calls this), and
|
|
93776
|
+
* `resolveAssumeRoleArn` can source it from a DEPLOYED STATE RECORD rather
|
|
93777
|
+
* than from argv. Every character of a legitimate ARN is in `PLAIN_IDENT`, so
|
|
93778
|
+
* it renders bare exactly as before. cdk-local's twin of this line flattens
|
|
93779
|
+
* the same value for the same reason; cdkd had nothing.
|
|
93780
|
+
*
|
|
93781
|
+
* `error` takes `displaySafe` — the DENYLIST, since an SDK error message is
|
|
93782
|
+
* free-form text rather than an identifier, which is the line
|
|
93783
|
+
* `display-safe.ts`'s header draws. The first cut stringified it and sanitized
|
|
93784
|
+
* NOTHING, and go-to-k/cdkd#3390's round-2 security review showed that was the
|
|
93785
|
+
* hole the other two guards leave open: STS answers an unparseable RoleArn with
|
|
93786
|
+
* a `ValidationError` that ECHOES THE SUBMITTED VALUE VERBATIM, so the
|
|
93787
|
+
* attacker-controlled ARN comes back through the error string and lands on the
|
|
93788
|
+
* same line the `displayIdent` two expressions to its left was added to
|
|
93789
|
+
* protect. A C1 byte, `U+0085`, `U+2028` and the bidi overrides all survive an
|
|
93790
|
+
* XML 1.0 response body. `formatError` sanitizes a `cause` for exactly this
|
|
93791
|
+
* reason and cannot help here, because this is a logger MESSAGE and
|
|
93792
|
+
* `ConsoleLogger.formatMessage` sanitizes only a call's EXTRA ARGS.
|
|
93793
|
+
*/
|
|
93794
|
+
function assumeRoleFallbackWarning(args) {
|
|
93795
|
+
const fallback = args.profile ? `--profile ${displayIdent(args.profile)}` : args.fallbackWithoutProfile;
|
|
93796
|
+
return `--assume-role: STS AssumeRole(${displayIdent(args.assumeRoleArn, { maxCodePoints: 612 })}) failed for ${args.purpose}: ${displaySafe(args.error instanceof Error ? args.error.message : String(args.error))}. Falling back to ${fallback}.`;
|
|
93797
|
+
}
|
|
93798
|
+
/**
|
|
93203
93799
|
* Resolve the role ARN to assume, honoring the three `--assume-role` forms.
|
|
93204
93800
|
* Bare `--assume-role` uses the runtime's literal `RoleArn`; when that is an
|
|
93205
93801
|
* intrinsic (the common L2 case — `Fn::GetAtt` to an auto-created role) it
|
|
@@ -93213,7 +93809,7 @@ function resolveAssumeRoleArn(options, resolved, loaded) {
|
|
|
93213
93809
|
if (loaded) {
|
|
93214
93810
|
const fromState = resolveExecutionRoleArnFromState(loaded, resolved.logicalId, "RoleArn");
|
|
93215
93811
|
if (fromState) {
|
|
93216
|
-
getLogger().debug(`--assume-role: resolved RoleArn from state: ${fromState}`);
|
|
93812
|
+
getLogger().debug(`--assume-role: resolved RoleArn from state: ${displayIdent(fromState)}`);
|
|
93217
93813
|
return fromState;
|
|
93218
93814
|
}
|
|
93219
93815
|
}
|
|
@@ -93358,7 +93954,7 @@ async function assumeAgentCoreExecutionRole(roleArn, region) {
|
|
|
93358
93954
|
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-invoke-agentcore-${Date.now()}`,
|
|
93359
93955
|
DurationSeconds: 3600
|
|
93360
93956
|
}))).Credentials;
|
|
93361
|
-
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new CdkdError(`AssumeRole(${roleArn}) returned no usable credentials.`, "LOCAL_INVOKE_AGENTCORE_ASSUMEROLE_NO_CREDS");
|
|
93957
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new CdkdError(`AssumeRole(${displayIdent(roleArn, { maxCodePoints: 612 })}) returned no usable credentials.`, "LOCAL_INVOKE_AGENTCORE_ASSUMEROLE_NO_CREDS");
|
|
93362
93958
|
return {
|
|
93363
93959
|
accessKeyId: creds.AccessKeyId,
|
|
93364
93960
|
secretAccessKey: creds.SecretAccessKey,
|
|
@@ -93971,7 +94567,7 @@ async function localInvokeCommand(target, options) {
|
|
|
93971
94567
|
const arn = resolveExecutionRoleArnFromState(stateForRoleHint, lambda.logicalId);
|
|
93972
94568
|
if (arn) {
|
|
93973
94569
|
resolvedAssumeRoleArn = arn;
|
|
93974
|
-
logger.info(`--assume-role: auto-resolved execution role from cdkd state: ${arn}`);
|
|
94570
|
+
logger.info(`--assume-role: auto-resolved execution role from cdkd state: ${displayIdent(arn, { maxCodePoints: 612 })}`);
|
|
93975
94571
|
} else logger.warn(`--assume-role: could not resolve the execution role ARN from cdkd state for '${lambda.logicalId}'. Pass the ARN explicitly: --assume-role <arn>. Falling back to the developer's shell credentials.`);
|
|
93976
94572
|
}
|
|
93977
94573
|
} else maybeSuggestAssumeRole(options, stateForRoleHint, lambda.logicalId);
|
|
@@ -94439,7 +95035,7 @@ async function applyLambdaCredentialEnv(dockerEnv, args) {
|
|
|
94439
95035
|
assumeSucceeded = true;
|
|
94440
95036
|
} catch (err) {
|
|
94441
95037
|
const reason = err instanceof Error ? err.message : String(err);
|
|
94442
|
-
logger.warn(`--assume-role: STS AssumeRole(${args.assumeRoleArn}) failed: ${reason}. Falling back to the developer's shell credentials.`);
|
|
95038
|
+
logger.warn(`--assume-role: STS AssumeRole(${displayIdent(args.assumeRoleArn, { maxCodePoints: 612 })}) failed: ${displaySafe(reason)}. Falling back to the developer's shell credentials.`);
|
|
94443
95039
|
}
|
|
94444
95040
|
}
|
|
94445
95041
|
if (!assumeSucceeded) {
|
|
@@ -94477,7 +95073,7 @@ async function assumeLambdaExecutionRole(roleArn, region) {
|
|
|
94477
95073
|
RoleSessionName: `cdkd-local-invoke-${Date.now()}`,
|
|
94478
95074
|
DurationSeconds: 3600
|
|
94479
95075
|
}))).Credentials;
|
|
94480
|
-
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
95076
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${displayIdent(roleArn, { maxCodePoints: 612 })}) returned no usable credentials.`);
|
|
94481
95077
|
return {
|
|
94482
95078
|
accessKeyId: creds.AccessKeyId,
|
|
94483
95079
|
secretAccessKey: creds.SecretAccessKey,
|
|
@@ -94598,7 +95194,7 @@ function materializeInlineCode(handler, source, fileExtension) {
|
|
|
94598
95194
|
function suggestAssumeRoleFromState(state, logicalId) {
|
|
94599
95195
|
const logger = getLogger();
|
|
94600
95196
|
const roleArn = resolveExecutionRoleArnFromState(state, logicalId);
|
|
94601
|
-
if (roleArn) logger.info(`Hint: the deployed function uses execution role ${roleArn}. Re-run with --assume-role to invoke under the deployed function's narrow permissions.`);
|
|
95197
|
+
if (roleArn) logger.info(`Hint: the deployed function uses execution role ${displayIdent(roleArn, { maxCodePoints: 612 })}. Re-run with --assume-role to invoke under the deployed function's narrow permissions.`);
|
|
94602
95198
|
}
|
|
94603
95199
|
/**
|
|
94604
95200
|
* Emit the "re-run with `--assume-role`" hint, if it applies.
|
|
@@ -94758,4 +95354,4 @@ function buildProgram() {
|
|
|
94758
95354
|
|
|
94759
95355
|
//#endregion
|
|
94760
95356
|
export { buildProgram };
|
|
94761
|
-
//# sourceMappingURL=program-
|
|
95357
|
+
//# sourceMappingURL=program-C1iVGcxJ.js.map
|