cdk-local 0.36.1 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,23 @@ 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
- 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}.`);
18396
+ const overrideNote = hostPort !== declaredHostPort ? " (--host-port override)" : "";
18397
+ getLogger().child("ecs").info(`Container '${container.name}' container port ${pm.containerPort} published on ${containerHost}:${hostPort}${overrideNote}. Reach it at ${containerHost}:${hostPort}.`);
18375
18398
  args.push("-p", `${containerHost}:${hostPort}:${pm.containerPort}/${pm.protocol}`);
18376
18399
  }
18400
+ if (ephemeralPorts.size > 0) {
18401
+ const alreadyEphemeral = /* @__PURE__ */ new Set();
18402
+ for (const pm of container.portMappings) {
18403
+ if (!ephemeralPorts.has(pm.containerPort) || alreadyEphemeral.has(pm.containerPort)) continue;
18404
+ alreadyEphemeral.add(pm.containerPort);
18405
+ args.push("-p", `${containerHost}::${pm.containerPort}/${pm.protocol}`);
18406
+ }
18407
+ }
18377
18408
  if (opts.profileCredentialsFile) args.push("-v", `${opts.profileCredentialsFile.hostPath}:${opts.profileCredentialsFile.containerPath}:ro`);
18378
18409
  for (const mp of container.mountPoints) {
18379
18410
  const v = volumeByName.get(mp.sourceVolume);
@@ -18772,7 +18803,7 @@ function createLocalRunTaskCommand(opts = {}) {
18772
18803
  function resolveEcsServiceTarget(target, stacks, context) {
18773
18804
  if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
18774
18805
  const parsed = parseEcsTarget(target);
18775
- const stack = pickStack(parsed, stacks);
18806
+ const stack = pickStack$1(parsed, stacks);
18776
18807
  const resources = stack.template.Resources ?? {};
18777
18808
  let serviceLogicalId;
18778
18809
  let serviceResource;
@@ -18974,7 +19005,7 @@ function parseServiceName(raw, serviceLogicalId) {
18974
19005
  * service-specific extensions (e.g. cross-stack service-to-task refs)
18975
19006
  * can diverge without breaking the run-task code path.
18976
19007
  */
18977
- function pickStack(parsed, stacks) {
19008
+ function pickStack$1(parsed, stacks) {
18978
19009
  if (parsed.stackPattern === null) {
18979
19010
  if (stacks.length === 1) return stacks[0];
18980
19011
  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 +19073,35 @@ async function getContainerNetworkIp(containerId, networkName) {
19042
19073
  return;
19043
19074
  }
19044
19075
  }
19076
+ /**
19077
+ * Issue #86 v1 helper — read back the docker-assigned host port a container
19078
+ * port was published on. The local ALB front-door publishes each replica's
19079
+ * target container port on an ephemeral host port (`-p <host>::<containerPort>`)
19080
+ * and round-robins to `127.0.0.1:<hostPort>`; this resolves that host port
19081
+ * after the replica boots.
19082
+ *
19083
+ * Uses a nil-safe Go template (`with index`) so a container that did NOT
19084
+ * publish the port yields empty output instead of a template error. Returns
19085
+ * `undefined` when the port is unpublished, the container vanished, or the
19086
+ * value isn't a parseable port.
19087
+ */
19088
+ async function getPublishedHostPort(containerId, containerPort, protocol = "tcp") {
19089
+ const format = `{{with index .NetworkSettings.Ports "${`${containerPort}/${protocol}`}"}}{{with index . 0}}{{.HostPort}}{{end}}{{end}}`;
19090
+ try {
19091
+ const { stdout } = await execFileAsync(getDockerCmd(), [
19092
+ "inspect",
19093
+ "--format",
19094
+ format,
19095
+ containerId
19096
+ ]);
19097
+ const raw = stdout.trim();
19098
+ if (!raw) return void 0;
19099
+ const port = parseInt(raw, 10);
19100
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : void 0;
19101
+ } catch {
19102
+ return;
19103
+ }
19104
+ }
19045
19105
 
19046
19106
  //#endregion
19047
19107
  //#region src/local/ecs-service-runner.ts
@@ -19172,7 +19232,8 @@ async function startEcsService(service, options, runState) {
19172
19232
  restartCount: 0,
19173
19233
  shuttingDown: false,
19174
19234
  inFlightBoot: void 0,
19175
- cloudMapHandles: []
19235
+ cloudMapHandles: [],
19236
+ frontDoorOwnerKey: void 0
19176
19237
  };
19177
19238
  runState.replicas.push(instance);
19178
19239
  const bootPromise = bootReplica(service, options, instance);
@@ -19260,6 +19321,7 @@ var ServiceController = class {
19260
19321
  } catch {}
19261
19322
  instance.cloudMapHandles = [];
19262
19323
  }
19324
+ unregisterReplicaFromFrontDoor(instance, this.options.frontDoor);
19263
19325
  try {
19264
19326
  await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
19265
19327
  } catch (err) {
@@ -19316,6 +19378,7 @@ async function bootReplica(service, options, instance) {
19316
19378
  const sharedNetwork = options.discovery?.sharedNetwork;
19317
19379
  const networkAliasesByContainer = buildNetworkAliasesByContainer(service);
19318
19380
  const skipHostPortPublish = computeReplicaCount(service.desiredCount, options.maxTasks) > 1;
19381
+ const ephemeralPublishContainerPorts = options.frontDoor ? [...new Set(options.frontDoor.pools.map((p) => p.targetContainerPort))] : [];
19319
19382
  const perReplicaTaskOptions = {
19320
19383
  ...options.taskOptions,
19321
19384
  cluster: perReplicaCluster,
@@ -19323,11 +19386,13 @@ async function bootReplica(service, options, instance) {
19323
19386
  ...skipHostPortPublish ? { skipHostPortPublish: true } : {},
19324
19387
  ...sharedNetwork ? { existingNetwork: sharedNetwork } : { subnetOctet: pickSubnetOctet(instance.index) },
19325
19388
  ...addHostFlags.length > 0 ? { addHostFlags } : {},
19326
- ...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {}
19389
+ ...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {},
19390
+ ...ephemeralPublishContainerPorts.length > 0 ? { ephemeralPublishContainerPorts } : {}
19327
19391
  };
19328
19392
  logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
19329
19393
  await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
19330
19394
  if (options.discovery) await publishReplicaToCloudMap(service, instance, options.discovery, ownerKeyPrefix);
19395
+ if (options.frontDoor) await publishReplicaToFrontDoor(service, instance, options.frontDoor, options.taskOptions.containerHost, ownerKeyPrefix);
19331
19396
  }
19332
19397
  /**
19333
19398
  * After the replica's main container is up, discover its docker
@@ -19408,6 +19473,57 @@ async function publishReplicaToCloudMap(service, instance, discovery, ownerKeyPr
19408
19473
  }
19409
19474
  }
19410
19475
  /**
19476
+ * Issue #86 v1 — register this replica's host-reachable endpoint into every
19477
+ * front-door pool. For each pool, find the target container's docker id, read
19478
+ * back the ephemeral host port docker assigned to its target container port
19479
+ * (`-p <host>::<port>`), and register `<containerHost>:<hostPort>` under the
19480
+ * per-replica owner key.
19481
+ *
19482
+ * Best-effort, mirroring `publishReplicaToCloudMap`: a missing container / port
19483
+ * logs a warn and skips that pool rather than aborting the replica (the
19484
+ * replica is alive; the front-door just can't route to it until it re-boots).
19485
+ * The owner key is stamped on the instance so the restart / shutdown paths can
19486
+ * `pool.unregister` symmetrically.
19487
+ */
19488
+ async function publishReplicaToFrontDoor(service, instance, frontDoor, containerHost, ownerKeyPrefix) {
19489
+ const logger = getLogger().child("ecs-service");
19490
+ instance.frontDoorOwnerKey = ownerKeyPrefix;
19491
+ for (const target of frontDoor.pools) {
19492
+ const started = instance.state.startedContainers.find((c) => c.name === target.targetContainerName);
19493
+ if (!started) {
19494
+ 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.`);
19495
+ continue;
19496
+ }
19497
+ let hostPort;
19498
+ try {
19499
+ hostPort = await getPublishedHostPort(started.id, target.targetContainerPort);
19500
+ } catch (err) {
19501
+ logger.warn(`Replica ${instance.index}: docker inspect failed before front-door publish: ${err instanceof Error ? err.message : String(err)}`);
19502
+ continue;
19503
+ }
19504
+ if (hostPort === void 0) {
19505
+ 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.`);
19506
+ continue;
19507
+ }
19508
+ target.pool.register(ownerKeyPrefix, {
19509
+ host: containerHost,
19510
+ port: hostPort
19511
+ });
19512
+ logger.debug(`Front-door: replica ${instance.index} registered at ${containerHost}:${hostPort} (container ${target.targetContainerName}:${target.targetContainerPort}).`);
19513
+ }
19514
+ }
19515
+ /**
19516
+ * Drop this replica's endpoint from every front-door pool. Idempotent; called
19517
+ * from the watcher restart branch and the controller shutdown path so a
19518
+ * dying / restarting replica is removed from round-robin before its container
19519
+ * is torn down.
19520
+ */
19521
+ function unregisterReplicaFromFrontDoor(instance, frontDoor) {
19522
+ if (!frontDoor || !instance.frontDoorOwnerKey) return;
19523
+ for (const target of frontDoor.pools) target.pool.unregister(instance.frontDoorOwnerKey);
19524
+ instance.frontDoorOwnerKey = void 0;
19525
+ }
19526
+ /**
19411
19527
  * Long-running watcher loop for one replica. Polls the essential
19412
19528
  * container's exit code via `docker wait`; on exit, decides whether to
19413
19529
  * restart per `restartPolicy` + applies exponential backoff. The loop
@@ -19447,6 +19563,7 @@ async function watchReplica(service, options, instance, runState) {
19447
19563
  } catch {}
19448
19564
  instance.cloudMapHandles = [];
19449
19565
  }
19566
+ unregisterReplicaFromFrontDoor(instance, options.frontDoor);
19450
19567
  try {
19451
19568
  await cleanupEcsRun(instance.state, { keepRunning: false });
19452
19569
  } catch (err) {
@@ -19859,14 +19976,176 @@ function extractDnsRecords(serviceProps) {
19859
19976
  }
19860
19977
 
19861
19978
  //#endregion
19862
- //#region src/cli/commands/local-start-service.ts
19979
+ //#region src/local/front-door-pool.ts
19980
+ var FrontDoorEndpointPool = class {
19981
+ entries = [];
19982
+ /** Monotonic counter; `next()` rotates over the current entries by index. */
19983
+ cursor = 0;
19984
+ /**
19985
+ * Register (or idempotently replace) the endpoint for `ownerKey`. A replica
19986
+ * restart calls this again with the same key and the new ephemeral port; the
19987
+ * prior entry for that key is replaced rather than duplicated.
19988
+ */
19989
+ register(ownerKey, endpoint) {
19990
+ if (!ownerKey) throw new Error("FrontDoorEndpointPool.register: ownerKey must be non-empty.");
19991
+ const next = this.entries.filter((e) => e.ownerKey !== ownerKey);
19992
+ next.push({
19993
+ ownerKey,
19994
+ host: endpoint.host,
19995
+ port: endpoint.port
19996
+ });
19997
+ this.entries = next;
19998
+ }
19999
+ /** Drop the endpoint for `ownerKey`. Idempotent; returns whether one was removed. */
20000
+ unregister(ownerKey) {
20001
+ const next = this.entries.filter((e) => e.ownerKey !== ownerKey);
20002
+ const removed = next.length !== this.entries.length;
20003
+ this.entries = next;
20004
+ return removed;
20005
+ }
20006
+ /**
20007
+ * Round-robin the next live endpoint, or `undefined` when the pool is empty
20008
+ * (the front-door server replies 503 in that case). The cursor advances per
20009
+ * call; modulo by the current length tolerates entries being added / removed
20010
+ * between calls.
20011
+ */
20012
+ next() {
20013
+ if (this.entries.length === 0) return void 0;
20014
+ const entry = this.entries[this.cursor % this.entries.length];
20015
+ this.cursor = (this.cursor + 1) % Number.MAX_SAFE_INTEGER;
20016
+ return {
20017
+ host: entry.host,
20018
+ port: entry.port
20019
+ };
20020
+ }
20021
+ /** Snapshot of the current endpoints (for diagnostics / tests). */
20022
+ list() {
20023
+ return this.entries.map((e) => ({
20024
+ host: e.host,
20025
+ port: e.port
20026
+ }));
20027
+ }
20028
+ /** Number of live endpoints. */
20029
+ size() {
20030
+ return this.entries.length;
20031
+ }
20032
+ };
20033
+
20034
+ //#endregion
20035
+ //#region src/local/front-door-server.ts
20036
+ /** Default per-request upstream timeout — a hung replica yields a 504, not a hang. */
20037
+ const DEFAULT_UPSTREAM_TIMEOUT_MS = 3e4;
20038
+ async function startFrontDoorServer(opts) {
20039
+ const logger = getLogger().child("front-door");
20040
+ const host = opts.host ?? "127.0.0.1";
20041
+ const server = createServer$1((req, res) => {
20042
+ handleProxyRequest(req, res, opts).catch((err) => {
20043
+ logger.debug(`front-door request error: ${err instanceof Error ? err.message : String(err)}`);
20044
+ if (!res.headersSent) writeError(res, 502, "Bad Gateway");
20045
+ });
20046
+ });
20047
+ server.on("connection", (socket) => socket.setNoDelay(true));
20048
+ const boundPort = await new Promise((resolve, reject) => {
20049
+ server.once("error", reject);
20050
+ server.listen(opts.port, host, () => {
20051
+ const addr = server.address();
20052
+ if (addr === null || typeof addr === "string") {
20053
+ reject(/* @__PURE__ */ new Error("Could not determine front-door listening address"));
20054
+ return;
20055
+ }
20056
+ resolve(addr.port);
20057
+ });
20058
+ });
20059
+ let closed = false;
20060
+ return {
20061
+ port: boundPort,
20062
+ host,
20063
+ server,
20064
+ close: async () => {
20065
+ if (closed) return;
20066
+ closed = true;
20067
+ await new Promise((resolve) => {
20068
+ server.close(() => resolve());
20069
+ server.closeAllConnections?.();
20070
+ });
20071
+ }
20072
+ };
20073
+ }
20074
+ function handleProxyRequest(req, res, opts) {
20075
+ return new Promise((resolve) => {
20076
+ const endpoint = opts.pool.next();
20077
+ if (!endpoint) {
20078
+ writeError(res, 503, `No running replicas for service '${opts.serviceName}'. The front-door has no healthy target to forward to.`);
20079
+ resolve();
20080
+ return;
20081
+ }
20082
+ let settled = false;
20083
+ const done = () => {
20084
+ if (settled) return;
20085
+ settled = true;
20086
+ resolve();
20087
+ };
20088
+ const headers = { ...req.headers };
20089
+ appendForwardedHeaders(headers, req, opts.listenerPort);
20090
+ const proxyReq = request({
20091
+ host: endpoint.host,
20092
+ port: endpoint.port,
20093
+ method: req.method,
20094
+ path: req.url,
20095
+ headers
20096
+ }, (proxyRes) => {
20097
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
20098
+ proxyRes.pipe(res);
20099
+ proxyRes.on("end", done);
20100
+ proxyRes.on("error", () => {
20101
+ if (!res.writableEnded) res.destroy();
20102
+ done();
20103
+ });
20104
+ });
20105
+ proxyReq.setTimeout(opts.upstreamTimeoutMs ?? 3e4, () => {
20106
+ if (!res.headersSent) writeError(res, 504, `Replica ${endpoint.host}:${endpoint.port} for service '${opts.serviceName}' did not respond in time.`);
20107
+ else if (!res.writableEnded) res.destroy();
20108
+ proxyReq.destroy();
20109
+ done();
20110
+ });
20111
+ proxyReq.on("error", () => {
20112
+ if (!res.headersSent) writeError(res, 502, `Failed to reach replica ${endpoint.host}:${endpoint.port} for service '${opts.serviceName}'.`);
20113
+ else if (!res.writableEnded) res.destroy();
20114
+ done();
20115
+ });
20116
+ res.on("close", () => {
20117
+ if (!res.writableEnded) proxyReq.destroy();
20118
+ });
20119
+ req.pipe(proxyReq);
20120
+ });
20121
+ }
19863
20122
  /**
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.
20123
+ * Inject the ALB-style forwarding headers a downstream app may read. Appends
20124
+ * the client IP to any existing `X-Forwarded-For` chain (ALB appends rather
20125
+ * than replaces) and stamps the scheme / listener port.
20126
+ */
20127
+ function appendForwardedHeaders(headers, req, listenerPort) {
20128
+ const clientIp = req.socket.remoteAddress ?? "";
20129
+ const existing = headers["x-forwarded-for"];
20130
+ const chain = Array.isArray(existing) ? existing.join(", ") : existing;
20131
+ headers["x-forwarded-for"] = chain ? `${chain}, ${clientIp}` : clientIp;
20132
+ headers["x-forwarded-proto"] = "http";
20133
+ headers["x-forwarded-port"] = String(listenerPort);
20134
+ }
20135
+ function writeError(res, statusCode, message) {
20136
+ res.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
20137
+ res.end(`${message}\n`);
20138
+ }
20139
+
20140
+ //#endregion
20141
+ //#region src/cli/commands/ecs-service-emulator.ts
20142
+ /**
20143
+ * Long-running ECS-service emulator. Synths the app, resolves the strategy's
20144
+ * targets into service boots, boots every replica pool (with optional
20145
+ * front-door), and blocks until `^C`. Idempotent single-flight cleanup tears
20146
+ * down every replica + front-door server + the shared network + sidecar.
19868
20147
  */
19869
- async function localStartServiceCommand(targets, options, extraStateProviders) {
20148
+ async function runEcsServiceEmulator(targets, options, strategy, extraStateProviders) {
19870
20149
  const logger = getLogger();
19871
20150
  if (options.verbose) logger.setLevel("debug");
19872
20151
  warnIfDeprecatedRegion(options);
@@ -19883,6 +20162,7 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19883
20162
  await Promise.allSettled(pt.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0));
19884
20163
  await Promise.allSettled(pt.runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
19885
20164
  }
20165
+ 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
20166
  }));
19887
20167
  if (profileCredsFile) {
19888
20168
  try {
@@ -19921,14 +20201,17 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19921
20201
  };
19922
20202
  const { stacks } = await synthesizer.synthesize(synthOpts);
19923
20203
  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.`)
