@go-to-k/cdkd 0.125.0 → 0.127.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/cli.js CHANGED
@@ -65,6 +65,7 @@ import { promisify } from "node:util";
65
65
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
66
66
  import { readFile } from "fs/promises";
67
67
  import { createServer as createServer$1 } from "node:http";
68
+ import { createServer as createServer$2 } from "node:https";
68
69
  import * as chokidar from "chokidar";
69
70
 
70
71
  //#region src/cli/options.ts
@@ -34748,6 +34749,143 @@ function createStateCommand() {
34748
34749
  return cmd;
34749
34750
  }
34750
34751
 
34752
+ //#endregion
34753
+ //#region src/cli/upload-cfn-template.ts
34754
+ /**
34755
+ * CloudFormation `TemplateBody` hard limit (51,200 bytes). Templates larger
34756
+ * than this cannot be submitted inline and must be uploaded to S3 and
34757
+ * referenced via `TemplateURL` instead — see {@link uploadCfnTemplate}.
34758
+ */
34759
+ const CFN_TEMPLATE_BODY_LIMIT = 51200;
34760
+ /**
34761
+ * CloudFormation `TemplateURL` hard limit (1 MB / 1,048,576 bytes).
34762
+ * Templates larger than this are structurally unsubmittable through any
34763
+ * CloudFormation API — no S3 indirection helps. The caller surfaces a
34764
+ * pre-flight error pointing the user at template-splitting (nested stacks)
34765
+ * or shrinking inline asset payloads (`lambda.Code.fromAsset`).
34766
+ */
34767
+ const CFN_TEMPLATE_URL_LIMIT = 1048576;
34768
+ /**
34769
+ * Shared S3 key prefix for transient CFn templates uploaded by `cdkd import
34770
+ * --migrate-from-cloudformation` and `cdkd export`. Kept distinct from
34771
+ * cdkd's `cdkd/` state prefix so `state list` / `state info` never conflate
34772
+ * transient migration artifacts with persisted stack state. The prefix is
34773
+ * intentionally human-grep-able — leftovers (if cleanup fails) point
34774
+ * straight at the offending stack name.
34775
+ *
34776
+ * Re-used by both commands so operator-facing audit trails (CloudTrail
34777
+ * records of the migrate-tmp uploads) stay consistent across the two
34778
+ * flows.
34779
+ */
34780
+ const MIGRATE_TMP_PREFIX = "cdkd-migrate-tmp";
34781
+ /**
34782
+ * Upload a CFn template body to the cdkd state bucket and return both a
34783
+ * virtual-hosted-style HTTPS URL CloudFormation can fetch via
34784
+ * `TemplateURL` and a `cleanup` callback that deletes the object (and
34785
+ * destroys the S3 client).
34786
+ *
34787
+ * The state bucket's actual region is resolved via `GetBucketLocation`
34788
+ * (cached per-process) so the upload client and the URL match the
34789
+ * bucket's region — the calling CLI's profile region is irrelevant here.
34790
+ *
34791
+ * Cleanup is the caller's responsibility: invoke `cleanup` in a `finally`
34792
+ * around the CFn call. CloudFormation copies the template into its own
34793
+ * internal storage during the synchronous `CreateChangeSet` /
34794
+ * `UpdateStack` API call, so the S3 object is no longer needed after that
34795
+ * call returns (success or failure).
34796
+ *
34797
+ * Shared between `cdkd import --migrate-from-cloudformation` (via
34798
+ * `retire-cfn-stack.ts`) and `cdkd export` (via `commands/export.ts`) so
34799
+ * the upload + cleanup contract is single-sourced.
34800
+ */
34801
+ async function uploadCfnTemplate(args) {
34802
+ const { bucket, body, stackName, format, s3ClientOpts } = args;
34803
+ const region = await resolveBucketRegion(bucket, {
34804
+ ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
34805
+ ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
34806
+ });
34807
+ const s3 = new S3Client({
34808
+ region,
34809
+ ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
34810
+ ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
34811
+ });
34812
+ const ext = format === "yaml" ? "yaml" : "json";
34813
+ const contentType = format === "yaml" ? "application/x-yaml" : "application/json";
34814
+ const key = `${MIGRATE_TMP_PREFIX}/${stackName}/${Date.now()}.${ext}`;
34815
+ try {
34816
+ await s3.send(new PutObjectCommand({
34817
+ Bucket: bucket,
34818
+ Key: key,
34819
+ Body: body,
34820
+ ContentType: contentType
34821
+ }));
34822
+ } catch (err) {
34823
+ s3.destroy();
34824
+ throw err;
34825
+ }
34826
+ const url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
34827
+ const cleanup = async () => {
34828
+ try {
34829
+ await s3.send(new DeleteObjectCommand({
34830
+ Bucket: bucket,
34831
+ Key: key
34832
+ }));
34833
+ } finally {
34834
+ s3.destroy();
34835
+ }
34836
+ };
34837
+ return {
34838
+ url,
34839
+ cleanup
34840
+ };
34841
+ }
34842
+ /**
34843
+ * Threshold (in bytes) above which a single resource's serialized
34844
+ * `Properties` block is considered an "inline payload" worth surfacing as
34845
+ * a contributor to a template that exceeds the 1 MB CFn `TemplateURL`
34846
+ * ceiling. 4 KB matches the typical inline `Code.ZipFile` Lambda payload
34847
+ * that pushes a multi-resource CDK app over the wire-format limit.
34848
+ */
34849
+ const LARGE_INLINE_RESOURCE_THRESHOLD = 4096;
34850
+ /**
34851
+ * Walk a CFn template and surface every resource whose serialized
34852
+ * `Properties` block exceeds {@link LARGE_INLINE_RESOURCE_THRESHOLD}.
34853
+ * Used to build the actionable "offending resources" list in the
34854
+ * pre-flight error when a template exceeds the 1 MB `TemplateURL`
34855
+ * ceiling — typical culprits are inline `Code.ZipFile` Lambdas, inline
34856
+ * StepFunctions definitions, or large `AWS::CloudFormation::Stack`
34857
+ * bodies.
34858
+ *
34859
+ * Returns entries sorted by `approxBytes` descending so the user sees
34860
+ * the biggest contributor first. A non-CFn-template input (no
34861
+ * `Resources` object) returns an empty array.
34862
+ */
34863
+ function findLargeInlineResources(template, threshold = LARGE_INLINE_RESOURCE_THRESHOLD) {
34864
+ const result = [];
34865
+ const resources = template["Resources"];
34866
+ if (!resources || typeof resources !== "object" || Array.isArray(resources)) return result;
34867
+ for (const [logicalId, resource] of Object.entries(resources)) {
34868
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
34869
+ const r = resource;
34870
+ const resourceType = typeof r["Type"] === "string" ? r["Type"] : "<unknown>";
34871
+ const properties = r["Properties"];
34872
+ if (properties === void 0 || properties === null) continue;
34873
+ let approxBytes;
34874
+ try {
34875
+ approxBytes = JSON.stringify(properties).length;
34876
+ } catch {
34877
+ continue;
34878
+ }
34879
+ if (approxBytes >= threshold) result.push({
34880
+ logicalId,
34881
+ resourceType,
34882
+ approxBytes
34883
+ });
34884
+ }
34885
+ result.sort((a, b) => b.approxBytes - a.approxBytes);
34886
+ return result;
34887
+ }
34888
+
34751
34889
  //#endregion
