cdk-local 0.36.1 → 0.37.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.
@@ -24,7 +24,7 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
24
24
  import { readFile } from "fs/promises";
25
25
  import { join as join$1 } from "path";
26
26
  import { WebSocketServer } from "ws";
27
- import { createServer as createServer$1 } from "node:http";
27
+ import { createServer as createServer$1, request } from "node:http";
28
28
  import { createServer as createServer$2 } from "node:https";
29
29
  import * as chokidar from "chokidar";
30
30
  import graphlib from "graphlib";
@@ -794,7 +794,7 @@ function parseTarget(target) {
794
794
  function resolveLambdaTarget(target, stacks) {
795
795
  if (stacks.length === 0) throw new LocalInvokeResolutionError("No stacks found in the synthesized assembly.");
796
796
  const parsed = parseTarget(target);
797
- const stack = pickStack$3(parsed, stacks);
797
+ const stack = pickStack$4(parsed, stacks);
798
798
  const template = stack.template;
799
799
  const resources = template.Resources ?? {};
800
800
  let match;
@@ -828,7 +828,7 @@ function resolveLambdaTarget(target, stacks) {
828
828
  * user may omit the stack prefix. Otherwise an explicit stack pattern is
829
829
  * required.
830
830
  */
831
- function pickStack$3(parsed, stacks) {
831
+ function pickStack$4(parsed, stacks) {
832
832
  if (parsed.stackPattern === null) {
833
833
  if (stacks.length === 1) return stacks[0];
834
834
  throw new LocalInvokeResolutionError(`Multiple stacks in app, target '${parsed.pathOrId}' is missing a stack prefix. Use 'StackName:${parsed.pathOrId}' or 'StackName/...' (path form). Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
@@ -1222,7 +1222,7 @@ var AgentCoreResolutionError = class AgentCoreResolutionError extends Error {
1222
1222
  function resolveAgentCoreTarget(target, stacks, imageContext) {
1223
1223
  if (stacks.length === 0) throw new AgentCoreResolutionError("No stacks found in the synthesized assembly.");
1224
1224
  const parsed = parseTarget(target);
1225
- const stack = pickStack$2(parsed, stacks);
1225
+ const stack = pickStack$3(parsed, stacks);
1226
1226
  const resources = stack.template.Resources ?? {};
1227
1227
  const { logicalId, resource } = matchRuntime(parsed, target, stack, resources);
1228
1228
  if (resource.Type !== "AWS::BedrockAgentCore::Runtime") throw new AgentCoreResolutionError(`Resource '${logicalId}' in ${stack.stackName} is ${resource.Type}, not ${AGENTCORE_RUNTIME_TYPE}. ${getEmbedConfig().cliName} invoke-agentcore only runs Bedrock AgentCore Runtime resources.`);
@@ -1234,7 +1234,7 @@ function resolveAgentCoreTarget(target, stacks, imageContext) {
1234
1234
  * Mirrors the Lambda / ECS resolvers' behavior via the shared
1235
1235
  * stack-matcher.
1236
1236
  */
1237
- function pickStack$2(parsed, stacks) {
1237
+ function pickStack$3(parsed, stacks) {
1238
1238
  if (parsed.stackPattern === null) {
1239
1239
  if (stacks.length === 1) return stacks[0];
1240
1240
  throw new AgentCoreResolutionError(`Multiple stacks in app, target '${parsed.pathOrId}' is missing a stack prefix. Use 'StackName:${parsed.pathOrId}' or 'StackName/...' (path form). Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
@@ -2974,12 +2974,30 @@ function listApiSurfaces(stacks) {
2974
2974
  return [...byKey.values()];
2975
2975
  }
2976
2976
  /**
2977
- * Walk every synthesized stack and collect the resources the four
2978
- * `cdkl` commands can run locally, grouped by command.
2977
+ * Enumerate the application Load Balancers `cdkl start-alb` can front. Only
2978
+ * `Type: application` (the default) is included NLBs / Gateway LBs are a
2979
+ * different architecture the local front-door does not emulate.
2980
+ */
2981
+ function scanApplicationLoadBalancers(stacks) {
2982
+ const entries = [];
2983
+ for (const stack of stacks) {
2984
+ const resources = stack.template.Resources ?? {};
2985
+ for (const [logicalId, resource] of Object.entries(resources)) {
2986
+ if (resource.Type !== "AWS::ElasticLoadBalancingV2::LoadBalancer") continue;
2987
+ const type = resource.Properties?.["Type"];
2988
+ if (type !== void 0 && type !== "application") continue;
2989
+ entries.push(makeEntry(stack.stackName, logicalId, readCdkPathOrUndefined(resource)));
2990
+ }
2991
+ }
2992
+ return entries;
2993
+ }
2994
+ /**
2995
+ * Walk every synthesized stack and collect the resources the `cdkl` commands
2996
+ * can run locally, grouped by command.
2979
2997
  *
2980
2998
  * Pure over the synthesized {@link StackInfo}[] — no Docker / AWS /
2981
- * filesystem access — so both `cdkl list` and (future) interactive
2982
- * target pickers can share it.
2999
+ * filesystem access — so both `cdkl list` and the interactive target
3000
+ * pickers can share it.
2983
3001
  */
2984
3002
  function listTargets(stacks) {
2985
3003
  return {
@@ -2987,7 +3005,8 @@ function listTargets(stacks) {
2987
3005
  apis: sortApiEntries(listApiSurfaces(stacks)),
2988
3006
  ecsServices: sortEntries(scanByType(stacks, "AWS::ECS::Service")),
2989
3007
  ecsTaskDefinitions: sortEntries(scanByType(stacks, "AWS::ECS::TaskDefinition")),
2990
- agentCoreRuntimes: sortEntries(scanByType(stacks, AGENTCORE_RUNTIME_TYPE))
3008
+ agentCoreRuntimes: sortEntries(scanByType(stacks, AGENTCORE_RUNTIME_TYPE)),
3009
+ loadBalancers: sortEntries(scanApplicationLoadBalancers(stacks))
2991
3010
  };
2992
3011
  }
2993
3012
  const pathOf = (e) => e.displayPath ?? e.qualifiedId;
@@ -3019,7 +3038,7 @@ function sortApiEntries(entries) {
3019
3038
  }
3020
3039
  /** Total number of targets across every category. */
3021
3040
  function countTargets(listing) {
3022
- return listing.lambdas.length + listing.apis.length + listing.ecsServices.length + listing.ecsTaskDefinitions.length + listing.agentCoreRuntimes.length;
3041
+ return listing.lambdas.length + listing.apis.length + listing.ecsServices.length + listing.ecsTaskDefinitions.length + listing.agentCoreRuntimes.length + listing.loadBalancers.length;
3023
3042
  }
3024
3043
 
3025
3044
  //#endregion
@@ -5333,7 +5352,7 @@ function parseEcsTarget(target) {
5333
5352
  function resolveEcsTaskTarget(target, stacks, context) {
5334
5353
  if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
5335
5354
  const parsed = parseEcsTarget(target);
5336
- const stack = pickStack$1(parsed, stacks);
5355
+ const stack = pickStack$2(parsed, stacks);
5337
5356
  const resources = stack.template.Resources ?? {};
5338
5357
  let logicalId;
5339
5358
  let resource;
@@ -5354,7 +5373,7 @@ function resolveEcsTaskTarget(target, stacks, context) {
5354
5373
  if (resource.Type !== "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`Resource '${logicalId}' in ${stack.stackName} is ${resource.Type}, not an AWS::ECS::TaskDefinition.`);
5355
5374
  return extractTaskDefinitionProperties(stack, logicalId, resource, context);
5356
5375
  }
5357
- function pickStack$1(parsed, stacks) {
5376
+ function pickStack$2(parsed, stacks) {
5358
5377
  if (parsed.stackPattern === null) {
5359
5378
  if (stacks.length === 1) return stacks[0];
5360
5379
  throw new EcsTaskResolutionError(`Multiple stacks in app, target '${parsed.pathOrId}' is missing a stack prefix. Use 'StackName:${parsed.pathOrId}' or 'StackName/...' (path form). Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
@@ -13976,7 +13995,7 @@ async function startApiServer(opts) {
13976
13995
  const requestHandler = (req, res) => {
13977
13996
  handleRequest(req, res, currentState, opts).catch((err) => {
13978
13997
  logger.error(`Unhandled request error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
13979
- if (!res.headersSent) writeError(res, 502);
13998
+ if (!res.headersSent) writeError$1(res, 502);
13980
13999
  });
13981
14000
  };
13982
14001
  const server = opts.mtls ? createServer$2({
@@ -14049,7 +14068,7 @@ async function handleRequest(req, res, state, opts) {
14049
14068
  if (await opts.preDispatch(req, res)) return;
14050
14069
  } catch (err) {
14051
14070
  logger.error(`preDispatch hook threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
14052
- if (!res.headersSent) writeError(res, 502);
14071
+ if (!res.headersSent) writeError$1(res, 502);
14053
14072
  return;
14054
14073
  }
14055
14074
  const bodyBuf = await readBody(req);
@@ -14059,7 +14078,7 @@ async function handleRequest(req, res, state, opts) {
14059
14078
  if (method === "OPTIONS" ? maybeHandleCorsPreflight(req, res, requestPath, state) : false) return;
14060
14079
  const match = matchRoute(method, requestPath, state.routes.map((r) => r.route));
14061
14080
  if (!match) {
14062
- writeError(res, 404, "{\"message\":\"Not Found\"}");
14081
+ writeError$1(res, 404, "{\"message\":\"Not Found\"}");
14063
14082
  return;
14064
14083
  }
14065
14084
  const requestOrigin = pickFirstHeaderValue(collectHeaders(req), "origin") ?? void 0;
@@ -14125,7 +14144,7 @@ async function handleRequest(req, res, state, opts) {
14125
14144
  logger.error(`REST v1 ${match.route.restV1Integration.kind} dispatch failed for ${match.route.declaredAt}: ${err instanceof Error ? err.message : String(err)}`);
14126
14145
  if (!res.headersSent) {
14127
14146
  applyCors();
14128
- writeError(res, 502);
14147
+ writeError$1(res, 502);
14129
14148
  } else res.end();
14130
14149
  }
14131
14150
  return;
@@ -14136,7 +14155,7 @@ async function handleRequest(req, res, state, opts) {
14136
14155
  } catch (err) {
14137
14156
  logger.error(`Failed to acquire container for ${match.route.lambdaLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
14138
14157
  applyCors();
14139
- writeError(res, 502);
14158
+ writeError$1(res, 502);
14140
14159
  return;
14141
14160
  }
14142
14161
  if (match.route.invokeMode === "RESPONSE_STREAM") {
@@ -14156,7 +14175,7 @@ async function handleRequest(req, res, state, opts) {
14156
14175
  logger.error(`RIE streaming invoke failed for ${match.route.lambdaLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
14157
14176
  if (!res.headersSent) {
14158
14177
  applyCors();
14159
- writeError(res, 502);
14178
+ writeError$1(res, 502);
14160
14179
  } else res.end();
14161
14180
  state.pool.release(handle);
14162
14181
  return;
@@ -14173,7 +14192,7 @@ async function handleRequest(req, res, state, opts) {
14173
14192
  logger.error(`RIE invoke failed for ${match.route.lambdaLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
14174
14193
  if (!res.headersSent) {
14175
14194
  applyCors();
14176
- writeError(res, 502);
14195
+ writeError$1(res, 502);
14177
14196
  } else res.end();
14178
14197
  } finally {
14179
14198
  state.pool.release(handle);
@@ -14582,25 +14601,25 @@ function buildOverlay(authorizer, result, routeApiVersion) {
14582
14601
  function writeAuthRejection(res, apiVersion, denyKind, authorizerKind) {
14583
14602
  if (apiVersion === "v1" && authorizerKind === "iam") {
14584
14603
  if (denyKind === "missing-identity") {
14585
- writeError(res, 403, "{\"message\":\"Missing Authentication Token\"}");
14604
+ writeError$1(res, 403, "{\"message\":\"Missing Authentication Token\"}");
14586
14605
  return;
14587
14606
  }
14588
- writeError(res, 403, "{\"message\":\"Forbidden\"}");
14607
+ writeError$1(res, 403, "{\"message\":\"Forbidden\"}");
14589
14608
  return;
14590
14609
  }
14591
14610
  if (apiVersion === "v2" && authorizerKind === "iam") {
14592
- writeError(res, 403, "{\"Message\":\"Forbidden\"}");
14611
+ writeError$1(res, 403, "{\"Message\":\"Forbidden\"}");
14593
14612
  return;
14594
14613
  }
14595
14614
  if (apiVersion === "v2") {
14596
- writeError(res, 401, "{\"message\":\"Unauthorized\"}");
14615
+ writeError$1(res, 401, "{\"message\":\"Unauthorized\"}");
14597
14616
  return;
14598
14617
  }
14599
14618
  if (denyKind === "missing-identity") {
14600
- writeError(res, 401, "{\"message\":\"Unauthorized\"}");
14619
+ writeError$1(res, 401, "{\"message\":\"Unauthorized\"}");
14601
14620
  return;
14602
14621
  }
14603
- writeError(res, 403, "{\"message\":\"Forbidden\"}");
14622
+ writeError$1(res, 403, "{\"message\":\"Forbidden\"}");
14604
14623
  }
14605
14624
  function hashOne(value) {
14606
14625
  return value;
@@ -14688,7 +14707,7 @@ function collectHeaders(req) {
14688
14707
  * the handler at all (no matching route, container acquire failed, RIE
14689
14708
  * unreachable).
14690
14709
  */
14691
- function writeError(res, statusCode, body = "{\"message\":\"Internal server error\"}") {
14710
+ function writeError$1(res, statusCode, body = "{\"message\":\"Internal server error\"}") {
14692
14711
  res.statusCode = statusCode;
14693
14712
  res.setHeader("content-type", "application/json");
14694
14713
  res.setHeader("content-length", String(Buffer.byteLength(body, "utf-8")));
@@ -14723,7 +14742,7 @@ async function handleServiceIntegrationRequest(req, res, match, bodyBuf, opts, a
14723
14742
  const route = match.route;
14724
14743
  const svc = route.serviceIntegration;
14725
14744
  if (!svc) {
14726
- writeError(res, 500);
14745
+ writeError$1(res, 500);
14727
14746
  return;
14728
14747
  }
14729
14748
  const rawUrl = req.url ?? "/";
@@ -18007,6 +18026,7 @@ async function runEcsTask(task, options, state) {
18007
18026
  sidecarIp: state.network.sidecarIp,
18008
18027
  ...options.skipHostPortPublish ? { skipHostPortPublish: true } : {},
18009
18028
  ...options.hostPortOverrides ? { hostPortOverrides: options.hostPortOverrides } : {},
18029
+ ...options.ephemeralPublishContainerPorts && options.ephemeralPublishContainerPorts.length > 0 ? { ephemeralPublishContainerPorts: options.ephemeralPublishContainerPorts } : {},
18010
18030
  ...options.addHostFlags && options.addHostFlags.length > 0 ? { addHostFlags: options.addHostFlags } : {},
18011
18031
  ...(options.networkAliasesByContainer?.get(container.name)?.length ?? 0) > 0 ? { networkAliases: options.networkAliasesByContainer.get(container.name) } : {},
18012
18032
  ...options.profileCredentialsFile && { profileCredentialsFile: options.profileCredentialsFile }
@@ -18368,12 +18388,22 @@ function buildDockerRunArgs(opts) {
18368
18388
  if (opts.addHostFlags && opts.addHostFlags.length > 0) for (const f of opts.addHostFlags) args.push(f);
18369
18389
  if (opts.platformOverride) args.push("--platform", opts.platformOverride);
18370
18390
  else if (task.runtimePlatform) args.push("--platform", task.runtimePlatform.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64");
18391
+ const ephemeralPorts = new Set(opts.ephemeralPublishContainerPorts ?? []);
18371
18392
  if (!opts.skipHostPortPublish) for (const pm of container.portMappings) {
18393
+ if (ephemeralPorts.has(pm.containerPort)) continue;
18372
18394
  const declaredHostPort = pm.hostPort ?? pm.containerPort;
18373
18395
  const hostPort = opts.hostPortOverrides?.[pm.containerPort] ?? declaredHostPort;
18374
18396
  if (hostPort !== declaredHostPort) getLogger().child("ecs").info(`Container '${container.name}' container port ${pm.containerPort} published on ${containerHost}:${hostPort} (--host-port override). Reach it at ${containerHost}:${hostPort}.`);
18375
18397
  args.push("-p", `${containerHost}:${hostPort}:${pm.containerPort}/${pm.protocol}`);
18376
18398
  }
18399
+ if (ephemeralPorts.size > 0) {
18400
+ const alreadyEphemeral = /* @__PURE__ */ new Set();
18401
+ for (const pm of container.portMappings) {
18402
+ if (!ephemeralPorts.has(pm.containerPort) || alreadyEphemeral.has(pm.containerPort)) continue;
18403
+ alreadyEphemeral.add(pm.containerPort);
18404
+ args.push("-p", `${containerHost}::${pm.containerPort}/${pm.protocol}`);
18405
+ }
18406
+ }
18377
18407
  if (opts.profileCredentialsFile) args.push("-v", `${opts.profileCredentialsFile.hostPath}:${opts.profileCredentialsFile.containerPath}:ro`);
18378
18408
  for (const mp of container.mountPoints) {
18379
18409
  const v = volumeByName.get(mp.sourceVolume);
@@ -18772,7 +18802,7 @@ function createLocalRunTaskCommand(opts = {}) {
18772
18802
  function resolveEcsServiceTarget(target, stacks, context) {
18773
18803
  if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
18774
18804
  const parsed = parseEcsTarget(target);
18775
- const stack = pickStack(parsed, stacks);
18805
+ const stack = pickStack$1(parsed, stacks);
18776
18806
  const resources = stack.template.Resources ?? {};
18777
18807
  let serviceLogicalId;
18778
18808
  let serviceResource;
@@ -18974,7 +19004,7 @@ function parseServiceName(raw, serviceLogicalId) {
18974
19004
  * service-specific extensions (e.g. cross-stack service-to-task refs)
18975
19005
  * can diverge without breaking the run-task code path.
18976
19006
  */
18977
- function pickStack(parsed, stacks) {
19007
+ function pickStack$1(parsed, stacks) {
18978
19008
  if (parsed.stackPattern === null) {
18979
19009
  if (stacks.length === 1) return stacks[0];
18980
19010
  throw new EcsTaskResolutionError(`Target has no stack prefix, and the assembly contains ${stacks.length} stacks: ${stacks.map((s) => s.stackName).join(", ")}. Pass the target as 'Stack/Path' or 'Stack:LogicalId'.`);
@@ -19042,6 +19072,35 @@ async function getContainerNetworkIp(containerId, networkName) {
19042
19072
  return;
19043
19073
  }
19044
19074
  }
19075
+ /**
19076
+ * Issue #86 v1 helper — read back the docker-assigned host port a container
19077
+ * port was published on. The local ALB front-door publishes each replica's
19078
+ * target container port on an ephemeral host port (`-p <host>::<containerPort>`)
19079
+ * and round-robins to `127.0.0.1:<hostPort>`; this resolves that host port
19080
+ * after the replica boots.
19081
+ *
19082
+ * Uses a nil-safe Go template (`with index`) so a container that did NOT
19083
+ * publish the port yields empty output instead of a template error. Returns
19084
+ * `undefined` when the port is unpublished, the container vanished, or the
19085
+ * value isn't a parseable port.
19086
+ */
19087
+ async function getPublishedHostPort(containerId, containerPort, protocol = "tcp") {
19088
+ const format = `{{with index .NetworkSettings.Ports "${`${containerPort}/${protocol}`}"}}{{with index . 0}}{{.HostPort}}{{end}}{{end}}`;
19089
+ try {
19090
+ const { stdout } = await execFileAsync(getDockerCmd(), [
19091
+ "inspect",
19092
+ "--format",
19093
+ format,
19094
+ containerId
19095
+ ]);
19096
+ const raw = stdout.trim();
19097
+ if (!raw) return void 0;
19098
+ const port = parseInt(raw, 10);
19099
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : void 0;
19100
+ } catch {
19101
+ return;
19102
+ }
19103
+ }
19045
19104
 
19046
19105
  //#endregion
19047
19106
  //#region src/local/ecs-service-runner.ts
@@ -19172,7 +19231,8 @@ async function startEcsService(service, options, runState) {
19172
19231
  restartCount: 0,
19173
19232
  shuttingDown: false,
19174
19233
  inFlightBoot: void 0,
19175
- cloudMapHandles: []
19234
+ cloudMapHandles: [],
19235
+ frontDoorOwnerKey: void 0
19176
19236
  };
19177
19237
  runState.replicas.push(instance);
19178
19238
  const bootPromise = bootReplica(service, options, instance);
@@ -19260,6 +19320,7 @@ var ServiceController = class {
19260
19320
  } catch {}
19261
19321
  instance.cloudMapHandles = [];
19262
19322
  }
19323
+ unregisterReplicaFromFrontDoor(instance, this.options.frontDoor);
19263
19324
  try {
19264
19325
  await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
19265
19326
  } catch (err) {
@@ -19316,6 +19377,7 @@ async function bootReplica(service, options, instance) {
19316
19377
  const sharedNetwork = options.discovery?.sharedNetwork;
19317
19378
  const networkAliasesByContainer = buildNetworkAliasesByContainer(service);
19318
19379
  const skipHostPortPublish = computeReplicaCount(service.desiredCount, options.maxTasks) > 1;
19380
+ const ephemeralPublishContainerPorts = options.frontDoor ? [...new Set(options.frontDoor.pools.map((p) => p.targetContainerPort))] : [];
19319
19381
  const perReplicaTaskOptions = {
19320
19382
  ...options.taskOptions,
19321
19383
  cluster: perReplicaCluster,
@@ -19323,11 +19385,13 @@ async function bootReplica(service, options, instance) {
19323
19385
  ...skipHostPortPublish ? { skipHostPortPublish: true } : {},
19324
19386
  ...sharedNetwork ? { existingNetwork: sharedNetwork } : { subnetOctet: pickSubnetOctet(instance.index) },
19325
19387
  ...addHostFlags.length > 0 ? { addHostFlags } : {},
19326
- ...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {}
19388
+ ...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {},
19389
+ ...ephemeralPublishContainerPorts.length > 0 ? { ephemeralPublishContainerPorts } : {}
19327
19390
  };
19328
19391
  logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
19329
19392
  await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
19330
19393
  if (options.discovery) await publishReplicaToCloudMap(service, instance, options.discovery, ownerKeyPrefix);
19394
+ if (options.frontDoor) await publishReplicaToFrontDoor(service, instance, options.frontDoor, options.taskOptions.containerHost, ownerKeyPrefix);
19331
19395
  }
19332
19396
  /**
19333
19397
  * After the replica's main container is up, discover its docker
@@ -19408,6 +19472,57 @@ async function publishReplicaToCloudMap(service, instance, discovery, ownerKeyPr
19408
19472
  }
19409
19473
  }
19410
19474
  /**
19475
+ * Issue #86 v1 — register this replica's host-reachable endpoint into every
19476
+ * front-door pool. For each pool, find the target container's docker id, read
19477
+ * back the ephemeral host port docker assigned to its target container port
19478
+ * (`-p <host>::<port>`), and register `<containerHost>:<hostPort>` under the
19479
+ * per-replica owner key.
19480
+ *
19481
+ * Best-effort, mirroring `publishReplicaToCloudMap`: a missing container / port
19482
+ * logs a warn and skips that pool rather than aborting the replica (the
19483
+ * replica is alive; the front-door just can't route to it until it re-boots).
19484
+ * The owner key is stamped on the instance so the restart / shutdown paths can
19485
+ * `pool.unregister` symmetrically.
19486
+ */
19487
+ async function publishReplicaToFrontDoor(service, instance, frontDoor, containerHost, ownerKeyPrefix) {
19488
+ const logger = getLogger().child("ecs-service");
19489
+ instance.frontDoorOwnerKey = ownerKeyPrefix;
19490
+ for (const target of frontDoor.pools) {
19491
+ const started = instance.state.startedContainers.find((c) => c.name === target.targetContainerName);
19492
+ if (!started) {
19493
+ logger.warn(`ECS Service '${service.serviceLogicalId}' front-door: container '${target.targetContainerName}' did not start for replica ${instance.index}; the front-door cannot route to it.`);
19494
+ continue;
19495
+ }
19496
+ let hostPort;
19497
+ try {
19498
+ hostPort = await getPublishedHostPort(started.id, target.targetContainerPort);
19499
+ } catch (err) {
19500
+ logger.warn(`Replica ${instance.index}: docker inspect failed before front-door publish: ${err instanceof Error ? err.message : String(err)}`);
19501
+ continue;
19502
+ }
19503
+ if (hostPort === void 0) {
19504
+ logger.warn(`Replica ${instance.index}: container port ${target.targetContainerPort} on '${target.targetContainerName}' was not published on a host port; skipping front-door registration for this replica.`);
19505
+ continue;
19506
+ }
19507
+ target.pool.register(ownerKeyPrefix, {
19508
+ host: containerHost,
19509
+ port: hostPort
19510
+ });
19511
+ logger.debug(`Front-door: replica ${instance.index} registered at ${containerHost}:${hostPort} (container ${target.targetContainerName}:${target.targetContainerPort}).`);
19512
+ }
19513
+ }
19514
+ /**
19515
+ * Drop this replica's endpoint from every front-door pool. Idempotent; called
19516
+ * from the watcher restart branch and the controller shutdown path so a
19517
+ * dying / restarting replica is removed from round-robin before its container
19518
+ * is torn down.
19519
+ */
19520
+ function unregisterReplicaFromFrontDoor(instance, frontDoor) {
19521
+ if (!frontDoor || !instance.frontDoorOwnerKey) return;
19522
+ for (const target of frontDoor.pools) target.pool.unregister(instance.frontDoorOwnerKey);
19523
+ instance.frontDoorOwnerKey = void 0;
19524
+ }
19525
+ /**
19411
19526
  * Long-running watcher loop for one replica. Polls the essential
19412
19527
  * container's exit code via `docker wait`; on exit, decides whether to
19413
19528
  * restart per `restartPolicy` + applies exponential backoff. The loop
@@ -19447,6 +19562,7 @@ async function watchReplica(service, options, instance, runState) {
19447
19562
  } catch {}
19448
19563
  instance.cloudMapHandles = [];
19449
19564
  }
19565
+ unregisterReplicaFromFrontDoor(instance, options.frontDoor);
19450
19566
  try {
19451
19567
  await cleanupEcsRun(instance.state, { keepRunning: false });
19452
19568
  } catch (err) {
@@ -19859,14 +19975,176 @@ function extractDnsRecords(serviceProps) {
19859
19975
  }
19860
19976
 
19861
19977
  //#endregion
19862
- //#region src/cli/commands/local-start-service.ts
19978
+ //#region src/local/front-door-pool.ts
19979
+ var FrontDoorEndpointPool = class {
19980
+ entries = [];
19981
+ /** Monotonic counter; `next()` rotates over the current entries by index. */
19982
+ cursor = 0;
19983
+ /**
19984
+ * Register (or idempotently replace) the endpoint for `ownerKey`. A replica
19985
+ * restart calls this again with the same key and the new ephemeral port; the
19986
+ * prior entry for that key is replaced rather than duplicated.
19987
+ */
19988
+ register(ownerKey, endpoint) {
19989
+ if (!ownerKey) throw new Error("FrontDoorEndpointPool.register: ownerKey must be non-empty.");
19990
+ const next = this.entries.filter((e) => e.ownerKey !== ownerKey);
19991
+ next.push({
19992
+ ownerKey,
19993
+ host: endpoint.host,
19994
+ port: endpoint.port
19995
+ });
19996
+ this.entries = next;
19997
+ }
19998
+ /** Drop the endpoint for `ownerKey`. Idempotent; returns whether one was removed. */
19999
+ unregister(ownerKey) {
20000
+ const next = this.entries.filter((e) => e.ownerKey !== ownerKey);
20001
+ const removed = next.length !== this.entries.length;
20002
+ this.entries = next;
20003
+ return removed;
20004
+ }
20005
+ /**
20006
+ * Round-robin the next live endpoint, or `undefined` when the pool is empty
20007
+ * (the front-door server replies 503 in that case). The cursor advances per
20008
+ * call; modulo by the current length tolerates entries being added / removed
20009
+ * between calls.
20010
+ */
20011
+ next() {
20012
+ if (this.entries.length === 0) return void 0;
20013
+ const entry = this.entries[this.cursor % this.entries.length];
20014
+ this.cursor = (this.cursor + 1) % Number.MAX_SAFE_INTEGER;
20015
+ return {
20016
+ host: entry.host,
20017
+ port: entry.port
20018
+ };
20019
+ }
20020
+ /** Snapshot of the current endpoints (for diagnostics / tests). */
20021
+ list() {
20022
+ return this.entries.map((e) => ({
20023
+ host: e.host,
20024
+ port: e.port
20025
+ }));
20026
+ }
20027
+ /** Number of live endpoints. */
20028
+ size() {
20029
+ return this.entries.length;
20030
+ }
20031
+ };
20032
+
20033
+ //#endregion
20034
+ //#region src/local/front-door-server.ts
20035
+ /** Default per-request upstream timeout — a hung replica yields a 504, not a hang. */
20036
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 3e4;
20037
+ async function startFrontDoorServer(opts) {
20038
+ const logger = getLogger().child("front-door");
20039
+ const host = opts.host ?? "127.0.0.1";
20040
+ const server = createServer$1((req, res) => {
20041
+ handleProxyRequest(req, res, opts).catch((err) => {
20042
+ logger.debug(`front-door request error: ${err instanceof Error ? err.message : String(err)}`);
20043
+ if (!res.headersSent) writeError(res, 502, "Bad Gateway");
20044
+ });
20045
+ });
20046
+ server.on("connection", (socket) => socket.setNoDelay(true));
20047
+ const boundPort = await new Promise((resolve, reject) => {
20048
+ server.once("error", reject);
20049
+ server.listen(opts.port, host, () => {
20050
+ const addr = server.address();
20051
+ if (addr === null || typeof addr === "string") {
20052
+ reject(/* @__PURE__ */ new Error("Could not determine front-door listening address"));
20053
+ return;
20054
+ }
20055
+ resolve(addr.port);
20056
+ });
20057
+ });
20058
+ let closed = false;
20059
+ return {
20060
+ port: boundPort,
20061
+ host,
20062
+ server,
20063
+ close: async () => {
20064
+ if (closed) return;
20065
+ closed = true;
20066
+ await new Promise((resolve) => {
20067
+ server.close(() => resolve());
20068
+ server.closeAllConnections?.();
20069
+ });
20070
+ }
20071
+ };
20072
+ }
20073
+ function handleProxyRequest(req, res, opts) {
20074
+ return new Promise((resolve) => {
20075
+ const endpoint = opts.pool.next();
20076
+ if (!endpoint) {
20077
+ writeError(res, 503, `No running replicas for service '${opts.serviceName}'. The front-door has no healthy target to forward to.`);
20078
+ resolve();
20079
+ return;
20080
+ }
20081
+ let settled = false;
20082
+ const done = () => {
20083
+ if (settled) return;
20084
+ settled = true;
20085
+ resolve();
20086
+ };
20087
+ const headers = { ...req.headers };
20088
+ appendForwardedHeaders(headers, req, opts.listenerPort);
20089
+ const proxyReq = request({
20090
+ host: endpoint.host,
20091
+ port: endpoint.port,
20092
+ method: req.method,
20093
+ path: req.url,
20094
+ headers
20095
+ }, (proxyRes) => {
20096
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
20097
+ proxyRes.pipe(res);
20098
+ proxyRes.on("end", done);
20099
+ proxyRes.on("error", () => {
20100
+ if (!res.writableEnded) res.destroy();
20101
+ done();
20102
+ });
20103
+ });
20104
+ proxyReq.setTimeout(opts.upstreamTimeoutMs ?? 3e4, () => {
20105
+ if (!res.headersSent) writeError(res, 504, `Replica ${endpoint.host}:${endpoint.port} for service '${opts.serviceName}' did not respond in time.`);
20106
+ else if (!res.writableEnded) res.destroy();
20107
+ proxyReq.destroy();
20108
+ done();
20109
+ });
20110
+ proxyReq.on("error", () => {
20111
+ if (!res.headersSent) writeError(res, 502, `Failed to reach replica ${endpoint.host}:${endpoint.port} for service '${opts.serviceName}'.`);
20112
+ else if (!res.writableEnded) res.destroy();
20113
+ done();
20114
+ });
20115
+ res.on("close", () => {
20116
+ if (!res.writableEnded) proxyReq.destroy();
20117
+ });
20118
+ req.pipe(proxyReq);
20119
+ });
20120
+ }
19863
20121
  /**
19864
- * `cdkl start-service <Stack/Service>` Phase 2 of #262. Spins up
19865
- * `DesiredCount` task replicas locally (clamped by `--max-tasks`) using
19866
- * the existing `ecs-task-runner` per replica. Long-running; ^C cleans
19867
- * every replica + sidecar + per-task network.
20122
+ * Inject the ALB-style forwarding headers a downstream app may read. Appends
20123
+ * the client IP to any existing `X-Forwarded-For` chain (ALB appends rather
20124
+ * than replaces) and stamps the scheme / listener port.
20125
+ */
20126
+ function appendForwardedHeaders(headers, req, listenerPort) {
20127
+ const clientIp = req.socket.remoteAddress ?? "";
20128
+ const existing = headers["x-forwarded-for"];
20129
+ const chain = Array.isArray(existing) ? existing.join(", ") : existing;
20130
+ headers["x-forwarded-for"] = chain ? `${chain}, ${clientIp}` : clientIp;
20131
+ headers["x-forwarded-proto"] = "http";
20132
+ headers["x-forwarded-port"] = String(listenerPort);
20133
+ }
20134
+ function writeError(res, statusCode, message) {
20135
+ res.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
20136
+ res.end(`${message}\n`);
20137
+ }
20138
+
20139
+ //#endregion
20140
+ //#region src/cli/commands/ecs-service-emulator.ts
20141
+ /**
20142
+ * Long-running ECS-service emulator. Synths the app, resolves the strategy's
20143
+ * targets into service boots, boots every replica pool (with optional
20144
+ * front-door), and blocks until `^C`. Idempotent single-flight cleanup tears
20145
+ * down every replica + front-door server + the shared network + sidecar.
19868
20146
  */
19869
- async function localStartServiceCommand(targets, options, extraStateProviders) {
20147
+ async function runEcsServiceEmulator(targets, options, strategy, extraStateProviders) {
19870
20148
  const logger = getLogger();
19871
20149
  if (options.verbose) logger.setLevel("debug");
19872
20150
  warnIfDeprecatedRegion(options);
@@ -19883,6 +20161,7 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19883
20161
  await Promise.allSettled(pt.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0));
19884
20162
  await Promise.allSettled(pt.runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
19885
20163
  }
20164
+ await Promise.allSettled((pt.frontDoorServers ?? []).map((s) => s.close().catch((err) => getLogger().warn(`front-door server teardown failed: ${err instanceof Error ? err.message : String(err)}`))));
19886
20165
  }));
19887
20166
  if (profileCredsFile) {
19888
20167
  try {
@@ -19921,14 +20200,17 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19921
20200
  };
19922
20201
  const { stacks } = await synthesizer.synthesize(synthOpts);
19923
20202
  const resolvedTargets = await resolveMultiTarget(targets, {
19924
- entries: listTargets(stacks).ecsServices,
19925
- message: "Select one or more ECS services to run",
19926
- noun: "ECS services",
19927
- onMissing: () => new LocalStartServiceError(`${getEmbedConfig().cliName} start-service requires at least one <target>. Pass one or more service paths like 'Stack/Orders' 'Stack/Frontend', or run it in a TTY to pick interactively.`)
20203
+ entries: strategy.pickEntries(stacks),
20204
+ message: strategy.pickerMessage,
20205
+ noun: strategy.pickerNoun,
20206
+ onMissing: () => strategy.onMissing()
19928
20207
  });
19929
- rejectExplicitCfnStackWithMultipleStacks(options, resolvedTargets.length);
19930
- perTarget = resolvedTargets.map((t) => ({
19931
- target: t,
20208
+ const { boots, warnings } = strategy.resolveBoots(stacks, resolvedTargets);
20209
+ for (const w of warnings) logger.warn(w);
20210
+ if (boots.length === 0) throw new LocalStartServiceError(`No runnable ECS service resolved from ${resolvedTargets.join(", ")}.`);
20211
+ rejectExplicitCfnStackWithMultipleStacks(options, boots.length);
20212
+ perTarget = boots.map((boot) => ({
20213
+ boot,
19932
20214
  runState: createServiceRunState()
19933
20215
  }));
19934
20216
  const cloudMapIndexByStack = /* @__PURE__ */ new Map();
@@ -19966,7 +20248,11 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19966
20248
  };
19967
20249
  process.on("SIGINT", sigintHandler);
19968
20250
  process.on("SIGTERM", sigintHandler);
19969
- for (const pt of perTarget) pt.controller = await bootOneTarget(pt.target, pt.runState, stacks, options, discovery, skipPull, extraStateProviders, profileCredsFile);
20251
+ for (const pt of perTarget) {
20252
+ const booted = await bootOneTarget(pt.boot, pt.runState, stacks, options, discovery, skipPull, extraStateProviders, profileCredsFile, strategy.lbPortOverrides);
20253
+ pt.controller = booted.controller;
20254
+ pt.frontDoorServers = booted.frontDoorServers;
20255
+ }
19970
20256
  const summary = perTarget.map((pt) => `${pt.controller.service.serviceName} (${pt.controller.activeReplicaCount()} replica(s))`).join(", ");
19971
20257
  logger.info(`Service(s) running: ${summary}.`);
19972
20258
  logger.info("Press ^C to shut down.");
@@ -19979,21 +20265,18 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19979
20265
  await cleanup();
19980
20266
  }
19981
20267
  }
19982
- /**
19983
- * Boot one target. Returns the started controller for the outer code
19984
- * to wait + tear down.
19985
- */
19986
- async function bootOneTarget(target, runState, stacks, options, discovery, skipPull, extraStateProviders, profileCredsFile) {
19987
- const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
20268
+ async function bootOneTarget(boot, runState, stacks, options, discovery, skipPull, extraStateProviders, profileCredsFile, lbPortOverrides) {
20269
+ const candidate = pickCandidateStack(parseEcsTarget(boot.target).stackPattern, stacks);
19988
20270
  const stateProvider = createLocalStateProvider(options, candidate?.stackName ?? "", await resolveCfnFallbackRegion(options, candidate?.region), extraStateProviders);
19989
20271
  try {
19990
- return await runOneTarget(target, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile);
20272
+ return await runOneTarget(boot, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile, lbPortOverrides);
19991
20273
  } finally {
19992
20274
  if (stateProvider) stateProvider.dispose();
19993
20275
  }
19994
20276
  }
19995
- async function runOneTarget(target, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile) {
20277
+ async function runOneTarget(boot, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile, lbPortOverrides) {
19996
20278
  const logger = getLogger();
20279
+ const target = boot.target;
19997
20280
  const imageContext = await buildEcsImageResolutionContext(target, stacks, options, stateProvider);
19998
20281
  const service = resolveEcsServiceTarget(target, stacks, imageContext);
19999
20282
  logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
@@ -20047,12 +20330,72 @@ async function runOneTarget(target, runState, stacks, options, discovery, skipPu
20047
20330
  containerPath: profileCredsFile.containerPath,
20048
20331
  profileName: profileCredsFile.profileName
20049
20332
  };
20050
- return startEcsService(service, {
20333
+ const { frontDoorContext, frontDoorServers } = await startFrontDoorServers(boot.frontDoorTargets, service, options.containerHost, lbPortOverrides, logger);
20334
+ const runnerOpts = {
20051
20335
  maxTasks: options.maxTasks,
20052
20336
  restartPolicy: options.restartPolicy,
20053
20337
  taskOptions: taskOpts,
20054
- discovery
20055
- }, runState);
20338
+ discovery,
20339
+ ...frontDoorContext ? { frontDoor: frontDoorContext } : {}
20340
+ };
20341
+ let controller;
20342
+ try {
20343
+ controller = await startEcsService(service, runnerOpts, runState);
20344
+ } catch (err) {
20345
+ await Promise.allSettled(frontDoorServers.map((s) => s.close()));
20346
+ throw err;
20347
+ }
20348
+ return {
20349
+ controller,
20350
+ frontDoorServers
20351
+ };
20352
+ }
20353
+ /**
20354
+ * Issue #86 v1 — stand up one host-side reverse-proxy server per resolved
20355
+ * front-door listener and return the {@link FrontDoorRunnerContext} to thread
20356
+ * into the runner (so each replica publishes + registers its ephemeral
20357
+ * endpoint), plus the started servers for teardown. Pure front-door MECHANISM:
20358
+ * the ALB-specific resolution that produced `frontDoorTargets` lives in the
20359
+ * `start-alb` command. Returns an undefined context + empty servers when there
20360
+ * are no front-door targets (the pure-compute path).
20361
+ *
20362
+ * On a bind failure (e.g. EACCES on a privileged listener port, or the port is
20363
+ * already in use) every server started so far is closed and the error is
20364
+ * re-thrown with a `--lb-port` hint.
20365
+ */
20366
+ async function startFrontDoorServers(frontDoorTargets, service, containerHost, lbPortOverrides, logger) {
20367
+ if (frontDoorTargets.length === 0) return {
20368
+ frontDoorContext: void 0,
20369
+ frontDoorServers: []
20370
+ };
20371
+ const frontDoorServers = [];
20372
+ const pools = [];
20373
+ try {
20374
+ for (const t of frontDoorTargets) {
20375
+ const pool = new FrontDoorEndpointPool();
20376
+ const server = await startFrontDoorServer({
20377
+ pool,
20378
+ port: lbPortOverrides[t.listenerPort] ?? t.listenerPort,
20379
+ host: containerHost,
20380
+ listenerPort: t.listenerPort,
20381
+ serviceName: service.serviceName
20382
+ });
20383
+ frontDoorServers.push(server);
20384
+ pools.push({
20385
+ pool,
20386
+ targetContainerName: t.targetContainerName,
20387
+ targetContainerPort: t.targetContainerPort
20388
+ });
20389
+ logger.info(`ALB front-door: http://${server.host}:${server.port} -> ${service.serviceName} (listener port ${t.listenerPort} -> container ${t.targetContainerName}:${t.targetContainerPort}, round-robin across replicas)`);
20390
+ }
20391
+ } catch (err) {
20392
+ await Promise.allSettled(frontDoorServers.map((s) => s.close()));
20393
+ throw new LocalStartServiceError(`Failed to start ALB front-door for service '${service.serviceName}': ${err instanceof Error ? err.message : String(err)}. If the listener port is privileged (< 1024), remap it to a non-privileged host port with --lb-port <listenerPort>=<hostPort> (e.g. --lb-port 80=8080).`);
20394
+ }
20395
+ return {
20396
+ frontDoorContext: { pools },
20397
+ frontDoorServers
20398
+ };
20056
20399
  }
20057
20400
  async function resolvePlaceholderAccount(arn, region) {
20058
20401
  if (!arn.includes("${AWS::AccountId}")) return arn;
@@ -20086,11 +20429,9 @@ async function assumeTaskRole(roleArn, region) {
20086
20429
  }
20087
20430
  }
20088
20431
  /**
20089
- * Build the substitution context the ECS resolver consumes.
20090
- *
20091
- * Exported for the site-level binding test that locks the `--from-cfn-stack`
20092
- * SSM-parameter resolution call (issue #94) into this start-service call site
20093
- * (mirrors `local-run-task`'s exported helper).
20432
+ * Build the substitution context the ECS resolver consumes. Exported for the
20433
+ * site-level binding test that locks the `--from-cfn-stack` SSM-parameter
20434
+ * resolution call (issue #94).
20094
20435
  */
20095
20436
  async function buildEcsImageResolutionContext(target, stacks, options, stateProvider) {
20096
20437
  const logger = getLogger();
@@ -20175,9 +20516,8 @@ function parsePositiveInt(raw, flagName) {
20175
20516
  return parsed;
20176
20517
  }
20177
20518
  /**
20178
- * Hard cap on `--max-tasks` driven by the per-replica subnet allocator
20179
- * in `ecs-service-runner.ts:pickSubnetOctet`. The allocator walks the
20180
- * link-local /24 range `169.254.170.0..169.254.253.0` and skips 171.
20519
+ * Hard cap on `--max-tasks` driven by the per-replica subnet allocator in
20520
+ * `ecs-service-runner.ts:pickSubnetOctet`.
20181
20521
  */
20182
20522
  const MAX_TASKS_SUBNET_RANGE_CAP = 83;
20183
20523
  function parseMaxTasks(raw) {
@@ -20190,37 +20530,23 @@ function parseRestartPolicy(raw) {
20190
20530
  throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
20191
20531
  }
20192
20532
  /**
20193
- * Pick the credentials forwarded to the AWS-published
20194
- * `amazon-ecs-local-container-endpoints` sidecar. `cdkl start-service`'s
20195
- * sidecar is SHARED across every replica boot in one CLI invocation, so
20196
- * this resolves ONCE at startup. Precedence:
20197
- * 1. `--profile <p>` resolved via {@link resolveProfileCredentials}
20198
- * (the SDK's default credential provider chain — SSO / IAM
20199
- * Identity Center / fromIni / role-assumption). NEW in this PR.
20200
- * 2. Not set → `undefined`; the sidecar runs with its own default
20201
- * credential chain (typically empty inside a fresh container —
20202
- * user containers will get 4xx from the credentials endpoint).
20203
- *
20204
- * Note: per-service `--assume-task-role <Service>=<arn>` overrides are
20205
- * INTENTIONALLY NOT consulted here. The shared sidecar has no concept
20206
- * of per-service IAM — per-service `TaskRoleArn` flows into each
20207
- * container's env via `buildMetadataEnv` at boot time, where the
20208
- * sidecar's `/role/<role-arn>` path resolves per-request. The shared
20209
- * sidecar's OWN startup credentials govern only the fallback path
20210
- * (containers that did not bind a `TaskRoleArn`).
20211
- *
20212
- * Extracted as an exported helper so a unit test can exercise both
20213
- * branches without having to mock the full Synth + Docker + AWS
20214
- * pipeline (the strategy used for the Lambda container path).
20533
+ * Resolve the credentials forwarded to the AWS-published metadata-endpoints
20534
+ * sidecar (shared across every replica boot in one CLI invocation). `--profile`
20535
+ * resolves via the SDK default chain; unset yields `undefined`. Per-service
20536
+ * `--assume-task-role` overrides are intentionally NOT consulted here. Exported
20537
+ * for a unit test that exercises both branches.
20215
20538
  */
20216
20539
  async function resolveSharedSidecarCredentials(options) {
20217
20540
  if (options.profile) return resolveProfileCredentials(options.profile);
20218
20541
  }
20219
- function createLocalStartServiceCommand(opts = {}) {
20220
- setEmbedConfig(opts.embedConfig);
20221
- 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 \`${getEmbedConfig().cliName} 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. Omit <targets> in an interactive terminal to multi-select the services from a list.`).argument("[targets...]", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ECS::Service resources to run (omit to multi-select interactively in a TTY)").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default(getEmbedConfig().resourceNamePrefix)).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("--host-port <containerPort=hostPort...>", "Publish a container port on a specific host port (e.g. 80=8080); repeatable. Default: host port == container port. Use this on macOS to map a privileged container port (< 1024) to a non-privileged host port and avoid the Docker Desktop admin-password prompt. (Single-replica services only — multi-replica services do not publish host ports.)")).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 ${83} 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-cfn-stack [cfn-stack-name]", `Read a deployed CloudFormation stack via ListStackResources and substitute Ref / Fn::ImportValue in container env vars / secrets / image URIs with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (\`cdk deploy\`). Bare form uses the ${getEmbedConfig().binaryName} stack name; pass an explicit value when the CFn stack name differs. Fn::GetAtt is warn-and-dropped in v1 (CFn ListStackResources does not return per-attribute values).`)).addOption(new Option("--stack-region <region>", "Region of the state record to read. Used with --from-cfn-stack as the CFn client region.")).action(withErrorHandling(async (targets, options) => {
20222
- await localStartServiceCommand(targets, options, opts.extraStateProviders);
20223
- }));
20542
+ /**
20543
+ * Add the CLI options shared by both ECS-service commands (`start-service` and
20544
+ * `start-alb`) to a command. The command-specific argument / description and
20545
+ * the one unique option (`--host-port` vs `--lb-port`) are added by each
20546
+ * factory.
20547
+ */
20548
+ function addCommonEcsServiceOptions(cmd) {
20549
+ cmd.addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default(getEmbedConfig().resourceNamePrefix)).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 ${83} 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-cfn-stack [cfn-stack-name]", `Read a deployed CloudFormation stack via ListStackResources and substitute Ref / Fn::ImportValue in container env vars / secrets / image URIs with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (\`cdk deploy\`). Bare form uses the ${getEmbedConfig().binaryName} stack name; pass an explicit value when the CFn stack name differs. Fn::GetAtt is warn-and-dropped in v1 (CFn ListStackResources does not return per-attribute values).`)).addOption(new Option("--stack-region <region>", "Region of the state record to read. Used with --from-cfn-stack as the CFn client region."));
20224
20550
  [
20225
20551
  ...commonOptions(),
20226
20552
  ...appOptions(),
@@ -20230,6 +20556,359 @@ function createLocalStartServiceCommand(opts = {}) {
20230
20556
  return cmd;
20231
20557
  }
20232
20558
 
20559
+ //#endregion
20560
+ //#region src/cli/commands/local-start-service.ts
20561
+ /**
20562
+ * `cdkl start-service` strategy — a pure ECS replica runner. It picks
20563
+ * `AWS::ECS::Service` targets and boots each with NO front-door (the ALB
20564
+ * front-door is its own command, `cdkl start-alb`). This keeps `start-service`
20565
+ * a leaf compute runner, symmetric with `invoke` / `run-task`.
20566
+ */
20567
+ function serviceStrategy() {
20568
+ return {
20569
+ pickEntries: (stacks) => listTargets(stacks).ecsServices,
20570
+ pickerMessage: "Select one or more ECS services to run",
20571
+ pickerNoun: "ECS services",
20572
+ onMissing: () => new LocalStartServiceError(`${getEmbedConfig().cliName} start-service requires at least one <target>. Pass one or more service paths like 'Stack/Orders' 'Stack/Frontend', or run it in a TTY to pick interactively.`),
20573
+ resolveBoots: (_stacks, chosenTargets) => ({
20574
+ boots: chosenTargets.map((target) => ({
20575
+ target,
20576
+ frontDoorTargets: []
20577
+ })),
20578
+ warnings: []
20579
+ }),
20580
+ lbPortOverrides: {}
20581
+ };
20582
+ }
20583
+ /**
20584
+ * `cdkl start-service <Stack/Service>` — Phase 2 of #262. Spins up
20585
+ * `DesiredCount` task replicas locally (clamped by `--max-tasks`) using the
20586
+ * existing `ecs-task-runner` per replica. Long-running; ^C cleans every replica
20587
+ * + sidecar + shared network. Pure compute: to put a local ALB front-door in
20588
+ * front of an ALB-fronted service, use `cdkl start-alb`.
20589
+ */
20590
+ function createLocalStartServiceCommand(opts = {}) {
20591
+ setEmbedConfig(opts.embedConfig);
20592
+ return addCommonEcsServiceOptions(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 \`${getEmbedConfig().cliName} 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. Omit <targets> in an interactive terminal to multi-select the services from a list. To put a local ALB front-door in front of an ALB-fronted service, use \`${getEmbedConfig().cliName} start-alb\` instead.`).argument("[targets...]", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ECS::Service resources to run (omit to multi-select interactively in a TTY)").addOption(new Option("--host-port <containerPort=hostPort...>", "Publish a container port on a specific host port (e.g. 80=8080); repeatable. Default: host port == container port. Use this on macOS to map a privileged container port (< 1024) to a non-privileged host port and avoid the Docker Desktop admin-password prompt. (Single-replica services only — multi-replica services do not publish host ports.)")).action(withErrorHandling(async (targets, options) => {
20593
+ await runEcsServiceEmulator(targets, options, serviceStrategy(), opts.extraStateProviders);
20594
+ })));
20595
+ }
20596
+
20597
+ //#endregion
20598
+ //#region src/local/elb-front-door-resolver.ts
20599
+ /**
20600
+ * Issue #86 v1 — resolve an `AWS::ElasticLoadBalancingV2::LoadBalancer` (an
20601
+ * ALB) into the backing ECS service(s) and the host listener port(s) a local
20602
+ * front-door should expose for each. This is the `cdkl start-alb` entry: you
20603
+ * name the ALB, and cdk-local discovers the services behind it (mirroring how
20604
+ * `start-api` names the API and discovers the backing Lambdas).
20605
+ *
20606
+ * The synthesized linkage (confirmed against real `cdk synth` of
20607
+ * `ApplicationLoadBalancedFargateService`):
20608
+ *
20609
+ * ```
20610
+ * ElasticLoadBalancingV2::LoadBalancer (the ALB you name)
20611
+ * ElasticLoadBalancingV2::Listener : { LoadBalancerArn:{Ref:<ALB>}, Port, Protocol,
20612
+ * DefaultActions:[{ Type:"forward", TargetGroupArn:{Ref:<TG>} }] }
20613
+ * ElasticLoadBalancingV2::TargetGroup : { Port, Protocol, TargetType:"ip" }
20614
+ * ECS::Service.LoadBalancers[] -> { ContainerName, ContainerPort, TargetGroupArn:{Ref:<TG>} }
20615
+ * ```
20616
+ *
20617
+ * Resolution walks ALB -> listeners (by `LoadBalancerArn` Ref) -> default
20618
+ * `forward` target groups -> the ECS Service whose `LoadBalancers[]` references
20619
+ * that target group (a reverse scan; there is no direct TG -> service pointer).
20620
+ *
20621
+ * v1 scope (single forward): only listener `DefaultActions` are honored.
20622
+ * `AWS::ElasticLoadBalancingV2::ListenerRule` (path / host / weighted routing)
20623
+ * is ignored — tracked in #123. HTTPS / TLS listeners and `TargetType:"lambda"`
20624
+ * target groups are skipped with a warning.
20625
+ */
20626
+ const ALB_TYPE = "AWS::ElasticLoadBalancingV2::LoadBalancer";
20627
+ const LISTENER_TYPE = "AWS::ElasticLoadBalancingV2::Listener";
20628
+ const TARGET_GROUP_TYPE = "AWS::ElasticLoadBalancingV2::TargetGroup";
20629
+ const SERVICE_TYPE = "AWS::ECS::Service";
20630
+ /**
20631
+ * Resolve an ALB into its backing services + front-door targets. Pure — reads
20632
+ * only the supplied stack template. Returns an empty `services` array (with
20633
+ * warnings) when the ALB fronts nothing cdk-local can serve locally.
20634
+ */
20635
+ function resolveAlbFrontDoor(stack, albLogicalId) {
20636
+ const warnings = [];
20637
+ const resources = stack.template.Resources ?? {};
20638
+ const tgToService = indexTargetGroupToService(resources);
20639
+ const byService = /* @__PURE__ */ new Map();
20640
+ const seenPortsByService = /* @__PURE__ */ new Map();
20641
+ for (const [listenerLogicalId, resource] of Object.entries(resources)) {
20642
+ if (resource.Type !== LISTENER_TYPE) continue;
20643
+ const props = resource.Properties ?? {};
20644
+ if (refOf(props["LoadBalancerArn"]) !== albLogicalId) continue;
20645
+ const port = parsePort(props["Port"]);
20646
+ if (port === void 0) continue;
20647
+ const protocol = typeof props["Protocol"] === "string" ? props["Protocol"] : "HTTP";
20648
+ const tgRefs = collectForwardTargetGroupRefs(props["DefaultActions"]);
20649
+ if (tgRefs.size === 0) {
20650
+ if (hasUnresolvableForward(props["DefaultActions"])) warnings.push(`Listener '${listenerLogicalId}' on port ${port} forwards to a non-Ref TargetGroupArn (literal / cross-stack / imported); the local front-door only supports in-stack target groups. Skipping it.`);
20651
+ continue;
20652
+ }
20653
+ if (protocol !== "HTTP") {
20654
+ warnings.push(`Listener '${listenerLogicalId}' on port ${port} uses protocol ${protocol}; the local ALB front-door supports HTTP listeners only in v1 (TLS termination is deferred). Skipping it.`);
20655
+ continue;
20656
+ }
20657
+ for (const tgRef of tgRefs) {
20658
+ const tg = resources[tgRef];
20659
+ if (!tg || tg.Type !== TARGET_GROUP_TYPE) {
20660
+ warnings.push(`Listener '${listenerLogicalId}' forwards to target group '${tgRef}', but no ${TARGET_GROUP_TYPE} with that logical id exists in ${stack.stackName}. Skipping it.`);
20661
+ continue;
20662
+ }
20663
+ if (tg.Properties?.["TargetType"] === "lambda") {
20664
+ warnings.push(`Target group '${tgRef}' is a Lambda target (TargetType: lambda). The local ALB front-door supports ECS targets only in v1; Lambda targets are deferred to a follow-up. Skipping it.`);
20665
+ continue;
20666
+ }
20667
+ const backing = tgToService.get(tgRef);
20668
+ if (!backing) {
20669
+ warnings.push(`Target group '${tgRef}' (listener '${listenerLogicalId}', port ${port}) is not referenced by any ${SERVICE_TYPE}.LoadBalancers[] in ${stack.stackName}; cdk-local has no ECS service to front behind it. Skipping it.`);
20670
+ continue;
20671
+ }
20672
+ const seenPorts = seenPortsByService.get(backing.serviceLogicalId) ?? /* @__PURE__ */ new Set();
20673
+ if (seenPorts.has(port)) {
20674
+ warnings.push(`Service '${backing.serviceLogicalId}' is fronted by more than one listener on host port ${port}; the local front-door fronts only the first.`);
20675
+ continue;
20676
+ }
20677
+ seenPorts.add(port);
20678
+ seenPortsByService.set(backing.serviceLogicalId, seenPorts);
20679
+ const targets = byService.get(backing.serviceLogicalId) ?? [];
20680
+ targets.push({
20681
+ listenerPort: port,
20682
+ listenerProtocol: "HTTP",
20683
+ targetContainerName: backing.containerName,
20684
+ targetContainerPort: backing.containerPort,
20685
+ targetGroupLogicalId: tgRef,
20686
+ listenerLogicalId
20687
+ });
20688
+ byService.set(backing.serviceLogicalId, targets);
20689
+ }
20690
+ }
20691
+ return {
20692
+ services: [...byService.entries()].map(([serviceLogicalId, targets]) => ({
20693
+ serviceLogicalId,
20694
+ targets
20695
+ })),
20696
+ warnings
20697
+ };
20698
+ }
20699
+ /** True when the resource is an application Load Balancer (the `start-alb` target type). */
20700
+ function isApplicationLoadBalancer(resource) {
20701
+ if (resource.Type !== ALB_TYPE) return false;
20702
+ const type = resource.Properties?.["Type"];
20703
+ return type === void 0 || type === "application";
20704
+ }
20705
+ /**
20706
+ * Build a `targetGroupLogicalId -> backing ECS service` index by scanning every
20707
+ * `AWS::ECS::Service.LoadBalancers[]`. First service wins on a shared target
20708
+ * group (unusual; would only happen with a hand-rolled template).
20709
+ */
20710
+ function indexTargetGroupToService(resources) {
20711
+ const index = /* @__PURE__ */ new Map();
20712
+ for (const [serviceLogicalId, resource] of Object.entries(resources)) {
20713
+ if (resource.Type !== SERVICE_TYPE) continue;
20714
+ const lbs = resource.Properties?.["LoadBalancers"];
20715
+ if (!Array.isArray(lbs)) continue;
20716
+ for (const entry of lbs) {
20717
+ if (!entry || typeof entry !== "object") continue;
20718
+ const e = entry;
20719
+ const tgRef = refOf(e["TargetGroupArn"]);
20720
+ const containerName = typeof e["ContainerName"] === "string" ? e["ContainerName"] : void 0;
20721
+ const containerPort = parseContainerPort(e["ContainerPort"]);
20722
+ if (!tgRef || !containerName || containerPort === void 0) continue;
20723
+ if (!index.has(tgRef)) index.set(tgRef, {
20724
+ serviceLogicalId,
20725
+ containerName,
20726
+ containerPort
20727
+ });
20728
+ }
20729
+ }
20730
+ return index;
20731
+ }
20732
+ function collectForwardTargetGroupRefs(defaultActions) {
20733
+ const refs = /* @__PURE__ */ new Set();
20734
+ if (!Array.isArray(defaultActions)) return refs;
20735
+ for (const action of defaultActions) {
20736
+ if (!action || typeof action !== "object") continue;
20737
+ const a = action;
20738
+ if (a["Type"] !== "forward") continue;
20739
+ const direct = refOf(a["TargetGroupArn"]);
20740
+ if (direct) refs.add(direct);
20741
+ const forwardConfig = a["ForwardConfig"];
20742
+ if (forwardConfig && typeof forwardConfig === "object") {
20743
+ const groups = forwardConfig["TargetGroups"];
20744
+ if (Array.isArray(groups)) for (const g of groups) {
20745
+ if (!g || typeof g !== "object") continue;
20746
+ const ref = refOf(g["TargetGroupArn"]);
20747
+ if (ref) refs.add(ref);
20748
+ }
20749
+ }
20750
+ }
20751
+ return refs;
20752
+ }
20753
+ /**
20754
+ * True when `DefaultActions` has at least one `forward` action that references
20755
+ * a target group via a NON-`Ref` arn (literal / `Fn::GetAtt` / cross-stack) —
20756
+ * i.e. a forward we could not resolve to an in-stack target group. Used to warn
20757
+ * rather than silently skip such a listener.
20758
+ */
20759
+ function hasUnresolvableForward(defaultActions) {
20760
+ if (!Array.isArray(defaultActions)) return false;
20761
+ for (const action of defaultActions) {
20762
+ if (!action || typeof action !== "object") continue;
20763
+ const a = action;
20764
+ if (a["Type"] !== "forward") continue;
20765
+ if (a["TargetGroupArn"] !== void 0 && refOf(a["TargetGroupArn"]) === void 0) return true;
20766
+ const forwardConfig = a["ForwardConfig"];
20767
+ if (forwardConfig && typeof forwardConfig === "object") {
20768
+ const groups = forwardConfig["TargetGroups"];
20769
+ if (Array.isArray(groups)) for (const g of groups) {
20770
+ if (!g || typeof g !== "object") continue;
20771
+ const arn = g["TargetGroupArn"];
20772
+ if (arn !== void 0 && refOf(arn) === void 0) return true;
20773
+ }
20774
+ }
20775
+ }
20776
+ return false;
20777
+ }
20778
+ function refOf(raw) {
20779
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
20780
+ const ref = raw["Ref"];
20781
+ if (typeof ref === "string" && ref.length > 0) return ref;
20782
+ }
20783
+ }
20784
+ function parsePort(raw) {
20785
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 65535) return raw;
20786
+ if (typeof raw === "string" && /^\d+$/.test(raw)) {
20787
+ const n = parseInt(raw, 10);
20788
+ if (n >= 1 && n <= 65535) return n;
20789
+ }
20790
+ }
20791
+ function parseContainerPort(raw) {
20792
+ return parsePort(raw);
20793
+ }
20794
+
20795
+ //#endregion
20796
+ //#region src/cli/commands/local-start-alb.ts
20797
+ /**
20798
+ * Issue #86 v1 — parse `--lb-port <listenerPort>=<hostPort>` overrides into a
20799
+ * `listenerPort -> hostPort` map. The local ALB front-door binds the listener
20800
+ * port on the host by default, but a privileged listener port (e.g. 80 / 443)
20801
+ * fails to bind as non-root on macOS, so the user opts in to a non-privileged
20802
+ * host port (e.g. `--lb-port 80=8080`). Repeatable; each value is
20803
+ * `<listenerPort>=<hostPort>` with both in 1-65535.
20804
+ */
20805
+ function parseLbPortOverrides(values) {
20806
+ const out = {};
20807
+ for (const raw of values ?? []) {
20808
+ const m = /^(\d+)=(\d+)$/.exec(raw.trim());
20809
+ if (!m) throw new LocalStartServiceError(`Invalid --lb-port '${raw}'. Expected <listenerPort>=<hostPort> (e.g. 80=8080).`);
20810
+ const listenerPort = Number(m[1]);
20811
+ const hostPort = Number(m[2]);
20812
+ for (const [label, p] of [["listener", listenerPort], ["host", hostPort]]) if (p < 1 || p > 65535) throw new LocalStartServiceError(`Invalid --lb-port '${raw}': ${label} port must be 1-65535.`);
20813
+ out[listenerPort] = hostPort;
20814
+ }
20815
+ return out;
20816
+ }
20817
+ /**
20818
+ * Resolve an ALB target string (`Stack/Path` display path or `Stack:LogicalId`)
20819
+ * to its stack + `AWS::ElasticLoadBalancingV2::LoadBalancer` logical id. Mirrors
20820
+ * the ECS service resolver's target grammar.
20821
+ */
20822
+ function resolveAlbTarget(target, stacks) {
20823
+ if (stacks.length === 0) throw new LocalStartServiceError("No stacks found in the synthesized assembly.");
20824
+ const parsed = parseEcsTarget(target);
20825
+ const stack = pickStack(parsed.stackPattern, stacks, target);
20826
+ const resources = stack.template.Resources ?? {};
20827
+ if (parsed.isPath) {
20828
+ const index = buildCdkPathIndex(stack.template);
20829
+ const albs = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId }) => {
20830
+ const r = resources[logicalId];
20831
+ return r !== void 0 && isApplicationLoadBalancer(r);
20832
+ });
20833
+ if (albs.length === 0) throw notFound(target, stack, resources);
20834
+ if (albs.length > 1) throw new LocalStartServiceError(`Target '${target}' matches ${albs.length} load balancers in ${stack.stackName}: ${albs.map((a) => a.logicalId).join(", ")}. Refine the path or use the stack:LogicalId form.`);
20835
+ return {
20836
+ stack,
20837
+ albLogicalId: albs[0].logicalId
20838
+ };
20839
+ }
20840
+ const res = resources[parsed.pathOrId];
20841
+ if (!res || !isApplicationLoadBalancer(res)) throw notFound(target, stack, resources);
20842
+ return {
20843
+ stack,
20844
+ albLogicalId: parsed.pathOrId
20845
+ };
20846
+ }
20847
+ function pickStack(stackPattern, stacks, target) {
20848
+ if (stackPattern === null) {
20849
+ if (stacks.length === 1) return stacks[0];
20850
+ throw new LocalStartServiceError(`Target '${target}' has no stack prefix, and the assembly contains ${stacks.length} stacks: ${stacks.map((s) => s.stackName).join(", ")}. Pass it as 'Stack/Path' or 'Stack:LogicalId'.`);
20851
+ }
20852
+ const matched = matchStacks(stacks, [stackPattern]);
20853
+ if (matched.length === 0) throw new LocalStartServiceError(`No stack matches '${stackPattern}'. Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
20854
+ if (matched.length > 1) throw new LocalStartServiceError(`Multiple stacks match '${stackPattern}': ${matched.map((s) => s.stackName).join(", ")}. Refine the pattern.`);
20855
+ return matched[0];
20856
+ }
20857
+ function notFound(target, stack, resources) {
20858
+ const albs = Object.entries(resources).filter(([, r]) => r.Type === "AWS::ElasticLoadBalancingV2::LoadBalancer").map(([logicalId]) => logicalId);
20859
+ const available = albs.length > 0 ? ` Available load balancers in ${stack.stackName}: ${albs.join(", ")}.` : ` ${stack.stackName} declares no AWS::ElasticLoadBalancingV2::LoadBalancer resources.`;
20860
+ return new LocalStartServiceError(`Target '${target}' did not match an application Load Balancer in ${stack.stackName}.${available}`);
20861
+ }
20862
+ /**
20863
+ * `cdkl start-alb` strategy — name the ALB, boot the ECS service(s) behind it,
20864
+ * and expose each listener via a local front-door. Mirrors how `start-api`
20865
+ * names the API and serves its backing Lambdas.
20866
+ */
20867
+ function albStrategy(options) {
20868
+ return {
20869
+ pickEntries: (stacks) => listTargets(stacks).loadBalancers,
20870
+ pickerMessage: "Select one or more Application Load Balancers to run",
20871
+ pickerNoun: "Application Load Balancers",
20872
+ onMissing: () => new LocalStartServiceError(`${getEmbedConfig().cliName} start-alb requires at least one <target>. Pass one or more ALB paths like 'Stack/MyAlb', or run it in a TTY to pick interactively.`),
20873
+ resolveBoots: (stacks, chosenTargets) => {
20874
+ const byServiceTarget = /* @__PURE__ */ new Map();
20875
+ const warnings = [];
20876
+ for (const albTarget of chosenTargets) {
20877
+ const { stack, albLogicalId } = resolveAlbTarget(albTarget, stacks);
20878
+ const resolution = resolveAlbFrontDoor(stack, albLogicalId);
20879
+ warnings.push(...resolution.warnings);
20880
+ for (const svc of resolution.services) {
20881
+ const target = `${stack.stackName}:${svc.serviceLogicalId}`;
20882
+ const existing = byServiceTarget.get(target);
20883
+ if (existing) existing.frontDoorTargets.push(...svc.targets);
20884
+ else byServiceTarget.set(target, {
20885
+ target,
20886
+ frontDoorTargets: [...svc.targets]
20887
+ });
20888
+ }
20889
+ }
20890
+ return {
20891
+ boots: [...byServiceTarget.values()],
20892
+ warnings
20893
+ };
20894
+ },
20895
+ lbPortOverrides: parseLbPortOverrides(options.lbPort)
20896
+ };
20897
+ }
20898
+ /**
20899
+ * `cdkl start-alb <Stack/Alb>` — Issue #86 v1. Names an
20900
+ * `AWS::ElasticLoadBalancingV2::LoadBalancer`, discovers the ECS service(s)
20901
+ * behind its HTTP `forward` listeners, boots their replicas, and stands up a
20902
+ * local front-door on each listener port that round-robins across the replicas.
20903
+ * The symmetric ALB counterpart of `start-api`.
20904
+ */
20905
+ function createLocalStartAlbCommand(opts = {}) {
20906
+ setEmbedConfig(opts.embedConfig);
20907
+ return addCommonEcsServiceOptions(new Command("start-alb").description("Run an Application Load Balancer locally: name the ALB, and cdk-local boots the ECS service(s) behind its HTTP forward listeners and stands up a local front-door on each listener port that round-robins across the running replicas — a single stable host endpoint, like behind a real load balancer. The symmetric ALB counterpart of `start-api`. Each <target> accepts a CDK display path (MyStack/MyAlb) or stack-qualified logical ID; single-stack apps may omit the stack prefix. v1 supports a single default-action forward to an HTTP listener; HTTPS listeners and Lambda target groups are skipped with a warning, and listener-rule routing is tracked separately. Omit <targets> in an interactive terminal to multi-select the load balancers from a list.").argument("[targets...]", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ElasticLoadBalancingV2::LoadBalancer resources to run (omit to multi-select interactively in a TTY)").addOption(new Option("--lb-port <listenerPort=hostPort...>", "Bind the local front-door on a specific host port (e.g. 80=8080); repeatable. Default: host port == ALB listener port. Use this on macOS to remap a privileged listener port (< 1024) to a non-privileged host port.")).action(withErrorHandling(async (targets, options) => {
20908
+ await runEcsServiceEmulator(targets, options, albStrategy(options), opts.extraStateProviders);
20909
+ })));
20910
+ }
20911
+
20233
20912
  //#endregion
20234
20913
  //#region src/cli/commands/local-list.ts
20235
20914
  async function localListCommand(options) {
@@ -20265,14 +20944,15 @@ async function localListCommand(options) {
20265
20944
  * without running synthesis.
20266
20945
  */
20267
20946
  function formatTargetListing(listing, cliName, options = {}) {
20268
- if (countTargets(listing) === 0) return `No runnable targets (Lambda functions, APIs, ECS services / tasks, AgentCore Runtimes) found in this CDK app.`;
20947
+ if (countTargets(listing) === 0) return `No runnable targets (Lambda functions, APIs, ECS services / tasks, AgentCore Runtimes, load balancers) found in this CDK app.`;
20269
20948
  const long = options.long ?? false;
20270
20949
  return "\n" + [
20271
20950
  formatSection("Lambda Functions", `${cliName} invoke <target>`, listing.lambdas, long),
20272
20951
  formatSection("APIs", `${cliName} start-api [target...]`, listing.apis, long),
20273
20952
  formatSection("ECS Services", `${cliName} start-service <target...>`, listing.ecsServices, long),
20274
20953
  formatSection("ECS Task Definitions", `${cliName} run-task <target>`, listing.ecsTaskDefinitions, long),
20275
- formatSection("AgentCore Runtimes", `${cliName} invoke-agentcore <target>`, listing.agentCoreRuntimes, long)
20954
+ formatSection("AgentCore Runtimes", `${cliName} invoke-agentcore <target>`, listing.agentCoreRuntimes, long),
20955
+ formatSection("Application Load Balancers", `${cliName} start-alb <target...>`, listing.loadBalancers, long)
20276
20956
  ].filter((lines) => lines.length > 0).map((lines) => lines.join("\n")).join("\n\n");
20277
20957
  }
20278
20958
  function formatSection(title, command, entries, long) {
@@ -20287,7 +20967,7 @@ function formatSection(title, command, entries, long) {
20287
20967
  }
20288
20968
  function createLocalListCommand(opts = {}) {
20289
20969
  setEmbedConfig(opts.embedConfig);
20290
- const cmd = new Command("list").alias("ls").description("List the runnable targets in the synthesized CDK app, grouped by the command that runs them: Lambda functions (invoke), API Gateway REST v1 / HTTP v2 / Function URL / WebSocket surfaces (start-api), ECS services (start-service), ECS task definitions (run-task), and AgentCore Runtimes (invoke-agentcore). Each target is shown by its CDK display path; pass -l to also print the stack-qualified logical ID. Tip: you usually do not need to copy these — just run the command (e.g. `invoke`) with no target in a terminal and pick from the list.").addOption(new Option("-l, --long", "Also print each target's stack-qualified logical ID (<Stack>:<LogicalId>) beneath it").default(false)).action(withErrorHandling(async (options) => {
20970
+ const cmd = new Command("list").alias("ls").description("List the runnable targets in the synthesized CDK app, grouped by the command that runs them: Lambda functions (invoke), API Gateway REST v1 / HTTP v2 / Function URL / WebSocket surfaces (start-api), ECS services (start-service), ECS task definitions (run-task), AgentCore Runtimes (invoke-agentcore), and Application Load Balancers (start-alb). Each target is shown by its CDK display path; pass -l to also print the stack-qualified logical ID. Tip: you usually do not need to copy these — just run the command (e.g. `invoke`) with no target in a terminal and pick from the list.").addOption(new Option("-l, --long", "Also print each target's stack-qualified logical ID (<Stack>:<LogicalId>) beneath it").default(false)).action(withErrorHandling(async (options) => {
20291
20971
  await localListCommand(options);
20292
20972
  }));
20293
20973
  [
@@ -20300,5 +20980,5 @@ function createLocalListCommand(opts = {}) {
20300
20980
  }
20301
20981
 
20302
20982
  //#endregion
20303
- export { bufferToBody as $, buildMethodArn as A, CfnLocalStateProvider as At, matchPreflight as B, pickRefLogicalId as Bt, resolveServiceIntegrationParameters as C, LocalStateSourceError as Ct, createJwksCache as D, resolveCfnFallbackRegion as Dt, buildJwksUrlFromIssuer as E, rejectExplicitCfnStackWithMultipleStacks as Et, attachAuthorizers as F, listTargets as Ft, buildRestV1Event as G, resolveAgentCoreTarget as Gt, translateLambdaResponse as H, AGENTCORE_HTTP_PROTOCOL as Ht, applyCorsResponseHeaders as I, discoverWebSocketApis as It, selectIntegrationResponse as J, tryResolveImageFnJoin as Jt, evaluateResponseParameters as K, derivePseudoParametersFromRegion as Kt, buildCorsConfigByApiId as L, discoverWebSocketApisOrThrow as Lt, evaluateCachedLambdaPolicy as M, resolveSsmParameters as Mt, invokeRequestAuthorizer as N, resolveWatchConfig as Nt, verifyCognitoJwt as O, resolveCfnRegion as Ot, invokeTokenAuthorizer as P, countTargets as Pt, probeHostGatewaySupport as Q, buildCorsConfigFromCloudFrontChain as R, parseSelectionExpressionPath as Rt, resolveSelectionExpression as S, materializeLayerFromArn as St, buildCognitoJwksUrl as T, isCfnFlagPresent as Tt, applyAuthorizerOverlay as U, AGENTCORE_RUNTIME_TYPE as Ut, matchRoute as V, resolveLambdaArnIntrinsic as Vt, buildHttpApiV2Event as W, AgentCoreResolutionError as Wt, VtlEvaluationError as X, tryParseStatus as Y, LocalInvokeBuildError as Yt, HOST_GATEWAY_MIN_VERSION as Z, filterRoutesByApiIdentifier as _, substituteAgainstState as _t, CloudMapRegistry as a, buildDisconnectEvent as at, readMtlsMaterialsFromDisk as b, substituteEnvVarsFromStateAsync as bt, createLocalInvokeAgentCoreCommand as c, invokeAgentCore as ct, resolveApiTargetSubset as d, architectureToPlatform as dt, ConnectionRegistry as et, createAuthorizerCache as f, buildContainerImage as ft, availableApiIdentifiers as g, EcsTaskResolutionError as gt, buildStageMap as h, resolveRuntimeImage as ht, buildCloudMapIndex as i, buildConnectEvent as it, computeRequestIdentityHash as j, collectSsmParameterRefs as jt, verifyJwtAuthorizer as k, resolveCfnStackName as kt, createLocalStartApiCommand as l, waitForAgentCorePing as lt, attachStageContext as m, resolveRuntimeFileExtension as mt, formatTargetListing as n, handleConnectionsRequest as nt, getContainerNetworkIp as o, buildMessageEvent as ot, createFileWatcher as p, resolveRuntimeCodeMountPath as pt, pickResponseTemplate as q, substituteImagePlaceholders as qt, createLocalStartServiceCommand as r, parseConnectionsPath as rt, createLocalRunTaskCommand as s, AGENTCORE_SESSION_ID_HEADER as st, createLocalListCommand as t, buildMgmtEndpointEnvUrl as tt, createWatchPredicates as u, createLocalInvokeCommand as ut, filterRoutesByApiIdentifiers as v, substituteAgainstStateAsync as vt, defaultCredentialsLoader as w, createLocalStateProvider as wt, startApiServer as x, resolveEnvVars as xt, groupRoutesByServer as y, substituteEnvVarsFromState as yt, isFunctionUrlOacFronted as z, discoverRoutes as zt };
20304
- //# sourceMappingURL=local-list-D1MAmo5o.js.map
20983
+ export { probeHostGatewaySupport as $, verifyJwtAuthorizer as A, resolveCfnStackName as At, isFunctionUrlOacFronted as B, discoverRoutes as Bt, resolveSelectionExpression as C, materializeLayerFromArn as Ct, buildJwksUrlFromIssuer as D, rejectExplicitCfnStackWithMultipleStacks as Dt, buildCognitoJwksUrl as E, isCfnFlagPresent as Et, invokeTokenAuthorizer as F, countTargets as Ft, buildHttpApiV2Event as G, AgentCoreResolutionError as Gt, matchRoute as H, resolveLambdaArnIntrinsic as Ht, attachAuthorizers as I, listTargets as It, pickResponseTemplate as J, substituteImagePlaceholders as Jt, buildRestV1Event as K, resolveAgentCoreTarget as Kt, applyCorsResponseHeaders as L, discoverWebSocketApis as Lt, computeRequestIdentityHash as M, collectSsmParameterRefs as Mt, evaluateCachedLambdaPolicy as N, resolveSsmParameters as Nt, createJwksCache as O, resolveCfnFallbackRegion as Ot, invokeRequestAuthorizer as P, resolveWatchConfig as Pt, HOST_GATEWAY_MIN_VERSION as Q, buildCorsConfigByApiId as R, discoverWebSocketApisOrThrow as Rt, startApiServer as S, resolveEnvVars as St, defaultCredentialsLoader as T, createLocalStateProvider as Tt, translateLambdaResponse as U, AGENTCORE_HTTP_PROTOCOL as Ut, matchPreflight as V, pickRefLogicalId as Vt, applyAuthorizerOverlay as W, AGENTCORE_RUNTIME_TYPE as Wt, tryParseStatus as X, LocalInvokeBuildError as Xt, selectIntegrationResponse as Y, tryResolveImageFnJoin as Yt, VtlEvaluationError as Z, availableApiIdentifiers as _, EcsTaskResolutionError as _t, buildCloudMapIndex as a, buildConnectEvent as at, groupRoutesByServer as b, substituteEnvVarsFromState as bt, createLocalRunTaskCommand as c, AGENTCORE_SESSION_ID_HEADER as ct, createWatchPredicates as d, createLocalInvokeCommand as dt, bufferToBody as et, resolveApiTargetSubset as f, architectureToPlatform as ft, buildStageMap as g, resolveRuntimeImage as gt, attachStageContext as h, resolveRuntimeFileExtension as ht, createLocalStartServiceCommand as i, parseConnectionsPath as it, buildMethodArn as j, CfnLocalStateProvider as jt, verifyCognitoJwt as k, resolveCfnRegion as kt, createLocalInvokeAgentCoreCommand as l, invokeAgentCore as lt, createFileWatcher as m, resolveRuntimeCodeMountPath as mt, formatTargetListing as n, buildMgmtEndpointEnvUrl as nt, CloudMapRegistry as o, buildDisconnectEvent as ot, createAuthorizerCache as p, buildContainerImage as pt, evaluateResponseParameters as q, derivePseudoParametersFromRegion as qt, createLocalStartAlbCommand as r, handleConnectionsRequest as rt, getContainerNetworkIp as s, buildMessageEvent as st, createLocalListCommand as t, ConnectionRegistry as tt, createLocalStartApiCommand as u, waitForAgentCorePing as ut, filterRoutesByApiIdentifier as v, substituteAgainstState as vt, resolveServiceIntegrationParameters as w, LocalStateSourceError as wt, readMtlsMaterialsFromDisk as x, substituteEnvVarsFromStateAsync as xt, filterRoutesByApiIdentifiers as y, substituteAgainstStateAsync as yt, buildCorsConfigFromCloudFrontChain as z, parseSelectionExpressionPath as zt };
20984
+ //# sourceMappingURL=local-list-Dklrlnu1.js.map