20204
+ entries: strategy.pickEntries(stacks),
20205
+ message: strategy.pickerMessage,
20206
+ noun: strategy.pickerNoun,
20207
+ onMissing: () => strategy.onMissing()
19928
20208
  });
19929
- rejectExplicitCfnStackWithMultipleStacks(options, resolvedTargets.length);
19930
- perTarget = resolvedTargets.map((t) => ({
19931
- target: t,
20209
+ const { boots, warnings } = strategy.resolveBoots(stacks, resolvedTargets);
20210
+ for (const w of warnings) logger.warn(w);
20211
+ if (boots.length === 0) throw new LocalStartServiceError(`No runnable ECS service resolved from ${resolvedTargets.join(", ")}.`);
20212
+ rejectExplicitCfnStackWithMultipleStacks(options, boots.length);
20213
+ perTarget = boots.map((boot) => ({
20214
+ boot,
19932
20215
  runState: createServiceRunState()
19933
20216
  }));
19934
20217
  const cloudMapIndexByStack = /* @__PURE__ */ new Map();
@@ -19966,7 +20249,11 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19966
20249
  };
19967
20250
  process.on("SIGINT", sigintHandler);
19968
20251
  process.on("SIGTERM", sigintHandler);
19969
- for (const pt of perTarget) pt.controller = await bootOneTarget(pt.target, pt.runState, stacks, options, discovery, skipPull, extraStateProviders, profileCredsFile);
20252
+ for (const pt of perTarget) {
20253
+ const booted = await bootOneTarget(pt.boot, pt.runState, stacks, options, discovery, skipPull, extraStateProviders, profileCredsFile, strategy.lbPortOverrides);
20254
+ pt.controller = booted.controller;
20255
+ pt.frontDoorServers = booted.frontDoorServers;
20256
+ }
19970
20257
  const summary = perTarget.map((pt) => `${pt.controller.service.serviceName} (${pt.controller.activeReplicaCount()} replica(s))`).join(", ");
19971
20258
  logger.info(`Service(s) running: ${summary}.`);
19972
20259
  logger.info("Press ^C to shut down.");
@@ -19979,21 +20266,18 @@ async function localStartServiceCommand(targets, options, extraStateProviders) {
19979
20266
  await cleanup();
19980
20267
  }
19981
20268
  }
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);
20269
+ async function bootOneTarget(boot, runState, stacks, options, discovery, skipPull, extraStateProviders, profileCredsFile, lbPortOverrides) {
20270
+ const candidate = pickCandidateStack(parseEcsTarget(boot.target).stackPattern, stacks);
19988
20271
  const stateProvider = createLocalStateProvider(options, candidate?.stackName ?? "", await resolveCfnFallbackRegion(options, candidate?.region), extraStateProviders);
19989
20272
  try {
19990
- return await runOneTarget(target, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile);
20273
+ return await runOneTarget(boot, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile, lbPortOverrides);
19991
20274
  } finally {
19992
20275
  if (stateProvider) stateProvider.dispose();
19993
20276
  }
19994
20277
  }
