@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 +520 -159
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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 =
|
|
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 =
|
|
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
|
-
|
|
35214
|
-
|
|
35215
|
-
|
|
35216
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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.
|
|
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());
|