@go-to-k/cdkd 0.138.0 → 0.139.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/README.md +17 -5
- package/dist/cli.js +814 -117
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -38097,6 +38097,8 @@ function parseContainerDefinition(raw, idx, taskLogicalId, resources, stack, con
|
|
|
38097
38097
|
protocol: pickString(p["Protocol"]) === "udp" ? "udp" : "tcp"
|
|
38098
38098
|
};
|
|
38099
38099
|
if (hostPort !== void 0) pm.hostPort = hostPort;
|
|
38100
|
+
const portName = typeof p["Name"] === "string" ? p["Name"] : void 0;
|
|
38101
|
+
if (portName !== void 0) pm.name = portName;
|
|
38100
38102
|
portMappings.push(pm);
|
|
38101
38103
|
}
|
|
38102
38104
|
const mountPoints = [];
|
|
@@ -38745,7 +38747,7 @@ function resolveRuntimeCodeMountPath(runtime) {
|
|
|
38745
38747
|
|
|
38746
38748
|
//#endregion
|
|
38747
38749
|
//#region src/local/docker-runner.ts
|
|
38748
|
-
const execFileAsync$
|
|
38750
|
+
const execFileAsync$4 = promisify(execFile);
|
|
38749
38751
|
/**
|
|
38750
38752
|
* Wraps `docker pull` / `docker run` / `docker rm` for `cdkd local invoke`.
|
|
38751
38753
|
*
|
|
@@ -38838,7 +38840,7 @@ async function runDetached(opts) {
|
|
|
38838
38840
|
args.push(opts.image, ...entryPointTail, ...opts.cmd);
|
|
38839
38841
|
getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
|
|
38840
38842
|
try {
|
|
38841
|
-
const { stdout } = await execFileAsync$
|
|
38843
|
+
const { stdout } = await execFileAsync$4(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
|
|
38842
38844
|
return stdout.trim();
|
|
38843
38845
|
} catch (error) {
|
|
38844
38846
|
const err = error;
|
|
@@ -38875,7 +38877,7 @@ async function removeContainer(containerId) {
|
|
|
38875
38877
|
if (!containerId) return;
|
|
38876
38878
|
const logger = getLogger().child("docker");
|
|
38877
38879
|
try {
|
|
38878
|
-
await execFileAsync$
|
|
38880
|
+
await execFileAsync$4(getDockerCmd(), [
|
|
38879
38881
|
"rm",
|
|
38880
38882
|
"-f",
|
|
38881
38883
|
containerId
|
|
@@ -38894,7 +38896,7 @@ async function removeContainer(containerId) {
|
|
|
38894
38896
|
async function ensureDockerAvailable() {
|
|
38895
38897
|
const cmd = getDockerCmd();
|
|
38896
38898
|
try {
|
|
38897
|
-
await execFileAsync$
|
|
38899
|
+
await execFileAsync$4(cmd, [
|
|
38898
38900
|
"version",
|
|
38899
38901
|
"--format",
|
|
38900
38902
|
"{{.Server.Version}}"
|
|
@@ -49340,7 +49342,7 @@ function createLocalStartApiCommand() {
|
|
|
49340
49342
|
|
|
49341
49343
|
//#endregion
|
|
49342
49344
|
//#region src/local/ecs-network.ts
|
|
49343
|
-
const execFileAsync$
|
|
49345
|
+
const execFileAsync$3 = promisify(execFile);
|
|
49344
49346
|
/**
|
|
49345
49347
|
* Docker network + AWS-published metadata-endpoints sidecar lifecycle for
|
|
49346
49348
|
* `cdkd local run-task`. The sidecar (a small Go binary maintained by
|
|
@@ -49358,8 +49360,10 @@ const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:lat
|
|
|
49358
49360
|
* matches the documented AWS task-metadata endpoint address. Containers
|
|
49359
49361
|
* inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
|
|
49360
49362
|
* to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
|
|
49361
|
-
* start-service`
|
|
49362
|
-
*
|
|
49363
|
+
* start-service` creates ONE shared network at CLI startup (design
|
|
49364
|
+
* § 5 Option A) — the shared sidecar lives at `169.254.171.2` (see
|
|
49365
|
+
* `SHARED_SVC_SUBNET_OCTET` below), one octet up so the two CLI
|
|
49366
|
+
* variants can run on the same host without bridge-pool collision.
|
|
49363
49367
|
*/
|
|
49364
49368
|
const METADATA_ENDPOINT_IP = "169.254.170.2";
|
|
49365
49369
|
/** Default subnet — used when no `subnetOctet` override is supplied. */
|
|
@@ -49387,23 +49391,58 @@ function buildEndpointSubnet(subnetOctet) {
|
|
|
49387
49391
|
};
|
|
49388
49392
|
}
|
|
49389
49393
|
/**
|
|
49390
|
-
*
|
|
49391
|
-
*
|
|
49392
|
-
*
|
|
49393
|
-
*
|
|
49394
|
+
* Subnet octet for the shared-service docker network used by
|
|
49395
|
+
* `cdkd local start-service`. One octet up from `cdkd local run-task`'s
|
|
49396
|
+
* default (170 → 171) so the two CLI variants can run on the same host
|
|
49397
|
+
* without docker rejecting the second `--subnet`. The shared-service
|
|
49398
|
+
* network reuses the same `createTaskNetwork` machinery; the sidecar at
|
|
49399
|
+
* `169.254.171.2` serves the same metadata-endpoint API to every
|
|
49400
|
+
* container that joins this one network.
|
|
49394
49401
|
*/
|
|
49395
|
-
|
|
49402
|
+
const SHARED_SVC_SUBNET_OCTET = 171;
|
|
49403
|
+
/**
|
|
49404
|
+
* Create the one shared docker network + metadata-endpoints sidecar
|
|
49405
|
+
* used by every service-replica boot in a single
|
|
49406
|
+
* `cdkd local start-service` invocation. This is design doc § 5
|
|
49407
|
+
* Option A — one network per CLI invocation instead of one network
|
|
49408
|
+
* per task — so peer services can reach each other by IP / network
|
|
49409
|
+
* alias without docker `--network connect` choreography (Option B,
|
|
49410
|
+
* rejected in design § 5 as "unwieldy and racy"). The returned
|
|
49411
|
+
* `TaskNetwork` carries `ownedByCaller: true` so `cleanupEcsRun()`
|
|
49412
|
+
* (called per replica by the service runner) does NOT teardown — the
|
|
49413
|
+
* CLI tears down ONCE at the end of the run.
|
|
49414
|
+
*/
|
|
49415
|
+
async function createSharedSvcNetwork(options = {}) {
|
|
49416
|
+
const networkName = `${options.prefix ?? "cdkd-local"}-svc-${randomBytes(4).toString("hex")}`;
|
|
49417
|
+
const { cidr, sidecarIp } = buildEndpointSubnet(171);
|
|
49418
|
+
return {
|
|
49419
|
+
networkName,
|
|
49420
|
+
sidecarContainerId: await createNetworkAndSidecar({
|
|
49421
|
+
networkName,
|
|
49422
|
+
cidr,
|
|
49423
|
+
sidecarIp,
|
|
49424
|
+
skipPull: options.skipPull ?? false,
|
|
49425
|
+
...options.credentials !== void 0 ? { credentials: options.credentials } : {},
|
|
49426
|
+
...options.cluster !== void 0 ? { cluster: options.cluster } : {}
|
|
49427
|
+
}),
|
|
49428
|
+
sidecarIp,
|
|
49429
|
+
ownedByCaller: true
|
|
49430
|
+
};
|
|
49431
|
+
}
|
|
49432
|
+
/**
|
|
49433
|
+
* Internal helper shared by `createTaskNetwork` (per-task) and
|
|
49434
|
+
* `createSharedSvcNetwork` (per-CLI-run). Creates the docker network,
|
|
49435
|
+
* pulls the sidecar image, and starts the sidecar at the documented
|
|
49436
|
+
* IP. Throws `DockerRunnerError` with a hint when the network already
|
|
49437
|
+
* exists (the typical "leftover from previous run" path).
|
|
49438
|
+
*/
|
|
49439
|
+
async function createNetworkAndSidecar(args) {
|
|
49396
49440
|
const logger = getLogger().child("ecs-network");
|
|
49397
|
-
const networkName
|
|
49398
|
-
|
|
49399
|
-
const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
|
|
49400
|
-
cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
|
|
49401
|
-
sidecarIp: METADATA_ENDPOINT_IP
|
|
49402
|
-
} : buildEndpointSubnet(subnetOctet);
|
|
49403
|
-
await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
|
|
49441
|
+
const { networkName, cidr, sidecarIp, credentials, cluster, skipPull } = args;
|
|
49442
|
+
await pullImage(METADATA_ENDPOINT_IMAGE, skipPull);
|
|
49404
49443
|
logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
|
|
49405
49444
|
try {
|
|
49406
|
-
await execFileAsync$
|
|
49445
|
+
await execFileAsync$3(getDockerCmd(), [
|
|
49407
49446
|
"network",
|
|
49408
49447
|
"create",
|
|
49409
49448
|
"--driver",
|
|
@@ -49414,7 +49453,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
49414
49453
|
]);
|
|
49415
49454
|
} catch (err) {
|
|
49416
49455
|
const e = err;
|
|
49417
|
-
throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run
|
|
49456
|
+
throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` shares one network across every service in the run; bare \`cdkd local run-task\` uses a per-task network so only one run can be active at a time.`);
|
|
49418
49457
|
}
|
|
49419
49458
|
const sidecarArgs = [
|
|
49420
49459
|
"run",
|
|
@@ -49428,27 +49467,46 @@ async function createTaskNetwork(options = {}) {
|
|
|
49428
49467
|
sidecarIp
|
|
49429
49468
|
];
|
|
49430
49469
|
const sidecarEnv = {};
|
|
49431
|
-
if (
|
|
49432
|
-
sidecarEnv["AWS_ACCESS_KEY_ID"] =
|
|
49433
|
-
sidecarEnv["AWS_SECRET_ACCESS_KEY"] =
|
|
49434
|
-
if (
|
|
49470
|
+
if (credentials) {
|
|
49471
|
+
sidecarEnv["AWS_ACCESS_KEY_ID"] = credentials.accessKeyId;
|
|
49472
|
+
sidecarEnv["AWS_SECRET_ACCESS_KEY"] = credentials.secretAccessKey;
|
|
49473
|
+
if (credentials.sessionToken) sidecarEnv["AWS_SESSION_TOKEN"] = credentials.sessionToken;
|
|
49435
49474
|
}
|
|
49436
|
-
if (
|
|
49475
|
+
if (cluster) sidecarEnv["CLUSTER"] = cluster;
|
|
49437
49476
|
for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
|
|
49438
49477
|
sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
|
|
49439
49478
|
logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
|
|
49440
|
-
let sidecarContainerId;
|
|
49441
49479
|
try {
|
|
49442
|
-
const { stdout } = await execFileAsync$
|
|
49443
|
-
|
|
49480
|
+
const { stdout } = await execFileAsync$3(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
|
|
49481
|
+
return stdout.trim();
|
|
49444
49482
|
} catch (err) {
|
|
49445
49483
|
await destroyNetworkOnly(networkName);
|
|
49446
49484
|
const e = err;
|
|
49447
49485
|
throw new DockerRunnerError(`Failed to start metadata-endpoints sidecar: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
49448
49486
|
}
|
|
49487
|
+
}
|
|
49488
|
+
/**
|
|
49489
|
+
* Create the per-task docker network + start the metadata-endpoints
|
|
49490
|
+
* sidecar. The sidecar must come up at the well-known address BEFORE any
|
|
49491
|
+
* user container starts so the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
|
|
49492
|
+
* lookup at container start doesn't race.
|
|
49493
|
+
*/
|
|
49494
|
+
async function createTaskNetwork(options = {}) {
|
|
49495
|
+
const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
|
|
49496
|
+
const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
|
|
49497
|
+
cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
|
|
49498
|
+
sidecarIp: METADATA_ENDPOINT_IP
|
|
49499
|
+
} : buildEndpointSubnet(options.subnetOctet);
|
|
49449
49500
|
return {
|
|
49450
49501
|
networkName,
|
|
49451
|
-
sidecarContainerId
|
|
49502
|
+
sidecarContainerId: await createNetworkAndSidecar({
|
|
49503
|
+
networkName,
|
|
49504
|
+
cidr,
|
|
49505
|
+
sidecarIp,
|
|
49506
|
+
skipPull: options.skipPull ?? false,
|
|
49507
|
+
...options.credentials !== void 0 ? { credentials: options.credentials } : {},
|
|
49508
|
+
...options.cluster !== void 0 ? { cluster: options.cluster } : {}
|
|
49509
|
+
}),
|
|
49452
49510
|
sidecarIp
|
|
49453
49511
|
};
|
|
49454
49512
|
}
|
|
@@ -49487,7 +49545,7 @@ async function destroyNetworkOnly(networkName) {
|
|
|
49487
49545
|
if (!networkName) return;
|
|
49488
49546
|
const logger = getLogger().child("ecs-network");
|
|
49489
49547
|
try {
|
|
49490
|
-
await execFileAsync$
|
|
49548
|
+
await execFileAsync$3(getDockerCmd(), [
|
|
49491
49549
|
"network",
|
|
49492
49550
|
"rm",
|
|
49493
49551
|
networkName
|
|
@@ -49620,7 +49678,7 @@ async function resolveSsm(entry, shape, client) {
|
|
|
49620
49678
|
|
|
49621
49679
|
//#endregion
|
|
49622
49680
|
//#region src/local/ecs-task-runner.ts
|
|
49623
|
-
const execFileAsync$
|
|
49681
|
+
const execFileAsync$2 = promisify(execFile);
|
|
49624
49682
|
/**
|
|
49625
49683
|
* Top-level orchestrator for `cdkd local run-task`. Coordinates image
|
|
49626
49684
|
* preparation, secret resolution, docker-network bring-up, container
|
|
@@ -49677,10 +49735,10 @@ async function cleanupEcsRun(state, options) {
|
|
|
49677
49735
|
}
|
|
49678
49736
|
state.startedContainers = [];
|
|
49679
49737
|
}
|
|
49680
|
-
await destroyTaskNetwork(state.network);
|
|
49738
|
+
if (state.network && !state.network.ownedByCaller) await destroyTaskNetwork(state.network);
|
|
49681
49739
|
state.network = void 0;
|
|
49682
49740
|
for (const v of state.dockerVolumeNames) try {
|
|
49683
|
-
await execFileAsync$
|
|
49741
|
+
await execFileAsync$2(getDockerCmd(), [
|
|
49684
49742
|
"volume",
|
|
49685
49743
|
"rm",
|
|
49686
49744
|
v
|
|
@@ -49710,14 +49768,20 @@ async function runEcsTask(task, options, state) {
|
|
|
49710
49768
|
valueFrom: s.valueFrom
|
|
49711
49769
|
});
|
|
49712
49770
|
const secretsByContainer = groupSecretsByContainer(await resolveEcsSecrets(allSecrets, { ...options.region !== void 0 && { region: options.region } }));
|
|
49713
|
-
|
|
49714
|
-
|
|
49715
|
-
|
|
49716
|
-
};
|
|
49717
|
-
|
|
49718
|
-
|
|
49719
|
-
|
|
49720
|
-
|
|
49771
|
+
if (options.existingNetwork) state.network = {
|
|
49772
|
+
...options.existingNetwork,
|
|
49773
|
+
ownedByCaller: true
|
|
49774
|
+
};
|
|
49775
|
+
else {
|
|
49776
|
+
const netCreateOpts = {
|
|
49777
|
+
prefix: options.cluster,
|
|
49778
|
+
skipPull: options.skipPull
|
|
49779
|
+
};
|
|
49780
|
+
if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
|
|
49781
|
+
if (options.cluster) netCreateOpts.cluster = options.cluster;
|
|
49782
|
+
if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
|
|
49783
|
+
state.network = await createTaskNetwork(netCreateOpts);
|
|
49784
|
+
}
|
|
49721
49785
|
const volumeByName = await realizeDockerVolumes(task.volumes, state);
|
|
49722
49786
|
const dockerCmds = /* @__PURE__ */ new Map();
|
|
49723
49787
|
for (const container of task.containers) {
|
|
@@ -49735,7 +49799,9 @@ async function runEcsTask(task, options, state) {
|
|
|
49735
49799
|
roleArn: options.taskRoleArn,
|
|
49736
49800
|
platformOverride: options.platformOverride,
|
|
49737
49801
|
region: options.region,
|
|
49738
|
-
sidecarIp: state.network.sidecarIp
|
|
49802
|
+
sidecarIp: state.network.sidecarIp,
|
|
49803
|
+
...options.addHostFlags && options.addHostFlags.length > 0 ? { addHostFlags: options.addHostFlags } : {},
|
|
49804
|
+
...(options.networkAliasesByContainer?.get(container.name)?.length ?? 0) > 0 ? { networkAliases: options.networkAliasesByContainer.get(container.name) } : {}
|
|
49739
49805
|
}));
|
|
49740
49806
|
}
|
|
49741
49807
|
const startedByName = /* @__PURE__ */ new Map();
|
|
@@ -49746,7 +49812,7 @@ async function runEcsTask(task, options, state) {
|
|
|
49746
49812
|
logger.info(`Starting container '${container.name}' (image=${imagePlan.get(container.name)})`);
|
|
49747
49813
|
let id;
|
|
49748
49814
|
try {
|
|
49749
|
-
const { stdout } = await execFileAsync$
|
|
49815
|
+
const { stdout } = await execFileAsync$2(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
|
|
49750
49816
|
id = stdout.trim();
|
|
49751
49817
|
} catch (err) {
|
|
49752
49818
|
const e = err;
|
|
@@ -49855,7 +49921,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
49855
49921
|
let lastStatus = "";
|
|
49856
49922
|
while (Date.now() < deadline) {
|
|
49857
49923
|
try {
|
|
49858
|
-
const { stdout } = await execFileAsync$
|
|
49924
|
+
const { stdout } = await execFileAsync$2(getDockerCmd(), [
|
|
49859
49925
|
"inspect",
|
|
49860
49926
|
"--format",
|
|
49861
49927
|
"{{.State.Health.Status}}",
|
|
@@ -49878,7 +49944,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
49878
49944
|
}
|
|
49879
49945
|
async function waitForContainerExit(containerId) {
|
|
49880
49946
|
try {
|
|
49881
|
-
const { stdout } = await execFileAsync$
|
|
49947
|
+
const { stdout } = await execFileAsync$2(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
|
|
49882
49948
|
const code = Number.parseInt(stdout.trim(), 10);
|
|
49883
49949
|
return Number.isFinite(code) ? code : 1;
|
|
49884
49950
|
} catch (err) {
|
|
@@ -49888,7 +49954,7 @@ async function waitForContainerExit(containerId) {
|
|
|
49888
49954
|
}
|
|
49889
49955
|
async function stopContainer(containerId, graceSeconds) {
|
|
49890
49956
|
try {
|
|
49891
|
-
await execFileAsync$
|
|
49957
|
+
await execFileAsync$2(getDockerCmd(), [
|
|
49892
49958
|
"stop",
|
|
49893
49959
|
"-t",
|
|
49894
49960
|
String(graceSeconds),
|
|
@@ -50013,7 +50079,7 @@ async function realizeDockerVolumes(volumes, state) {
|
|
|
50013
50079
|
const dockerVolumeName = `cdkd-local-${v.name}-${randHex(4)}`;
|
|
50014
50080
|
args.push(dockerVolumeName);
|
|
50015
50081
|
try {
|
|
50016
|
-
await execFileAsync$
|
|
50082
|
+
await execFileAsync$2(getDockerCmd(), args);
|
|
50017
50083
|
state.dockerVolumeNames.push(dockerVolumeName);
|
|
50018
50084
|
logger.debug(`Created docker volume ${dockerVolumeName} for task volume '${v.name}'`);
|
|
50019
50085
|
} catch (err) {
|
|
@@ -50053,6 +50119,14 @@ function buildDockerRunArgs(opts) {
|
|
|
50053
50119
|
args.push("--name", `cdkd-local-${task.family}-${container.name}-${randHex(3)}`);
|
|
50054
50120
|
args.push("--network", network);
|
|
50055
50121
|
args.push("--network-alias", container.name);
|
|
50122
|
+
if (opts.networkAliases && opts.networkAliases.length > 0) {
|
|
50123
|
+
const seen = new Set([container.name]);
|
|
50124
|
+
for (const a of opts.networkAliases) if (!seen.has(a)) {
|
|
50125
|
+
args.push("--network-alias", a);
|
|
50126
|
+
seen.add(a);
|
|
50127
|
+
}
|
|
50128
|
+
}
|
|
50129
|
+
if (opts.addHostFlags && opts.addHostFlags.length > 0) for (const f of opts.addHostFlags) args.push(f);
|
|
50056
50130
|
if (opts.platformOverride) args.push("--platform", opts.platformOverride);
|
|
50057
50131
|
else if (task.runtimePlatform) args.push("--platform", task.runtimePlatform.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64");
|
|
50058
50132
|
for (const pm of container.portMappings) {
|
|
@@ -50459,8 +50533,8 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
|
|
|
50459
50533
|
const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
|
|
50460
50534
|
const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
|
|
50461
50535
|
if (Array.isArray(props["LoadBalancers"]) && props["LoadBalancers"].length > 0) warnings.push(`ECS Service '${serviceLogicalId}' declares LoadBalancers, but local load-balancer emulation is deferred to a follow-up PR. Containers are NOT registered to a local listener; reach them via their published ports.`);
|
|
50462
|
-
|
|
50463
|
-
|
|
50536
|
+
const serviceConnect = extractServiceConnect(props["ServiceConnectConfiguration"], task);
|
|
50537
|
+
const out = {
|
|
50464
50538
|
stack,
|
|
50465
50539
|
serviceLogicalId,
|
|
50466
50540
|
resource,
|
|
@@ -50468,8 +50542,103 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
|
|
|
50468
50542
|
desiredCount,
|
|
50469
50543
|
healthCheckGracePeriodSeconds,
|
|
50470
50544
|
task,
|
|
50545
|
+
serviceRegistries: extractServiceRegistries(props["ServiceRegistries"]),
|
|
50471
50546
|
warnings
|
|
50472
50547
|
};
|
|
50548
|
+
if (serviceConnect) out.serviceConnect = serviceConnect;
|
|
50549
|
+
return out;
|
|
50550
|
+
}
|
|
50551
|
+
/**
|
|
50552
|
+
* Parse `ServiceConnectConfiguration` against the producer TaskDef.
|
|
50553
|
+
* Returns `undefined` when the block is missing OR `Enabled: false`.
|
|
50554
|
+
*
|
|
50555
|
+
* Reject conditions (surface as resolver-time errors so the user sees
|
|
50556
|
+
* them BEFORE the docker network is created):
|
|
50557
|
+
* - `Namespace` is not a literal string. CDK 2.x always emits a
|
|
50558
|
+
* literal string here (verified 2026-05-22); cross-stack /
|
|
50559
|
+
* intrinsic shapes are out of scope.
|
|
50560
|
+
* - `Services[].PortName` doesn't match any of the TaskDef's
|
|
50561
|
+
* `ContainerDefinitions[].PortMappings[].Name` entries.
|
|
50562
|
+
*
|
|
50563
|
+
* Note on `clientAliases[]` shape: each ClientAlias can declare a
|
|
50564
|
+
* `DnsName` (the bare short-name peers connect to, e.g. `orders`) AND
|
|
50565
|
+
* a `Port` (the listening port the alias maps to inside the consumer).
|
|
50566
|
+
* cdkd surfaces both verbatim; the registry / `--add-host` overlay
|
|
50567
|
+
* publishes each `DnsName` as a bare alias pointing at the same IP as
|
|
50568
|
+
* the canonical fqdn.
|
|
50569
|
+
*/
|
|
50570
|
+
function extractServiceConnect(raw, task) {
|
|
50571
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
50572
|
+
const cfg = raw;
|
|
50573
|
+
if (cfg["Enabled"] === false) return void 0;
|
|
50574
|
+
const namespaceName = pickServiceConnectNamespace(cfg["Namespace"]);
|
|
50575
|
+
if (!namespaceName) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Namespace must be a literal string (the Cloud Map namespace name like 'cdkd-local.local'); got ${JSON.stringify(cfg["Namespace"])}. Intrinsic / cross-stack namespace references are not supported in v1.`);
|
|
50576
|
+
const rawServices = cfg["Services"];
|
|
50577
|
+
if (!Array.isArray(rawServices) || rawServices.length === 0) return {
|
|
50578
|
+
namespaceName,
|
|
50579
|
+
services: []
|
|
50580
|
+
};
|
|
50581
|
+
const portByName = /* @__PURE__ */ new Map();
|
|
50582
|
+
for (const c of task.containers) for (const pm of c.portMappings) if (pm.name) portByName.set(pm.name, pm.containerPort);
|
|
50583
|
+
const services = [];
|
|
50584
|
+
for (const entry of rawServices) {
|
|
50585
|
+
if (!entry || typeof entry !== "object") continue;
|
|
50586
|
+
const e = entry;
|
|
50587
|
+
const portName = typeof e["PortName"] === "string" ? e["PortName"] : void 0;
|
|
50588
|
+
if (!portName) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Services[] entry has no PortName: ${JSON.stringify(entry)}. Every Service entry must reference a producer-side PortMappings[].Name.`);
|
|
50589
|
+
const containerPort = portByName.get(portName);
|
|
50590
|
+
if (containerPort === void 0) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Services[].PortName='${portName}' does not match any PortMappings[].Name on the producer TaskDef (available: ${[...portByName.keys()].join(", ") || "(none)"}).`);
|
|
50591
|
+
const clientAliases = [];
|
|
50592
|
+
if (Array.isArray(e["ClientAliases"])) for (const ca of e["ClientAliases"]) {
|
|
50593
|
+
if (!ca || typeof ca !== "object") continue;
|
|
50594
|
+
const caObj = ca;
|
|
50595
|
+
const dnsName = typeof caObj["DnsName"] === "string" ? caObj["DnsName"] : void 0;
|
|
50596
|
+
const aliasEntry = { port: typeof caObj["Port"] === "number" ? caObj["Port"] : containerPort };
|
|
50597
|
+
if (dnsName !== void 0) aliasEntry.dnsName = dnsName;
|
|
50598
|
+
clientAliases.push(aliasEntry);
|
|
50599
|
+
}
|
|
50600
|
+
const discoveryName = clientAliases.find((c) => c.dnsName !== void 0)?.dnsName ?? portName;
|
|
50601
|
+
services.push({
|
|
50602
|
+
portName,
|
|
50603
|
+
containerPort,
|
|
50604
|
+
discoveryName,
|
|
50605
|
+
clientAliases
|
|
50606
|
+
});
|
|
50607
|
+
}
|
|
50608
|
+
return {
|
|
50609
|
+
namespaceName,
|
|
50610
|
+
services
|
|
50611
|
+
};
|
|
50612
|
+
}
|
|
50613
|
+
/**
|
|
50614
|
+
* Parse `ServiceRegistries[]`. Each entry's `RegistryArn` is the
|
|
50615
|
+
* canonical `Fn::GetAtt: [<CloudMapServiceLogicalId>, 'Arn']` shape;
|
|
50616
|
+
* cdkd surfaces the logical id (the AWS-side ARN is irrelevant
|
|
50617
|
+
* locally — the registry is in-process).
|
|
50618
|
+
*/
|
|
50619
|
+
function extractServiceRegistries(raw) {
|
|
50620
|
+
if (!Array.isArray(raw)) return [];
|
|
50621
|
+
const out = [];
|
|
50622
|
+
for (const entry of raw) {
|
|
50623
|
+
if (!entry || typeof entry !== "object") continue;
|
|
50624
|
+
const e = entry;
|
|
50625
|
+
const registryArn = e["RegistryArn"];
|
|
50626
|
+
let cloudMapServiceLogicalId;
|
|
50627
|
+
if (typeof registryArn === "string") continue;
|
|
50628
|
+
if (registryArn && typeof registryArn === "object" && !Array.isArray(registryArn)) {
|
|
50629
|
+
const getAtt = registryArn["Fn::GetAtt"];
|
|
50630
|
+
if (Array.isArray(getAtt) && typeof getAtt[0] === "string") cloudMapServiceLogicalId = getAtt[0];
|
|
50631
|
+
}
|
|
50632
|
+
if (!cloudMapServiceLogicalId) continue;
|
|
50633
|
+
const reg = { cloudMapServiceLogicalId };
|
|
50634
|
+
if (typeof e["ContainerName"] === "string") reg.containerName = e["ContainerName"];
|
|
50635
|
+
if (typeof e["ContainerPort"] === "number") reg.containerPort = e["ContainerPort"];
|
|
50636
|
+
out.push(reg);
|
|
50637
|
+
}
|
|
50638
|
+
return out;
|
|
50639
|
+
}
|
|
50640
|
+
function pickServiceConnectNamespace(raw) {
|
|
50641
|
+
if (typeof raw === "string" && raw.length > 0) return raw;
|
|
50473
50642
|
}
|
|
50474
50643
|
/**
|
|
50475
50644
|
* Resolve `Properties.TaskDefinition` to a logical id in the same stack.
|
|
@@ -50534,6 +50703,42 @@ function notFoundError(target, stack, resources) {
|
|
|
50534
50703
|
return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
|
|
50535
50704
|
}
|
|
50536
50705
|
|
|
50706
|
+
//#endregion
|
|
50707
|
+
//#region src/local/docker-inspect.ts
|
|
50708
|
+
const execFileAsync$1 = promisify(execFile);
|
|
50709
|
+
/**
|
|
50710
|
+
* Phase 3 of #262 (Issue #460) helper — query the docker network IP
|
|
50711
|
+
* assigned to a freshly-started container so the Cloud Map registry
|
|
50712
|
+
* can publish reachable endpoints for peer discovery.
|
|
50713
|
+
*
|
|
50714
|
+
* Returns `undefined` when:
|
|
50715
|
+
* - The container is not found (docker rm raced us).
|
|
50716
|
+
* - The container is not attached to the named network.
|
|
50717
|
+
* - The IP is empty (docker hasn't fully wired the network yet —
|
|
50718
|
+
* caller should retry-or-skip; the helper deliberately does NOT
|
|
50719
|
+
* embed a retry loop to keep the caller in charge of timing).
|
|
50720
|
+
*
|
|
50721
|
+
* Errors propagate verbatim to the caller (`DockerRunnerError`-style
|
|
50722
|
+
* wrapping is the caller's concern; this helper is structurally a
|
|
50723
|
+
* simple inspect wrapper).
|
|
50724
|
+
*/
|
|
50725
|
+
async function getContainerNetworkIp(containerId, networkName) {
|
|
50726
|
+
const format = `{{with index .NetworkSettings.Networks "${networkName}"}}{{.IPAddress}}{{end}}`;
|
|
50727
|
+
try {
|
|
50728
|
+
const { stdout } = await execFileAsync$1(getDockerCmd(), [
|
|
50729
|
+
"inspect",
|
|
50730
|
+
"--format",
|
|
50731
|
+
format,
|
|
50732
|
+
containerId
|
|
50733
|
+
]);
|
|
50734
|
+
const ip = stdout.trim();
|
|
50735
|
+
if (!ip) return void 0;
|
|
50736
|
+
return ip;
|
|
50737
|
+
} catch {
|
|
50738
|
+
return;
|
|
50739
|
+
}
|
|
50740
|
+
}
|
|
50741
|
+
|
|
50537
50742
|
//#endregion
|
|
50538
50743
|
//#region src/local/ecs-service-runner.ts
|
|
50539
50744
|
/**
|
|
@@ -50551,10 +50756,20 @@ function notFoundError(target, stack, resources) {
|
|
|
50551
50756
|
* stops compounding cleanup work.
|
|
50552
50757
|
* - Long-running lifecycle (returns only on shutdown).
|
|
50553
50758
|
*
|
|
50759
|
+
* Phase 3 of #262 (Issue #460) — Cloud Map / Service Connect peer
|
|
50760
|
+
* discovery is wired through `ServiceRunnerOptions.discovery`. When
|
|
50761
|
+
* supplied, every booted replica discovers its docker IP, registers
|
|
50762
|
+
* itself into the shared in-process `CloudMapRegistry`, and emits
|
|
50763
|
+
* `--add-host` flags so consumer containers reach peer services via
|
|
50764
|
+
* the canonical `<discoveryName>.<namespace>` fqdn. Envoy L7 sidecar
|
|
50765
|
+
* emulation (design Layer B) is deferred to a follow-up PR per the
|
|
50766
|
+
* design's §O5 "--no-envoy by default" recommendation.
|
|
50767
|
+
*
|
|
50554
50768
|
* Deferred to follow-up PRs:
|
|
50555
50769
|
* - Local load-balancer emulation (LB listener + target-group health
|
|
50556
50770
|
* check + round-robin) — separate PR per the issue's PR-split.
|
|
50557
|
-
* - Service Connect
|
|
50771
|
+
* - Envoy sidecar for Service Connect L7 routing / retries / circuit
|
|
50772
|
+
* breaking (Cloud Map DNS-only mode ships now).
|
|
50558
50773
|
* - Rolling deployment (`--reload` / `--watch`).
|
|
50559
50774
|
*/
|
|
50560
50775
|
var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
|
|
@@ -50620,7 +50835,8 @@ async function startEcsService(service, options, runState) {
|
|
|
50620
50835
|
state: createEcsRunState(),
|
|
50621
50836
|
restartCount: 0,
|
|
50622
50837
|
shuttingDown: false,
|
|
50623
|
-
inFlightBoot: void 0
|
|
50838
|
+
inFlightBoot: void 0,
|
|
50839
|
+
cloudMapHandles: []
|
|
50624
50840
|
};
|
|
50625
50841
|
runState.replicas.push(instance);
|
|
50626
50842
|
const bootPromise = bootReplica(service, options, instance);
|
|
@@ -50702,6 +50918,12 @@ var ServiceController = class {
|
|
|
50702
50918
|
await Promise.allSettled(inFlightBoots);
|
|
50703
50919
|
}
|
|
50704
50920
|
await Promise.allSettled(this.runState.replicas.map(async (instance) => {
|
|
50921
|
+
if (this.options.discovery) {
|
|
50922
|
+
for (const handle of instance.cloudMapHandles) try {
|
|
50923
|
+
this.options.discovery.registry.unregister(handle);
|
|
50924
|
+
} catch {}
|
|
50925
|
+
instance.cloudMapHandles = [];
|
|
50926
|
+
}
|
|
50705
50927
|
try {
|
|
50706
50928
|
await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
|
|
50707
50929
|
} catch (err) {
|
|
@@ -50712,6 +50934,39 @@ var ServiceController = class {
|
|
|
50712
50934
|
}
|
|
50713
50935
|
};
|
|
50714
50936
|
/**
|
|
50937
|
+
* Build the `--network-alias` map for one service's containers (design
|
|
50938
|
+
* doc § 5 Option A). For every Service Connect entry, attach the
|
|
50939
|
+
* fqdn (`<discoveryName>.<namespaceName>`), the bare discoveryName,
|
|
50940
|
+
* AND every ClientAlias DnsName to the container that owns the
|
|
50941
|
+
* matching PortName. Other containers in the task get NO extra
|
|
50942
|
+
* aliases (only their default `--name`-derived alias from
|
|
50943
|
+
* `buildDockerRunArgs`).
|
|
50944
|
+
*
|
|
50945
|
+
* Aliases per container are de-duplicated so docker doesn't reject
|
|
50946
|
+
* a `--network-alias X` repeated against the same container.
|
|
50947
|
+
*
|
|
50948
|
+
* Returns an empty map when the service has no Service Connect — the
|
|
50949
|
+
* runner's `... .size > 0 ? { networkAliasesByContainer } : {}` guard
|
|
50950
|
+
* short-circuits in that case so backward-compat callers pay no cost.
|
|
50951
|
+
*/
|
|
50952
|
+
function buildNetworkAliasesByContainer(service) {
|
|
50953
|
+
const out = /* @__PURE__ */ new Map();
|
|
50954
|
+
const sc = service.serviceConnect;
|
|
50955
|
+
if (!sc) return out;
|
|
50956
|
+
for (const entry of sc.services) {
|
|
50957
|
+
const owner = service.task.containers.find((c) => c.portMappings.some((pm) => pm.name === entry.portName));
|
|
50958
|
+
if (!owner) continue;
|
|
50959
|
+
const aliases = [];
|
|
50960
|
+
aliases.push(entry.discoveryName);
|
|
50961
|
+
aliases.push(`${entry.discoveryName}.${sc.namespaceName}`);
|
|
50962
|
+
for (const ca of entry.clientAliases) if (ca.dnsName) aliases.push(ca.dnsName);
|
|
50963
|
+
const existing = out.get(owner.name) ?? [];
|
|
50964
|
+
for (const a of aliases) if (!existing.includes(a)) existing.push(a);
|
|
50965
|
+
out.set(owner.name, existing);
|
|
50966
|
+
}
|
|
50967
|
+
return out;
|
|
50968
|
+
}
|
|
50969
|
+
/**
|
|
50715
50970
|
* Boot a single replica. Mutates the supplied `instance.state` so the
|
|
50716
50971
|
* shutdown path's `cleanupEcsRun(instance.state)` covers every partial
|
|
50717
50972
|
* side effect. Network names are suffixed with the replica index so
|
|
@@ -50720,15 +50975,99 @@ var ServiceController = class {
|
|
|
50720
50975
|
async function bootReplica(service, options, instance) {
|
|
50721
50976
|
const logger = getLogger().child("ecs-service");
|
|
50722
50977
|
const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
|
|
50723
|
-
const
|
|
50978
|
+
const ownerKeyPrefix = `${service.serviceLogicalId}:r${instance.index}`;
|
|
50979
|
+
const addHostFlags = options.discovery?.registry ? options.discovery.registry.buildAddHostFlags(ownerKeyPrefix) : [];
|
|
50980
|
+
const sharedNetwork = options.discovery?.sharedNetwork;
|
|
50981
|
+
const networkAliasesByContainer = buildNetworkAliasesByContainer(service);
|
|
50724
50982
|
const perReplicaTaskOptions = {
|
|
50725
50983
|
...options.taskOptions,
|
|
50726
50984
|
cluster: perReplicaCluster,
|
|
50727
|
-
|
|
50728
|
-
|
|
50985
|
+
detach: true,
|
|
50986
|
+
...sharedNetwork ? { existingNetwork: sharedNetwork } : { subnetOctet: 170 + instance.index % 84 },
|
|
50987
|
+
...addHostFlags.length > 0 ? { addHostFlags } : {},
|
|
50988
|
+
...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {}
|
|
50729
50989
|
};
|
|
50730
50990
|
logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
|
|
50731
50991
|
await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
|
|
50992
|
+
if (options.discovery) await publishReplicaToCloudMap(service, instance, options.discovery, ownerKeyPrefix);
|
|
50993
|
+
}
|
|
50994
|
+
/**
|
|
50995
|
+
* After the replica's main container is up, discover its docker
|
|
50996
|
+
* network IP and publish the configured Service Connect + Cloud Map
|
|
50997
|
+
* endpoints into the shared registry. The handles are tracked on the
|
|
50998
|
+
* instance so the shutdown / restart path can unregister symmetrically.
|
|
50999
|
+
*
|
|
51000
|
+
* Errors here are best-effort: docker inspect can fail right after run
|
|
51001
|
+
* (container vanished, network not fully wired), and the registry is
|
|
51002
|
+
* advisory — losing one replica's registration means peer services
|
|
51003
|
+
* can't reach it via the overlay, but it doesn't break that replica's
|
|
51004
|
+
* own work or AWS SDK calls.
|
|
51005
|
+
*/
|
|
51006
|
+
async function publishReplicaToCloudMap(service, instance, discovery, ownerKeyPrefix) {
|
|
51007
|
+
const logger = getLogger().child("ecs-service");
|
|
51008
|
+
const networkName = instance.state.network?.networkName;
|
|
51009
|
+
if (!networkName) return;
|
|
51010
|
+
const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
|
|
51011
|
+
if (!essential) return;
|
|
51012
|
+
const started = instance.state.startedContainers.find((c) => c.name === essential.name);
|
|
51013
|
+
if (!started) return;
|
|
51014
|
+
let ip;
|
|
51015
|
+
try {
|
|
51016
|
+
ip = await getContainerNetworkIp(started.id, networkName);
|
|
51017
|
+
} catch (err) {
|
|
51018
|
+
logger.warn(`Replica ${instance.index}: docker inspect failed before Cloud Map publish: ${err instanceof Error ? err.message : String(err)}`);
|
|
51019
|
+
return;
|
|
51020
|
+
}
|
|
51021
|
+
if (!ip) {
|
|
51022
|
+
logger.warn(`Replica ${instance.index}: no docker IP discovered on network ${networkName}; skipping Cloud Map publish for this replica.`);
|
|
51023
|
+
return;
|
|
51024
|
+
}
|
|
51025
|
+
if (service.serviceConnect) {
|
|
51026
|
+
const ns = service.serviceConnect.namespaceName;
|
|
51027
|
+
const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
|
|
51028
|
+
if (index && !index.namespacesByName.has(ns)) logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceConnectConfiguration.Namespace='${ns}' does not match any AWS::ServiceDiscovery::PrivateDnsNamespace declared in stack ${service.stack.stackName}. Publishing under the literal name anyway; peer services using the same literal will still discover this endpoint.`);
|
|
51029
|
+
let i = 0;
|
|
51030
|
+
for (const entry of service.serviceConnect.services) {
|
|
51031
|
+
const ownerKey = `${ownerKeyPrefix}:sc:${i}`;
|
|
51032
|
+
const handle = discovery.registry.register(ns, entry.discoveryName, {
|
|
51033
|
+
ip,
|
|
51034
|
+
port: entry.containerPort,
|
|
51035
|
+
ownerKey
|
|
51036
|
+
});
|
|
51037
|
+
instance.cloudMapHandles.push(handle);
|
|
51038
|
+
for (const alias of entry.clientAliases) if (alias.dnsName) discovery.registry.registerAlias(alias.dnsName, handle.fqdn);
|
|
51039
|
+
i++;
|
|
51040
|
+
}
|
|
51041
|
+
}
|
|
51042
|
+
if (service.serviceRegistries.length > 0) {
|
|
51043
|
+
const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
|
|
51044
|
+
if (!index) {
|
|
51045
|
+
logger.warn(`ECS Service '${service.serviceLogicalId}' declares ServiceRegistries[] but cdkd has no Cloud Map index for stack ${service.stack.stackName}. Skipping registration.`);
|
|
51046
|
+
return;
|
|
51047
|
+
}
|
|
51048
|
+
let j = 0;
|
|
51049
|
+
for (const reg of service.serviceRegistries) {
|
|
51050
|
+
const cm = index.servicesByLogicalId.get(reg.cloudMapServiceLogicalId);
|
|
51051
|
+
if (!cm) {
|
|
51052
|
+
logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceRegistries[].cloudMapServiceLogicalId='${reg.cloudMapServiceLogicalId}' did not resolve to an AWS::ServiceDiscovery::Service in stack ${service.stack.stackName}. Skipping this registration.`);
|
|
51053
|
+
continue;
|
|
51054
|
+
}
|
|
51055
|
+
let port = reg.containerPort;
|
|
51056
|
+
if (port === void 0 && essential.portMappings.length > 0) port = essential.portMappings[0].containerPort;
|
|
51057
|
+
if (port === void 0) {
|
|
51058
|
+
logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceRegistries[] entry for Cloud Map service '${cm.logicalId}' has no resolvable container port; skipping.`);
|
|
51059
|
+
continue;
|
|
51060
|
+
}
|
|
51061
|
+
const ownerKey = `${ownerKeyPrefix}:sr:${j}`;
|
|
51062
|
+
const handle = discovery.registry.register(cm.namespaceName, cm.name, {
|
|
51063
|
+
ip,
|
|
51064
|
+
port,
|
|
51065
|
+
ownerKey
|
|
51066
|
+
});
|
|
51067
|
+
instance.cloudMapHandles.push(handle);
|
|
51068
|
+
j++;
|
|
51069
|
+
}
|
|
51070
|
+
}
|
|
50732
51071
|
}
|
|
50733
51072
|
/**
|
|
50734
51073
|
* Long-running watcher loop for one replica. Polls the essential
|
|
@@ -50762,6 +51101,12 @@ async function watchReplica(service, options, instance, runState) {
|
|
|
50762
51101
|
logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
|
|
50763
51102
|
await sleep(delay);
|
|
50764
51103
|
if (instance.shuttingDown || runState.shuttingDown) return;
|
|
51104
|
+
if (options.discovery) {
|
|
51105
|
+
for (const handle of instance.cloudMapHandles) try {
|
|
51106
|
+
options.discovery.registry.unregister(handle);
|
|
51107
|
+
} catch {}
|
|
51108
|
+
instance.cloudMapHandles = [];
|
|
51109
|
+
}
|
|
50765
51110
|
try {
|
|
50766
51111
|
await cleanupEcsRun(instance.state, { keepRunning: false });
|
|
50767
51112
|
} catch (err) {
|
|
@@ -50816,6 +51161,305 @@ function sleep(ms) {
|
|
|
50816
51161
|
return sleepImpl(ms);
|
|
50817
51162
|
}
|
|
50818
51163
|
|
|
51164
|
+
//#endregion
|
|
51165
|
+
//#region src/local/cloud-map-registry.ts
|
|
51166
|
+
/**
|
|
51167
|
+
* In-process registry. `register()` is called by the service runner
|
|
51168
|
+
* after each replica's main container boot; `unregister()` is called by
|
|
51169
|
+
* the shutdown / restart paths. `lookupHosts()` produces the
|
|
51170
|
+
* `--add-host` flag list a consumer task's `docker run` injects so
|
|
51171
|
+
* `<discoveryName>.<namespace>` and (when registered as an alias)
|
|
51172
|
+
* bare `<discoveryName>` both resolve to a registered endpoint inside
|
|
51173
|
+
* the consumer container.
|
|
51174
|
+
*
|
|
51175
|
+
* Concurrency note: docker-run callers are not concurrent for the same
|
|
51176
|
+
* replica (the runner boots replicas sequentially), but `lookupHosts()`
|
|
51177
|
+
* MAY be called concurrently with `register()` / `unregister()` of an
|
|
51178
|
+
* unrelated service. The implementation uses synchronous Map mutations
|
|
51179
|
+
* so a stale read returns the previous snapshot — never a partially-
|
|
51180
|
+
* mutated one. No async / mutex needed.
|
|
51181
|
+
*/
|
|
51182
|
+
var CloudMapRegistry = class {
|
|
51183
|
+
/** Map<fqdn, RegistryEntry[]> — one per replica registered under that fqdn. */
|
|
51184
|
+
byFqdn = /* @__PURE__ */ new Map();
|
|
51185
|
+
/**
|
|
51186
|
+
* Map<alias, fqdn> — secondary index for ClientAlias short forms.
|
|
51187
|
+
* Multiple aliases can point at the same fqdn; one alias only points
|
|
51188
|
+
* at one fqdn (last write wins, with a warn surfaced by the resolver
|
|
51189
|
+
* when a cross-namespace collision is detected — see design §O6).
|
|
51190
|
+
*/
|
|
51191
|
+
aliasIndex = /* @__PURE__ */ new Map();
|
|
51192
|
+
/**
|
|
51193
|
+
* Register a replica's endpoint under `{namespace}/{discoveryName}`.
|
|
51194
|
+
*
|
|
51195
|
+
* @param namespace Cloud Map namespace name, e.g. `cdkd-local.local`.
|
|
51196
|
+
* An empty string is rejected — Cloud Map requires a
|
|
51197
|
+
* named namespace.
|
|
51198
|
+
* @param discoveryName Cloud Map service name, e.g. `orders`. Rejected
|
|
51199
|
+
* when empty.
|
|
51200
|
+
* @param endpoint Reachable endpoint + owner key for symmetric
|
|
51201
|
+
* unregister.
|
|
51202
|
+
* @returns A handle the caller stores for later `unregister(handle)`.
|
|
51203
|
+
*/
|
|
51204
|
+
register(namespace, discoveryName, endpoint) {
|
|
51205
|
+
if (!namespace) throw new Error("CloudMapRegistry.register: namespace must be a non-empty string.");
|
|
51206
|
+
if (!discoveryName) throw new Error("CloudMapRegistry.register: discoveryName must be a non-empty string.");
|
|
51207
|
+
const fqdn = `${discoveryName}.${namespace}`;
|
|
51208
|
+
const filtered = (this.byFqdn.get(fqdn) ?? []).filter((e) => e.endpoint.ownerKey !== endpoint.ownerKey);
|
|
51209
|
+
filtered.push({
|
|
51210
|
+
namespace,
|
|
51211
|
+
discoveryName,
|
|
51212
|
+
endpoint
|
|
51213
|
+
});
|
|
51214
|
+
this.byFqdn.set(fqdn, filtered);
|
|
51215
|
+
return {
|
|
51216
|
+
fqdn,
|
|
51217
|
+
ownerKey: endpoint.ownerKey
|
|
51218
|
+
};
|
|
51219
|
+
}
|
|
51220
|
+
/**
|
|
51221
|
+
* Register a bare-name alias (`<discoveryName>` without the namespace
|
|
51222
|
+
* suffix). Cloud Map / Service Connect does NOT auto-create such
|
|
51223
|
+
* aliases — they're populated by `ClientAliases[].DnsName` entries in
|
|
51224
|
+
* the consumer service's `ServiceConnectConfiguration`. Aliases are
|
|
51225
|
+
* scoped per-CLI-invocation and last-write-wins on collision (the
|
|
51226
|
+
* resolver surfaces a `warn` when this happens — see §O6).
|
|
51227
|
+
*
|
|
51228
|
+
* @param alias The bare discovery name (e.g. `orders` for an alias to
|
|
51229
|
+
* `orders.cdkd-local.local`).
|
|
51230
|
+
* @param targetFqdn The full `{discoveryName}.{namespace}` the alias
|
|
51231
|
+
* resolves to.
|
|
51232
|
+
*/
|
|
51233
|
+
registerAlias(alias, targetFqdn) {
|
|
51234
|
+
if (!alias) throw new Error("CloudMapRegistry.registerAlias: alias must be a non-empty string.");
|
|
51235
|
+
if (!targetFqdn) throw new Error("CloudMapRegistry.registerAlias: targetFqdn must be a non-empty string.");
|
|
51236
|
+
this.aliasIndex.set(alias, targetFqdn);
|
|
51237
|
+
}
|
|
51238
|
+
/**
|
|
51239
|
+
* Remove a single endpoint registered under the supplied handle.
|
|
51240
|
+
* Idempotent — unknown handles return false without throwing.
|
|
51241
|
+
*/
|
|
51242
|
+
unregister(handle) {
|
|
51243
|
+
const entries = this.byFqdn.get(handle.fqdn);
|
|
51244
|
+
if (!entries) return false;
|
|
51245
|
+
const filtered = entries.filter((e) => e.endpoint.ownerKey !== handle.ownerKey);
|
|
51246
|
+
if (filtered.length === entries.length) return false;
|
|
51247
|
+
if (filtered.length === 0) this.byFqdn.delete(handle.fqdn);
|
|
51248
|
+
else this.byFqdn.set(handle.fqdn, filtered);
|
|
51249
|
+
return true;
|
|
51250
|
+
}
|
|
51251
|
+
/**
|
|
51252
|
+
* Drop every registration with the supplied owner key (e.g. teardown
|
|
51253
|
+
* of every replica of a service). Used by the service controller's
|
|
51254
|
+
* shutdown path; complementary to per-replica `unregister`.
|
|
51255
|
+
*/
|
|
51256
|
+
unregisterByOwner(ownerKeyPrefix) {
|
|
51257
|
+
let removed = 0;
|
|
51258
|
+
for (const [fqdn, entries] of [...this.byFqdn.entries()]) {
|
|
51259
|
+
const filtered = entries.filter((e) => !e.endpoint.ownerKey.startsWith(ownerKeyPrefix));
|
|
51260
|
+
removed += entries.length - filtered.length;
|
|
51261
|
+
if (filtered.length === 0) this.byFqdn.delete(fqdn);
|
|
51262
|
+
else this.byFqdn.set(fqdn, filtered);
|
|
51263
|
+
}
|
|
51264
|
+
return removed;
|
|
51265
|
+
}
|
|
51266
|
+
/**
|
|
51267
|
+
* Look up every endpoint registered under `{discoveryName}.{namespace}`.
|
|
51268
|
+
* Returns `undefined` when no endpoint exists (which is the consumer
|
|
51269
|
+
* runner's signal to log a warn — the user likely forgot to start the
|
|
51270
|
+
* producer). Returns the underlying array verbatim so callers cannot
|
|
51271
|
+
* accidentally mutate registry state — call `.slice()` if you need a
|
|
51272
|
+
* detachable copy.
|
|
51273
|
+
*/
|
|
51274
|
+
lookup(namespace, discoveryName) {
|
|
51275
|
+
const fqdn = `${discoveryName}.${namespace}`;
|
|
51276
|
+
const entries = this.byFqdn.get(fqdn);
|
|
51277
|
+
if (!entries || entries.length === 0) return void 0;
|
|
51278
|
+
return entries.map((e) => e.endpoint);
|
|
51279
|
+
}
|
|
51280
|
+
/**
|
|
51281
|
+
* Resolve a bare alias to its target endpoints. Returns the same
|
|
51282
|
+
* shape as `lookup` for the alias's target fqdn, or `undefined` when
|
|
51283
|
+
* no such alias exists (which is distinct from "alias known but
|
|
51284
|
+
* target has no live replica" — caller may want different warns).
|
|
51285
|
+
*/
|
|
51286
|
+
lookupAlias(alias) {
|
|
51287
|
+
const fqdn = this.aliasIndex.get(alias);
|
|
51288
|
+
if (!fqdn) return void 0;
|
|
51289
|
+
const entries = this.byFqdn.get(fqdn);
|
|
51290
|
+
if (!entries || entries.length === 0) return void 0;
|
|
51291
|
+
return entries.map((e) => e.endpoint);
|
|
51292
|
+
}
|
|
51293
|
+
/**
|
|
51294
|
+
* Build the `--add-host` flag list for a consumer container's
|
|
51295
|
+
* `docker run`. Returns each unique `(hostname, ip)` pair as a flat
|
|
51296
|
+
* `['--add-host', 'name:ip', ...]` array consumable verbatim by the
|
|
51297
|
+
* docker-runner. Includes both the fqdn AND every alias mapped to it.
|
|
51298
|
+
*
|
|
51299
|
+
* Multiple replicas per fqdn cannot be expressed as multiple
|
|
51300
|
+
* `--add-host` entries with the same name (docker's resolver takes
|
|
51301
|
+
* the *last* entry on duplicate keys per `getent hosts` semantics),
|
|
51302
|
+
* so this returns the **first** registered endpoint per fqdn /
|
|
51303
|
+
* alias. Multi-instance round-robin via the static `--add-host`
|
|
51304
|
+
* shape is structurally impossible; a true rotation requires the
|
|
51305
|
+
* DNS-sidecar option (deferred). Documented as a v1 limitation.
|
|
51306
|
+
*/
|
|
51307
|
+
buildAddHostFlags(excludeOwnerKeyPrefix) {
|
|
51308
|
+
const flags = [];
|
|
51309
|
+
const seen = /* @__PURE__ */ new Set();
|
|
51310
|
+
for (const [fqdn, entries] of this.byFqdn.entries()) {
|
|
51311
|
+
const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
|
|
51312
|
+
if (!candidate) continue;
|
|
51313
|
+
if (seen.has(fqdn)) continue;
|
|
51314
|
+
flags.push("--add-host", `${fqdn}:${candidate.endpoint.ip}`);
|
|
51315
|
+
seen.add(fqdn);
|
|
51316
|
+
}
|
|
51317
|
+
for (const [alias, targetFqdn] of this.aliasIndex.entries()) {
|
|
51318
|
+
if (seen.has(alias)) continue;
|
|
51319
|
+
const entries = this.byFqdn.get(targetFqdn);
|
|
51320
|
+
if (!entries || entries.length === 0) continue;
|
|
51321
|
+
const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
|
|
51322
|
+
if (!candidate) continue;
|
|
51323
|
+
flags.push("--add-host", `${alias}:${candidate.endpoint.ip}`);
|
|
51324
|
+
seen.add(alias);
|
|
51325
|
+
}
|
|
51326
|
+
return flags;
|
|
51327
|
+
}
|
|
51328
|
+
/**
|
|
51329
|
+
* Diagnostic snapshot used by the boot banner / test assertions.
|
|
51330
|
+
* Stable iteration order (insertion-order is preserved by JS Maps).
|
|
51331
|
+
*/
|
|
51332
|
+
list() {
|
|
51333
|
+
const out = [];
|
|
51334
|
+
for (const [, entries] of this.byFqdn.entries()) {
|
|
51335
|
+
if (entries.length === 0) continue;
|
|
51336
|
+
const first = entries[0];
|
|
51337
|
+
out.push({
|
|
51338
|
+
namespace: first.namespace,
|
|
51339
|
+
discoveryName: first.discoveryName,
|
|
51340
|
+
endpoints: entries.map((e) => e.endpoint),
|
|
51341
|
+
isAlias: false
|
|
51342
|
+
});
|
|
51343
|
+
}
|
|
51344
|
+
for (const [alias, fqdn] of this.aliasIndex.entries()) {
|
|
51345
|
+
const entries = this.byFqdn.get(fqdn);
|
|
51346
|
+
if (!entries || entries.length === 0) continue;
|
|
51347
|
+
out.push({
|
|
51348
|
+
namespace: "",
|
|
51349
|
+
discoveryName: alias,
|
|
51350
|
+
endpoints: entries.map((e) => e.endpoint),
|
|
51351
|
+
isAlias: true
|
|
51352
|
+
});
|
|
51353
|
+
}
|
|
51354
|
+
return out;
|
|
51355
|
+
}
|
|
51356
|
+
/** True when no endpoint is registered. Used by the runner to short-circuit. */
|
|
51357
|
+
isEmpty() {
|
|
51358
|
+
return this.byFqdn.size === 0;
|
|
51359
|
+
}
|
|
51360
|
+
};
|
|
51361
|
+
|
|
51362
|
+
//#endregion
|
|
51363
|
+
//#region src/local/cloud-map-resolver.ts
|
|
51364
|
+
/**
|
|
51365
|
+
* Build the `CloudMapIndex` for one stack's template. Empty index when
|
|
51366
|
+
* the stack declares no `AWS::ServiceDiscovery::*` resources.
|
|
51367
|
+
*
|
|
51368
|
+
* Hard-reject errors (throw `EcsTaskResolutionError`):
|
|
51369
|
+
* - `AWS::ServiceDiscovery::PublicDnsNamespace` — defeats "local" semantics.
|
|
51370
|
+
* - `AWS::ServiceDiscovery::HttpNamespace` — DiscoverInstances-only,
|
|
51371
|
+
* no DNS, would require shimming the AWS SDK inside every container.
|
|
51372
|
+
* - An `AWS::ServiceDiscovery::Service` with a `NamespaceId` that
|
|
51373
|
+
* doesn't resolve to a same-stack `PrivateDnsNamespace`.
|
|
51374
|
+
*/
|
|
51375
|
+
function buildCloudMapIndex(stack) {
|
|
51376
|
+
const namespacesByLogicalId = /* @__PURE__ */ new Map();
|
|
51377
|
+
const namespacesByName = /* @__PURE__ */ new Map();
|
|
51378
|
+
const servicesByLogicalId = /* @__PURE__ */ new Map();
|
|
51379
|
+
const warnings = [];
|
|
51380
|
+
const resources = stack.template.Resources ?? {};
|
|
51381
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
51382
|
+
if (resource.Type === "AWS::ServiceDiscovery::PublicDnsNamespace") throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::PublicDnsNamespace '${logicalId}' is not supported by local emulation — public DNS defeats the "local" point. Use a PrivateDnsNamespace for cdkd local start-service.`);
|
|
51383
|
+
if (resource.Type === "AWS::ServiceDiscovery::HttpNamespace") throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::HttpNamespace '${logicalId}' is not supported by local emulation — HttpNamespace uses the AWS Cloud Map DiscoverInstances API directly (no DNS), which would require shimming the AWS SDK inside every container. Use a PrivateDnsNamespace + DnsConfig instead.`);
|
|
51384
|
+
if (resource.Type !== "AWS::ServiceDiscovery::PrivateDnsNamespace") continue;
|
|
51385
|
+
const props = resource.Properties ?? {};
|
|
51386
|
+
const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
|
|
51387
|
+
if (!name) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: PrivateDnsNamespace '${logicalId}' has no literal Name property. Intrinsic-valued names are not supported (cross-stack / dynamic namespace names require deploy-state resolution which is out of scope for v1).`);
|
|
51388
|
+
const entry = {
|
|
51389
|
+
logicalId,
|
|
51390
|
+
name
|
|
51391
|
+
};
|
|
51392
|
+
namespacesByLogicalId.set(logicalId, entry);
|
|
51393
|
+
if (namespacesByName.has(name)) warnings.push(`Stack ${stack.stackName}: two PrivateDnsNamespace resources share Name='${name}' ('${namespacesByName.get(name).logicalId}' and '${logicalId}'). Local emulation routes registrations to the first; the second will silently shadow.`);
|
|
51394
|
+
else namespacesByName.set(name, entry);
|
|
51395
|
+
}
|
|
51396
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
51397
|
+
if (resource.Type !== "AWS::ServiceDiscovery::Service") continue;
|
|
51398
|
+
const props = resource.Properties ?? {};
|
|
51399
|
+
const namespaceLogicalId = resolveNamespaceIdRef(props["NamespaceId"] ?? props["DnsConfig"]?.["NamespaceId"], stack.stackName, logicalId);
|
|
51400
|
+
const ns = namespacesByLogicalId.get(namespaceLogicalId);
|
|
51401
|
+
if (!ns) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::Service '${logicalId}' references NamespaceId '${namespaceLogicalId}' but no PrivateDnsNamespace with that logical id exists in this stack. Cross-stack Cloud Map namespaces are not supported in v1.`);
|
|
51402
|
+
const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
|
|
51403
|
+
if (!name) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::Service '${logicalId}' has no literal Name property. Intrinsic-valued names are not supported in v1.`);
|
|
51404
|
+
const dnsRecords = extractDnsRecords(props);
|
|
51405
|
+
servicesByLogicalId.set(logicalId, {
|
|
51406
|
+
logicalId,
|
|
51407
|
+
namespaceLogicalId,
|
|
51408
|
+
namespaceName: ns.name,
|
|
51409
|
+
name,
|
|
51410
|
+
dnsRecords
|
|
51411
|
+
});
|
|
51412
|
+
}
|
|
51413
|
+
return {
|
|
51414
|
+
namespacesByLogicalId,
|
|
51415
|
+
namespacesByName,
|
|
51416
|
+
servicesByLogicalId,
|
|
51417
|
+
warnings
|
|
51418
|
+
};
|
|
51419
|
+
}
|
|
51420
|
+
/**
|
|
51421
|
+
* Resolve `NamespaceId` to the parent's logical id. CDK 2.x synthesizes
|
|
51422
|
+
* this as `{Fn::GetAtt: ['<NsLogicalId>', 'Id']}` (verified via real
|
|
51423
|
+
* `cdk synth` on 2026-05-22). `Ref` is also accepted defensively
|
|
51424
|
+
* (returns the namespace's physical id, but inside one synth template
|
|
51425
|
+
* the Ref target IS the logical id we want).
|
|
51426
|
+
*
|
|
51427
|
+
* Cross-stack / intrinsic shapes that we cannot resolve at synth time
|
|
51428
|
+
* are hard-rejected — cdkd would otherwise silently route to no
|
|
51429
|
+
* namespace and the consumer would get an unhelpful "DNS lookup failed"
|
|
51430
|
+
* at runtime.
|
|
51431
|
+
*/
|
|
51432
|
+
function resolveNamespaceIdRef(raw, stackName, serviceLogicalId) {
|
|
51433
|
+
if (typeof raw === "string") return raw;
|
|
51434
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
51435
|
+
const obj = raw;
|
|
51436
|
+
if (typeof obj["Ref"] === "string") return obj["Ref"];
|
|
51437
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
51438
|
+
if (Array.isArray(getAtt) && typeof getAtt[0] === "string") return getAtt[0];
|
|
51439
|
+
}
|
|
51440
|
+
throw new EcsTaskResolutionError(`Stack ${stackName}: AWS::ServiceDiscovery::Service '${serviceLogicalId}' has an unsupported NamespaceId reference shape: ${JSON.stringify(raw)}. Accepted shapes are {Fn::GetAtt: [<NsLogicalId>, 'Id']} or {Ref: <NsLogicalId>} pointing at a same-stack PrivateDnsNamespace.`);
|
|
51441
|
+
}
|
|
51442
|
+
function extractDnsRecords(serviceProps) {
|
|
51443
|
+
const dnsConfig = serviceProps["DnsConfig"];
|
|
51444
|
+
if (!dnsConfig || typeof dnsConfig !== "object") return [];
|
|
51445
|
+
const records = dnsConfig["DnsRecords"];
|
|
51446
|
+
if (!Array.isArray(records)) return [];
|
|
51447
|
+
const out = [];
|
|
51448
|
+
for (const r of records) {
|
|
51449
|
+
if (!r || typeof r !== "object") continue;
|
|
51450
|
+
const obj = r;
|
|
51451
|
+
const type = obj["Type"];
|
|
51452
|
+
if (type !== "A" && type !== "SRV") continue;
|
|
51453
|
+
const ttl = obj["TTL"];
|
|
51454
|
+
const ttlSeconds = typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? Math.floor(ttl) : typeof ttl === "string" && /^\d+$/.test(ttl) ? parseInt(ttl, 10) : 60;
|
|
51455
|
+
out.push({
|
|
51456
|
+
type,
|
|
51457
|
+
ttlSeconds
|
|
51458
|
+
});
|
|
51459
|
+
}
|
|
51460
|
+
return out;
|
|
51461
|
+
}
|
|
51462
|
+
|
|
50819
51463
|
//#endregion
|
|
50820
51464
|
//#region src/cli/commands/local-start-service.ts
|
|
50821
51465
|
/**
|
|
@@ -50830,17 +51474,34 @@ function sleep(ms) {
|
|
|
50830
51474
|
* - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
|
|
50831
51475
|
* - Service Connect / Cloud Map — tracked separately in #460.
|
|
50832
51476
|
*/
|
|
50833
|
-
async function localStartServiceCommand(
|
|
51477
|
+
async function localStartServiceCommand(targets, options) {
|
|
50834
51478
|
const logger = getLogger();
|
|
50835
51479
|
if (options.verbose) logger.setLevel("debug");
|
|
50836
51480
|
warnIfDeprecatedRegion(options);
|
|
50837
|
-
|
|
51481
|
+
if (!targets || targets.length === 0) throw new LocalStartServiceError("cdkd local start-service requires at least one <target>. Pass one or more service paths like 'Stack/Orders' 'Stack/Frontend'.");
|
|
51482
|
+
const perTarget = targets.map((t) => ({
|
|
51483
|
+
target: t,
|
|
51484
|
+
runState: createServiceRunState()
|
|
51485
|
+
}));
|
|
50838
51486
|
let sigintHandler;
|
|
50839
51487
|
let sigintCount = 0;
|
|
50840
|
-
let
|
|
51488
|
+
let sharedNetwork;
|
|
50841
51489
|
const cleanup = singleFlight(async () => {
|
|
50842
|
-
|
|
50843
|
-
|
|
51490
|
+
await Promise.allSettled(perTarget.map(async (pt) => {
|
|
51491
|
+
if (pt.controller) await pt.controller.shutdown();
|
|
51492
|
+
else {
|
|
51493
|
+
await Promise.allSettled(pt.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0));
|
|
51494
|
+
await Promise.allSettled(pt.runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
|
|
51495
|
+
}
|
|
51496
|
+
}));
|
|
51497
|
+
if (sharedNetwork) {
|
|
51498
|
+
try {
|
|
51499
|
+
await destroyTaskNetwork(sharedNetwork);
|
|
51500
|
+
} catch (err) {
|
|
51501
|
+
getLogger().warn(`shared service network teardown failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
51502
|
+
}
|
|
51503
|
+
sharedNetwork = void 0;
|
|
51504
|
+
}
|
|
50844
51505
|
}, (err) => getLogger().warn(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
50845
51506
|
try {
|
|
50846
51507
|
await applyRoleArnIfSet({
|
|
@@ -50863,72 +51524,42 @@ async function localStartServiceCommand(target, options) {
|
|
|
50863
51524
|
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
50864
51525
|
};
|
|
50865
51526
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
50866
|
-
const
|
|
50867
|
-
const
|
|
50868
|
-
|
|
50869
|
-
|
|
50870
|
-
|
|
50871
|
-
|
|
50872
|
-
|
|
50873
|
-
|
|
50874
|
-
|
|
50875
|
-
|
|
50876
|
-
|
|
51527
|
+
const cloudMapIndexByStack = /* @__PURE__ */ new Map();
|
|
51528
|
+
for (const stack of stacks) {
|
|
51529
|
+
const index = buildCloudMapIndex(stack);
|
|
51530
|
+
cloudMapIndexByStack.set(stack.stackName, index);
|
|
51531
|
+
for (const w of index.warnings) logger.warn(w);
|
|
51532
|
+
}
|
|
51533
|
+
const registry = new CloudMapRegistry();
|
|
51534
|
+
try {
|
|
51535
|
+
sharedNetwork = await createSharedSvcNetwork({
|
|
51536
|
+
prefix: options.cluster,
|
|
51537
|
+
skipPull: options.pull === false,
|
|
51538
|
+
cluster: options.cluster
|
|
50877
51539
|
});
|
|
50878
|
-
|
|
50879
|
-
|
|
50880
|
-
|
|
50881
|
-
|
|
50882
|
-
|
|
50883
|
-
|
|
50884
|
-
|
|
50885
|
-
|
|
50886
|
-
} finally {
|
|
50887
|
-
built.dispose();
|
|
50888
|
-
}
|
|
50889
|
-
} else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
|
|
51540
|
+
} catch (err) {
|
|
51541
|
+
throw new LocalStartServiceError(`Failed to create shared service network: ${err instanceof Error ? err.message : String(err)}`);
|
|
51542
|
+
}
|
|
51543
|
+
const discovery = {
|
|
51544
|
+
registry,
|
|
51545
|
+
cloudMapIndexByStack,
|
|
51546
|
+
sharedNetwork
|
|
51547
|
+
};
|
|
50890
51548
|
sigintHandler = () => {
|
|
50891
51549
|
sigintCount += 1;
|
|
50892
51550
|
if (sigintCount >= 2) {
|
|
50893
51551
|
process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
|
|
50894
51552
|
process.exit(130);
|
|
50895
51553
|
}
|
|
50896
|
-
logger.info("Stopping service...");
|
|
51554
|
+
logger.info("Stopping service(s)...");
|
|
50897
51555
|
cleanup().then(() => process.exit(130));
|
|
50898
51556
|
};
|
|
50899
51557
|
process.on("SIGINT", sigintHandler);
|
|
50900
51558
|
process.on("SIGTERM", sigintHandler);
|
|
50901
|
-
|
|
50902
|
-
|
|
50903
|
-
|
|
50904
|
-
|
|
50905
|
-
resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
|
|
50906
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
50907
|
-
} else if (typeof options.assumeTaskRole === "string") {
|
|
50908
|
-
resolvedRoleArn = options.assumeTaskRole;
|
|
50909
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
50910
|
-
}
|
|
50911
|
-
const envOverrides = readEnvOverridesFile$1(options.envVars);
|
|
50912
|
-
const taskOpts = {
|
|
50913
|
-
cluster: options.cluster,
|
|
50914
|
-
containerHost: options.containerHost,
|
|
50915
|
-
skipPull: options.pull === false,
|
|
50916
|
-
keepRunning: false,
|
|
50917
|
-
detach: true
|
|
50918
|
-
};
|
|
50919
|
-
if (envOverrides) taskOpts.envOverrides = envOverrides;
|
|
50920
|
-
if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
|
|
50921
|
-
if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
|
|
50922
|
-
if (options.platform) taskOpts.platformOverride = options.platform;
|
|
50923
|
-
if (options.region) taskOpts.region = options.region;
|
|
50924
|
-
if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
|
|
50925
|
-
controller = await startEcsService(service, {
|
|
50926
|
-
maxTasks: options.maxTasks,
|
|
50927
|
-
restartPolicy: options.restartPolicy,
|
|
50928
|
-
taskOptions: taskOpts
|
|
50929
|
-
}, runState);
|
|
50930
|
-
logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
|
|
50931
|
-
await controller.waitForShutdown();
|
|
51559
|
+
for (const pt of perTarget) pt.controller = await bootOneTarget(pt.target, pt.runState, stacks, options, discovery);
|
|
51560
|
+
const summary = perTarget.map((pt) => `${pt.controller.service.serviceName} (${pt.controller.activeReplicaCount()} replica(s))`).join(", ");
|
|
51561
|
+
logger.info(`Service(s) running: ${summary}. Press ^C to shut down.`);
|
|
51562
|
+
await Promise.all(perTarget.map((pt) => pt.controller.waitForShutdown()));
|
|
50932
51563
|
} finally {
|
|
50933
51564
|
if (sigintHandler) {
|
|
50934
51565
|
process.off("SIGINT", sigintHandler);
|
|
@@ -50937,6 +51568,72 @@ async function localStartServiceCommand(target, options) {
|
|
|
50937
51568
|
await cleanup();
|
|
50938
51569
|
}
|
|
50939
51570
|
}
|
|
51571
|
+
/**
|
|
51572
|
+
* Boot one target. Extracted from the loop so each per-service block
|
|
51573
|
+
* (image context, cross-stack resolver, task-role credentials, runner
|
|
51574
|
+
* options) is scoped locally. Returns the started controller for the
|
|
51575
|
+
* outer code to wait + tear down.
|
|
51576
|
+
*/
|
|
51577
|
+
async function bootOneTarget(target, runState, stacks, options, discovery) {
|
|
51578
|
+
const logger = getLogger();
|
|
51579
|
+
const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
|
|
51580
|
+
const service = resolveEcsServiceTarget(target, stacks, imageContext);
|
|
51581
|
+
logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
|
|
51582
|
+
for (const w of service.warnings) logger.warn(w);
|
|
51583
|
+
if (service.serviceConnect) logger.info(`Service Connect: namespace='${service.serviceConnect.namespaceName}', ${service.serviceConnect.services.length} service(s) registered for peer discovery.`);
|
|
51584
|
+
if (service.serviceRegistries.length > 0) logger.info(`Cloud Map: ${service.serviceRegistries.length} ServiceRegistry binding(s).`);
|
|
51585
|
+
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
|
|
51586
|
+
if (options.fromState && taskNeeds.needsCrossStackResolver) {
|
|
51587
|
+
const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
|
|
51588
|
+
const built = await buildCrossStackResolver(consumerRegion, {
|
|
51589
|
+
...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
|
|
51590
|
+
statePrefix: options.statePrefix,
|
|
51591
|
+
...options.region !== void 0 && { region: options.region },
|
|
51592
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
51593
|
+
});
|
|
51594
|
+
if (built) try {
|
|
51595
|
+
const subContext = {
|
|
51596
|
+
resources: imageContext?.stateResources ?? {},
|
|
51597
|
+
...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
|
|
51598
|
+
consumerRegion,
|
|
51599
|
+
crossStackResolver: built.resolver
|
|
51600
|
+
};
|
|
51601
|
+
await applyCrossStackResolverToTask(service.task, subContext);
|
|
51602
|
+
} finally {
|
|
51603
|
+
built.dispose();
|
|
51604
|
+
}
|
|
51605
|
+
} else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
|
|
51606
|
+
let assumedCredentials;
|
|
51607
|
+
let resolvedRoleArn;
|
|
51608
|
+
if (options.assumeTaskRole === true) {
|
|
51609
|
+
if (!service.task.taskRoleArn) throw new LocalStartServiceError(`--assume-task-role passed without an ARN but service '${service.serviceLogicalId}' has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>`);
|
|
51610
|
+
resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
|
|
51611
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
51612
|
+
} else if (typeof options.assumeTaskRole === "string") {
|
|
51613
|
+
resolvedRoleArn = options.assumeTaskRole;
|
|
51614
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
51615
|
+
}
|
|
51616
|
+
const envOverrides = readEnvOverridesFile$1(options.envVars);
|
|
51617
|
+
const taskOpts = {
|
|
51618
|
+
cluster: options.cluster,
|
|
51619
|
+
containerHost: options.containerHost,
|
|
51620
|
+
skipPull: options.pull === false,
|
|
51621
|
+
keepRunning: false,
|
|
51622
|
+
detach: true
|
|
51623
|
+
};
|
|
51624
|
+
if (envOverrides) taskOpts.envOverrides = envOverrides;
|
|
51625
|
+
if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
|
|
51626
|
+
if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
|
|
51627
|
+
if (options.platform) taskOpts.platformOverride = options.platform;
|
|
51628
|
+
if (options.region) taskOpts.region = options.region;
|
|
51629
|
+
if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
|
|
51630
|
+
return startEcsService(service, {
|
|
51631
|
+
maxTasks: options.maxTasks,
|
|
51632
|
+
restartPolicy: options.restartPolicy,
|
|
51633
|
+
taskOptions: taskOpts,
|
|
51634
|
+
discovery
|
|
51635
|
+
}, runState);
|
|
51636
|
+
}
|
|
50940
51637
|
async function resolvePlaceholderAccount(arn, region) {
|
|
50941
51638
|
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
50942
51639
|
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
@@ -51079,7 +51776,7 @@ function parseRestartPolicy(raw) {
|
|
|
51079
51776
|
throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
|
|
51080
51777
|
}
|
|
51081
51778
|
function createLocalStartServiceCommand() {
|
|
51082
|
-
const cmd = new Command("start-service").description("Run
|
|
51779
|
+
const cmd = new Command("start-service").description("Run one or more AWS::ECS::Service resources locally as a long-running emulator. Spins up DesiredCount task replicas per service (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Each <target> accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix. When two or more <target>s are supplied, every service is booted into a shared Cloud Map / Service Connect registry so peer services discover each other via docker --add-host overlay (Issue #460).").argument("<targets...>", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ECS::Service resources to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed task role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").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).")).action(withErrorHandling(localStartServiceCommand));
|
|
51083
51780
|
[
|
|
51084
51781
|
...commonOptions,
|
|
51085
51782
|
...appOptions,
|
|
@@ -54262,7 +54959,7 @@ function reorderArgs(argv) {
|
|
|
54262
54959
|
*/
|
|
54263
54960
|
async function main() {
|
|
54264
54961
|
const program = new Command();
|
|
54265
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
54962
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.139.0");
|
|
54266
54963
|
program.addCommand(createBootstrapCommand());
|
|
54267
54964
|
program.addCommand(createSynthCommand());
|
|
54268
54965
|
program.addCommand(createListCommand());
|