@go-to-k/cdkd 0.134.0 → 0.135.1
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { _ as withSkipPrefix, a as runDockerStreaming, c as getLogger, d as getLiveRenderer, f as PATTERN_B_NAME_PROPERTIES, g as generateResourceNameWithFallback, h as generateResourceName, i as runDockerForeground, n as formatDockerLoginError, p as PATTERN_B_RESOURCE_TYPES, r as getDockerCmd, u as runStackBuffered, v as withStackName } from "./docker-cmd-EtWSTAje.js";
|
|
3
|
-
import {
|
|
3
|
+
import { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as resolveBucketRegion, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, Q as LocalStartServiceError, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as AssemblyReader, V as resolveStateBucketWithDefaultAndSource, X as LocalInvokeBuildError, Z as LocalMigrateError, _ as normalizeAwsTagsToCfn, a as withRetry, at as RouteDiscoveryError, b as CloudControlProvider, c as cyan, d as red, et as MissingCdkCliError, f as yellow, ft as normalizeAwsError, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, it as ResourceUpdateNotSupportedError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as ProvisioningError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as StackHasActiveImportsError, p as IAMRoleProvider, pt as withErrorHandling, q as CdkdError, r as DeployEngine, rt as ResourceTimeoutError, s as bold, st as StackTerminationProtectionError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as PartialFailureError, u as green, v as resolveExplicitPhysicalId, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-CX1x5ug1.js";
|
|
4
4
|
import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
|
|
5
5
|
import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -20,7 +20,7 @@ import { CloudFrontClient, CreateCloudFrontOriginAccessIdentityCommand, CreateDi
|
|
|
20
20
|
import { CloudWatchClient, DeleteAlarmsCommand, DescribeAlarmsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$4, PutMetricAlarmCommand, TagResourceCommand as TagResourceCommand$6, UntagResourceCommand as UntagResourceCommand$6 } from "@aws-sdk/client-cloudwatch";
|
|
21
21
|
import { CloudWatchLogsClient, CreateLogGroupCommand, DeleteDataProtectionPolicyCommand, DeleteIndexPolicyCommand, DeleteLogGroupCommand, DeleteRetentionPolicyCommand, DescribeIndexPoliciesCommand, DescribeLogGroupsCommand, GetDataProtectionPolicyCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$5, PutBearerTokenAuthenticationCommand, PutDataProtectionPolicyCommand, PutIndexPolicyCommand, PutLogGroupDeletionProtectionCommand, PutRetentionPolicyCommand, ResourceAlreadyExistsException, ResourceNotFoundException as ResourceNotFoundException$4, TagResourceCommand as TagResourceCommand$7, UntagResourceCommand as UntagResourceCommand$7 } from "@aws-sdk/client-cloudwatch-logs";
|
|
22
22
|
import { BedrockAgentCoreControlClient, CreateAgentRuntimeCommand, DeleteAgentRuntimeCommand, GetAgentRuntimeCommand, ResourceNotFoundException as ResourceNotFoundException$5, UpdateAgentRuntimeCommand } from "@aws-sdk/client-bedrock-agentcore-control";
|
|
23
|
-
import { cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
24
|
import * as path from "node:path";
|
|
25
25
|
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
26
26
|
import { execFile, spawn } from "node:child_process";
|
|
@@ -35304,10 +35304,15 @@ async function retireCloudFormationStack(options) {
|
|
|
35304
35304
|
logger.info(`[3/4] Updating CloudFormation stack with Retain policies...`);
|
|
35305
35305
|
let updateRan = false;
|
|
35306
35306
|
try {
|
|
35307
|
+
const previousParameters = (stack.Parameters ?? []).map((p) => ({
|
|
35308
|
+
ParameterKey: p.ParameterKey,
|
|
35309
|
+
UsePreviousValue: true
|
|
35310
|
+
}));
|
|
35307
35311
|
await cfnClient.send(new UpdateStackCommand({
|
|
35308
35312
|
StackName: cfnStackName,
|
|
35309
35313
|
...updateInput,
|
|
35310
|
-
Capabilities: capabilities
|
|
35314
|
+
Capabilities: capabilities,
|
|
35315
|
+
...previousParameters.length > 0 && { Parameters: previousParameters }
|
|
35311
35316
|
}));
|
|
35312
35317
|
updateRan = true;
|
|
35313
35318
|
} catch (err) {
|
|
@@ -35448,6 +35453,9 @@ async function confirmPrompt$2(prompt) {
|
|
|
35448
35453
|
|
|
35449
35454
|
//#endregion
|
|
35450
35455
|
//#region src/cli/commands/import.ts
|
|
35456
|
+
async function runImport(stackArg, options) {
|
|
35457
|
+
return importCommand(stackArg, options);
|
|
35458
|
+
}
|
|
35451
35459
|
async function importCommand(stackArg, options) {
|
|
35452
35460
|
const logger = getLogger();
|
|
35453
35461
|
if (options.verbose) {
|
|
@@ -38761,7 +38769,7 @@ function resolveRuntimeCodeMountPath(runtime) {
|
|
|
38761
38769
|
|
|
38762
38770
|
//#endregion
|
|
38763
38771
|
//#region src/local/docker-runner.ts
|
|
38764
|
-
const execFileAsync$
|
|
38772
|
+
const execFileAsync$3 = promisify(execFile);
|
|
38765
38773
|
/**
|
|
38766
38774
|
* Wraps `docker pull` / `docker run` / `docker rm` for `cdkd local invoke`.
|
|
38767
38775
|
*
|
|
@@ -38853,7 +38861,7 @@ async function runDetached(opts) {
|
|
|
38853
38861
|
args.push(opts.image, ...entryPointTail, ...opts.cmd);
|
|
38854
38862
|
getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
|
|
38855
38863
|
try {
|
|
38856
|
-
const { stdout } = await execFileAsync$
|
|
38864
|
+
const { stdout } = await execFileAsync$3(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
|
|
38857
38865
|
return stdout.trim();
|
|
38858
38866
|
} catch (error) {
|
|
38859
38867
|
const err = error;
|
|
@@ -38890,7 +38898,7 @@ async function removeContainer(containerId) {
|
|
|
38890
38898
|
if (!containerId) return;
|
|
38891
38899
|
const logger = getLogger().child("docker");
|
|
38892
38900
|
try {
|
|
38893
|
-
await execFileAsync$
|
|
38901
|
+
await execFileAsync$3(getDockerCmd(), [
|
|
38894
38902
|
"rm",
|
|
38895
38903
|
"-f",
|
|
38896
38904
|
containerId
|
|
@@ -38909,7 +38917,7 @@ async function removeContainer(containerId) {
|
|
|
38909
38917
|
async function ensureDockerAvailable() {
|
|
38910
38918
|
const cmd = getDockerCmd();
|
|
38911
38919
|
try {
|
|
38912
|
-
await execFileAsync$
|
|
38920
|
+
await execFileAsync$3(cmd, [
|
|
38913
38921
|
"version",
|
|
38914
38922
|
"--format",
|
|
38915
38923
|
"{{.Server.Version}}"
|
|
@@ -42004,29 +42012,50 @@ function selectIntegrationResponse(entries, matchTarget, fallbackStatusCode = 20
|
|
|
42004
42012
|
try {
|
|
42005
42013
|
if (new RegExp(`^${entry.SelectionPattern}$`).test(matchTarget)) return {
|
|
42006
42014
|
entry,
|
|
42007
|
-
statusCode: parseStatus
|
|
42015
|
+
statusCode: parseStatus(entry.StatusCode, fallbackStatusCode)
|
|
42008
42016
|
};
|
|
42009
42017
|
} catch {}
|
|
42010
42018
|
}
|
|
42011
42019
|
const entry = defaultEntry ?? null;
|
|
42012
42020
|
return {
|
|
42013
42021
|
entry,
|
|
42014
|
-
statusCode: entry !== null ? parseStatus
|
|
42022
|
+
statusCode: entry !== null ? parseStatus(entry.StatusCode, fallbackStatusCode) : fallbackStatusCode
|
|
42015
42023
|
};
|
|
42016
42024
|
}
|
|
42017
|
-
|
|
42025
|
+
/**
|
|
42026
|
+
* Parse an `IntegrationResponse.StatusCode` value into an HTTP status
|
|
42027
|
+
* code, returning `undefined` when the value is malformed or outside
|
|
42028
|
+
* the valid `[100, 600)` range.
|
|
42029
|
+
*
|
|
42030
|
+
* Issue (#507) items 6 + 7 + PR #515 item 4: this is the single source
|
|
42031
|
+
* of truth for "is `raw` a usable HTTP status code?" — `parseStatus`
|
|
42032
|
+
* in `rest-v1-integrations.ts` and this module's `parseStatus(raw, fallback)`
|
|
42033
|
+
* both delegate here so future shape-tightening (e.g. accepting `0x10`
|
|
42034
|
+
* the way `Number("0x10") === 16` does) lands in one place.
|
|
42035
|
+
*
|
|
42036
|
+
* Validation rules:
|
|
42037
|
+
* - non-string non-number → undefined
|
|
42038
|
+
* - empty / whitespace-only string → undefined
|
|
42039
|
+
* - NaN / non-integer → undefined
|
|
42040
|
+
* - integer outside `[100, 600)` (HTTP valid range) → undefined
|
|
42041
|
+
* - hex literal `"0x10"` → Number coerces to 16 but below 100 → undefined
|
|
42042
|
+
*/
|
|
42043
|
+
function tryParseStatus(raw) {
|
|
42018
42044
|
if (typeof raw === "number") {
|
|
42019
42045
|
if (Number.isInteger(raw) && raw >= 100 && raw < 600) return raw;
|
|
42020
|
-
return
|
|
42046
|
+
return;
|
|
42021
42047
|
}
|
|
42022
|
-
if (typeof raw !== "string") return
|
|
42048
|
+
if (typeof raw !== "string") return void 0;
|
|
42023
42049
|
const trimmed = raw.trim();
|
|
42024
|
-
if (trimmed === "") return
|
|
42050
|
+
if (trimmed === "") return void 0;
|
|
42025
42051
|
const parsed = Number(trimmed);
|
|
42026
|
-
if (!Number.isInteger(parsed)) return
|
|
42027
|
-
if (parsed < 100 || parsed >= 600) return
|
|
42052
|
+
if (!Number.isInteger(parsed)) return void 0;
|
|
42053
|
+
if (parsed < 100 || parsed >= 600) return void 0;
|
|
42028
42054
|
return parsed;
|
|
42029
42055
|
}
|
|
42056
|
+
function parseStatus(raw, fallback) {
|
|
42057
|
+
return tryParseStatus(raw) ?? fallback;
|
|
42058
|
+
}
|
|
42030
42059
|
/**
|
|
42031
42060
|
* Evaluate `IntegrationResponse.ResponseParameters` — header literals
|
|
42032
42061
|
* mapped onto the HTTP response. Returns `{name: value}` for every entry
|
|
@@ -42132,7 +42161,7 @@ function dispatchMockIntegration(config, req) {
|
|
|
42132
42161
|
return vtlFailure("request", err, config.requestTemplate);
|
|
42133
42162
|
}
|
|
42134
42163
|
let entry = null;
|
|
42135
|
-
if (pickedStatus !== void 0) entry = config.responses.find((e) =>
|
|
42164
|
+
if (pickedStatus !== void 0) entry = config.responses.find((e) => tryParseStatus(e.StatusCode) === pickedStatus) ?? defaultResponseEntry(config.responses);
|
|
42136
42165
|
else entry = defaultResponseEntry(config.responses);
|
|
42137
42166
|
if (!entry) return {
|
|
42138
42167
|
statusCode: pickedStatus ?? 200,
|
|
@@ -42156,7 +42185,7 @@ function dispatchMockIntegration(config, req) {
|
|
|
42156
42185
|
Object.assign(headers, evaluateResponseParameters(entry.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`MOCK response: ${reason}`) }));
|
|
42157
42186
|
if (body === "" && headers["content-type"] === contentType) delete headers["content-type"];
|
|
42158
42187
|
return {
|
|
42159
|
-
statusCode:
|
|
42188
|
+
statusCode: tryParseStatus(entry.StatusCode) ?? 200,
|
|
42160
42189
|
headers,
|
|
42161
42190
|
body
|
|
42162
42191
|
};
|
|
@@ -42461,19 +42490,6 @@ function extractStatusCodeFromRendered(rendered) {
|
|
|
42461
42490
|
function defaultResponseEntry(entries) {
|
|
42462
42491
|
return entries.find((e) => e.SelectionPattern === void 0 || e.SelectionPattern === "") ?? null;
|
|
42463
42492
|
}
|
|
42464
|
-
function parseStatus(raw) {
|
|
42465
|
-
if (typeof raw === "number") {
|
|
42466
|
-
if (Number.isInteger(raw) && raw >= 100 && raw < 600) return raw;
|
|
42467
|
-
return;
|
|
42468
|
-
}
|
|
42469
|
-
if (typeof raw !== "string") return void 0;
|
|
42470
|
-
const trimmed = raw.trim();
|
|
42471
|
-
if (trimmed === "") return void 0;
|
|
42472
|
-
const n = Number(trimmed);
|
|
42473
|
-
if (!Number.isInteger(n)) return void 0;
|
|
42474
|
-
if (n < 100 || n >= 600) return void 0;
|
|
42475
|
-
return n;
|
|
42476
|
-
}
|
|
42477
42493
|
/**
|
|
42478
42494
|
* Heuristic: is the given HTTP `Content-Type` header value likely to
|
|
42479
42495
|
* carry text content that VTL ResponseTemplates can safely render
|
|
@@ -43781,6 +43797,38 @@ function matchHeaderList(headerList, allowed) {
|
|
|
43781
43797
|
return true;
|
|
43782
43798
|
}
|
|
43783
43799
|
|
|
43800
|
+
//#endregion
|
|
43801
|
+
//#region src/local/authorizer-context.ts
|
|
43802
|
+
/**
|
|
43803
|
+
* Build the per-kind context shape for the authorizer pipeline. Returns
|
|
43804
|
+
* an empty object only when nothing in the result is surfaceable (e.g.
|
|
43805
|
+
* a Lambda authorizer with no `principalId` and no `context`); callers
|
|
43806
|
+
* decide whether to skip surfacing in that case.
|
|
43807
|
+
*
|
|
43808
|
+
* For Lambda kinds: callers may need to wrap the returned shape in a
|
|
43809
|
+
* `lambda` namespace for HTTP API v2 (`{ lambda: shape }`); the wrap
|
|
43810
|
+
* is consumer-specific so it's NOT done here. The shape returned is
|
|
43811
|
+
* the always-flat REST v1 form.
|
|
43812
|
+
*/
|
|
43813
|
+
function buildAuthorizerContextShape(authorizer, result) {
|
|
43814
|
+
if (authorizer.kind === "lambda-token" || authorizer.kind === "lambda-request") {
|
|
43815
|
+
const ctx = {};
|
|
43816
|
+
if (result.principalId !== void 0) ctx["principalId"] = result.principalId;
|
|
43817
|
+
if (result.context) Object.assign(ctx, result.context);
|
|
43818
|
+
return ctx;
|
|
43819
|
+
}
|
|
43820
|
+
if (authorizer.kind === "iam") {
|
|
43821
|
+
const ctx = {};
|
|
43822
|
+
if (result.principalId !== void 0) ctx["principalId"] = result.principalId;
|
|
43823
|
+
return ctx;
|
|
43824
|
+
}
|
|
43825
|
+
if (authorizer.kind === "cognito") return { claims: { ...result.context ?? {} } };
|
|
43826
|
+
return { jwt: {
|
|
43827
|
+
claims: { ...result.context ?? {} },
|
|
43828
|
+
scopes: []
|
|
43829
|
+
} };
|
|
43830
|
+
}
|
|
43831
|
+
|
|
43784
43832
|
//#endregion
|
|
43785
43833
|
//#region src/local/authorizer-resolver.ts
|
|
43786
43834
|
/**
|
|
@@ -46292,22 +46340,7 @@ function buildServiceIntegrationContextVars(req, route) {
|
|
|
46292
46340
|
*/
|
|
46293
46341
|
function buildAuthorizerContextForServiceIntegration(authorizer, result) {
|
|
46294
46342
|
if (!authorizer || !result) return void 0;
|
|
46295
|
-
|
|
46296
|
-
const ctx = {};
|
|
46297
|
-
if (result.principalId !== void 0) ctx["principalId"] = result.principalId;
|
|
46298
|
-
if (result.context) Object.assign(ctx, result.context);
|
|
46299
|
-
return ctx;
|
|
46300
|
-
}
|
|
46301
|
-
if (authorizer.kind === "iam") {
|
|
46302
|
-
const ctx = {};
|
|
46303
|
-
if (result.principalId !== void 0) ctx["principalId"] = result.principalId;
|
|
46304
|
-
return ctx;
|
|
46305
|
-
}
|
|
46306
|
-
if (authorizer.kind === "cognito") return { claims: { ...result.context ?? {} } };
|
|
46307
|
-
return { jwt: {
|
|
46308
|
-
claims: { ...result.context ?? {} },
|
|
46309
|
-
scopes: []
|
|
46310
|
-
} };
|
|
46343
|
+
return buildAuthorizerContextShape(authorizer, result);
|
|
46311
46344
|
}
|
|
46312
46345
|
/**
|
|
46313
46346
|
* Write the 501 Not Implemented response surfaced for routes the
|
|
@@ -48052,7 +48085,7 @@ function createLocalStartApiCommand() {
|
|
|
48052
48085
|
|
|
48053
48086
|
//#endregion
|
|
48054
48087
|
//#region src/local/ecs-network.ts
|
|
48055
|
-
const execFileAsync$
|
|
48088
|
+
const execFileAsync$2 = promisify(execFile);
|
|
48056
48089
|
/**
|
|
48057
48090
|
* Docker network + AWS-published metadata-endpoints sidecar lifecycle for
|
|
48058
48091
|
* `cdkd local run-task`. The sidecar (a small Go binary maintained by
|
|
@@ -48115,7 +48148,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
48115
48148
|
await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
|
|
48116
48149
|
logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
|
|
48117
48150
|
try {
|
|
48118
|
-
await execFileAsync$
|
|
48151
|
+
await execFileAsync$2(getDockerCmd(), [
|
|
48119
48152
|
"network",
|
|
48120
48153
|
"create",
|
|
48121
48154
|
"--driver",
|
|
@@ -48151,7 +48184,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
48151
48184
|
logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
|
|
48152
48185
|
let sidecarContainerId;
|
|
48153
48186
|
try {
|
|
48154
|
-
const { stdout } = await execFileAsync$
|
|
48187
|
+
const { stdout } = await execFileAsync$2(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
|
|
48155
48188
|
sidecarContainerId = stdout.trim();
|
|
48156
48189
|
} catch (err) {
|
|
48157
48190
|
await destroyNetworkOnly(networkName);
|
|
@@ -48199,7 +48232,7 @@ async function destroyNetworkOnly(networkName) {
|
|
|
48199
48232
|
if (!networkName) return;
|
|
48200
48233
|
const logger = getLogger().child("ecs-network");
|
|
48201
48234
|
try {
|
|
48202
|
-
await execFileAsync$
|
|
48235
|
+
await execFileAsync$2(getDockerCmd(), [
|
|
48203
48236
|
"network",
|
|
48204
48237
|
"rm",
|
|
48205
48238
|
networkName
|
|
@@ -48332,7 +48365,7 @@ async function resolveSsm(entry, shape, client) {
|
|
|
48332
48365
|
|
|
48333
48366
|
//#endregion
|
|
48334
48367
|
//#region src/local/ecs-task-runner.ts
|
|
48335
|
-
const execFileAsync = promisify(execFile);
|
|
48368
|
+
const execFileAsync$1 = promisify(execFile);
|
|
48336
48369
|
/**
|
|
48337
48370
|
* Top-level orchestrator for `cdkd local run-task`. Coordinates image
|
|
48338
48371
|
* preparation, secret resolution, docker-network bring-up, container
|
|
@@ -48392,7 +48425,7 @@ async function cleanupEcsRun(state, options) {
|
|
|
48392
48425
|
await destroyTaskNetwork(state.network);
|
|
48393
48426
|
state.network = void 0;
|
|
48394
48427
|
for (const v of state.dockerVolumeNames) try {
|
|
48395
|
-
await execFileAsync(getDockerCmd(), [
|
|
48428
|
+
await execFileAsync$1(getDockerCmd(), [
|
|
48396
48429
|
"volume",
|
|
48397
48430
|
"rm",
|
|
48398
48431
|
v
|
|
@@ -48458,7 +48491,7 @@ async function runEcsTask(task, options, state) {
|
|
|
48458
48491
|
logger.info(`Starting container '${container.name}' (image=${imagePlan.get(container.name)})`);
|
|
48459
48492
|
let id;
|
|
48460
48493
|
try {
|
|
48461
|
-
const { stdout } = await execFileAsync(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
|
|
48494
|
+
const { stdout } = await execFileAsync$1(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
|
|
48462
48495
|
id = stdout.trim();
|
|
48463
48496
|
} catch (err) {
|
|
48464
48497
|
const e = err;
|
|
@@ -48567,7 +48600,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
48567
48600
|
let lastStatus = "";
|
|
48568
48601
|
while (Date.now() < deadline) {
|
|
48569
48602
|
try {
|
|
48570
|
-
const { stdout } = await execFileAsync(getDockerCmd(), [
|
|
48603
|
+
const { stdout } = await execFileAsync$1(getDockerCmd(), [
|
|
48571
48604
|
"inspect",
|
|
48572
48605
|
"--format",
|
|
48573
48606
|
"{{.State.Health.Status}}",
|
|
@@ -48590,7 +48623,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
48590
48623
|
}
|
|
48591
48624
|
async function waitForContainerExit(containerId) {
|
|
48592
48625
|
try {
|
|
48593
|
-
const { stdout } = await execFileAsync(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
|
|
48626
|
+
const { stdout } = await execFileAsync$1(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
|
|
48594
48627
|
const code = Number.parseInt(stdout.trim(), 10);
|
|
48595
48628
|
return Number.isFinite(code) ? code : 1;
|
|
48596
48629
|
} catch (err) {
|
|
@@ -48600,7 +48633,7 @@ async function waitForContainerExit(containerId) {
|
|
|
48600
48633
|
}
|
|
48601
48634
|
async function stopContainer(containerId, graceSeconds) {
|
|
48602
48635
|
try {
|
|
48603
|
-
await execFileAsync(getDockerCmd(), [
|
|
48636
|
+
await execFileAsync$1(getDockerCmd(), [
|
|
48604
48637
|
"stop",
|
|
48605
48638
|
"-t",
|
|
48606
48639
|
String(graceSeconds),
|
|
@@ -48725,7 +48758,7 @@ async function realizeDockerVolumes(volumes, state) {
|
|
|
48725
48758
|
const dockerVolumeName = `cdkd-local-${v.name}-${randHex(4)}`;
|
|
48726
48759
|
args.push(dockerVolumeName);
|
|
48727
48760
|
try {
|
|
48728
|
-
await execFileAsync(getDockerCmd(), args);
|
|
48761
|
+
await execFileAsync$1(getDockerCmd(), args);
|
|
48729
48762
|
state.dockerVolumeNames.push(dockerVolumeName);
|
|
48730
48763
|
logger.debug(`Created docker volume ${dockerVolumeName} for task volume '${v.name}'`);
|
|
48731
48764
|
} catch (err) {
|
|
@@ -49520,8 +49553,10 @@ const defaultWaitForExitImpl = async (containerId) => {
|
|
|
49520
49553
|
* test-overridable function so unit tests do not need a real container.
|
|
49521
49554
|
*/
|
|
49522
49555
|
let waitForExitImpl = defaultWaitForExitImpl;
|
|
49556
|
+
const defaultSleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
49557
|
+
let sleepImpl = defaultSleepImpl;
|
|
49523
49558
|
function sleep(ms) {
|
|
49524
|
-
return
|
|
49559
|
+
return sleepImpl(ms);
|
|
49525
49560
|
}
|
|
49526
49561
|
|
|
49527
49562
|
//#endregion
|
|
@@ -51859,6 +51894,1069 @@ function createExportCommand() {
|
|
|
51859
51894
|
return cmd;
|
|
51860
51895
|
}
|
|
51861
51896
|
|
|
51897
|
+
//#endregion
|
|
51898
|
+
//#region src/cli/commands/migrate/cdk-cli-check.ts
|
|
51899
|
+
const execFileAsync = promisify(execFile);
|
|
51900
|
+
/**
|
|
51901
|
+
* Minimum aws-cdk CLI version where `cdk migrate --from-stack` is
|
|
51902
|
+
* considered stable (per the design doc at docs/design/465-cfn-migrate.md
|
|
51903
|
+
* §4 — "Version requirement"). Below this we WARN but do not block,
|
|
51904
|
+
* because users may have a working older release and a hard rejection
|
|
51905
|
+
* would be more disruptive than a warning.
|
|
51906
|
+
*/
|
|
51907
|
+
const RECOMMENDED_MIN_VERSION = "2.124.0";
|
|
51908
|
+
/**
|
|
51909
|
+
* Verify that the upstream `cdk` CLI is available on PATH (or at the
|
|
51910
|
+
* override path passed via `--cdk-bin`) and report its version.
|
|
51911
|
+
*
|
|
51912
|
+
* Hard-errors with {@link MissingCdkCliError} when `cdk` cannot be
|
|
51913
|
+
* spawned (ENOENT, permission denied, etc.). Emits a `warn` field on
|
|
51914
|
+
* the result when the version string is below the recommended minimum
|
|
51915
|
+
* — the caller decides how to surface it.
|
|
51916
|
+
*
|
|
51917
|
+
* `cdk --version` output format empirically (verified 2026-05-22
|
|
51918
|
+
* against cdk@2.1112.0): `<MAJOR.MINOR.PATCH> (build <id>)`. The
|
|
51919
|
+
* version is the first whitespace-separated token; the trailing
|
|
51920
|
+
* `(build <id>)` is informational and ignored.
|
|
51921
|
+
*
|
|
51922
|
+
* @param cdkBinPath - Path to the `cdk` binary. Defaults to `'cdk'`
|
|
51923
|
+
* which uses the system PATH for resolution.
|
|
51924
|
+
*/
|
|
51925
|
+
async function verifyCdkCliAvailable(cdkBinPath = "cdk") {
|
|
51926
|
+
let stdout;
|
|
51927
|
+
try {
|
|
51928
|
+
stdout = (await execFileAsync(cdkBinPath, ["--version"])).stdout ?? "";
|
|
51929
|
+
} catch (err) {
|
|
51930
|
+
throw new MissingCdkCliError(`Failed to run '${cdkBinPath} --version': ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
51931
|
+
}
|
|
51932
|
+
const version = parseCdkVersion(stdout);
|
|
51933
|
+
if (!version) throw new MissingCdkCliError(`'${cdkBinPath} --version' produced unexpected output: ${JSON.stringify(stdout.trim())}`);
|
|
51934
|
+
if (compareSemver(version, RECOMMENDED_MIN_VERSION) < 0) return {
|
|
51935
|
+
version,
|
|
51936
|
+
warn: `cdk CLI version ${version} is older than the recommended minimum ${RECOMMENDED_MIN_VERSION}. 'cdkd migrate' relies on the stabilized 'cdk migrate --from-stack' flag; upgrade with 'npm install -g aws-cdk@latest' if you hit codegen issues.`
|
|
51937
|
+
};
|
|
51938
|
+
return { version };
|
|
51939
|
+
}
|
|
51940
|
+
/**
|
|
51941
|
+
* Parse the first semver-shaped token (`<MAJOR>.<MINOR>.<PATCH>`) out of
|
|
51942
|
+
* `cdk --version` stdout. Tolerates a trailing `(build <id>)` suffix.
|
|
51943
|
+
*
|
|
51944
|
+
* Returns `undefined` when no semver-shaped token is present so the
|
|
51945
|
+
* caller can surface the raw stdout in the error message.
|
|
51946
|
+
*/
|
|
51947
|
+
function parseCdkVersion(stdout) {
|
|
51948
|
+
const match = stdout.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
51949
|
+
if (!match) return void 0;
|
|
51950
|
+
return `${match[1]}.${match[2]}.${match[3]}`;
|
|
51951
|
+
}
|
|
51952
|
+
/**
|
|
51953
|
+
* Compare two semver strings (`MAJOR.MINOR.PATCH`). Returns a negative
|
|
51954
|
+
* number when `a < b`, zero when `a === b`, positive when `a > b`.
|
|
51955
|
+
*
|
|
51956
|
+
* Strictly numeric comparison per component — no pre-release / build
|
|
51957
|
+
* suffix handling needed for this use case (`cdk --version` always
|
|
51958
|
+
* emits a stable `X.Y.Z` triple).
|
|
51959
|
+
*/
|
|
51960
|
+
function compareSemver(a, b) {
|
|
51961
|
+
const aParts = a.split(".").map((n) => Number.parseInt(n, 10));
|
|
51962
|
+
const bParts = b.split(".").map((n) => Number.parseInt(n, 10));
|
|
51963
|
+
for (let i = 0; i < 3; i++) {
|
|
51964
|
+
const aN = aParts[i] ?? 0;
|
|
51965
|
+
const bN = bParts[i] ?? 0;
|
|
51966
|
+
if (aN !== bN) return aN - bN;
|
|
51967
|
+
}
|
|
51968
|
+
return 0;
|
|
51969
|
+
}
|
|
51970
|
+
|
|
51971
|
+
//#endregion
|
|
51972
|
+
//#region src/cli/commands/migrate/cfn-stack-prefetch.ts
|
|
51973
|
+
/**
|
|
51974
|
+
* Resource types that `cdkd migrate` refuses to adopt under any
|
|
51975
|
+
* circumstances. The rationale per type:
|
|
51976
|
+
*
|
|
51977
|
+
* - `AWS::CloudFormation::CustomResource` — Lambda-backed Custom
|
|
51978
|
+
* Resources whose response protocol (cfn-response over a
|
|
51979
|
+
* pre-signed S3 URL) is incompatible with cdkd's import flow. The
|
|
51980
|
+
* backing Lambda's onCreate handler would need to be re-invoked
|
|
51981
|
+
* for a cdkd-side adoption to make sense, and that's structurally
|
|
51982
|
+
* different from the metadata-transfer migration this command
|
|
51983
|
+
* provides.
|
|
51984
|
+
*
|
|
51985
|
+
* - `AWS::CloudFormation::Stack` — nested stacks. cdkd has no
|
|
51986
|
+
* provider for this type, and the matching `cdk migrate` output
|
|
51987
|
+
* flattens nested stacks into separate generated apps in a way
|
|
51988
|
+
* that doesn't round-trip cleanly. Out of scope for #465.
|
|
51989
|
+
*
|
|
51990
|
+
* - `Custom::*` — any user-defined Custom Resource type prefix.
|
|
51991
|
+
* Same rationale as `AWS::CloudFormation::CustomResource`.
|
|
51992
|
+
*/
|
|
51993
|
+
const HARD_REJECT_RESOURCE_TYPES = new Set(["AWS::CloudFormation::CustomResource", "AWS::CloudFormation::Stack"]);
|
|
51994
|
+
/**
|
|
51995
|
+
* Fetch the source CFn stack's current state + resources + transform
|
|
51996
|
+
* info. Issues 3 read-only AWS API calls (`DescribeStacks`,
|
|
51997
|
+
* `DescribeStackResources`, `GetTemplate(Stage=Original)`); none of
|
|
51998
|
+
* them mutate AWS state. The result is fed into
|
|
51999
|
+
* {@link validatePrefetchResult} for the hard-reject check and
|
|
52000
|
+
* surfaced to {@link runMigrateLibrary}'s caller (PR B) as part of
|
|
52001
|
+
* `RunMigrateLibraryResult`.
|
|
52002
|
+
*
|
|
52003
|
+
* `DescribeStackResources` is preferred over `ListStackResources`
|
|
52004
|
+
* because the response is unpaginated up to 500 resources (CFn's hard
|
|
52005
|
+
* stack cap) — no pagination loop needed for the migration use case.
|
|
52006
|
+
*/
|
|
52007
|
+
async function prefetchCfnStack(stackName, cfnClient) {
|
|
52008
|
+
const stack = (await cfnClient.send(new DescribeStacksCommand({ StackName: stackName }))).Stacks?.[0];
|
|
52009
|
+
if (!stack) throw new LocalMigrateError(`CloudFormation stack '${stackName}' not found.`);
|
|
52010
|
+
const stackStatus = stack.StackStatus ?? "";
|
|
52011
|
+
const resourcesResp = await cfnClient.send(new DescribeStackResourcesCommand({ StackName: stackName }));
|
|
52012
|
+
const resources = [];
|
|
52013
|
+
for (const r of resourcesResp.StackResources ?? []) {
|
|
52014
|
+
if (!r.LogicalResourceId || !r.PhysicalResourceId || !r.ResourceType) continue;
|
|
52015
|
+
resources.push({
|
|
52016
|
+
LogicalResourceId: r.LogicalResourceId,
|
|
52017
|
+
PhysicalResourceId: r.PhysicalResourceId,
|
|
52018
|
+
ResourceType: r.ResourceType
|
|
52019
|
+
});
|
|
52020
|
+
}
|
|
52021
|
+
let transformInfo = {
|
|
52022
|
+
hasSamTransform: false,
|
|
52023
|
+
hasIncludeTransform: false
|
|
52024
|
+
};
|
|
52025
|
+
let sourceCfnTemplate = null;
|
|
52026
|
+
try {
|
|
52027
|
+
const tplResp = await cfnClient.send(new GetTemplateCommand({
|
|
52028
|
+
StackName: stackName,
|
|
52029
|
+
TemplateStage: "Original"
|
|
52030
|
+
}));
|
|
52031
|
+
if (tplResp.TemplateBody) {
|
|
52032
|
+
try {
|
|
52033
|
+
sourceCfnTemplate = parseCfnTemplate(tplResp.TemplateBody);
|
|
52034
|
+
} catch {
|
|
52035
|
+
sourceCfnTemplate = null;
|
|
52036
|
+
}
|
|
52037
|
+
transformInfo = detectTransformsFromParsed(sourceCfnTemplate);
|
|
52038
|
+
}
|
|
52039
|
+
} catch (err) {
|
|
52040
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
52041
|
+
getLogger().warn(`[migrate] GetTemplate failed for '${stackName}': ${detail}. Skipping transform detection.`);
|
|
52042
|
+
}
|
|
52043
|
+
return {
|
|
52044
|
+
stackStatus,
|
|
52045
|
+
resources,
|
|
52046
|
+
transformInfo,
|
|
52047
|
+
sourceCfnTemplate
|
|
52048
|
+
};
|
|
52049
|
+
}
|
|
52050
|
+
/**
|
|
52051
|
+
* Pre-flight reject when the source CFn stack contains any
|
|
52052
|
+
* un-migratable resource type OR is in a non-terminal state. Surfaces
|
|
52053
|
+
* SAM / Include transforms via the result's `transformInfo` instead —
|
|
52054
|
+
* the caller is responsible for emitting INFO logs.
|
|
52055
|
+
*
|
|
52056
|
+
* Per #465 Q2 (parent-session decision): every hard-reject case
|
|
52057
|
+
* routes the user to the standalone `cdk migrate --from-stack <name>
|
|
52058
|
+
* --output-dir ./tmp` flow so they can manually inspect the generated
|
|
52059
|
+
* code and decide how to proceed (e.g. delete the Custom Resource
|
|
52060
|
+
* from the source CFn stack before re-running cdkd migrate).
|
|
52061
|
+
*/
|
|
52062
|
+
function validatePrefetchResult(result) {
|
|
52063
|
+
if (!STABLE_TERMINAL_STATUSES.has(result.stackStatus)) throw new LocalMigrateError(`CloudFormation stack is in status '${result.stackStatus}', which is not a stable terminal state. Wait for the stack to settle (or roll back) before running 'cdkd migrate'.`);
|
|
52064
|
+
const offenders = [];
|
|
52065
|
+
for (const r of result.resources) if (isHardRejectType(r.ResourceType)) offenders.push({
|
|
52066
|
+
LogicalResourceId: r.LogicalResourceId,
|
|
52067
|
+
ResourceType: r.ResourceType
|
|
52068
|
+
});
|
|
52069
|
+
if (offenders.length === 0) return;
|
|
52070
|
+
throw new LocalMigrateError(`Source CloudFormation stack contains resource types that 'cdkd migrate' cannot adopt:\n${offenders.map((o) => ` - ${o.LogicalResourceId} (${o.ResourceType})`).join("\n")}\n\nLambda-backed Custom Resources and nested CloudFormation stacks are out of scope for #465.\nTo migrate the rest of the stack, either:\n 1. Delete the unsupported resources from the source CFn stack first, OR\n 2. Run upstream 'cdk migrate' standalone to inspect the generated code:\n cdk migrate --from-stack --stack-name <source-stack> --output-path ./tmp\n then hand-author CDK constructs to replace the Custom Resource semantics.`);
|
|
52071
|
+
}
|
|
52072
|
+
/**
|
|
52073
|
+
* Whether the given resource type is in the hard-reject set. Matches
|
|
52074
|
+
* literal types from {@link HARD_REJECT_RESOURCE_TYPES} AND the
|
|
52075
|
+
* `Custom::*` prefix pattern (user-defined Custom Resource types).
|
|
52076
|
+
*/
|
|
52077
|
+
function isHardRejectType(resourceType) {
|
|
52078
|
+
if (HARD_REJECT_RESOURCE_TYPES.has(resourceType)) return true;
|
|
52079
|
+
if (resourceType.startsWith("Custom::")) return true;
|
|
52080
|
+
return false;
|
|
52081
|
+
}
|
|
52082
|
+
/**
|
|
52083
|
+
* Scan an already-parsed CFn template object for stack-level
|
|
52084
|
+
* Transform blocks. Accepts both the scalar form (`Transform:
|
|
52085
|
+
* AWS::Serverless-2016-10-31`) and the array form (`Transform:
|
|
52086
|
+
* [AWS::Serverless-2016-10-31, ...]`).
|
|
52087
|
+
*
|
|
52088
|
+
* The caller is responsible for parsing the raw template body via the
|
|
52089
|
+
* CFn-aware codec; this helper operates on the parsed object so the
|
|
52090
|
+
* `GetTemplate` response can be parsed once and reused.
|
|
52091
|
+
*/
|
|
52092
|
+
function detectTransformsFromParsed(parsed) {
|
|
52093
|
+
if (!parsed || typeof parsed !== "object") return {
|
|
52094
|
+
hasSamTransform: false,
|
|
52095
|
+
hasIncludeTransform: false
|
|
52096
|
+
};
|
|
52097
|
+
const transformRaw = parsed["Transform"];
|
|
52098
|
+
const transforms = Array.isArray(transformRaw) ? transformRaw.map((t) => String(t)) : typeof transformRaw === "string" ? [transformRaw] : [];
|
|
52099
|
+
return {
|
|
52100
|
+
hasSamTransform: transforms.some((t) => t.startsWith("AWS::Serverless")),
|
|
52101
|
+
hasIncludeTransform: transforms.some((t) => t === "AWS::Include" || t.startsWith("AWS::Include"))
|
|
52102
|
+
};
|
|
52103
|
+
}
|
|
52104
|
+
|
|
52105
|
+
//#endregion
|
|
52106
|
+
//#region src/cli/commands/migrate/output-dir-guard.ts
|
|
52107
|
+
/**
|
|
52108
|
+
* Refuse to run `cdkd migrate` when the upstream `cdk migrate` would
|
|
52109
|
+
* write its output into an existing non-empty directory. `cdk migrate`
|
|
52110
|
+
* itself silently overwrites in that case; cdkd surfaces the collision
|
|
52111
|
+
* up-front with the exact recovery command so the user is not left
|
|
52112
|
+
* with a half-overwritten CDK app.
|
|
52113
|
+
*
|
|
52114
|
+
* Behavior matrix (`outputPath = <user-supplied dir>`, `stackName =
|
|
52115
|
+
* <CFn stack name>`; cdk writes to `<outputPath>/<stackName>`):
|
|
52116
|
+
* - target dir does not exist → OK
|
|
52117
|
+
* - target dir exists but is empty → OK (treated as fresh)
|
|
52118
|
+
* - target path is a file, not a directory → typed error
|
|
52119
|
+
* - target dir exists and contains entries → typed error with
|
|
52120
|
+
* the canonical
|
|
52121
|
+
* `rm -rf` recovery
|
|
52122
|
+
* command in the
|
|
52123
|
+
* message
|
|
52124
|
+
*
|
|
52125
|
+
* The parent `outputPath` is NOT checked — `cdk migrate` creates
|
|
52126
|
+
* `<outputPath>` itself if missing, so a non-existent parent is fine.
|
|
52127
|
+
*/
|
|
52128
|
+
function assertOutputDirAvailable(outputPath, stackName) {
|
|
52129
|
+
const targetDir = resolve(outputPath, stackName);
|
|
52130
|
+
if (!existsSync(targetDir)) return;
|
|
52131
|
+
if (!statSync(targetDir).isDirectory()) throw new LocalMigrateError(`Output path '${targetDir}' exists but is not a directory. Remove it (or pass --output-dir <NEW_PATH>) and retry:\n rm -f ${targetDir} && cdkd migrate --from-cfn-stack ${stackName}`);
|
|
52132
|
+
const entries = readdirSync(targetDir);
|
|
52133
|
+
if (entries.length === 0) return;
|
|
52134
|
+
throw new LocalMigrateError(`Output dir '${targetDir}' already exists and is non-empty (e.g. '${join(targetDir, entries[0])}'); remove it or pass --output-dir <NEW_PATH>:\n rm -rf ${targetDir} && cdkd migrate --from-cfn-stack ${stackName}`);
|
|
52135
|
+
}
|
|
52136
|
+
|
|
52137
|
+
//#endregion
|
|
52138
|
+
//#region src/cli/commands/migrate/cdk-migrate-spawn.ts
|
|
52139
|
+
/**
|
|
52140
|
+
* Spawn `cdk migrate --from-stack` as a subprocess and stream its
|
|
52141
|
+
* output through cdkd's logger.
|
|
52142
|
+
*
|
|
52143
|
+
* Why subprocess (not Node module import): the upstream `cdk` CLI is
|
|
52144
|
+
* a bundled CommonJS app with no stable public API; pinning a
|
|
52145
|
+
* runtime-injected `aws-cdk-lib` from cdkd is impractical. Subprocess
|
|
52146
|
+
* isolation also gives us a clean failure boundary — non-zero exit
|
|
52147
|
+
* surfaces as {@link LocalMigrateError} with the captured streams
|
|
52148
|
+
* folded into the error message.
|
|
52149
|
+
*
|
|
52150
|
+
* Streaming is preferred over a buffered `execFile` because `cdk
|
|
52151
|
+
* migrate` emits progress lines (downloading the resource schema,
|
|
52152
|
+
* synthesizing the L1 code for each resource) that users expect to
|
|
52153
|
+
* see in real time. Captured streams are still returned for caller
|
|
52154
|
+
* inspection.
|
|
52155
|
+
*/
|
|
52156
|
+
async function spawnCdkMigrate(opts) {
|
|
52157
|
+
const logger = getLogger();
|
|
52158
|
+
const cdkBin = opts.cdkBinPath ?? "cdk";
|
|
52159
|
+
const language = opts.language ?? "typescript";
|
|
52160
|
+
const outputDir = resolve(opts.outputPath, opts.stackName);
|
|
52161
|
+
const args = [
|
|
52162
|
+
"migrate",
|
|
52163
|
+
"--from-stack",
|
|
52164
|
+
"--stack-name",
|
|
52165
|
+
opts.fromStackName,
|
|
52166
|
+
"--output-path",
|
|
52167
|
+
opts.outputPath,
|
|
52168
|
+
"--language",
|
|
52169
|
+
language
|
|
52170
|
+
];
|
|
52171
|
+
if (opts.region) args.push("--region", opts.region);
|
|
52172
|
+
if (opts.account) args.push("--account", opts.account);
|
|
52173
|
+
if (opts.profile) args.push("--profile", opts.profile);
|
|
52174
|
+
for (const filter of opts.filters ?? []) args.push("--filter", filter);
|
|
52175
|
+
const env = {
|
|
52176
|
+
...process.env,
|
|
52177
|
+
...opts.extraEnv ?? {}
|
|
52178
|
+
};
|
|
52179
|
+
logger.info(`[cdk-migrate] ${cdkBin} ${args.join(" ")}`);
|
|
52180
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
52181
|
+
let stdout = "";
|
|
52182
|
+
let stderr = "";
|
|
52183
|
+
let child;
|
|
52184
|
+
try {
|
|
52185
|
+
child = spawn(cdkBin, args, {
|
|
52186
|
+
env,
|
|
52187
|
+
stdio: [
|
|
52188
|
+
"ignore",
|
|
52189
|
+
"pipe",
|
|
52190
|
+
"pipe"
|
|
52191
|
+
]
|
|
52192
|
+
});
|
|
52193
|
+
} catch (err) {
|
|
52194
|
+
rejectPromise(new LocalMigrateError(`Failed to spawn '${cdkBin} migrate': ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0));
|
|
52195
|
+
return;
|
|
52196
|
+
}
|
|
52197
|
+
child.stdout?.on("data", (chunk) => {
|
|
52198
|
+
const text = chunk.toString("utf-8");
|
|
52199
|
+
stdout += text;
|
|
52200
|
+
const trimmed = text.replace(/\n$/, "");
|
|
52201
|
+
if (trimmed) logger.info(`[cdk-migrate] ${trimmed}`);
|
|
52202
|
+
});
|
|
52203
|
+
child.stderr?.on("data", (chunk) => {
|
|
52204
|
+
const text = chunk.toString("utf-8");
|
|
52205
|
+
stderr += text;
|
|
52206
|
+
const trimmed = text.replace(/\n$/, "");
|
|
52207
|
+
if (trimmed) logger.warn(`[cdk-migrate] ${trimmed}`);
|
|
52208
|
+
});
|
|
52209
|
+
child.on("error", (err) => {
|
|
52210
|
+
rejectPromise(new LocalMigrateError(`'${cdkBin} migrate' subprocess error: ${err.message}`, err));
|
|
52211
|
+
});
|
|
52212
|
+
child.on("close", (code, signal) => {
|
|
52213
|
+
if (code === 0) {
|
|
52214
|
+
resolvePromise({
|
|
52215
|
+
outputDir,
|
|
52216
|
+
stdout,
|
|
52217
|
+
stderr
|
|
52218
|
+
});
|
|
52219
|
+
return;
|
|
52220
|
+
}
|
|
52221
|
+
rejectPromise(new LocalMigrateError(`'${cdkBin} migrate' ${signal !== null ? `killed by signal ${signal}` : code !== null ? `exited with code ${code}` : "process closed without a code or signal"}.\n--- stdout ---\n${stdout || "(empty)"}\n--- stderr ---\n${stderr || "(empty)"}\n`));
|
|
52222
|
+
});
|
|
52223
|
+
});
|
|
52224
|
+
}
|
|
52225
|
+
|
|
52226
|
+
//#endregion
|
|
52227
|
+
//#region src/cli/commands/migrate/synth-after-migrate.ts
|
|
52228
|
+
/**
|
|
52229
|
+
* Run `npm install` inside the generated CDK app directory so the
|
|
52230
|
+
* `cdk synth` step (which dynamically loads `aws-cdk-lib`) has its
|
|
52231
|
+
* dependencies in place.
|
|
52232
|
+
*
|
|
52233
|
+
* Skippable via `skipInstall: true` — CI users with a pre-populated
|
|
52234
|
+
* node_modules cache can save the ~30s `npm install` round-trip.
|
|
52235
|
+
*
|
|
52236
|
+
* Streams stdout / stderr through cdkd's logger so users see progress
|
|
52237
|
+
* for the often-slow npm step. Non-zero exit surfaces as a typed
|
|
52238
|
+
* {@link LocalMigrateError} with the captured streams folded into
|
|
52239
|
+
* the error message.
|
|
52240
|
+
*/
|
|
52241
|
+
async function installGeneratedAppDeps(outputDir, opts) {
|
|
52242
|
+
const logger = getLogger();
|
|
52243
|
+
if (opts.skipInstall) {
|
|
52244
|
+
logger.info(`[cdk-migrate] Skipping 'npm install' (--skip-install).`);
|
|
52245
|
+
return;
|
|
52246
|
+
}
|
|
52247
|
+
const absDir = resolve(outputDir);
|
|
52248
|
+
if (!existsSync(absDir)) throw new LocalMigrateError(`Generated app directory '${absDir}' does not exist; cannot run 'npm install'.`);
|
|
52249
|
+
logger.info(`[cdk-migrate] Running 'npm install' in ${absDir}...`);
|
|
52250
|
+
await runStreamingCommand("npm", ["install"], absDir, opts.extraEnv ? {
|
|
52251
|
+
...process.env,
|
|
52252
|
+
...opts.extraEnv
|
|
52253
|
+
} : void 0, "npm install");
|
|
52254
|
+
}
|
|
52255
|
+
/**
|
|
52256
|
+
* Run `cdk synth --quiet` inside the generated CDK app directory and
|
|
52257
|
+
* return both the assembly dir path and the parsed root-stack
|
|
52258
|
+
* template.
|
|
52259
|
+
*
|
|
52260
|
+
* `--quiet` suppresses the noisy template echo that `cdk synth`
|
|
52261
|
+
* defaults to on stdout — we read the template from disk
|
|
52262
|
+
* (`cdk.out/<StackName>.template.json`) instead.
|
|
52263
|
+
*
|
|
52264
|
+
* Skippable via `skipSynth: true` — useful when PR B's orchestrator
|
|
52265
|
+
* needs the codegen output without running synth (e.g. preview /
|
|
52266
|
+
* dry-run modes).
|
|
52267
|
+
*/
|
|
52268
|
+
async function synthGeneratedApp(outputDir, opts) {
|
|
52269
|
+
const logger = getLogger();
|
|
52270
|
+
const absDir = resolve(outputDir);
|
|
52271
|
+
const assemblyDir = join(absDir, "cdk.out");
|
|
52272
|
+
if (opts.skipSynth) {
|
|
52273
|
+
logger.info(`[cdk-migrate] Skipping 'cdk synth' (--skip-synth).`);
|
|
52274
|
+
return {
|
|
52275
|
+
assemblyDir,
|
|
52276
|
+
templateBody: null
|
|
52277
|
+
};
|
|
52278
|
+
}
|
|
52279
|
+
if (!existsSync(absDir)) throw new LocalMigrateError(`Generated app directory '${absDir}' does not exist; cannot run 'cdk synth'.`);
|
|
52280
|
+
const cdkBin = opts.cdkBinPath ?? "cdk";
|
|
52281
|
+
const env = {
|
|
52282
|
+
...process.env,
|
|
52283
|
+
...opts.extraEnv ?? {}
|
|
52284
|
+
};
|
|
52285
|
+
logger.info(`[cdk-migrate] Running '${cdkBin} synth --quiet' in ${absDir}...`);
|
|
52286
|
+
await runStreamingCommand(cdkBin, ["synth", "--quiet"], absDir, env, `${cdkBin} synth`);
|
|
52287
|
+
if (!existsSync(assemblyDir)) throw new LocalMigrateError(`'cdk synth' completed but produced no '${assemblyDir}' directory.`);
|
|
52288
|
+
const templates = readdirSync(assemblyDir).filter((f) => f.endsWith(".template.json"));
|
|
52289
|
+
if (templates.length === 0) return {
|
|
52290
|
+
assemblyDir,
|
|
52291
|
+
templateBody: null
|
|
52292
|
+
};
|
|
52293
|
+
const templatePath = join(assemblyDir, templates[0]);
|
|
52294
|
+
let templateBody;
|
|
52295
|
+
try {
|
|
52296
|
+
const raw = readFileSync(templatePath, "utf-8");
|
|
52297
|
+
templateBody = JSON.parse(raw);
|
|
52298
|
+
} catch (err) {
|
|
52299
|
+
throw new LocalMigrateError(`Failed to parse generated template '${templatePath}': ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0);
|
|
52300
|
+
}
|
|
52301
|
+
return {
|
|
52302
|
+
assemblyDir,
|
|
52303
|
+
templateBody
|
|
52304
|
+
};
|
|
52305
|
+
}
|
|
52306
|
+
/**
|
|
52307
|
+
* Helper — spawn a subprocess in the given working directory, stream
|
|
52308
|
+
* its output through cdkd's logger, and reject with a typed
|
|
52309
|
+
* {@link LocalMigrateError} on non-zero exit.
|
|
52310
|
+
*
|
|
52311
|
+
* Factored out so the `npm install` and `cdk synth` call sites use the
|
|
52312
|
+
* same shape; both expose progress to the user and surface failures
|
|
52313
|
+
* with the captured streams.
|
|
52314
|
+
*/
|
|
52315
|
+
async function runStreamingCommand(bin, args, cwd, env, label) {
|
|
52316
|
+
const logger = getLogger();
|
|
52317
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
52318
|
+
let stdout = "";
|
|
52319
|
+
let stderr = "";
|
|
52320
|
+
let child;
|
|
52321
|
+
try {
|
|
52322
|
+
child = spawn(bin, args, {
|
|
52323
|
+
cwd,
|
|
52324
|
+
env: env ?? process.env,
|
|
52325
|
+
stdio: [
|
|
52326
|
+
"ignore",
|
|
52327
|
+
"pipe",
|
|
52328
|
+
"pipe"
|
|
52329
|
+
]
|
|
52330
|
+
});
|
|
52331
|
+
} catch (err) {
|
|
52332
|
+
rejectPromise(new LocalMigrateError(`Failed to spawn ${label}: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : void 0));
|
|
52333
|
+
return;
|
|
52334
|
+
}
|
|
52335
|
+
child.stdout?.on("data", (chunk) => {
|
|
52336
|
+
const text = chunk.toString("utf-8");
|
|
52337
|
+
stdout += text;
|
|
52338
|
+
const trimmed = text.replace(/\n$/, "");
|
|
52339
|
+
if (trimmed) logger.info(`[${label}] ${trimmed}`);
|
|
52340
|
+
});
|
|
52341
|
+
child.stderr?.on("data", (chunk) => {
|
|
52342
|
+
const text = chunk.toString("utf-8");
|
|
52343
|
+
stderr += text;
|
|
52344
|
+
const trimmed = text.replace(/\n$/, "");
|
|
52345
|
+
if (trimmed) logger.warn(`[${label}] ${trimmed}`);
|
|
52346
|
+
});
|
|
52347
|
+
child.on("error", (err) => {
|
|
52348
|
+
rejectPromise(new LocalMigrateError(`${label} subprocess error: ${err.message}`, err));
|
|
52349
|
+
});
|
|
52350
|
+
child.on("close", (code, signal) => {
|
|
52351
|
+
if (code === 0) {
|
|
52352
|
+
resolvePromise();
|
|
52353
|
+
return;
|
|
52354
|
+
}
|
|
52355
|
+
rejectPromise(new LocalMigrateError(`${label} ${signal !== null ? `killed by signal ${signal}` : code !== null ? `exited with code ${code}` : "process closed without a code or signal"}.\n--- stdout ---\n${stdout || "(empty)"}\n--- stderr ---\n${stderr || "(empty)"}\n`));
|
|
52356
|
+
});
|
|
52357
|
+
});
|
|
52358
|
+
}
|
|
52359
|
+
|
|
52360
|
+
//#endregion
|
|
52361
|
+
//#region src/cli/commands/migrate/index.ts
|
|
52362
|
+
/**
|
|
52363
|
+
* Build an extra-env bag for subprocesses (`npm install`, `cdk synth`)
|
|
52364
|
+
* so they inherit the AWS profile the caller selected via `--profile`.
|
|
52365
|
+
*
|
|
52366
|
+
* Without this, `cdk synth` falls back to the SDK default credential
|
|
52367
|
+
* chain (or no credentials at all) when the user runs cdkd under a
|
|
52368
|
+
* non-default profile — which silently produces a template synthesized
|
|
52369
|
+
* against the wrong account / region context.
|
|
52370
|
+
*/
|
|
52371
|
+
function buildAwsEnv(opts) {
|
|
52372
|
+
return opts.profile ? { AWS_PROFILE: opts.profile } : {};
|
|
52373
|
+
}
|
|
52374
|
+
/**
|
|
52375
|
+
* PR A library entry point — orchestrates the codegen + synth half of
|
|
52376
|
+
* `cdkd migrate --from-cfn-stack` without writing cdkd state, running
|
|
52377
|
+
* import, or touching the source CFn stack.
|
|
52378
|
+
*
|
|
52379
|
+
* Flow:
|
|
52380
|
+
* 1. `verifyCdkCliAvailable` — hard-fail if `cdk` is missing.
|
|
52381
|
+
* 2. `prefetchCfnStack` — read source stack state + resources +
|
|
52382
|
+
* transform info.
|
|
52383
|
+
* 3. `validatePrefetchResult` — reject CR / nested-stack / non-
|
|
52384
|
+
* terminal state; INFO log SAM / Include transforms.
|
|
52385
|
+
* 4. `assertOutputDirAvailable` — refuse pre-existing non-empty
|
|
52386
|
+
* output dir.
|
|
52387
|
+
* 5. `spawnCdkMigrate` — run `cdk migrate --from-stack` subprocess.
|
|
52388
|
+
* 6. `installGeneratedAppDeps` (gated by `skipInstall`).
|
|
52389
|
+
* 7. `synthGeneratedApp` (gated by `skipSynth`).
|
|
52390
|
+
* 8. Return every artifact PR B will consume.
|
|
52391
|
+
*
|
|
52392
|
+
* Per #465 Q4 (parent-session decision): PR A is library-only. The CLI
|
|
52393
|
+
* command (`cdkd migrate`), state writes, retire flow, and resource-
|
|
52394
|
+
* mapping algorithm all live in PR B.
|
|
52395
|
+
*/
|
|
52396
|
+
async function runMigrateLibrary(opts) {
|
|
52397
|
+
const logger = getLogger();
|
|
52398
|
+
const stackName = opts.fromCfnStack;
|
|
52399
|
+
const outputPath = opts.outputDir ?? resolve(process.cwd(), stackName);
|
|
52400
|
+
logger.info(`[migrate] Verifying upstream 'cdk' CLI...`);
|
|
52401
|
+
const cliCheck = await verifyCdkCliAvailable(opts.cdkBinPath);
|
|
52402
|
+
logger.info(`[migrate] Using cdk CLI v${cliCheck.version}`);
|
|
52403
|
+
if (cliCheck.warn) logger.warn(`[migrate] ${cliCheck.warn}`);
|
|
52404
|
+
logger.info(`[migrate] Pre-fetching CloudFormation stack '${stackName}'...`);
|
|
52405
|
+
const cfnClient = buildCfnClient(opts);
|
|
52406
|
+
let prefetch;
|
|
52407
|
+
try {
|
|
52408
|
+
prefetch = await prefetchCfnStack(stackName, cfnClient);
|
|
52409
|
+
} finally {
|
|
52410
|
+
cfnClient.destroy?.();
|
|
52411
|
+
}
|
|
52412
|
+
validatePrefetchResult(prefetch);
|
|
52413
|
+
if (prefetch.transformInfo.hasSamTransform) logger.info("[migrate] INFO: source CFn stack uses 'AWS::Serverless' transform; cdk migrate will expand it client-side and the generated CDK code will use plain Lambda + API Gateway L1 constructs (not SAM).");
|
|
52414
|
+
if (prefetch.transformInfo.hasIncludeTransform) logger.info("[migrate] INFO: source CFn stack uses 'AWS::Include' transform; cdk migrate will expand it client-side and the generated CDK code will inline the included template content.");
|
|
52415
|
+
assertOutputDirAvailable(outputPath, stackName);
|
|
52416
|
+
const spawnResult = await spawnCdkMigrate({
|
|
52417
|
+
stackName,
|
|
52418
|
+
fromStackName: stackName,
|
|
52419
|
+
outputPath,
|
|
52420
|
+
...opts.language && { language: opts.language },
|
|
52421
|
+
...opts.region && { region: opts.region },
|
|
52422
|
+
...opts.account && { account: opts.account },
|
|
52423
|
+
...opts.filters && { filters: opts.filters },
|
|
52424
|
+
...opts.profile && { profile: opts.profile },
|
|
52425
|
+
...opts.cdkBinPath && { cdkBinPath: opts.cdkBinPath }
|
|
52426
|
+
});
|
|
52427
|
+
await installGeneratedAppDeps(spawnResult.outputDir, {
|
|
52428
|
+
skipInstall: opts.skipInstall ?? false,
|
|
52429
|
+
extraEnv: buildAwsEnv(opts)
|
|
52430
|
+
});
|
|
52431
|
+
const synthResult = await synthGeneratedApp(spawnResult.outputDir, {
|
|
52432
|
+
skipSynth: opts.skipSynth ?? false,
|
|
52433
|
+
...opts.cdkBinPath && { cdkBinPath: opts.cdkBinPath },
|
|
52434
|
+
extraEnv: buildAwsEnv(opts)
|
|
52435
|
+
});
|
|
52436
|
+
if (prefetch.sourceCfnTemplate === null) throw new LocalMigrateError(`Could not read the source CloudFormation template for '${stackName}' (GetTemplate failed during pre-flight). Re-run after granting cloudformation:GetTemplate on the stack, or check the earlier warning for details.`);
|
|
52437
|
+
return {
|
|
52438
|
+
outputDir: spawnResult.outputDir,
|
|
52439
|
+
assemblyDir: synthResult.assemblyDir,
|
|
52440
|
+
templateBody: synthResult.templateBody,
|
|
52441
|
+
sourceCfnTemplate: prefetch.sourceCfnTemplate,
|
|
52442
|
+
sourceResources: prefetch.resources
|
|
52443
|
+
};
|
|
52444
|
+
}
|
|
52445
|
+
/**
|
|
52446
|
+
* Build a CloudFormation client using cdkd's shared {@link AwsClients}
|
|
52447
|
+
* factory so the credential chain matches every other cdkd command.
|
|
52448
|
+
*
|
|
52449
|
+
* Keeping the construction in one place makes the per-command CFn-
|
|
52450
|
+
* client recipe a single readable hook for future plumbing (e.g.
|
|
52451
|
+
* threading PR B's `--role-arn` STS-assume).
|
|
52452
|
+
*/
|
|
52453
|
+
function buildCfnClient(opts) {
|
|
52454
|
+
const config = {};
|
|
52455
|
+
if (opts.region) config.region = opts.region;
|
|
52456
|
+
if (opts.profile) config.profile = opts.profile;
|
|
52457
|
+
return new AwsClients(config).getCloudFormationClient();
|
|
52458
|
+
}
|
|
52459
|
+
|
|
52460
|
+
//#endregion
|
|
52461
|
+
//#region src/cli/commands/migrate/resource-mapper.ts
|
|
52462
|
+
/**
|
|
52463
|
+
* Run the 2-pass mapping algorithm against `(sourceCfnTemplate,
|
|
52464
|
+
* synthTemplate, sourceResources)` and return the resolved mapping plus
|
|
52465
|
+
* any unmatched entries.
|
|
52466
|
+
*
|
|
52467
|
+
* Pure-functional: no AWS calls, no I/O, no mutation of inputs. Safe to
|
|
52468
|
+
* call repeatedly in tests against shared fixtures.
|
|
52469
|
+
*
|
|
52470
|
+
* Throws when an override references a synth id that does not exist —
|
|
52471
|
+
* this is a user error in the hand-edited mapping JSON and surfacing
|
|
52472
|
+
* the available synth ids in the message is the path back to a working
|
|
52473
|
+
* config.
|
|
52474
|
+
*/
|
|
52475
|
+
function buildResourceMapping(opts) {
|
|
52476
|
+
const sourceEntries = extractSourceResources(opts.sourceCfnTemplate);
|
|
52477
|
+
const synthEntries = extractSynthResources(opts.synthTemplate);
|
|
52478
|
+
const physicalIdByLogicalId = /* @__PURE__ */ new Map();
|
|
52479
|
+
for (const r of opts.sourceResources) physicalIdByLogicalId.set(r.LogicalResourceId, r.PhysicalResourceId);
|
|
52480
|
+
const overrides = opts.overrides ?? {};
|
|
52481
|
+
const synthLogicalIds = new Set(synthEntries.map((s) => s.logicalId));
|
|
52482
|
+
for (const [srcId, synthId] of Object.entries(overrides)) if (!synthLogicalIds.has(synthId)) throw new Error(`Resource-mapping override targets synth logical id '${synthId}' (for source id '${srcId}'), but that id is not in the synthesized template. Available synth ids: ${[...synthLogicalIds].sort().join(", ")}`);
|
|
52483
|
+
const missingPhysicalIds = [];
|
|
52484
|
+
for (const src of sourceEntries) if (!physicalIdByLogicalId.has(src.logicalId)) missingPhysicalIds.push(` - ${src.logicalId} (${src.type})`);
|
|
52485
|
+
if (missingPhysicalIds.length > 0) throw new Error(`Source CFn resource(s) not returned by DescribeStackResources — cannot migrate:\n${missingPhysicalIds.join("\n")}\n\nThe stack may have been mid-operation; wait for it to settle (CREATE_COMPLETE / UPDATE_COMPLETE / etc.) and retry.`);
|
|
52486
|
+
const synthByLastPathSegment = indexSynthByLastPathSegment(synthEntries);
|
|
52487
|
+
const synthByLogicalId = new Map(synthEntries.map((s) => [s.logicalId, s]));
|
|
52488
|
+
const claimedSynthIds = /* @__PURE__ */ new Set();
|
|
52489
|
+
const pairs = [];
|
|
52490
|
+
const unmatched = [];
|
|
52491
|
+
const sourcesHandledByOverride = /* @__PURE__ */ new Set();
|
|
52492
|
+
for (const [srcId, synthId] of Object.entries(overrides)) {
|
|
52493
|
+
const src = sourceEntries.find((e) => e.logicalId === srcId);
|
|
52494
|
+
if (!src) {
|
|
52495
|
+
const availableSrc = sourceEntries.map((e) => e.logicalId).sort();
|
|
52496
|
+
throw new Error(`Resource-mapping override references source logical id '${srcId}', but no resource with that id exists in the source CloudFormation template. Available source ids: ${availableSrc.join(", ")}`);
|
|
52497
|
+
}
|
|
52498
|
+
const synth = synthByLogicalId.get(synthId);
|
|
52499
|
+
if (!synth) continue;
|
|
52500
|
+
const physicalId = physicalIdByLogicalId.get(src.logicalId);
|
|
52501
|
+
pairs.push({
|
|
52502
|
+
sourceLogicalId: src.logicalId,
|
|
52503
|
+
synthLogicalId: synth.logicalId,
|
|
52504
|
+
physicalId,
|
|
52505
|
+
resourceType: src.type
|
|
52506
|
+
});
|
|
52507
|
+
claimedSynthIds.add(synth.logicalId);
|
|
52508
|
+
sourcesHandledByOverride.add(src.logicalId);
|
|
52509
|
+
}
|
|
52510
|
+
const passOnePending = [];
|
|
52511
|
+
for (const src of sourceEntries) {
|
|
52512
|
+
if (sourcesHandledByOverride.has(src.logicalId)) continue;
|
|
52513
|
+
const candidatesForLastSegment = synthByLastPathSegment.get(src.logicalId) ?? [];
|
|
52514
|
+
const rawCandidateCount = candidatesForLastSegment.length;
|
|
52515
|
+
const availableCandidates = candidatesForLastSegment.filter((c) => !claimedSynthIds.has(c.logicalId));
|
|
52516
|
+
if (rawCandidateCount === 1 && availableCandidates.length === 1) {
|
|
52517
|
+
const synth = availableCandidates[0];
|
|
52518
|
+
pairs.push({
|
|
52519
|
+
sourceLogicalId: src.logicalId,
|
|
52520
|
+
synthLogicalId: synth.logicalId,
|
|
52521
|
+
physicalId: physicalIdByLogicalId.get(src.logicalId),
|
|
52522
|
+
resourceType: src.type
|
|
52523
|
+
});
|
|
52524
|
+
claimedSynthIds.add(synth.logicalId);
|
|
52525
|
+
} else passOnePending.push(src);
|
|
52526
|
+
}
|
|
52527
|
+
for (const src of passOnePending) {
|
|
52528
|
+
const candidatesSameType = synthEntries.filter((s) => s.type === src.type && !claimedSynthIds.has(s.logicalId));
|
|
52529
|
+
const propertyMatches = candidatesSameType.filter((s) => deepEqualIgnoreNoValue(src.properties, s.properties));
|
|
52530
|
+
if (propertyMatches.length === 1) {
|
|
52531
|
+
const synth = propertyMatches[0];
|
|
52532
|
+
pairs.push({
|
|
52533
|
+
sourceLogicalId: src.logicalId,
|
|
52534
|
+
synthLogicalId: synth.logicalId,
|
|
52535
|
+
physicalId: physicalIdByLogicalId.get(src.logicalId),
|
|
52536
|
+
resourceType: src.type
|
|
52537
|
+
});
|
|
52538
|
+
claimedSynthIds.add(synth.logicalId);
|
|
52539
|
+
} else {
|
|
52540
|
+
const isCollision = (synthByLastPathSegment.get(src.logicalId) ?? []).length >= 2;
|
|
52541
|
+
unmatched.push({
|
|
52542
|
+
sourceLogicalId: src.logicalId,
|
|
52543
|
+
resourceType: src.type,
|
|
52544
|
+
candidates: candidatesSameType.map((c) => c.logicalId).sort(),
|
|
52545
|
+
reason: isCollision ? "logical-id-collision" : "no-match"
|
|
52546
|
+
});
|
|
52547
|
+
}
|
|
52548
|
+
}
|
|
52549
|
+
const mapping = {};
|
|
52550
|
+
for (const p of pairs) mapping[p.sourceLogicalId] = p.synthLogicalId;
|
|
52551
|
+
return {
|
|
52552
|
+
mapping,
|
|
52553
|
+
pairs,
|
|
52554
|
+
unmatched
|
|
52555
|
+
};
|
|
52556
|
+
}
|
|
52557
|
+
/**
|
|
52558
|
+
* Walk a parsed CFn template's `Resources` and return one entry per
|
|
52559
|
+
* resource. `Type` defaults to `''` and `Properties` to `{}` when the
|
|
52560
|
+
* template omits them (CFn allows resources with only `DeletionPolicy`
|
|
52561
|
+
* + `Type`); the mapper's downstream deep-equal handles `{}` cleanly.
|
|
52562
|
+
*
|
|
52563
|
+
* Top-level keys other than `Resources` (Conditions / Parameters /
|
|
52564
|
+
* Rules / Outputs / Mappings / Metadata) are NOT walked — the mapping
|
|
52565
|
+
* is resource-to-resource only, and §5.5 of the design doc spells out
|
|
52566
|
+
* the four CDK-synth injections that live in those sibling keys.
|
|
52567
|
+
*/
|
|
52568
|
+
function extractSourceResources(template) {
|
|
52569
|
+
if (!template || typeof template !== "object") return [];
|
|
52570
|
+
const resources = template["Resources"];
|
|
52571
|
+
if (!resources || typeof resources !== "object") return [];
|
|
52572
|
+
const out = [];
|
|
52573
|
+
for (const [logicalId, raw] of Object.entries(resources)) {
|
|
52574
|
+
if (!raw || typeof raw !== "object") continue;
|
|
52575
|
+
const r = raw;
|
|
52576
|
+
const type = typeof r["Type"] === "string" ? r["Type"] : "";
|
|
52577
|
+
const props = r["Properties"] && typeof r["Properties"] === "object" ? r["Properties"] : {};
|
|
52578
|
+
out.push({
|
|
52579
|
+
logicalId,
|
|
52580
|
+
type,
|
|
52581
|
+
properties: props
|
|
52582
|
+
});
|
|
52583
|
+
}
|
|
52584
|
+
return out;
|
|
52585
|
+
}
|
|
52586
|
+
/**
|
|
52587
|
+
* Walk the synth template's `Resources` and return one entry per
|
|
52588
|
+
* resource, EXCLUDING `AWS::CDK::Metadata` (synth-only sentinel that has
|
|
52589
|
+
* no source counterpart). Captures `Metadata['aws:cdk:path']` so Pass 1
|
|
52590
|
+
* can match against the last `/`-separated segment.
|
|
52591
|
+
*
|
|
52592
|
+
* The four other CDK-synth injections (CDKMetadataAvailable Condition,
|
|
52593
|
+
* BootstrapVersion Parameter, CheckBootstrapVersion Rule) live outside
|
|
52594
|
+
* `Resources` and are structurally excluded by this function.
|
|
52595
|
+
*/
|
|
52596
|
+
function extractSynthResources(template) {
|
|
52597
|
+
if (!template || typeof template !== "object") return [];
|
|
52598
|
+
const resources = template["Resources"];
|
|
52599
|
+
if (!resources || typeof resources !== "object") return [];
|
|
52600
|
+
const out = [];
|
|
52601
|
+
for (const [logicalId, raw] of Object.entries(resources)) {
|
|
52602
|
+
if (!raw || typeof raw !== "object") continue;
|
|
52603
|
+
const r = raw;
|
|
52604
|
+
const type = typeof r["Type"] === "string" ? r["Type"] : "";
|
|
52605
|
+
if (type === "AWS::CDK::Metadata") continue;
|
|
52606
|
+
const props = r["Properties"] && typeof r["Properties"] === "object" ? r["Properties"] : {};
|
|
52607
|
+
const meta = r["Metadata"] && typeof r["Metadata"] === "object" ? r["Metadata"] : {};
|
|
52608
|
+
const path = typeof meta["aws:cdk:path"] === "string" ? meta["aws:cdk:path"] : "";
|
|
52609
|
+
out.push({
|
|
52610
|
+
logicalId,
|
|
52611
|
+
type,
|
|
52612
|
+
properties: props,
|
|
52613
|
+
awsCdkPath: path
|
|
52614
|
+
});
|
|
52615
|
+
}
|
|
52616
|
+
return out;
|
|
52617
|
+
}
|
|
52618
|
+
/**
|
|
52619
|
+
* Group synth entries by the LAST `/`-separated segment of their
|
|
52620
|
+
* `aws:cdk:path` metadata so Pass 1 can look up the source logical id
|
|
52621
|
+
* directly. Per [docs/design/465-cfn-migrate.md](../../../../docs/design/465-cfn-migrate.md) §5.5:
|
|
52622
|
+
* `cdk migrate` emits `<StackName>/<LogicalId>` as the metadata value,
|
|
52623
|
+
* so the last segment IS the source logical id in the typical case.
|
|
52624
|
+
*
|
|
52625
|
+
* Entries without an `aws:cdk:path` are silently skipped — they cannot
|
|
52626
|
+
* be addressed by source logical id and will only be reachable via
|
|
52627
|
+
* Pass 2's Properties match.
|
|
52628
|
+
*/
|
|
52629
|
+
function indexSynthByLastPathSegment(entries) {
|
|
52630
|
+
const out = /* @__PURE__ */ new Map();
|
|
52631
|
+
for (const e of entries) {
|
|
52632
|
+
if (!e.awsCdkPath) continue;
|
|
52633
|
+
const lastSegment = e.awsCdkPath.split("/").pop() ?? "";
|
|
52634
|
+
if (!lastSegment) continue;
|
|
52635
|
+
const bucket = out.get(lastSegment);
|
|
52636
|
+
if (bucket) bucket.push(e);
|
|
52637
|
+
else out.set(lastSegment, [e]);
|
|
52638
|
+
}
|
|
52639
|
+
return out;
|
|
52640
|
+
}
|
|
52641
|
+
/**
|
|
52642
|
+
* Recursive deep equality between two values, with two CFn-specific
|
|
52643
|
+
* accommodations:
|
|
52644
|
+
*
|
|
52645
|
+
* 1. Object key order is NOT significant — `JSON.stringify` would
|
|
52646
|
+
* diverge on the canonical-source-vs-synth-alphabetized case
|
|
52647
|
+
* (verified by PR A's empirical run, §5.5 point 3) so we walk the
|
|
52648
|
+
* keys explicitly.
|
|
52649
|
+
* 2. `AWS::NoValue` placeholder values are stripped before compare —
|
|
52650
|
+
* `cdk migrate`'s codegen sometimes emits the placeholder for a
|
|
52651
|
+
* property the source template simply omitted, and the structural
|
|
52652
|
+
* intent matches. Source side never has the placeholder; synth side
|
|
52653
|
+
* can on either side of a nested object.
|
|
52654
|
+
*
|
|
52655
|
+
* Arrays are compared positionally (order IS significant — Tags arrays
|
|
52656
|
+
* and similar carry ordering semantics).
|
|
52657
|
+
*/
|
|
52658
|
+
function deepEqualIgnoreNoValue(a, b) {
|
|
52659
|
+
if (a === b) return true;
|
|
52660
|
+
if (isAwsNoValue(a) !== isAwsNoValue(b)) return false;
|
|
52661
|
+
if (isAwsNoValue(a) && isAwsNoValue(b)) return true;
|
|
52662
|
+
if (typeof a !== typeof b) return false;
|
|
52663
|
+
if (a === null || b === null) return a === b;
|
|
52664
|
+
if (typeof a !== "object") return a === b;
|
|
52665
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
52666
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
52667
|
+
if (a.length !== b.length) return false;
|
|
52668
|
+
for (let i = 0; i < a.length; i++) if (!deepEqualIgnoreNoValue(a[i], b[i])) return false;
|
|
52669
|
+
return true;
|
|
52670
|
+
}
|
|
52671
|
+
const aObj = a;
|
|
52672
|
+
const bObj = b;
|
|
52673
|
+
const aKeys = Object.keys(aObj).filter((k) => !isAwsNoValue(aObj[k]) && aObj[k] !== void 0);
|
|
52674
|
+
const bKeys = Object.keys(bObj).filter((k) => !isAwsNoValue(bObj[k]) && bObj[k] !== void 0);
|
|
52675
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
52676
|
+
const aKeySet = new Set(aKeys);
|
|
52677
|
+
for (const k of bKeys) {
|
|
52678
|
+
if (!aKeySet.has(k)) return false;
|
|
52679
|
+
if (!deepEqualIgnoreNoValue(aObj[k], bObj[k])) return false;
|
|
52680
|
+
}
|
|
52681
|
+
return true;
|
|
52682
|
+
}
|
|
52683
|
+
/**
|
|
52684
|
+
* True when the value is the CFn `AWS::NoValue` placeholder
|
|
52685
|
+
* (`{Ref: 'AWS::NoValue'}`). `cdk migrate`'s codegen emits this for
|
|
52686
|
+
* conditional properties the source template omitted; the structural
|
|
52687
|
+
* intent matches and deep-equal must treat them as absent.
|
|
52688
|
+
*/
|
|
52689
|
+
function isAwsNoValue(v) {
|
|
52690
|
+
if (!v || typeof v !== "object") return false;
|
|
52691
|
+
const obj = v;
|
|
52692
|
+
const keys = Object.keys(obj);
|
|
52693
|
+
return keys.length === 1 && keys[0] === "Ref" && obj["Ref"] === "AWS::NoValue";
|
|
52694
|
+
}
|
|
52695
|
+
|
|
52696
|
+
//#endregion
|
|
52697
|
+
//#region src/cli/commands/migrate/resource-mapping-file.ts
|
|
52698
|
+
/** Conventional filename inside the migrate output dir. */
|
|
52699
|
+
const RESOURCE_MAPPING_FILENAME = "cdkd-resource-mapping.json";
|
|
52700
|
+
/**
|
|
52701
|
+
* Build the on-disk file shape from a {@link ResourceMappingResult} +
|
|
52702
|
+
* the source / output stack names, then write it to
|
|
52703
|
+
* `<outputDir>/cdkd-resource-mapping.json` (overwriting any prior file
|
|
52704
|
+
* on the same path — same idempotency contract as `cdk migrate`'s own
|
|
52705
|
+
* codegen, and the user expects a re-run to refresh stale data).
|
|
52706
|
+
*
|
|
52707
|
+
* Returns the absolute on-disk path so the orchestrator can name it in
|
|
52708
|
+
* the error message when the mapping is incomplete.
|
|
52709
|
+
*/
|
|
52710
|
+
function writeMappingFile(outputDir, args) {
|
|
52711
|
+
const path = join(outputDir, RESOURCE_MAPPING_FILENAME);
|
|
52712
|
+
const file = {
|
|
52713
|
+
version: 1,
|
|
52714
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52715
|
+
sourceStack: args.sourceStack,
|
|
52716
|
+
outputStack: args.outputStack,
|
|
52717
|
+
mapping: args.result.mapping
|
|
52718
|
+
};
|
|
52719
|
+
if (args.result.unmatched.length > 0) file._unmatched = args.result.unmatched;
|
|
52720
|
+
writeFileSync(path, JSON.stringify(file, null, 2) + "\n", "utf-8");
|
|
52721
|
+
return path;
|
|
52722
|
+
}
|
|
52723
|
+
/**
|
|
52724
|
+
* Read a user-supplied resource-mapping file from disk and return the
|
|
52725
|
+
* `{<srcLogicalId>: <synthLogicalId>}` overrides for the mapper.
|
|
52726
|
+
*
|
|
52727
|
+
* Tolerates `_unmatched` on input (a user that re-runs against a
|
|
52728
|
+
* partial-failure file without editing should hit the same failure on
|
|
52729
|
+
* the resolution side — not a parse error here). Hard-errors on
|
|
52730
|
+
* structurally invalid input (missing `mapping`, non-object root,
|
|
52731
|
+
* non-string values, wrong `version`).
|
|
52732
|
+
*
|
|
52733
|
+
* Surfaces every shape error as `LocalMigrateError` (exit code 2) so
|
|
52734
|
+
* the CLI handler routes it through the normal error path.
|
|
52735
|
+
*/
|
|
52736
|
+
function readMappingFile(path) {
|
|
52737
|
+
if (!existsSync(path)) throw new LocalMigrateError(`Resource-mapping file not found: ${path}. Drop the --resource-mapping flag or supply a valid path.`);
|
|
52738
|
+
let raw;
|
|
52739
|
+
try {
|
|
52740
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
52741
|
+
} catch (err) {
|
|
52742
|
+
throw new LocalMigrateError(`Resource-mapping file '${path}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
52743
|
+
}
|
|
52744
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new LocalMigrateError(`Resource-mapping file '${path}' must contain a JSON object at the top level.`);
|
|
52745
|
+
const obj = raw;
|
|
52746
|
+
if (obj["version"] !== 1) throw new LocalMigrateError(`Resource-mapping file '${path}' has unsupported version '${String(obj["version"])}'. Expected version 1.`);
|
|
52747
|
+
if (typeof obj["sourceStack"] !== "string" || obj["sourceStack"].length === 0) throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'sourceStack' field.`);
|
|
52748
|
+
if (typeof obj["outputStack"] !== "string" || obj["outputStack"].length === 0) throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'outputStack' field.`);
|
|
52749
|
+
if (typeof obj["generatedAt"] !== "string") throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'generatedAt' field.`);
|
|
52750
|
+
const mappingRaw = obj["mapping"];
|
|
52751
|
+
if (!mappingRaw || typeof mappingRaw !== "object" || Array.isArray(mappingRaw)) throw new LocalMigrateError(`Resource-mapping file '${path}' is missing the required 'mapping' object.`);
|
|
52752
|
+
const mapping = {};
|
|
52753
|
+
for (const [k, v] of Object.entries(mappingRaw)) {
|
|
52754
|
+
if (typeof v !== "string") throw new LocalMigrateError(`Resource-mapping file '${path}' has a non-string value for key '${k}'.`);
|
|
52755
|
+
mapping[k] = v;
|
|
52756
|
+
}
|
|
52757
|
+
const result = {
|
|
52758
|
+
version: 1,
|
|
52759
|
+
generatedAt: obj["generatedAt"],
|
|
52760
|
+
sourceStack: obj["sourceStack"],
|
|
52761
|
+
outputStack: obj["outputStack"],
|
|
52762
|
+
mapping
|
|
52763
|
+
};
|
|
52764
|
+
if (Array.isArray(obj["_unmatched"])) {
|
|
52765
|
+
const cleaned = [];
|
|
52766
|
+
for (const entry of obj["_unmatched"]) {
|
|
52767
|
+
if (!entry || typeof entry !== "object") continue;
|
|
52768
|
+
const e = entry;
|
|
52769
|
+
if (typeof e["sourceLogicalId"] !== "string" || typeof e["resourceType"] !== "string" || !Array.isArray(e["candidates"]) || e["reason"] !== "no-match" && e["reason"] !== "logical-id-collision") continue;
|
|
52770
|
+
cleaned.push({
|
|
52771
|
+
sourceLogicalId: e["sourceLogicalId"],
|
|
52772
|
+
resourceType: e["resourceType"],
|
|
52773
|
+
candidates: e["candidates"].filter((c) => typeof c === "string"),
|
|
52774
|
+
reason: e["reason"]
|
|
52775
|
+
});
|
|
52776
|
+
}
|
|
52777
|
+
if (cleaned.length > 0) result._unmatched = cleaned;
|
|
52778
|
+
}
|
|
52779
|
+
return result;
|
|
52780
|
+
}
|
|
52781
|
+
|
|
52782
|
+
//#endregion
|
|
52783
|
+
//#region src/cli/commands/migrate-command.ts
|
|
52784
|
+
/**
|
|
52785
|
+
* `cdkd migrate --from-cfn-stack <name>` end-to-end orchestrator.
|
|
52786
|
+
*
|
|
52787
|
+
* Closes the CLI half of [#465](https://github.com/go-to-k/cdkd/issues/465).
|
|
52788
|
+
* Reads the source CFn stack, runs upstream `cdk migrate` via the PR A
|
|
52789
|
+
* library, builds the source → synth logical-ID mapping with the 2-pass
|
|
52790
|
+
* algorithm, writes a `cdkd-resource-mapping.json` audit file, prompts
|
|
52791
|
+
* for confirmation, invokes `cdkd import` to write state under the
|
|
52792
|
+
* synth logical IDs, and (when `--retire-cfn-stack` is set) finally
|
|
52793
|
+
* retires the source CFn stack so management responsibility transfers
|
|
52794
|
+
* fully to cdkd.
|
|
52795
|
+
*
|
|
52796
|
+
* AWS resources are never modified — the migration is a metadata
|
|
52797
|
+
* transfer only. See [docs/design/465-cfn-migrate.md](../../../docs/design/465-cfn-migrate.md) §7
|
|
52798
|
+
* for the post-migration state matrix.
|
|
52799
|
+
*/
|
|
52800
|
+
async function migrateCommandAction(positionalStack, options) {
|
|
52801
|
+
const logger = getLogger();
|
|
52802
|
+
if (options.verbose) {
|
|
52803
|
+
logger.setLevel("debug");
|
|
52804
|
+
process.env["CDKD_NO_LIVE"] = "1";
|
|
52805
|
+
}
|
|
52806
|
+
const sourceCfnStackName = options.fromCfnStack ?? positionalStack;
|
|
52807
|
+
if (!sourceCfnStackName) throw new LocalMigrateError("Missing required argument: --from-cfn-stack <name> (or pass the stack name positionally).");
|
|
52808
|
+
if (options.retireCfnStack && options.skipSynth) throw new LocalMigrateError("--retire-cfn-stack is incompatible with --skip-synth: the post-state-write retirement (UpdateStack + DeleteStack) cannot run without a synthesized template + cdkd state.");
|
|
52809
|
+
if (options.retireCfnStack && options.dryRun) throw new LocalMigrateError("--retire-cfn-stack is incompatible with --dry-run: retirement issues real AWS calls (UpdateStack injects Retain policies, then DeleteStack).");
|
|
52810
|
+
if (options.retireCfnStack && options.filter && options.filter.length > 0) throw new LocalMigrateError("--retire-cfn-stack is incompatible with --filter: a partial migration that retires the whole source CFn stack would strand the un-migrated resources. Drop one of the flags or migrate the rest of the stack first.");
|
|
52811
|
+
await applyRoleArnIfSet({
|
|
52812
|
+
roleArn: options.roleArn,
|
|
52813
|
+
region: options.region
|
|
52814
|
+
});
|
|
52815
|
+
const region = options.region || process.env["AWS_REGION"] || "us-east-1";
|
|
52816
|
+
const outputDir = resolve(options.outputDir ?? sourceCfnStackName);
|
|
52817
|
+
logger.info(`[migrate] Source CFn stack: ${sourceCfnStackName}`);
|
|
52818
|
+
logger.info(`[migrate] Output directory: ${outputDir}`);
|
|
52819
|
+
const libResult = await runMigrateLibrary({
|
|
52820
|
+
fromCfnStack: sourceCfnStackName,
|
|
52821
|
+
outputDir,
|
|
52822
|
+
language: options.language ?? "typescript",
|
|
52823
|
+
...options.region && { region: options.region },
|
|
52824
|
+
...options.account && { account: options.account },
|
|
52825
|
+
...options.filter && options.filter.length > 0 && { filters: options.filter },
|
|
52826
|
+
...options.profile && { profile: options.profile },
|
|
52827
|
+
...options.cdkBin && { cdkBinPath: options.cdkBin },
|
|
52828
|
+
skipInstall: options.skipInstall ?? false,
|
|
52829
|
+
skipSynth: options.skipSynth ?? false
|
|
52830
|
+
});
|
|
52831
|
+
if (options.skipSynth || !libResult.templateBody) {
|
|
52832
|
+
logger.info(`[migrate] --skip-synth: generated CDK app at ${libResult.outputDir}. Re-run without --skip-synth to write cdkd state.`);
|
|
52833
|
+
return;
|
|
52834
|
+
}
|
|
52835
|
+
let overrides;
|
|
52836
|
+
if (options.resourceMapping) {
|
|
52837
|
+
const mappingPath = resolve(options.resourceMapping);
|
|
52838
|
+
logger.info(`[migrate] Loading user-supplied resource mapping from ${mappingPath}`);
|
|
52839
|
+
overrides = readMappingFile(mappingPath).mapping;
|
|
52840
|
+
}
|
|
52841
|
+
let mappingResult;
|
|
52842
|
+
try {
|
|
52843
|
+
mappingResult = buildResourceMapping({
|
|
52844
|
+
sourceCfnTemplate: libResult.sourceCfnTemplate,
|
|
52845
|
+
synthTemplate: libResult.templateBody,
|
|
52846
|
+
sourceResources: libResult.sourceResources,
|
|
52847
|
+
...overrides && { overrides }
|
|
52848
|
+
});
|
|
52849
|
+
} catch (err) {
|
|
52850
|
+
throw new LocalMigrateError(err instanceof Error ? err.message : String(err));
|
|
52851
|
+
}
|
|
52852
|
+
const outputStack = sourceCfnStackName;
|
|
52853
|
+
const mappingFilePath = writeMappingFile(libResult.outputDir, {
|
|
52854
|
+
sourceStack: sourceCfnStackName,
|
|
52855
|
+
outputStack,
|
|
52856
|
+
result: mappingResult
|
|
52857
|
+
});
|
|
52858
|
+
logger.info(`[migrate] Resource mapping written to ${mappingFilePath}`);
|
|
52859
|
+
if (mappingResult.unmatched.length > 0) {
|
|
52860
|
+
const lines = mappingResult.unmatched.map((u) => {
|
|
52861
|
+
const candidatesStr = u.candidates.length > 0 ? ` (synth candidates of same Type: ${u.candidates.join(", ")})` : " (no synth resource has the same Type)";
|
|
52862
|
+
return ` - ${u.sourceLogicalId} (${u.resourceType}) [${u.reason}]${candidatesStr}`;
|
|
52863
|
+
});
|
|
52864
|
+
throw new LocalMigrateError(`Could not auto-map ${mappingResult.unmatched.length} of ${mappingResult.unmatched.length + mappingResult.pairs.length} source resource(s) to the synthesized CDK template:\n${lines.join("\n")}\n\nEdit '${mappingFilePath}'\nto add the correct '<sourceLogicalId>: <synthLogicalId>' pairs under "mapping", then re-run:\n cdkd migrate --from-cfn-stack ${sourceCfnStackName} --resource-mapping ${mappingFilePath} --output-dir ${libResult.outputDir}\n(The --output-dir override re-uses the already-generated CDK code; drop it if you let the default location be used.)`);
|
|
52865
|
+
}
|
|
52866
|
+
printMappingTable(mappingResult, sourceCfnStackName, outputStack);
|
|
52867
|
+
if (options.dryRun) {
|
|
52868
|
+
logger.info(`[migrate] --dry-run: would import ${mappingResult.pairs.length} resource(s) into cdkd state under stack '${outputStack}' (region '${region}'). State NOT written.`);
|
|
52869
|
+
return;
|
|
52870
|
+
}
|
|
52871
|
+
if (!options.yes) {
|
|
52872
|
+
if (!await promptConfirm(`Import ${mappingResult.pairs.length} resource(s) into cdkd state for stack '${outputStack}' (${region})?`)) {
|
|
52873
|
+
logger.info("[migrate] Cancelled by user. No state written.");
|
|
52874
|
+
return;
|
|
52875
|
+
}
|
|
52876
|
+
}
|
|
52877
|
+
const importMapping = {};
|
|
52878
|
+
for (const p of mappingResult.pairs) importMapping[p.synthLogicalId] = p.physicalId;
|
|
52879
|
+
const resolvedStateBucket = await resolveStateBucketWithDefault(options.stateBucket, region);
|
|
52880
|
+
await runImport(outputStack, {
|
|
52881
|
+
app: libResult.assemblyDir,
|
|
52882
|
+
statePrefix: options.statePrefix ?? "cdkd",
|
|
52883
|
+
resourceMappingInline: JSON.stringify(importMapping),
|
|
52884
|
+
auto: false,
|
|
52885
|
+
dryRun: false,
|
|
52886
|
+
yes: true,
|
|
52887
|
+
force: false,
|
|
52888
|
+
verbose: options.verbose ?? false,
|
|
52889
|
+
stateBucket: resolvedStateBucket,
|
|
52890
|
+
...options.region && { region: options.region },
|
|
52891
|
+
...options.profile && { profile: options.profile },
|
|
52892
|
+
...options.roleArn && { roleArn: options.roleArn }
|
|
52893
|
+
});
|
|
52894
|
+
if (options.retireCfnStack) {
|
|
52895
|
+
logger.info(`[migrate] Retiring source CloudFormation stack '${sourceCfnStackName}'...`);
|
|
52896
|
+
const cfnConfig = {};
|
|
52897
|
+
if (options.region) cfnConfig.region = options.region;
|
|
52898
|
+
if (options.profile) cfnConfig.profile = options.profile;
|
|
52899
|
+
const awsClients = new AwsClients(cfnConfig);
|
|
52900
|
+
const cfnClient = awsClients.cloudFormation;
|
|
52901
|
+
try {
|
|
52902
|
+
await retireCloudFormationStack({
|
|
52903
|
+
cfnStackName: sourceCfnStackName,
|
|
52904
|
+
cfnClient,
|
|
52905
|
+
yes: options.yes ?? false,
|
|
52906
|
+
stateBucket: resolvedStateBucket,
|
|
52907
|
+
...options.profile && { s3ClientOpts: { profile: options.profile } }
|
|
52908
|
+
});
|
|
52909
|
+
} finally {
|
|
52910
|
+
awsClients.destroy();
|
|
52911
|
+
}
|
|
52912
|
+
}
|
|
52913
|
+
logger.info(`[migrate] Migrated ${mappingResult.pairs.length} resource(s) from CloudFormation stack '${sourceCfnStackName}' to cdkd state for stack '${outputStack}'.`);
|
|
52914
|
+
logger.info(`[migrate] Generated CDK app at ${libResult.outputDir}`);
|
|
52915
|
+
if (!options.retireCfnStack) logger.info(`[migrate] Source CloudFormation stack '${sourceCfnStackName}' is unchanged. Re-run with --retire-cfn-stack to retire it (or do so manually later).`);
|
|
52916
|
+
}
|
|
52917
|
+
/**
|
|
52918
|
+
* Print a tabular `(sourceLogicalId → synthLogicalId physicalId)`
|
|
52919
|
+
* summary so users can see what's about to be imported before the
|
|
52920
|
+
* confirmation prompt. Matches the visual shape of `cdkd import`'s
|
|
52921
|
+
* summary table for consistency.
|
|
52922
|
+
*/
|
|
52923
|
+
function printMappingTable(result, sourceStack, outputStack) {
|
|
52924
|
+
const logger = getLogger();
|
|
52925
|
+
logger.info("");
|
|
52926
|
+
logger.info(`[migrate] Resolved mapping (${sourceStack} → ${outputStack}):`);
|
|
52927
|
+
for (const p of result.pairs) logger.info(` ${p.sourceLogicalId} → ${p.synthLogicalId} [${p.resourceType}] ${p.physicalId}`);
|
|
52928
|
+
logger.info("");
|
|
52929
|
+
}
|
|
52930
|
+
/**
|
|
52931
|
+
* Minimal `(y/N)` confirmation prompt using `node:readline`. Mirrors
|
|
52932
|
+
* the shape of [src/cli/commands/import.ts](import.ts)'s `confirmPrompt`
|
|
52933
|
+
* so the user UX is consistent across the import + migrate surfaces.
|
|
52934
|
+
* Non-TTY callers (CI) should pass `--yes` to skip this entirely.
|
|
52935
|
+
*/
|
|
52936
|
+
async function promptConfirm(message) {
|
|
52937
|
+
if (!process.stdin.isTTY) throw new LocalMigrateError(`Non-interactive shell detected and --yes was not supplied. Re-run with --yes to confirm: "${message}"`);
|
|
52938
|
+
const rl = (await import("node:readline/promises")).createInterface({
|
|
52939
|
+
input: process.stdin,
|
|
52940
|
+
output: process.stdout
|
|
52941
|
+
});
|
|
52942
|
+
try {
|
|
52943
|
+
const v = (await rl.question(`${message} (y/N) `)).trim().toLowerCase();
|
|
52944
|
+
return v === "y" || v === "yes";
|
|
52945
|
+
} finally {
|
|
52946
|
+
rl.close();
|
|
52947
|
+
}
|
|
52948
|
+
}
|
|
52949
|
+
/**
|
|
52950
|
+
* Commander factory for `cdkd migrate`. Registered in `src/cli/index.ts`
|
|
52951
|
+
* alongside the existing top-level commands. Matches the design doc §3
|
|
52952
|
+
* flag set; the action handler routes through {@link migrateCommandAction}
|
|
52953
|
+
* via `withErrorHandling` so library callers see exceptions but CLI
|
|
52954
|
+
* callers get the standard exit-code-2 routing.
|
|
52955
|
+
*/
|
|
52956
|
+
function createMigrateCommand() {
|
|
52957
|
+
return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default("cdkd")).addOption(new Option("--profile <name>", "AWS profile name.")).addOption(new Option("--role-arn <arn>", "IAM role to assume before any AWS call.")).addOption(new Option("--verbose", "Enable debug-level logging.").default(false)).action(withErrorHandling(migrateCommandAction));
|
|
52958
|
+
}
|
|
52959
|
+
|
|
51862
52960
|
//#endregion
|
|
51863
52961
|
//#region src/cli/index.ts
|
|
51864
52962
|
const SUBCOMMANDS = new Set([
|
|
@@ -51876,7 +52974,8 @@ const SUBCOMMANDS = new Set([
|
|
|
51876
52974
|
"publish-assets",
|
|
51877
52975
|
"force-unlock",
|
|
51878
52976
|
"state",
|
|
51879
|
-
"local"
|
|
52977
|
+
"local",
|
|
52978
|
+
"migrate"
|
|
51880
52979
|
]);
|
|
51881
52980
|
/**
|
|
51882
52981
|
* Reorder args so options before the subcommand are moved after it.
|
|
@@ -51900,7 +52999,7 @@ function reorderArgs(argv) {
|
|
|
51900
52999
|
*/
|
|
51901
53000
|
async function main() {
|
|
51902
53001
|
const program = new Command();
|
|
51903
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
53002
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.135.1");
|
|
51904
53003
|
program.addCommand(createBootstrapCommand());
|
|
51905
53004
|
program.addCommand(createSynthCommand());
|
|
51906
53005
|
program.addCommand(createListCommand());
|
|
@@ -51915,6 +53014,7 @@ async function main() {
|
|
|
51915
53014
|
program.addCommand(createStateCommand());
|
|
51916
53015
|
program.addCommand(createLocalCommand());
|
|
51917
53016
|
program.addCommand(createExportCommand());
|
|
53017
|
+
program.addCommand(createMigrateCommand());
|
|
51918
53018
|
const args = reorderArgs(process.argv);
|
|
51919
53019
|
await program.parseAsync(args);
|
|
51920
53020
|
}
|