@go-to-k/cdkd 0.286.3 → 0.287.0
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-uaOnRUMe.js → asg-provider-D10GWdiA.js} +146 -4
- package/dist/asg-provider-D10GWdiA.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/{deploy-engine--rkIGhow.js → deploy-engine-B377YKBI.js} +1548 -266
- package/dist/deploy-engine-B377YKBI.js.map +1 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-CG34-sQd.js → program-D2Vykejc.js} +741 -323
- package/dist/{program-CG34-sQd.js.map → program-D2Vykejc.js.map} +1 -1
- package/dist/{version-D3md0Lde.js → version-Rs7TV7Gz.js} +2 -2
- package/dist/{version-D3md0Lde.js.map → version-Rs7TV7Gz.js.map} +1 -1
- package/package.json +2 -1
- package/dist/asg-provider-uaOnRUMe.js.map +0 -1
- package/dist/deploy-engine--rkIGhow.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { _ as withStackName, d as applyDefaultNameForFallback, f as generateResourceName, h as looksLikeCdkdGeneratedName, m as getCurrentStackName, n as getLogger, p as generateResourceNameWithFallback, r as isStdoutReservedForPayload, s as getLiveRenderer } from "./logger-Dw-3y48G.js";
|
|
2
2
|
import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
|
|
3
|
-
import { t as getCdkdVersion } from "./version-
|
|
3
|
+
import { t as getCdkdVersion } from "./version-Rs7TV7Gz.js";
|
|
4
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetBucketReplicationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -6085,6 +6085,49 @@ var FileAssetPublisher = class {
|
|
|
6085
6085
|
}
|
|
6086
6086
|
};
|
|
6087
6087
|
|
|
6088
|
+
//#endregion
|
|
6089
|
+
//#region src/utils/regexp.ts
|
|
6090
|
+
/**
|
|
6091
|
+
* Escape a literal string for embedding in a RegExp.
|
|
6092
|
+
*
|
|
6093
|
+
* Lives here because the same four-line helper was spelled THREE times — in
|
|
6094
|
+
* `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
|
|
6095
|
+
* `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
|
|
6096
|
+
* (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
|
|
6097
|
+
* label) into a pattern. Copies of a security-shaped helper are exactly the
|
|
6098
|
+
* shape that drifts: the escaped set is what stops a `.` in an interpolated
|
|
6099
|
+
* literal matching ANY character, and in `gc.ts` a widened match decides which
|
|
6100
|
+
* live assets are treated as referenced.
|
|
6101
|
+
*
|
|
6102
|
+
* The character set is the union every JS engine treats as special in a
|
|
6103
|
+
* non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
|
|
6104
|
+
* character class, and no caller embeds into one.
|
|
6105
|
+
*/
|
|
6106
|
+
function escapeRegExp$1(value) {
|
|
6107
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6108
|
+
}
|
|
6109
|
+
/**
|
|
6110
|
+
* Remove control characters from a value about to be PRINTED.
|
|
6111
|
+
*
|
|
6112
|
+
* Lives beside {@link escapeRegExp} for the reason that helper's own note
|
|
6113
|
+
* gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
|
|
6114
|
+
* in `src/deployment/outputs-export-alias.ts` — and copies of a
|
|
6115
|
+
* security-shaped helper drift. Both print the same class of string: a
|
|
6116
|
+
* resolved CloudFormation Output / `Export.Name`, which passed no CFn
|
|
6117
|
+
* validator, so it can carry ANSI escapes or bidi overrides straight into a
|
|
6118
|
+
* terminal or a CI log.
|
|
6119
|
+
*
|
|
6120
|
+
* C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
|
|
6121
|
+
* only: a `--json` payload is a machine interface where an export name is data
|
|
6122
|
+
* a consumer may match on, and mutating it there trades a correctness
|
|
6123
|
+
* regression for a display concern (see `stripDisplayOnlyChars`, which is the
|
|
6124
|
+
* narrower guard for an already-serialized payload and stays local to its
|
|
6125
|
+
* caller because its C0 exclusion is specific to that path).
|
|
6126
|
+
*/
|
|
6127
|
+
function stripControlChars(value) {
|
|
6128
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
6129
|
+
}
|
|
6130
|
+
|
|
6088
6131
|
//#endregion
|
|
6089
6132
|
//#region src/utils/docker-cmd.ts
|
|
6090
6133
|
/**
|
|
@@ -6503,6 +6546,21 @@ const REDACTED_ARGV_VALUE = "***";
|
|
|
6503
6546
|
* - `--opt` — `DockerVolumeConfiguration.DriverOpts`, which for the `local`
|
|
6504
6547
|
* driver carries mount options (`o=addr=…,username=…,password=…`).
|
|
6505
6548
|
* - `--label` — `DockerVolumeConfiguration.Labels`, user-authored metadata.
|
|
6549
|
+
* - `--build-arg` — a `DockerImageAsset`'s `buildArgs`, forwarded by
|
|
6550
|
+
* `src/assets/docker-build.ts` on BOTH the deploy-time ECR publish path and
|
|
6551
|
+
* `cdkd local run-task`'s image build
|
|
6552
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)). The value is
|
|
6553
|
+
* frequently NOT a secret and IS diagnostic (a version pin, a base-image
|
|
6554
|
+
* tag), which is why this one needed arguing rather than assuming — the
|
|
6555
|
+
* argument that settles it is RECOVERABILITY, not likelihood. The build-arg
|
|
6556
|
+
* values sit unredacted in `cdk.out/*.assets.json` on the operator's own
|
|
6557
|
+
* disk, so a masked `--verbose` line costs one `jq` against a file they
|
|
6558
|
+
* already have; the log line is the copy that travels into a CI archive and
|
|
6559
|
+
* a pasted issue, where a build-time registry / package token
|
|
6560
|
+
* (`NPM_TOKEN`, `GITHUB_TOKEN` — a common, if discouraged, use of
|
|
6561
|
+
* `buildArgs`) is disclosed irreversibly. The KEY survives, so "which build
|
|
6562
|
+
* arg" — the half of the diagnostic that identifies the failure — is
|
|
6563
|
+
* unaffected.
|
|
6506
6564
|
*
|
|
6507
6565
|
* A flag NOT in this list keeps its value. For most of the argv that is
|
|
6508
6566
|
* because the value is cdkd-authored or infrastructure-shaped — a container
|
|
@@ -6518,33 +6576,348 @@ const REDACTED_ARGV_VALUE = "***";
|
|
|
6518
6576
|
* `Environment` / `Secrets` / `DriverOpts`. Revisit per flag if a real leak is
|
|
6519
6577
|
* found through one; do not widen the set on suspicion, since every addition
|
|
6520
6578
|
* trades away diagnostic text.
|
|
6579
|
+
*
|
|
6580
|
+
* Two `docker build` flags were considered WITH `--build-arg` and deliberately
|
|
6581
|
+
* left out, because both carry a LOCATOR rather than a value
|
|
6582
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)):
|
|
6583
|
+
*
|
|
6584
|
+
* - `--secret id=<id>,src=<path>` (or `,env=<NAME>`) — BuildKit resolves the
|
|
6585
|
+
* material itself at build time and docker has NO inline-value syntax, so
|
|
6586
|
+
* cdkd never holds the secret to leak. Masking would also blank the `id`,
|
|
6587
|
+
* since the mask keys on the first `=` and `id` is what sits there — trading
|
|
6588
|
+
* the whole diagnostic ("which secret, sourced from where") for a path.
|
|
6589
|
+
* - `--build-context <name>=<path|image-ref|git-url>` — a path or a ref, like
|
|
6590
|
+
* the positional image. A URL form CAN embed a credential, but so can the
|
|
6591
|
+
* image ref; masking one of the two would be a guarantee this set does not
|
|
6592
|
+
* make, and neither is a documented place to put one.
|
|
6593
|
+
*
|
|
6594
|
+
* `--cache-from` / `--cache-to` were in that second bullet for one review
|
|
6595
|
+
* round and the security reviewer was right to refuse it: their value is a
|
|
6596
|
+
* comma-separated PARAM LIST, and BuildKit's cache backends take real
|
|
6597
|
+
* credentials inline there — s3's `access_key_id` / `secret_access_key` /
|
|
6598
|
+
* `session_token`, azblob's `secret_access_key`, gha's `token`. Those are not
|
|
6599
|
+
* locators, so they get their own structural mask keyed on the PARAM name:
|
|
6600
|
+
* {@link ARGV_PARAM_LIST_FLAGS} / {@link ARGV_PARAM_LIST_LOCATOR_PARAMS}.
|
|
6521
6601
|
*/
|
|
6522
6602
|
const ARGV_VALUE_BEARING_FLAGS = /* @__PURE__ */ new Set([
|
|
6523
6603
|
"-e",
|
|
6524
6604
|
"--env",
|
|
6525
6605
|
"--opt",
|
|
6526
|
-
"--label"
|
|
6606
|
+
"--label",
|
|
6607
|
+
"--build-arg"
|
|
6608
|
+
]);
|
|
6609
|
+
/**
|
|
6610
|
+
* `docker` argv flags whose NEXT token is a COMMA-SEPARATED `key=value` param
|
|
6611
|
+
* list rather than one `KEY=VALUE` pair ([#2623](https://github.com/go-to-k/cdkd/issues/2623)
|
|
6612
|
+
* security review).
|
|
6613
|
+
*
|
|
6614
|
+
* Only the params in {@link ARGV_PARAM_LIST_LOCATOR_PARAMS} survive, and the
|
|
6615
|
+
* rest of the list is masked — `type=s3,region=us-east-1,bucket=b` IS the
|
|
6616
|
+
* diagnostic ("which backend, where"), and blanking the whole value would
|
|
6617
|
+
* destroy it to hide one field.
|
|
6618
|
+
*/
|
|
6619
|
+
const ARGV_PARAM_LIST_FLAGS = /* @__PURE__ */ new Set(["--cache-from", "--cache-to"]);
|
|
6620
|
+
/**
|
|
6621
|
+
* Param names inside an {@link ARGV_PARAM_LIST_FLAGS} value that are LOCATORS
|
|
6622
|
+
* and therefore survive. **Everything else in the list is masked** — this is
|
|
6623
|
+
* an ALLOWLIST, and it is one deliberately.
|
|
6624
|
+
*
|
|
6625
|
+
* The first cut was a denylist of the credential params BuildKit's backends
|
|
6626
|
+
* spell (`secret_access_key`, `access_key_id`, `session_token`, `token`), and
|
|
6627
|
+
* both round-2 reviewers broke it the same way:
|
|
6628
|
+
* `DockerCacheOption.params` is an arbitrary user map that
|
|
6629
|
+
* `cacheOptionToFlag` joins with a bare `,` and no quoting, so a value
|
|
6630
|
+
* CONTAINING a comma (`token=aa,bb`, or BuildKit's own legal
|
|
6631
|
+
* `secret_access_key="ab,cd"`) split into a masked head and a bare tail that
|
|
6632
|
+
* printed verbatim — output that LOOKS redacted. Patching that one shape is
|
|
6633
|
+
* the enumerate-bad-shapes treadmill; inverting the test ends it, because a
|
|
6634
|
+
* CSV continuation fragment has no recognised param name and so masks by
|
|
6635
|
+
* construction.
|
|
6636
|
+
*
|
|
6637
|
+
* The trade is the right way round for a leak fix: a param missing from this
|
|
6638
|
+
* list costs one degraded diagnostic, a param missing from a denylist costs a
|
|
6639
|
+
* printed credential. The list covers every backend BuildKit ships —
|
|
6640
|
+
* `registry` (`ref`), `local` (`dest` / `src` / `digest` / `tag`), `s3`
|
|
6641
|
+
* (`region` / `bucket` / `name` / the prefixes / `use_path_style`), `azblob`
|
|
6642
|
+
* (`account_name` / `name` / `prefix`), `gha` (`scope` / `timeout`),
|
|
6643
|
+
* `inline` (none) — plus the shared export options, so the common case masks
|
|
6644
|
+
* nothing.
|
|
6645
|
+
*
|
|
6646
|
+
* The four URL-valued locators live in {@link ARGV_PARAM_LIST_URL_PARAMS}
|
|
6647
|
+
* instead: they survive only after their userinfo and query are stripped. An
|
|
6648
|
+
* earlier revision kept them HERE, whole, arguing the repo's locator policy;
|
|
6649
|
+
* that paragraph is gone rather than softened, because azblob makes it false.
|
|
6650
|
+
*
|
|
6651
|
+
* Matched case-INSENSITIVELY on the trimmed param name — the comparison is
|
|
6652
|
+
* over a user-supplied key and BuildKit's own option parsing is
|
|
6653
|
+
* case-insensitive, so a `Secret_Access_Key` spelling must not walk past.
|
|
6654
|
+
*/
|
|
6655
|
+
const ARGV_PARAM_LIST_LOCATOR_PARAMS = /* @__PURE__ */ new Set([
|
|
6656
|
+
"type",
|
|
6657
|
+
"mode",
|
|
6658
|
+
"compression",
|
|
6659
|
+
"compression-level",
|
|
6660
|
+
"force-compression",
|
|
6661
|
+
"ignore-error",
|
|
6662
|
+
"image-manifest",
|
|
6663
|
+
"oci-mediatypes",
|
|
6664
|
+
"timeout",
|
|
6665
|
+
"ref",
|
|
6666
|
+
"dest",
|
|
6667
|
+
"src",
|
|
6668
|
+
"digest",
|
|
6669
|
+
"tag",
|
|
6670
|
+
"region",
|
|
6671
|
+
"bucket",
|
|
6672
|
+
"name",
|
|
6673
|
+
"prefix",
|
|
6674
|
+
"manifests_prefix",
|
|
6675
|
+
"blobs_prefix",
|
|
6676
|
+
"use_path_style",
|
|
6677
|
+
"upload_parallelism",
|
|
6678
|
+
"touch_refresh",
|
|
6679
|
+
"account_name",
|
|
6680
|
+
"scope"
|
|
6527
6681
|
]);
|
|
6528
6682
|
/**
|
|
6683
|
+
* Locator params whose value is a URL, and which therefore survive only after
|
|
6684
|
+
* their USERINFO, QUERY and FRAGMENT are stripped — and only when the value
|
|
6685
|
+
* parses at all
|
|
6686
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623) round-3 security
|
|
6687
|
+
* review).
|
|
6688
|
+
*
|
|
6689
|
+
* These were plain allowlist entries for one round, on the reasoning that a
|
|
6690
|
+
* credential-in-URL is an incidental hazard shared with `--build-context` and
|
|
6691
|
+
* the positional image ref. That reasoning does not survive contact with
|
|
6692
|
+
* azblob: BuildKit builds an UNAUTHENTICATED client from `account_url` when
|
|
6693
|
+
* `secret_access_key` is absent, so a SAS token in its query string is that
|
|
6694
|
+
* backend's SUPPORTED auth path, not an accident. With every other param now
|
|
6695
|
+
* fail-closed, leaving the whole URL was the one entry carrying credential
|
|
6696
|
+
* material by design.
|
|
6697
|
+
*
|
|
6698
|
+
* Scheme and host survive, which is what "which endpoint" needs — on the
|
|
6699
|
+
* ordinary path. FOUR things mask more than that, and every one of them also
|
|
6700
|
+
* masks each param after it in the same value, because they all set `masked`.
|
|
6701
|
+
* Three take the value WHOLE: no recognisable `//` authority; a host that is
|
|
6702
|
+
* really `user:password` with its `@` cut away; and a later param carrying
|
|
6703
|
+
* this URL's severed `@` (the caller's rule, since only it still holds the
|
|
6704
|
+
* parts). The fourth is milder in what it keeps, not in what it cascades — a
|
|
6705
|
+
* `@` in the PATH costs the host but keeps scheme and tail (`https://h/x@y`
|
|
6706
|
+
* -> `https://***@y`). Over-masking is the deliberate direction: see
|
|
6707
|
+
* {@link redactUrlLocator} for what each boundary rule leaked before this one.
|
|
6708
|
+
*/
|
|
6709
|
+
const ARGV_PARAM_LIST_URL_PARAMS = /* @__PURE__ */ new Set([
|
|
6710
|
+
"account_url",
|
|
6711
|
+
"endpoint_url",
|
|
6712
|
+
"url",
|
|
6713
|
+
"url_v2"
|
|
6714
|
+
]);
|
|
6715
|
+
/**
|
|
6716
|
+
* A URL locator with its credential-bearing parts removed: `userinfo@`, and
|
|
6717
|
+
* everything from `?` or `#` on.
|
|
6718
|
+
*
|
|
6719
|
+
* Returns the input UNCHANGED when there was nothing to strip, which is the
|
|
6720
|
+
* signal the caller keys "was this masked?" on — so every other branch must
|
|
6721
|
+
* differ from its input. An unparseable or implausible value returns `***`
|
|
6722
|
+
* rather than itself: these four params are the only ones exempt from the
|
|
6723
|
+
* allowlist's mask-by-default rule, so this function is their whole
|
|
6724
|
+
* protection and it fails CLOSED.
|
|
6725
|
+
*/
|
|
6726
|
+
function redactUrlLocator(value) {
|
|
6727
|
+
if (value === "") return value;
|
|
6728
|
+
const authority = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\//.exec(value);
|
|
6729
|
+
if (authority === null) return REDACTED_ARGV_VALUE;
|
|
6730
|
+
const prefix = authority[0];
|
|
6731
|
+
let rest = value.slice(prefix.length);
|
|
6732
|
+
const lastAt = rest.lastIndexOf("@");
|
|
6733
|
+
if (lastAt >= 0) rest = `${REDACTED_ARGV_VALUE}${rest.slice(lastAt)}`;
|
|
6734
|
+
else {
|
|
6735
|
+
const host = rest.split(/[/?#]/)[0];
|
|
6736
|
+
const ipv6 = /^\[[0-9A-Fa-f:.]+\]/.exec(host);
|
|
6737
|
+
const bare = ipv6 ? host.slice(ipv6[0].length) : host;
|
|
6738
|
+
if (/:(?!\d{1,5}$)/.test(bare)) return REDACTED_ARGV_VALUE;
|
|
6739
|
+
}
|
|
6740
|
+
for (const delimiter of ["?", "#"]) {
|
|
6741
|
+
const at = rest.indexOf(delimiter);
|
|
6742
|
+
if (at >= 0) rest = `${rest.slice(0, at)}${delimiter}${REDACTED_ARGV_VALUE}`;
|
|
6743
|
+
}
|
|
6744
|
+
return `${prefix}${rest}`;
|
|
6745
|
+
}
|
|
6746
|
+
/**
|
|
6747
|
+
* The masked rendering of `value` for `flag`, or `undefined` when this flag
|
|
6748
|
+
* carries nothing to mask.
|
|
6749
|
+
*
|
|
6750
|
+
* The single place every argv spelling converges — `--flag VALUE` (two
|
|
6751
|
+
* tokens), `--flag=VALUE` (one), and pflag's shorthand cluster in both forms,
|
|
6752
|
+
* which reach it through {@link maskAttachedShortFlag} and through
|
|
6753
|
+
* {@link trailingShortFlagOfCluster} in {@link redactDockerArgvValues}.
|
|
6754
|
+
*
|
|
6755
|
+
* The joined form is valid docker syntax for every long flag here, and it was
|
|
6756
|
+
* UNMASKED until the [#2623](https://github.com/go-to-k/cdkd/issues/2623)
|
|
6757
|
+
* review: harmless while every argv this repo builds emits two tokens, but
|
|
6758
|
+
* `src/assets/docker-build.ts`'s `executable` source mode renders a
|
|
6759
|
+
* USER-AUTHORED command line, and a wrapper script spelling
|
|
6760
|
+
* `--build-arg=NPM_TOKEN=…` leaked it verbatim into both the `--verbose` log
|
|
6761
|
+
* and a thrown error.
|
|
6762
|
+
*/
|
|
6763
|
+
function maskArgvFlagValue(flag, value) {
|
|
6764
|
+
if (ARGV_VALUE_BEARING_FLAGS.has(flag)) {
|
|
6765
|
+
const eqIdx = value.indexOf("=");
|
|
6766
|
+
return eqIdx >= 0 ? `${value.substring(0, eqIdx)}=${REDACTED_ARGV_VALUE}` : void 0;
|
|
6767
|
+
}
|
|
6768
|
+
if (ARGV_PARAM_LIST_FLAGS.has(flag)) {
|
|
6769
|
+
let masked = false;
|
|
6770
|
+
const rawParts = value.split(",");
|
|
6771
|
+
const parts = rawParts.map((part, index) => {
|
|
6772
|
+
if (part === "") return part;
|
|
6773
|
+
if (masked) return REDACTED_ARGV_VALUE;
|
|
6774
|
+
const eqIdx = part.indexOf("=");
|
|
6775
|
+
if (eqIdx < 0) {
|
|
6776
|
+
if (index === 0) return part;
|
|
6777
|
+
masked = true;
|
|
6778
|
+
return REDACTED_ARGV_VALUE;
|
|
6779
|
+
}
|
|
6780
|
+
const name = part.substring(0, eqIdx);
|
|
6781
|
+
const normalized = name.trim().toLowerCase();
|
|
6782
|
+
if (ARGV_PARAM_LIST_URL_PARAMS.has(normalized)) {
|
|
6783
|
+
if (rawParts.slice(index + 1).some((later) => later.includes("@"))) {
|
|
6784
|
+
masked = true;
|
|
6785
|
+
return `${name}=${REDACTED_ARGV_VALUE}`;
|
|
6786
|
+
}
|
|
6787
|
+
const rawUrl = part.substring(eqIdx + 1);
|
|
6788
|
+
const safeUrl = redactUrlLocator(rawUrl);
|
|
6789
|
+
if (safeUrl === rawUrl) return part;
|
|
6790
|
+
masked = true;
|
|
6791
|
+
return `${name}=${safeUrl}`;
|
|
6792
|
+
}
|
|
6793
|
+
if (ARGV_PARAM_LIST_LOCATOR_PARAMS.has(normalized)) return part;
|
|
6794
|
+
masked = true;
|
|
6795
|
+
return `${name}=${REDACTED_ARGV_VALUE}`;
|
|
6796
|
+
});
|
|
6797
|
+
return masked ? parts.join(",") : void 0;
|
|
6798
|
+
}
|
|
6799
|
+
}
|
|
6800
|
+
/**
|
|
6801
|
+
* The masked rendering of an ATTACHED short-flag element (`-eKEY=VALUE`), or
|
|
6802
|
+
* `undefined` when this element is not one.
|
|
6803
|
+
*
|
|
6804
|
+
* pflag — which docker uses — accepts a short flag's value glued to it, so
|
|
6805
|
+
* `-eNPM_TOKEN=secret` is `-e NPM_TOKEN=secret`, and neither the two-token nor
|
|
6806
|
+
* the `--flag=VALUE` branch can see it ([#2623](https://github.com/go-to-k/cdkd/issues/2623)
|
|
6807
|
+
* round-2 security review). No argv this repo BUILDS uses the form, but
|
|
6808
|
+
* `src/assets/docker-build.ts`'s `executable` source mode hands a user's own
|
|
6809
|
+
* command line straight to `spawn`, which is the whole reason the joined form
|
|
6810
|
+
* is masked too.
|
|
6811
|
+
*
|
|
6812
|
+
* This OVER-MASKS relative to pflag, and the earlier claim that it "follows
|
|
6813
|
+
* docker's own parse" was wrong: pflag stops at the first cluster letter that
|
|
6814
|
+
* consumes a value, so it reads `-file=a=b` as `-f` taking `ile=a=b` while
|
|
6815
|
+
* this masks at the `e`. Every such divergence costs a diagnostic and hides
|
|
6816
|
+
* nothing that was not already a `KEY=VALUE` pair, so the direction is the
|
|
6817
|
+
* safe one — but it is a divergence, not parity.
|
|
6818
|
+
*/
|
|
6819
|
+
function maskAttachedShortFlag(arg) {
|
|
6820
|
+
if (arg.startsWith("--") || !arg.startsWith("-")) return void 0;
|
|
6821
|
+
let chosen;
|
|
6822
|
+
for (const flag of ARGV_VALUE_BEARING_FLAGS) {
|
|
6823
|
+
if (flag.length !== 2 || !flag.startsWith("-") || flag[1] === "-") continue;
|
|
6824
|
+
const at = arg.indexOf(flag[1], 1);
|
|
6825
|
+
if (at < 0 || at === arg.length - 1) continue;
|
|
6826
|
+
if (chosen === void 0 || at < chosen.at) chosen = {
|
|
6827
|
+
at,
|
|
6828
|
+
flag
|
|
6829
|
+
};
|
|
6830
|
+
}
|
|
6831
|
+
if (chosen === void 0) return void 0;
|
|
6832
|
+
const masked = maskArgvFlagValue(chosen.flag, arg.slice(chosen.at + 1));
|
|
6833
|
+
return masked === void 0 ? void 0 : `${arg.slice(0, chosen.at + 1)}${masked}`;
|
|
6834
|
+
}
|
|
6835
|
+
/**
|
|
6836
|
+
* The value-bearing short flag a shorthand CLUSTER ENDS in, if any — the form
|
|
6837
|
+
* that takes its value from the NEXT argv element.
|
|
6838
|
+
*
|
|
6839
|
+
* pflag's `parseSingleShortArg` falls through to `value = args[0]` when the
|
|
6840
|
+
* cluster runs out, so `-itde K=v` is `-i -t -d -e K=v` with the value in a
|
|
6841
|
+
* separate token. {@link maskAttachedShortFlag} bails on a flag letter in last
|
|
6842
|
+
* position (there is nothing attached to mask) and the two-token branch looked
|
|
6843
|
+
* `-itde` up as a whole flag and missed, so this spelling printed verbatim
|
|
6844
|
+
* until [#2623](https://github.com/go-to-k/cdkd/issues/2623)'s round-4 review
|
|
6845
|
+
* measured it. Pass 2 cannot rescue it either: its alternation needs a
|
|
6846
|
+
* whitespace-preceded `-e`.
|
|
6847
|
+
*/
|
|
6848
|
+
function trailingShortFlagOfCluster(arg) {
|
|
6849
|
+
if (arg.startsWith("--") || !arg.startsWith("-")) return void 0;
|
|
6850
|
+
const last = arg[arg.length - 1];
|
|
6851
|
+
for (const flag of ARGV_VALUE_BEARING_FLAGS) if (flag.length === 2 && flag.startsWith("-") && flag[1] !== "-" && flag[1] === last) return flag;
|
|
6852
|
+
}
|
|
6853
|
+
/**
|
|
6529
6854
|
* Structural, whitespace-delimited form of {@link ARGV_VALUE_BEARING_FLAGS}
|
|
6530
6855
|
* for scanning a STRING that embeds a space-joined argv. Keyed on the FLAG's
|
|
6531
6856
|
* position — never on the secret's value, so an unrelated literal that merely
|
|
6532
6857
|
* coincides with a value is left alone.
|
|
6533
6858
|
*
|
|
6859
|
+
* Covers the SEPARATED and JOINED spellings of
|
|
6860
|
+
* {@link ARGV_VALUE_BEARING_FLAGS} only. Two gaps, recorded rather than
|
|
6861
|
+
* implied. pflag's shorthand cluster is not modelled here in EITHER form —
|
|
6862
|
+
* attached (`-eKEY=VALUE` / `-itdeKEY=VALUE`) or separated (`-itde KEY=VALUE`,
|
|
6863
|
+
* which this pass's alternation cannot see because it needs a
|
|
6864
|
+
* whitespace-preceded `-e`) — this pass has no argv to resolve
|
|
6865
|
+
* the cluster against, and a bare `-e` prefix scan over free text would fire
|
|
6866
|
+
* on any word starting with `-e`; {@link redactDockerArgvValues} and passes 1
|
|
6867
|
+
* / 1b handle it wherever an argv IS available, which is every composer. And
|
|
6868
|
+
* {@link ARGV_PARAM_LIST_FLAGS}
|
|
6869
|
+
* is deliberately absent: this pass runs with NO argv to key on, and picking a
|
|
6870
|
+
* param name out of a free-text comma list is where a structural match stops
|
|
6871
|
+
* being structural. The param mask therefore rides passes 1 and 1b, both of
|
|
6872
|
+
* which derive from {@link redactDockerArgvValues} and so have the real argv —
|
|
6873
|
+
* i.e. a cache credential is masked in every text composed through a composer,
|
|
6874
|
+
* and not in a bare text handed to {@link redactDockerArgvInText} with no
|
|
6875
|
+
* `args`. Recorded rather than implied.
|
|
6876
|
+
*
|
|
6877
|
+
* DERIVED from the Set rather than re-spelled beside it
|
|
6878
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)). Until then this was
|
|
6879
|
+
* a hand-written literal listing the same four flags, i.e. the shape where
|
|
6880
|
+
* adding a flag to one spelling and not the other masks it on the argv pass
|
|
6881
|
+
* and leaks it on the text pass — a divergence no behavioural test of either
|
|
6882
|
+
* one can see. Deriving is stronger than fencing the pair: there is nothing
|
|
6883
|
+
* left to diverge.
|
|
6884
|
+
*
|
|
6534
6885
|
* What each piece actually buys, since one of them was mis-credited in review:
|
|
6535
6886
|
* the `(^|\s)` lead is what stops `-e` matching inside `--env` (there is no
|
|
6536
6887
|
* `m` flag, so `^` is start-of-INPUT), NOT the alternation order — reordering
|
|
6537
6888
|
* the branches leaves every test green. The order is kept as belt and braces
|
|
6538
|
-
* only
|
|
6539
|
-
* `-easy` / `--environment` matching
|
|
6540
|
-
*
|
|
6541
|
-
|
|
6542
|
-
|
|
6889
|
+
* only, which is why the derivation sorts longest-first. The separator group
|
|
6890
|
+
* IS load-bearing: `\s+` is what stops `-easy` / `--environment` matching, and
|
|
6891
|
+
* the `=` alternative is the joined `--build-arg=KEY=VALUE` spelling
|
|
6892
|
+
* {@link maskArgvFlagValue} handles on the argv side (the group can never be
|
|
6893
|
+
* empty, so `--build-argument=X=y` still cannot match). The KEY is `[^\s=]*`
|
|
6894
|
+
* rather than `+` so an EMPTY key (`-e =value`) is masked too.
|
|
6895
|
+
*
|
|
6896
|
+
* The alternation is asserted NON-EMPTY at construction. An empty
|
|
6897
|
+
* {@link ARGV_VALUE_BEARING_FLAGS} would collapse it to `(^|\s)()(\s+|=)…`,
|
|
6898
|
+
* which masks EVERY whitespace-preceded `k=v` in any docker text — a
|
|
6899
|
+
* fail-OPEN that destroys the diagnostic wholesale, and the one failure mode
|
|
6900
|
+
* deriving from the Set introduced.
|
|
6901
|
+
*/
|
|
6902
|
+
const ARGV_VALUE_TOKEN_RE = (() => {
|
|
6903
|
+
const alternation = [...ARGV_VALUE_BEARING_FLAGS].sort((a, b) => b.length - a.length || a.localeCompare(b)).map(escapeRegExp$1).join("|");
|
|
6904
|
+
if (alternation.length === 0 || [...ARGV_VALUE_BEARING_FLAGS].some((f) => f.trim() === "")) throw new Error("ARGV_VALUE_BEARING_FLAGS is empty or holds an empty flag: the token scan would match every k=v");
|
|
6905
|
+
return new RegExp(`(^|\\s)(${alternation})(\\s+|=)([^\\s=]*)=\\S*`, "g");
|
|
6906
|
+
})();
|
|
6543
6907
|
/**
|
|
6544
6908
|
* Return a copy of `args` with the VALUE of every
|
|
6545
|
-
* {@link ARGV_VALUE_BEARING_FLAGS} pair replaced by
|
|
6546
|
-
*
|
|
6547
|
-
*
|
|
6909
|
+
* {@link ARGV_VALUE_BEARING_FLAGS} pair replaced by `***`, and the credential
|
|
6910
|
+
* PARAMS of every {@link ARGV_PARAM_LIST_FLAGS} value replaced likewise. The
|
|
6911
|
+
* KEY survives — "which variable" is the diagnostic, "what it was set to" is
|
|
6912
|
+
* the disclosure.
|
|
6913
|
+
*
|
|
6914
|
+
* All FOUR argv spellings are handled: `--flag VALUE` (two tokens, what every
|
|
6915
|
+
* argv this repo builds emits), `--flag=VALUE`, and pflag's shorthand cluster
|
|
6916
|
+
* in both its forms — attached (`-itdeKEY=VALUE`) and separated
|
|
6917
|
+
* (`-itde KEY=VALUE`, where the cluster ends in the flag letter and pflag
|
|
6918
|
+
* takes the NEXT token). The last three are handled because a user's own
|
|
6919
|
+
* build script may spell them
|
|
6920
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)).
|
|
6548
6921
|
* A value-less `-e KEY` (the form {@link partitionSensitiveEnv} emits for a
|
|
6549
6922
|
* sensitive key) has nothing to mask and is returned unchanged. `args` is
|
|
6550
6923
|
* never mutated: the redacted copy is for DISPLAY only, never for `spawn`.
|
|
@@ -6553,11 +6926,24 @@ function redactDockerArgvValues(args) {
|
|
|
6553
6926
|
const out = [];
|
|
6554
6927
|
for (let i = 0; i < args.length; i++) {
|
|
6555
6928
|
const cur = args[i];
|
|
6929
|
+
const joinedEq = cur.indexOf("=");
|
|
6930
|
+
if (joinedEq > 0) {
|
|
6931
|
+
const maskedJoined = maskArgvFlagValue(cur.substring(0, joinedEq), cur.substring(joinedEq + 1));
|
|
6932
|
+
if (maskedJoined !== void 0) {
|
|
6933
|
+
out.push(`${cur.substring(0, joinedEq)}=${maskedJoined}`);
|
|
6934
|
+
continue;
|
|
6935
|
+
}
|
|
6936
|
+
}
|
|
6937
|
+
const maskedAttached = maskAttachedShortFlag(cur);
|
|
6938
|
+
if (maskedAttached !== void 0) {
|
|
6939
|
+
out.push(maskedAttached);
|
|
6940
|
+
continue;
|
|
6941
|
+
}
|
|
6556
6942
|
const next = args[i + 1];
|
|
6557
|
-
if (
|
|
6558
|
-
const
|
|
6559
|
-
if (
|
|
6560
|
-
out.push(cur,
|
|
6943
|
+
if (typeof next === "string") {
|
|
6944
|
+
const maskedNext = maskArgvFlagValue(ARGV_VALUE_BEARING_FLAGS.has(cur) ? cur : trailingShortFlagOfCluster(cur) ?? cur, next);
|
|
6945
|
+
if (maskedNext !== void 0) {
|
|
6946
|
+
out.push(cur, maskedNext);
|
|
6561
6947
|
i++;
|
|
6562
6948
|
continue;
|
|
6563
6949
|
}
|
|
@@ -6881,32 +7267,39 @@ async function buildDockerImage(asset, cdkOutDir, options) {
|
|
|
6881
7267
|
const [cmd, ...args] = source.executable;
|
|
6882
7268
|
if (!cmd) throw options.wrapError("asset source.executable[] is empty");
|
|
6883
7269
|
const cwd = source.directory ? `${cdkOutDir}/${source.directory}` : cdkOutDir;
|
|
6884
|
-
|
|
7270
|
+
const shownExecutable = redactDockerArgvValues(source.executable).join(" ");
|
|
7271
|
+
logger.debug(`Building Docker image via executable: ${shownExecutable} (cwd=${cwd})`);
|
|
6885
7272
|
let result;
|
|
6886
7273
|
try {
|
|
6887
7274
|
result = await spawnStreaming(cmd, args, { cwd });
|
|
6888
7275
|
} catch (err) {
|
|
6889
|
-
|
|
6890
|
-
throw options.wrapError(e.stderr || e.message || String(err));
|
|
7276
|
+
throw options.wrapError(describeDockerFailure(err, args));
|
|
6891
7277
|
}
|
|
6892
7278
|
const tag = result.stdout.trim();
|
|
6893
|
-
if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${
|
|
7279
|
+
if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${shownExecutable}`);
|
|
6894
7280
|
return tag;
|
|
6895
7281
|
}
|
|
6896
|
-
if (!source.directory)
|
|
7282
|
+
if (!source.directory) {
|
|
7283
|
+
const carriesAValue = (v) => {
|
|
7284
|
+
if (v === void 0 || v === null || v === "") return false;
|
|
7285
|
+
if (typeof v !== "object") return true;
|
|
7286
|
+
return (Array.isArray(v) ? v.length : Object.keys(v).length) > 0;
|
|
7287
|
+
};
|
|
7288
|
+
const present = Object.keys(source).filter((k) => carriesAValue(source[k])).map((k) => displaySafe(k, { asciiOnly: true })).filter((k) => k !== "").sort().join(", ") || "<no fields set>";
|
|
7289
|
+
throw options.wrapError(`DockerImageAssetSource must set either 'directory' or 'executable' (fields present: ${present})`);
|
|
7290
|
+
}
|
|
6897
7291
|
if (!options.tag) throw options.wrapError("buildDockerImage(directory mode) requires options.tag");
|
|
6898
7292
|
const buildArgs = buildDockerBuildCommand(source, options.tag, options.platform);
|
|
6899
7293
|
const contextDir = `${cdkOutDir}/${source.directory}`;
|
|
6900
7294
|
buildArgs.push(".");
|
|
6901
|
-
logger.debug(`${getDockerCmd()} ${buildArgs.join(" ")} (cwd=${contextDir})`);
|
|
7295
|
+
logger.debug(`${getDockerCmd()} ${redactDockerArgvValues(buildArgs).join(" ")} (cwd=${contextDir})`);
|
|
6902
7296
|
try {
|
|
6903
7297
|
await runDockerStreaming(buildArgs, {
|
|
6904
7298
|
cwd: contextDir,
|
|
6905
7299
|
env: { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" }
|
|
6906
7300
|
});
|
|
6907
7301
|
} catch (err) {
|
|
6908
|
-
|
|
6909
|
-
throw options.wrapError(e.stderr || e.message || String(err));
|
|
7302
|
+
throw options.wrapError(describeDockerFailure(err, buildArgs));
|
|
6910
7303
|
}
|
|
6911
7304
|
return options.tag;
|
|
6912
7305
|
}
|
|
@@ -7074,8 +7467,9 @@ var DockerAssetPublisher = class {
|
|
|
7074
7467
|
}
|
|
7075
7468
|
/**
|
|
7076
7469
|
* Build Docker image — delegates to the shared `buildDockerImage`
|
|
7077
|
-
* helper so this code path stays in sync with `cdkd local
|
|
7078
|
-
*
|
|
7470
|
+
* helper so this code path stays in sync with `cdkd local run-task`'s
|
|
7471
|
+
* `ContainerImage.fromAsset` build path (the other caller; `cdkd local
|
|
7472
|
+
* invoke` moved to `cdk-local`'s own builder). `--platform` is read from the asset
|
|
7079
7473
|
* manifest's `source.platform` (when set); cdkd does not currently
|
|
7080
7474
|
* inject a publish-side override.
|
|
7081
7475
|
*
|
|
@@ -7159,33 +7553,33 @@ var DockerAssetPublisher = class {
|
|
|
7159
7553
|
const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
|
|
7160
7554
|
if (!username || password === void 0) throw new AssetError("ECR authorization token has unexpected shape (missing username/password)");
|
|
7161
7555
|
const endpoint = authData.proxyEndpoint || `https://${accountId}.dkr.ecr.${region}.${ecrUrlSuffix(region)}`;
|
|
7556
|
+
const loginArgs = [
|
|
7557
|
+
"login",
|
|
7558
|
+
"--username",
|
|
7559
|
+
username,
|
|
7560
|
+
"--password-stdin",
|
|
7561
|
+
endpoint
|
|
7562
|
+
];
|
|
7162
7563
|
try {
|
|
7163
|
-
await runDockerStreaming(
|
|
7164
|
-
"login",
|
|
7165
|
-
"--username",
|
|
7166
|
-
username,
|
|
7167
|
-
"--password-stdin",
|
|
7168
|
-
endpoint
|
|
7169
|
-
], { input: password });
|
|
7564
|
+
await runDockerStreaming(loginArgs, { input: password });
|
|
7170
7565
|
loggedInRegistries.add(registryKey);
|
|
7171
7566
|
} catch (err) {
|
|
7172
|
-
|
|
7173
|
-
throw new AssetError(`ECR login failed: ${formatDockerLoginError(e.stderr || e.message || String(err), endpoint)}`);
|
|
7567
|
+
throw new AssetError(`ECR login failed: ${formatDockerLoginError(describeDockerFailure(err, loginArgs), endpoint)}`);
|
|
7174
7568
|
}
|
|
7175
7569
|
}
|
|
7176
7570
|
/**
|
|
7177
7571
|
* Tag Docker image
|
|
7178
7572
|
*/
|
|
7179
7573
|
async tagImage(source, target) {
|
|
7574
|
+
const tagArgs = [
|
|
7575
|
+
"tag",
|
|
7576
|
+
source,
|
|
7577
|
+
target
|
|
7578
|
+
];
|
|
7180
7579
|
try {
|
|
7181
|
-
await runDockerStreaming(
|
|
7182
|
-
"tag",
|
|
7183
|
-
source,
|
|
7184
|
-
target
|
|
7185
|
-
]);
|
|
7580
|
+
await runDockerStreaming(tagArgs);
|
|
7186
7581
|
} catch (err) {
|
|
7187
|
-
|
|
7188
|
-
throw new AssetError(`Docker tag failed: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
7582
|
+
throw new AssetError(`Docker tag failed: ${describeDockerFailure(err, tagArgs)}`);
|
|
7189
7583
|
}
|
|
7190
7584
|
}
|
|
7191
7585
|
/**
|
|
@@ -7195,11 +7589,11 @@ var DockerAssetPublisher = class {
|
|
|
7195
7589
|
*/
|
|
7196
7590
|
async pushImage(uri) {
|
|
7197
7591
|
this.logger.debug(`Pushing: ${uri}`);
|
|
7592
|
+
const pushArgs = ["push", uri];
|
|
7198
7593
|
try {
|
|
7199
|
-
await runDockerStreaming(
|
|
7594
|
+
await runDockerStreaming(pushArgs);
|
|
7200
7595
|
} catch (err) {
|
|
7201
|
-
|
|
7202
|
-
throw new AssetError(`Docker push failed: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
7596
|
+
throw new AssetError(`Docker push failed: ${describeDockerFailure(err, pushArgs)}`);
|
|
7203
7597
|
}
|
|
7204
7598
|
}
|
|
7205
7599
|
/**
|
|
@@ -7937,49 +8331,6 @@ var AssetModeResolver = class {
|
|
|
7937
8331
|
}
|
|
7938
8332
|
};
|
|
7939
8333
|
|
|
7940
|
-
//#endregion
|
|
7941
|
-
//#region src/utils/regexp.ts
|
|
7942
|
-
/**
|
|
7943
|
-
* Escape a literal string for embedding in a RegExp.
|
|
7944
|
-
*
|
|
7945
|
-
* Lives here because the same four-line helper was spelled THREE times — in
|
|
7946
|
-
* `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
|
|
7947
|
-
* `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
|
|
7948
|
-
* (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
|
|
7949
|
-
* label) into a pattern. Copies of a security-shaped helper are exactly the
|
|
7950
|
-
* shape that drifts: the escaped set is what stops a `.` in an interpolated
|
|
7951
|
-
* literal matching ANY character, and in `gc.ts` a widened match decides which
|
|
7952
|
-
* live assets are treated as referenced.
|
|
7953
|
-
*
|
|
7954
|
-
* The character set is the union every JS engine treats as special in a
|
|
7955
|
-
* non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
|
|
7956
|
-
* character class, and no caller embeds into one.
|
|
7957
|
-
*/
|
|
7958
|
-
function escapeRegExp$1(value) {
|
|
7959
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7960
|
-
}
|
|
7961
|
-
/**
|
|
7962
|
-
* Remove control characters from a value about to be PRINTED.
|
|
7963
|
-
*
|
|
7964
|
-
* Lives beside {@link escapeRegExp} for the reason that helper's own note
|
|
7965
|
-
* gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
|
|
7966
|
-
* in `src/deployment/outputs-export-alias.ts` — and copies of a
|
|
7967
|
-
* security-shaped helper drift. Both print the same class of string: a
|
|
7968
|
-
* resolved CloudFormation Output / `Export.Name`, which passed no CFn
|
|
7969
|
-
* validator, so it can carry ANSI escapes or bidi overrides straight into a
|
|
7970
|
-
* terminal or a CI log.
|
|
7971
|
-
*
|
|
7972
|
-
* C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
|
|
7973
|
-
* only: a `--json` payload is a machine interface where an export name is data
|
|
7974
|
-
* a consumer may match on, and mutating it there trades a correctness
|
|
7975
|
-
* regression for a display concern (see `stripDisplayOnlyChars`, which is the
|
|
7976
|
-
* narrower guard for an already-serialized payload and stays local to its
|
|
7977
|
-
* caller because its C0 exclusion is specific to that path).
|
|
7978
|
-
*/
|
|
7979
|
-
function stripControlChars(value) {
|
|
7980
|
-
return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
7981
|
-
}
|
|
7982
|
-
|
|
7983
8334
|
//#endregion
|
|
7984
8335
|
//#region src/assets/asset-redirect.ts
|
|
7985
8336
|
/**
|
|
@@ -8861,6 +9212,16 @@ function formatRemaining(ms) {
|
|
|
8861
9212
|
if (minutes < 1) return "in under a minute";
|
|
8862
9213
|
return `in ~${minutes}m`;
|
|
8863
9214
|
}
|
|
9215
|
+
/**
|
|
9216
|
+
* Quote a value for a pasteable shell command.
|
|
9217
|
+
*
|
|
9218
|
+
* EXPORTED since issue [#2610]: `src/provisioning/replacement-protection-advice.ts`
|
|
9219
|
+
* prints `aws <service> ...` recovery commands naming a resource's physical id,
|
|
9220
|
+
* which is the same hazard one directory over. A second spelling of this
|
|
9221
|
+
* predicate is how the two would come to disagree about which values need
|
|
9222
|
+
* quoting -- the reason `display-safe.ts`'s header gives for not widening a
|
|
9223
|
+
* rule by hand. It is a pure function of its argument and imports nothing.
|
|
9224
|
+
*/
|
|
8864
9225
|
function shellQuote(value) {
|
|
8865
9226
|
return /^[A-Za-z0-9._/@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
8866
9227
|
}
|
|
@@ -8880,6 +9241,18 @@ function buildForceUnlockCommand(stackName, region, recovery) {
|
|
|
8880
9241
|
return parts.join(" ");
|
|
8881
9242
|
}
|
|
8882
9243
|
/**
|
|
9244
|
+
* What to say INSTEAD of a `cdkd force-unlock ...` line when
|
|
9245
|
+
* {@link buildForceUnlockCommand} suppresses.
|
|
9246
|
+
*
|
|
9247
|
+
* Exported since issue [#2610]: `lock-manager.ts`'s exhausted-retry arm needed
|
|
9248
|
+
* the same branch, and it was the THIRD place to spell it. The module header's
|
|
9249
|
+
* point applies to the suppression sentence as much as to the command -- a
|
|
9250
|
+
* banner ending in a bare `run: ` is the shape the review found, and copies are
|
|
9251
|
+
* how the next one drifts. Byte-identical to what `forceQuitRecoveryClause`
|
|
9252
|
+
* emitted before the extraction; its callers see no change.
|
|
9253
|
+
*/
|
|
9254
|
+
const UNREPRODUCIBLE_LOCK_CLAUSE = "Inspect the lock object directly: the name or region recorded for this stack cannot be reproduced safely on a command line, so any command shown here would address a different lock.";
|
|
9255
|
+
/**
|
|
8883
9256
|
* The force-quit banner's recovery sentence.
|
|
8884
9257
|
*
|
|
8885
9258
|
* Exported so the two `destroy-runner.ts` banners do not each decide what to
|
|
@@ -8890,7 +9263,7 @@ function buildForceUnlockCommand(stackName, region, recovery) {
|
|
|
8890
9263
|
*/
|
|
8891
9264
|
function forceQuitRecoveryClause(stackName, region, recovery) {
|
|
8892
9265
|
const command = buildForceUnlockCommand(stackName, region, recovery);
|
|
8893
|
-
return command ? ` If the next run reports a lock, run: ${command}` :
|
|
9266
|
+
return command ? ` If the next run reports a lock, run: ${command}` : ` ${UNREPRODUCIBLE_LOCK_CLAUSE}`;
|
|
8894
9267
|
}
|
|
8895
9268
|
/**
|
|
8896
9269
|
* Build the contention message, reading the holder's identity best-effort.
|
|
@@ -10138,7 +10511,7 @@ var LockManager = class {
|
|
|
10138
10511
|
}
|
|
10139
10512
|
return false;
|
|
10140
10513
|
}
|
|
10141
|
-
throw new LockError(`Failed to acquire lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : void 0);
|
|
10514
|
+
throw new LockError(`Failed to acquire lock for stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}): ${displaySafe(error instanceof Error ? error.message : String(error))}`, error instanceof Error ? error : void 0);
|
|
10142
10515
|
}
|
|
10143
10516
|
}
|
|
10144
10517
|
/**
|
|
@@ -10362,8 +10735,11 @@ var LockManager = class {
|
|
|
10362
10735
|
/**
|
|
10363
10736
|
* Force release a lock regardless of owner or expiry status
|
|
10364
10737
|
*
|
|
10365
|
-
* This is intended for CLI usage
|
|
10366
|
-
* is stuck and needs manual
|
|
10738
|
+
* This is intended for CLI usage -- it is what the `cdkd force-unlock
|
|
10739
|
+
* <stack>` SUBCOMMAND calls -- when a lock is stuck and needs manual
|
|
10740
|
+
* intervention. There is no `--force-unlock` FLAG anywhere in `src/`; this
|
|
10741
|
+
* comment said there was, and issue [#2610] site 14 records that the message
|
|
10742
|
+
* `acquireLockWithRetry` used to raise had inherited the same mistake.
|
|
10367
10743
|
*
|
|
10368
10744
|
* Pass `region: undefined` to operate on a legacy
|
|
10369
10745
|
* `{prefix}/{stackName}/lock.json` file.
|
|
@@ -10706,7 +11082,7 @@ var LockManager = class {
|
|
|
10706
11082
|
if (lockInfo) {
|
|
10707
11083
|
const remainingMs = lockInfo.expiresAt - Date.now();
|
|
10708
11084
|
if (attempt < maxRetries) {
|
|
10709
|
-
this.logger.info(`Stack '${stackName}' (${region}) is locked by ${lockInfo.owner}${lockInfo.operation ? ` (operation: ${lockInfo.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`);
|
|
11085
|
+
this.logger.info(`Stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}) is locked by ${lockInfo.owner}${lockInfo.operation ? ` (operation: ${lockInfo.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`);
|
|
10710
11086
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
10711
11087
|
continue;
|
|
10712
11088
|
}
|
|
@@ -10714,7 +11090,9 @@ var LockManager = class {
|
|
|
10714
11090
|
}
|
|
10715
11091
|
const lockInfo = await this.getLockInfo(stackName, region);
|
|
10716
11092
|
const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
|
|
10717
|
-
|
|
11093
|
+
const forceUnlockCommand = buildForceUnlockCommand(stackName, region);
|
|
11094
|
+
const recovery = forceUnlockCommand ? `If you are certain no other process is active, run: ${forceUnlockCommand}` : `If you are certain no other process is active, ${UNREPRODUCIBLE_LOCK_CLAUSE.charAt(0).toLowerCase()}${UNREPRODUCIBLE_LOCK_CLAUSE.slice(1)}`;
|
|
11095
|
+
throw new LockError(`Failed to acquire lock for stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}) after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. ` + recovery : `Lock exists but could not read lock info. ${recovery}`));
|
|
10718
11096
|
}
|
|
10719
11097
|
};
|
|
10720
11098
|
|
|
@@ -10783,7 +11161,9 @@ function recordResolvedPair(secrets, expression, plaintext) {
|
|
|
10783
11161
|
* evidence — so a literal `Output` embedding one of two same-plaintext
|
|
10784
11162
|
* references would fall back to the value scan and persist the sibling's
|
|
10785
11163
|
* expression. The engine's other entry-by-entry copy — an `Export.Name`'s
|
|
10786
|
-
* secrets into the pass map — deliberately does NOT call this
|
|
11164
|
+
* secrets into the pass map — deliberately does NOT call this (and `cdkd
|
|
11165
|
+
* scrub`'s name loop resolves through a VIEW whose pairs never reach the pass
|
|
11166
|
+
* map at all, issue #2531): a name never
|
|
10787
11167
|
* positions a leaf, a value re-using the same token records its own pair at
|
|
10788
11168
|
* the seam, and the only thing the merge could add is a CONFLICT (a
|
|
10789
11169
|
* non-cacheable `{{resolve:ssm:X}}` whose value moved between the value pass
|
|
@@ -11468,7 +11848,7 @@ function storeAssociation(associations, key, expression, plaintext) {
|
|
|
11468
11848
|
* matters: an AWS resource type string is fixed by AWS, and a typo in either
|
|
11469
11849
|
* copy makes that copy's gate simply never fire (no-op), never fire wrongly.
|
|
11470
11850
|
*/
|
|
11471
|
-
const NESTED_STACK_RESOURCE_TYPE$
|
|
11851
|
+
const NESTED_STACK_RESOURCE_TYPE$2 = "AWS::CloudFormation::Stack";
|
|
11472
11852
|
/**
|
|
11473
11853
|
* What a PARENT stack's resolution proved about each `Parameters` entry of an
|
|
11474
11854
|
* `AWS::CloudFormation::Stack` row it is about to provision, keyed by the
|
|
@@ -11575,7 +11955,7 @@ const nestedStackParameterExpressions = /* @__PURE__ */ new WeakMap();
|
|
|
11575
11955
|
* `secret-redaction-nested-parameter-source.test.ts`.
|
|
11576
11956
|
*/
|
|
11577
11957
|
function recordNestedStackParameterExpressions(secrets, resourceType, resolvedProperties, sourceProperties, rules = TEMPLATE_DERIVED_RULES) {
|
|
11578
|
-
if (resourceType !== NESTED_STACK_RESOURCE_TYPE$
|
|
11958
|
+
if (resourceType !== NESTED_STACK_RESOURCE_TYPE$2) return;
|
|
11579
11959
|
if (secrets.size === 0) return;
|
|
11580
11960
|
if (!isPlainObject$2(resolvedProperties) || !isPlainObject$2(sourceProperties)) return;
|
|
11581
11961
|
if (!Object.hasOwn(resolvedProperties, "Parameters")) return;
|
|
@@ -16753,9 +17133,28 @@ function secretsManagerSecretId(inner) {
|
|
|
16753
17133
|
* - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
|
|
16754
17134
|
* (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
|
|
16755
17135
|
* a different thing in the refusal message than the one that would be read.
|
|
17136
|
+
*
|
|
17137
|
+
* A COLON-LESS body (`{{resolve:ssm}}` / `{{resolve:ssm-secure}}`) names no
|
|
17138
|
+
* parameter at all, and the empty string is what says so: the caller reads a
|
|
17139
|
+
* falsy name as `local`, which hands the reference on to the resolver's own
|
|
17140
|
+
* `PARAMETER_NAME is required` — thrown BEFORE any client is built, so no
|
|
17141
|
+
* `GetParameter` is ever issued for such a token. Without the guard
|
|
17142
|
+
* `indexOf(':')` is `-1` and `substring(0)` returns the SERVICE STRING as the
|
|
17143
|
+
* parameter name, so with a foreign producer region on record the same
|
|
17144
|
+
* malformed input drew the ambiguous-region refusal naming `'ssm-secure'` as
|
|
17145
|
+
* the secret. {@link secretsManagerSecretId} already answers `''` for its own
|
|
17146
|
+
* nameless body (a fixed-length `substring` past the end of the string), and a
|
|
17147
|
+
* sibling that answers something else is the kind of split a later reader has
|
|
17148
|
+
* to rediscover.
|
|
17149
|
+
*
|
|
17150
|
+
* The colon-less relaxation has a CONSUMER-VISIBLE consequence on a mixed
|
|
17151
|
+
* leaf, and it is recorded on {@link classifyReplaySecretRegion} rather than
|
|
17152
|
+
* here: this function produces a NAME, and the delta belongs beside the
|
|
17153
|
+
* VERDICT a reader arrives at it through.
|
|
16756
17154
|
*/
|
|
16757
17155
|
function ssmParameterName(inner) {
|
|
16758
|
-
|
|
17156
|
+
const serviceEnd = inner.indexOf(":");
|
|
17157
|
+
return serviceEnd < 0 ? "" : inner.substring(serviceEnd + 1);
|
|
16759
17158
|
}
|
|
16760
17159
|
/**
|
|
16761
17160
|
* The region an ARN names, or `undefined` for anything that is not an ARN with
|
|
@@ -16893,6 +17292,101 @@ function producerRegionsFromState(state) {
|
|
|
16893
17292
|
* A same-region ARN answers `local` even when a foreign producer region IS on
|
|
16894
17293
|
* record: the expression settles the question itself, so the weaker evidence
|
|
16895
17294
|
* never gets consulted.
|
|
17295
|
+
*
|
|
17296
|
+
* ---
|
|
17297
|
+
*
|
|
17298
|
+
* WHO CONSUMES THIS VERDICT, and what the issue #2501 colon-less fix changed
|
|
17299
|
+
* for them. Recorded here because a reader arrives at the blast radius through
|
|
17300
|
+
* the verdict, not through {@link ssmParameterName}, which merely produces a
|
|
17301
|
+
* name.
|
|
17302
|
+
*
|
|
17303
|
+
* Three LEAF PRE-PASSES consume it — `rollback-executor.ts`'s
|
|
17304
|
+
* `resolveLeafByRegion`, `cdkd drift`'s `resolveDriftLeafByRegion` and `cdkd
|
|
17305
|
+
* scrub`'s — and each refuses the WHOLE leaf as soon as ONE token classifies
|
|
17306
|
+
* `ambiguous`, before any token is fetched. There is a FOURTH consumer, and it
|
|
17307
|
+
* is not a pre-pass: `resolveDynamicReferences` classifies token-by-token
|
|
17308
|
+
* INSIDE its substitution loop, so it already fetches earlier tokens before
|
|
17309
|
+
* refusing a later one. That is why the pre-fetch refusal was never a designed
|
|
17310
|
+
* property of this verdict.
|
|
17311
|
+
*
|
|
17312
|
+
* Before the fix, a colon-less `{{resolve:ssm-secure}}` produced the SERVICE
|
|
17313
|
+
* STRING as its name, so with a foreign producer region on record it drew an
|
|
17314
|
+
* `ambiguous` verdict and the pre-pass refused the whole leaf: for
|
|
17315
|
+
* `local=<same-region ARN>;secure={{resolve:ssm-secure}}`, nothing was
|
|
17316
|
+
* resolved. It now answers `local`, so the leaf IS resolved — by the primary
|
|
17317
|
+
* resolver when every other token is `local`, or through the segment-rebuild
|
|
17318
|
+
* path when one is `named-region` — and the ARN sibling's plaintext is fetched
|
|
17319
|
+
* and cached before the degenerate token throws `PARAMETER_NAME is required`.
|
|
17320
|
+
* Pinned by `rollback-executor-cross-region-secret.test.ts`'s "the MIXED-leaf
|
|
17321
|
+
* delta the colon-less guard accepts", so the trade is visible rather than
|
|
17322
|
+
* argued.
|
|
17323
|
+
*
|
|
17324
|
+
* Accepted on three grounds, the third being a LIMIT rather than a
|
|
17325
|
+
* reassurance:
|
|
17326
|
+
*
|
|
17327
|
+
* 1. The lost refusal was an ACCIDENT of the mis-parse, not a designed
|
|
17328
|
+
* protection: with no foreign producer region on record — the
|
|
17329
|
+
* overwhelmingly common case — the colon-less token classified `local`
|
|
17330
|
+
* before the fix too, so the same sibling was already fetched.
|
|
17331
|
+
* 2. The sibling is fetched from the region its OWN verdict names, never a
|
|
17332
|
+
* guessed one (a name-form sibling would itself be `ambiguous` and the
|
|
17333
|
+
* pre-pass would still refuse), so nothing here weakens the issue #1957
|
|
17334
|
+
* rule this module exists to enforce. Nothing fetched reaches a DURABLE
|
|
17335
|
+
* sink — `state.json`, the rollback journal, the `deployments/` event
|
|
17336
|
+
* store, a `--json` payload — and the reason is NOT "the op throws before
|
|
17337
|
+
* any write", which is false for scrub (see 3: scrub swallows and keeps
|
|
17338
|
+
* writing). It is that both places a fetched plaintext is retained are
|
|
17339
|
+
* IN-PROCESS and neither is copied out: the resolver's own
|
|
17340
|
+
* `cachedDynamicReferences` (instance-scoped since issue #1933, so it dies
|
|
17341
|
+
* with the resolver) and `recordedSecretValues`, which every persist path
|
|
17342
|
+
* consults as a redaction NEEDLE set — an entry there causes a value to be
|
|
17343
|
+
* REPLACED BY its expression on the way out, never inserted.
|
|
17344
|
+
*
|
|
17345
|
+
* ON THIS PATH one more needle can only redact more, which is why the
|
|
17346
|
+
* direction is safe even though it fetches more — and the qualifier is
|
|
17347
|
+
* load-bearing rather than hedging. It is NOT a general property of the
|
|
17348
|
+
* needle machinery: `redactSecretsForState`'s own doc (see the
|
|
17349
|
+
* `preferPositionDecisions` ordering note) records that a needle rewriting
|
|
17350
|
+
* a FRAME ANCHOR can un-certify `unkeyedArrayPairsByAnchors`, refuse the
|
|
17351
|
+
* array, and leave a sibling MIXED leaf in plaintext — "a regression of
|
|
17352
|
+
* shipped redaction, in the GHSA disclosure direction". That is reachable
|
|
17353
|
+
* with a NON-EMPTY map on a `STATE_SOURCED_READBACK_RULES` caller, e.g.
|
|
17354
|
+
* `rollback-executor.ts`'s `redactRollbackRecord` ->
|
|
17355
|
+
* `scrubResourceRecord`. It is not what fires here: the case this
|
|
17356
|
+
* paragraph is about records FEWER needles, not more, so nothing new can
|
|
17357
|
+
* rewrite an anchor.
|
|
17358
|
+
* 3. WHAT THE OP DOES NEXT IS NOT UNIFORM. The replay fails the op and `cdkd
|
|
17359
|
+
* drift` reports the resource NOT compared — both loud. `cdkd scrub` is
|
|
17360
|
+
* NOT: `isRegionAmbiguousRefusal` re-raises only a
|
|
17361
|
+
* `DynamicReferenceRegionAmbiguousError`, and the resolver's replacement is
|
|
17362
|
+
* a plain `Error`, so scrub swallows it to `debug` and the run reports
|
|
17363
|
+
* clean. A LOUDNESS regression for that one command on this one input,
|
|
17364
|
+
* bounded by the identical silent miss that already happens for the same
|
|
17365
|
+
* leaf whenever no foreign producer region is on record.
|
|
17366
|
+
*
|
|
17367
|
+
* Issue [#2692](https://github.com/go-to-k/cdkd/issues/2692) tracks it, and
|
|
17368
|
+
* the remedy is NOT resolver-local. Two candidate fixes are wrong, each in
|
|
17369
|
+
* its own way, and scrub has TWO predicates that must not be confused:
|
|
17370
|
+
* `isRegionAmbiguousRefusal` decides RE-RAISE vs SWALLOW (its three call
|
|
17371
|
+
* sites), while `isByDesignRefusal` — matching
|
|
17372
|
+
* `CrossAccountSecretRefusalError` — decides FINDING vs REFUSE. They point
|
|
17373
|
+
* in opposite directions, so a fix aimed at one must not be argued from
|
|
17374
|
+
* the other's record.
|
|
17375
|
+
*
|
|
17376
|
+
* - Throwing `IntrinsicResolutionRefusalError` and widening
|
|
17377
|
+
* `isRegionAmbiguousRefusal` to match the BASE class would make every
|
|
17378
|
+
* user-fixable refusal that class carries RE-RAISE and fail the whole
|
|
17379
|
+
* stack — not the silent-downgrade hazard `isByDesignRefusal`'s doc
|
|
17380
|
+
* records, but the opposite over-refusal, on a class with throw sites
|
|
17381
|
+
* spread across the resolver.
|
|
17382
|
+
* - Throwing the region-ambiguous SUBCLASS is wrong differently: scrub
|
|
17383
|
+
* already re-raises it, but its message tells the user to spell the
|
|
17384
|
+
* reference as a full ARN, which cannot fix a reference naming no
|
|
17385
|
+
* parameter.
|
|
17386
|
+
*
|
|
17387
|
+
* So #2692 needs a NEW sibling subclass AND a scrub-side predicate change,
|
|
17388
|
+
* which makes it blocked on `src/cli/commands/scrub.ts` (held by PR
|
|
17389
|
+
* #2562), not merely on the resolver's `integ-broad` cost.
|
|
16896
17390
|
*/
|
|
16897
17391
|
function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
|
|
16898
17392
|
const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
|
|
@@ -18586,7 +19080,7 @@ function carriesDynamicReference(value) {
|
|
|
18586
19080
|
return false;
|
|
18587
19081
|
}
|
|
18588
19082
|
/** The nested-stack resource type, whose `Outputs.<Name>` attributes are re-resolved (issue #2055). */
|
|
18589
|
-
const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
|
|
19083
|
+
const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
|
|
18590
19084
|
/** Prefix `NestedStackProvider` records a child stack output under. */
|
|
18591
19085
|
const NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX = "Outputs.";
|
|
18592
19086
|
/**
|
|
@@ -19720,6 +20214,10 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
19720
20214
|
const conditions = {};
|
|
19721
20215
|
const templateConditions = context.template.Conditions;
|
|
19722
20216
|
if (!templateConditions || typeof templateConditions !== "object") return conditions;
|
|
20217
|
+
const maskingContext = context.recordedSecretValues ? context : {
|
|
20218
|
+
...context,
|
|
20219
|
+
recordedSecretValues: /* @__PURE__ */ new Map()
|
|
20220
|
+
};
|
|
19723
20221
|
const inProgress = /* @__PURE__ */ new Set();
|
|
19724
20222
|
const evaluateByName = async (name) => {
|
|
19725
20223
|
if (name in conditions) return conditions[name];
|
|
@@ -19733,7 +20231,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
19733
20231
|
inProgress.add(name);
|
|
19734
20232
|
try {
|
|
19735
20233
|
const result = await this.resolveValue(definition, {
|
|
19736
|
-
...
|
|
20234
|
+
...maskingContext,
|
|
19737
20235
|
conditionResolver: evaluateByName
|
|
19738
20236
|
});
|
|
19739
20237
|
const value = Boolean(result);
|
|
@@ -19747,7 +20245,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
19747
20245
|
for (const name of Object.keys(templateConditions)) try {
|
|
19748
20246
|
await evaluateByName(name);
|
|
19749
20247
|
} catch (error) {
|
|
19750
|
-
this.logger.warn(`Failed to evaluate condition ${name}: ${error instanceof Error ? error.message : String(error)}, assuming false
|
|
20248
|
+
this.logger.warn(this.maskSecretsForLog(`Failed to evaluate condition ${name}: ${error instanceof Error ? error.message : String(error)}, assuming false`, maskingContext));
|
|
19751
20249
|
conditions[name] = false;
|
|
19752
20250
|
inProgress.delete(name);
|
|
19753
20251
|
}
|
|
@@ -20077,7 +20575,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
20077
20575
|
return this.noteAttributeSecrecy(logicalId, attributeName, nameServers, context);
|
|
20078
20576
|
}
|
|
20079
20577
|
this.logger.debug(`Resolved Fn::GetAtt from attributes: ${logicalId}.${attributeName} -> ${stringifyAttributeForLog(attributeName, flatValue)}`);
|
|
20080
|
-
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
|
|
20578
|
+
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
|
|
20081
20579
|
return this.noteAttributeSecrecy(logicalId, attributeName, flatValue, context);
|
|
20082
20580
|
}
|
|
20083
20581
|
if (attributeName.includes(".")) {
|
|
@@ -20094,7 +20592,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
20094
20592
|
}
|
|
20095
20593
|
}
|
|
20096
20594
|
}
|
|
20097
|
-
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
|
|
20595
|
+
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
|
|
20098
20596
|
const declared = Object.keys(resource.attributes ?? {}).filter((k) => k.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)).map((k) => k.slice(8)).sort();
|
|
20099
20597
|
throw markNonRetryable(new IntrinsicResolutionRefusalError(`Cannot resolve Fn::GetAtt [${logicalId}, ${attributeName}]: the nested stack '${logicalId}' declares no output named '${attributeName.slice(8)}'. Its outputs are ${declared.length > 0 ? declared.join(", ") : "(none)"}. Check the output name in the nested stack's template, and deploy the child stack again if you have just added it.`));
|
|
20100
20598
|
}
|
|
@@ -21849,10 +22347,10 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
21849
22347
|
* which caches nothing (issue #1933).
|
|
21850
22348
|
*/
|
|
21851
22349
|
let cacheable = true;
|
|
21852
|
-
if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner);
|
|
22350
|
+
if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context);
|
|
21853
22351
|
else if (service === "ssm") {
|
|
21854
22352
|
const decrypt = context?.skipDynamicReferences !== true;
|
|
21855
|
-
const param = await this.resolveSSMReference(parts, decrypt);
|
|
22353
|
+
const param = await this.resolveSSMReference(parts, decrypt, "ssm", context);
|
|
21856
22354
|
if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
|
|
21857
22355
|
else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
|
|
21858
22356
|
else cacheable = false;
|
|
@@ -21862,12 +22360,12 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
21862
22360
|
}
|
|
21863
22361
|
resolved = param.value;
|
|
21864
22362
|
} else if (service === "ssm-secure") {
|
|
21865
|
-
const param = await this.resolveSSMReference(parts, true, "ssm-secure");
|
|
22363
|
+
const param = await this.resolveSSMReference(parts, true, "ssm-secure", context);
|
|
21866
22364
|
if (!param.secure) throw markNonRetryable(new IntrinsicResolutionRefusalError(`Refusing to resolve ${fullMatch}: the parameter is a ${param.type} parameter, and the ssm-secure spelling is defined for SecureString parameters only. Reference it as {{resolve:ssm:...}} if it is public configuration.`));
|
|
21867
22365
|
isSecret = true;
|
|
21868
22366
|
resolved = param.value;
|
|
21869
22367
|
} else {
|
|
21870
|
-
this.logger.warn(`Unsupported dynamic reference service: ${service}
|
|
22368
|
+
this.logger.warn(this.maskSecretsForLog(`Unsupported dynamic reference service: ${service}`, context));
|
|
21871
22369
|
continue;
|
|
21872
22370
|
}
|
|
21873
22371
|
if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
|
|
@@ -21898,7 +22396,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
21898
22396
|
* runs when no mid-string ":SecretString:" delimiter is present, so the json-key / version
|
|
21899
22397
|
* forms are unaffected.)
|
|
21900
22398
|
*/
|
|
21901
|
-
async resolveSecretsManagerReference(inner) {
|
|
22399
|
+
async resolveSecretsManagerReference(inner, context) {
|
|
21902
22400
|
const afterService = inner.substring(15);
|
|
21903
22401
|
let secretId;
|
|
21904
22402
|
let jsonKey = "";
|
|
@@ -21927,14 +22425,14 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
21927
22425
|
} else secretId = afterService;
|
|
21928
22426
|
if (!versionStage) versionStage = "AWSCURRENT";
|
|
21929
22427
|
if (!secretId) throw new Error("Dynamic reference: secretsmanager SECRET_ID is required");
|
|
21930
|
-
this.logger.debug(`Resolving dynamic reference: secretsmanager:${secretId}:SecretString:${jsonKey}:${versionStage}:${versionId}
|
|
22428
|
+
this.logger.debug(this.maskSecretsForLog(`Resolving dynamic reference: secretsmanager:${secretId}:SecretString:${jsonKey}:${versionStage}:${versionId}`, context));
|
|
21931
22429
|
const client = this.clientsForRegion(this.explicitRegion).secretsManager;
|
|
21932
22430
|
const command = new GetSecretValueCommand({
|
|
21933
22431
|
SecretId: secretId,
|
|
21934
22432
|
...versionStage && versionStage !== "" && { VersionStage: versionStage },
|
|
21935
22433
|
...versionId && versionId !== "" && { VersionId: versionId }
|
|
21936
22434
|
});
|
|
21937
|
-
const secretString = (await this.sendWithThrottleRetry(() => client.send(command), `secretsmanager:${secretId}
|
|
22435
|
+
const secretString = (await this.sendWithThrottleRetry(() => client.send(command), this.maskSecretsForLog(`secretsmanager:${secretId}`, context))).SecretString;
|
|
21938
22436
|
if (!secretString) throw new Error(`Dynamic reference: secret '${secretId}' does not contain a SecretString value`);
|
|
21939
22437
|
if (jsonKey) try {
|
|
21940
22438
|
const keyValue = JSON.parse(secretString)[jsonKey];
|
|
@@ -22087,16 +22585,16 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
22087
22585
|
* discard the value when `secure` is set — it is ciphertext, not the resolved
|
|
22088
22586
|
* reference.
|
|
22089
22587
|
*/
|
|
22090
|
-
async resolveSSMReference(parts, decrypt = true, service = "ssm") {
|
|
22588
|
+
async resolveSSMReference(parts, decrypt = true, service = "ssm", context) {
|
|
22091
22589
|
const parameterName = parts.slice(1).join(":");
|
|
22092
22590
|
if (!parameterName) throw new Error(`Dynamic reference: ${service} PARAMETER_NAME is required`);
|
|
22093
|
-
this.logger.debug(`Resolving dynamic reference: ${service}:${parameterName}
|
|
22591
|
+
this.logger.debug(this.maskSecretsForLog(`Resolving dynamic reference: ${service}:${parameterName}`, context));
|
|
22094
22592
|
const client = this.clientsForRegion(this.explicitRegion).ssm;
|
|
22095
22593
|
const command = new GetParameterCommand({
|
|
22096
22594
|
Name: parameterName,
|
|
22097
22595
|
WithDecryption: decrypt
|
|
22098
22596
|
});
|
|
22099
|
-
const response = await this.sendWithThrottleRetry(() => client.send(command), `${service}:${parameterName}
|
|
22597
|
+
const response = await this.sendWithThrottleRetry(() => client.send(command), this.maskSecretsForLog(`${service}:${parameterName}`, context));
|
|
22100
22598
|
const paramValue = response.Parameter?.Value;
|
|
22101
22599
|
if (paramValue === void 0 || paramValue === null) throw new Error(`Dynamic reference: SSM parameter '${parameterName}' not found or has no value`);
|
|
22102
22600
|
const paramType = response.Parameter?.Type;
|
|
@@ -22104,7 +22602,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
22104
22602
|
if (secure && paramType !== "SecureString" && !this.warnedUnrecognizedSsmTypes.has(`${parameterName}\u0000${String(paramType)}`)) {
|
|
22105
22603
|
this.warnedUnrecognizedSsmTypes.add(`${parameterName}\u0000${String(paramType)}`);
|
|
22106
22604
|
const reported = paramType === void 0 ? "(absent)" : `'${String(paramType)}'`;
|
|
22107
|
-
this.logger.warn(`SSM parameter '${parameterName}' reported an unrecognized Type ${reported} — treating its value as a secret, so cdkd will persist the {{resolve:${service}:...}} expression rather than the resolved value. Declare the parameter as String / StringList if it is public config
|
|
22605
|
+
this.logger.warn(this.maskSecretsForLog(`SSM parameter '${parameterName}' reported an unrecognized Type ${reported} — treating its value as a secret, so cdkd will persist the {{resolve:${service}:...}} expression rather than the resolved value. Declare the parameter as String / StringList if it is public config.`, context));
|
|
22108
22606
|
}
|
|
22109
22607
|
return {
|
|
22110
22608
|
value: paramValue,
|
|
@@ -23057,9 +23555,14 @@ function describeJsonKeys(document) {
|
|
|
23057
23555
|
* `disableCcApiFallback` is not read on that path. The poisoned pre-guard
|
|
23058
23556
|
* state record the issue is about is precisely a record that already says
|
|
23059
23557
|
* `cc-api`, so it would still arrive here. Only adding the type to
|
|
23060
|
-
* `STICKY_CC_MIGRATION_EXEMPT` would divert it
|
|
23061
|
-
*
|
|
23062
|
-
*
|
|
23558
|
+
* `STICKY_CC_MIGRATION_EXEMPT` would divert it. Since issue #2719 that
|
|
23559
|
+
* table admits two modes, and NEITHER helps here: `'cc-broken'` is for
|
|
23560
|
+
* types Cloud Control cannot manage, and `'sdk-coverage'` diverts a
|
|
23561
|
+
* resource only when its property bags carry no actionable silent drop --
|
|
23562
|
+
* which is the opposite of this case by construction. Were a type somehow
|
|
23563
|
+
* admitted anyway, the divert would send the silent-drop property back
|
|
23564
|
+
* down the dropping path on the next deploy; the property gate is what
|
|
23565
|
+
* prevents it.
|
|
23063
23566
|
*
|
|
23064
23567
|
* So the confirmation belongs where the delete is actually issued. The set is
|
|
23065
23568
|
* a set rather than an `if` because the hazard is not S3-specific in kind: any
|
|
@@ -23308,7 +23811,7 @@ var CloudControlProvider = class {
|
|
|
23308
23811
|
const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
|
|
23309
23812
|
if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
|
|
23310
23813
|
this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
|
|
23311
|
-
const { ASGProvider } = await import("./asg-provider-
|
|
23814
|
+
const { ASGProvider } = await import("./asg-provider-D10GWdiA.js").then((n) => n.n);
|
|
23312
23815
|
return withIndeterminateGuard(await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
|
|
23313
23816
|
}
|
|
23314
23817
|
const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
|
|
@@ -25493,7 +25996,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
25493
25996
|
reuseClientCredentials: true,
|
|
25494
25997
|
tolerateNonStandardClient: true,
|
|
25495
25998
|
onRebuild: ({ bucketRegion, currentRegion }) => {
|
|
25496
|
-
this.logger.debug(`Custom resource response bucket '${bucket}' is in '${bucketRegion}' (client was '${
|
|
25999
|
+
this.logger.debug(`Custom resource response bucket '${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}' is in '${displaySafe(bucketRegion, { asciiOnly: true }) || "<unrenderable>"}' (client was '${displaySafe(currentRegion, { asciiOnly: true }) || "<unrenderable>"}'); building a region-corrected S3 client for response operations.`);
|
|
25497
26000
|
}
|
|
25498
26001
|
});
|
|
25499
26002
|
if (generation !== this.responseClientGeneration) {
|
|
@@ -26170,7 +26673,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
26170
26673
|
Key: responseKey
|
|
26171
26674
|
});
|
|
26172
26675
|
const presignedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 7200 });
|
|
26173
|
-
this.logger.debug(`Generated pre-signed URL for response: s3://${this.responseBucket}/${responseKey}`);
|
|
26676
|
+
this.logger.debug(`Generated pre-signed URL for response: s3://${displaySafe(this.responseBucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}`);
|
|
26174
26677
|
return presignedUrl;
|
|
26175
26678
|
}
|
|
26176
26679
|
/**
|
|
@@ -26274,7 +26777,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
26274
26777
|
Key: responseKey
|
|
26275
26778
|
}));
|
|
26276
26779
|
} catch (error) {
|
|
26277
|
-
this.logger.debug(`Failed to delete custom-resource response object s3://${bucket}/${responseKey}; it remains as a current object. Underlying error: ${error instanceof Error ? error.message : String(error)}`);
|
|
26780
|
+
this.logger.debug(`Failed to delete custom-resource response object s3://${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}; it remains as a current object. Underlying error: ${displaySafe(error instanceof Error ? error.message : String(error)) || "<unrenderable>"}`);
|
|
26278
26781
|
}
|
|
26279
26782
|
await purgeNoncurrentKeyVersions(this.s3Client, bucket, [responseKey], {
|
|
26280
26783
|
logger: this.logger,
|
|
@@ -28564,6 +29067,65 @@ function findActionableSilentDrops(resourceType, templateProperties, allowedKeys
|
|
|
28564
29067
|
if (drops.length === 0) return drops;
|
|
28565
29068
|
return drops.filter(({ property }) => !allowedKeys.has(`${resourceType}:${property}`));
|
|
28566
29069
|
}
|
|
29070
|
+
/**
|
|
29071
|
+
* A 1-click pre-filled GitHub issue link requesting cdkd support for a
|
|
29072
|
+
* specific top-level property on a resource type. Surfaced in the pre-flight
|
|
29073
|
+
* error so a user hitting a silent drop lands directly in the "request
|
|
29074
|
+
* support" flow.
|
|
29075
|
+
*/
|
|
29076
|
+
function unsupportedPropertyIssueUrl(resourceType, property) {
|
|
29077
|
+
return `https://github.com/go-to-k/cdkd/issues/new?title=${encodeURIComponent(`Support property ${resourceType}.${property}`)}&labels=resource-support`;
|
|
29078
|
+
}
|
|
29079
|
+
/**
|
|
29080
|
+
* Identify top-level template properties this type's committed CFn schema
|
|
29081
|
+
* snapshot does not know about at all — present in neither
|
|
29082
|
+
* `coverage.handled` nor `coverage.silentDrop` (issue
|
|
29083
|
+
* [#2718](https://github.com/go-to-k/cdkd/issues/2718)).
|
|
29084
|
+
*
|
|
29085
|
+
* The complement of {@link findSilentDropProperties}, which deliberately
|
|
29086
|
+
* PASSES these through: a property absent from the schema is indistinguishable
|
|
29087
|
+
* at deploy time from a user typo or an `addPropertyOverride` escape hatch, so
|
|
29088
|
+
* it cannot drive a routing decision. That tolerance is correct for routing
|
|
29089
|
+
* and wrong for silence — the routing table is built offline from
|
|
29090
|
+
* `tests/fixtures/cfn-schemas/*.json`, so every property AWS publishes AFTER
|
|
29091
|
+
* that snapshot lands here, and on the SDK route it reaches neither AWS nor an
|
|
29092
|
+
* error while the deploy reports success. That is the issue
|
|
29093
|
+
* [#614](https://github.com/go-to-k/cdkd/issues/614) failure class arriving
|
|
29094
|
+
* through the one input the #614 machinery cannot observe.
|
|
29095
|
+
*
|
|
29096
|
+
* **Why a warn built on this has no false-positive mode.** An SDK provider
|
|
29097
|
+
* writes only what it declares in `handledProperties`, so a top-level property
|
|
29098
|
+
* in neither set does not reach AWS under ANY of the three readings — a
|
|
29099
|
+
* post-snapshot AWS addition, a typo, or a deliberate `addPropertyOverride`.
|
|
29100
|
+
* "This value will not reach AWS" is true in all three, so the caller does not
|
|
29101
|
+
* have to guess intent, and at deploy time it could not: cdkd holds only the
|
|
29102
|
+
* template and the baked-in table. Firing on a typo is a feature rather than
|
|
29103
|
+
* noise — CloudFormation would have REJECTED that typo, so today's silence is
|
|
29104
|
+
* strictly the worst of the three behaviors.
|
|
29105
|
+
*
|
|
29106
|
+
* Deliberately NOT gated on the snapshot's age: the statement is true whatever
|
|
29107
|
+
* `generatedAt` says, and an age gate would trade the typo visibility away for
|
|
29108
|
+
* a wall-clock dependence.
|
|
29109
|
+
*
|
|
29110
|
+
* Returns `[]` for Tier 2 / Custom / unknown types (no coverage record — Cloud
|
|
29111
|
+
* Control forwards the full property map, so nothing is dropped) and sorts
|
|
29112
|
+
* alphabetically, mirroring {@link findSilentDropProperties}. The CALLER is
|
|
29113
|
+
* responsible for firing only when the resource actually resolves to the SDK
|
|
29114
|
+
* route; see `ProviderRegistry.reportSilentDropDecisions`.
|
|
29115
|
+
*/
|
|
29116
|
+
function findUnrecognizedProperties(resourceType, templateProperties) {
|
|
29117
|
+
if (!templateProperties) return [];
|
|
29118
|
+
const coverage = getPropertyCoverage(resourceType);
|
|
29119
|
+
if (!coverage) return [];
|
|
29120
|
+
const unrecognized = [];
|
|
29121
|
+
for (const prop of Object.keys(templateProperties)) {
|
|
29122
|
+
if (prop === "Ref" || prop.startsWith("Fn::")) continue;
|
|
29123
|
+
if (coverage.handled.has(prop)) continue;
|
|
29124
|
+
if (coverage.silentDrop.has(prop)) continue;
|
|
29125
|
+
unrecognized.push(prop);
|
|
29126
|
+
}
|
|
29127
|
+
return unrecognized.sort((a, b) => a.localeCompare(b));
|
|
29128
|
+
}
|
|
28567
29129
|
|
|
28568
29130
|
//#endregion
|
|
28569
29131
|
//#region src/provisioning/mutually-exclusive-properties.ts
|
|
@@ -28673,56 +29235,39 @@ function buildMutuallyExclusiveMessage(logicalId, violation) {
|
|
|
28673
29235
|
//#endregion
|
|
28674
29236
|
//#region src/provisioning/provider-registry.ts
|
|
28675
29237
|
/**
|
|
28676
|
-
* Provider registry for managing resource providers.
|
|
28677
|
-
*
|
|
28678
|
-
* Selection strategy for a fresh resource (see {@link getProviderFor}):
|
|
28679
|
-
* 1. Custom Resource (`Custom::*` / `AWS::CloudFormation::CustomResource`)
|
|
28680
|
-
* → Custom Resource provider (recorded as `provisionedBy: 'sdk'`).
|
|
28681
|
-
* 2. Existing-state `provisionedBy: 'cc-api'` → Cloud Control (sticky).
|
|
28682
|
-
* 3. SDK Provider registered, no silent-drop properties (after the
|
|
28683
|
-
* `--allow-unsupported-properties` override filter) → SDK Provider.
|
|
28684
|
-
* 4. SDK Provider registered, silent-drop properties present, NOT all
|
|
28685
|
-
* in the allow set → Cloud Control (auto-route, info-logged). When the
|
|
28686
|
-
* CC route is NOT viable — the type is `NON_PROVISIONABLE` (no CC
|
|
28687
|
-
* handlers, e.g. AWS::FSx::FileSystem) or the provider sets
|
|
28688
|
-
* `disableCcApiFallback` (e.g. NestedStackProvider) — throw the clear
|
|
28689
|
-
* pre-flight error instead of failing opaquely at provisioning time.
|
|
28690
|
-
* 5. SDK Provider registered, silent-drop properties present, ALL in
|
|
28691
|
-
* the allow set → SDK Provider (the user explicitly accepted the
|
|
28692
|
-
* silent drop, warn-logged).
|
|
28693
|
-
* 6. No SDK Provider, Cloud Control supports the type → Cloud Control.
|
|
28694
|
-
* 7. `--allow-unsupported-types` escape hatch → Cloud Control optimistically.
|
|
28695
|
-
* 8. Otherwise → throw (no provider available).
|
|
28696
|
-
*
|
|
28697
|
-
* SDK-provider-less Tier 3 (`NON_PROVISIONABLE`) types are rejected earlier
|
|
28698
|
-
* by {@link validateResourceTypes}. A Tier 1 type that is ALSO
|
|
28699
|
-
* NON_PROVISIONABLE (SDK provider registered for a type Cloud Control cannot
|
|
28700
|
-
* manage — e.g. AWS::FSx::FileSystem, AWS::DLM::LifecyclePolicy) passes the
|
|
28701
|
-
* type check but has no viable CC auto-route; rule 4's viability guard turns
|
|
28702
|
-
* that case into a clear pre-flight error.
|
|
28703
|
-
*/
|
|
28704
|
-
/**
|
|
28705
29238
|
* Types exempt from the sticky `provisionedBy: 'cc-api'` routing rule.
|
|
28706
29239
|
*
|
|
28707
|
-
* The sticky rule exists to avoid physical-ID
|
|
28708
|
-
* backfilled for a type Cloud Control was
|
|
28709
|
-
*
|
|
28710
|
-
*
|
|
28711
|
-
*
|
|
28712
|
-
*
|
|
28713
|
-
*
|
|
28714
|
-
*
|
|
28715
|
-
*
|
|
28716
|
-
|
|
28717
|
-
|
|
28718
|
-
|
|
28719
|
-
|
|
28720
|
-
|
|
28721
|
-
|
|
28722
|
-
|
|
28723
|
-
|
|
28724
|
-
|
|
28725
|
-
|
|
29240
|
+
* The sticky rule (rule 2 in `getProviderFor`) exists to avoid physical-ID
|
|
29241
|
+
* churn when an SDK provider is backfilled for a type Cloud Control was
|
|
29242
|
+
* already managing fine. Both exemption modes are narrow escapes from it; see
|
|
29243
|
+
* `StickyExemptMode` for which applies when.
|
|
29244
|
+
*
|
|
29245
|
+
* Condition 2 -- physicalId parity -- is a hard requirement in BOTH modes and
|
|
29246
|
+
* is what makes a flip churn-free. It is also why this is a curated table
|
|
29247
|
+
* rather than a predicate: an automatic flip keyed on "the type has coverage"
|
|
29248
|
+
* would assert parity for types nobody measured.
|
|
29249
|
+
*/
|
|
29250
|
+
const STICKY_CC_MIGRATION_EXEMPT = /* @__PURE__ */ new Map([["AWS::Scheduler::Schedule", {
|
|
29251
|
+
mode: "cc-broken",
|
|
29252
|
+
physicalIdForm: "both layers store the bare schedule name; the state properties carry GroupName, so the SDK provider addresses existing records correctly",
|
|
29253
|
+
issue: "https://github.com/go-to-k/cdkd/issues/961",
|
|
29254
|
+
integFixture: "scheduler-custom-group"
|
|
29255
|
+
}], ["AWS::SNS::Topic", {
|
|
29256
|
+
mode: "sdk-coverage",
|
|
29257
|
+
physicalIdForm: "both layers store the topic ARN: the schema primaryIdentifier is TopicArn and SnsTopicProvider.create records the CreateTopic TopicArn",
|
|
29258
|
+
issue: "https://github.com/go-to-k/cdkd/issues/2719",
|
|
29259
|
+
integFixture: "cc-to-sdk-reroute"
|
|
29260
|
+
}]]);
|
|
29261
|
+
function wouldReturnToSdkProvider(input) {
|
|
29262
|
+
const { resourceType, desiredProperties, previousProperties, allowedUnsupportedProperties = /* @__PURE__ */ new Set(), forceCcApi = false, exemptions = STICKY_CC_MIGRATION_EXEMPT } = input;
|
|
29263
|
+
const exemption = exemptions.get(resourceType);
|
|
29264
|
+
if (exemption === void 0) return false;
|
|
29265
|
+
if (exemption.mode === "cc-broken") return true;
|
|
29266
|
+
if (forceCcApi) return false;
|
|
29267
|
+
if (desiredProperties === void 0) return false;
|
|
29268
|
+
if (previousProperties === void 0) return false;
|
|
29269
|
+
return [desiredProperties, previousProperties].every((bag) => findActionableSilentDrops(resourceType, bag, allowedUnsupportedProperties).length === 0);
|
|
29270
|
+
}
|
|
28726
29271
|
var ProviderRegistry = class {
|
|
28727
29272
|
logger = getLogger().child("ProviderRegistry");
|
|
28728
29273
|
providers = /* @__PURE__ */ new Map();
|
|
@@ -28817,18 +29362,36 @@ var ProviderRegistry = class {
|
|
|
28817
29362
|
provisionedBy: "sdk"
|
|
28818
29363
|
};
|
|
28819
29364
|
}
|
|
28820
|
-
|
|
28821
|
-
|
|
28822
|
-
|
|
28823
|
-
|
|
28824
|
-
|
|
28825
|
-
|
|
29365
|
+
let returningToSdk = false;
|
|
29366
|
+
if (provisionedBy === "cc-api") {
|
|
29367
|
+
if (!wouldReturnToSdkProvider({
|
|
29368
|
+
resourceType,
|
|
29369
|
+
desiredProperties: properties,
|
|
29370
|
+
previousProperties: input.previousProperties,
|
|
29371
|
+
allowedUnsupportedProperties: this.allowedUnsupportedProperties,
|
|
29372
|
+
forceCcApi: input.forceCcApi === true
|
|
29373
|
+
})) {
|
|
29374
|
+
this.logger.debug(`Routing ${resourceType} via Cloud Control (state-recorded provisionedBy=cc-api)`);
|
|
29375
|
+
return {
|
|
29376
|
+
provider: this.cloudControlProvider,
|
|
29377
|
+
provisionedBy: "cc-api"
|
|
29378
|
+
};
|
|
29379
|
+
}
|
|
29380
|
+
returningToSdk = true;
|
|
28826
29381
|
}
|
|
28827
29382
|
const specificProvider = this.providers.get(resourceType);
|
|
28828
29383
|
if (specificProvider) {
|
|
28829
29384
|
const actionableDrops = findActionableSilentDrops(resourceType, properties, this.allowedUnsupportedProperties);
|
|
28830
29385
|
if (actionableDrops.length === 0) {
|
|
28831
29386
|
this.logger.debug(`Using specific SDK provider for ${resourceType}`);
|
|
29387
|
+
if (returningToSdk) {
|
|
29388
|
+
this.logger.debug(`${resourceType} is returning to its SDK provider from a state-recorded cc-api route; physical id is preserved`);
|
|
29389
|
+
return {
|
|
29390
|
+
provider: specificProvider,
|
|
29391
|
+
provisionedBy: "sdk",
|
|
29392
|
+
sdkMigration: true
|
|
29393
|
+
};
|
|
29394
|
+
}
|
|
28832
29395
|
return {
|
|
28833
29396
|
provider: specificProvider,
|
|
28834
29397
|
provisionedBy: "sdk"
|
|
@@ -29028,7 +29591,6 @@ var ProviderRegistry = class {
|
|
|
29028
29591
|
reportSilentDropDecisions(resources) {
|
|
29029
29592
|
for (const { logicalId, resourceType, properties, provisionedBy } of resources) {
|
|
29030
29593
|
const drops = findSilentDropProperties(resourceType, properties);
|
|
29031
|
-
if (drops.length === 0) continue;
|
|
29032
29594
|
const overridden = [];
|
|
29033
29595
|
const autoRouted = [];
|
|
29034
29596
|
for (const { property } of drops) {
|
|
@@ -29047,9 +29609,71 @@ var ProviderRegistry = class {
|
|
|
29047
29609
|
const propList = overridden.join(", ");
|
|
29048
29610
|
this.logger.warn(`${logicalId} (${resourceType}): ${propList} will be silently dropped (--allow-unsupported-properties override accepted). Remove the override to route this resource via Cloud Control API instead.`);
|
|
29049
29611
|
}
|
|
29612
|
+
this.reportUnrecognizedProperties(logicalId, resourceType, properties, {
|
|
29613
|
+
provisionedBy,
|
|
29614
|
+
autoRouted: autoRouted.length > 0
|
|
29615
|
+
});
|
|
29050
29616
|
}
|
|
29051
29617
|
}
|
|
29052
29618
|
/**
|
|
29619
|
+
* Warn about top-level template properties this type's committed CFn schema
|
|
29620
|
+
* snapshot does not know about, on resources that resolve to the SDK route
|
|
29621
|
+
* (issue [#2718](https://github.com/go-to-k/cdkd/issues/2718)).
|
|
29622
|
+
*
|
|
29623
|
+
* The gap this closes: {@link getProviderFor} decides SDK-vs-Cloud-Control
|
|
29624
|
+
* from `property-coverage.generated.ts`, built offline from the schema
|
|
29625
|
+
* fixtures, and there is no runtime `DescribeType` on that path. So a
|
|
29626
|
+
* property AWS publishes AFTER the fixture snapshot produces no
|
|
29627
|
+
* `silentDrop` entry, does not auto-route to Cloud Control, and is dropped
|
|
29628
|
+
* with the deploy reporting success — the issue
|
|
29629
|
+
* [#614](https://github.com/go-to-k/cdkd/issues/614) failure class reached
|
|
29630
|
+
* through the one input the #614 machinery cannot observe. The scheduled
|
|
29631
|
+
* fixture-refresh job is the FIX (the property enters the fixture and the
|
|
29632
|
+
* existing auto-route handles it); this warn is what protects a user
|
|
29633
|
+
* deploying BETWEEN refresh cycles.
|
|
29634
|
+
*
|
|
29635
|
+
* Fires only on the SDK route, which is where the drop actually happens.
|
|
29636
|
+
* The two Cloud-Control routes both forward the full property map verbatim,
|
|
29637
|
+
* so the property does reach AWS there and a warn would be false:
|
|
29638
|
+
* - `provisionedBy: 'cc-api'` from existing state (sticky rule 2 of
|
|
29639
|
+
* {@link getProviderFor}), minus the `STICKY_CC_MIGRATION_EXEMPT` types
|
|
29640
|
+
* that deliberately re-route back to their SDK provider;
|
|
29641
|
+
* - an actionable silent drop auto-routing this deploy (`autoRouted`).
|
|
29642
|
+
*
|
|
29643
|
+
* The route test MIRRORS `getProviderFor` rather than re-deriving it — the
|
|
29644
|
+
* two answering differently is the only way this warn can be wrong about a
|
|
29645
|
+
* resource, and it is not decidable from the message.
|
|
29646
|
+
*
|
|
29647
|
+
* **Known divergence, in the SAFE direction.** This runs on the template's
|
|
29648
|
+
* RAW properties (`deploy-engine.ts` calls `validateResourceProperties`
|
|
29649
|
+
* pre-flight) while `getProviderFor` runs on RESOLVED ones. So a silent-drop
|
|
29650
|
+
* key present only behind an `Fn::If` that resolves to `AWS::NoValue` makes
|
|
29651
|
+
* `autoRouted` true here and suppresses the warn, while the real route ends
|
|
29652
|
+
* up on the SDK provider and does drop the unrecognized property. The result
|
|
29653
|
+
* is a MISSING warn, never a false one — which is the right direction for an
|
|
29654
|
+
* advisory line, and why this is documented rather than fixed by resolving
|
|
29655
|
+
* twice. `getProviderFor` remains the authority on routing; nothing here
|
|
29656
|
+
* changes a routing decision.
|
|
29657
|
+
*
|
|
29658
|
+
* Suppressed per `<Type>:<Prop>` by `--allow-unsupported-properties`, whose
|
|
29659
|
+
* meaning ("accept the silent drop, stay on the SDK path") is exactly this
|
|
29660
|
+
* case; deliberately no new flag. Warn rather than error because the drop
|
|
29661
|
+
* may be intended, and deliberately NOT an auto-route: flipping to
|
|
29662
|
+
* Cloud Control on an UNRECOGNIZED property would let a typo trigger the
|
|
29663
|
+
* currently one-way `cc-api` state flip (issue
|
|
29664
|
+
* [#2719](https://github.com/go-to-k/cdkd/issues/2719)), and CC would reject
|
|
29665
|
+
* the unknown key anyway.
|
|
29666
|
+
*/
|
|
29667
|
+
reportUnrecognizedProperties(logicalId, resourceType, properties, route) {
|
|
29668
|
+
if (route.provisionedBy === "cc-api" && !STICKY_CC_MIGRATION_EXEMPT.has(resourceType) || route.autoRouted) return;
|
|
29669
|
+
const unrecognized = findUnrecognizedProperties(resourceType, properties).filter((property) => !this.allowedUnsupportedProperties.has(`${resourceType}:${property}`));
|
|
29670
|
+
if (unrecognized.length === 0) return;
|
|
29671
|
+
const propList = unrecognized.join(", ");
|
|
29672
|
+
const overrideHint = unrecognized.map((p) => `${resourceType}:${p}`).join(",");
|
|
29673
|
+
const one = unrecognized.length === 1;
|
|
29674
|
+
this.logger.warn(`${logicalId} (${resourceType}): ${propList} ${one ? "is" : "are"} not in cdkd's CFn schema snapshot for this type, so ${one ? "it" : "they"} will NOT reach AWS — the deploy will still report success. Anything of these shapes looks the same here: a misspelled name (fix the spelling); a read-only attribute, which is not settable on any engine (remove it); or a property AWS published after cdkd's snapshot, which cdkd should be routing via Cloud Control — please report that one: ${unsupportedPropertyIssueUrl(resourceType, unrecognized[0])}${one ? "" : ` (link is for ${unrecognized[0]})`}. If the drop is intended — an addPropertyOverride escape hatch — silence this via --allow-unsupported-properties ${overrideHint}.`);
|
|
29675
|
+
}
|
|
29676
|
+
/**
|
|
29053
29677
|
* Pure-functional discovery of every resource whose template uses one or
|
|
29054
29678
|
* more silent-drop properties that are NOT in the
|
|
29055
29679
|
* `--allow-unsupported-properties` allow set — i.e. every resource that
|
|
@@ -29771,6 +30395,70 @@ function getCurrentResourceSecrets() {
|
|
|
29771
30395
|
return currentResourceSecretsStore.getStore();
|
|
29772
30396
|
}
|
|
29773
30397
|
|
|
30398
|
+
//#endregion
|
|
30399
|
+
//#region src/deployment/type-change-guard.ts
|
|
30400
|
+
/**
|
|
30401
|
+
* The CFn type of a nested stack's row in its PARENT's template.
|
|
30402
|
+
*
|
|
30403
|
+
* Spelled locally rather than imported, matching
|
|
30404
|
+
* `src/deployment/recreate-targets.ts`: the only exported copy lives in
|
|
30405
|
+
* `src/cli/commands/retire-cfn-stack.ts`, and importing a CLI command module
|
|
30406
|
+
* from the deployment layer would invert the dependency direction.
|
|
30407
|
+
*/
|
|
30408
|
+
const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
|
|
30409
|
+
/**
|
|
30410
|
+
* Find every planned change whose recorded type and template type differ with
|
|
30411
|
+
* `AWS::CloudFormation::Stack` on one side.
|
|
30412
|
+
*
|
|
30413
|
+
* Reads exactly the two values the defect is made of: `change.resourceType`
|
|
30414
|
+
* (what `provisionResource` binds and routes BOTH replacement halves on) and
|
|
30415
|
+
* the state record's `resourceType` (the resource that actually exists). That
|
|
30416
|
+
* is deliberate — deriving the "desired" type from the template again would be
|
|
30417
|
+
* a second implementation of the diff's own Type-change rule (metadata skip,
|
|
30418
|
+
* condition pruning) which could drift away from the routing decision this
|
|
30419
|
+
* guards.
|
|
30420
|
+
*
|
|
30421
|
+
* `changeType` is not filtered on. A Type change surfaces as an `UPDATE`, but a
|
|
30422
|
+
* DELETE / NO_CHANGE row cannot diverge in the first place (the diff builds
|
|
30423
|
+
* both from the state record's own type), so filtering would only add a way for
|
|
30424
|
+
* a future change-shape to slip past.
|
|
30425
|
+
*/
|
|
30426
|
+
function findNestedStackTypeChanges(input) {
|
|
30427
|
+
const found = [];
|
|
30428
|
+
for (const [logicalId, change] of input.changes) {
|
|
30429
|
+
if (!Object.hasOwn(input.stateResources, logicalId)) continue;
|
|
30430
|
+
const currentResource = input.stateResources[logicalId];
|
|
30431
|
+
if (!currentResource) continue;
|
|
30432
|
+
const currentType = currentResource.resourceType;
|
|
30433
|
+
const desiredType = change.resourceType;
|
|
30434
|
+
if (currentType === desiredType) continue;
|
|
30435
|
+
const intoNested = desiredType === NESTED_STACK_RESOURCE_TYPE;
|
|
30436
|
+
if (!intoNested && !(currentType === NESTED_STACK_RESOURCE_TYPE)) continue;
|
|
30437
|
+
found.push({
|
|
30438
|
+
logicalId,
|
|
30439
|
+
currentType,
|
|
30440
|
+
desiredType,
|
|
30441
|
+
physicalId: currentResource.physicalId,
|
|
30442
|
+
direction: intoNested ? "into-nested-stack" : "out-of-nested-stack"
|
|
30443
|
+
});
|
|
30444
|
+
}
|
|
30445
|
+
return found;
|
|
30446
|
+
}
|
|
30447
|
+
/**
|
|
30448
|
+
* Render the refusal. Names the logical id, BOTH types, the resource the
|
|
30449
|
+
* mis-routed delete would be aimed at, and what to do instead.
|
|
30450
|
+
*
|
|
30451
|
+
* `stackName` is the stack being deployed, so the into-nested arm can print the
|
|
30452
|
+
* child stack name `NestedStackProvider.delete` would derive and destroy — the
|
|
30453
|
+
* one piece of the damage the user cannot read off their own template.
|
|
30454
|
+
*/
|
|
30455
|
+
function renderNestedStackTypeChangeRefusal(typeChanges, stackName) {
|
|
30456
|
+
const rows = typeChanges.map((tc) => {
|
|
30457
|
+
return `${` - ${tc.logicalId}: Type changes from ${tc.currentType} to ${tc.desiredType} (the existing ${tc.currentType} is ${tc.physicalId}).`}\n${tc.direction === "into-nested-stack" ? ` Both halves of the replacement would route on the TEMPLATE's type, so the existing resource's delete would be dispatched at the ${NESTED_STACK_RESOURCE_TYPE} provider — which ignores the physical id it is handed and instead destroys the nested child stack "${stackName}~${tc.logicalId}" and every resource that child owns. Where no such child exists the delete is a no-op and ${tc.physicalId} is silently leaked instead.` : ` Both halves of the replacement would route on the TEMPLATE's type, so the existing nested stack's delete would be dispatched at the ${tc.desiredType} provider, which cannot delete a nested stack — the child stack "${stackName}~${tc.logicalId}" and every resource it owns would be left behind, untracked.`}`;
|
|
30458
|
+
});
|
|
30459
|
+
return `Refusing to deploy ${stackName}: ` + (typeChanges.length === 1 ? `a resource changes its Type ` : `${typeChanges.length} resources change their Type `) + `into or out of ${NESTED_STACK_RESOURCE_TYPE}, which cdkd cannot replace safely (issue #2668).\n${rows.join("\n")}\n Deploy this as two changes instead: give the new resource a DIFFERENT logical id (in CDK, rename the construct) so the existing row is deleted through its own type's provider and the new one is created under its own — or remove the resource in one deploy and add its replacement in the next. There is no flag that overrides this refusal: the delete's TARGET would be wrong, not merely its consequences.`;
|
|
30460
|
+
}
|
|
30461
|
+
|
|
29774
30462
|
//#endregion
|
|
29775
30463
|
//#region src/deployment/outputs-export-alias.ts
|
|
29776
30464
|
/**
|
|
@@ -30094,6 +30782,218 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
|
|
|
30094
30782
|
}
|
|
30095
30783
|
}
|
|
30096
30784
|
|
|
30785
|
+
//#endregion
|
|
30786
|
+
//#region src/provisioning/dynamodb-warm-throughput.ts
|
|
30787
|
+
/**
|
|
30788
|
+
* The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
|
|
30789
|
+
* `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
|
|
30790
|
+
*
|
|
30791
|
+
* `WarmThroughput` is the same CloudFormation block on both types, and both
|
|
30792
|
+
* providers have to answer the same two questions about it before an
|
|
30793
|
+
* `UpdateTable` / `CreateTable` goes out:
|
|
30794
|
+
*
|
|
30795
|
+
* 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
|
|
30796
|
+
* stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
|
|
30797
|
+
* came back from an `Fn::Sub` — arrives as a STRING and must not be
|
|
30798
|
+
* forwarded verbatim into a numeric `Long` field.
|
|
30799
|
+
* 2. **Would sending it LOWER what AWS already reports?** Warm throughput
|
|
30800
|
+
* only ever rises with a table's traffic and AWS REJECTS a call that
|
|
30801
|
+
* lowers it (`decreasing WarmThroughput is not supported`, measured live
|
|
30802
|
+
* us-east-1 2026-08-13 for issue #1768).
|
|
30803
|
+
*
|
|
30804
|
+
* **Provenance, stated precisely because it is easy to over-claim.** Exactly
|
|
30805
|
+
* ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
|
|
30806
|
+
* (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
|
|
30807
|
+
* existed outside the change that created this file (issue #1857). So this is
|
|
30808
|
+
* the Table rule LIFTED — not two shipped rules reconciled, and no deployed
|
|
30809
|
+
* behaviour changed for either type when it moved here.
|
|
30810
|
+
*
|
|
30811
|
+
* What WAS compared is the lifted rule against the GlobalTable spelling
|
|
30812
|
+
* drafted alongside it, over the shapes both had to answer (a quoted numeric
|
|
30813
|
+
* string, a partially usable block, a mixed decrease/increase, an absent live
|
|
30814
|
+
* value, a zero, a negative, a non-numeric string, a whitespace string, an
|
|
30815
|
+
* explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
|
|
30816
|
+
* array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
|
|
30817
|
+
* agreed on all but a whitespace-only string, where a bare `Number(' ')` is
|
|
30818
|
+
* `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
|
|
30819
|
+
* since `' '` is not a capacity anyone declared and a warm throughput of 0
|
|
30820
|
+
* is not a request AWS can honour. That is a DRAFT reconciled against a
|
|
30821
|
+
* shipped rule, which is worth less than two shipped rules agreeing — the
|
|
30822
|
+
* reason to read the probe list as a design note rather than as field
|
|
30823
|
+
* evidence.
|
|
30824
|
+
*
|
|
30825
|
+
* Living here rather than in either provider is the point. A decrease guard is
|
|
30826
|
+
* three clauses that all FAIL OPEN for different reasons, and two files
|
|
30827
|
+
* spelling it independently is two chances for a later "fix" to change one of
|
|
30828
|
+
* them — at which point the sibling type silently keeps the old answer, and
|
|
30829
|
+
* nothing in the tree says which is intended. Same class as
|
|
30830
|
+
* `emr-configuration.ts`, and the same reason.
|
|
30831
|
+
*
|
|
30832
|
+
* Deliberately NOT here: everything only ONE provider has. The `Table` side's
|
|
30833
|
+
* `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
|
|
30834
|
+
* `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
|
|
30835
|
+
* and already-matches gates, which `GlobalTable` has no counterpart to (issue
|
|
30836
|
+
* #1742 strips the per-index `WarmThroughput` from BOTH of its drift
|
|
30837
|
+
* comparison sides unconditionally, so drift never asks the question there);
|
|
30838
|
+
* the `GlobalTable` side's `warmThroughputDiagnostic` builds a
|
|
30839
|
+
* `ThroughputDiagnostic` that only that provider's collector understands.
|
|
30840
|
+
* Moving a helper with one caller here would buy nothing and cost a hop.
|
|
30841
|
+
*
|
|
30842
|
+
* Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
|
|
30843
|
+
*/
|
|
30844
|
+
/**
|
|
30845
|
+
* The two `WarmThroughput` members, in the ONE order every message, every
|
|
30846
|
+
* comparison and every emitted block uses. A shared order is what makes two
|
|
30847
|
+
* blocks carrying the same numbers compare equal by `deepEqual` and serialize
|
|
30848
|
+
* to the same wire bytes regardless of the order the template wrote them in.
|
|
30849
|
+
*/
|
|
30850
|
+
const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
|
|
30851
|
+
/**
|
|
30852
|
+
* A CloudFormation-borne numeric property, or `undefined` when the value is
|
|
30853
|
+
* not a usable number.
|
|
30854
|
+
*
|
|
30855
|
+
* Plain `Number()` coercion is NOT good enough and the tree learned it three
|
|
30856
|
+
* separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
|
|
30857
|
+
* and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
|
|
30858
|
+
* EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
|
|
30859
|
+
* string would be forwarded as a request for zero units.
|
|
30860
|
+
*
|
|
30861
|
+
* A YAML-borne numeric STRING is still accepted, because that is a real
|
|
30862
|
+
* template shape and `'12000'` genuinely means 12000.
|
|
30863
|
+
*
|
|
30864
|
+
* The accepted STRING set is `Number()`'s, which is WIDER than a decimal
|
|
30865
|
+
* integer: `'0x1e'`, `'0o36'`, `'1e3'`, `'30.5'` and `' 30 '` all coerce.
|
|
30866
|
+
* Whether CloudFormation accepts those for an `Integer`-typed property is
|
|
30867
|
+
* unmeasured, and issue [#2698](https://github.com/go-to-k/cdkd/issues/2698)
|
|
30868
|
+
* holds the live A/B that would settle it. It matters at the CALLERS that
|
|
30869
|
+
* FORWARD the result to AWS rather than merely compare it — narrowing this
|
|
30870
|
+
* shared helper would change the DynamoDB capacity readers too, so read that
|
|
30871
|
+
* issue before tightening anything here.
|
|
30872
|
+
*
|
|
30873
|
+
* EXPORTED, and not because warm throughput needs it exported. This exact rule
|
|
30874
|
+
* was hand-written three times — here, as `dynamodb-table-provider.ts`'s
|
|
30875
|
+
* `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
|
|
30876
|
+
* `toFiniteNumber` (a different spelling of the same total function) — which is
|
|
30877
|
+
* precisely the divergence this module exists to stop: the next "fix" to one of
|
|
30878
|
+
* them would have left the other two answering differently, with nothing in the
|
|
30879
|
+
* tree saying which was intended. It reads `ReadCapacityUnits` /
|
|
30880
|
+
* `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
|
|
30881
|
+
* `ReadUnitsPerSecond`; there is only ever one right answer for "is this
|
|
30882
|
+
* stringly-typed CFn value a number I can send?".
|
|
30883
|
+
*/
|
|
30884
|
+
function toFiniteNumber(value) {
|
|
30885
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
30886
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
30887
|
+
const n = Number(value);
|
|
30888
|
+
return Number.isFinite(n) ? n : void 0;
|
|
30889
|
+
}
|
|
30890
|
+
}
|
|
30891
|
+
/**
|
|
30892
|
+
* Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
|
|
30893
|
+
* fields accept, PER MEMBER.
|
|
30894
|
+
*
|
|
30895
|
+
* PER MEMBER, not whole-block, and that distinction is the point: a block
|
|
30896
|
+
* whose write half is an unresolved intrinsic still has a perfectly good read
|
|
30897
|
+
* half, and dropping both would silently discard a value the template really
|
|
30898
|
+
* did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
|
|
30899
|
+
* warning can say which half went missing instead of reporting the whole
|
|
30900
|
+
* property.
|
|
30901
|
+
*
|
|
30902
|
+
* A block with NO usable member yields `spec: undefined` — refused rather than
|
|
30903
|
+
* forwarded, because forwarding a malformed block surfaces as an opaque AWS
|
|
30904
|
+
* validation error naming neither cdkd nor the property. `droppedMembers` is
|
|
30905
|
+
* still populated in that case, so a REFUSAL message can name what it refused;
|
|
30906
|
+
* a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
|
|
30907
|
+
* array) has no member to name and reports none, which is what lets a caller
|
|
30908
|
+
* word "one half went missing" differently from "the block is unusable".
|
|
30909
|
+
*
|
|
30910
|
+
* Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
|
|
30911
|
+
* own message, so the wording stays consistent across that provider's send
|
|
30912
|
+
* sites without forcing one wording across both providers.
|
|
30913
|
+
*/
|
|
30914
|
+
function coerceWarmThroughput(raw) {
|
|
30915
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
|
|
30916
|
+
const bag = raw;
|
|
30917
|
+
const spec = {};
|
|
30918
|
+
const droppedMembers = [];
|
|
30919
|
+
for (const member of WARM_THROUGHPUT_MEMBERS) {
|
|
30920
|
+
if (bag[member] === void 0) continue;
|
|
30921
|
+
const coerced = toFiniteNumber(bag[member]);
|
|
30922
|
+
if (coerced === void 0) {
|
|
30923
|
+
droppedMembers.push(member);
|
|
30924
|
+
continue;
|
|
30925
|
+
}
|
|
30926
|
+
spec[member] = coerced;
|
|
30927
|
+
}
|
|
30928
|
+
if (Object.keys(spec).length === 0) return { droppedMembers };
|
|
30929
|
+
return {
|
|
30930
|
+
spec,
|
|
30931
|
+
droppedMembers
|
|
30932
|
+
};
|
|
30933
|
+
}
|
|
30934
|
+
/**
|
|
30935
|
+
* Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
|
|
30936
|
+
* reports.
|
|
30937
|
+
*
|
|
30938
|
+
* Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
|
|
30939
|
+
* reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
|
|
30940
|
+
*
|
|
30941
|
+
* ```
|
|
30942
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
|
|
30943
|
+
* ValidationException: One or more parameter values were invalid: Requested
|
|
30944
|
+
* ReadUnitsPerSecond for WarmThroughput for table is lower than current
|
|
30945
|
+
* WarmThroughput, decreasing WarmThroughput is not supported
|
|
30946
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
|
|
30947
|
+
* UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
|
|
30948
|
+
* UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
|
|
30949
|
+
* ```
|
|
30950
|
+
*
|
|
30951
|
+
* So the value is one AWS raises with the table's traffic and never lowers,
|
|
30952
|
+
* and a decrease is REJECTED rather than accepted-and-ignored — which is what
|
|
30953
|
+
* had to be measured before choosing, because the two produce different
|
|
30954
|
+
* correct answers. The caller SKIPS the call on a true here.
|
|
30955
|
+
*
|
|
30956
|
+
* Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
|
|
30957
|
+
* makes the verdict describe a request that is not the one being sent (a
|
|
30958
|
+
* dropped member is not part of the call and must not be part of the
|
|
30959
|
+
* comparison).
|
|
30960
|
+
*
|
|
30961
|
+
* Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
|
|
30962
|
+
* leave AWS as the authority — because a false positive here silently drops a
|
|
30963
|
+
* legitimate INCREASE, which is a real capacity change the user asked for,
|
|
30964
|
+
* while a false negative merely reproduces the pre-fix behaviour of an
|
|
30965
|
+
* AWS-side rejection that names the property:
|
|
30966
|
+
* - DECLARED members only. An absent member is not a request to lower
|
|
30967
|
+
* anything, so it takes no part in the verdict.
|
|
30968
|
+
* - MIXED is not a decrease. One member below live and the other above means
|
|
30969
|
+
* the call carries a genuine increase; AWS decides.
|
|
30970
|
+
* - An absent or unusable LIVE counterpart is not a decrease. Without a
|
|
30971
|
+
* number to compare against there is no evidence of one. Only the LIVE side
|
|
30972
|
+
* can reach that arm: the desired side is a COERCED spec, so a malformed
|
|
30973
|
+
* template value has already been dropped by {@link coerceWarmThroughput}.
|
|
30974
|
+
*
|
|
30975
|
+
* A decrease therefore requires every declared member to be at-or-below live
|
|
30976
|
+
* AND at least one to be strictly below.
|
|
30977
|
+
*
|
|
30978
|
+
* The skip this drives is right for EVERY `update()` caller — the deploy
|
|
30979
|
+
* engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
|
|
30980
|
+
* because none of them can make AWS lower the value, so none loses anything a
|
|
30981
|
+
* doomed call would have achieved.
|
|
30982
|
+
*/
|
|
30983
|
+
function isWarmThroughputDecrease(desired, live) {
|
|
30984
|
+
if (desired === void 0 || live === void 0) return false;
|
|
30985
|
+
let sawDecrease = false;
|
|
30986
|
+
for (const member of WARM_THROUGHPUT_MEMBERS) {
|
|
30987
|
+
if (desired[member] === void 0) continue;
|
|
30988
|
+
const wanted = toFiniteNumber(desired[member]);
|
|
30989
|
+
const current = toFiniteNumber(live[member]);
|
|
30990
|
+
if (wanted === void 0 || current === void 0) return false;
|
|
30991
|
+
if (wanted > current) return false;
|
|
30992
|
+
if (wanted < current) sawDecrease = true;
|
|
30993
|
+
}
|
|
30994
|
+
return sawDecrease;
|
|
30995
|
+
}
|
|
30996
|
+
|
|
30097
30997
|
//#endregion
|
|
30098
30998
|
//#region src/provisioning/stateful-types.ts
|
|
30099
30999
|
/**
|
|
@@ -30140,9 +31040,12 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
|
|
|
30140
31040
|
* "never expire" — the most data-bearing configuration the type
|
|
30141
31041
|
* has, and what `LogsLogGroupProvider` records `0` for — so reading
|
|
30142
31042
|
* it as "holds nothing" destroyed years of events on a plain
|
|
30143
|
-
* `cdkd deploy`. A
|
|
30144
|
-
* `has-retention` from
|
|
30145
|
-
*
|
|
31043
|
+
* `cdkd deploy`. A `RetentionInDays > 0` recorded in EITHER of the
|
|
31044
|
+
* state record's property bags still answers `has-retention` from
|
|
31045
|
+
* the bags alone (a cheap positive, never probed away); every other
|
|
31046
|
+
* bag DEFERS, exactly as the bucket does. Which bags, and why the
|
|
31047
|
+
* value is coerced rather than type-tested, is issue [#2521] —
|
|
31048
|
+
* see {@link logGroupHasPositiveRetention}.
|
|
30146
31049
|
* The pre-flight resolves the deferral with a live
|
|
30147
31050
|
* `logs:DescribeLogStreams` probe (a log group with no stream can
|
|
30148
31051
|
* hold no event, since every event belongs to a stream); mid-deploy,
|
|
@@ -30327,10 +31230,66 @@ const STATEFUL_TYPES = /* @__PURE__ */ new Set([
|
|
|
30327
31230
|
*/
|
|
30328
31231
|
const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::DynamoDB::GlobalTable"]);
|
|
30329
31232
|
/**
|
|
30330
|
-
*
|
|
31233
|
+
* Does either recorded bag prove this log group carries a POSITIVE
|
|
31234
|
+
* `RetentionInDays`? (issue [#2521])
|
|
31235
|
+
*
|
|
31236
|
+
* Two decisions, and both were defects before this function existed.
|
|
31237
|
+
*
|
|
31238
|
+
* **The value is COERCED, not type-tested.** The predicate used to gate on
|
|
31239
|
+
* `typeof retention === 'number' && retention > 0`, and CloudFormation is
|
|
31240
|
+
* stringly typed: a hand-written L1, an `Fn::Sub`-produced value, a
|
|
31241
|
+
* `Type: String` parameter's default, or a record imported by
|
|
31242
|
+
* `cdkd import --migrate-from-cloudformation` all put `'30'` into the bag,
|
|
31243
|
+
* where the type test answers "not a number". {@link toFiniteNumber} is the
|
|
31244
|
+
* repo's single answer to "is this stringly-typed CFn value a number?" —
|
|
31245
|
+
* imported rather than re-spelled here for the reason its own doc gives, that
|
|
31246
|
+
* the same rule hand-written twice diverges on the next fix to one copy. It
|
|
31247
|
+
* rejects `''`, `' '`, `null`, `[]` and `false`, each of which a bare
|
|
31248
|
+
* `Number()` turns into a `0` that would read as never-expire.
|
|
31249
|
+
*
|
|
31250
|
+
* **BOTH bags are consulted, and a positive in EITHER wins.** The old read
|
|
31251
|
+
* saw `properties` alone, so a retention set OUT OF BAND (the console,
|
|
31252
|
+
* `aws logs put-retention-policy`, another tool) or one recorded only in
|
|
31253
|
+
* `observedProperties` — an imported record whose template never declared
|
|
31254
|
+
* the property — never produced `has-retention`.
|
|
31255
|
+
*
|
|
31256
|
+
* The rule is a plain OR, and the loop order below is INERT — a review round
|
|
31257
|
+
* caught an earlier version of this paragraph claiming `observedProperties`
|
|
31258
|
+
* is "read FIRST", a precedence the code does not implement and must not.
|
|
31259
|
+
* A strict "observed when present, else properties" rule — which is what
|
|
31260
|
+
* issue [#2521] literally prescribed — was tried and REJECTED:
|
|
31261
|
+
* `LogsLogGroupProvider.readCurrentState` writes `RetentionInDays: 0` for a
|
|
31262
|
+
* group with no retention policy, so the observed bag almost always CARRIES
|
|
31263
|
+
* the key, and precedence would make the recorded bag dead for every record
|
|
31264
|
+
* that has ever been captured and would DROP the `has-retention` verdict this
|
|
31265
|
+
* guard already produced. A test pins that rejection
|
|
31266
|
+
* (`tests/unit/provisioning/stateful-types.test.ts`, "reads a retention that
|
|
31267
|
+
* lives ONLY in properties, even against a ZERO observed one"). The OR is
|
|
31268
|
+
* also the strictly safer direction: it can only ADD refusals, never remove
|
|
31269
|
+
* one.
|
|
31270
|
+
*
|
|
31271
|
+
* What a `false` here means is DEFER, not "holds nothing" — see the callers.
|
|
31272
|
+
*/
|
|
31273
|
+
function logGroupHasPositiveRetention(recordedProperties, observedProperties) {
|
|
31274
|
+
for (const bag of [observedProperties, recordedProperties]) {
|
|
31275
|
+
const retention = toFiniteNumber(bag?.["RetentionInDays"]);
|
|
31276
|
+
if (retention !== void 0 && retention > 0) return true;
|
|
31277
|
+
}
|
|
31278
|
+
return false;
|
|
31279
|
+
}
|
|
31280
|
+
/**
|
|
31281
|
+
* Cheap, synchronous read of the state record's own property bags only —
|
|
31282
|
+
* no AWS call. Both bags are parameters rather than one: `properties` is
|
|
31283
|
+
* what the last deploy applied, `observedProperties` what it read back,
|
|
31284
|
+
* and the log group's arm consults BOTH (issue [#2521]). Passing the
|
|
31285
|
+
* observed bag is REQUIRED, not optional, so a new call site has to
|
|
31286
|
+
* decide what it holds instead of silently repeating the omission that
|
|
31287
|
+
* issue records; `undefined` is the right answer where no record is in
|
|
31288
|
+
* hand (`recreate-confirm-prompt.ts`).
|
|
31289
|
+
*
|
|
30331
31290
|
* TWO types return `null` meaning DEFER rather than "not stateful":
|
|
30332
|
-
* `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever
|
|
30333
|
-
*
|
|
31291
|
+
* `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever neither
|
|
31292
|
+
* bag already proves `has-retention`. The live probes
|
|
30334
31293
|
* that resolve both deferrals (`ListObjectVersions` for the bucket,
|
|
30335
31294
|
* `DescribeLogStreams` for the log group) live in
|
|
30336
31295
|
* `src/deployment/recreate-targets.ts#probeStatefulRecreateTargetsAsync`
|
|
@@ -30343,11 +31302,10 @@ const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::Dynam
|
|
|
30343
31302
|
* Returns the {@link StatefulReason} when the type is stateful (or
|
|
30344
31303
|
* `null` for non-stateful types).
|
|
30345
31304
|
*/
|
|
30346
|
-
function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
|
|
31305
|
+
function isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties) {
|
|
30347
31306
|
if (!STATEFUL_TYPES.has(resourceType)) return null;
|
|
30348
31307
|
if (resourceType === "AWS::Logs::LogGroup") {
|
|
30349
|
-
|
|
30350
|
-
if (typeof retention === "number" && retention > 0) return "has-retention";
|
|
31308
|
+
if (logGroupHasPositiveRetention(recordedProperties, observedProperties)) return "has-retention";
|
|
30351
31309
|
return null;
|
|
30352
31310
|
}
|
|
30353
31311
|
if (resourceType === "AWS::S3::Bucket") return null;
|
|
@@ -30374,16 +31332,38 @@ function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
|
|
|
30374
31332
|
* — be DELETE + CREATEd (data loss) without `--force-stateful-recreation`. To
|
|
30375
31333
|
* stay fail-safe, both deferrals resolve to stateful here: the user must pass
|
|
30376
31334
|
* `--force-stateful-recreation` to replace ANY S3 bucket, and any log group
|
|
30377
|
-
* whose recorded
|
|
30378
|
-
* paths — empty or not.
|
|
31335
|
+
* neither of whose recorded property bags already proves `has-retention`, on
|
|
31336
|
+
* any of those paths — empty or not.
|
|
31337
|
+
*
|
|
31338
|
+
* `UpdateReplacePolicy: Retain` is the standing EXEMPTION, and it covers all
|
|
31339
|
+
* three (issue [#2604]): the engine never consults this predicate under it —
|
|
31340
|
+
* the property-driven guard tests `updateReplacePolicy !== 'Retain'`
|
|
31341
|
+
* directly, and the fallback's two triggers short-circuit through
|
|
31342
|
+
* `retainOldOnReplace`, which issue [#2518] added. The old resource survives
|
|
31343
|
+
* the replacement, so there is no data loss to confirm, and the refusal's own
|
|
31344
|
+
* remedy would have destroyed exactly what the user asked to keep.
|
|
31345
|
+
* `Snapshot` is NOT exempt on any of them — a snapshot is a copy, not a
|
|
31346
|
+
* surviving resource. So read the paragraph above as scoped to a template
|
|
31347
|
+
* that is not retaining.
|
|
31348
|
+
*
|
|
31349
|
+
* The two engine sites are not the only callers: a THIRD sits outside those
|
|
31350
|
+
* paths and outside that exemption — `recreate-confirm-prompt.ts` re-derives
|
|
31351
|
+
* a `null` verdict for the `--recreate-via-*` pre-flight, where
|
|
31352
|
+
* `--force-stateful-recreation` is what skipped the probe.
|
|
31353
|
+
* `stateful-replace-message-doc-sync` pins the whole guard's reader list by
|
|
31354
|
+
* file, but the residuals comment above its own walk enumerates what that
|
|
31355
|
+
* cannot see — among them an ALIASED import, a `.mts` / `.cts` reader, and,
|
|
31356
|
+
* the one that bit, a NEW PATH routed through an existing call site, which
|
|
31357
|
+
* adds no file and reds nothing (issue [#2514]'s shape). So this enumeration
|
|
31358
|
+
* is maintained by hand.
|
|
30379
31359
|
*
|
|
30380
31360
|
* The log group's arm is the one issue [#2558] added, and the reason it is
|
|
30381
31361
|
* needed is that the old predicate treated "no retention recorded" as "holds
|
|
30382
31362
|
* nothing" when it is CloudWatch Logs' never-expire. Every other type matches
|
|
30383
31363
|
* {@link isStatefulRecreateTargetSync} exactly.
|
|
30384
31364
|
*/
|
|
30385
|
-
function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
|
|
30386
|
-
const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties);
|
|
31365
|
+
function isStatefulRecreateTargetForReplace(resourceType, recordedProperties, observedProperties) {
|
|
31366
|
+
const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties);
|
|
30387
31367
|
if (sync) return sync;
|
|
30388
31368
|
if (resourceType === "AWS::S3::Bucket") return "has-objects";
|
|
30389
31369
|
if (resourceType === "AWS::Logs::LogGroup") return "has-log-events";
|
|
@@ -31545,6 +32525,84 @@ function rollbackFinalSnapshotId(resourceType, record, fallbackProvisionedBy) {
|
|
|
31545
32525
|
return buildFinalSnapshotIdentifier(record.physicalId, resourceType);
|
|
31546
32526
|
}
|
|
31547
32527
|
/**
|
|
32528
|
+
* `UpdateReplacePolicy: Retain` on the resource a replacement CREATED — the
|
|
32529
|
+
* copy a rollback would otherwise destroy (issue
|
|
32530
|
+
* [#2598](https://github.com/go-to-k/cdkd/issues/2598)).
|
|
32531
|
+
*
|
|
32532
|
+
* Reads the CURRENT record, i.e. the one the replacing deploy wrote from the
|
|
32533
|
+
* template it was applying (`extractTemplateAttributes`), so the attribute
|
|
32534
|
+
* consulted is the one that was in force when the new copy was created. Its
|
|
32535
|
+
* `Snapshot` sibling, {@link rollbackFinalSnapshotId}, reads the same field of
|
|
32536
|
+
* the same record — `Retain` and `Snapshot` are alternative values of ONE
|
|
32537
|
+
* attribute, so the two can never both apply.
|
|
32538
|
+
*
|
|
32539
|
+
* **`UpdateReplacePolicy`, NOT `DeletionPolicy`, and that is measured, not
|
|
32540
|
+
* reasoned.** The repo refuses a CloudFormation-parity claim taken on
|
|
32541
|
+
* folklore, and the AWS documentation answers nothing here: every sentence on
|
|
32542
|
+
* both attribute pages, in the API reference and in the release notes
|
|
32543
|
+
* describes the OLD resource, never the new copy's fate during a rollback. A
|
|
32544
|
+
* live four-variant A/B (2026-09-05, us-east-1: a forced `AWS::SSM::Parameter`
|
|
32545
|
+
* replacement plus a deterministically failing sibling, rolled back) settled
|
|
32546
|
+
* it:
|
|
32547
|
+
*
|
|
32548
|
+
* | DeletionPolicy | UpdateReplacePolicy | new copy | decisive event |
|
|
32549
|
+
* | -------------- | ------------------- | --------- | ---------------- |
|
|
32550
|
+
* | (none) | (none) | DELETED | `DELETE_COMPLETE` |
|
|
32551
|
+
* | Retain | (none) | DELETED | `DELETE_COMPLETE` |
|
|
32552
|
+
* | (none) | Retain | SURVIVED | `DELETE_SKIPPED` |
|
|
32553
|
+
* | Retain | Retain | SURVIVED | `DELETE_SKIPPED` |
|
|
32554
|
+
*
|
|
32555
|
+
* Row 2 alone refutes "`DeletionPolicy` governs it"; row 3 alone refutes
|
|
32556
|
+
* "neither — always deleted". The old copy was restored intact in all four,
|
|
32557
|
+
* and both outcomes land in `UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS`.
|
|
32558
|
+
*
|
|
32559
|
+
* A retained new copy is ORPHANED OUT of the stack, not kept as a managed
|
|
32560
|
+
* resource — the A/B proved it by deleting the whole stack afterwards and
|
|
32561
|
+
* finding the retained parameter still alive. So every caller below leaves NO
|
|
32562
|
+
* state record naming the survivor: the two arms that can complete point state
|
|
32563
|
+
* at the old resource exactly as they already did, and the survivor becomes
|
|
32564
|
+
* untracked. That is the same disposition the deploy engine gives a
|
|
32565
|
+
* `Retain`-orphaned OLD resource, so the two directions agree.
|
|
32566
|
+
*
|
|
32567
|
+
* LIMIT OF THAT EVIDENCE, stated so a later reader does not over-read it: all
|
|
32568
|
+
* four variants carried the SAME policy in both template versions, so the A/B
|
|
32569
|
+
* pinned WHICH ATTRIBUTE wins and did NOT discriminate which template's copy
|
|
32570
|
+
* of it is read. This function reads the current record for the reasons above
|
|
32571
|
+
* (it is the one the new copy was created under, and it matches the
|
|
32572
|
+
* `Snapshot` sibling and both CREATE-rollback arms), not because the A/B
|
|
32573
|
+
* settled that question.
|
|
32574
|
+
*/
|
|
32575
|
+
function rollbackRetainsNewResource(record) {
|
|
32576
|
+
return record?.updateReplacePolicy === "Retain";
|
|
32577
|
+
}
|
|
32578
|
+
/**
|
|
32579
|
+
* The two sentences a `UpdateReplacePolicy: Retain` survivor needs: the `⚠`
|
|
32580
|
+
* terminal warning and the compact `reason` that rides on the durable
|
|
32581
|
+
* `ROLLBACK_RESOURCE_SUCCEEDED` event.
|
|
32582
|
+
*
|
|
32583
|
+
* ONE function because the two must not drift apart. Both replacement-rollback
|
|
32584
|
+
* retain arms produced these by hand, four near-identical copies, and the
|
|
32585
|
+
* failure mode a reviewer named is precise: the warn and the DURABLE record
|
|
32586
|
+
* disagreeing about which id survived. Deriving both from one set of inputs
|
|
32587
|
+
* makes that unrepresentable. The shapes stay deliberately different -- the
|
|
32588
|
+
* warn carries the cost/`cdkd destroy` guidance a human reads once, the reason
|
|
32589
|
+
* stays compact for a `--json` consumer -- so this is one input set, not one
|
|
32590
|
+
* string.
|
|
32591
|
+
*
|
|
32592
|
+
* `stateClause` is the only thing that differs between the two arms (the
|
|
32593
|
+
* readopt arm restores the old id; the create-first arm records a re-created
|
|
32594
|
+
* one), so it is a parameter rather than a branch in here.
|
|
32595
|
+
*
|
|
32596
|
+
* NOT used by the delete-failed survivor a few lines down: that one is an
|
|
32597
|
+
* orphan by OUTCOME rather than by policy, and says so.
|
|
32598
|
+
*/
|
|
32599
|
+
function retainedSurvivorMessages(logicalId, resourceType, survivorPhysicalId, stateClause) {
|
|
32600
|
+
return {
|
|
32601
|
+
warn: ` ⚠ ${logicalId} (${resourceType}) has UpdateReplacePolicy: Retain — the replacement's new physical resource (${survivorPhysicalId}) is RETAINED by this rollback and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need it. ${stateClause}`,
|
|
32602
|
+
reason: `UpdateReplacePolicy: Retain kept the replacement's new ${resourceType} (${survivorPhysicalId}); it is live, still billing, and no longer tracked by cdkd. ${stateClause}`
|
|
32603
|
+
};
|
|
32604
|
+
}
|
|
32605
|
+
/**
|
|
31548
32606
|
* `DeletionPolicy: Snapshot` on a rolled-back CREATE (issue #1358) — the
|
|
31549
32607
|
* executor's copy of the deploy engine's `prepareFinalSnapshotForDelete`
|
|
31550
32608
|
* mechanism matrix, run BEFORE the delete. Shared with the FAILED in-flight
|
|
@@ -31643,7 +32701,7 @@ function classifyRollbackOp(op, stateResources, orphanLogicalIds) {
|
|
|
31643
32701
|
if (deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
|
|
31644
32702
|
return "skip-mismatch";
|
|
31645
32703
|
}
|
|
31646
|
-
return op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
|
|
32704
|
+
return op.oldResourceRetained ?? op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
|
|
31647
32705
|
}
|
|
31648
32706
|
if (op.previousState && deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
|
|
31649
32707
|
return "revert";
|
|
@@ -31683,12 +32741,16 @@ function planFailedOps(failedOps, stateResources) {
|
|
|
31683
32741
|
*/
|
|
31684
32742
|
function planRollback(operations, stateResources, orphanLogicalIds = /* @__PURE__ */ new Set()) {
|
|
31685
32743
|
const { createOps, otherOps } = partitionOps(operations);
|
|
31686
|
-
return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) =>
|
|
31687
|
-
op,
|
|
31688
|
-
|
|
31689
|
-
|
|
31690
|
-
|
|
31691
|
-
|
|
32744
|
+
return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => {
|
|
32745
|
+
const action = classifyRollbackOp(op, stateResources, orphanLogicalIds);
|
|
32746
|
+
return {
|
|
32747
|
+
op,
|
|
32748
|
+
action,
|
|
32749
|
+
replacement: isReplacementOp(op),
|
|
32750
|
+
effectiveProvisionedBy: effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy),
|
|
32751
|
+
retainsNewResource: (action === "reverse-replacement" || action === "reverse-replacement-readopt") && rollbackRetainsNewResource(stateResources[op.logicalId])
|
|
32752
|
+
};
|
|
32753
|
+
});
|
|
31692
32754
|
}
|
|
31693
32755
|
function partitionOps(operations) {
|
|
31694
32756
|
const createOps = [];
|
|
@@ -32046,11 +33108,32 @@ async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resol
|
|
|
32046
33108
|
* writer is one of only two that can honestly claim it.
|
|
32047
33109
|
*
|
|
32048
33110
|
* Using `STATE_SOURCED_READBACK_RULES` here reads plausible and is wrong in the
|
|
32049
|
-
* quiet direction: it turns positional array descent off.
|
|
32050
|
-
*
|
|
32051
|
-
*
|
|
32052
|
-
*
|
|
32053
|
-
*
|
|
33111
|
+
* quiet direction: it turns BLIND positional array descent off. BLIND is
|
|
33112
|
+
* load-bearing, and an earlier revision of this paragraph omitted it — the
|
|
33113
|
+
* concrete loss is narrower than "positional descent is off" makes it sound,
|
|
33114
|
+
* by TWO mechanisms rather than one:
|
|
33115
|
+
*
|
|
33116
|
+
* - Since issue #1915 a `Tags[]` / ECS `Environment[]` element is reached by
|
|
33117
|
+
* the order-independent KEYED descent either way.
|
|
33118
|
+
* - Since issue #2012 an UNKEYED list is reached too, under corroboration.
|
|
33119
|
+
* That is not a general relaxation: swapping this constant in would satisfy
|
|
33120
|
+
* all three conjuncts of `isReadbackProjectedFromState`
|
|
33121
|
+
* (`trustAnyExpression && !descendArrays && sourceIsSameGeneration`), which
|
|
33122
|
+
* ARMS `refuseUncertifiedReadbackPositions`, and its unkeyed arm walks
|
|
33123
|
+
* element i against element i whenever `unkeyedArrayPairsByAnchors`
|
|
33124
|
+
* corroborates the alignment (index counts match; every position whose
|
|
33125
|
+
* SOURCE subtree carries no dynamic reference is deep-equal on both sides;
|
|
33126
|
+
* every reference-bearing element carries a distinguishing anchor of its own
|
|
33127
|
+
* or, being a bare reference leaf, leans on the array's literal frame; and
|
|
33128
|
+
* no two reference-bearing elements share an order-insensitive anchor
|
|
33129
|
+
* signature).
|
|
33130
|
+
*
|
|
33131
|
+
* So the residual loss is narrower again: an unkeyed list whose positions ALSO
|
|
33132
|
+
* fail to corroborate. The CONCLUSION is unchanged — `STATE_DERIVED_RULES` is
|
|
33133
|
+
* still right here, for the reason one paragraph up (the bag was produced by
|
|
33134
|
+
* resolving the source, so the two correspond positionally by construction and
|
|
33135
|
+
* need no corroboration to say so). What changes is only how much a reader
|
|
33136
|
+
* should think the alternative costs (issue #2691).
|
|
32054
33137
|
*
|
|
32055
33138
|
* No-op when the op resolved no secret.
|
|
32056
33139
|
*/
|
|
@@ -32260,7 +33343,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32260
33343
|
return;
|
|
32261
33344
|
case "orphan-flag":
|
|
32262
33345
|
if (op.changeType === "CREATE") {
|
|
32263
|
-
const
|
|
33346
|
+
const record = stateResources[op.logicalId];
|
|
33347
|
+
const orphanFlagProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
|
|
32264
33348
|
createRollbackRoute = orphanFlagProvisionedBy;
|
|
32265
33349
|
delete stateResources[op.logicalId];
|
|
32266
33350
|
logger.info(` Rollback: Orphaning created resource ${op.logicalId} (--orphan)`);
|
|
@@ -32271,12 +33355,17 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32271
33355
|
operation: "CREATE",
|
|
32272
33356
|
logicalId: op.logicalId,
|
|
32273
33357
|
resourceType: op.resourceType,
|
|
32274
|
-
...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy }
|
|
33358
|
+
...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy },
|
|
33359
|
+
...record?.physicalId && {
|
|
33360
|
+
physicalId: record.physicalId,
|
|
33361
|
+
reason: `--orphan left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
|
|
33362
|
+
}
|
|
32275
33363
|
});
|
|
32276
33364
|
} else logger.info(` Rollback: Leaving ${op.logicalId} at its new state (--orphan)`);
|
|
32277
33365
|
return;
|
|
32278
33366
|
case "orphan-retain": {
|
|
32279
|
-
const
|
|
33367
|
+
const record = stateResources[op.logicalId];
|
|
33368
|
+
const orphanProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
|
|
32280
33369
|
createRollbackRoute = orphanProvisionedBy;
|
|
32281
33370
|
delete stateResources[op.logicalId];
|
|
32282
33371
|
logger.info(` Rollback: Leaving ${op.logicalId} (${op.resourceType}) in AWS (DeletionPolicy: Retain) — removed from state`);
|
|
@@ -32287,7 +33376,11 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32287
33376
|
operation: "CREATE",
|
|
32288
33377
|
logicalId: op.logicalId,
|
|
32289
33378
|
resourceType: op.resourceType,
|
|
32290
|
-
...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy }
|
|
33379
|
+
...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy },
|
|
33380
|
+
...record?.physicalId && {
|
|
33381
|
+
physicalId: record.physicalId,
|
|
33382
|
+
reason: `DeletionPolicy: Retain left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
|
|
33383
|
+
}
|
|
32291
33384
|
});
|
|
32292
33385
|
return;
|
|
32293
33386
|
}
|
|
@@ -32330,11 +33423,32 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32330
33423
|
const current = stateResources[op.logicalId];
|
|
32331
33424
|
const prev = op.previousState;
|
|
32332
33425
|
logger.info(` Rollback: Reversing replacement of ${op.logicalId} (${op.resourceType}) — deleting the new resource and re-adopting the retained old one (${prev.physicalId})`);
|
|
32333
|
-
|
|
32334
|
-
|
|
32335
|
-
|
|
32336
|
-
|
|
32337
|
-
|
|
33426
|
+
/**
|
|
33427
|
+
* Set when this arm ORPHANS the replacement's new copy. Read at the
|
|
33428
|
+
* `ROLLBACK_RESOURCE_SUCCEEDED` event below, which is the only channel
|
|
33429
|
+
* that OUTLIVES the terminal (security review of issue #2598): a
|
|
33430
|
+
* rollback runs during an already-failing deploy, often non-TTY with
|
|
33431
|
+
* the log truncated or discarded, so a `logger.warn` is the least
|
|
33432
|
+
* likely thing the user still has. Without this the survivor's id dies
|
|
33433
|
+
* with the terminal -- `cdkd events` shows a clean success and state
|
|
33434
|
+
* names only the OLD resource, while a live, billing, untracked copy
|
|
33435
|
+
* remains. `Retain` is precisely the marker users put on data-bearing
|
|
33436
|
+
* resources, so that is the worst population to lose the id for.
|
|
33437
|
+
*
|
|
33438
|
+
* Same shape as the `rollbackPartial` survivor record ~700 lines down
|
|
33439
|
+
* and as the deploy engine's `RESOURCE_SKIPPED` twin.
|
|
33440
|
+
*/
|
|
33441
|
+
let survivorReason;
|
|
33442
|
+
if (rollbackRetainsNewResource(current)) {
|
|
33443
|
+
const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State is restored to the old resource (${prev.physicalId}).`);
|
|
33444
|
+
logger.warn(survivorMessages.warn);
|
|
33445
|
+
survivorReason = survivorMessages.reason;
|
|
33446
|
+
result.warnings++;
|
|
33447
|
+
} else {
|
|
33448
|
+
const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
|
|
33449
|
+
resourceType: op.resourceType,
|
|
33450
|
+
provisionedBy: current.provisionedBy ?? op.provisionedBy
|
|
33451
|
+
});
|
|
32338
33452
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
32339
33453
|
throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
32340
33454
|
expectedRegion: ctx.region,
|
|
@@ -32344,13 +33458,19 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32344
33458
|
stateResources[op.logicalId] = prev;
|
|
32345
33459
|
logger.info(` Rollback: ${op.logicalId} restored to the retained old resource`);
|
|
32346
33460
|
await afterOp?.(op.logicalId);
|
|
33461
|
+
const survivorProvisionedBy = current.provisionedBy;
|
|
32347
33462
|
ctx.recordEvent?.({
|
|
32348
33463
|
eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
|
|
32349
33464
|
stackName,
|
|
32350
33465
|
operation: "UPDATE",
|
|
32351
33466
|
logicalId: op.logicalId,
|
|
32352
33467
|
resourceType: op.resourceType,
|
|
32353
|
-
...op.provisionedBy && { provisionedBy: op.provisionedBy }
|
|
33468
|
+
...op.provisionedBy && { provisionedBy: op.provisionedBy },
|
|
33469
|
+
...survivorReason !== void 0 && {
|
|
33470
|
+
physicalId: current.physicalId,
|
|
33471
|
+
reason: maskSecretsInText(survivorReason, secrets),
|
|
33472
|
+
...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
|
|
33473
|
+
}
|
|
32354
33474
|
});
|
|
32355
33475
|
return;
|
|
32356
33476
|
}
|
|
@@ -32366,10 +33486,10 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32366
33486
|
resourceType: op.resourceType,
|
|
32367
33487
|
provisionedBy: prev.provisionedBy
|
|
32368
33488
|
});
|
|
32369
|
-
const
|
|
33489
|
+
const resolveNewDeleteProvider = () => ctx.providerRegistry.getProviderFor({
|
|
32370
33490
|
resourceType: op.resourceType,
|
|
32371
33491
|
provisionedBy: current.provisionedBy ?? op.provisionedBy
|
|
32372
|
-
});
|
|
33492
|
+
}).provider;
|
|
32373
33493
|
let deletedNewFirst = false;
|
|
32374
33494
|
let createResult;
|
|
32375
33495
|
try {
|
|
@@ -32378,11 +33498,13 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32378
33498
|
interruptedMessage: "Rollback interrupted while waiting out the name cooldown"
|
|
32379
33499
|
});
|
|
32380
33500
|
} catch (createError) {
|
|
32381
|
-
|
|
33501
|
+
const msg = createError instanceof Error ? createError.message : String(createError);
|
|
33502
|
+
if (!isNameCollisionError(msg)) throw createError;
|
|
33503
|
+
if (rollbackRetainsNewResource(current)) throw markNonRetryable(new CdkdError(maskSecretsInText(`Cannot reverse the replacement of ${op.logicalId} (${op.resourceType}): the re-create of the old resource (${prev.physicalId}) collided with the name still held by the new one (${current.physicalId}), and UpdateReplacePolicy: Retain pins that new resource in place, so cdkd will not delete it to free the name. Delete the new resource yourself, or remove UpdateReplacePolicy: Retain, then re-run \`cdkd rollback\` — the journal is kept, so the revert resumes from here. To leave THIS resource alone and let the rest of the rollback proceed, re-run with \`cdkd rollback --orphan ${op.logicalId}\`: one op failure stops the segment loop, so a single pinned resource otherwise halts every OLDER segment too. Underlying collision: ${msg}`, secrets), "NAMED_REPLACEMENT_COLLISION", maskSecretsInError(createError instanceof Error ? createError : void 0, secrets)));
|
|
32382
33504
|
logger.info(` Rollback: re-create collided with the new resource's name — deleting the new resource (${current.physicalId}) first...`);
|
|
32383
33505
|
{
|
|
32384
33506
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
32385
|
-
throwIfDeleteSkipped(await
|
|
33507
|
+
throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
32386
33508
|
expectedRegion: ctx.region,
|
|
32387
33509
|
...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
|
|
32388
33510
|
}), op.logicalId, current.physicalId, "while clearing the new resource so the old one could be re-created");
|
|
@@ -32396,7 +33518,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32396
33518
|
interruptedMessage: "Rollback interrupted while waiting for the old name to release"
|
|
32397
33519
|
});
|
|
32398
33520
|
} catch (recreateError) {
|
|
32399
|
-
throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets));
|
|
33521
|
+
throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets), { cause: maskSecretsInError(recreateError instanceof Error ? recreateError : void 0, secrets) });
|
|
32400
33522
|
}
|
|
32401
33523
|
}
|
|
32402
33524
|
const adoptedLiveNewResource = !deletedNewFirst && createResult.physicalId === current.physicalId;
|
|
@@ -32412,24 +33534,37 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32412
33534
|
properties: recordedPropertiesAfterReplayCreate(prevRecord, createResult)
|
|
32413
33535
|
}, secrets, prevRecord.properties);
|
|
32414
33536
|
await afterOp?.(op.logicalId);
|
|
32415
|
-
|
|
33537
|
+
let survivorReason;
|
|
33538
|
+
if (!deletedNewFirst && !adoptedLiveNewResource && rollbackRetainsNewResource(current)) {
|
|
33539
|
+
const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State records the re-created old resource (${stateResources[op.logicalId]?.physicalId ?? prev.physicalId}).`);
|
|
33540
|
+
logger.warn(survivorMessages.warn);
|
|
33541
|
+
survivorReason = survivorMessages.reason;
|
|
33542
|
+
result.warnings++;
|
|
33543
|
+
} else if (!deletedNewFirst && !adoptedLiveNewResource) try {
|
|
32416
33544
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
32417
|
-
throwIfDeleteSkipped(await
|
|
33545
|
+
throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
32418
33546
|
expectedRegion: ctx.region,
|
|
32419
33547
|
...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
|
|
32420
33548
|
}), op.logicalId, current.physicalId, "while deleting the new resource after re-creating the old one");
|
|
32421
33549
|
} catch (deleteError) {
|
|
32422
33550
|
logger.warn(maskSecretsInText(` Rollback: old ${op.logicalId} re-created, but deleting the new resource (${current.physicalId}) failed: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. Delete it manually — it is no longer tracked in state.`, secrets));
|
|
33551
|
+
survivorReason = `The replacement's new ${op.resourceType} (${current.physicalId}) could not be deleted after the old resource was re-created: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. It is live, still billing, and no longer tracked by cdkd — delete it yourself.`;
|
|
32423
33552
|
result.warnings++;
|
|
32424
33553
|
}
|
|
32425
33554
|
logger.info(adoptedLiveNewResource ? ` Rollback: ${op.logicalId} adopted the live resource (${createResult.physicalId}) — replacement NOT fully reversed (name-idempotent Create API)` : ` Rollback: ${op.logicalId} replacement reversed (old resource re-created as ${createResult.physicalId})`);
|
|
33555
|
+
const survivorProvisionedBy = current.provisionedBy;
|
|
32426
33556
|
ctx.recordEvent?.({
|
|
32427
33557
|
eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
|
|
32428
33558
|
stackName,
|
|
32429
33559
|
operation: "UPDATE",
|
|
32430
33560
|
logicalId: op.logicalId,
|
|
32431
33561
|
resourceType: op.resourceType,
|
|
32432
|
-
...op.provisionedBy && { provisionedBy: op.provisionedBy }
|
|
33562
|
+
...op.provisionedBy && { provisionedBy: op.provisionedBy },
|
|
33563
|
+
...survivorReason !== void 0 && {
|
|
33564
|
+
physicalId: current.physicalId,
|
|
33565
|
+
reason: maskSecretsInText(survivorReason, secrets),
|
|
33566
|
+
...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
|
|
33567
|
+
}
|
|
32433
33568
|
});
|
|
32434
33569
|
return;
|
|
32435
33570
|
}
|
|
@@ -33118,7 +34253,10 @@ var DeploymentEventsReader = class {
|
|
|
33118
34253
|
* window and rewrites (or removes) `index.json` to match.
|
|
33119
34254
|
*
|
|
33120
34255
|
* Retention semantics (see {@link DeploymentEventsPruneOptions}):
|
|
33121
|
-
* - `all` — delete every run + the index
|
|
34256
|
+
* - `all` — delete every run + the index. Clears the LISTING only:
|
|
34257
|
+
* this deletes by key with no `VersionId`, so on a
|
|
34258
|
+
* versioned bucket every earlier version survives
|
|
34259
|
+
* (issue #2624).
|
|
33122
34260
|
* - `keep N` — retain the newest N runs, delete the rest.
|
|
33123
34261
|
* - `olderThanMs`— delete runs whose run-id timestamp is older than the
|
|
33124
34262
|
* cutoff; a run id with no parseable timestamp is kept.
|
|
@@ -33238,31 +34376,15 @@ var InterruptedError = class extends Error {
|
|
|
33238
34376
|
this.name = "InterruptedError";
|
|
33239
34377
|
}
|
|
33240
34378
|
};
|
|
33241
|
-
|
|
33242
|
-
* Best-effort routing inference for the live-progress task label
|
|
33243
|
-
* (#614 §9). Mirrors the routing decision tree but is purely cosmetic:
|
|
33244
|
-
* errors here never surface — when the inference fails we return
|
|
33245
|
-
* `undefined` and the label gets no `[CC API]` tag. The real
|
|
33246
|
-
* `getProviderFor` call inside the deploy/destroy critical path is the
|
|
33247
|
-
* load-bearing dispatch.
|
|
33248
|
-
*
|
|
33249
|
-
* Inputs:
|
|
33250
|
-
* - CREATE / UPDATE → template-side `desiredProperties` (top-level CFn
|
|
33251
|
-
* property names; intrinsic resolution does not change those, so we
|
|
33252
|
-
* can route ahead of the resolver run).
|
|
33253
|
-
* - DELETE → sticky `provisionedBy` from the existing-state record.
|
|
33254
|
-
*
|
|
33255
|
-
* Exported so {@link DeployEngine.peekRoutingForLabel} stays a 1-line
|
|
33256
|
-
* delegate and the routing-inference logic is directly unit-testable
|
|
33257
|
-
* without standing up a full DeployEngine harness.
|
|
33258
|
-
*/
|
|
33259
|
-
function deriveLabelRouting(change, existingState, registry) {
|
|
34379
|
+
function deriveLabelRouting(change, existingState, registry, forceCcApi = false) {
|
|
33260
34380
|
try {
|
|
33261
34381
|
if (change.changeType === "DELETE") return existingState?.provisionedBy;
|
|
33262
34382
|
return registry.getProviderFor({
|
|
33263
34383
|
resourceType: change.resourceType,
|
|
33264
|
-
properties: change.desiredProperties,
|
|
33265
|
-
provisionedBy: existingState?.provisionedBy
|
|
34384
|
+
properties: change.desiredProperties ?? {},
|
|
34385
|
+
provisionedBy: existingState?.provisionedBy,
|
|
34386
|
+
previousProperties: existingState?.properties,
|
|
34387
|
+
forceCcApi
|
|
33266
34388
|
}).provisionedBy;
|
|
33267
34389
|
} catch {
|
|
33268
34390
|
return;
|
|
@@ -33533,6 +34655,39 @@ var DeployEngine = class {
|
|
|
33533
34655
|
*/
|
|
33534
34656
|
attemptedResolvedProps = /* @__PURE__ */ new Map();
|
|
33535
34657
|
/**
|
|
34658
|
+
* Logical ids whose replacement this deploy DELIBERATELY left the old
|
|
34659
|
+
* physical resource alive for — `UpdateReplacePolicy: Retain` (issue
|
|
34660
|
+
* [#2603](https://github.com/go-to-k/cdkd/issues/2603)).
|
|
34661
|
+
*
|
|
34662
|
+
* Written by every engine path that skips the post-replacement delete, read
|
|
34663
|
+
* once at the `completedOperations.push` site to stamp
|
|
34664
|
+
* {@link CompletedOperation.oldResourceRetained}. It exists because the
|
|
34665
|
+
* rollback classifier used to re-derive the verdict from
|
|
34666
|
+
* `previousState.updateReplacePolicy` — a DIFFERENT source than the
|
|
34667
|
+
* template read the engine decides from — so the two disagreed on exactly
|
|
34668
|
+
* the deploy that changes the attribute, in both directions:
|
|
34669
|
+
*
|
|
34670
|
+
* - ADDING `Retain`: the deploy orphans the old resource while the
|
|
34671
|
+
* previous state record carries no policy, so the rollback re-CREATED a
|
|
34672
|
+
* resource that is still alive (a duplicate for an auto-named type, an
|
|
34673
|
+
* `AlreadyExists` failure for a user-named one).
|
|
34674
|
+
* - DROPPING `Retain`: state still carries the stale `Retain`, the
|
|
34675
|
+
* template omits it so the deploy correctly DELETES the old resource,
|
|
34676
|
+
* and the rollback then re-adopted a physical id that no longer exists,
|
|
34677
|
+
* leaving state naming a deleted resource.
|
|
34678
|
+
*
|
|
34679
|
+
* Records only the DELIBERATE retention. A best-effort cleanup delete that
|
|
34680
|
+
* FAILED, was SKIPPED, or was blocked by a failed final snapshot also leaves
|
|
34681
|
+
* the old resource alive, but the deploy does not KNOW it survived — those
|
|
34682
|
+
* stay `false` and keep today's `reverse-replacement` behaviour rather than
|
|
34683
|
+
* having the rollback re-adopt an id it cannot vouch for (issue
|
|
34684
|
+
* [#2631](https://github.com/go-to-k/cdkd/issues/2631)).
|
|
34685
|
+
*
|
|
34686
|
+
* Cleared per `deploy()` alongside the other per-run maps: a `false` here
|
|
34687
|
+
* must mean "this deploy deleted it", never "a previous run said so".
|
|
34688
|
+
*/
|
|
34689
|
+
retainedOldOnReplacement = /* @__PURE__ */ new Set();
|
|
34690
|
+
/**
|
|
33536
34691
|
* Target region for this stack. Required — load-bearing for the
|
|
33537
34692
|
* region-prefixed S3 state key and recorded in state.json for
|
|
33538
34693
|
* cross-region destroy.
|
|
@@ -33571,6 +34726,7 @@ var DeployEngine = class {
|
|
|
33571
34726
|
this.outputSecrets = /* @__PURE__ */ new Map();
|
|
33572
34727
|
this.outputsTemplateSource = {};
|
|
33573
34728
|
this.outputsSourceUsable = true;
|
|
34729
|
+
this.retainedOldOnReplacement = /* @__PURE__ */ new Set();
|
|
33574
34730
|
this.resolver.resetPhysicalIdFallbackCount();
|
|
33575
34731
|
return withStackName(stackName, () => this.doDeploy(stackName, template));
|
|
33576
34732
|
}
|
|
@@ -33992,7 +35148,10 @@ var DeployEngine = class {
|
|
|
33992
35148
|
for (const { logicalId, resource } of candidates) {
|
|
33993
35149
|
let provider;
|
|
33994
35150
|
try {
|
|
33995
|
-
provider = this.providerRegistry.
|
|
35151
|
+
provider = this.providerRegistry.getProviderFor({
|
|
35152
|
+
resourceType: resource.resourceType,
|
|
35153
|
+
provisionedBy: resource.provisionedBy
|
|
35154
|
+
}).provider;
|
|
33996
35155
|
} catch {
|
|
33997
35156
|
continue;
|
|
33998
35157
|
}
|
|
@@ -34084,6 +35243,11 @@ var DeployEngine = class {
|
|
|
34084
35243
|
diffResolverContext.skipDynamicReferences = true;
|
|
34085
35244
|
const diffResolveFn = (value) => this.resolver.resolve(value, diffResolverContext);
|
|
34086
35245
|
const changes = await this.diffCalculator.calculateDiff(currentState, effectiveTemplate, diffResolveFn, makeCanonicalizePropertiesFn(this.providerRegistry));
|
|
35246
|
+
const nestedStackTypeChanges = findNestedStackTypeChanges({
|
|
35247
|
+
changes,
|
|
35248
|
+
stateResources: currentState.resources
|
|
35249
|
+
});
|
|
35250
|
+
if (nestedStackTypeChanges.length > 0) throw markNonRetryable(new CdkdError(renderNestedStackTypeChangeRefusal(nestedStackTypeChanges, stackName), "TYPE_CHANGE_NESTED_STACK"));
|
|
34087
35251
|
if (!this.diffCalculator.hasChanges(changes)) {
|
|
34088
35252
|
this.logger.info("No changes detected. Stack is up to date.");
|
|
34089
35253
|
let persistedOutputs = currentState.outputs ?? {};
|
|
@@ -34293,7 +35457,8 @@ var DeployEngine = class {
|
|
|
34293
35457
|
provisionedBy: newResources[logicalId]?.provisionedBy ?? previousState?.provisionedBy,
|
|
34294
35458
|
previousState,
|
|
34295
35459
|
physicalId: newResources[logicalId]?.physicalId,
|
|
34296
|
-
properties: newResources[logicalId]?.properties
|
|
35460
|
+
properties: newResources[logicalId]?.properties,
|
|
35461
|
+
...change.changeType === "UPDATE" && { oldResourceRetained: this.retainedOldOnReplacement.has(logicalId) }
|
|
34297
35462
|
});
|
|
34298
35463
|
saveStateAfterResource(logicalId);
|
|
34299
35464
|
}, () => this.interrupted);
|
|
@@ -34611,9 +35776,10 @@ var DeployEngine = class {
|
|
|
34611
35776
|
async provisionResource(logicalId, change, stateResources, stackName, template, parameterValues, conditions, counts, progress) {
|
|
34612
35777
|
const resourceType = change.resourceType;
|
|
34613
35778
|
const renderer = getLiveRenderer();
|
|
34614
|
-
const
|
|
35779
|
+
const labelRecreateDirection = this.recreateDirectionFor(stackName, logicalId);
|
|
35780
|
+
const needsReplacement = change.changeType === "UPDATE" && (change.propertyChanges?.some((pc) => pc.requiresReplacement) ?? false) || labelRecreateDirection !== void 0;
|
|
34615
35781
|
const verb = change.changeType === "CREATE" ? "Creating" : change.changeType === "DELETE" ? "Deleting" : needsReplacement ? "Replacing" : "Updating";
|
|
34616
|
-
const labelRouting = this.peekRoutingForLabel(change, stateResources[logicalId]);
|
|
35782
|
+
const labelRouting = this.peekRoutingForLabel(change, stateResources[logicalId], stackName, logicalId, needsReplacement, labelRecreateDirection);
|
|
34617
35783
|
const baseLabel = `${verb} ${logicalId} (${resourceType})${labelRouting === "cc-api" ? " [CC API]" : ""}`;
|
|
34618
35784
|
renderer.addTask(logicalId, baseLabel);
|
|
34619
35785
|
const operationKind = change.changeType === "CREATE" ? "CREATE" : change.changeType === "DELETE" ? "DELETE" : "UPDATE";
|
|
@@ -34708,8 +35874,51 @@ var DeployEngine = class {
|
|
|
34708
35874
|
renderer.removeTask(logicalId);
|
|
34709
35875
|
}
|
|
34710
35876
|
}
|
|
34711
|
-
|
|
34712
|
-
|
|
35877
|
+
/**
|
|
35878
|
+
* Is this resource pinned to Cloud Control for this deploy (`--pin-cc-api`)?
|
|
35879
|
+
*
|
|
35880
|
+
* ONE implementation, called by the update dispatch and by the progress
|
|
35881
|
+
* label. They carried separate copies of this expression for one revision,
|
|
35882
|
+
* and a mutation probe caught the predictable result: neutering the LABEL's
|
|
35883
|
+
* copy left every test green, because the only cases that existed exercised
|
|
35884
|
+
* the dispatch's. Same shape as the duplicated flip predicate this lane
|
|
35885
|
+
* already collapsed once.
|
|
35886
|
+
*
|
|
35887
|
+
* SCOPED BY STACK, like `recreateTargets`. `NestedStackProvider.runChildDeploy`
|
|
35888
|
+
* spreads the parent's options into every child engine, and a logical id is
|
|
35889
|
+
* unique only within one template, so an unscoped set would pin a same-named
|
|
35890
|
+
* resource in a stack the user never named — silently, since a pin produces
|
|
35891
|
+
* no output of its own.
|
|
35892
|
+
*/
|
|
35893
|
+
isPinnedToCcApi(stackName, logicalId) {
|
|
35894
|
+
return this.options.pinCcApi?.stackName === stackName && this.options.pinCcApi.logicalIds.has(logicalId);
|
|
35895
|
+
}
|
|
35896
|
+
/**
|
|
35897
|
+
* The `--recreate-via-*` direction for this resource, or `undefined`.
|
|
35898
|
+
*
|
|
35899
|
+
* Stack-scoped for the same reason as {@link isPinnedToCcApi}, and extracted
|
|
35900
|
+
* for a sharper one: the LABEL and the DISPATCH were computing "is this a
|
|
35901
|
+
* replacement" from DIFFERENT expressions. The dispatch asks
|
|
35902
|
+
* `propertyDrivenReplacement || recreateFlagged`; the label asked only the
|
|
35903
|
+
* property half. So a `--recreate-via-*` target whose property change does
|
|
35904
|
+
* not itself force a replacement took the label's non-replacement path and
|
|
35905
|
+
* was routed from the state record, while the dispatch routed it from the
|
|
35906
|
+
* flag -- mislabelling in BOTH directions, and rendering `Updating` over a
|
|
35907
|
+
* destroy + recreate.
|
|
35908
|
+
*
|
|
35909
|
+
* Three review rounds fixed three instances of that one class (the pin, then
|
|
35910
|
+
* the sticky inputs, then this) by subtracting one input at a time from the
|
|
35911
|
+
* label. The class closes by asking the same QUESTION at both sites instead.
|
|
35912
|
+
*/
|
|
35913
|
+
recreateDirectionFor(stackName, logicalId) {
|
|
35914
|
+
const targets = this.options.recreateTargets?.stackName === stackName ? this.options.recreateTargets : void 0;
|
|
35915
|
+
if (targets === void 0) return void 0;
|
|
35916
|
+
if (targets.viaCcApi.has(logicalId)) return "cc-api";
|
|
35917
|
+
if (targets.viaSdkProvider.has(logicalId)) return "sdk";
|
|
35918
|
+
}
|
|
35919
|
+
peekRoutingForLabel(change, existingState, stackName, logicalId, needsReplacement = false, recreateDirection) {
|
|
35920
|
+
if (needsReplacement) return deriveLabelRouting(change, recreateDirection === void 0 ? void 0 : { provisionedBy: recreateDirection }, this.providerRegistry, recreateDirection === "cc-api");
|
|
35921
|
+
return deriveLabelRouting(change, existingState, this.providerRegistry, this.isPinnedToCcApi(stackName, logicalId));
|
|
34713
35922
|
}
|
|
34714
35923
|
/**
|
|
34715
35924
|
* #808 — forward one structured deployment event to the optional
|
|
@@ -34820,7 +36029,7 @@ var DeployEngine = class {
|
|
|
34820
36029
|
isRetryable: isRecreateRetryableError
|
|
34821
36030
|
});
|
|
34822
36031
|
} catch (recreateError) {
|
|
34823
|
-
throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets));
|
|
36032
|
+
throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets), { cause: recreateError instanceof Error ? recreateError : void 0 });
|
|
34824
36033
|
}
|
|
34825
36034
|
}
|
|
34826
36035
|
/**
|
|
@@ -34917,15 +36126,16 @@ var DeployEngine = class {
|
|
|
34917
36126
|
break;
|
|
34918
36127
|
}
|
|
34919
36128
|
const propertyDrivenReplacement = change.propertyChanges?.some((pc) => pc.requiresReplacement);
|
|
34920
|
-
const
|
|
34921
|
-
const
|
|
36129
|
+
const recreateTargets = this.options.recreateTargets?.stackName === stackName ? this.options.recreateTargets : void 0;
|
|
36130
|
+
const recreateViaCcApi = recreateTargets?.viaCcApi.has(logicalId) ?? false;
|
|
36131
|
+
const recreateViaSdkProvider = recreateTargets?.viaSdkProvider.has(logicalId) ?? false;
|
|
34922
36132
|
const recreateFlagged = recreateViaCcApi || recreateViaSdkProvider;
|
|
34923
36133
|
const needsReplacement = propertyDrivenReplacement || recreateFlagged;
|
|
34924
36134
|
const dependencies = this.extractAllDependencies(template, logicalId);
|
|
34925
36135
|
const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
|
|
34926
36136
|
if (needsReplacement) {
|
|
34927
36137
|
if (propertyDrivenReplacement && !recreateFlagged && updateReplacePolicy !== "Retain") {
|
|
34928
|
-
const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps);
|
|
36138
|
+
const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
|
|
34929
36139
|
if (statefulReason && this.options.forceStatefulRecreation !== true) {
|
|
34930
36140
|
const immutableProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
|
|
34931
36141
|
throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement (immutable property changed: ${immutableProps}) but it is a stateful resource — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.`, "STATEFUL_REPLACE_BLOCKED"));
|
|
@@ -34940,7 +36150,8 @@ var DeployEngine = class {
|
|
|
34940
36150
|
const replaceDecision = this.providerRegistry.getProviderFor({
|
|
34941
36151
|
resourceType,
|
|
34942
36152
|
properties: resolvedProps,
|
|
34943
|
-
...recreateDirectionHint && { provisionedBy: recreateDirectionHint }
|
|
36153
|
+
...recreateDirectionHint && { provisionedBy: recreateDirectionHint },
|
|
36154
|
+
...recreateViaCcApi && { forceCcApi: true }
|
|
34944
36155
|
});
|
|
34945
36156
|
const replaceProvider = replaceDecision.provider;
|
|
34946
36157
|
const replaceProps = replaceDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
|
|
@@ -34951,8 +36162,10 @@ var DeployEngine = class {
|
|
|
34951
36162
|
let createResult;
|
|
34952
36163
|
if (recreateFlagged) {
|
|
34953
36164
|
const recreateFlagName = recreateViaCcApi ? "--recreate-via-cc-api" : "--recreate-via-sdk-provider";
|
|
34954
|
-
if (updateReplacePolicy === "Retain")
|
|
34955
|
-
|
|
36165
|
+
if (updateReplacePolicy === "Retain") {
|
|
36166
|
+
this.retainedOldOnReplacement.add(logicalId);
|
|
36167
|
+
this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — ${recreateFlagName} will leak the old physical resource (${currentResource.physicalId}). The new resource shares the same name where applicable; if the type has user-supplied names (e.g. functionName, bucketName), the create will deterministically collide with the retained orphan.`);
|
|
36168
|
+
} else {
|
|
34956
36169
|
this.logger.info(` Destroying old ${logicalId} (${currentResource.physicalId}) before recreate...`);
|
|
34957
36170
|
const recreateFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
|
|
34958
36171
|
let recreateDeleteResult;
|
|
@@ -35003,8 +36216,10 @@ var DeployEngine = class {
|
|
|
35003
36216
|
deletedOldFirst = true;
|
|
35004
36217
|
createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateSecrets, updateReplacePolicy);
|
|
35005
36218
|
}
|
|
35006
|
-
if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain")
|
|
35007
|
-
|
|
36219
|
+
if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") {
|
|
36220
|
+
this.retainedOldOnReplacement.add(logicalId);
|
|
36221
|
+
this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
|
|
36222
|
+
} else {
|
|
35008
36223
|
this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
|
|
35009
36224
|
let cleanupFinalSnapshotId;
|
|
35010
36225
|
let snapshotBlockedDelete = false;
|
|
@@ -35055,12 +36270,30 @@ var DeployEngine = class {
|
|
|
35055
36270
|
const updateDecision = this.providerRegistry.getProviderFor({
|
|
35056
36271
|
resourceType,
|
|
35057
36272
|
properties: resolvedProps,
|
|
35058
|
-
provisionedBy: currentResource.provisionedBy
|
|
36273
|
+
provisionedBy: currentResource.provisionedBy,
|
|
36274
|
+
previousProperties: currentResource.properties,
|
|
36275
|
+
...this.isPinnedToCcApi(stackName, logicalId) && { forceCcApi: true }
|
|
35059
36276
|
});
|
|
36277
|
+
if (updateDecision.sdkMigration === true) {
|
|
36278
|
+
const exemptMode = STICKY_CC_MIGRATION_EXEMPT.get(resourceType)?.mode;
|
|
36279
|
+
const preserved = "The physical id is preserved";
|
|
36280
|
+
let message;
|
|
36281
|
+
switch (exemptMode) {
|
|
36282
|
+
case "cc-broken":
|
|
36283
|
+
message = `${logicalId} (${resourceType}): moving to the SDK provider — Cloud Control cannot manage this type correctly. ${preserved}, and this routing is not optional.`;
|
|
36284
|
+
break;
|
|
36285
|
+
case "sdk-coverage":
|
|
36286
|
+
message = `${logicalId} (${resourceType}): returning to the SDK provider — cdkd now covers every property this resource uses. ${preserved}; pass --pin-cc-api ${logicalId} to decline this for a deploy.`;
|
|
36287
|
+
break;
|
|
36288
|
+
default: message = `${logicalId} (${resourceType}): moving to the SDK provider. ${preserved}.`;
|
|
36289
|
+
}
|
|
36290
|
+
this.logger.info(message);
|
|
36291
|
+
}
|
|
35060
36292
|
const updateProvider = updateDecision.provider;
|
|
35061
36293
|
const updateProps = updateDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
|
|
35062
36294
|
let result;
|
|
35063
36295
|
let resultProvisionedBy = updateDecision.provisionedBy;
|
|
36296
|
+
let captureProvider = updateProvider;
|
|
35064
36297
|
try {
|
|
35065
36298
|
result = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => updateProvider.update(logicalId, currentResource.physicalId, resourceType, updateProps, currentProps, {
|
|
35066
36299
|
maskSecrets: createSecretMasker(updateSecrets),
|
|
@@ -35071,7 +36304,7 @@ var DeployEngine = class {
|
|
|
35071
36304
|
const replaceOptIn = updateError instanceof ResourceUpdateNotSupportedError && this.options.replace === true;
|
|
35072
36305
|
if (ccUnsupported || replaceOptIn) {
|
|
35073
36306
|
const retainOldOnReplace = updateReplacePolicy === "Retain";
|
|
35074
|
-
const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps);
|
|
36307
|
+
const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
|
|
35075
36308
|
if (statefulReason && this.options.forceStatefulRecreation !== true) throw markNonRetryable(new CdkdError(replaceOptIn ? `--replace would DELETE + CREATE the stateful resource ${logicalId} (${resourceType}) — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.` : `${logicalId} (${resourceType}) cannot be updated in place by the provisioning layer it routes through, so applying this change would DELETE + CREATE it — but it is a stateful resource: ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the update.`, "STATEFUL_REPLACE_BLOCKED", updateError instanceof Error ? updateError : void 0));
|
|
35076
36309
|
this.logger.info(retainOldOnReplace ? `UPDATE not supported for ${logicalId} (${resourceType}), replacing (CREATE only — UpdateReplacePolicy: Retain keeps the old resource)` : `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE → CREATE)`);
|
|
35077
36310
|
if (!retainOldOnReplace) {
|
|
@@ -35102,7 +36335,7 @@ var DeployEngine = class {
|
|
|
35102
36335
|
try {
|
|
35103
36336
|
createResult = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => replProvider.create(logicalId, resourceType, replProps, { maskSecrets: createSecretMasker(updateSecrets) })), logicalId, void 0, void 0, replProvider);
|
|
35104
36337
|
} catch (createError) {
|
|
35105
|
-
if (!retainOldOnReplace) throw createError;
|
|
36338
|
+
if (!retainOldOnReplace) throw new Error(maskSecretsInText(`Failed to create ${logicalId} after the UPDATE-not-supported replacement: the old resource (${currentResource.physicalId}) is now gone. Cause: ${createError instanceof Error ? createError.message : String(createError)}. Re-run the deploy to create it fresh.`, updateSecrets), { cause: createError instanceof Error ? createError : void 0 });
|
|
35106
36339
|
if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
|
|
35107
36340
|
const nameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
|
|
35108
36341
|
throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement because the provisioning layer cannot update it in place — but its physical name is still held by the existing resource AND UpdateReplacePolicy: Retain pins that resource in place. ${nameOrigin.descriptor}. ${nameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed. Removing UpdateReplacePolicy: Retain lets cdkd delete the old resource first, which destroys it and any data it holds.`, "NAMED_REPLACEMENT_COLLISION", createError instanceof Error ? createError : void 0));
|
|
@@ -35112,6 +36345,7 @@ var DeployEngine = class {
|
|
|
35112
36345
|
const idempotentNameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
|
|
35113
36346
|
throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement, but its Create API is name-idempotent: the create returned the existing resource (${currentResource.physicalId}) instead of creating a new one, and UpdateReplacePolicy: Retain pins that resource in place, so the new properties were not applied. ${idempotentNameOrigin.descriptor}. ${idempotentNameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed.`, "NAMED_REPLACEMENT_IDEMPOTENT_CREATE"));
|
|
35114
36347
|
}
|
|
36348
|
+
this.retainedOldOnReplacement.add(logicalId);
|
|
35115
36349
|
this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — the old physical resource (${currentResource.physicalId}) is RETAINED and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need its data.`);
|
|
35116
36350
|
retainedSurvivorReason = `UpdateReplacePolicy: Retain kept the old ${resourceType} (${currentResource.physicalId}), now untracked by cdkd`;
|
|
35117
36351
|
}
|
|
@@ -35129,6 +36363,7 @@ var DeployEngine = class {
|
|
|
35129
36363
|
if (createResult.effectiveProperties) replacementResult.effectiveProperties = createResult.effectiveProperties;
|
|
35130
36364
|
result = replacementResult;
|
|
35131
36365
|
resultProvisionedBy = replDecision.provisionedBy;
|
|
36366
|
+
captureProvider = replProvider;
|
|
35132
36367
|
} else throw updateError;
|
|
35133
36368
|
}
|
|
35134
36369
|
if (result.wasReplaced) this.logger.info(`Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`);
|
|
@@ -35148,7 +36383,7 @@ var DeployEngine = class {
|
|
|
35148
36383
|
provisionedBy: resultProvisionedBy
|
|
35149
36384
|
};
|
|
35150
36385
|
const updateCaptureSiblings = await this.buildObservedCaptureSiblings(resourceType, logicalId, result.physicalId, template, stateResources, stackName, parameterValues, conditions);
|
|
35151
|
-
this.kickOffObservedCapture(
|
|
36386
|
+
this.kickOffObservedCapture(captureProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
|
|
35152
36387
|
const updatePartial = updatePartialReason(result);
|
|
35153
36388
|
if (counts) if (updatePartial !== void 0) counts.updatePartial++;
|
|
35154
36389
|
else counts.updated++;
|
|
@@ -35506,10 +36741,55 @@ var DeployEngine = class {
|
|
|
35506
36741
|
* the deploy instead of silently publishing nothing (which breaks downstream
|
|
35507
36742
|
* `Fn::ImportValue` consumers with "export not found" long after this deploy
|
|
35508
36743
|
* exits 0).
|
|
35509
|
-
|
|
35510
|
-
|
|
35511
|
-
|
|
35512
|
-
|
|
36744
|
+
*
|
|
36745
|
+
* The error is masked on both arms (issue
|
|
36746
|
+
* [#2728](https://github.com/go-to-k/cdkd/issues/2728)). The resolver's own
|
|
36747
|
+
* failures echo the offending reference token, a secret id / JSON key, or
|
|
36748
|
+
* an SSM parameter name, and `resolveSub` / `resolveJoin` re-enter
|
|
36749
|
+
* `resolveDynamicReferences` with the ASSEMBLED string — so a reference
|
|
36750
|
+
* built out of a value this same pass resolved from a secret puts that
|
|
36751
|
+
* plaintext into the echoed field (a JSON key assembled from the resolved
|
|
36752
|
+
* password says `key '<password>' not found`).
|
|
36753
|
+
*
|
|
36754
|
+
* TWO bags, the inherited one first, the same pair the resolver's
|
|
36755
|
+
* `maskSecretsForLog` masks against and for the same reason (issue #1903
|
|
36756
|
+
* round 2): on a nested-stack child the parent-decrypted parameter
|
|
36757
|
+
* plaintext is in `inheritedSecrets` and not in the pass map until a
|
|
36758
|
+
* `{Ref: <Param>}` resolution copies it across. No shape reaching this
|
|
36759
|
+
* handler before that copy has been constructed (every `${Param}` route
|
|
36760
|
+
* goes through `resolveRef`, which records), so the inherited bag is
|
|
36761
|
+
* defense in depth here — but two masking sites in one flow must not argue
|
|
36762
|
+
* opposite sides of the same question. `secrets` is the outputs pass's own
|
|
36763
|
+
* map: everything recorded before this handler runs, an `Export.Name`
|
|
36764
|
+
* resolution's entries included (its `finally` merges them back before the
|
|
36765
|
+
* `catch` reaches here). What a still-pending concurrent part would have
|
|
36766
|
+
* recorded is outside both — issue #2563's late write, the same bound every
|
|
36767
|
+
* other masking site in this engine has.
|
|
36768
|
+
*
|
|
36769
|
+
* The strict arm's `cause` is masked as an OBJECT, through
|
|
36770
|
+
* `maskSecretsInError` — a clone of each `Error` link `errorCauseChain`
|
|
36771
|
+
* reaches (`ERROR_CAUSE_MASK_MAX_DEPTH` links, a cycle stops it; a
|
|
36772
|
+
* non-`Error` cause is kept verbatim), symbols included, so
|
|
36773
|
+
* `isMarkedNonRetryable`'s non-enumerable marker survives (the same reason
|
|
36774
|
+
* `provisionResource` uses it). No sink on the deploy path renders past
|
|
36775
|
+
* one level today (`formatError` prints `Caused by:` for a `CdkdError`'s
|
|
36776
|
+
* direct cause only), but that is a property of reachability, not of the
|
|
36777
|
+
* sinks: `cdkd scrub`'s `describeFailure` renders a chain, and is safe
|
|
36778
|
+
* because its boundary masks with this same helper and it walks the same
|
|
36779
|
+
* bounded `errorCauseChain`. Masking here gives a renderer within that
|
|
36780
|
+
* bound nothing to leak. A thrown STRING is masked as text; any other
|
|
36781
|
+
* non-`Error` value is not threaded as a cause at all (it would travel
|
|
36782
|
+
* unmasked, and `markNonRetryable` cannot have marked it).
|
|
36783
|
+
*/
|
|
36784
|
+
handleOutputResolutionFailure(error, outputKey, outputs, secrets, inheritedSecrets) {
|
|
36785
|
+
let detail = error instanceof Error ? error.message || error.name : String(error);
|
|
36786
|
+
for (const bag of [inheritedSecrets, secrets]) detail = maskSecretsInText(detail, bag);
|
|
36787
|
+
if (this.options.strictGetAtt) {
|
|
36788
|
+
let cause = error;
|
|
36789
|
+
for (const bag of [inheritedSecrets, secrets]) cause = typeof cause === "string" ? maskSecretsInText(cause, bag) : maskSecretsInError(cause, bag);
|
|
36790
|
+
throw new Error(`Failed to resolve output ${outputKey}: ${detail} (--strict-getatt promotes output resolution failures to deploy errors; drop the flag to warn and skip the output instead)`, cause instanceof Error || typeof cause === "string" ? { cause } : {});
|
|
36791
|
+
}
|
|
36792
|
+
this.logger.warn(`Failed to resolve output ${outputKey}: ${detail}`);
|
|
35513
36793
|
outputs[outputKey] = void 0;
|
|
35514
36794
|
}
|
|
35515
36795
|
/**
|
|
@@ -35531,6 +36811,8 @@ var DeployEngine = class {
|
|
|
35531
36811
|
...parameterValues && { parameters: parameterValues },
|
|
35532
36812
|
...conditions && { conditions }
|
|
35533
36813
|
}, stackName);
|
|
36814
|
+
const outputsPassSecrets = context.recordedSecretValues ?? EMPTY_SECRETS;
|
|
36815
|
+
const outputsPassInherited = context.inheritedSecrets ?? EMPTY_SECRETS;
|
|
35534
36816
|
const publishedOutputNames = collectPublishedOutputNames(template.Outputs, conditions);
|
|
35535
36817
|
let outputsPassCompleted = false;
|
|
35536
36818
|
try {
|
|
@@ -35542,7 +36824,7 @@ var DeployEngine = class {
|
|
|
35542
36824
|
try {
|
|
35543
36825
|
outputs[outputKey] = await this.resolver.resolve(output.Value, context);
|
|
35544
36826
|
} catch (error) {
|
|
35545
|
-
this.handleOutputResolutionFailure(error, outputKey, outputs);
|
|
36827
|
+
this.handleOutputResolutionFailure(error, outputKey, outputs, outputsPassSecrets, outputsPassInherited);
|
|
35546
36828
|
}
|
|
35547
36829
|
}
|
|
35548
36830
|
for (const [outputKey, output] of Object.entries(template.Outputs)) {
|
|
@@ -35562,7 +36844,7 @@ var DeployEngine = class {
|
|
|
35562
36844
|
for (const [plaintext, expression] of nameSecrets) context.recordedSecretValues?.set(plaintext, expression);
|
|
35563
36845
|
}
|
|
35564
36846
|
} catch (error) {
|
|
35565
|
-
this.handleOutputResolutionFailure(error, outputKey, outputs);
|
|
36847
|
+
this.handleOutputResolutionFailure(error, outputKey, outputs, outputsPassSecrets, outputsPassInherited);
|
|
35566
36848
|
continue;
|
|
35567
36849
|
}
|
|
35568
36850
|
if (typeof exportName !== "string") continue;
|
|
@@ -35601,5 +36883,5 @@ var DeployEngine = class {
|
|
|
35601
36883
|
};
|
|
35602
36884
|
|
|
35603
36885
|
//#endregion
|
|
35604
|
-
export {
|
|
35605
|
-
//# sourceMappingURL=deploy-engine
|
|
36886
|
+
export { findActionableSilentDrops as $, runDockerStreaming as $n, StackTerminationProtectionError as $r, carriesSecretMask as $t, WARM_THROUGHPUT_MEMBERS as A, rewriteTemplateAssetReferences as An, clearBucketRegionCache as Ar, configStringRefusal as At, yellow as B, validateContainerRepoName as Bn, DependencyError as Br, s3BucketDualStackDomainName as Bt, unsupportedFinalSnapshotError as C, shouldRetainResource as Cn, displaySafe as Cr, refStateLookupFromResource as Ct, isStatefulRecreateTargetForReplace as D, buildAssetRedirectMap as Dn, derivePartitionAndUrlSuffix as Dr, assertRegionMatch as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, WorkGraph as En, canonicalizeRegion as Er, resolveExplicitPhysicalId as Et, bold as F, getBootstrapMarkerKey as Fn, setAwsClients as Fr, requireConfigString as Ft, secretBearingStateKeyWarning as G, describeDockerExecFailure as Gn, LocalStartServiceError as Gr, INTRINSIC_KEYS as Gt, collectPublishedOutputNames as H, describeAwsFailure as Hn, DynamicReferenceRegionAmbiguousError as Hr, s3BucketWebsiteUrl as Ht, cyan as I, isCrossRegionRedirect as In, AssetError as Ir, classifyReplaySecretRegion as It, IAMRoleProvider as J, formatDockerLoginError as Jn, PartialFailureError as Jr, DagBuilder as Jt, stateKeySecretExposure as K, describeDockerFailure as Kn, LockError as Kr, describeTypeWithThrottleRetry as Kt, gray as L, parseBootstrapMarker as Ln, CdkdError as Lr, producerRegionsFromState as Lt, isWarmThroughputDecrease as M, BOOTSTRAP_MARKER_PREFIX as Mn, AwsClients as Mr, replayWarn as Mt, toFiniteNumber as N, assertAssetBucketRegion as Nn, getAwsClients as Nr, requireConfigArray as Nt, isStatefulRecreateTargetSync as O, createAssetRedirectResolver as On, AssemblyReader as Or, coerceCfnBoolean as Ot, formatResourceLine as P, ensureAssetStorage as Pn, resetAwsClients as Pr, requireConfigObject as Pt, wouldReturnToSdkProvider as Q, runDockerForeground as Qn, StackHasActiveImportsError as Qr, TEMPLATE_SOURCED_RULES as Qt, green as R, readBootstrapMarkerBody as Rn, ConfigError as Rr, s3BucketArn as Rt, refusesFinalSnapshot as S, importableOutputs as Sn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as Sr, parameterTypeMayLoseSecretIdentity as St, extractDeploymentEventError as T, stringifyValue as Tn, PARTITION_TABLE as Tr, normalizeAwsTagsToCfn as Tt, exportAliasCollisionScrubWarning as U, buildDockerImage as Un, IntrinsicResolutionRefusalError as Ur, applyRoleArnIfSet as Ut, collectDeclaredOutputNames as V, buildDenyExternalAccessPolicy as Vn, DeployCancelledError as Vr, s3BucketRegionalDomainName as Vt, isExportAliasCollision as W, describeDockerCapturedOutput as Wn, LocalInvokeBuildError as Wr, DiffCalculator as Wt, clearOnUpdateRemoval as X, partitionSensitiveEnv as Xn, ResourceTimeoutError as Xr, STATE_SOURCED_CROSS_GENERATION_RULES as Xt, collectInlinePolicyNamesManagedBySiblings as Y, getDockerCmd as Yn, ProvisioningError as Yr, TemplateParser as Yt, ProviderRegistry as Z, redactDockerArgvValues as Zn, ResourceUpdateNotSupportedError as Zr, STATE_SOURCED_READBACK_RULES as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, shellQuote as _n, CFN_TEMPLATE_BODY_LIMIT as _r, carriesDynamicReference as _t, DeploymentEventsStore as a, withErrorHandling as ai, maskSecretsInText as an, synthesisStatusMessage as ar, endCommandInterruptScope as at, createPreDeleteFinalSnapshot as b, exportNamesCarriedFrom as bn, findLargeInlineResources as br, getAccountInfo as bt, replayFailedOperations as c, isThrottlingError as ci, redactSecretsForState as cn, resolveApp as cr, startInterruptWatch as ct, updatePartialReason as d, markRedactedCause as di, S3StateBackend as dn, resolveSkipPrefix as dr, UNSPECIFIED_SKIP_REASON as dt, StateError as ei, createSecretMasker as en, escapeRegExp$1 as er, findSilentDropProperties as et, withResourceDeadline as f, retryClassificationText as fi, rebuildClientForBucketRegion as fn, resolveStateBucketWithDefault as fr, deleteIndeterminateGuards as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, forceQuitRecoveryClause as gn, warnDeprecatedNoPrefixCliFlag as gr, IntrinsicFunctionResolver as gt, computeImplicitDeleteEdges as h, buildLockContentionMessage as hn, stateBucketExistenceConfirmed as hr, isTerminationProtectionPropagationError as ht, DeploymentEventsReader as i, normalizeAwsError as ii, maskSecretsInError as in, Synthesizer as ir, beginCommandInterruptScope as it, coerceWarmThroughput as j, AssetModeResolver as jn, resolveBucketRegion as jr, readConfigString as jt, renderStatefulReason as k, loadPublishableAssetManifest as kn, processStackMessages as kr, configBooleanRefusal as kt, replayRollback as l, isTransientServerError as li, scrubResourceRecord as ln, resolveAutoAssetStorage as lr, CloudControlProvider as lt, IMPLICIT_DELETE_DEPENDENCIES as m, buildForceUnlockCommand as mn, resolveUseCdkBootstrapAssets as mr, disableInstanceApiTermination as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, formatError as ni, errorCauseChain as nn, AssetManifestLoader as nr, maskDeep as nt, planFailedOps as o, isMarkedNonRetryable as oi, recordMaskOnlyValue as on, getDefaultStateBucketName as or, interruptWatchListenerCount as ot, maskingRetryLogger as p, __exportAll as pi, UNRENDERABLE as pn, resolveStateBucketWithDefaultAndSource as pr, deleteSkipReason as pt, getCurrentResourceSecrets as q, dockerSpawnEnvWithSensitive as qn, NestedStackChildDirectDestroyError as qr, withRetry as qt, DeployEngine as r, isCdkdError as ri, isSingleDynamicReferenceToken as rn, getDockerImageBySourceHash as rr, maskerOrIdentity as rt, planRollback as s, isRetryableTransientError as si, recoverMaskedOutput as sn, getLegacyStateBucketName as sr, isInterruptedWaitError as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, SynthesisError as ti, dynamicReferenceTokens as tn, stripControlChars as tr, createMaskedRetryLogger as tt, updatePartialMessage as u, markNonRetryable as ui, LockManager as un, resolveCaptureObservedState as ur, slowCcOperationTimeoutMs as ut, buildFinalSnapshotIdentifier as v, CUSTOM_RESOURCE_RESPONSE_PREFIX as vn, CFN_TEMPLATE_URL_LIMIT as vr, cfnRefValueFromPhysicalId as vt, makeCanonicalizePropertiesFn as w, AssetPublisher as wn, expectedOwnerParam as wr, WAFv2WebACLProvider as wt, isFinalSnapshotError as x, importableOutputKeys as xn, uploadCfnTemplate as xr, isUnboundTemplateParameter as xt, ccRoutedFinalSnapshotError as y, DEFAULT_STATE_PREFIX as yn, MIGRATE_TMP_PREFIX as yr, coerceParameterTypedValue as yt, red as z, validateAssetBucketName as zn, CrossAccountSecretRefusalError as zr, s3BucketDomainName as zt };
|
|
36887
|
+
//# sourceMappingURL=deploy-engine-B377YKBI.js.map
|