@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/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$3 = promisify(execFile);
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$3(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
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$3(getDockerCmd(), [
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$3(cmd, [
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$2 = promisify(execFile);
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` allocates a per-replica subnet (via `subnetOctet`) so
49362
- * concurrent replicas don't collide on a single docker network range.
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
- * Create the per-task docker network + start the metadata-endpoints
49391
- * sidecar. The sidecar must come up at the well-known address BEFORE any
49392
- * user container starts so the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
49393
- * lookup at container start doesn't race.
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
- async function createTaskNetwork(options = {}) {
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 = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
49398
- const subnetOctet = options.subnetOctet ?? 170;
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$2(getDockerCmd(), [
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-task on the same host 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\` walks subnetOctet per replica to avoid this; bare \`cdkd local run-task\` uses the default subnet so only one run can be active at a time.`);
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 (options.credentials) {
49432
- sidecarEnv["AWS_ACCESS_KEY_ID"] = options.credentials.accessKeyId;
49433
- sidecarEnv["AWS_SECRET_ACCESS_KEY"] = options.credentials.secretAccessKey;
49434
- if (options.credentials.sessionToken) sidecarEnv["AWS_SESSION_TOKEN"] = options.credentials.sessionToken;
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 (options.cluster) sidecarEnv["CLUSTER"] = options.cluster;
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$2(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
49443
- sidecarContainerId = stdout.trim();
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$2(getDockerCmd(), [
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$1 = promisify(execFile);
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$1(getDockerCmd(), [
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
- const netCreateOpts = {
49714
- prefix: options.cluster,
49715
- skipPull: options.skipPull
49716
- };
49717
- if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
49718
- if (options.cluster) netCreateOpts.cluster = options.cluster;
49719
- if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
49720
- state.network = await createTaskNetwork(netCreateOpts);
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$1(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
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$1(getDockerCmd(), [
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$1(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
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$1(getDockerCmd(), [
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$1(getDockerCmd(), args);
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
- if (props["ServiceConnectConfiguration"]) warnings.push(`ECS Service '${serviceLogicalId}' declares ServiceConnectConfiguration, but Service Connect / Cloud Map emulation is deferred (tracked in #460). Cross-service discovery between locally-run services is not provided.`);
50463
- return {
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 / Cloud Map (tracked in #460).
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 perReplicaSubnetOctet = 170 + instance.index % 84;
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
- subnetOctet: perReplicaSubnetOctet,
50728
- detach: true
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(target, options) {
51477
+ async function localStartServiceCommand(targets, options) {
50834
51478
  const logger = getLogger();
50835
51479
  if (options.verbose) logger.setLevel("debug");
50836
51480
  warnIfDeprecatedRegion(options);
50837
- const runState = createServiceRunState();
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 controller;
51488
+ let sharedNetwork;
50841
51489
  const cleanup = singleFlight(async () => {
50842
- if (controller) await controller.shutdown();
50843
- else await Promise.allSettled(runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
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 imageContext = await buildEcsImageResolutionContext(target, stacks, options);
50867
- const service = resolveEcsServiceTarget(target, stacks, imageContext);
50868
- logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
50869
- const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
50870
- if (options.fromState && taskNeeds.needsCrossStackResolver) {
50871
- const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
50872
- const built = await buildCrossStackResolver(consumerRegion, {
50873
- ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
50874
- statePrefix: options.statePrefix,
50875
- ...options.region !== void 0 && { region: options.region },
50876
- ...options.profile !== void 0 && { profile: options.profile }
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
- if (built) try {
50879
- const subContext = {
50880
- resources: imageContext?.stateResources ?? {},
50881
- ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
50882
- consumerRegion,
50883
- crossStackResolver: built.resolver
50884
- };
50885
- await applyCrossStackResolverToTask(service.task, subContext);
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
- let assumedCredentials;
50902
- let resolvedRoleArn;
50903
- if (options.assumeTaskRole === true) {
50904
- if (!service.task.taskRoleArn) throw new LocalStartServiceError("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>");
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 an AWS::ECS::Service locally as a long-running emulator. Spins up DesiredCount task replicas (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. Target accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::Service 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));
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.138.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());