19995
- async function runOneTarget(target, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile) {
20278
+ async function runOneTarget(boot, runState, stacks, options, discovery, skipPull, stateProvider, profileCredsFile, lbPortOverrides) {
19996
20279
  const logger = getLogger();
20280
+ const target = boot.target;
19997
20281
  const imageContext = await buildEcsImageResolutionContext(target, stacks, options, stateProvider);
19998
20282
  const service = resolveEcsServiceTarget(target, stacks, imageContext);
19999
20283
  logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
@@ -20047,12 +20331,72 @@ async function runOneTarget(target, runState, stacks, options, discovery, skipPu
20047
20331
  containerPath: profileCredsFile.containerPath,
20048
20332
  profileName: profileCredsFile.profileName
20049
20333
  };
20050
- return startEcsService(service, {
20334
+ const { frontDoorContext, frontDoorServers } = await startFrontDoorServers(boot.frontDoorTargets, service, options.containerHost, lbPortOverrides, logger);
20335
+ const runnerOpts = {
20051
20336
  maxTasks: options.maxTasks,
20052
20337
  restartPolicy: options.restartPolicy,
20053
20338
  taskOptions: taskOpts,
20054
- discovery
20055
- }, runState);
20339
+ discovery,
20340
+ ...frontDoorContext ? { frontDoor: frontDoorContext } : {}
20341
+ };
20342
+ let controller;
20343
+ try {
20344
+ controller = await startEcsService(service, runnerOpts, runState);
20345
+ } catch (err) {
20346
+ await Promise.allSettled(frontDoorServers.map((s) => s.close()));
20347
+ throw err;
20348
+ }
20349
+ return {
20350
+ controller,
20351
+ frontDoorServers
20352
+ };
20353
+ }
20354
+ /**
20355
+ * Issue #86 v1 — stand up one host-side reverse-proxy server per resolved
20356
+ * front-door listener and return the {@link FrontDoorRunnerContext} to thread
20357
+ * into the runner (so each replica publishes + registers its ephemeral
20358
+ * endpoint), plus the started servers for teardown. Pure front-door MECHANISM:
20359
+ * the ALB-specific resolution that produced `frontDoorTargets` lives in the
20360
+ * `start-alb` command. Returns an undefined context + empty servers when there
20361
+ * are no front-door targets (the pure-compute path).
20362
+ *
20363
+ * On a bind failure (e.g. EACCES on a privileged listener port, or the port is
20364
+ * already in use) every server started so far is closed and the error is
20365
+ * re-thrown with a `--lb-port` hint.
20366
+ */
20367
+ async function startFrontDoorServers(frontDoorTargets, service, containerHost, lbPortOverrides, logger) {
20368
+ if (frontDoorTargets.length === 0) return {
20369
+ frontDoorContext: void 0,
20370
+ frontDoorServers: []
20371
+ };
20372
+ const frontDoorServers = [];
20373
+ const pools = [];
20374
+ try {
20375
+ for (const t of frontDoorTargets) {
20376
+ const pool = new FrontDoorEndpointPool();
20377
+ const server = await startFrontDoorServer({
20378
+ pool,
20379
+ port: lbPortOverrides[t.listenerPort] ?? t.listenerPort,
20380
+ host: containerHost,
20381
+ listenerPort: t.listenerPort,
20382
+ serviceName: service.serviceName
20383
+ });
20384
+ frontDoorServers.push(server);
20385
+ pools.push({
20386
+ pool,
20387
+ targetContainerName: t.targetContainerName,
20388
+ targetContainerPort: t.targetContainerPort
20389
+ });
20390
+ 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)`);
20391
+ }
20392
+ } catch (err) {
20393
+ await Promise.allSettled(frontDoorServers.map((s) => s.close()));
20394
+ 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).`);
20395
+ }
20396
+ return {
20397
+ frontDoorContext: { pools },
20398
+ frontDoorServers
20399
+ };
20056
20400
  }