34752
34890
  //#region src/cli/yaml-cfn.ts
34753
34891
  /**
@@ -35067,22 +35205,16 @@ const STABLE_TERMINAL_STATUSES = new Set([
35067
35205
  * UpdateStack TemplateBody hard limit (51,200 bytes). Templates larger than
35068
35206
  * this are uploaded to cdkd's state S3 bucket and submitted via `TemplateURL`
35069
35207
  * instead — see {@link uploadTemplateForUpdateStack}.
35208
+ *
35209
+ * Re-exported from `upload-cfn-template.ts` as the shared source of truth.
35070
35210
  */
35071
- const TEMPLATE_BODY_LIMIT = 51200;
35211
+ const TEMPLATE_BODY_LIMIT = CFN_TEMPLATE_BODY_LIMIT;
35072
35212
  /**
35073
35213
  * UpdateStack TemplateURL hard limit (1 MB / 1,048,576 bytes). Templates
35074
35214
  * larger than this cannot be submitted at all and require manual
35075
35215
  * intervention.
35076
35216
  */
35077
- const TEMPLATE_URL_LIMIT = 1048576;
35078
- /**
35079
- * S3 key prefix for the transient Retain-injected template uploaded by the
35080
- * `--migrate-from-cloudformation` flow when the template exceeds the inline
35081
- * 51,200-byte limit. Kept distinct from cdkd's `cdkd/` state prefix so
35082
- * `state list` / `state info` never conflate transient migration artifacts
35083
- * with persisted state.
35084
- */
35085
- const MIGRATE_TMP_PREFIX = "cdkd-migrate-tmp";
35217
+ const TEMPLATE_URL_LIMIT = CFN_TEMPLATE_URL_LIMIT;
35086
35218
  /**
35087
35219
  * Retire a CloudFormation stack whose resources have just been adopted into
35088
35220
  * cdkd state. The 4-step procedure is the one AWS recommends for handing
@@ -35210,45 +35342,13 @@ async function retireCloudFormationStack(options) {
35210
35342
  * Exported for unit testing.
35211
35343
  */
