@go-to-k/cdkd 0.135.1 → 0.136.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.
@@ -1,5 +1,6 @@
1
1
  import { a as runDockerStreaming, c as getLogger, d as getLiveRenderer, g as generateResourceNameWithFallback, m as applyDefaultNameForFallback, n as formatDockerLoginError, o as spawnStreaming, r as getDockerCmd, v as withStackName } from "./docker-cmd-EtWSTAje.js";
2
2
  import { r as getAwsClients } from "./aws-clients-BF03Alpe.js";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { DeleteObjectCommand, GetBucketLocationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command, NoSuchKey, PutObjectCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
4
5
  import { CloudControlClient, CreateResourceCommand, DeleteResourceCommand, GetResourceCommand, GetResourceRequestStatusCommand, ListResourcesCommand, UpdateResourceCommand } from "@aws-sdk/client-cloudcontrol";
5
6
  import { AttachRolePolicyCommand, CreateRoleCommand, DeleteRoleCommand, DeleteRolePermissionsBoundaryCommand, DeleteRolePolicyCommand, DetachRolePolicyCommand, GetRoleCommand, GetRolePolicyCommand, IAMClient, ListAttachedRolePoliciesCommand, ListInstanceProfilesForRoleCommand, ListRolePoliciesCommand, ListRoleTagsCommand, ListRolesCommand, NoSuchEntityException, PutRolePermissionsBoundaryCommand, PutRolePolicyCommand, RemoveRoleFromInstanceProfileCommand, TagRoleCommand, UntagRoleCommand, UpdateAssumeRolePolicyCommand, UpdateRoleCommand } from "@aws-sdk/client-iam";
@@ -8,6 +9,7 @@ import { GetFunctionUrlConfigCommand, InvokeCommand, LambdaClient, waitUntilFunc
8
9
  import { AssumeRoleCommand, GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";
9
10
  import { DescribeAvailabilityZonesCommand, DescribeImagesCommand, DescribeLaunchTemplatesCommand, DescribeRouteTablesCommand, DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, DescribeVpnGatewaysCommand, EC2Client } from "@aws-sdk/client-ec2";
10
11
  import { DescribeTableCommand } from "@aws-sdk/client-dynamodb";
12
+ import { CloudFormationClient, CreateChangeSetCommand, DeleteStackCommand, DescribeChangeSetCommand, GetTemplateCommand, waitUntilChangeSetCreateComplete } from "@aws-sdk/client-cloudformation";
11
13
  import { GetRestApiCommand } from "@aws-sdk/client-api-gateway";
12
14
  import { GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
13
15
  import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
@@ -388,6 +390,42 @@ var LocalMigrateError = class LocalMigrateError extends CdkdError {
388
390
  }
389
391
  };
390
392
  /**
393
+ * CloudFormation macro / `Fn::Transform` expansion failure (#463).
394
+ *
395
+ * cdkd hands templates that declare `Transform: [...]` (or carry
396
+ * `Fn::Transform: {...}` snippets) to CloudFormation server-side via a
397
+ * transient `CreateChangeSet --change-set-type CREATE` against a
398
+ * `cdkd-macro-expand-<id>` stack name. This error wraps every failure
399
+ * mode of that round-trip:
400
+ *
401
+ * - `CreateChangeSet` rejection (bad template, missing macro IAM
402
+ * permission, custom macro not found in the account).
403
+ * - Changeset settles in `FAILED` (`StatusReason` from CFn is
404
+ * surfaced verbatim — typically a custom macro Lambda error).
405
+ * - Waiter timeout (the macro Lambda is stuck or oversized).
406
+ * - `GetTemplate --template-stage Processed` returns no body (would
407
+ * indicate a CFn-side regression — fail loud rather than silently
408
+ * proceed with the un-expanded template).
409
+ * - Multi-stage detection: the expanded template still contains
410
+ * macros, which cdkd v1 does not support (the design intentionally
411
+ * rejects this so a second round-trip is not silently triggered).
412
+ *
413
+ * The error surfaces at exit code 2 (partial-failure family) — the
414
+ * cleanup `finally` in the expander always runs `DeleteChangeSet` +
415
+ * `DeleteStack` regardless of this error firing, so a failed
416
+ * expansion never leaves a transient CFn stack behind in a routine
417
+ * case. The user can re-run `cdkd deploy` once the upstream cause is
418
+ * fixed.
419
+ */
420
+ var MacroExpansionError = class MacroExpansionError extends CdkdError {
421
+ exitCode = 2;
422
+ constructor(message, cause) {
423
+ super(message, "MACRO_EXPANSION_ERROR", cause);
424
+ this.name = "MacroExpansionError";
425
+ Object.setPrototypeOf(this, MacroExpansionError.prototype);
426
+ }
427
+ };
428
+ /**
391
429
  * Check if error is a cdkd error
392
430
  */
393
431
  function isCdkdError(error) {
@@ -421,7 +459,8 @@ function handleError(error) {
421
459
  const logger = getLogger();
422
460
  if (!(error instanceof CdkdError && error.silent)) logger.error(formatError(error));
423
461
  if (error instanceof Error && error.stack) logger.debug("Stack trace:", error.stack);
424
- const exitCode = error instanceof PartialFailureError ? error.exitCode : 1;
462
+ const customExitCode = error instanceof CdkdError ? error.exitCode : void 0;
463
+ const exitCode = typeof customExitCode === "number" ? customExitCode : 1;
425
464
  process.exit(exitCode);
426
465
  }
427
466
  /**
@@ -1593,6 +1632,567 @@ var ContextProviderRegistry = class {
1593
1632
  }
1594
1633
  };
1595
1634
 
1635
+ //#endregion
1636
+ //#region src/synthesis/macro-detector.ts
1637
+ /**
1638
+ * CloudFormation macro detection (Issue #463 Phase 1).
1639
+ *
1640
+ * Pure-functional helpers that determine whether a synth template uses
1641
+ * any CloudFormation transform (top-level `Transform: [...]` or
1642
+ * snippet-level `Fn::Transform: {...}` blocks). cdkd's analyzer /
1643
+ * provisioner pipeline does NOT understand `Fn::Transform` — the
1644
+ * resolver has no handler and the DAG builder cannot extract refs
1645
+ * buried inside an unexpanded macro snippet — so a template that
1646
+ * triggers `containsMacro` must be handed to CloudFormation for
1647
+ * server-side expansion via {@link import('./macro-expander.js')}
1648
+ * before the rest of the pipeline can safely consume it.
1649
+ *
1650
+ * Design: [docs/design/463-cfn-macros.md](../../docs/design/463-cfn-macros.md).
1651
+ */
1652
+ /**
1653
+ * Returns true when the given template uses any CloudFormation
1654
+ * transform. Tolerates malformed inputs (null / non-object / missing
1655
+ * `Resources`) by returning `false` so the rest of the synthesis
1656
+ * pipeline surfaces the malformed-template error rather than the
1657
+ * detector silently throwing.
1658
+ *
1659
+ * Detection rule:
1660
+ * - `template.Transform` is set (string OR array form).
1661
+ * - OR a recursive walk over `Resources` / `Outputs` / `Mappings` /
1662
+ * `Conditions` / `Rules` finds any `{Fn::Transform: {...}}` key.
1663
+ *
1664
+ * The walk does NOT descend into `Metadata` blocks: CloudFormation
1665
+ * does not expand transforms inside metadata (the field is preserved
1666
+ * verbatim to AWS), so a `Fn::Transform` literally appearing under
1667
+ * `Metadata` is not a real macro reference and we must not surface it
1668
+ * as such.
1669
+ */
1670
+ function containsMacro(template) {
1671
+ if (!template || typeof template !== "object" || Array.isArray(template)) return false;
1672
+ const t = template;
1673
+ if (hasTopLevelTransform(t)) return true;
1674
+ for (const section of [
1675
+ "Resources",
1676
+ "Outputs",
1677
+ "Mappings",
1678
+ "Conditions",
1679
+ "Rules"
1680
+ ]) {
1681
+ const sub = t[section];
1682
+ if (sub && typeof sub === "object" && hasFnTransformDeep(sub)) return true;
1683
+ }
1684
+ return false;
1685
+ }
1686
+ /**
1687
+ * Returns every transform name declared in `Transform` plus the names
1688
+ * referenced via `Fn::Transform`, deduplicated and in encounter order.
1689
+ * Used for telemetry / UX (e.g. logging which transforms are about to
1690
+ * be expanded). Malformed entries (non-string Transform names, missing
1691
+ * `Name` field on Fn::Transform) are skipped silently — they would be
1692
+ * surfaced as a clear error by CloudFormation at expansion time.
1693
+ *
1694
+ * The walk follows the same rules as {@link containsMacro}: it descends
1695
+ * into `Resources` / `Outputs` / `Mappings` / `Conditions` / `Rules`
1696
+ * but NOT into `Metadata`.
1697
+ */
1698
+ function enumerateMacros(template) {
1699
+ if (!template || typeof template !== "object" || Array.isArray(template)) return [];
1700
+ const t = template;
1701
+ const seen = /* @__PURE__ */ new Set();
1702
+ const result = [];
1703
+ const top = t["Transform"];
1704
+ if (typeof top === "string") pushName(top, seen, result);
1705
+ else if (Array.isArray(top)) {
1706
+ for (const entry of top) if (typeof entry === "string") pushName(entry, seen, result);
1707
+ else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
1708
+ const name = entry["Name"];
1709
+ if (typeof name === "string") pushName(name, seen, result);
1710
+ }
1711
+ } else if (top && typeof top === "object" && !Array.isArray(top)) {
1712
+ const name = top["Name"];
1713
+ if (typeof name === "string") pushName(name, seen, result);
1714
+ }
1715
+ for (const section of [
1716
+ "Resources",
1717
+ "Outputs",
1718
+ "Mappings",
1719
+ "Conditions",
1720
+ "Rules"
1721
+ ]) {
1722
+ const sub = t[section];
1723
+ if (sub && typeof sub === "object") collectFnTransformNames(sub, seen, result);
1724
+ }
1725
+ return result;
1726
+ }
1727
+ function pushName(name, seen, out) {
1728
+ if (seen.has(name)) return;
1729
+ seen.add(name);
1730
+ out.push(name);
1731
+ }
1732
+ function hasTopLevelTransform(t) {
1733
+ const top = t["Transform"];
1734
+ if (top === void 0 || top === null) return false;
1735
+ if (Array.isArray(top) && top.length === 0) return false;
1736
+ return true;
1737
+ }
1738
+ /**
1739
+ * Recursively walk the given value and return true if any nested
1740
+ * object carries a `Fn::Transform` key (with a non-null value — a
1741
+ * literal `Fn::Transform: null` is not a real macro reference).
1742
+ *
1743
+ * Does NOT descend into `Metadata` keys at any depth (CFn does not
1744
+ * expand transforms inside metadata blocks, so a `Fn::Transform`
1745
+ * literally appearing there must not trigger expansion). The
1746
+ * `Metadata` exclusion mirrors the same rule applied at the
1747
+ * top-level section walk in {@link containsMacro}.
1748
+ */
1749
+ function hasFnTransformDeep(value) {
1750
+ if (!value || typeof value !== "object") return false;
1751
+ if (Array.isArray(value)) {
1752
+ for (const item of value) if (hasFnTransformDeep(item)) return true;
1753
+ return false;
1754
+ }
1755
+ const obj = value;
1756
+ if (obj["Fn::Transform"] !== void 0 && obj["Fn::Transform"] !== null) return true;
1757
+ for (const [key, sub] of Object.entries(obj)) {
1758
+ if (key === "Metadata") continue;
1759
+ if (hasFnTransformDeep(sub)) return true;
1760
+ }
1761
+ return false;
1762
+ }
1763
+ /**
1764
+ * Recursively walk and collect every `Fn::Transform.Name` value into
1765
+ * the provided dedup set / order-preserving array. Sibling to
1766
+ * {@link hasFnTransformDeep}; same `Metadata` exclusion.
1767
+ */
1768
+ function collectFnTransformNames(value, seen, out) {
1769
+ if (!value || typeof value !== "object") return;
1770
+ if (Array.isArray(value)) {
1771
+ for (const item of value) collectFnTransformNames(item, seen, out);
1772
+ return;
1773
+ }
1774
+ const obj = value;
1775
+ const fnT = obj["Fn::Transform"];
1776
+ if (fnT && typeof fnT === "object" && !Array.isArray(fnT)) {
1777
+ const name = fnT["Name"];
1778
+ if (typeof name === "string") pushName(name, seen, out);
1779
+ }
1780
+ for (const [key, sub] of Object.entries(obj)) {
1781
+ if (key === "Metadata") continue;
1782
+ collectFnTransformNames(sub, seen, out);
1783
+ }
1784
+ }
1785
+
1786
+ //#endregion
1787
+ //#region src/cli/upload-cfn-template.ts
1788
+ /**
1789
+ * CloudFormation `TemplateBody` hard limit (51,200 bytes). Templates larger
1790
+ * than this cannot be submitted inline and must be uploaded to S3 and
1791
+ * referenced via `TemplateURL` instead — see {@link uploadCfnTemplate}.
1792
+ */
1793
+ const CFN_TEMPLATE_BODY_LIMIT = 51200;
1794
+ /**
1795
+ * CloudFormation `TemplateURL` hard limit (1 MB / 1,048,576 bytes).
1796
+ * Templates larger than this are structurally unsubmittable through any
1797
+ * CloudFormation API — no S3 indirection helps. The caller surfaces a
1798
+ * pre-flight error pointing the user at template-splitting (nested stacks)
1799
+ * or shrinking inline asset payloads (`lambda.Code.fromAsset`).
1800
+ */
1801
+ const CFN_TEMPLATE_URL_LIMIT = 1048576;
1802
+ /**
1803
+ * Shared S3 key prefix for transient CFn templates uploaded by `cdkd import
1804
+ * --migrate-from-cloudformation` and `cdkd export`. Kept distinct from
1805
+ * cdkd's `cdkd/` state prefix so `state list` / `state info` never conflate
1806
+ * transient migration artifacts with persisted stack state. The prefix is
1807
+ * intentionally human-grep-able — leftovers (if cleanup fails) point
1808
+ * straight at the offending stack name.
1809
+ *
1810
+ * Re-used by both commands so operator-facing audit trails (CloudTrail
1811
+ * records of the migrate-tmp uploads) stay consistent across the two
1812
+ * flows.
1813
+ */
1814
+ const MIGRATE_TMP_PREFIX = "cdkd-migrate-tmp";
1815
+ /**
1816
+ * Upload a CFn template body to the cdkd state bucket and return both a
1817
+ * virtual-hosted-style HTTPS URL CloudFormation can fetch via
1818
+ * `TemplateURL` and a `cleanup` callback that deletes the object (and
1819
+ * destroys the S3 client).
1820
+ *
1821
+ * The state bucket's actual region is resolved via `GetBucketLocation`
1822
+ * (cached per-process) so the upload client and the URL match the
1823
+ * bucket's region — the calling CLI's profile region is irrelevant here.
1824
+ *
1825
+ * Cleanup is the caller's responsibility: invoke `cleanup` in a `finally`
1826
+ * around the CFn call. CloudFormation copies the template into its own
1827
+ * internal storage during the synchronous `CreateChangeSet` /
1828
+ * `UpdateStack` API call, so the S3 object is no longer needed after that
1829
+ * call returns (success or failure).
1830
+ *
1831
+ * Shared between `cdkd import --migrate-from-cloudformation` (via
1832
+ * `retire-cfn-stack.ts`) and `cdkd export` (via `commands/export.ts`) so
1833
+ * the upload + cleanup contract is single-sourced.
1834
+ */
1835
+ async function uploadCfnTemplate(args) {
1836
+ const { bucket, body, stackName, format, s3ClientOpts } = args;
1837
+ const region = await resolveBucketRegion(bucket, {
1838
+ ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
1839
+ ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
1840
+ });
1841
+ const s3 = new S3Client({
1842
+ region,
1843
+ ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
1844
+ ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
1845
+ });
1846
+ const ext = format === "yaml" ? "yaml" : "json";
1847
+ const contentType = format === "yaml" ? "application/x-yaml" : "application/json";
1848
+ const key = `${MIGRATE_TMP_PREFIX}/${stackName}/${Date.now()}.${ext}`;
1849
+ try {
1850
+ await s3.send(new PutObjectCommand({
1851
+ Bucket: bucket,
1852
+ Key: key,
1853
+ Body: body,
1854
+ ContentType: contentType
1855
+ }));
1856
+ } catch (err) {
1857
+ s3.destroy();
1858
+ throw err;
1859
+ }
1860
+ const url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
1861
+ const cleanup = async () => {
1862
+ try {
1863
+ await s3.send(new DeleteObjectCommand({
1864
+ Bucket: bucket,
1865
+ Key: key
1866
+ }));
1867
+ } finally {
1868
+ s3.destroy();
1869
+ }
1870
+ };
1871
+ return {
1872
+ url,
1873
+ cleanup
1874
+ };
1875
+ }
1876
+ /**
1877
+ * Threshold (in bytes) above which a single resource's serialized
1878
+ * `Properties` block is considered an "inline payload" worth surfacing as
1879
+ * a contributor to a template that exceeds the 1 MB CFn `TemplateURL`
1880
+ * ceiling. 4 KB matches the typical inline `Code.ZipFile` Lambda payload
1881
+ * that pushes a multi-resource CDK app over the wire-format limit.
1882
+ */
1883
+ const LARGE_INLINE_RESOURCE_THRESHOLD = 4096;
1884
+ /**
1885
+ * Walk a CFn template and surface every resource whose serialized
1886
+ * `Properties` block exceeds {@link LARGE_INLINE_RESOURCE_THRESHOLD}.
1887
+ * Used to build the actionable "offending resources" list in the
1888
+ * pre-flight error when a template exceeds the 1 MB `TemplateURL`
1889
+ * ceiling — typical culprits are inline `Code.ZipFile` Lambdas, inline
1890
+ * StepFunctions definitions, or large `AWS::CloudFormation::Stack`
1891
+ * bodies.
1892
+ *
1893
+ * Returns entries sorted by `approxBytes` descending so the user sees
1894
+ * the biggest contributor first. A non-CFn-template input (no
1895
+ * `Resources` object) returns an empty array.
1896
+ */
1897
+ function findLargeInlineResources(template, threshold = LARGE_INLINE_RESOURCE_THRESHOLD) {
1898
+ const result = [];
1899
+ const resources = template["Resources"];
1900
+ if (!resources || typeof resources !== "object" || Array.isArray(resources)) return result;
1901
+ for (const [logicalId, resource] of Object.entries(resources)) {
1902
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
1903
+ const r = resource;
1904
+ const resourceType = typeof r["Type"] === "string" ? r["Type"] : "<unknown>";
1905
+ const properties = r["Properties"];
1906
+ if (properties === void 0 || properties === null) continue;
1907
+ let approxBytes;
1908
+ try {
1909
+ approxBytes = JSON.stringify(properties).length;
1910
+ } catch {
1911
+ continue;
1912
+ }
1913
+ if (approxBytes >= threshold) result.push({
1914
+ logicalId,
1915
+ resourceType,
1916
+ approxBytes
1917
+ });
1918
+ }
1919
+ result.sort((a, b) => b.approxBytes - a.approxBytes);
1920
+ return result;
1921
+ }
1922
+
1923
+ //#endregion
1924
+ //#region src/synthesis/macro-expander.ts
1925
+ /** 600 seconds = 10 minutes. SDK waiter's `maxWaitTime` is in seconds. */
1926
+ const WAITER_MAX_WAIT_SECONDS = 600;
1927
+ const PARAMETER_PLACEHOLDER = "cdkd-macro-expand-placeholder";
1928
+ /**
1929
+ * Capabilities sent on every `CreateChangeSet` call:
1930
+ *
1931
+ * - `CAPABILITY_AUTO_EXPAND` is the load-bearing one — required for CFn
1932
+ * to actually run macro / `Transform` expansion (without it, CFn
1933
+ * rejects the changeset on any template that declares a Transform).
1934
+ * - `CAPABILITY_NAMED_IAM` / `CAPABILITY_IAM` are defense-in-depth: SAM
1935
+ * transforms (`AWS::Serverless-2016-10-31`) emit Lambda execution
1936
+ * roles, and user-authored macros may emit arbitrary IAM resources
1937
+ * too. Sending them unconditionally avoids a second round-trip when
1938
+ * the expanded template carries IAM resources cdkd's deploy pipeline
1939
+ * would otherwise complain about.
1940
+ */
1941
+ const CAPABILITIES = [
1942
+ "CAPABILITY_AUTO_EXPAND",
1943
+ "CAPABILITY_NAMED_IAM",
1944
+ "CAPABILITY_IAM"
1945
+ ];
1946
+ /**
1947
+ * Per-Type placeholder values used when a template Parameter has no
1948
+ * `Default`. CFn validates Parameter `Type` BEFORE the macro Lambda
1949
+ * runs, so a single bare-string placeholder rejects `CreateChangeSet`
1950
+ * on `Number` / `List<*>` / `AWS::EC2::*::Id` typed parameters
1951
+ * (`Parameter '<X>' must be a number`, etc.). The actual values do NOT
1952
+ * leak into the Processed-stage template (CFn preserves
1953
+ * `Ref: <param>` intact through expansion — see {@link
1954
+ * EMPIRICAL_FINDINGS_VERIFIED_2026_05_23} Q4), so any type-valid value
1955
+ * is sufficient. AWS-published Parameter Types list:
1956
+ * <https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html#parameters-section-structure-properties>
1957
+ */
1958
+ const PARAMETER_TYPE_PLACEHOLDERS = {
1959
+ String: PARAMETER_PLACEHOLDER,
1960
+ Number: "0",
1961
+ "List<Number>": "0",
1962
+ CommaDelimitedList: "",
1963
+ "List<String>": "",
1964
+ "AWS::EC2::AvailabilityZone::Name": "us-east-1a",
1965
+ "AWS::EC2::Image::Id": "ami-00000000",
1966
+ "AWS::EC2::Instance::Id": "i-00000000",
1967
+ "AWS::EC2::KeyPair::KeyName": "placeholder-key",
1968
+ "AWS::EC2::SecurityGroup::GroupName": "placeholder-sg",
1969
+ "AWS::EC2::SecurityGroup::Id": "sg-00000000",
1970
+ "AWS::EC2::Subnet::Id": "subnet-00000000",
1971
+ "AWS::EC2::Volume::Id": "vol-00000000",
1972
+ "AWS::EC2::VPC::Id": "vpc-00000000",
1973
+ "AWS::Route53::HostedZone::Id": "Z00000000000000000000",
1974
+ "AWS::SSM::Parameter::Name": "placeholder",
1975
+ "List<AWS::EC2::AvailabilityZone::Name>": "us-east-1a",
1976
+ "List<AWS::EC2::Image::Id>": "ami-00000000",
1977
+ "List<AWS::EC2::Instance::Id>": "i-00000000",
1978
+ "List<AWS::EC2::SecurityGroup::GroupName>": "placeholder-sg",
1979
+ "List<AWS::EC2::SecurityGroup::Id>": "sg-00000000",
1980
+ "List<AWS::EC2::Subnet::Id>": "subnet-00000000",
1981
+ "List<AWS::EC2::Volume::Id>": "vol-00000000",
1982
+ "List<AWS::EC2::VPC::Id>": "vpc-00000000",
1983
+ "List<AWS::Route53::HostedZone::Id>": "Z00000000000000000000"
1984
+ };
1985
+ /**
1986
+ * Expand CloudFormation macros / `Fn::Transform` blocks in a synth
1987
+ * template via a transient CloudFormation changeset round-trip.
1988
+ *
1989
+ * The flow (per design §3 Approach A):
1990
+ *
1991
+ * 1. Mint a unique transient stack name (`cdkd-macro-expand-<id>`).
1992
+ * 2. Build parameter values for every declared template Parameter
1993
+ * (template Default if present; synthetic placeholder otherwise).
1994
+ * CFn does NOT substitute these into the Processed-stage
1995
+ * template, but it requires them for the changeset to be valid.
1996
+ * 3. `CreateChangeSet --change-set-type CREATE` with
1997
+ * `Capabilities: ['CAPABILITY_AUTO_EXPAND','CAPABILITY_NAMED_IAM',
1998
+ * 'CAPABILITY_IAM']`. Use inline `TemplateBody` when <= 51,200
1999
+ * bytes; upload to the cdkd state bucket and pass `TemplateURL`
2000
+ * when between 51,200 bytes and 1 MB; refuse outright when > 1 MB
2001
+ * (CFn `TemplateURL` ceiling).
2002
+ * 4. Wait for `ChangeSetStatus: CREATE_COMPLETE`. On `FAILED`, surface
2003
+ * the `StatusReason` verbatim — typically the macro Lambda's error
2004
+ * message, or "no transforms found" for a template with an empty
2005
+ * `Transform` array.
2006
+ * 5. `GetTemplate --template-stage Processed` returns the
2007
+ * post-expansion template.
2008
+ * 6. Cleanup in `finally`: `DeleteChangeSet` + `DeleteStack`
2009
+ * (idempotent — both tolerate `*NotFound` errors). The transient
2010
+ * S3 upload (if any) is cleaned up too.
2011
+ * 7. Re-check `containsMacro(expanded)` and reject the multi-stage
2012
+ * case — cdkd v1 does not support templates whose expansion emits
2013
+ * ANOTHER macro reference. CFn handles single-step expansion
2014
+ * natively; second-round expansion is intentionally out of scope.
2015
+ *
2016
+ * Returns the expanded template as a parsed `CloudFormationTemplate`.
2017
+ * Throws {@link MacroExpansionError} on any failure mode. Cleanup
2018
+ * failures during the `finally` block log at WARN but do not mask the
2019
+ * outer success / error.
2020
+ */
2021
+ async function expandMacros(template, opts) {
2022
+ const logger = getLogger().child("MacroExpander");
2023
+ if (!containsMacro(template)) return template;
2024
+ const macros = enumerateMacros(template);
2025
+ logger.debug(`Macro expansion: detected transforms [${macros.join(", ")}], starting CFn round-trip...`);
2026
+ const transientStackName = `cdkd-macro-expand-${randomUUID().slice(0, 16)}`;
2027
+ const changeSetName = `${transientStackName}-changeset`;
2028
+ const region = opts.region;
2029
+ const stateBucket = opts.stateBucket;
2030
+ const waiterMaxWaitSeconds = opts.waiterMaxWaitSeconds ?? WAITER_MAX_WAIT_SECONDS;
2031
+ let cfn;
2032
+ let ownsClient = false;
2033
+ let s3Cleanup;
2034
+ try {
2035
+ const serialized = JSON.stringify(template);
2036
+ const parameters = buildParameterValues(template, logger);
2037
+ ownsClient = opts.cfnClient === void 0;
2038
+ cfn = opts.cfnClient ?? new CloudFormationClient({ region });
2039
+ let templateInput;
2040
+ if (serialized.length > 1048576) throw new MacroExpansionError(`Template is ${serialized.length} bytes, which exceeds CloudFormation's ${CFN_TEMPLATE_URL_LIMIT}-byte TemplateURL ceiling for macro expansion. Shrink inline payloads (move inline lambda.Code.ZipFile to lambda.Code.fromAsset, etc.) or split the stack before retrying.`);
2041
+ if (serialized.length <= 51200) templateInput = { TemplateBody: serialized };
2042
+ else {
2043
+ if (!stateBucket) throw new MacroExpansionError(`Template is ${serialized.length} bytes (over ${CFN_TEMPLATE_BODY_LIMIT} inline limit) — cdkd needs a state bucket to upload the transient template for CloudFormation's TemplateURL parameter. Pass --state-bucket <name> or ensure STS GetCallerIdentity can resolve a default bucket (cdkd-state-<accountId>).`);
2044
+ logger.debug(`Macro expansion: template is ${serialized.length} bytes (over ${CFN_TEMPLATE_BODY_LIMIT} inline limit) — uploading to state bucket '${stateBucket}' for TemplateURL.`);
2045
+ const uploaded = await uploadCfnTemplate({
2046
+ bucket: stateBucket,
2047
+ body: serialized,
2048
+ stackName: transientStackName,
2049
+ format: "json",
2050
+ ...opts.s3ClientOpts && { s3ClientOpts: opts.s3ClientOpts }
2051
+ });
2052
+ templateInput = { TemplateURL: uploaded.url };
2053
+ s3Cleanup = uploaded.cleanup;
2054
+ }
2055
+ try {
2056
+ await cfn.send(new CreateChangeSetCommand({
2057
+ StackName: transientStackName,
2058
+ ChangeSetName: changeSetName,
2059
+ ChangeSetType: "CREATE",
2060
+ ...templateInput,
2061
+ Capabilities: CAPABILITIES,
2062
+ ...parameters.length > 0 && { Parameters: parameters }
2063
+ }));
2064
+ } catch (err) {
2065
+ throw new MacroExpansionError(`CloudFormation rejected the macro-expansion changeset: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
2066
+ }
2067
+ let waiterFailed = false;
2068
+ let waiterError;
2069
+ try {
2070
+ await waitUntilChangeSetCreateComplete({
2071
+ client: cfn,
2072
+ maxWaitTime: waiterMaxWaitSeconds
2073
+ }, {
2074
+ StackName: transientStackName,
2075
+ ChangeSetName: changeSetName
2076
+ });
2077
+ } catch (waiterErr) {
2078
+ waiterFailed = true;
2079
+ waiterError = waiterErr;
2080
+ }
2081
+ if (waiterFailed) {
2082
+ const desc = await cfn.send(new DescribeChangeSetCommand({
2083
+ StackName: transientStackName,
2084
+ ChangeSetName: changeSetName
2085
+ })).catch(() => void 0);
2086
+ const reason = desc?.StatusReason ?? "unknown (DescribeChangeSet failed)";
2087
+ throw new MacroExpansionError(`CloudFormation macro expansion failed (status=${desc?.Status ?? "UNKNOWN"}): ${reason}`, waiterError instanceof Error ? waiterError : void 0);
2088
+ }
2089
+ const tpl = await cfn.send(new GetTemplateCommand({
2090
+ StackName: transientStackName,
2091
+ ChangeSetName: changeSetName,
2092
+ TemplateStage: "Processed"
2093
+ }));
2094
+ if (tpl.TemplateBody === void 0 || tpl.TemplateBody === null) throw new MacroExpansionError(`CloudFormation returned no Processed-stage template body for the macro-expansion changeset. This typically indicates a CFn-side regression — re-run, and if the failure persists open an issue with the transforms involved: [${macros.join(", ")}].`);
2095
+ const expanded = parseTemplateBody(tpl.TemplateBody);
2096
+ if (containsMacro(expanded)) throw new MacroExpansionError(`Macro expansion produced a template that still contains macros [${enumerateMacros(expanded).join(", ")}]. Multi-stage macros (a macro whose expansion emits another macro reference) are intentionally out of scope in cdkd v1 — see https://github.com/go-to-k/cdkd/issues/463. If you need this pattern, manually pre-expand the template and deploy the result.`);
2097
+ logger.debug(`Macro expansion: success — ${Object.keys(expanded.Resources ?? {}).length} resources after expansion.`);
2098
+ return expanded;
2099
+ } finally {
2100
+ if (cfn !== void 0) try {
2101
+ await cfn.send(new DeleteStackCommand({ StackName: transientStackName }));
2102
+ } catch (cleanupErr) {
2103
+ logger.warn(`Failed to delete transient macro-expand stack '${transientStackName}': ${formatErr(cleanupErr)}. Clean up manually via 'aws cloudformation delete-stack --stack-name ${transientStackName}'.`);
2104
+ }
2105
+ if (s3Cleanup) try {
2106
+ await s3Cleanup();
2107
+ } catch (cleanupErr) {
2108
+ logger.warn(`Failed to delete transient macro-expand template upload from state bucket '${stateBucket}' (key prefix 'cdkd-migrate-tmp/${transientStackName}/'): ${formatErr(cleanupErr)}. Sweep manually via 'aws s3 rm s3://${stateBucket}/cdkd-migrate-tmp/${transientStackName}/ --recursive'.`);
2109
+ }
2110
+ if (ownsClient && cfn !== void 0) cfn.destroy();
2111
+ }
2112
+ }
2113
+ /**
2114
+ * Build the `Parameters` list passed to `CreateChangeSet`. CFn requires
2115
+ * a value for every declared parameter; we fall back to a Type-aware
2116
+ * synthetic placeholder when the template has no Default. Per the
2117
+ * empirical verification above, these values do NOT leak into the
2118
+ * Processed template (CFn keeps `Ref: <param>` intact for cdkd's own
2119
+ * resolver to substitute later with the real values).
2120
+ */
2121
+ function buildParameterValues(template, logger) {
2122
+ if (!template || typeof template !== "object" || Array.isArray(template)) return [];
2123
+ const params = template["Parameters"];
2124
+ if (!params || typeof params !== "object" || Array.isArray(params)) return [];
2125
+ const out = [];
2126
+ for (const [key, def] of Object.entries(params)) {
2127
+ if (!def || typeof def !== "object" || Array.isArray(def)) continue;
2128
+ const defObj = def;
2129
+ const defDefault = defObj["Default"];
2130
+ const defType = typeof defObj["Type"] === "string" ? defObj["Type"] : void 0;
2131
+ out.push({
2132
+ ParameterKey: key,
2133
+ ParameterValue: stringifyParamDefault(defDefault, defType, key, logger)
2134
+ });
2135
+ }
2136
+ return out;
2137
+ }
2138
+ /**
2139
+ * Coerce a CFn Parameter `Default` (or build a synthetic placeholder
2140
+ * when the Default is absent) into the string `CreateChangeSet`
2141
+ * requires. Strings / numbers / booleans pass through verbatim;
2142
+ * object-shaped Defaults (rare — array-typed Defaults that haven't
2143
+ * been comma-joined) are JSON-stringified. When no Default is present,
2144
+ * routes through {@link PARAMETER_TYPE_PLACEHOLDERS} so a `Number` /
2145
+ * `List<Number>` / `AWS::EC2::*::Id` Parameter sees a value CFn's
2146
+ * pre-macro Type validator accepts. The actual value does NOT leak
2147
+ * into the Processed-stage template (CFn preserves `Ref: <param>`
2148
+ * intact through expansion — see empirical findings).
2149
+ */
2150
+ function stringifyParamDefault(value, type, paramKey, logger) {
2151
+ if (value !== void 0 && value !== null) {
2152
+ if (typeof value === "string") return value;
2153
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
2154
+ try {
2155
+ return JSON.stringify(value);
2156
+ } catch {}
2157
+ }
2158
+ if (type !== void 0) {
2159
+ const known = PARAMETER_TYPE_PLACEHOLDERS[type];
2160
+ if (known !== void 0) return known;
2161
+ if (type.startsWith("AWS::SSM::Parameter::Value<")) {
2162
+ const inner = type.slice(27, -1);
2163
+ if (inner.startsWith("List<") || inner === "CommaDelimitedList") return "placeholder,placeholder";
2164
+ return "placeholder";
2165
+ }
2166
+ logger.warn(`Parameter '${paramKey}' has unrecognized CFn Type '${type}'; using a generic string placeholder for the transient macro-expansion changeset. If CFn rejects the changeset with a type error, file an issue with the offending Type.`);
2167
+ return PARAMETER_PLACEHOLDER;
2168
+ }
2169
+ return PARAMETER_PLACEHOLDER;
2170
+ }
2171
+ /**
2172
+ * Parse the `TemplateBody` field returned by `GetTemplate`. The SDK
2173
+ * types it as `string | undefined`, but empirical observation shows
2174
+ * the wire shape may be either a string (for YAML or some pre-parsed
2175
+ * cases) or a parsed object (for JSON templates against
2176
+ * `--template-stage Processed`). Handle both.
2177
+ */
2178
+ function parseTemplateBody(body) {
2179
+ let parsed;
2180
+ if (typeof body === "string") try {
2181
+ parsed = JSON.parse(body);
2182
+ } catch (err) {
2183
+ throw new MacroExpansionError(`CloudFormation returned a non-JSON Processed-stage template body. cdkd's macro-expansion path only supports JSON-shaped synth templates (CDK apps emit JSON by default). Cause: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
2184
+ }
2185
+ else if (body && typeof body === "object" && !Array.isArray(body)) parsed = body;
2186
+ else throw new MacroExpansionError(`CloudFormation returned an unexpected TemplateBody shape (${typeof body}). Expected a JSON string or parsed object.`);
2187
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new MacroExpansionError(`CloudFormation returned a malformed Processed-stage template body — expected a JSON object at the top level, got ${Array.isArray(parsed) ? "array" : typeof parsed}.`);
2188
+ const resources = parsed["Resources"];
2189
+ if (resources !== void 0 && (typeof resources !== "object" || resources === null || Array.isArray(resources))) throw new MacroExpansionError(`CloudFormation returned a malformed Processed-stage template body — 'Resources' must be an object map, got ${resources === null ? "null" : Array.isArray(resources) ? "array" : typeof resources}.`);
2190
+ return parsed;
2191
+ }
2192
+ function formatErr(err) {
2193
+ return err instanceof Error ? err.message : String(err);
2194
+ }
2195
+
1596
2196
  //#endregion
1597
2197
  //#region src/cli/config-loader.ts
1598
2198
  /**
@@ -1903,6 +2503,19 @@ var Synthesizer = class {
1903
2503
  this.logger.debug(`Using pre-synthesized cloud assembly at ${appPath}`);
1904
2504
  const manifest = this.assemblyReader.readManifest(appPath);
1905
2505
  const stacks = this.assemblyReader.getAllStacks(appPath, manifest);
2506
+ const presynthRegion = options.region || process.env["AWS_REGION"] || process.env["AWS_DEFAULT_REGION"];
2507
+ let presynthAccountId;
2508
+ try {
2509
+ const stsClient = new STSClient({ ...presynthRegion && { region: presynthRegion } });
2510
+ presynthAccountId = (await stsClient.send(new GetCallerIdentityCommand({}))).Account;
2511
+ stsClient.destroy();
2512
+ } catch {
2513
+ this.logger.debug("Could not resolve AWS account ID via STS (pre-synth branch)");
2514
+ }
2515
+ await this.expandMacrosForStacks(stacks, options, {
2516
+ region: presynthRegion,
2517
+ ...presynthAccountId && { accountId: presynthAccountId }
2518
+ });
1906
2519
  this.logger.debug(`Loaded ${stacks.length} stack(s) from pre-synthesized assembly`);
1907
2520
  return {
1908
2521
  manifest,
@@ -1955,6 +2568,10 @@ var Synthesizer = class {
1955
2568
  const manifest = this.assemblyReader.readManifest(outputDir);
1956
2569
  if (!manifest.missing || manifest.missing.length === 0) {
1957
2570
  const stacks = this.assemblyReader.getAllStacks(outputDir, manifest);
2571
+ await this.expandMacrosForStacks(stacks, options, {
2572
+ region,
2573
+ ...accountId && { accountId }
2574
+ });
1958
2575
  this.logger.debug(`Synthesis complete: ${stacks.length} stack(s)`);
1959
2576
  return {
1960
2577
  manifest,
@@ -1978,6 +2595,49 @@ var Synthesizer = class {
1978
2595
  async listStacks(options) {
1979
2596
  return (await this.synthesize(options)).stacks.map((s) => s.stackName);
1980
2597
  }
2598
+ /**
2599
+ * Per-stack macro-expansion pass (Issue #463). Mutates each stack's
2600
+ * `template` in place when {@link containsMacro} flags it. Runs
2601
+ * AFTER the context-provider loop has settled and BEFORE the
2602
+ * analyzer / provisioner pipeline consumes the templates, so every
2603
+ * downstream stage sees the post-expansion shape.
2604
+ *
2605
+ * Skipped silently when no stack carries a macro — pure no-op cost.
2606
+ * When a macro IS detected and the caller did NOT thread a
2607
+ * region into the synthesizer, falls back to resolving region from
2608
+ * the synthesized stack's environment (set by `cdk.Stack.region`).
2609
+ * Throws `SynthesisError` when no region can be resolved (the
2610
+ * upstream caller treats it as a synth failure) and propagates
2611
+ * `MacroExpansionError` (from {@link expandMacros}) on any CFn-side
2612
+ * failure during the round-trip.
2613
+ */
2614
+ async expandMacrosForStacks(stacks, options, resolved) {
2615
+ const stacksWithMacros = stacks.filter((s) => containsMacro(s.template));
2616
+ if (stacksWithMacros.length === 0) return;
2617
+ const region = resolved?.region || options.region || process.env["AWS_REGION"] || process.env["AWS_DEFAULT_REGION"] || stacksWithMacros[0]?.region;
2618
+ if (!region) throw new SynthesisError(`Stack(s) [${stacksWithMacros.map((s) => s.stackName).join(", ")}] use CloudFormation macros (Transform / Fn::Transform) but cdkd could not resolve an AWS region for the expansion round-trip. Set AWS_REGION, pass --region <r>, or set env: { region: '<r>' } in your CDK Stack constructor.`);
2619
+ let stateBucket;
2620
+ if (options.stateBucket) stateBucket = options.stateBucket;
2621
+ else if (resolved?.accountId) stateBucket = `cdkd-state-${resolved.accountId}`;
2622
+ else {
2623
+ const oversize = stacksWithMacros.find((s) => JSON.stringify(s.template).length > 51200);
2624
+ if (oversize) throw new SynthesisError(`Stack '${oversize.stackName}' uses CloudFormation macros AND its serialized template exceeds the 51,200-byte inline TemplateBody limit, so cdkd must upload the template to S3 for the transient expansion changeset. cdkd could not resolve a state bucket: STS GetCallerIdentity failed AND --state-bucket was not provided. Pass --state-bucket <name> (cdkd uses the same bucket as cdkd deploy state storage; typically 'cdkd-state-<accountId>').`);
2625
+ stateBucket = void 0;
2626
+ }
2627
+ for (const stack of stacksWithMacros) {
2628
+ const macros = enumerateMacros(stack.template);
2629
+ this.logger.info(`[macros] Expanding CloudFormation macros for stack '${stack.stackName}' via CFn round-trip (transforms: ${macros.join(", ")}; may take 30-60s)...`);
2630
+ const before = Date.now();
2631
+ const expanded = await expandMacros(stack.template, {
2632
+ region,
2633
+ ...stateBucket !== void 0 && { stateBucket },
2634
+ ...options.macroExpandS3ClientOpts && { s3ClientOpts: options.macroExpandS3ClientOpts }
2635
+ });
2636
+ stack.template = expanded;
2637
+ const elapsedSec = Math.round((Date.now() - before) / 1e3);
2638
+ this.logger.info(`[macros] ... done in ${elapsedSec}s (${Object.keys(expanded.Resources ?? {}).length} resources after expansion).`);
2639
+ }
2640
+ }
1981
2641
  };
1982
2642
  /**
1983
2643
  * Check if two sets contain the same elements
@@ -9238,5 +9898,5 @@ var DeployEngine = class {
9238
9898
  };
9239
9899
 
9240
9900
  //#endregion
9241
- export { LockError as $, AssetPublisher as A, resolveStateBucketWithDefault as B, applyRoleArnIfSet as C, LockManager as D, TemplateParser as E, getDefaultStateBucketName as F, resolveBucketRegion as G, warnDeprecatedNoPrefixCliFlag as H, getLegacyStateBucketName as I, ConfigError as J, AssetError as K, resolveApp as L, WorkGraph as M, buildDockerImage as N, S3StateBackend as O, Synthesizer as P, LocalStartServiceError as Q, resolveCaptureObservedState as R, IntrinsicFunctionResolver as S, DagBuilder as T, AssemblyReader as U, resolveStateBucketWithDefaultAndSource as V, clearBucketRegionCache as W, LocalInvokeBuildError as X, DependencyError as Y, LocalMigrateError as Z, normalizeAwsTagsToCfn as _, withRetry as a, RouteDiscoveryError as at, CloudControlProvider as b, cyan as c, StateError as ct, red as d, isCdkdError as dt, MissingCdkCliError as et, yellow as f, normalizeAwsError as ft, matchesCdkPath as g, CDK_PATH_TAG as h, withResourceDeadline as i, ResourceUpdateNotSupportedError as it, stringifyValue as j, shouldRetainResource as k, gray as l, SynthesisError as lt, collectInlinePolicyNamesManagedBySiblings as m, DEFAULT_RESOURCE_WARN_AFTER_MS as n, ProvisioningError as nt, IMPLICIT_DELETE_DEPENDENCIES as o, StackHasActiveImportsError as ot, IAMRoleProvider as p, withErrorHandling as pt, CdkdError as q, DeployEngine as r, ResourceTimeoutError as rt, bold as s, StackTerminationProtectionError as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, PartialFailureError as tt, green as u, formatError as ut, resolveExplicitPhysicalId as v, DiffCalculator as w, assertRegionMatch as x, ProviderRegistry as y, resolveSkipPrefix as z };
9242
- //# sourceMappingURL=deploy-engine-CX1x5ug1.js.map
9901
+ export { ConfigError as $, AssetPublisher as A, resolveStateBucketWithDefault as B, applyRoleArnIfSet as C, LockManager as D, TemplateParser as E, getDefaultStateBucketName as F, MIGRATE_TMP_PREFIX as G, warnDeprecatedNoPrefixCliFlag as H, getLegacyStateBucketName as I, AssemblyReader as J, findLargeInlineResources as K, resolveApp as L, WorkGraph as M, buildDockerImage as N, S3StateBackend as O, Synthesizer as P, CdkdError as Q, resolveCaptureObservedState as R, IntrinsicFunctionResolver as S, DagBuilder as T, CFN_TEMPLATE_BODY_LIMIT as U, resolveStateBucketWithDefaultAndSource as V, CFN_TEMPLATE_URL_LIMIT as W, resolveBucketRegion as X, clearBucketRegionCache as Y, AssetError as Z, normalizeAwsTagsToCfn as _, normalizeAwsError as _t, withRetry as a, MissingCdkCliError as at, CloudControlProvider as b, cyan as c, ResourceTimeoutError as ct, red as d, StackHasActiveImportsError as dt, DependencyError as et, yellow as f, StackTerminationProtectionError as ft, matchesCdkPath as g, isCdkdError as gt, CDK_PATH_TAG as h, formatError as ht, withResourceDeadline as i, LockError as it, stringifyValue as j, shouldRetainResource as k, gray as l, ResourceUpdateNotSupportedError as lt, collectInlinePolicyNamesManagedBySiblings as m, SynthesisError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, LocalMigrateError as nt, IMPLICIT_DELETE_DEPENDENCIES as o, PartialFailureError as ot, IAMRoleProvider as p, StateError as pt, uploadCfnTemplate as q, DeployEngine as r, LocalStartServiceError as rt, bold as s, ProvisioningError as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, LocalInvokeBuildError as tt, green as u, RouteDiscoveryError as ut, resolveExplicitPhysicalId as v, withErrorHandling as vt, DiffCalculator as w, assertRegionMatch as x, ProviderRegistry as y, resolveSkipPrefix as z };
9902
+ //# sourceMappingURL=deploy-engine-DZYchTh6.js.map