20057
20401
  async function resolvePlaceholderAccount(arn, region) {
20058
20402
  if (!arn.includes("${AWS::AccountId}")) return arn;
@@ -20086,11 +20430,9 @@ async function assumeTaskRole(roleArn, region) {
20086
20430
  }
20087
20431
  }
20088
20432
  /**
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).
20433
+ * Build the substitution context the ECS resolver consumes. Exported for the
20434
+ * site-level binding test that locks the `--from-cfn-stack` SSM-parameter
20435
+ * resolution call (issue #94).
20094
20436
  */
20095
20437
  async function buildEcsImageResolutionContext(target, stacks, options, stateProvider) {
20096
20438
  const logger = getLogger();
@@ -20175,9 +20517,8 @@ function parsePositiveInt(raw, flagName) {
20175
20517
  return parsed;
20176
20518
  }
20177
20519
  /**
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.
20520
+ * Hard cap on `--max-tasks` driven by the per-replica subnet allocator in
20521
+ * `ecs-service-runner.ts:pickSubnetOctet`.
20181
20522
  */
20182
20523
  const MAX_TASKS_SUBNET_RANGE_CAP = 83;
20183
20524
  function parseMaxTasks(raw) {
@@ -20190,37 +20531,23 @@ function parseRestartPolicy(raw) {
20190
20531
  throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
20191
20532
  }
20192
20533
  /**
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).
20534
+ * Resolve the credentials forwarded to the AWS-published metadata-endpoints
20535
+ * sidecar (shared across every replica boot in one CLI invocation). `--profile`
20536
+ * resolves via the SDK default chain; unset yields `undefined`. Per-service
20537
+ * `--assume-task-role` overrides are intentionally NOT consulted here. Exported
20538
+ * for a unit test that exercises both branches.
20215
20539
  */
20216
20540
  async function resolveSharedSidecarCredentials(options) {
20217
20541
  if (options.profile) return resolveProfileCredentials(options.profile);
20218
20542
  }
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
- }));
20543
+ /**
20544
+ * Add the CLI options shared by both ECS-service commands (`start-service` and
20545
+ * `start-alb`) to a command. The command-specific argument / description and
20546
+ * the one unique option (`--host-port` vs `--lb-port`) are added by each
20547
+ * factory.
20548
+ */
20549
+ function addCommonEcsServiceOptions(cmd) {
20550
+ 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
20551
  [
20225
20552
  ...commonOptions(),
20226
20553
  ...appOptions(),
@@ -20230,6 +20557,367 @@ function createLocalStartServiceCommand(opts = {}) {
20230
20557
  return cmd;
20231
20558
  }
20232
20559
 
20560
+ //#endregion
20561
+ //#region src/cli/commands/local-start-service.ts
20562
+ /**
20563
+ * `cdkl start-service` strategy — a pure ECS replica runner. It picks
20564
+ * `AWS::ECS::Service` targets and boots each with NO front-door (the ALB
20565
+ * front-door is its own command, `cdkl start-alb`). This keeps `start-service`
20566
+ * a leaf compute runner, symmetric with `invoke` / `run-task`.
20567
+ */
20568
+ function serviceStrategy() {
20569
+ return {
20570
+ pickEntries: (stacks) => listTargets(stacks).ecsServices,
20571
+ pickerMessage: "Select one or more ECS services to run",
20572
+ pickerNoun: "ECS services",
20573
+ 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.`),
20574
+ resolveBoots: (_stacks, chosenTargets) => ({
20575
+ boots: chosenTargets.map((target) => ({
20576
+ target,
20577
+ frontDoorTargets: []
20578
+ })),
20579
+ warnings: []
20580
+ }),
20581
+ lbPortOverrides: {}
20582
+ };
20583
+ }
20584
+ /**
20585
+ * `cdkl start-service <Stack/Service>` — Phase 2 of #262. Spins up
20586
+ * `DesiredCount` task replicas locally (clamped by `--max-tasks`) using the
20587
+ * existing `ecs-task-runner` per replica. Long-running; ^C cleans every replica
20588
+ * + sidecar + shared network. Pure compute: to put a local ALB front-door in
20589
+ * front of an ALB-fronted service, use `cdkl start-alb`.
20590
+ */
20591
+ function createLocalStartServiceCommand(opts = {}) {
20592
+ setEmbedConfig(opts.embedConfig);
20593
+ 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) => {
20594
+ await runEcsServiceEmulator(targets, options, serviceStrategy(), opts.extraStateProviders);
20595
+ })));
20596
+ }
20597
+
20598
+ //#endregion
20599
+ //#region src/local/elb-front-door-resolver.ts
20600
+ /**
20601
+ * Issue #86 v1 — resolve an `AWS::ElasticLoadBalancingV2::LoadBalancer` (an
20602
+ * ALB) into the backing ECS service(s) and the host listener port(s) a local
20603
+ * front-door should expose for each. This is the `cdkl start-alb` entry: you
20604
+ * name the ALB, and cdk-local discovers the services behind it (mirroring how
20605
+ * `start-api` names the API and discovers the backing Lambdas).
20606
+ *
20607
+ * The synthesized linkage (confirmed against real `cdk synth` of
20608
+ * `ApplicationLoadBalancedFargateService`):
20609
+ *
20610
+ * ```
20611
+ * ElasticLoadBalancingV2::LoadBalancer (the ALB you name)
20612
+ * ElasticLoadBalancingV2::Listener : { LoadBalancerArn:{Ref:<ALB>}, Port, Protocol,
20613
+ * DefaultActions:[{ Type:"forward", TargetGroupArn:{Ref:<TG>} }] }
20614
+ * ElasticLoadBalancingV2::TargetGroup : { Port, Protocol, TargetType:"ip" }
20615
+ * ECS::Service.LoadBalancers[] -> { ContainerName, ContainerPort, TargetGroupArn:{Ref:<TG>} }
20616
+ * ```
20617
+ *
20618
+ * Resolution walks ALB -> listeners (by `LoadBalancerArn` Ref) -> default
20619
+ * `forward` target groups -> the ECS Service whose `LoadBalancers[]` references
20620
+ * that target group (a reverse scan; there is no direct TG -> service pointer).
20621
+ *
20622
+ * v1 scope (single forward): only listener `DefaultActions` are honored.
20623
+ * `AWS::ElasticLoadBalancingV2::ListenerRule` (path / host / weighted routing)
20624
+ * is ignored — tracked in #123. HTTPS / TLS listeners and `TargetType:"lambda"`
20625
+ * target groups are skipped with a warning.
20626
+ */
20627
+ const ALB_TYPE = "AWS::ElasticLoadBalancingV2::LoadBalancer";
20628
+ const LISTENER_TYPE = "AWS::ElasticLoadBalancingV2::Listener";
20629
+ const TARGET_GROUP_TYPE = "AWS::ElasticLoadBalancingV2::TargetGroup";
20630
+ const SERVICE_TYPE = "AWS::ECS::Service";
20631
+ /**
20632
+ * Resolve an ALB into its backing services + front-door targets. Pure — reads
20633
+ * only the supplied stack template. Returns an empty `services` array (with
20634
+ * warnings) when the ALB fronts nothing cdk-local can serve locally.
20635
+ */
20636
+ function resolveAlbFrontDoor(stack, albLogicalId) {
20637
+ const warnings = [];
20638
+ const resources = stack.template.Resources ?? {};
20639
+ const tgToService = indexTargetGroupToService(resources);
20640
+ const byService = /* @__PURE__ */ new Map();
20641
+ const seenPortsByService = /* @__PURE__ */ new Map();
20642
+ for (const [listenerLogicalId, resource] of Object.entries(resources)) {
20643
+ if (resource.Type !== LISTENER_TYPE) continue;
20644
+ const props = resource.Properties ?? {};
20645
+ if (refOf(props["LoadBalancerArn"]) !== albLogicalId) continue;
20646
+ const port = parsePort(props["Port"]);
20647
+ if (port === void 0) continue;
20648
+ const protocol = typeof props["Protocol"] === "string" ? props["Protocol"] : "HTTP";
20649
+ const tgRefs = collectForwardTargetGroupRefs(props["DefaultActions"]);
20650
+ if (tgRefs.size === 0) {
20651
+ 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.`);
20652
+ continue;
20653
+ }
20654
+ if (protocol !== "HTTP") {
20655
+ 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.`);
20656
+ continue;
20657
+ }
20658
+ for (const tgRef of tgRefs) {
20659
+ const tg = resources[tgRef];
20660
+ if (!tg || tg.Type !== TARGET_GROUP_TYPE) {
20661
+ warnings.push(`Listener '${listenerLogicalId}' forwards to target group '${tgRef}', but no ${TARGET_GROUP_TYPE} with that logical id exists in ${stack.stackName}. Skipping it.`);
20662
+ continue;
20663
+ }
20664
+ if (tg.Properties?.["TargetType"] === "lambda") {
20665
+ 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.`);
20666
+ continue;
20667
+ }
20668
+ const backing = tgToService.get(tgRef);
20669
+ if (!backing) {
20670
+ 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.`);
20671
+ continue;
20672
+ }
20673
+ const seenPorts = seenPortsByService.get(backing.serviceLogicalId) ?? /* @__PURE__ */ new Set();
20674
+ if (seenPorts.has(port)) {
20675
+ warnings.push(`Service '${backing.serviceLogicalId}' is fronted by more than one listener on host port ${port}; the local front-door fronts only the first.`);
20676
+ continue;
20677
+ }
20678
+ seenPorts.add(port);
20679
+ seenPortsByService.set(backing.serviceLogicalId, seenPorts);
20680
+ const targets = byService.get(backing.serviceLogicalId) ?? [];
20681
+ targets.push({
20682
+ listenerPort: port,
20683
+ listenerProtocol: "HTTP",
20684
+ targetContainerName: backing.containerName,
20685
+ targetContainerPort: backing.containerPort,
20686
+ targetGroupLogicalId: tgRef,
20687
+ listenerLogicalId
20688
+ });
20689
+ byService.set(backing.serviceLogicalId, targets);
20690
+ }
20691
+ }
20692
+ return {
20693
+ services: [...byService.entries()].map(([serviceLogicalId, targets]) => ({
20694
+ serviceLogicalId,
20695
+ targets
20696
+ })),
20697
+ warnings
20698
+ };
20699
+ }
20700
+ /** True when the resource is an application Load Balancer (the `start-alb` target type). */
20701
+ function isApplicationLoadBalancer(resource) {
20702
+ if (resource.Type !== ALB_TYPE) return false;
20703
+ const type = resource.Properties?.["Type"];
20704
+ return type === void 0 || type === "application";
20705
+ }
20706
+ /**
20707
+ * Build a `targetGroupLogicalId -> backing ECS service` index by scanning every
20708
+ * `AWS::ECS::Service.LoadBalancers[]`. First service wins on a shared target
20709
+ * group (unusual; would only happen with a hand-rolled template).
20710
+ */
20711
+ function indexTargetGroupToService(resources) {
20712
+ const index = /* @__PURE__ */ new Map();
20713
+ for (const [serviceLogicalId, resource] of Object.entries(resources)) {
20714
+ if (resource.Type !== SERVICE_TYPE) continue;
20715
+ const lbs = resource.Properties?.["LoadBalancers"];
20716
+ if (!Array.isArray(lbs)) continue;
20717
+ for (const entry of lbs) {
20718
+ if (!entry || typeof entry !== "object") continue;
20719
+ const e = entry;
20720
+ const tgRef = refOf(e["TargetGroupArn"]);
20721
+ const containerName = typeof e["ContainerName"] === "string" ? e["ContainerName"] : void 0;
20722
+ const containerPort = parseContainerPort(e["ContainerPort"]);
20723
+ if (!tgRef || !containerName || containerPort === void 0) continue;
20724
+ if (!index.has(tgRef)) index.set(tgRef, {
20725
+ serviceLogicalId,
20726
+ containerName,
20727
+ containerPort
20728
+ });
20729
+ }
20730
+ }
20731
+ return index;
20732
+ }
20733
+ function collectForwardTargetGroupRefs(defaultActions) {
20734
+ const refs = /* @__PURE__ */ new Set();
20735
+ if (!Array.isArray(defaultActions)) return refs;
20736
+ for (const action of defaultActions) {
20737
+ if (!action || typeof action !== "object") continue;
20738
+ const a = action;
20739
+ if (a["Type"] !== "forward") continue;
20740
+ const direct = refOf(a["TargetGroupArn"]);
20741
+ if (direct) refs.add(direct);
20742
+ const forwardConfig = a["ForwardConfig"];
20743
+ if (forwardConfig && typeof forwardConfig === "object") {
20744
+ const groups = forwardConfig["TargetGroups"];
20745
+ if (Array.isArray(groups)) for (const g of groups) {
20746
+ if (!g || typeof g !== "object") continue;
20747
+ const ref = refOf(g["TargetGroupArn"]);
20748
+ if (ref) refs.add(ref);
20749
+ }
20750
+ }
20751
+ }
20752
+ return refs;
20753
+ }
20754
+ /**
20755
+ * True when `DefaultActions` has at least one `forward` action that references
20756
+ * a target group via a NON-`Ref` arn (literal / `Fn::GetAtt` / cross-stack) —
20757
+ * i.e. a forward we could not resolve to an in-stack target group. Used to warn
20758
+ * rather than silently skip such a listener.
20759
+ */
20760
+ function hasUnresolvableForward(defaultActions) {
20761
+ if (!Array.isArray(defaultActions)) return false;
20762
+ for (const action of defaultActions) {
20763
+ if (!action || typeof action !== "object") continue;
20764
+ const a = action;
20765
+ if (a["Type"] !== "forward") continue;
20766
+ if (a["TargetGroupArn"] !== void 0 && refOf(a["TargetGroupArn"]) === void 0) return true;
20767
+ const forwardConfig = a["ForwardConfig"];
20768
+ if (forwardConfig && typeof forwardConfig === "object") {
20769
+ const groups = forwardConfig["TargetGroups"];
20770
+ if (Array.isArray(groups)) for (const g of groups) {
20771
+ if (!g || typeof g !== "object") continue;
20772
+ const arn = g["TargetGroupArn"];
20773
+ if (arn !== void 0 && refOf(arn) === void 0) return true;
20774
+ }
20775
+ }
20776
+ }
20777
+ return false;
20778
+ }
20779
+ function refOf(raw) {
20780
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
20781
+ const ref = raw["Ref"];
20782
+ if (typeof ref === "string" && ref.length > 0) return ref;
20783
+ }
20784
+ }
20785
+ function parsePort(raw) {
20786
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 65535) return raw;
20787
+ if (typeof raw === "string" && /^\d+$/.test(raw)) {
20788
+ const n = parseInt(raw, 10);
20789
+ if (n >= 1 && n <= 65535) return n;
20790
+ }
20791
+ }
20792
+ function parseContainerPort(raw) {
20793
+ return parsePort(raw);
20794
+ }
20795
+
20796
+ //#endregion
20797
+ //#region src/cli/commands/local-start-alb.ts
20798
+ /**
20799
+ * Issue #86 v1 — parse `--lb-port <listenerPort>=<hostPort>` overrides into a
20800
+ * `listenerPort -> hostPort` map. The local ALB front-door binds the listener
20801
+ * port on the host by default, but a privileged listener port (e.g. 80 / 443)
20802
+ * fails to bind as non-root on macOS, so the user opts in to a non-privileged
20803
+ * host port (e.g. `--lb-port 80=8080`). Repeatable; each value is
20804
+ * `<listenerPort>=<hostPort>` with both in 1-65535.
20805
+ */
20806
+ function parseLbPortOverrides(values) {
20807
+ const out = {};
20808
+ for (const raw of values ?? []) {
20809
+ const m = /^(\d+)=(\d+)$/.exec(raw.trim());
20810
+ if (!m) throw new LocalStartServiceError(`Invalid --lb-port '${raw}'. Expected <listenerPort>=<hostPort> (e.g. 80=8080).`);
20811
+ const listenerPort = Number(m[1]);
20812
+ const hostPort = Number(m[2]);
20813
+ 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.`);
20814
+ out[listenerPort] = hostPort;
20815
+ }
20816
+ return out;
20817
+ }
20818
+ /**
20819
+ * Resolve an ALB target string (`Stack/Path` display path or `Stack:LogicalId`)
20820
+ * to its stack + `AWS::ElasticLoadBalancingV2::LoadBalancer` logical id. Mirrors
20821
+ * the ECS service resolver's target grammar.
20822
+ */
20823
+ function resolveAlbTarget(target, stacks) {
20824
+ if (stacks.length === 0) throw new LocalStartServiceError("No stacks found in the synthesized assembly.");
20825
+ const parsed = parseEcsTarget(target);
20826
+ const stack = pickStack(parsed.stackPattern, stacks, target);
20827
+ const resources = stack.template.Resources ?? {};
20828
+ if (parsed.isPath) {
20829
+ const index = buildCdkPathIndex(stack.template);
20830
+ const albs = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId }) => {
20831
+ const r = resources[logicalId];
20832
+ return r !== void 0 && isApplicationLoadBalancer(r);
20833
+ });
20834
+ if (albs.length === 0) throw notFound(target, stack, resources);
20835
+ 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.`);
20836
+ return {
20837
+ stack,
20838
+ albLogicalId: albs[0].logicalId
20839
+ };
20840
+ }
20841
+ const res = resources[parsed.pathOrId];
20842
+ if (!res || !isApplicationLoadBalancer(res)) throw notFound(target, stack, resources);
20843
+ return {
20844
+ stack,
20845
+ albLogicalId: parsed.pathOrId
20846
+ };
20847
+ }
20848
+ function pickStack(stackPattern, stacks, target) {
20849
+ if (stackPattern === null) {
20850
+ if (stacks.length === 1) return stacks[0];
20851
+ 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'.`);
20852
+ }
20853
+ const matched = matchStacks(stacks, [stackPattern]);
20854
+ if (matched.length === 0) throw new LocalStartServiceError(`No stack matches '${stackPattern}'. Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
20855
+ if (matched.length > 1) throw new LocalStartServiceError(`Multiple stacks match '${stackPattern}': ${matched.map((s) => s.stackName).join(", ")}. Refine the pattern.`);
20856
+ return matched[0];
20857
+ }
20858
+ function notFound(target, stack, resources) {
20859
+ const albs = Object.entries(resources).filter(([, r]) => r.Type === "AWS::ElasticLoadBalancingV2::LoadBalancer").map(([logicalId]) => logicalId);
20860
+ const available = albs.length > 0 ? ` Available load balancers in ${stack.stackName}: ${albs.join(", ")}.` : ` ${stack.stackName} declares no AWS::ElasticLoadBalancingV2::LoadBalancer resources.`;
20861
+ return new LocalStartServiceError(`Target '${target}' did not match an application Load Balancer in ${stack.stackName}.${available}`);
20862
+ }
20863
+ /**
20864
+ * `cdkl start-alb` strategy — name the ALB, boot the ECS service(s) behind it,
20865
+ * and expose each listener via a local front-door. Mirrors how `start-api`
20866
+ * names the API and serves its backing Lambdas.
20867
+ */
20868
+ function albStrategy(options) {
20869
+ const lbPortOverrides = parseLbPortOverrides(options.lbPort);
20870
+ return {
20871
+ pickEntries: (stacks) => listTargets(stacks).loadBalancers,
20872
+ pickerMessage: "Select one or more Application Load Balancers to run",
20873
+ pickerNoun: "Application Load Balancers",
20874
+ 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.`),
20875
+ resolveBoots: (stacks, chosenTargets) => {
20876
+ const byServiceTarget = /* @__PURE__ */ new Map();
20877
+ const warnings = [];
20878
+ for (const albTarget of chosenTargets) {
20879
+ const { stack, albLogicalId } = resolveAlbTarget(albTarget, stacks);
20880
+ const resolution = resolveAlbFrontDoor(stack, albLogicalId);
20881
+ warnings.push(...resolution.warnings);
20882
+ for (const svc of resolution.services) {
20883
+ const target = `${stack.stackName}:${svc.serviceLogicalId}`;
20884
+ const existing = byServiceTarget.get(target);
20885
+ if (existing) existing.frontDoorTargets.push(...svc.targets);
20886
+ else byServiceTarget.set(target, {
20887
+ target,
20888
+ frontDoorTargets: [...svc.targets]
20889
+ });
20890
+ }
20891
+ }
20892
+ const boots = [...byServiceTarget.values()];
20893
+ const resolvedPorts = /* @__PURE__ */ new Set();
20894
+ for (const b of boots) for (const t of b.frontDoorTargets) resolvedPorts.add(t.listenerPort);
20895
+ for (const portStr of Object.keys(lbPortOverrides)) {
20896
+ const port = Number(portStr);
20897
+ if (!resolvedPorts.has(port)) warnings.push(`--lb-port override for listener port ${port} matched no ALB listener resolved for the named target(s); it was ignored.`);
20898
+ }
20899
+ return {
20900
+ boots,
20901
+ warnings
20902
+ };
20903
+ },
20904
+ lbPortOverrides
20905
+ };
20906
+ }
20907
+ /**
20908
+ * `cdkl start-alb <Stack/Alb>` — Issue #86 v1. Names an
20909
+ * `AWS::ElasticLoadBalancingV2::LoadBalancer`, discovers the ECS service(s)
20910
+ * behind its HTTP `forward` listeners, boots their replicas, and stands up a
20911
+ * local front-door on each listener port that round-robins across the replicas.
20912
+ * The symmetric ALB counterpart of `start-api`.
20913
+ */
20914
+ function createLocalStartAlbCommand(opts = {}) {
20915
+ setEmbedConfig(opts.embedConfig);
20916
+ 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) => {
20917
+ await runEcsServiceEmulator(targets, options, albStrategy(options), opts.extraStateProviders);
20918
+ })));
20919
+ }
20920
+
20233
20921
  //#endregion
