@go-to-k/cdkd 0.284.21 → 0.284.23
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-D5G53CUp.js → asg-provider-Cucl2K22.js} +2 -2
- package/dist/{asg-provider-D5G53CUp.js.map → asg-provider-Cucl2K22.js.map} +1 -1
- package/dist/cli.js +5 -4
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine--N_xnvjI.js → deploy-engine-HfFU96oJ.js} +579 -38
- package/dist/deploy-engine-HfFU96oJ.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine--N_xnvjI.js.map +0 -1
|
@@ -11106,6 +11106,29 @@ function dynamicReferenceTokens(value) {
|
|
|
11106
11106
|
return value.match(DYNAMIC_REFERENCE_TOKEN_SCAN) ?? [];
|
|
11107
11107
|
}
|
|
11108
11108
|
/**
|
|
11109
|
+
* Where each complete `{{resolve:...}}` token sits in the string, as
|
|
11110
|
+
* `[start, end)` offsets. The OFFSETS are what {@link dynamicReferenceTokens}
|
|
11111
|
+
* cannot give, and the value scan needs them to decide whether a needle match
|
|
11112
|
+
* lies inside a reference or merely beside one.
|
|
11113
|
+
*
|
|
11114
|
+
* `lastIndex` is reset before `matchAll`, and that is load-bearing rather than
|
|
11115
|
+
* defensive. `String.prototype.matchAll` does not MUTATE the pattern's
|
|
11116
|
+
* `lastIndex` — it clones — but it SEEDS the clone from it, so a caller that
|
|
11117
|
+
* left the shared constant dirty (the constant's own doc forbids `.exec` /
|
|
11118
|
+
* `.test` on it for exactly this reason) would make this function skip every
|
|
11119
|
+
* span before that offset, silently restoring the splice this offsets are used
|
|
11120
|
+
* to prevent. Measured, not assumed.
|
|
11121
|
+
*/
|
|
11122
|
+
function dynamicReferenceSpans(value) {
|
|
11123
|
+
DYNAMIC_REFERENCE_TOKEN_SCAN.lastIndex = 0;
|
|
11124
|
+
const spans = [];
|
|
11125
|
+
for (const match of value.matchAll(DYNAMIC_REFERENCE_TOKEN_SCAN)) spans.push({
|
|
11126
|
+
start: match.index,
|
|
11127
|
+
end: match.index + match[0].length
|
|
11128
|
+
});
|
|
11129
|
+
return spans;
|
|
11130
|
+
}
|
|
11131
|
+
/**
|
|
11109
11132
|
* Does this MIXED leaf embed a reference that may be PUBLIC config?
|
|
11110
11133
|
*
|
|
11111
11134
|
* A plain `{{resolve:ssm:...}}` is classified by the parameter's TYPE, not by
|
|
@@ -11283,6 +11306,92 @@ function redactSecretsForState(bag, secrets, source, rules = TEMPLATE_DERIVED_RU
|
|
|
11283
11306
|
}
|
|
11284
11307
|
const regex = buildNeedleRegex(secrets.keys());
|
|
11285
11308
|
const wholeValueExpr = (s) => s === "" ? void 0 : secrets.get(s);
|
|
11309
|
+
/**
|
|
11310
|
+
* The SUBSTRING arm for a leaf the resolver substituted INTO rather than
|
|
11311
|
+
* replaced. ONE rule over the WHOLE leaf (issue
|
|
11312
|
+
* [#1935](https://github.com/go-to-k/cdkd/issues/1935)):
|
|
11313
|
+
*
|
|
11314
|
+
* > replace every recorded-plaintext match EXCEPT one that lies STRICTLY
|
|
11315
|
+
* > INSIDE a complete `{{resolve:...}}` span.
|
|
11316
|
+
*
|
|
11317
|
+
* "Strictly inside" means contained by a span and SHORTER than it. The four
|
|
11318
|
+
* positions a match can take, and why each lands where it does:
|
|
11319
|
+
*
|
|
11320
|
+
* - **strictly inside a span** -> KEPT. This is the defect: a plaintext that
|
|
11321
|
+
* happens to occur inside a token's own TEXT was spliced into the
|
|
11322
|
+
* reference. Deploy 1 persists
|
|
11323
|
+
* `jdbc://appdb:{{resolve:secretsmanager:appdb/creds:SecretString:password}}@host`;
|
|
11324
|
+
* deploy 2 records an ssm SecureString whose plaintext is `appdb`; the walk
|
|
11325
|
+
* wrote `{{resolve:secretsmanager:{{resolve:ssm:/app/dbname}}/creds:...}}`.
|
|
11326
|
+
* `resolveReplayProps` scans with `([^}]+)`, which stops at the FIRST `}`,
|
|
11327
|
+
* so the replay asks Secrets Manager for the secret id
|
|
11328
|
+
* `{{resolve:ssm:/app/dbname` — rollback blocked, or garbage applied to a
|
|
11329
|
+
* live resource. `cdkd scrub` writes the same wreckage into `properties`
|
|
11330
|
+
* and `observedProperties`.
|
|
11331
|
+
* - **coextensive with a span** -> REPLACED. A secret whose resolved
|
|
11332
|
+
* PLAINTEXT is itself a `{{resolve:...}}` string (issue #1917), embedded in
|
|
11333
|
+
* a larger leaf. This is why "mask only OUTSIDE the spans" is wrong on its
|
|
11334
|
+
* own: that plaintext IS a span, so span-skipping would stop redacting it
|
|
11335
|
+
* and trade a mangling bug for a disclosure.
|
|
11336
|
+
* - **containing or straddling a span** -> REPLACED. A recorded plaintext
|
|
11337
|
+
* that embeds a whole reference plus surrounding text. Nothing is spliced,
|
|
11338
|
+
* because the whole reference is consumed by the replacement. An earlier
|
|
11339
|
+
* revision of this fix expressed the rule as TWO rules — replace a span
|
|
11340
|
+
* that is a recorded plaintext, value-scan the text between spans — and
|
|
11341
|
+
* that form DROPPED this case at both ends (it is neither a whole span nor
|
|
11342
|
+
* contained in the text between spans), persisting the plaintext in the
|
|
11343
|
+
* clear where the pre-fix code had redacted it. A REGRESSION, caught by the
|
|
11344
|
+
* security review, and the reason the rule is one predicate over the whole
|
|
11345
|
+
* leaf rather than a split.
|
|
11346
|
+
* - **disjoint from every span** -> REPLACED. The ordinary embedded secret.
|
|
11347
|
+
*
|
|
11348
|
+
* Scanning the WHOLE leaf in ONE pass is also what preserves what needle
|
|
11349
|
+
* PRECEDENCE there is. {@link buildNeedleRegex} sorts alternatives
|
|
11350
|
+
* longest-first, which decides only between alternatives matching at the SAME
|
|
11351
|
+
* offset; the scan itself is LEFTMOST-first, so a shorter secret starting
|
|
11352
|
+
* EARLIER still wins and the tail of the longer one survives in the clear
|
|
11353
|
+
* (`zzABCDEFzz` with needles `ABCD` / `BCDEF` leaves `EF`). That is regex
|
|
11354
|
+
* semantics, identical before and after this change, and is not something
|
|
11355
|
+
* this rule claims to fix.
|
|
11356
|
+
*
|
|
11357
|
+
* What the single pass DOES restore is the same-offset ordering across a span
|
|
11358
|
+
* boundary. The two-rule form scanned each BETWEEN-span stretch separately,
|
|
11359
|
+
* so a long straddling needle was never even a candidate and a short one
|
|
11360
|
+
* starting later in the tail won by default — the leaf took the WRONG
|
|
11361
|
+
* expression, which the replay then re-resolves and applies (the issue #1910
|
|
11362
|
+
* class).
|
|
11363
|
+
*
|
|
11364
|
+
* TWO KNOWN RESIDUALS around a STRAY `{{resolve:` opener, both pinned by
|
|
11365
|
+
* tests rather than left as prose, and they fail in OPPOSITE directions
|
|
11366
|
+
* because the span grammar is greedy `[^}]+` (deliberately — it is the
|
|
11367
|
+
* resolver's own spelling, unified by issue #1936):
|
|
11368
|
+
*
|
|
11369
|
+
* - **no later `}}` in the leaf** -> no span, so a needle after the opener is
|
|
11370
|
+
* REPLACED and the result reads as a reference to a bogus secret id.
|
|
11371
|
+
* Identical to the pre-fix code. Refusing to redact there would leave
|
|
11372
|
+
* PLAINTEXT behind two characters any string can contain.
|
|
11373
|
+
* - **a later `}}` anywhere in the leaf** -> the opener and that `}}` form
|
|
11374
|
+
* ONE span swallowing everything between them, so a needle in that region
|
|
11375
|
+
* is KEPT — the only shape where this rule redacts LESS than the code it
|
|
11376
|
+
* replaced. Narrow but real, and the reachable carrier is named rather than
|
|
11377
|
+
* waved at: the resolver shares this grammar, so such a leaf could not have
|
|
11378
|
+
* resolved on the deploy path, which leaves an `observedProperties`
|
|
11379
|
+
* READBACK (arbitrary text from AWS) as the way one arrives.
|
|
11380
|
+
*
|
|
11381
|
+
* Narrowing the span pattern here would close the second and open two worse
|
|
11382
|
+
* holes: it would re-fork the one grammar issue #1936 unified, and it would
|
|
11383
|
+
* make an ALREADY-MANGLED legacy leaf parse differently and be spliced again,
|
|
11384
|
+
* contradicting this change's own "not repaired, not made worse" property.
|
|
11385
|
+
* So the residual is documented, not fixed.
|
|
11386
|
+
*/
|
|
11387
|
+
const scanLeaf = (value, needles) => {
|
|
11388
|
+
const spans = isDynamicReferenceString(value) ? dynamicReferenceSpans(value) : [];
|
|
11389
|
+
return value.replace(needles, (match, offset) => {
|
|
11390
|
+
const end = offset + match.length;
|
|
11391
|
+
if (spans.some((span) => span.start <= offset && end <= span.end && (span.start !== offset || span.end !== end))) return match;
|
|
11392
|
+
return secrets.get(match) ?? "***";
|
|
11393
|
+
});
|
|
11394
|
+
};
|
|
11286
11395
|
const walk = (value) => {
|
|
11287
11396
|
if (typeof value === "string") {
|
|
11288
11397
|
const whole = wholeValueExpr(value);
|
|
@@ -11291,8 +11400,7 @@ function redactSecretsForState(bag, secrets, source, rules = TEMPLATE_DERIVED_RU
|
|
|
11291
11400
|
if (!regex) return value;
|
|
11292
11401
|
regex.lastIndex = 0;
|
|
11293
11402
|
if (!regex.test(value)) return value;
|
|
11294
|
-
|
|
11295
|
-
return value.replace(regex, (m) => secrets.get(m) ?? "***");
|
|
11403
|
+
return scanLeaf(value, regex);
|
|
11296
11404
|
}
|
|
11297
11405
|
if (Array.isArray(value)) return value.map(walk);
|
|
11298
11406
|
if (value !== null && typeof value === "object") {
|
|
@@ -16752,7 +16860,7 @@ var CloudControlProvider = class {
|
|
|
16752
16860
|
if (context?.finalSnapshotIdentifier !== void 0) throw new ProvisioningError(`${logicalId} (${resourceType}) requires a final snapshot (DeletionPolicy: Snapshot), but the Cloud Control API delete route has no final-snapshot parameter. Re-run with --skip-final-snapshot after snapshotting manually, or retain the resource.`, resourceType, logicalId, physicalId);
|
|
16753
16861
|
if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
|
|
16754
16862
|
this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
|
|
16755
|
-
const { ASGProvider } = await import("./asg-provider-
|
|
16863
|
+
const { ASGProvider } = await import("./asg-provider-Cucl2K22.js").then((n) => n.n);
|
|
16756
16864
|
return await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context);
|
|
16757
16865
|
}
|
|
16758
16866
|
const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
|
|
@@ -24119,7 +24227,7 @@ async function replayRollback(operations, stateResources, stackName, ctx, option
|
|
|
24119
24227
|
eventType: "ROLLBACK_STARTED",
|
|
24120
24228
|
stackName
|
|
24121
24229
|
});
|
|
24122
|
-
const resolver = new
|
|
24230
|
+
const resolver = new ReplayResolvers(ctx.region);
|
|
24123
24231
|
const { createOps, otherOps } = partitionOps(operations);
|
|
24124
24232
|
for (let i = otherOps.length - 1; i >= 0; i--) {
|
|
24125
24233
|
if (options.isInterrupted?.()) {
|
|
@@ -24184,6 +24292,21 @@ async function replayRollback(operations, stateResources, stackName, ctx, option
|
|
|
24184
24292
|
* Cognito `client_secret`). Rollback is synth-free, so re-resolve straight from
|
|
24185
24293
|
* the expression string here.
|
|
24186
24294
|
*
|
|
24295
|
+
* BOTH SIDES OF A DIFF ARE CLASSIFIED, not just the bag that is written, and
|
|
24296
|
+
* that is deliberate (issue #2057 review). The `revert` / `--revert-failed`
|
|
24297
|
+
* arms call this twice — once for the desired bag and once for the CURRENT /
|
|
24298
|
+
* ATTEMPTED one, which only becomes the provider's `previousProperties`. Two
|
|
24299
|
+
* things make a wrong-region value there consequential rather than cosmetic:
|
|
24300
|
+
* a patch-based provider computes its patch previous-vs-desired, so a wrong
|
|
24301
|
+
* previous side can emit a wrong patch or, when both sides carry the same
|
|
24302
|
+
* expression and resolve to the same wrong value, silently compute a NO-OP and
|
|
24303
|
+
* skip the revert entirely; and every resolved plaintext lands in the SHARED
|
|
24304
|
+
* per-op `secrets` map, which is the redaction needle for the state record this
|
|
24305
|
+
* op persists, so a foreign-region plaintext mis-redacts that record. In
|
|
24306
|
+
* practice both bags carry the SAME expression (state redacts them identically),
|
|
24307
|
+
* so scoping the refusal to the written bag would buy a rare case at the cost of
|
|
24308
|
+
* a rule nobody could apply by reading one call site.
|
|
24309
|
+
*
|
|
24187
24310
|
* Records each `plaintext -> expression` into `secrets` so the caller can redact
|
|
24188
24311
|
* the persisted state record back to the expression — the same
|
|
24189
24312
|
* resolve-for-provider + redact-for-state split the deploy engine applies at its
|
|
@@ -24197,28 +24320,365 @@ async function replayRollback(operations, stateResources, stackName, ctx, option
|
|
|
24197
24320
|
* `StringList` parameter is public config, stored resolved, and never appears
|
|
24198
24321
|
* as an expression in the journal.
|
|
24199
24322
|
*/
|
|
24200
|
-
async function resolveReplayProps(props,
|
|
24323
|
+
async function resolveReplayProps(props, resolvers, secrets, execCtx, logicalId) {
|
|
24201
24324
|
if (props === void 0) return void 0;
|
|
24202
|
-
const
|
|
24325
|
+
const resolverContext = {
|
|
24203
24326
|
template: { Resources: {} },
|
|
24204
24327
|
resources: {},
|
|
24205
24328
|
recordedSecretValues: secrets
|
|
24206
24329
|
};
|
|
24207
|
-
const walk = async (v) => {
|
|
24208
|
-
if (typeof v === "string")
|
|
24330
|
+
const walk = async (v, path) => {
|
|
24331
|
+
if (typeof v === "string") {
|
|
24332
|
+
if (!v.includes("{{resolve:")) return v;
|
|
24333
|
+
return await resolveLeafByRegion(v, path, logicalId, execCtx, resolvers, resolverContext);
|
|
24334
|
+
}
|
|
24209
24335
|
if (Array.isArray(v)) {
|
|
24210
24336
|
const out = new Array(v.length);
|
|
24211
|
-
for (let i = 0; i < v.length; i++) out[i] = await walk(v[i]);
|
|
24337
|
+
for (let i = 0; i < v.length; i++) out[i] = await walk(v[i], `${path}[${i}]`);
|
|
24212
24338
|
return out;
|
|
24213
24339
|
}
|
|
24214
24340
|
if (v !== null && typeof v === "object") {
|
|
24215
24341
|
const out = {};
|
|
24216
|
-
for (const [k, val] of Object.entries(v)) out[k] = await walk(val);
|
|
24342
|
+
for (const [k, val] of Object.entries(v)) out[k] = await walk(val, path === "" ? k : `${path}.${k}`);
|
|
24217
24343
|
return out;
|
|
24218
24344
|
}
|
|
24219
24345
|
return v;
|
|
24220
24346
|
};
|
|
24221
|
-
return await walk(props);
|
|
24347
|
+
return await walk(props, "");
|
|
24348
|
+
}
|
|
24349
|
+
/**
|
|
24350
|
+
* The `{{resolve:<service>:...}}` families whose value can be a SECRET, and
|
|
24351
|
+
* therefore the only ones the region question below is asked about: every
|
|
24352
|
+
* `secretsmanager` reference by spelling, and every `ssm` one, which is secret
|
|
24353
|
+
* exactly when its parameter is a `SecureString` (issue #1901).
|
|
24354
|
+
*
|
|
24355
|
+
* Every OTHER service is `local` because cdkd cannot resolve it at all, NOT
|
|
24356
|
+
* because it is public. `ssm-secure` is the live example and is emphatically
|
|
24357
|
+
* not public: `resolveDynamicReferences` has no arm for it, so the literal
|
|
24358
|
+
* token is passed through to AWS and CloudFormation resolves it SERVER-side.
|
|
24359
|
+
* cdkd never holds its value, so there is no region for cdkd to get wrong —
|
|
24360
|
+
* which is the only reason it can be waved through here.
|
|
24361
|
+
*/
|
|
24362
|
+
const REPLAY_SECRET_SERVICES = /* @__PURE__ */ new Set(["secretsmanager", "ssm"]);
|
|
24363
|
+
/**
|
|
24364
|
+
* Split a `{{resolve:secretsmanager:...}}` inner body into its SECRET_ID.
|
|
24365
|
+
*
|
|
24366
|
+
* Mirrors `IntrinsicFunctionResolver.resolveSecretsManagerReference`'s own
|
|
24367
|
+
* split — including the END-ANCHORED whole-secret form — because a secret ID
|
|
24368
|
+
* may legitimately contain colons (an ARN always does), so `split(':')[1]` is
|
|
24369
|
+
* wrong for exactly the shape this file cares about most.
|
|
24370
|
+
*/
|
|
24371
|
+
function secretsManagerSecretId(inner) {
|
|
24372
|
+
const afterService = inner.substring(15);
|
|
24373
|
+
let stringIdx = afterService.indexOf(":SecretString:");
|
|
24374
|
+
let binaryIdx = afterService.indexOf(":SecretBinary:");
|
|
24375
|
+
if (stringIdx < 0 && afterService.endsWith(":SecretString")) stringIdx = afterService.length - 13;
|
|
24376
|
+
if (binaryIdx < 0 && afterService.endsWith(":SecretBinary")) binaryIdx = afterService.length - 13;
|
|
24377
|
+
const delimiterIdx = stringIdx >= 0 && binaryIdx >= 0 ? Math.min(stringIdx, binaryIdx) : stringIdx >= 0 ? stringIdx : binaryIdx;
|
|
24378
|
+
return delimiterIdx >= 0 ? afterService.substring(0, delimiterIdx) : afterService;
|
|
24379
|
+
}
|
|
24380
|
+
/**
|
|
24381
|
+
* The parameter name an `{{resolve:ssm:...}}` reference asks for — byte-for-byte
|
|
24382
|
+
* what `IntrinsicFunctionResolver.resolveSSMReference` passes as `GetParameter`'s
|
|
24383
|
+
* `Name`, which is `parts.slice(1).join(':')` on the colon-split inner body.
|
|
24384
|
+
*
|
|
24385
|
+
* The whole remainder, deliberately, with NOTHING stripped:
|
|
24386
|
+
*
|
|
24387
|
+
* - An SSM dynamic reference CAN name a full ARN. The resolver joins the tail
|
|
24388
|
+
* back together, so `{{resolve:ssm:arn:aws:ssm:us-west-2:111122223333:parameter/db/pw}}`
|
|
24389
|
+
* reaches AWS as that ARN. A `split(':')[1]` here would yield the literal
|
|
24390
|
+
* `'arn'` — a parameter that does not exist — and then report the reference
|
|
24391
|
+
* as region-LESS and refuse it, which is the guess-in-the-other-direction the
|
|
24392
|
+
* `named-region` arm exists to prevent.
|
|
24393
|
+
* - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
|
|
24394
|
+
* (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
|
|
24395
|
+
* a different thing in the refusal message than the one that would be read.
|
|
24396
|
+
*/
|
|
24397
|
+
function ssmParameterName(inner) {
|
|
24398
|
+
return inner.substring(4);
|
|
24399
|
+
}
|
|
24400
|
+
/**
|
|
24401
|
+
* The region an ARN names, or `undefined` for anything that is not an ARN with
|
|
24402
|
+
* a populated region field (`arn:<partition>:<service>:<region>:...`).
|
|
24403
|
+
*/
|
|
24404
|
+
function arnRegion(secretId) {
|
|
24405
|
+
if (!secretId.startsWith("arn:")) return void 0;
|
|
24406
|
+
const region = secretId.split(":")[3];
|
|
24407
|
+
return region ? region : void 0;
|
|
24408
|
+
}
|
|
24409
|
+
/**
|
|
24410
|
+
* The producer regions a stack's persisted cross-stack reads name, for
|
|
24411
|
+
* {@link RollbackExecutorContext.importedProducerRegions} (issue #2057).
|
|
24412
|
+
*
|
|
24413
|
+
* Both record kinds count, and for the same reason: each one is a value this
|
|
24414
|
+
* stack read out of ANOTHER region's state, so each one is a way a
|
|
24415
|
+
* foreign-region `{{resolve:...}}` expression can have reached this stack's own
|
|
24416
|
+
* record. `imports` is the strong `Fn::ImportValue` edge; `outputReads` is the
|
|
24417
|
+
* weak `Fn::GetStackOutput` one (schema v8), which is the EASIER of the two to
|
|
24418
|
+
* point across a region boundary because the reference carries its own
|
|
24419
|
+
* `Region` argument.
|
|
24420
|
+
*
|
|
24421
|
+
* Deduplicated case-insensitively, keeping each region's first-recorded
|
|
24422
|
+
* spelling so the refusal message echoes what the user will see in
|
|
24423
|
+
* `state.json`. The consumer's own region is deliberately NOT filtered here —
|
|
24424
|
+
* {@link classifyReplaySecretRegion} does that, because it is the one that
|
|
24425
|
+
* knows which region is asking.
|
|
24426
|
+
*
|
|
24427
|
+
* Exported so the two `RollbackExecutorContext` construction sites derive the
|
|
24428
|
+
* list identically — `cdkd rollback` from the state it loaded, and
|
|
24429
|
+
* `DeployEngine.rollbackExecutorContext` from `crossStackReadsForPartialSave`,
|
|
24430
|
+
* which unions that snapshot with the reads the failing deploy itself made.
|
|
24431
|
+
*/
|
|
24432
|
+
function producerRegionsFromState(state) {
|
|
24433
|
+
const seen = /* @__PURE__ */ new Set();
|
|
24434
|
+
const regions = [];
|
|
24435
|
+
for (const entry of [...state.imports ?? [], ...state.outputReads ?? []]) {
|
|
24436
|
+
const canonical = canonicalizeRegion(entry.sourceRegion);
|
|
24437
|
+
if (!canonical || seen.has(canonical)) continue;
|
|
24438
|
+
seen.add(canonical);
|
|
24439
|
+
regions.push(entry.sourceRegion);
|
|
24440
|
+
}
|
|
24441
|
+
return regions;
|
|
24442
|
+
}
|
|
24443
|
+
/**
|
|
24444
|
+
* Decide which region must answer for a single `{{resolve:...}}` expression a
|
|
24445
|
+
* rollback replay is about to re-resolve — issue
|
|
24446
|
+
* [#2057](https://github.com/go-to-k/cdkd/issues/2057).
|
|
24447
|
+
*
|
|
24448
|
+
* WHY A REPLAY CAN BE HOLDING A FOREIGN REGION'S EXPRESSION AT ALL. Since
|
|
24449
|
+
* issue #1934 a cross-stack consumer re-resolves a redacted producer value in
|
|
24450
|
+
* the PRODUCER's region (`reresolveCrossStackValue` /
|
|
24451
|
+
* `resolverForProducerRegion`) — correct, because a Secrets Manager secret or
|
|
24452
|
+
* an SSM `SecureString` of the same NAME in two regions is two independent
|
|
24453
|
+
* values. The plaintext is then recorded into the CONSUMER's
|
|
24454
|
+
* `recordedSecretValues`, so the consumer's `state.json` (and from there the
|
|
24455
|
+
* rollback journal) persists the PRODUCER's spelling of the expression. That is
|
|
24456
|
+
* the right thing to persist, and it is region-less: the reader cannot tell
|
|
24457
|
+
* from the string which region produced it.
|
|
24458
|
+
*
|
|
24459
|
+
* The replay rebuilds its resolver from the CONSUMER's region alone, so
|
|
24460
|
+
* re-resolving that expression locally answers from a same-named secret in the
|
|
24461
|
+
* wrong region and writes it to a LIVE resource. Silent, and on the recovery
|
|
24462
|
+
* path. The rule applied here is the family's, from issue #1957: A NAMED REGION
|
|
24463
|
+
* BINDS; NEVER SUBSTITUTE A GUESS. The three verdicts are that one sentence:
|
|
24464
|
+
*
|
|
24465
|
+
* - **`named-region`** — the expression's SECRET_ID is an ARN, which names its
|
|
24466
|
+
* own region. The region is ESTABLISHED, so it binds: the caller resolves
|
|
24467
|
+
* through a resolver pinned to it ({@link ReplayResolvers.forRegion}) rather
|
|
24468
|
+
* than refusing. Refusing here would be the guess in the other direction.
|
|
24469
|
+
*
|
|
24470
|
+
* cdkd would otherwise get this wrong, which is why the arm exists at all:
|
|
24471
|
+
* `resolveSecretsManagerReference` builds its client from
|
|
24472
|
+
* `this.explicitRegion` and passes the ARN through as an opaque `SecretId`,
|
|
24473
|
+
* and `@aws-sdk/client-secrets-manager`'s endpoint ruleset has NO
|
|
24474
|
+
* ARN-derived endpoint rule (unlike, say, S3 access points), so a
|
|
24475
|
+
* foreign-region ARN is sent to the stack's own regional endpoint. What the
|
|
24476
|
+
* SERVICE then does with it is not something this repo can settle offline —
|
|
24477
|
+
* see the fixture note in
|
|
24478
|
+
* `tests/integration/rollback-cross-region-secret/README.md`. Pinning the
|
|
24479
|
+
* client to the ARN's region is correct either way: if Secrets Manager would
|
|
24480
|
+
* have refused the foreign ARN, this turns a hard failure into a correct
|
|
24481
|
+
* resolution; if it would have honoured it, this reaches the same value by
|
|
24482
|
+
* the documented route. Neither outcome is a regression.
|
|
24483
|
+
*
|
|
24484
|
+
* - **`ambiguous`** — the expression names no region (the plain name form) AND
|
|
24485
|
+
* this stack has a foreign producer region on record
|
|
24486
|
+
* ({@link RollbackExecutorContext.importedProducerRegions}). Nothing on hand
|
|
24487
|
+
* can establish the origin, so the replay refuses instead of guessing.
|
|
24488
|
+
*
|
|
24489
|
+
* KNOWN OVER-REFUSAL, accepted deliberately, and WIDER THAN THE SSM CASE
|
|
24490
|
+
* ALONE — state both, because the second one is the common shape:
|
|
24491
|
+
*
|
|
24492
|
+
* (a) Any NAME-FORM `secretsmanager` reference in a stack that has ANY
|
|
24493
|
+
* foreign producer region on record is refused, even when that secret is
|
|
24494
|
+
* the stack's own purely-local one and has nothing to do with the
|
|
24495
|
+
* cross-region read. The evidence is per-STACK, not per-reference, so one
|
|
24496
|
+
* cross-region export plus one ordinary
|
|
24497
|
+
* `{{resolve:secretsmanager:mysecret:SecretString:pw}}` is enough — and CDK's
|
|
24498
|
+
* `secretValueFromJson` emits exactly that name form, so this is the shape
|
|
24499
|
+
* most people will meet. It also persists: with the union the producer
|
|
24500
|
+
* region stays on record until the next SUCCESSFUL deploy. Per-reference
|
|
24501
|
+
* evidence is what would narrow it, and that needs the region recorded
|
|
24502
|
+
* ALONGSIDE the expression — the persisted-shape change issue #2057
|
|
24503
|
+
* deliberately deferred (its options 1 and 2). Until then the refusal is
|
|
24504
|
+
* loud, names the ARN spelling as the remedy, and is the fail-closed side
|
|
24505
|
+
* of a trade whose other side is a silent wrong-secret write.
|
|
24506
|
+
*
|
|
24507
|
+
* (b) An `ssm` reference is secret only when its parameter is a
|
|
24508
|
+
* `SecureString`, and this arm cannot tell. So a `{{resolve:ssm:/app/env}}`
|
|
24509
|
+
* naming a PUBLIC `String` that reached a persisted bag (issue #2036's
|
|
24510
|
+
* acknowledged over-redaction) is refused too. Narrowing it by
|
|
24511
|
+
* `isRecordedSecretExpression` was considered and REJECTED, and not because
|
|
24512
|
+
* the store is unreachable — it is imported by this very file. It is
|
|
24513
|
+
* unusable: `recordedSecretExpressions` is populated BY resolution, and in
|
|
24514
|
+
* the standalone `cdkd rollback` process nothing has resolved anything when
|
|
24515
|
+
* the first op is classified, so the store is empty and every `ssm` verdict
|
|
24516
|
+
* would come back "not secret" — turning the protection off for exactly the
|
|
24517
|
+
* SecureString case it exists for. Worse, once one op DID resolve a
|
|
24518
|
+
* reference the store would be warm for the next, so the verdict would
|
|
24519
|
+
* depend on OP ORDER. A resolve-the-type-first probe is unsound for the
|
|
24520
|
+
* same reason the whole issue exists: the TYPE is region-dependent (#1957),
|
|
24521
|
+
* so probing locally can report `String` for a name that is `SecureString`
|
|
24522
|
+
* in the producer's region and wave through the very write this refuses.
|
|
24523
|
+
* The residual is therefore a loud, actionable error on a narrow
|
|
24524
|
+
* intersection (an over-redacted public ssm reference AND a cross-region
|
|
24525
|
+
* read on record), which is the fail-closed side of the trade.
|
|
24526
|
+
*
|
|
24527
|
+
* - **`local`** — everything else, which is the overwhelmingly common case:
|
|
24528
|
+
* every non-secret service, every same-region ARN (the ordinary CDK
|
|
24529
|
+
* `secretValueFromJson` shape), and every name-form expression in a stack
|
|
24530
|
+
* with no foreign producer region recorded. Resolved exactly as before this
|
|
24531
|
+
* change.
|
|
24532
|
+
*
|
|
24533
|
+
* A same-region ARN answers `local` even when a foreign producer region IS on
|
|
24534
|
+
* record: the expression settles the question itself, so the weaker evidence
|
|
24535
|
+
* never gets consulted.
|
|
24536
|
+
*/
|
|
24537
|
+
function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
|
|
24538
|
+
const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
|
|
24539
|
+
if (inner === void 0) return { kind: "local" };
|
|
24540
|
+
const service = inner.split(":")[0];
|
|
24541
|
+
if (service === void 0 || !REPLAY_SECRET_SERVICES.has(service)) return { kind: "local" };
|
|
24542
|
+
const secretName = service === "secretsmanager" ? secretsManagerSecretId(inner) : ssmParameterName(inner);
|
|
24543
|
+
if (!secretName) return { kind: "local" };
|
|
24544
|
+
const named = arnRegion(secretName);
|
|
24545
|
+
if (named !== void 0) return canonicalizeRegion(named) === canonicalizeRegion(consumerRegion) ? { kind: "local" } : {
|
|
24546
|
+
kind: "named-region",
|
|
24547
|
+
secretName,
|
|
24548
|
+
region: named
|
|
24549
|
+
};
|
|
24550
|
+
const seen = /* @__PURE__ */ new Set();
|
|
24551
|
+
const foreignProducerRegions = [];
|
|
24552
|
+
for (const candidate of importedProducerRegions ?? []) {
|
|
24553
|
+
const canonical = canonicalizeRegion(candidate);
|
|
24554
|
+
if (!canonical || canonical === canonicalizeRegion(consumerRegion)) continue;
|
|
24555
|
+
if (seen.has(canonical)) continue;
|
|
24556
|
+
seen.add(canonical);
|
|
24557
|
+
foreignProducerRegions.push(candidate);
|
|
24558
|
+
}
|
|
24559
|
+
if (foreignProducerRegions.length === 0) return { kind: "local" };
|
|
24560
|
+
return {
|
|
24561
|
+
kind: "ambiguous",
|
|
24562
|
+
secretName,
|
|
24563
|
+
foreignProducerRegions
|
|
24564
|
+
};
|
|
24565
|
+
}
|
|
24566
|
+
/**
|
|
24567
|
+
* The replay's resolvers: the stack's own, plus one pinned sibling per FOREIGN
|
|
24568
|
+
* region an ARN-named reference asks for (issue #2057).
|
|
24569
|
+
*
|
|
24570
|
+
* One instance per replay, not per op — the resolved-value cache lives on the
|
|
24571
|
+
* resolver INSTANCE since issue #1933, so a resolver per op would re-fetch every
|
|
24572
|
+
* referenced secret once per op. The pinned siblings are cached here for the
|
|
24573
|
+
* same reason: a 100-op replay of a bag carrying one foreign ARN must pay one
|
|
24574
|
+
* `GetSecretValue`, not a hundred.
|
|
24575
|
+
*
|
|
24576
|
+
* A pinned sibling is a PLAIN resolver, deliberately NOT the resolver class's
|
|
24577
|
+
* own `producerRegionGuest` (which the class sets on the siblings
|
|
24578
|
+
* `resolverForProducerRegion` builds, to stop a foreign region pinning a verdict
|
|
24579
|
+
* in the process-global `recordedSecretExpressions` store — the issue #1933
|
|
24580
|
+
* shape, where an `ssm` parameter whose TYPE differs by region has one region's
|
|
24581
|
+
* verdict decide the other's redaction).
|
|
24582
|
+
*
|
|
24583
|
+
* WHY A GUEST FLAG IS NOT NEEDED HERE, and the argument has to be this one
|
|
24584
|
+
* rather than "only `secretsmanager` routes to a sibling" (that earlier claim
|
|
24585
|
+
* was FALSE — `resolveSSMReference` joins its colon-split tail back together, so
|
|
24586
|
+
* an `ssm` reference CAN name a full ARN and CAN therefore route here):
|
|
24587
|
+
*
|
|
24588
|
+
* {@link ReplayResolvers.forRegion} is reached ONLY from a `named-region`
|
|
24589
|
+
* verdict, which `classifyReplaySecretRegion` returns only when the
|
|
24590
|
+
* SECRET_ID / parameter name starts with `arn:` and carries a region. So a
|
|
24591
|
+
* pinned sibling only ever resolves an expression whose KEY EMBEDS THE
|
|
24592
|
+
* REGION IT IS BEING RESOLVED IN.
|
|
24593
|
+
*
|
|
24594
|
+
* The store is keyed by the expression string alone, and that is exactly what
|
|
24595
|
+
* makes #1933 possible: two regions sharing one key. An ARN-form key cannot be
|
|
24596
|
+
* shared by two regions, so a verdict pinned from a sibling can never contradict
|
|
24597
|
+
* another region's for the same key. If a future change ever routes a
|
|
24598
|
+
* region-LESS expression to `forRegion`, this argument dies with it and the
|
|
24599
|
+
* sibling needs the guest flag.
|
|
24600
|
+
*/
|
|
24601
|
+
var ReplayResolvers = class {
|
|
24602
|
+
/** The stack's own resolver — every `local` verdict resolves through this. */
|
|
24603
|
+
primary;
|
|
24604
|
+
pinned = /* @__PURE__ */ new Map();
|
|
24605
|
+
stackRegion;
|
|
24606
|
+
constructor(stackRegion) {
|
|
24607
|
+
this.stackRegion = stackRegion;
|
|
24608
|
+
this.primary = new IntrinsicFunctionResolver(stackRegion);
|
|
24609
|
+
}
|
|
24610
|
+
/** The resolver that must answer for `region` — `primary` when it is the stack's own. */
|
|
24611
|
+
forRegion(region) {
|
|
24612
|
+
const target = canonicalizeRegion(region);
|
|
24613
|
+
if (target === canonicalizeRegion(this.stackRegion)) return this.primary;
|
|
24614
|
+
const cached = this.pinned.get(target);
|
|
24615
|
+
if (cached) return cached;
|
|
24616
|
+
const scoped = new IntrinsicFunctionResolver(target);
|
|
24617
|
+
this.pinned.set(target, scoped);
|
|
24618
|
+
return scoped;
|
|
24619
|
+
}
|
|
24620
|
+
};
|
|
24621
|
+
/**
|
|
24622
|
+
* The refusal an `ambiguous` replay reference throws (issue #2057).
|
|
24623
|
+
*
|
|
24624
|
+
* A plain throw, like the final-snapshot refusals above and for the same
|
|
24625
|
+
* reason: the per-op catch in {@link replaySingle} /
|
|
24626
|
+
* {@link replayFailedOperations} counts it as a failure, which keeps the
|
|
24627
|
+
* journal segment and lets the user re-run once the reference is disambiguated.
|
|
24628
|
+
* Refusing is strictly better than the alternative it replaces — resolving a
|
|
24629
|
+
* producer-region reference against the consumer's region does not fail, it
|
|
24630
|
+
* succeeds with the WRONG credential and writes it to a resource that is live.
|
|
24631
|
+
*
|
|
24632
|
+
* Names the reference, the regions, and the remedy. Never the resolved value:
|
|
24633
|
+
* nothing here has resolved anything yet, and the expression is the same string
|
|
24634
|
+
* `state.json` already stores in the clear.
|
|
24635
|
+
*/
|
|
24636
|
+
function regionAmbiguousReplaySecretError(logicalId, propertyPath, secretName, foreignProducerRegions, consumerRegion) {
|
|
24637
|
+
return new CdkdError(`Rollback of ${logicalId}${propertyPath === "" ? "" : ` property '${propertyPath}'`} cannot re-resolve the secret reference '${secretName}': the reference carries no region of its own, and this stack read across a region boundary (producer region(s) on record: ${foreignProducerRegions.join(", ")}), so it may have been resolved in one of those rather than in '${consumerRegion}'. A secret of the same name in two regions is two independent values, so replaying this would write the WRONG secret to a live resource. Refusing instead. Resolve the reference in its own region and set the property directly (or spell it as a full ARN, which names its region and is resolved there), then re-run 'cdkd rollback'.`, "ROLLBACK_SECRET_REGION_AMBIGUOUS");
|
|
24638
|
+
}
|
|
24639
|
+
/**
|
|
24640
|
+
* Re-resolve one LEAF string, sending each `{{resolve:...}}` reference in it to
|
|
24641
|
+
* the region {@link classifyReplaySecretRegion} says must answer (issue #2057).
|
|
24642
|
+
*
|
|
24643
|
+
* Refuses FIRST, over the whole leaf, before any reference is fetched: a leaf
|
|
24644
|
+
* can splice several references together, and resolving the safe ones first
|
|
24645
|
+
* would leave half a credential fetched (and cached, and recorded as a
|
|
24646
|
+
* redaction needle) for an op that is about to be refused anyway.
|
|
24647
|
+
*
|
|
24648
|
+
* Then TWO paths, and the split is deliberate rather than an optimisation:
|
|
24649
|
+
*
|
|
24650
|
+
* - With no foreign-region reference — every leaf on every existing code path
|
|
24651
|
+
* — the leaf goes to `resolveDynamicReferences` WHOLE, exactly as before this
|
|
24652
|
+
* change. That method has its own well-tested substitution semantics (it
|
|
24653
|
+
* collects matches from the ORIGINAL string, so a resolved plaintext that is
|
|
24654
|
+
* itself token-shaped is never re-resolved — issue #1917), and this change
|
|
24655
|
+
* does not want to relitigate any of it.
|
|
24656
|
+
* - With one, the leaf is rebuilt segment by segment so each reference can be
|
|
24657
|
+
* resolved by its OWN region's resolver. `resolveDynamicReferences` resolves
|
|
24658
|
+
* every token in the string it is handed with the one resolver it is called
|
|
24659
|
+
* on, so a mixed leaf cannot be served by a single call. Each token is
|
|
24660
|
+
* resolved ALONE and its result concatenated, which means no resolved value
|
|
24661
|
+
* is ever re-scanned for tokens either.
|
|
24662
|
+
*
|
|
24663
|
+
* `dynamicReferenceTokens` returns the tokens in order and non-overlapping, so
|
|
24664
|
+
* walking the leaf with a moving `indexOf` cursor reproduces their positions
|
|
24665
|
+
* exactly, duplicates included.
|
|
24666
|
+
*/
|
|
24667
|
+
async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resolvers, resolverContext) {
|
|
24668
|
+
const verdicts = dynamicReferenceTokens(leaf).map((token) => [token, classifyReplaySecretRegion(token, execCtx.region, execCtx.importedProducerRegions)]);
|
|
24669
|
+
for (const [, verdict] of verdicts) if (verdict.kind === "ambiguous") throw regionAmbiguousReplaySecretError(logicalId, propertyPath, verdict.secretName, verdict.foreignProducerRegions, execCtx.region);
|
|
24670
|
+
if (!verdicts.some(([, verdict]) => verdict.kind === "named-region")) return await resolvers.primary.resolveDynamicReferences(leaf, resolverContext);
|
|
24671
|
+
let out = "";
|
|
24672
|
+
let cursor = 0;
|
|
24673
|
+
for (const [token, verdict] of verdicts) {
|
|
24674
|
+
const at = leaf.indexOf(token, cursor);
|
|
24675
|
+
if (at < 0) throw new CdkdError(`Rollback of ${logicalId}${propertyPath === "" ? "" : ` property '${propertyPath}'`} could not locate a scanned dynamic reference in the value it was scanned from. Refusing rather than resolving it in '${execCtx.region}', which would be the wrong region for a reference that names another one. This is an internal invariant failure — please report it with the resource type and property path.`, "ROLLBACK_SECRET_TOKEN_SCAN_MISMATCH");
|
|
24676
|
+
out += leaf.slice(cursor, at);
|
|
24677
|
+
const resolver = verdict.kind === "named-region" ? resolvers.forRegion(verdict.region) : resolvers.primary;
|
|
24678
|
+
out += await resolver.resolveDynamicReferences(token, resolverContext);
|
|
24679
|
+
cursor = at + token.length;
|
|
24680
|
+
}
|
|
24681
|
+
return out + leaf.slice(cursor);
|
|
24222
24682
|
}
|
|
24223
24683
|
/**
|
|
24224
24684
|
* Redact resolved secret plaintext back out of a post-rollback state record
|
|
@@ -24542,7 +25002,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
24542
25002
|
case "reverse-replacement": {
|
|
24543
25003
|
const current = stateResources[op.logicalId];
|
|
24544
25004
|
const prev = op.previousState;
|
|
24545
|
-
const resolvedPrevProps = await resolveReplayProps(prev.properties, resolver, secrets) ?? {};
|
|
25005
|
+
const resolvedPrevProps = await resolveReplayProps(prev.properties, resolver, secrets, ctx, op.logicalId) ?? {};
|
|
24546
25006
|
logger.info(` Rollback: Reversing replacement of ${op.logicalId} (${op.resourceType}) — re-creating the old resource and deleting the new one`);
|
|
24547
25007
|
if (STATEFUL_TYPES.has(op.resourceType)) logger.warn(` ⚠ ${op.logicalId} (${op.resourceType}) is a stateful type — the old physical resource's data was destroyed by the replacement and CANNOT be recovered; the re-created resource starts empty.`);
|
|
24548
25008
|
const { provider: createProvider } = ctx.providerRegistry.getProviderFor({
|
|
@@ -24634,8 +25094,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
24634
25094
|
resourceType: op.resourceType,
|
|
24635
25095
|
provisionedBy: op.provisionedBy
|
|
24636
25096
|
});
|
|
24637
|
-
const desiredProps = await resolveReplayProps(previousState.properties, resolver, secrets);
|
|
24638
|
-
const currentProps = await resolveReplayProps(current.properties, resolver, secrets);
|
|
25097
|
+
const desiredProps = await resolveReplayProps(previousState.properties, resolver, secrets, ctx, op.logicalId);
|
|
25098
|
+
const currentProps = await resolveReplayProps(current.properties, resolver, secrets, ctx, op.logicalId);
|
|
24639
25099
|
const revertResult = await updateWithRollbackRetry(provider, [
|
|
24640
25100
|
op.logicalId,
|
|
24641
25101
|
current.physicalId,
|
|
@@ -24695,7 +25155,7 @@ async function replayFailedOperations(failedOps, stateResources, stackName, ctx,
|
|
|
24695
25155
|
remainingFailedOps: []
|
|
24696
25156
|
};
|
|
24697
25157
|
const { logger } = ctx;
|
|
24698
|
-
const resolver = new
|
|
25158
|
+
const resolver = new ReplayResolvers(ctx.region);
|
|
24699
25159
|
const emitEnvelope = options.emitEnvelope === true && failedOps.length > 0;
|
|
24700
25160
|
if (emitEnvelope) ctx.recordEvent?.({
|
|
24701
25161
|
eventType: "ROLLBACK_STARTED",
|
|
@@ -24786,8 +25246,8 @@ async function replayFailedOperations(failedOps, stateResources, stackName, ctx,
|
|
|
24786
25246
|
resourceType: op.resourceType,
|
|
24787
25247
|
provisionedBy: op.provisionedBy ?? current.provisionedBy
|
|
24788
25248
|
});
|
|
24789
|
-
const desiredProps = await resolveReplayProps(prev.properties, resolver, secrets);
|
|
24790
|
-
const attemptedProps = await resolveReplayProps(op.attemptedProperties ?? current.properties, resolver, secrets);
|
|
25249
|
+
const desiredProps = await resolveReplayProps(prev.properties, resolver, secrets, ctx, op.logicalId);
|
|
25250
|
+
const attemptedProps = await resolveReplayProps(op.attemptedProperties ?? current.properties, resolver, secrets, ctx, op.logicalId);
|
|
24791
25251
|
const revertFailedResult = await updateWithRollbackRetry(provider, [
|
|
24792
25252
|
op.logicalId,
|
|
24793
25253
|
current.physicalId,
|
|
@@ -24925,7 +25385,7 @@ const FLUSH_INTERVAL_MS = 2e3;
|
|
|
24925
25385
|
const FLUSH_EVENT_THRESHOLD = 50;
|
|
24926
25386
|
/** Build-time cdkd version, with a dev fallback for non-built contexts. */
|
|
24927
25387
|
function getCdkdVersion() {
|
|
24928
|
-
return "0.284.
|
|
25388
|
+
return "0.284.23";
|
|
24929
25389
|
}
|
|
24930
25390
|
/**
|
|
24931
25391
|
* Generate a time-sortable unique run id, e.g.
|
|
@@ -25475,6 +25935,92 @@ function deepEqualValue(a, b) {
|
|
|
25475
25935
|
}
|
|
25476
25936
|
return true;
|
|
25477
25937
|
}
|
|
25938
|
+
/**
|
|
25939
|
+
* The `imports` / `outputReads` records to persist on a save that is NOT the
|
|
25940
|
+
* final success save — the UNION of the pre-deploy snapshot and what THIS
|
|
25941
|
+
* session resolved (issue
|
|
25942
|
+
* [#2057](https://github.com/go-to-k/cdkd/issues/2057) review).
|
|
25943
|
+
*
|
|
25944
|
+
* Every non-success save used to write `currentState.imports` /
|
|
25945
|
+
* `currentState.outputReads` verbatim, i.e. the PRE-DEPLOY snapshot, while
|
|
25946
|
+
* writing the POST-deploy `newResources` beside it. So a deploy that
|
|
25947
|
+
* introduced a cross-stack read and then failed persisted resources built FROM
|
|
25948
|
+
* that read next to a record that does not mention it. Two consequences, and
|
|
25949
|
+
* only the first is about #2057:
|
|
25950
|
+
*
|
|
25951
|
+
* 1. A rollback journal exists only after a FAILED deploy, so
|
|
25952
|
+
* {@link producerRegionsFromState} saw an empty list on exactly the deploy
|
|
25953
|
+
* that introduces a cross-region secret read — and
|
|
25954
|
+
* `classifyReplaySecretRegion` answered `local`, resolving the producer's
|
|
25955
|
+
* region-less expression in the consumer's region. The refusal was inert
|
|
25956
|
+
* where it mattered most.
|
|
25957
|
+
* 2. INDEPENDENT PRE-EXISTING BUG. `state.imports[]` is what
|
|
25958
|
+
* `findActiveImportConsumers` (`src/cli/commands/destroy-runner.ts`) scans
|
|
25959
|
+
* to refuse destroying a producer while a consumer still imports from it,
|
|
25960
|
+
* and `state.outputReads[]` is what `findDownstreamConsumers`
|
|
25961
|
+
* (`src/cli/commands/recreate-downstream-consumers.ts`) enumerates. A
|
|
25962
|
+
* failed deploy therefore silently DOWNGRADED a fresh strong reference to
|
|
25963
|
+
* no reference: the consumer's resource is live and recorded, its import is
|
|
25964
|
+
* not, and `cdkd destroy` on the producer sails through the strong-ref
|
|
25965
|
+
* pre-flight. This exists on main today, with or without #2057.
|
|
25966
|
+
*
|
|
25967
|
+
* DIRECTION OF THE RESIDUAL, stated rather than left to be discovered: a union
|
|
25968
|
+
* never drops a record, so a stack that STOPS reading across a region keeps the
|
|
25969
|
+
* stale entry until its next SUCCESSFUL deploy, whose save replaces the list
|
|
25970
|
+
* wholesale (`imports: [...this.recordedImports]`). Until then a purely-local
|
|
25971
|
+
* rollback can be refused on the strength of a read the template no longer has.
|
|
25972
|
+
* That is the fail-closed side — a clear error naming the region to reconcile,
|
|
25973
|
+
* versus a silent wrong-secret write — and the same asymmetry already justifies
|
|
25974
|
+
* preserving the snapshot at all (dropping it would strip a live strong-ref
|
|
25975
|
+
* record on every diff-clean deploy).
|
|
25976
|
+
*
|
|
25977
|
+
* THE RULE IS "EVERY SAVE EXCEPT THE TERMINAL SUCCESS ONE", and it is stated
|
|
25978
|
+
* that way rather than as "every non-success save" because the latter is loose
|
|
25979
|
+
* in both directions: the diff-clean no-change save in `doDeploy` is a SUCCESS
|
|
25980
|
+
* outcome and unions anyway (nothing was re-resolved, so the union is an
|
|
25981
|
+
* identity there and one rule beats an exception), while
|
|
25982
|
+
* `persistStateAfterOutputFailure` looks like a success save — provisioning
|
|
25983
|
+
* was clean — and is not one.
|
|
25984
|
+
*
|
|
25985
|
+
* THE ENUMERATION IS NOT KEPT HERE, DELIBERATELY. Two prose counts in this
|
|
25986
|
+
* lane were measured wrong (an "ALL FIVE" that missed
|
|
25987
|
+
* `persistStateAfterOutputFailure`, and a "three post-rollback saves" that is
|
|
25988
|
+
* two), and each wrong count is worse than none: it is the sentence a reader
|
|
25989
|
+
* uses to conclude the rule is already applied everywhere.
|
|
25990
|
+
* `tests/unit/deployment/deploy-engine-cross-stack-read-writers.test.ts`
|
|
25991
|
+
* derives the set instead — it SCANS this file for every `imports:` /
|
|
25992
|
+
* `outputReads:` object key that writes a VALUE and fails on any that is not
|
|
25993
|
+
* the one allow-listed success-path write, with a positive control proving the
|
|
25994
|
+
* scan can see a violation. A save site added here fails that test rather than
|
|
25995
|
+
* escaping silently, so the authority on "where is this applied" is a grep the
|
|
25996
|
+
* test performs, not a number anybody has to maintain.
|
|
25997
|
+
*/
|
|
25998
|
+
function crossStackReadsForPartialSave(previous, recordedImports, recordedOutputReads) {
|
|
25999
|
+
const imports = unionCrossStackReads(previous.imports, recordedImports, (e) => `${e.sourceStack}\u0000${canonicalizeRegion(e.sourceRegion)}\u0000${e.exportName}`);
|
|
26000
|
+
const outputReads = unionCrossStackReads(previous.outputReads, recordedOutputReads, (e) => `${e.sourceStack}\u0000${canonicalizeRegion(e.sourceRegion)}\u0000${e.outputName}`);
|
|
26001
|
+
return {
|
|
26002
|
+
...imports.length > 0 && { imports },
|
|
26003
|
+
...outputReads.length > 0 && { outputReads }
|
|
26004
|
+
};
|
|
26005
|
+
}
|
|
26006
|
+
/**
|
|
26007
|
+
* Concatenate two cross-stack-read lists, dropping a later duplicate of an
|
|
26008
|
+
* identity an earlier entry already carries. First-seen wins, so the PRE-DEPLOY
|
|
26009
|
+
* spelling of a region survives — entries are COMPARED on a canonicalized
|
|
26010
|
+
* region but STORED verbatim, mirroring `producerRegionsFromState`.
|
|
26011
|
+
*/
|
|
26012
|
+
function unionCrossStackReads(previous, recorded, identity) {
|
|
26013
|
+
const seen = /* @__PURE__ */ new Set();
|
|
26014
|
+
const out = [];
|
|
26015
|
+
for (const entry of [...previous ?? [], ...recorded]) {
|
|
26016
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
26017
|
+
const key = identity(entry);
|
|
26018
|
+
if (seen.has(key)) continue;
|
|
26019
|
+
seen.add(key);
|
|
26020
|
+
out.push(entry);
|
|
26021
|
+
}
|
|
26022
|
+
return out;
|
|
26023
|
+
}
|
|
25478
26024
|
var DeployEngine = class {
|
|
25479
26025
|
logger = getLogger().child("DeployEngine");
|
|
25480
26026
|
resolver;
|
|
@@ -25670,7 +26216,7 @@ var DeployEngine = class {
|
|
|
25670
26216
|
*/
|
|
25671
26217
|
redactOutputs(outputs) {
|
|
25672
26218
|
if (this.outputSecrets.size === 0) return outputs;
|
|
25673
|
-
return redactSecretsForState(outputs, this.outputSecrets, this.outputsSourceUsable ? this.outputsTemplateSource : void 0);
|
|
26219
|
+
return redactSecretsForState(outputs, this.outputSecrets, this.outputsSourceUsable ? this.outputsTemplateSource : void 0, TEMPLATE_SOURCED_RULES);
|
|
25674
26220
|
}
|
|
25675
26221
|
/**
|
|
25676
26222
|
* Redact resolved secret plaintext out of rollback-journal operations (GHSA
|
|
@@ -26020,8 +26566,7 @@ var DeployEngine = class {
|
|
|
26020
26566
|
stackName: currentState.stackName,
|
|
26021
26567
|
resources: currentState.resources,
|
|
26022
26568
|
outputs: outputsChanged ? resolvedOutputs : persistedOutputs,
|
|
26023
|
-
...currentState
|
|
26024
|
-
...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
|
|
26569
|
+
...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
|
|
26025
26570
|
lastModified: Date.now()
|
|
26026
26571
|
};
|
|
26027
26572
|
const saveOptions = {};
|
|
@@ -26139,8 +26684,7 @@ var DeployEngine = class {
|
|
|
26139
26684
|
stackName: currentState.stackName,
|
|
26140
26685
|
resources: newResources,
|
|
26141
26686
|
outputs: currentState.outputs,
|
|
26142
|
-
...currentState
|
|
26143
|
-
...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
|
|
26687
|
+
...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
|
|
26144
26688
|
lastModified: Date.now()
|
|
26145
26689
|
};
|
|
26146
26690
|
const migrate = pendingMigration;
|
|
@@ -26270,8 +26814,7 @@ var DeployEngine = class {
|
|
|
26270
26814
|
stackName: currentState.stackName,
|
|
26271
26815
|
resources: newResources,
|
|
26272
26816
|
outputs: currentState.outputs,
|
|
26273
|
-
...currentState
|
|
26274
|
-
...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
|
|
26817
|
+
...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
|
|
26275
26818
|
lastModified: Date.now()
|
|
26276
26819
|
};
|
|
26277
26820
|
const migrate = pendingMigration;
|
|
@@ -26297,7 +26840,7 @@ var DeployEngine = class {
|
|
|
26297
26840
|
this.logger.warn("Partial state has been saved. Run 'cdkd deploy' to resume, 'cdkd rollback' to revert, or destroy to clean up.");
|
|
26298
26841
|
} else {
|
|
26299
26842
|
await this.writeRollbackJournalSegment(stackName, completedOperations, failedOperations, "auto-rollback-started", initialDeploy);
|
|
26300
|
-
autoRollbackClean = (await this.performRollback(completedOperations, newResources, stackName)).failures === 0;
|
|
26843
|
+
autoRollbackClean = (await this.performRollback(completedOperations, newResources, stackName, currentState)).failures === 0;
|
|
26301
26844
|
}
|
|
26302
26845
|
try {
|
|
26303
26846
|
const postRollbackState = {
|
|
@@ -26306,8 +26849,7 @@ var DeployEngine = class {
|
|
|
26306
26849
|
stackName: currentState.stackName,
|
|
26307
26850
|
resources: newResources,
|
|
26308
26851
|
outputs: currentState.outputs,
|
|
26309
|
-
...currentState
|
|
26310
|
-
...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
|
|
26852
|
+
...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
|
|
26311
26853
|
lastModified: Date.now()
|
|
26312
26854
|
};
|
|
26313
26855
|
await this.stateBackend.saveState(stackName, this.stackRegion, this.withParentInfo(postRollbackState), { ...currentEtag !== void 0 && { expectedEtag: currentEtag } });
|
|
@@ -26323,8 +26865,7 @@ var DeployEngine = class {
|
|
|
26323
26865
|
stackName: currentState.stackName,
|
|
26324
26866
|
resources: newResources,
|
|
26325
26867
|
outputs: currentState.outputs,
|
|
26326
|
-
...currentState
|
|
26327
|
-
...currentState.outputReads && currentState.outputReads.length > 0 && { outputReads: currentState.outputReads },
|
|
26868
|
+
...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
|
|
26328
26869
|
lastModified: Date.now()
|
|
26329
26870
|
};
|
|
26330
26871
|
await this.stateBackend.saveState(stackName, this.stackRegion, this.withParentInfo(postRollbackState), { ...freshEtag !== void 0 && { expectedEtag: freshEtag } });
|
|
@@ -26389,8 +26930,7 @@ var DeployEngine = class {
|
|
|
26389
26930
|
stackName: currentState.stackName,
|
|
26390
26931
|
resources: newResources,
|
|
26391
26932
|
outputs: currentState.outputs,
|
|
26392
|
-
...this.recordedImports
|
|
26393
|
-
...this.recordedOutputReads.length > 0 && { outputReads: [...this.recordedOutputReads] },
|
|
26933
|
+
...crossStackReadsForPartialSave(currentState, this.recordedImports, this.recordedOutputReads),
|
|
26394
26934
|
lastModified: Date.now()
|
|
26395
26935
|
});
|
|
26396
26936
|
try {
|
|
@@ -26417,8 +26957,8 @@ var DeployEngine = class {
|
|
|
26417
26957
|
* command drives identical semantics). Thin wrapper that builds the
|
|
26418
26958
|
* executor context from the engine's collaborators and delegates.
|
|
26419
26959
|
*/
|
|
26420
|
-
async performRollback(completedOperations, stateResources, stackName) {
|
|
26421
|
-
const result = await replayRollback(completedOperations, stateResources, stackName, this.rollbackExecutorContext());
|
|
26960
|
+
async performRollback(completedOperations, stateResources, stackName, previousState) {
|
|
26961
|
+
const result = await replayRollback(completedOperations, stateResources, stackName, this.rollbackExecutorContext(previousState));
|
|
26422
26962
|
return {
|
|
26423
26963
|
failures: result.failures,
|
|
26424
26964
|
warnings: result.warnings
|
|
@@ -26477,14 +27017,15 @@ var DeployEngine = class {
|
|
|
26477
27017
|
this.logger.info(`The automatic rollback restored the pre-deploy state. The failed resource's pre-failure record was kept — if it was left partially applied, run 'cdkd rollback ${stackName} --revert-failed' to revert it.`);
|
|
26478
27018
|
}
|
|
26479
27019
|
/** Build the {@link RollbackExecutorContext} from the engine's fields. */
|
|
26480
|
-
rollbackExecutorContext() {
|
|
27020
|
+
rollbackExecutorContext(previousState) {
|
|
26481
27021
|
return {
|
|
26482
27022
|
providerRegistry: this.providerRegistry,
|
|
26483
27023
|
region: this.stackRegion,
|
|
26484
27024
|
logger: this.logger,
|
|
26485
27025
|
recordEvent: (event) => this.recordEvent(event),
|
|
26486
27026
|
finalSnapshotClients: this.options.finalSnapshotClients,
|
|
26487
|
-
skipFinalSnapshot: this.options.skipFinalSnapshot
|
|
27027
|
+
skipFinalSnapshot: this.options.skipFinalSnapshot,
|
|
27028
|
+
importedProducerRegions: producerRegionsFromState(crossStackReadsForPartialSave(previousState, this.recordedImports, this.recordedOutputReads))
|
|
26488
27029
|
};
|
|
26489
27030
|
}
|
|
26490
27031
|
/**
|
|
@@ -27464,5 +28005,5 @@ var DeployEngine = class {
|
|
|
27464
28005
|
};
|
|
27465
28006
|
|
|
27466
28007
|
//#endregion
|
|
27467
|
-
export {
|
|
27468
|
-
//# sourceMappingURL=deploy-engine
|
|
28008
|
+
export { disableInstanceApiTermination as $, NestedStackChildDirectDestroyError as $n, ensureAssetStorage as $t, isStatefulRecreateTargetSync as A, uploadCfnTemplate as An, s3BucketWebsiteUrl as At, collectPublishedOutputNames as B, getAwsClients as Bn, rebuildClientForBucketRegion as Bt, createPreDeleteFinalSnapshot as C, resolveUseCdkBootstrapAssets as Cn, maskSecretsInText as Ct, makeCanonicalizePropertiesFn as D, CFN_TEMPLATE_URL_LIMIT as Dn, s3BucketDomainName as Dt, unsupportedFinalSnapshotError as E, CFN_TEMPLATE_BODY_LIMIT as En, s3BucketArn as Et, gray as F, AssemblyReader as Fn, withRetry as Ft, IAMRoleProvider as G, ConfigError as Gn, buildAssetRedirectMap as Gt, isExportAliasCollision as H, setAwsClients as Hn, AssetPublisher as Ht, green as I, processStackMessages as In, DagBuilder as It, ProviderRegistry as J, LocalInvokeBuildError as Jn, rewriteTemplateAssetReferences as Jt, collectInlinePolicyNamesManagedBySiblings as K, DependencyError as Kn, createAssetRedirectResolver as Kt, red as L, clearBucketRegionCache as Ln, TemplateParser as Lt, formatResourceLine as M, PARTITION_TABLE as Mn, DiffCalculator as Mt, bold as N, canonicalizeRegion as Nn, INTRINSIC_KEYS as Nt, extractDeploymentEventError as O, MIGRATE_TMP_PREFIX as On, s3BucketDualStackDomainName as Ot, cyan as P, derivePartitionAndUrlSuffix as Pn, describeTypeWithThrottleRetry as Pt, slowCcOperationTimeoutMs as Q, MissingCdkCliError as Qn, BOOTSTRAP_MARKER_PREFIX as Qt, yellow as R, resolveBucketRegion as Rn, LockManager as Rt, ccRoutedFinalSnapshotError as S, resolveStateBucketWithDefaultAndSource as Sn, isSingleDynamicReferenceToken as St, refusesFinalSnapshot as T, warnDeprecatedNoPrefixCliFlag as Tn, scrubResourceRecord as Tt, secretBearingStateKeyWarning as U, AssetError as Un, stringifyValue as Ut, exportAliasCollisionScrubWarning as V, resetAwsClients as Vn, shouldRetainResource as Vt, stateKeySecretExposure as W, CdkdError as Wn, WorkGraph as Wt, findSilentDropProperties as X, LocalStartServiceError as Xn, stripControlChars as Xt, findActionableSilentDrops as Y, LocalMigrateError as Yn, escapeRegExp$1 as Yt, CloudControlProvider as Z, LockError as Zn, AssetModeResolver as Zt, IMPLICIT_DELETE_DEPENDENCIES as _, resolveApp as _n, STATE_SOURCED_CROSS_GENERATION_RULES as _t, DeploymentEventsStore as a, buildDenyExternalAccessPolicy as an, StackTerminationProtectionError as ar, WAFv2WebACLProvider as at, PRE_DELETE_SNAPSHOT_TYPES as b, resolveSkipPrefix as bn, createSecretMasker as bt, producerRegionsFromState as c, getDockerCmd as cn, formatError as cr, assertRegionMatch as ct, updatePartialMessage as d, AssetManifestLoader as dn, withErrorHandling as dr, configStringRefusal as dt, getBootstrapMarkerKey as en, PartialFailureError as er, isTerminationProtectionPropagationError as et, updatePartialReason as f, getDockerImageBySourceHash as fn, isMarkedNonRetryable as fr, readConfigString as ft, maskingRetryLogger as g, getLegacyStateBucketName as gn, __exportAll as gr, requireConfigString as gt, withResourceDeadline as h, getDefaultStateBucketName as hn, markNonRetryable as hr, requireConfigObject as ht, DeploymentEventsReader as i, validateContainerRepoName as in, StackHasActiveImportsError as ir, refStateLookupFromResource as it, renderStatefulReason as j, expectedOwnerParam as jn, applyRoleArnIfSet as jt, MULTI_REGION_RECREATE_BLOCKED_TYPES as k, findLargeInlineResources as kn, s3BucketRegionalDomainName as kt, replayFailedOperations as l, runDockerForeground as ln, isCdkdError as lr, coerceCfnBoolean as lt, deleteSkipReason as m, synthesisStatusMessage as mn, isThrottlingError as mr, requireConfigArray as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, readBootstrapMarkerBody as nn, ResourceTimeoutError as nr, cfnRefValueFromPhysicalId as nt, planFailedOps as o, buildDockerImage as on, StateError as or, normalizeAwsTagsToCfn as ot, UNSPECIFIED_SKIP_REASON as p, Synthesizer as pn, isRetryableTransientError as pr, replayWarn as pt, clearOnUpdateRemoval as q, DeployCancelledError as qn, loadPublishableAssetManifest as qt, DeployEngine as r, validateAssetBucketName as rn, ResourceUpdateNotSupportedError as rr, getAccountInfo as rt, planRollback as s, formatDockerLoginError as sn, SynthesisError as sr, resolveExplicitPhysicalId as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, parseBootstrapMarker as tn, ProvisioningError as tr, IntrinsicFunctionResolver as tt, replayRollback as u, runDockerStreaming as un, normalizeAwsError as ur, configBooleanRefusal as ut, computeImplicitDeleteEdges as v, resolveAutoAssetStorage as vn, STATE_SOURCED_READBACK_RULES as vt, isFinalSnapshotError as w, stateBucketExistenceConfirmed as wn, redactSecretsForState as wt, buildFinalSnapshotIdentifier as x, resolveStateBucketWithDefault as xn, dynamicReferenceTokens as xt, ATOMIC_FINAL_SNAPSHOT_TYPES as y, resolveCaptureObservedState as yn, TEMPLATE_SOURCED_RULES as yt, collectDeclaredOutputNames as z, AwsClients as zn, S3StateBackend as zt };
|
|
28009
|
+
//# sourceMappingURL=deploy-engine-HfFU96oJ.js.map
|