35212
35344
  async function uploadTemplateForUpdateStack(args) {
35213
- const { bucket, body, cfnStackName, format, s3ClientOpts } = args;
35214
- const region = await resolveBucketRegion(bucket, {
35215
- ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
35216
- ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
35345
+ return uploadCfnTemplate({
35346
+ bucket: args.bucket,
35347
+ body: args.body,
35348
+ stackName: args.cfnStackName,
35349
+ ...args.format && { format: args.format },
35350
+ ...args.s3ClientOpts && { s3ClientOpts: args.s3ClientOpts }
35217
35351
  });
35218
- const s3 = new S3Client({
35219
- region,
35220
- ...s3ClientOpts?.profile && { profile: s3ClientOpts.profile },
35221
- ...s3ClientOpts?.credentials && { credentials: s3ClientOpts.credentials }
35222
- });
35223
- const ext = format === "yaml" ? "yaml" : "json";
35224
- const contentType = format === "yaml" ? "application/x-yaml" : "application/json";
35225
- const key = `${MIGRATE_TMP_PREFIX}/${cfnStackName}/${Date.now()}.${ext}`;
35226
- try {
35227
- await s3.send(new PutObjectCommand({
35228
- Bucket: bucket,
35229
- Key: key,
35230
- Body: body,
35231
- ContentType: contentType
35232
- }));
35233
- } catch (err) {
35234
- s3.destroy();
35235
- throw err;
35236
- }
35237
- const url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
35238
- const cleanup = async () => {
35239
- try {
35240
- await s3.send(new DeleteObjectCommand({
35241
- Bucket: bucket,
35242
- Key: key
35243
- }));
35244
- } finally {
35245
- s3.destroy();
35246
- }
35247
- };
35248
- return {
35249
- url,
35250
- cleanup
35251
- };
35252
35352
  }
35253
35353
  /**
35254
35354
  * Parse a CloudFormation template body (JSON or YAML), set
@@ -40562,7 +40662,7 @@ function buildHttpApiV2Event(req, ctx, opts = {}) {
40562
40662
  stage: ctx.route.stage,
40563
40663
  time: formatRequestTime(now),
40564
40664
  timeEpoch: now.getTime(),
40565
- authentication: null,
40665
+ authentication: req.clientCert ? { clientCert: req.clientCert } : null,
40566
40666
  authorizer: null
40567
40667
  },
40568
40668
  body,
@@ -40612,7 +40712,8 @@ function buildRestV1Event(req, ctx, opts = {}) {
40612
40712
  httpMethod: req.method.toUpperCase(),
40613
40713
  identity: {
40614
40714
  sourceIp: req.sourceIp ?? "127.0.0.1",
40615
- userAgent: headers["user-agent"] ?? ""
40715
+ userAgent: headers["user-agent"] ?? "",
40716
+ ...req.clientCert && { clientCert: req.clientCert }
40616
40717
  },
40617
40718
  path: `/${ctx.route.stage}${ctx.matchedPath}`,
40618
40719
  protocol: "HTTP/1.1",
@@ -42804,12 +42905,20 @@ function amzDateOutsideSkew(amzDate, now) {
42804
42905
  async function startApiServer(opts) {
42805
42906
  const logger = getLogger().child("start-api");
42806
42907
  let currentState = opts.state;
42807
- const server = createServer$1((req, res) => {
42908
+ const requestHandler = (req, res) => {
42808
42909
  handleRequest(req, res, currentState, opts).catch((err) => {
42809
42910
  logger.error(`Unhandled request error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
42810
42911
  if (!res.headersSent) writeError(res, 502);
42811
42912
  });
42812
- });
42913
+ };
42914
+ const server = opts.mtls ? createServer$2({
42915
+ requestCert: true,
42916
+ rejectUnauthorized: true,
42917
+ ca: opts.mtls.caPem,
42918
+ cert: opts.mtls.certPem,
42919
+ key: opts.mtls.keyPem
42920
+ }, requestHandler) : createServer$1(requestHandler);
42921
+ const scheme = opts.mtls ? "https" : "http";
42813
42922
  server.on("connection", (socket) => {
42814
42923
  socket.setNoDelay(true);
42815
42924
  });
@@ -42831,6 +42940,7 @@ async function startApiServer(opts) {
42831
42940
  return {
42832
42941
  port: actualPort,
42833
42942
  host: actualHost,
42943
+ scheme,
42834
42944
  server,
42835
42945
  close: async () => {
42836
42946
  if (closed) return;
@@ -42886,12 +42996,14 @@ async function handleRequest(req, res, state, opts) {
42886
42996
  return;
42887
42997
  }
42888
42998
  const authorizer = state.routes.find((r) => r.route.declaredAt === match.route.declaredAt && r.route.method === match.route.method)?.authorizer;
42999
+ const clientCert = opts.mtls ? extractClientCert(req) : void 0;
42889
43000
  const snapshot = {
42890
43001
  method,
42891
43002
  rawUrl,
42892
43003
  headers: collectHeaders(req),
42893
43004
  body: bodyBuf,
42894
- ...req.socket.remoteAddress !== void 0 && { sourceIp: req.socket.remoteAddress }
43005
+ ...req.socket.remoteAddress !== void 0 && { sourceIp: req.socket.remoteAddress },
43006
+ ...clientCert && { clientCert }
42895
43007
  };
42896
43008
  const matchCtx = {
42897
43009
  route: match.route,
@@ -43357,6 +43469,135 @@ function writeMockCorsPreflight(res, preflight) {
43357
43469
  for (const [name, value] of Object.entries(preflight.headers)) res.setHeader(name, value);
43358
43470
  res.end();
43359
43471
  }
43472
+ /**
43473
+ * Extract the verified client certificate from a request's TLS socket.
43474
+ *
43475
+ * Pre-conditions (load-bearing — caller MUST gate on `opts.mtls`):
43476
+ * - The server was started with `https.createServer({requestCert: true,
43477
+ * rejectUnauthorized: true, ...})`, so the TLS handshake has
43478
+ * already rejected unknown-CA / self-signed / missing-cert clients
43479
+ * by the time `handleRequest` runs. Any peer cert we see here is
43480
+ * structurally valid against the supplied CA bundle — we do NOT
43481
+ * re-verify in code.
43482
+ *
43483
+ * Returns `undefined` when the request was not over a TLS socket (the
43484
+ * caller should NOT call this on plain-HTTP requests; the gate is the
43485
+ * `opts.mtls` check in `handleRequest`).
43486
+ *
43487
+ * The returned shape is the AWS-canonical
43488
+ * `requestContext.identity.clientCert` per
43489
+ * https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mutual-tls.html#api-gateway-mutual-tls-event-shape:
43490
+ *
43491
+ * {
43492
+ * clientCertPem: "-----BEGIN CERTIFICATE-----\n...",
43493
+ * subjectDN: "CN=client,O=example,C=US",
43494
+ * issuerDN: "CN=My CA,O=example,C=US",
43495
+ * serialNumber: "01:23:45:67:...",
43496
+ * validity: { notBefore: "May 22 03:30:00 2026 GMT",
43497
+ * notAfter: "May 22 03:30:00 2027 GMT" }
43498
+ * }
43499
+ *
43500
+ * Exported for unit testing — the helper is pure-functional given a
43501
+ * cert object and never touches the network.
43502
+ */
43503
+ function extractClientCert(req) {
43504
+ const socket = req.socket;
43505
+ if (typeof socket.getPeerCertificate !== "function") return void 0;
43506
+ return peerCertificateToAws(socket.getPeerCertificate(false));
43507
+ }
43508
+ /**
43509
+ * Convert Node's `PeerCertificate` object to the AWS-canonical
43510
+ * `clientCert` event shape. Exported separately from
43511
+ * {@link extractClientCert} so the conversion can be unit-tested
43512
+ * against a synthetic cert object without a real TLS socket.
43513
+ *
43514
+ * Returns `undefined` when the cert is empty (`getPeerCertificate`
43515
+ * returns `{}` when there is no peer cert). Otherwise emits every
43516
+ * field defined by the AWS shape, falling back to `''` for missing
43517
+ * subject / issuer DN segments so handlers do not need to null-check.
43518
+ */
43519
+ function peerCertificateToAws(cert) {
43520
+ if (!cert || typeof cert !== "object") return void 0;
43521
+ if (Object.keys(cert).length === 0) return void 0;
43522
+ const c = cert;
43523
+ const subject = c["subject"];
43524
+ const issuer = c["issuer"];
43525
+ const raw = c["raw"];
43526
+ const subjectDN = formatDN(subject);
43527
+ const issuerDN = formatDN(issuer);
43528
+ const serialNumber = typeof c["serialNumber"] === "string" ? c["serialNumber"] : "";
43529
+ const validity = {
43530
+ notBefore: typeof c["valid_from"] === "string" ? c["valid_from"] : "",
43531
+ notAfter: typeof c["valid_to"] === "string" ? c["valid_to"] : ""
43532
+ };
43533
+ return {
43534
+ clientCertPem: Buffer.isBuffer(raw) ? derBufferToPem(raw) : "",
43535
+ subjectDN,
43536
+ issuerDN,
43537
+ serialNumber,
43538
+ validity
43539
+ };
43540
+ }
43541
+ /**
43542
+ * Format a Node `subject` / `issuer` object (e.g.
43543
+ * `{C: 'US', O: 'example', CN: 'client'}`) as the canonical
43544
+ * comma-separated DN string AWS emits (`CN=client,O=example,C=US`).
43545
+ *
43546
+ * Ordering follows AWS / OpenSSL convention: CN first, then OU, O, L,
43547
+ * ST, C. Fields the cert does not declare are skipped silently.
43548
+ */
43549
+ function formatDN(dn) {
43550
+ if (!dn || typeof dn !== "object") return "";
43551
+ const obj = dn;
43552
+ const order = [
43553
+ "CN",
43554
+ "OU",
43555
+ "O",
43556
+ "L",
43557
+ "ST",
43558
+ "C"
43559
+ ];
43560
+ const parts = [];
43561
+ for (const key of order) {
43562
+ const v = obj[key];
43563
+ if (typeof v === "string" && v.length > 0) parts.push(`${key}=${v}`);
43564
+ }
43565
+ return parts.join(",");
43566
+ }
43567
+ /**
43568
+ * Encode a DER-encoded certificate Buffer as PEM. We wrap the base64
43569
+ * in 64-char-per-line segments the way `openssl x509` does so the
43570
+ * round-trip looks like what AWS API Gateway emits.
43571
+ */
43572
+ function derBufferToPem(der) {
43573
+ const b64 = der.toString("base64");
43574
+ const lines = [];
43575
+ for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64));
43576
+ return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----\n`;
43577
+ }
43578
+ /**
43579
+ * Read mTLS materials from disk. Each path is a PEM file. The function
43580
+ * throws a wrapped error naming the offending path on `ENOENT` /
43581
+ * permission failures so the CLI surfaces a clear error before the
43582
+ * server starts.
43583
+ *
43584
+ * Exported for the CLI's resolve-then-construct flow + for unit tests.
43585
+ */
43586
+ function readMtlsMaterialsFromDisk(opts) {
43587
+ return {
43588
+ caPem: readPemOrThrow(opts.truststorePath, "--mtls-truststore"),
43589
+ certPem: readPemOrThrow(opts.certPath, "--mtls-cert"),
43590
+ keyPem: readPemOrThrow(opts.keyPath, "--mtls-key")
43591
+ };
43592
+ }
43593
+ function readPemOrThrow(path, flagName) {
43594
+ try {
43595
+ return readFileSync(path);
43596
+ } catch (err) {
43597
+ const msg = err instanceof Error ? err.message : String(err);
43598
+ throw new Error(`${flagName}: cannot read PEM file at '${path}': ${msg}`);
43599
+ }
43600
+ }
43360
43601
 
43361
43602
  //#endregion
43362
43603
  //#region src/local/api-server-grouping.ts
@@ -43955,6 +44196,8 @@ async function localStartApiCommand(target, options) {
43955
44196
  const rieTimeoutMs = Math.max(3e4, maxTimeoutSec * 2 * 1e3);
43956
44197
  const basePort = parseInt(options.port, 10);
43957
44198
  if (!Number.isFinite(basePort) || basePort < 0 || basePort > 65535) throw new Error(`--port must be 0..65535 (got ${options.port}).`);
44199
+ const mtlsConfig = resolveMtlsConfig(options);
44200
+ if (mtlsConfig) logger.info("mTLS enabled: client certificates required (chain check against --mtls-truststore at TLS handshake).");
43958
44201
  const initialGroups = groupRoutesByServer(initialMaterial.routes);
43959
44202
  const servers = [];
43960
44203
  let nextPort = basePort;
@@ -43976,6 +44219,7 @@ async function localStartApiCommand(target, options) {
43976
44219
  state: groupState,
43977
44220
  rieTimeoutMs,
43978
44221
  host: options.host,
44222
+ ...mtlsConfig && { mtls: mtlsConfig },
43979
44223
  port: basePort === 0 ? 0 : nextPort,
43980
44224
  authorizerCache,
43981
44225
  jwksCache,
@@ -43993,7 +44237,7 @@ async function localStartApiCommand(target, options) {
43993
44237
  printPerServerRouteTables(servers);
43994
44238
  warnUnsupportedRoutes(servers.flatMap((s) => s.group.routes.map((r) => r.route)), logger);
43995
44239
  logger.info(`Per-Lambda concurrency: ${perLambdaConcurrency} (override with --per-lambda-concurrency)`);
43996
- for (const { group, server } of servers) process.stdout.write(`Server listening on http://${server.host}:${server.port} (${group.displayName})\n`);
44240
+ for (const { group, server } of servers) process.stdout.write(`Server listening on ${server.scheme}://${server.host}:${server.port} (${group.displayName})\n`);
43997
44241
  process.stdout.write("^C to stop and clean up containers.\n");
43998
44242
  let watcher;
43999
44243
  let reloadChain = Promise.resolve();
@@ -44714,10 +44958,36 @@ function parseDebugPort(raw) {
44714
44958
  return parsed;
44715
44959
  }
44716
44960
  /**
44961
+ * Resolve the mTLS configuration from CLI options. Returns `undefined`
44962
+ * when none of the three `--mtls-*` flags is set (the server stays
44963
+ * plain-HTTP). When any of the three is set, ALL THREE must be set —
44964
+ * partial configurations are rejected at parse time so the server
44965
+ * never boots in a half-configured state.
44966
+ *
44967
+ * Exported for unit testing.
44968
+ */
44969
+ function resolveMtlsConfig(options) {
44970
+ const present = [];
44971
+ const absent = [];
44972
+ if (options.mtlsTruststore !== void 0 && options.mtlsTruststore !== "") present.push("--mtls-truststore");
44973
+ else absent.push("--mtls-truststore");
44974
+ if (options.mtlsCert !== void 0 && options.mtlsCert !== "") present.push("--mtls-cert");
44975
+ else absent.push("--mtls-cert");
44976
+ if (options.mtlsKey !== void 0 && options.mtlsKey !== "") present.push("--mtls-key");
44977
+ else absent.push("--mtls-key");
44978
+ if (present.length === 0) return void 0;
44979
+ if (absent.length > 0) throw new Error(`mTLS configuration is incomplete: ${present.join(", ")} set but ${absent.join(", ")} missing. All three of --mtls-truststore, --mtls-cert, and --mtls-key must be set together to enable mTLS, or all three left unset for plain HTTP.`);
44980
+ return readMtlsMaterialsFromDisk({
44981
+ truststorePath: options.mtlsTruststore,
44982
+ certPath: options.mtlsCert,
44983
+ keyPath: options.mtlsKey
44984
+ });
44985
+ }
44986
+ /**
44717
44987
  * Builder for the `start-api` subcommand. Wired up by `local.ts`.
44718
44988
  */
44719
44989
  function createLocalStartApiCommand() {
44720
- const startApi = new Command("start-api").description("Run a long-running local HTTP server that maps API Gateway routes (REST v1, HTTP API, Function URL) to Lambda invocations against the AWS Lambda Runtime Interface Emulator (Docker required). Supports Lambda TOKEN/REQUEST authorizers, Cognito User Pool / HTTP v2 JWT authorizers, and REST v1 AWS_IAM (SigV4 signature verification only — IAM policy evaluation is NOT emulated; see docs/local-emulation.md). When JWKS is unreachable, JWT authorizers fall back to pass-through (every token accepted) with a warn line — local dev fallback. VPC-config Lambdas run locally and surface a warn line at startup; their containers do NOT get attached to the deployed VPC subnets, so calls to private RDS / ElastiCache will fail.").argument("[target]", "Optional API filter. Accepts the bare CDK logical id ('MyHttpApi'; single-stack apps only), stack-qualified logical id ('MyStack:MyHttpApi'), full CDK Construct path ('MyStack/MyHttpApi/Resource'), or an ancestor Construct path that prefix-matches ('MyStack/MyHttpApi'). When omitted, every discovered API gets its own server. Mirrors `cdkd local invoke` / `cdkd local run-task` target syntax.").addOption(new Option("--port <port>", "HTTP server port (default: auto-allocate)").default("0")).addOption(new Option("--host <host>", "Bind address").default("127.0.0.1")).addOption(new Option("--stack <name>", "Stack to start (single-stack apps auto-detect)")).addOption(new Option("--warm", "Pre-start one container per Lambda at server boot").default(false)).addOption(new Option("--per-lambda-concurrency <n>", "Pool size cap per Lambda (default 2, max 4)").default("2")).addOption(new Option("--no-pull", "Skip docker pull (cached image)")).addOption(new Option("--container-host <host>", "IP the host uses to bind/probe the RIE port (must be a numeric IP — `docker run -p <ip>:<port>:8080` rejects hostnames). Defaults to 127.0.0.1.").default("127.0.0.1")).addOption(new Option("--debug-port-base <port>", "Reserve a contiguous --debug-port range (one per Lambda)")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}, \"Parameters\": {...}})")).addOption(new Option("--assume-role <arn-or-pair>", "Assume the Lambda's execution role and forward STS-issued temp creds. Bare <arn> = global default; <LogicalId>=<arn> = per-Lambda override (repeatable). Per-Lambda > global > unset (developer creds passed through).").argParser((raw, prev) => parseAssumeRoleToken(raw, prev))).addOption(new Option("--watch", "Hot-reload: re-synth + re-discover routes when cdk.out/ or asset directories change. Off by default; the server keeps the previous version serving when synth fails mid-reload.").default(false)).addOption(new Option("--stage <name>", "Select an API Gateway Stage by its 'StageName'. Default: the first Stage attached to each API. Drives event.stageVariables for both REST v1 and HTTP API v2. NOTE: For HTTP API v2 routes, requestContext.stage is always '$default' regardless of this flag (AWS-side limitation — HTTP API only exposes one stage to the integration event); only event.stageVariables is affected for v2 routes. For REST v1 routes the selected StageName is also threaded into requestContext.stage.")).addOption(new Option("--api <id>", "DEPRECATED — use the positional <target> argument instead. Same accepted forms (bare logical id, stack-qualified, Construct path, ancestor prefix). Will be removed in a future major release.")).addOption(new Option("--layer-role-arn <arn>", "Role to sts:AssumeRole before calling lambda:GetLayerVersion on every literal-ARN entry in Properties.Layers (issue #448). Use only when the dev credentials cannot read the layer — typically cross-account layers. AWS-published public layers (e.g. Lambda Powertools) are readable from every account and need no role.")).addOption(new Option("--from-state", "Read cdkd S3 state for every routed stack and substitute Ref / Fn::GetAtt / Fn::Sub / Fn::Join (and AWS pseudo parameters) in Lambda env vars with the deployed physical IDs / attributes. Off by default — pre-PR warn-and-drop semantics are preserved. Turn on for stacks already deployed via cdkd deploy. Mirrors `cdkd local invoke --from-state` / `cdkd local run-task --from-state`. Re-runs against fresh state on every hot-reload firing (--watch).").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).addOption(new Option("--allow-unverified-sigv4", "Opt-in: allow AWS_IAM SigV4 requests that cannot be cryptographically verified (foreign access-key-id, OR no local AWS credentials configured) to pass through with a placeholder principalId. DEFAULT off — fail-closed so unauthenticated bypass is impossible against `event.requestContext.identity.accessKey`-trusting handler code. Use only in dev loops where you understand the risk.").default(false)).action(withErrorHandling(localStartApiCommand));
44990
+ const startApi = new Command("start-api").description("Run a long-running local HTTP server that maps API Gateway routes (REST v1, HTTP API, Function URL) to Lambda invocations against the AWS Lambda Runtime Interface Emulator (Docker required). Supports Lambda TOKEN/REQUEST authorizers, Cognito User Pool / HTTP v2 JWT authorizers, and REST v1 AWS_IAM (SigV4 signature verification only — IAM policy evaluation is NOT emulated; see docs/local-emulation.md). When JWKS is unreachable, JWT authorizers fall back to pass-through (every token accepted) with a warn line — local dev fallback. VPC-config Lambdas run locally and surface a warn line at startup; their containers do NOT get attached to the deployed VPC subnets, so calls to private RDS / ElastiCache will fail.").argument("[target]", "Optional API filter. Accepts the bare CDK logical id ('MyHttpApi'; single-stack apps only), stack-qualified logical id ('MyStack:MyHttpApi'), full CDK Construct path ('MyStack/MyHttpApi/Resource'), or an ancestor Construct path that prefix-matches ('MyStack/MyHttpApi'). When omitted, every discovered API gets its own server. Mirrors `cdkd local invoke` / `cdkd local run-task` target syntax.").addOption(new Option("--port <port>", "HTTP server port (default: auto-allocate)").default("0")).addOption(new Option("--host <host>", "Bind address").default("127.0.0.1")).addOption(new Option("--stack <name>", "Stack to start (single-stack apps auto-detect)")).addOption(new Option("--warm", "Pre-start one container per Lambda at server boot").default(false)).addOption(new Option("--per-lambda-concurrency <n>", "Pool size cap per Lambda (default 2, max 4)").default("2")).addOption(new Option("--no-pull", "Skip docker pull (cached image)")).addOption(new Option("--container-host <host>", "IP the host uses to bind/probe the RIE port (must be a numeric IP — `docker run -p <ip>:<port>:8080` rejects hostnames). Defaults to 127.0.0.1.").default("127.0.0.1")).addOption(new Option("--debug-port-base <port>", "Reserve a contiguous --debug-port range (one per Lambda)")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}, \"Parameters\": {...}})")).addOption(new Option("--assume-role <arn-or-pair>", "Assume the Lambda's execution role and forward STS-issued temp creds. Bare <arn> = global default; <LogicalId>=<arn> = per-Lambda override (repeatable). Per-Lambda > global > unset (developer creds passed through).").argParser((raw, prev) => parseAssumeRoleToken(raw, prev))).addOption(new Option("--watch", "Hot-reload: re-synth + re-discover routes when cdk.out/ or asset directories change. Off by default; the server keeps the previous version serving when synth fails mid-reload.").default(false)).addOption(new Option("--stage <name>", "Select an API Gateway Stage by its 'StageName'. Default: the first Stage attached to each API. Drives event.stageVariables for both REST v1 and HTTP API v2. NOTE: For HTTP API v2 routes, requestContext.stage is always '$default' regardless of this flag (AWS-side limitation — HTTP API only exposes one stage to the integration event); only event.stageVariables is affected for v2 routes. For REST v1 routes the selected StageName is also threaded into requestContext.stage.")).addOption(new Option("--api <id>", "DEPRECATED — use the positional <target> argument instead. Same accepted forms (bare logical id, stack-qualified, Construct path, ancestor prefix). Will be removed in a future major release.")).addOption(new Option("--layer-role-arn <arn>", "Role to sts:AssumeRole before calling lambda:GetLayerVersion on every literal-ARN entry in Properties.Layers (issue #448). Use only when the dev credentials cannot read the layer — typically cross-account layers. AWS-published public layers (e.g. Lambda Powertools) are readable from every account and need no role.")).addOption(new Option("--from-state", "Read cdkd S3 state for every routed stack and substitute Ref / Fn::GetAtt / Fn::Sub / Fn::Join (and AWS pseudo parameters) in Lambda env vars with the deployed physical IDs / attributes. Off by default — pre-PR warn-and-drop semantics are preserved. Turn on for stacks already deployed via cdkd deploy. Mirrors `cdkd local invoke --from-state` / `cdkd local run-task --from-state`. Re-runs against fresh state on every hot-reload firing (--watch).").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).addOption(new Option("--mtls-truststore <path>", "PEM-encoded CA bundle for client-certificate verification (mutual TLS). When set, the local server switches from HTTP to HTTPS and the TLS handshake rejects clients whose certificate doesn't chain to one of these CAs. Verified certs are surfaced on the Lambda event under requestContext.identity.clientCert (REST v1) / requestContext.authentication.clientCert (HTTP API v2). Must be set together with --mtls-cert + --mtls-key; partial flag sets are rejected. Generate a CA + server + client cert for local dev: openssl req -x509 -newkey rsa:2048 -nodes -keyout ca-key.pem -out ca.pem -subj \"/CN=cdkd-local-ca\" -days 365; openssl req -newkey rsa:2048 -nodes -keyout server-key.pem -out server-csr.pem -subj \"/CN=localhost\"; openssl x509 -req -in server-csr.pem -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -days 365; openssl req -newkey rsa:2048 -nodes -keyout client-key.pem -out client-csr.pem -subj \"/CN=client\"; openssl x509 -req -in client-csr.pem -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -days 365; curl --cacert ca.pem --cert client-cert.pem --key client-key.pem https://localhost:<port>/...")).addOption(new Option("--mtls-cert <path>", "PEM-encoded server certificate for mutual TLS. Self-signed is fine for local dev. Must be set together with --mtls-truststore + --mtls-key.")).addOption(new Option("--mtls-key <path>", "PEM-encoded server private key matching --mtls-cert. Must be set together with --mtls-truststore + --mtls-cert.")).addOption(new Option("--allow-unverified-sigv4", "Opt-in: allow AWS_IAM SigV4 requests that cannot be cryptographically verified (foreign access-key-id, OR no local AWS credentials configured) to pass through with a placeholder principalId. DEFAULT off — fail-closed so unauthenticated bypass is impossible against `event.requestContext.identity.accessKey`-trusting handler code. Use only in dev loops where you understand the risk.").default(false)).action(withErrorHandling(localStartApiCommand));
44721
44991
  [
44722
44992
  ...commonOptions,
44723
44993
  ...appOptions,
@@ -46840,7 +47110,10 @@ async function exportCommand(stackArg, options) {
46840
47110
  const phase1Template = filterTemplateForImport(template, phase1Imports);
46841
47111
  const injectedCount = injectDeletionPolicyForImport(phase1Template);
46842
47112
  if (injectedCount > 0) logger.info(`Injected DeletionPolicy: Delete on ${injectedCount} resource(s) without an explicit DeletionPolicy (required by CFn IMPORT — matches the CDK/CFn default for resources without RemovalPolicy).`);
46843
- await executeImportChangeSet(awsClients.cloudFormation, cfnStackName, phase1Template, phase1Imports, cfnParameters, templateFormat);
47113
+ await executeImportChangeSet(awsClients.cloudFormation, cfnStackName, phase1Template, phase1Imports, cfnParameters, templateFormat, {
47114
+ stateBucket,
47115
+ ...options.profile && { s3ClientOpts: { profile: options.profile } }
47116
+ });
46844
47117
  logger.info(`✓ Phase 1: CloudFormation stack '${cfnStackName}' created via IMPORT. ${phase1Imports.length} resource(s) imported.`);
46845
47118
  if (recreateBeforePhase2.length > 0) for (const entry of recreateBeforePhase2) {
46846
47119
  const handler = PRE_DELETE_HANDLERS[entry.resourceType];
@@ -46857,7 +47130,10 @@ async function exportCommand(stackArg, options) {
46857
47130
  const phase2Count = phase2Creates.length + recreateBeforePhase2.length;
46858
47131
  if (phase2Count > 0) try {
46859
47132
  const phase2Template = applyImportOverlayForPhase2(template, phase1Imports);
46860
- await executeUpdateChangeSet(awsClients.cloudFormation, cfnStackName, phase2Template, cfnParameters, templateFormat);
47133
+ await executeUpdateChangeSet(awsClients.cloudFormation, cfnStackName, phase2Template, cfnParameters, templateFormat, {
47134
+ stateBucket,
47135
+ ...options.profile && { s3ClientOpts: { profile: options.profile } }
47136
+ });
46861
47137
  const parts = [];
46862
47138
  if (phase2Creates.length > 0) parts.push(`${phase2Creates.length} non-importable resource(s) CREATEd`);
46863
47139
  if (recreateBeforePhase2.length > 0) parts.push(`${recreateBeforePhase2.length} IMPORT-unsupported resource(s) re-CREATEd`);
@@ -47473,7 +47749,84 @@ function printPlan(plan, cfnStackName) {
47473
47749
  }
47474
47750
  logger.info("");
47475
47751
  }
47476
- async function executeImportChangeSet(cfnClient, stackName, template, plan, parameters, templateFormat = "json") {
47752
+ /**
47753
+ * Decide whether to submit a CFn changeset's template inline via
47754
+ * `TemplateBody` or upload it to the cdkd state bucket and submit via
47755
+ * `TemplateURL`. Returns a discriminated outcome:
47756
+ *
47757
+ * - `kind: 'inline'` — payload <= 51,200 bytes; pass `TemplateBody`
47758
+ * directly. No S3 round-trip; the caller's `finally` cleanup is a
47759
+ * no-op.
47760
+ * - `kind: 'url'` — payload in (51,200, 1,048,576] bytes; helper
47761
+ * uploaded to `cdkd-migrate-tmp/<stackName>/<ts>.{json,yaml}`. The
47762
+ * caller MUST invoke the returned `cleanup` in a `finally` so the
47763
+ * transient object is deleted regardless of CFn success / failure.
47764
+ *
47765
+ * Payloads > 1,048,576 bytes throw pre-flight (the 1 MB ceiling applies
47766
+ * to every CFn API surface; no S3 indirection helps). The error names the
47767
+ * top inline-payload contributors so the user knows what to shrink.
47768
+ *
47769
+ * `phaseLabel` is interpolated into the pre-flight error so the user
47770
+ * sees which phase tripped the ceiling ("phase-1 IMPORT" vs "phase-2
47771
+ * UPDATE").
47772
+ *
47773
+ * When `uploadOpts` is undefined (e.g. unit tests that exercise only the
47774
+ * inline path), templates over the inline limit throw with a clear
47775
+ * "no upload bucket configured" error rather than silently failing on a
47776
+ * downstream CFn rejection.
47777
+ *
47778
+ * `templateFormat` (default `'json'`) drives the transient S3 object's
47779
+ * key suffix + Content-Type so YAML-authored templates stay YAML on the
47780
+ * wire (`.yaml` / `application/x-yaml`).
47781
+ */
47782
+ async function selectChangeSetTemplateSource(template, templateBody, uploadOpts, stackName, phaseLabel, templateFormat = "json") {
47783
+ const logger = getLogger();
47784
+ if (templateBody.length <= 51200) return {
47785
+ kind: "inline",
47786
+ templateBody,
47787
+ cleanup: async () => void 0
47788
+ };
47789
+ if (templateBody.length > 1048576) {
47790
+ const offenders = findLargeInlineResources(template);
47791
+ let detail = "";
47792
+ if (offenders.length > 0) {
47793
+ detail = `\nLargest inline payloads (move these to lambda.Code.fromAsset or split into nested stacks):\n${offenders.slice(0, 10).map((o) => ` - ${o.logicalId} (${o.resourceType}): ~${o.approxBytes} bytes`).join("\n")}`;
47794
+ if (offenders.length > 10) detail += `\n (and ${offenders.length - 10} more above the 4096-byte threshold)`;
47795
+ }
47796
+ throw new Error(`${phaseLabel} template is ${templateBody.length} bytes, over the ${CFN_TEMPLATE_URL_LIMIT}-byte CloudFormation TemplateURL limit. Templates that large cannot be submitted to CloudFormation — shrink inline payloads (e.g. inline Lambda Code.ZipFile larger than ~4 KB → switch to lambda.Code.fromAsset) or split the stack into nested AWS::CloudFormation::Stack resources.${detail}`);
47797
+ }
47798
+ if (!uploadOpts) throw new Error(`${phaseLabel} template is ${templateBody.length} bytes, over the inline ${CFN_TEMPLATE_BODY_LIMIT}-byte TemplateBody limit, but no upload bucket was provided to selectChangeSetTemplateSource — pass uploadOpts to enable the TemplateURL upload path.`);
47799
+ logger.info(` Template is ${templateBody.length} bytes (over ${CFN_TEMPLATE_BODY_LIMIT} inline limit) — uploading to state bucket '${uploadOpts.stateBucket}'.`);
47800
+ const uploaded = await uploadCfnTemplate({
47801
+ bucket: uploadOpts.stateBucket,
47802
+ body: templateBody,
47803
+ stackName,
47804
+ format: templateFormat,
47805
+ ...uploadOpts.s3ClientOpts && { s3ClientOpts: uploadOpts.s3ClientOpts }
47806
+ });
47807
+ return {
47808
+ kind: "url",
47809
+ templateUrl: uploaded.url,
47810
+ cleanup: uploaded.cleanup
47811
+ };
47812
+ }
47813
+ /**
47814
+ * Best-effort wrapper around the `cleanup` callback returned by
47815
+ * {@link selectChangeSetTemplateSource}. Mirrors the warn-on-failure
47816
+ * pattern from `retireCloudFormationStack` so a stranded `cdkd-migrate-tmp/`
47817
+ * object never blocks the calling command — it lives under an obviously
47818
+ * named prefix and can be reaped manually.
47819
+ */
47820
+ async function runTemplateUploadCleanup(cleanup, bucket) {
47821
+ const logger = getLogger();
47822
+ try {
47823
+ await cleanup();
47824
+ } catch (cleanupErr) {
47825
+ const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
47826
+ logger.warn(`Failed to delete temporary template upload from '${bucket}'. Clean up manually under prefix 'cdkd-migrate-tmp/'. Cause: ${msg}`);
47827
+ }
47828
+ }
47829
+ async function executeImportChangeSet(cfnClient, stackName, template, plan, parameters, templateFormat = "json", uploadOpts) {
47477
47830
  const logger = getLogger();
47478
47831
  const changeSetName = `cdkd-migrate-${Date.now()}`;
47479
47832
  const templateBody = stringifyCfnTemplate(template, templateFormat);
@@ -47483,67 +47836,71 @@ async function executeImportChangeSet(cfnClient, stackName, template, plan, para
47483
47836
  ResourceIdentifier: entry.resourceIdentifier
47484
47837
  }));
47485
47838
  logger.info(`Creating IMPORT changeset '${changeSetName}' for stack '${stackName}' (${plan.length} resource(s), ${templateBody.length} bytes)...`);
47486
- if (templateBody.length > 51200) throw new Error(`Filtered template is ${templateBody.length} bytes, over the 51,200-byte inline TemplateBody limit. Templates that large require TemplateURL upload (not yet implemented for cdkd export; please file an issue if you hit this).`);
47487
- try {
47488
- await cfnClient.send(new CreateChangeSetCommand({
47489
- StackName: stackName,
47490
- ChangeSetName: changeSetName,
47491
- ChangeSetType: "IMPORT",
47492
- TemplateBody: templateBody,
47493
- ResourcesToImport: resourcesToImport,
47494
- ...parameters.length > 0 && { Parameters: parameters },
47495
- Capabilities: [
47496
- "CAPABILITY_IAM",
47497
- "CAPABILITY_NAMED_IAM",
47498
- "CAPABILITY_AUTO_EXPAND"
47499
- ]
47500
- }));
47501
- } catch (err) {
47502
- const msg = err instanceof Error ? err.message : String(err);
47503
- throw new Error(`Failed to create IMPORT changeset: ${msg}`);
47504
- }
47839
+ const source = await selectChangeSetTemplateSource(template, templateBody, uploadOpts, stackName, "Filtered phase-1 IMPORT", templateFormat);
47505
47840
  try {
47506
- await waitUntilChangeSetCreateComplete({
47507
- client: cfnClient,
47508
- maxWaitTime: 600
47509
- }, {
47510
- StackName: stackName,
47511
- ChangeSetName: changeSetName
47512
- });
47513
- } catch (err) {
47514
47841
  try {
47515
- const reason = (await cfnClient.send(new DescribeChangeSetCommand({
47842
+ await cfnClient.send(new CreateChangeSetCommand({
47843
+ StackName: stackName,
47844
+ ChangeSetName: changeSetName,
47845
+ ChangeSetType: "IMPORT",
47846
+ ...source.kind === "inline" ? { TemplateBody: source.templateBody } : { TemplateURL: source.templateUrl },
47847
+ ResourcesToImport: resourcesToImport,
47848
+ ...parameters.length > 0 && { Parameters: parameters },
47849
+ Capabilities: [
47850
+ "CAPABILITY_IAM",
47851
+ "CAPABILITY_NAMED_IAM",
47852
+ "CAPABILITY_AUTO_EXPAND"
47853
+ ]
47854
+ }));
47855
+ } catch (err) {
47856
+ const msg = err instanceof Error ? err.message : String(err);
47857
+ throw new Error(`Failed to create IMPORT changeset: ${msg}`);
47858
+ }
47859
+ try {
47860
+ await waitUntilChangeSetCreateComplete({
47861
+ client: cfnClient,
47862
+ maxWaitTime: 600
47863
+ }, {
47516
47864
  StackName: stackName,
47517
47865
  ChangeSetName: changeSetName
47518
- }))).StatusReason ?? "unknown";
47866
+ });
47867
+ } catch (err) {
47868
+ try {
47869
+ const reason = (await cfnClient.send(new DescribeChangeSetCommand({
47870
+ StackName: stackName,
47871
+ ChangeSetName: changeSetName
47872
+ }))).StatusReason ?? "unknown";
47873
+ await cfnClient.send(new DeleteChangeSetCommand({
47874
+ StackName: stackName,
47875
+ ChangeSetName: changeSetName
47876
+ })).catch(() => {});
47877
+ throw new Error(`IMPORT changeset FAILED: ${reason}`);
47878
+ } catch (innerErr) {
47879
+ if (innerErr instanceof Error && innerErr.message.startsWith("IMPORT changeset FAILED")) throw innerErr;
47880
+ throw err;
47881
+ }
47882
+ }
47883
+ logger.info(`Executing IMPORT changeset...`);
47884
+ try {
47885
+ await cfnClient.send(new ExecuteChangeSetCommand({
47886
+ StackName: stackName,
47887
+ ChangeSetName: changeSetName
47888
+ }));
47889
+ await waitUntilStackImportComplete({
47890
+ client: cfnClient,
47891
+ maxWaitTime: 3600
47892
+ }, { StackName: stackName });
47893
+ } catch (err) {
47894
+ const failureSummary = await collectImportFailureSummary(cfnClient, stackName).catch(() => "");
47519
47895
  await cfnClient.send(new DeleteChangeSetCommand({
47520
47896
  StackName: stackName,
47521
47897
  ChangeSetName: changeSetName
47522
47898
  })).catch(() => {});
47523
- throw new Error(`IMPORT changeset FAILED: ${reason}`);
47524
- } catch (innerErr) {
47525
- if (innerErr instanceof Error && innerErr.message.startsWith("IMPORT changeset FAILED")) throw innerErr;
47899
+ if (failureSummary) throw new Error(`IMPORT changeset failed:\n${failureSummary}`, { cause: err });
47526
47900
  throw err;
47527
47901
  }
47528
- }
47529
- logger.info(`Executing IMPORT changeset...`);
47530
- try {
47531
- await cfnClient.send(new ExecuteChangeSetCommand({
47532
- StackName: stackName,
47533
- ChangeSetName: changeSetName
47534
- }));
47535
- await waitUntilStackImportComplete({
47536
- client: cfnClient,
47537
- maxWaitTime: 3600
47538
- }, { StackName: stackName });
47539
- } catch (err) {
47540
- const failureSummary = await collectImportFailureSummary(cfnClient, stackName).catch(() => "");
47541
- await cfnClient.send(new DeleteChangeSetCommand({
47542
- StackName: stackName,
47543
- ChangeSetName: changeSetName
47544
- })).catch(() => {});
47545
- if (failureSummary) throw new Error(`IMPORT changeset failed:\n${failureSummary}`, { cause: err });
47546
- throw err;
47902
+ } finally {
47903
+ await runTemplateUploadCleanup(source.cleanup, uploadOpts?.stateBucket ?? "");
47547
47904
  }
47548
47905
  }
47549
47906
  /**
@@ -47587,69 +47944,73 @@ async function collectImportFailureSummary(cfnClient, stackName) {
47587
47944
  * (cdkd state is intentionally NOT deleted between phases, so a phase-2
47588
47945
  * failure leaves a recoverable state).
47589
47946
  */
47590
- async function executeUpdateChangeSet(cfnClient, stackName, template, parameters, templateFormat = "json") {
47947
+ async function executeUpdateChangeSet(cfnClient, stackName, template, parameters, templateFormat = "json", uploadOpts) {
47591
47948
  const logger = getLogger();
47592
47949
  const changeSetName = `cdkd-phase2-${Date.now()}`;
47593
47950
  const templateBody = stringifyCfnTemplate(template, templateFormat);
47594
- if (templateBody.length > 51200) throw new Error(`Full template is ${templateBody.length} bytes, over the 51,200-byte inline TemplateBody limit for phase-2 UPDATE. TemplateURL upload is not yet implemented.`);
47595
47951
  logger.info(`Creating UPDATE changeset '${changeSetName}' for phase 2 (${templateBody.length} bytes)...`);
47952
+ const source = await selectChangeSetTemplateSource(template, templateBody, uploadOpts, stackName, "Phase-2 UPDATE", templateFormat);
47596
47953
  try {
47597
- await cfnClient.send(new CreateChangeSetCommand({
47598
- StackName: stackName,
47599
- ChangeSetName: changeSetName,
47600
- ChangeSetType: "UPDATE",
47601
- TemplateBody: templateBody,
47602
- ...parameters.length > 0 && { Parameters: parameters },
47603
- Capabilities: [
47604
- "CAPABILITY_IAM",
47605
- "CAPABILITY_NAMED_IAM",
47606
- "CAPABILITY_AUTO_EXPAND"
47607
- ]
47608
- }));
47609
- } catch (err) {
47610
- const msg = err instanceof Error ? err.message : String(err);
47611
- throw new Error(`Failed to create UPDATE changeset: ${msg}`);
47612
- }
47613
- try {
47614
- await waitUntilChangeSetCreateComplete({
47615
- client: cfnClient,
47616
- maxWaitTime: 600
47617
- }, {
47618
- StackName: stackName,
47619
- ChangeSetName: changeSetName
47620
- });
47621
- } catch (err) {
47622
47954
  try {
47623
- const reason = (await cfnClient.send(new DescribeChangeSetCommand({
47955
+ await cfnClient.send(new CreateChangeSetCommand({
47956
+ StackName: stackName,
47957
+ ChangeSetName: changeSetName,
47958
+ ChangeSetType: "UPDATE",
47959
+ ...source.kind === "inline" ? { TemplateBody: source.templateBody } : { TemplateURL: source.templateUrl },
47960
+ ...parameters.length > 0 && { Parameters: parameters },
47961
+ Capabilities: [
47962
+ "CAPABILITY_IAM",
47963
+ "CAPABILITY_NAMED_IAM",
47964
+ "CAPABILITY_AUTO_EXPAND"
47965
+ ]
47966
+ }));
47967
+ } catch (err) {
47968
+ const msg = err instanceof Error ? err.message : String(err);
47969
+ throw new Error(`Failed to create UPDATE changeset: ${msg}`);
47970
+ }
47971
+ try {
47972
+ await waitUntilChangeSetCreateComplete({
47973
+ client: cfnClient,
47974
+ maxWaitTime: 600
47975
+ }, {
47624
47976
  StackName: stackName,
47625
47977
  ChangeSetName: changeSetName
47626
- }))).StatusReason ?? "unknown";
47978
+ });
47979
+ } catch (err) {
47980
+ try {
47981
+ const reason = (await cfnClient.send(new DescribeChangeSetCommand({
47982
+ StackName: stackName,
47983
+ ChangeSetName: changeSetName
47984
+ }))).StatusReason ?? "unknown";
47985
+ await cfnClient.send(new DeleteChangeSetCommand({
47986
+ StackName: stackName,
47987
+ ChangeSetName: changeSetName
47988
+ })).catch(() => {});
47989
+ throw new Error(`UPDATE changeset FAILED: ${reason}`);
47990
+ } catch (innerErr) {
47991
+ if (innerErr instanceof Error && innerErr.message.startsWith("UPDATE changeset FAILED")) throw innerErr;
47992
+ throw err;
47993
+ }
47994
+ }
47995
+ logger.info(`Executing UPDATE changeset...`);
47996
+ try {
47997
+ await cfnClient.send(new ExecuteChangeSetCommand({
47998
+ StackName: stackName,
47999
+ ChangeSetName: changeSetName
48000
+ }));
48001
+ await waitUntilStackUpdateComplete({
48002
+ client: cfnClient,
48003
+ maxWaitTime: 3600
48004
+ }, { StackName: stackName });
48005
+ } catch (err) {
47627
48006
  await cfnClient.send(new DeleteChangeSetCommand({
47628
48007
  StackName: stackName,
47629
48008
  ChangeSetName: changeSetName
47630
48009
  })).catch(() => {});
47631
- throw new Error(`UPDATE changeset FAILED: ${reason}`);
47632
- } catch (innerErr) {
47633
- if (innerErr instanceof Error && innerErr.message.startsWith("UPDATE changeset FAILED")) throw innerErr;
47634
48010
  throw err;
47635
48011
  }
47636
- }
47637
- logger.info(`Executing UPDATE changeset...`);
47638
- try {
47639
- await cfnClient.send(new ExecuteChangeSetCommand({
47640
- StackName: stackName,
47641
- ChangeSetName: changeSetName
47642
- }));
47643
- await waitUntilStackUpdateComplete({
47644
- client: cfnClient,
47645
- maxWaitTime: 3600
47646
- }, { StackName: stackName });
47647
- } catch (err) {
47648
- await cfnClient.send(new DeleteChangeSetCommand({
47649
- StackName: stackName,
47650
- ChangeSetName: changeSetName
47651
- })).catch(() => {});
47652
- throw err;
48012
+ } finally {
48013
+ await runTemplateUploadCleanup(source.cleanup, uploadOpts?.stateBucket ?? "");
47653
48014
  }
47654
48015
  }
47655
48016
  /**
@@ -47768,7 +48129,7 @@ function reorderArgs(argv) {
47768
48129
  */
47769
48130
  async function main() {
47770
48131
  const program = new Command();
47771
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.125.0");
48132
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.127.0");
47772
48133
  program.addCommand(createBootstrapCommand());
47773
48134
  program.addCommand(createSynthCommand());
47774
48135
  program.addCommand(createListCommand());