20234
20922
  //#region src/cli/commands/local-list.ts
20235
20923
  async function localListCommand(options) {
@@ -20265,14 +20953,15 @@ async function localListCommand(options) {
20265
20953
  * without running synthesis.
20266
20954
  */
20267
20955
  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.`;
20956
+ if (countTargets(listing) === 0) return `No runnable targets (Lambda functions, APIs, ECS services / tasks, AgentCore Runtimes, load balancers) found in this CDK app.`;
20269
20957
  const long = options.long ?? false;
20270
20958
  return "\n" + [
20271
20959
  formatSection("Lambda Functions", `${cliName} invoke <target>`, listing.lambdas, long),
20272
20960
  formatSection("APIs", `${cliName} start-api [target...]`, listing.apis, long),
20273
20961
  formatSection("ECS Services", `${cliName} start-service <target...>`, listing.ecsServices, long),
20274
20962
  formatSection("ECS Task Definitions", `${cliName} run-task <target>`, listing.ecsTaskDefinitions, long),
20275
- formatSection("AgentCore Runtimes", `${cliName} invoke-agentcore <target>`, listing.agentCoreRuntimes, long)
20963
+ formatSection("AgentCore Runtimes", `${cliName} invoke-agentcore <target>`, listing.agentCoreRuntimes, long),
20964
+ formatSection("Application Load Balancers", `${cliName} start-alb <target...>`, listing.loadBalancers, long)
20276
20965
  ].filter((lines) => lines.length > 0).map((lines) => lines.join("\n")).join("\n\n");
20277
20966
  }
20278
20967
  function formatSection(title, command, entries, long) {
@@ -20287,7 +20976,7 @@ function formatSection(title, command, entries, long) {
20287
20976
  }
20288
20977
  function createLocalListCommand(opts = {}) {
20289
20978
  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) => {
20979
+ 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
20980
  await localListCommand(options);
20292
20981
  }));
20293
20982
  [
@@ -20300,5 +20989,5 @@ function createLocalListCommand(opts = {}) {
20300
20989
  }
20301
20990
 
20302
20991
  //#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
20992
+ 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 };
20993
+ //# sourceMappingURL=local-list-Du8LtabS.js.map