@go-to-k/cdkd 0.130.0 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-CuHRHcyW.js";
3
- import { A as AssetPublisher, B as getLegacyStateBucketName, C as applyRoleArnIfSet, Ct as generateResourceNameWithFallback, D as LockManager, E as TemplateParser, F as getDockerCmd, G as resolveStateBucketWithDefaultAndSource, H as resolveCaptureObservedState, I as runDockerForeground, K as warnDeprecatedNoPrefixCliFlag, L as runDockerStreaming, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as formatDockerLoginError, R as Synthesizer, S as IntrinsicFunctionResolver, St as generateResourceName, T as DagBuilder, Tt as withStackName, U as resolveSkipPrefix, V as resolveApp, W as resolveStateBucketWithDefault, Y as resolveBucketRegion, Z as CdkdError, _ as normalizeAwsTagsToCfn, a as withRetry, at as ResourceUpdateNotSupportedError, b as CloudControlProvider, bt as PATTERN_B_NAME_PROPERTIES, c as cyan, ct as StackTerminationProtectionError, d as red, et as LocalInvokeBuildError, f as yellow, g as matchesCdkPath, gt as getLogger, h as CDK_PATH_TAG, i as withResourceDeadline, it as ResourceTimeoutError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, mt as withErrorHandling, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as PartialFailureError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as RouteDiscoveryError, p as IAMRoleProvider, pt as normalizeAwsError, q as AssemblyReader, r as DeployEngine, rt as ProvisioningError, s as bold, st as StackHasActiveImportsError, t as DEFAULT_RESOURCE_TIMEOUT_MS, u as green, v as resolveExplicitPhysicalId, vt as runStackBuffered, w as DiffCalculator, wt as withSkipPrefix, x as assertRegionMatch, xt as PATTERN_B_RESOURCE_TYPES, y as ProviderRegistry, yt as getLiveRenderer, z as getDefaultStateBucketName } from "./deploy-engine-B-w4C_7O.js";
2
+ import { _ as withSkipPrefix, a as runDockerStreaming, c as getLogger, d as getLiveRenderer, f as PATTERN_B_NAME_PROPERTIES, g as generateResourceNameWithFallback, h as generateResourceName, i as runDockerForeground, n as formatDockerLoginError, p as PATTERN_B_RESOURCE_TYPES, r as getDockerCmd, u as runStackBuffered, v as withStackName } from "./docker-cmd-EtWSTAje.js";
3
+ import { $ as PartialFailureError, A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as resolveBucketRegion, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as AssemblyReader, V as resolveStateBucketWithDefaultAndSource, X as LocalInvokeBuildError, Z as LocalStartServiceError, _ as normalizeAwsTagsToCfn, a as withRetry, at as StackTerminationProtectionError, b as CloudControlProvider, c as cyan, d as red, dt as withErrorHandling, et as ProvisioningError, f as yellow, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, it as StackHasActiveImportsError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as ResourceUpdateNotSupportedError, o as IMPLICIT_DELETE_DEPENDENCIES, p as IAMRoleProvider, q as CdkdError, r as DeployEngine, rt as RouteDiscoveryError, s as bold, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as ResourceTimeoutError, u as green, ut as normalizeAwsError, v as resolveExplicitPhysicalId, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-Dn7oV5rA.js";
4
+ import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
4
5
  import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
6
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
6
7
  import { AddRoleToInstanceProfileCommand, AddUserToGroupCommand, AttachGroupPolicyCommand, AttachUserPolicyCommand, CreateGroupCommand, CreateInstanceProfileCommand, CreateLoginProfileCommand, CreateUserCommand, DeleteAccessKeyCommand, DeleteGroupCommand, DeleteGroupPolicyCommand, DeleteInstanceProfileCommand, DeleteLoginProfileCommand, DeleteRolePolicyCommand, DeleteUserCommand, DeleteUserPermissionsBoundaryCommand, DeleteUserPolicyCommand, DetachGroupPolicyCommand, DetachUserPolicyCommand, GetGroupCommand, GetGroupPolicyCommand, GetInstanceProfileCommand, GetRolePolicyCommand, GetUserCommand, GetUserPolicyCommand, IAMClient, ListAccessKeysCommand, ListAttachedGroupPoliciesCommand, ListAttachedUserPoliciesCommand, ListGroupPoliciesCommand, ListGroupsForUserCommand, ListInstanceProfilesCommand, ListUserPoliciesCommand, ListUserTagsCommand, ListUsersCommand, NoSuchEntityException, PutGroupPolicyCommand, PutRolePolicyCommand, PutUserPermissionsBoundaryCommand, PutUserPolicyCommand, RemoveRoleFromInstanceProfileCommand, RemoveUserFromGroupCommand, TagUserCommand, UntagUserCommand, UpdateLoginProfileCommand } from "@aws-sdk/client-iam";
@@ -36541,14 +36542,14 @@ function parseTarget(target) {
36541
36542
  function resolveLambdaTarget(target, stacks) {
36542
36543
  if (stacks.length === 0) throw new LocalInvokeResolutionError("No stacks found in the synthesized assembly.");
36543
36544
  const parsed = parseTarget(target);
36544
- const stack = pickStack$1(parsed, stacks);
36545
+ const stack = pickStack$2(parsed, stacks);
36545
36546
  const template = stack.template;
36546
36547
  const resources = template.Resources ?? {};
36547
36548
  let match;
36548
36549
  if (parsed.isPath) {
36549
36550
  const index = buildCdkPathIndex(template);
36550
36551
  const lambdaMatches = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId }) => resources[logicalId]?.Type === "AWS::Lambda::Function");
36551
- if (lambdaMatches.length === 0) throw notFoundError$1(target, stack, resources);
36552
+ if (lambdaMatches.length === 0) throw notFoundError$2(target, stack, resources);
36552
36553
  if (lambdaMatches.length > 1) throw new LocalInvokeResolutionError(`Target '${target}' matches ${lambdaMatches.length} Lambda functions in ${stack.stackName}: ` + lambdaMatches.map((m) => m.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
36553
36554
  const m = lambdaMatches[0];
36554
36555
  match = {
@@ -36557,7 +36558,7 @@ function resolveLambdaTarget(target, stacks) {
36557
36558
  };
36558
36559
  } else {
36559
36560
  const resource = resources[parsed.pathOrId];
36560
- if (!resource) throw notFoundError$1(target, stack, resources);
36561
+ if (!resource) throw notFoundError$2(target, stack, resources);
36561
36562
  match = {
36562
36563
  logicalId: parsed.pathOrId,
36563
36564
  resource
@@ -36575,7 +36576,7 @@ function resolveLambdaTarget(target, stacks) {
36575
36576
  * user may omit the stack prefix. Otherwise an explicit stack pattern is
36576
36577
  * required.
36577
36578
  */
36578
- function pickStack$1(parsed, stacks) {
36579
+ function pickStack$2(parsed, stacks) {
36579
36580
  if (parsed.stackPattern === null) {
36580
36581
  if (stacks.length === 1) return stacks[0];
36581
36582
  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(", ")}.`);
@@ -36912,7 +36913,7 @@ function describeLayerEntry(entry) {
36912
36913
  * the resolved stack so the user can copy/paste a valid target. Mirrors
36913
36914
  * the format the issue spec calls out.
36914
36915
  */
36915
- function notFoundError$1(target, stack, resources) {
36916
+ function notFoundError$2(target, stack, resources) {
36916
36917
  const lambdas = [];
36917
36918
  for (const [logicalId, resource] of Object.entries(resources)) {
36918
36919
  if (resource.Type !== "AWS::Lambda::Function") continue;
@@ -37936,28 +37937,28 @@ function parseEcsTarget(target) {
37936
37937
  function resolveEcsTaskTarget(target, stacks, context) {
37937
37938
  if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
37938
37939
  const parsed = parseEcsTarget(target);
37939
- const stack = pickStack(parsed, stacks);
37940
+ const stack = pickStack$1(parsed, stacks);
37940
37941
  const resources = stack.template.Resources ?? {};
37941
37942
  let logicalId;
37942
37943
  let resource;
37943
37944
  if (parsed.isPath) {
37944
37945
  const index = buildCdkPathIndex(stack.template);
37945
37946
  const taskDefs = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId: l }) => resources[l]?.Type === "AWS::ECS::TaskDefinition");
37946
- if (taskDefs.length === 0) throw notFoundError(target, stack, resources);
37947
+ if (taskDefs.length === 0) throw notFoundError$1(target, stack, resources);
37947
37948
  if (taskDefs.length > 1) throw new EcsTaskResolutionError(`Target '${target}' matches ${taskDefs.length} task definitions in ${stack.stackName}: ` + taskDefs.map((t) => t.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
37948
37949
  logicalId = taskDefs[0].logicalId;
37949
37950
  resource = resources[logicalId];
37950
37951
  } else {
37951
37952
  resource = resources[parsed.pathOrId];
37952
- if (!resource) throw notFoundError(target, stack, resources);
37953
+ if (!resource) throw notFoundError$1(target, stack, resources);
37953
37954
  logicalId = parsed.pathOrId;
37954
37955
  }
37955
- if (!logicalId || !resource) throw notFoundError(target, stack, resources);
37956
+ if (!logicalId || !resource) throw notFoundError$1(target, stack, resources);
37956
37957
  if (resource.Type === "AWS::Lambda::Function") throw new EcsTaskResolutionError(`Resource '${logicalId}' in ${stack.stackName} is a Lambda function, not an ECS task definition. Use \`cdkd local invoke\` for Lambda; \`cdkd local run-task\` is ECS only.`);
37957
37958
  if (resource.Type !== "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`Resource '${logicalId}' in ${stack.stackName} is ${resource.Type}, not an AWS::ECS::TaskDefinition.`);
37958
37959
  return extractTaskDefinitionProperties(stack, logicalId, resource, context);
37959
37960
  }
37960
- function pickStack(parsed, stacks) {
37961
+ function pickStack$1(parsed, stacks) {
37961
37962
  if (parsed.stackPattern === null) {
37962
37963
  if (stacks.length === 1) return stacks[0];
37963
37964
  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(", ")}.`);
@@ -38480,7 +38481,7 @@ function pickStringArray$1(value) {
38480
38481
  for (const v of value) if (typeof v === "string") out.push(v);
38481
38482
  return out;
38482
38483
  }
38483
- function notFoundError(target, stack, resources) {
38484
+ function notFoundError$1(target, stack, resources) {
38484
38485
  const tasks = [];
38485
38486
  for (const [logicalId, resource] of Object.entries(resources)) {
38486
38487
  if (resource.Type !== "AWS::ECS::TaskDefinition") continue;
@@ -45074,7 +45075,7 @@ async function localStartApiCommand(target, options) {
45074
45075
  await ensureDockerAvailable();
45075
45076
  const appCmd = resolveApp(options.app);
45076
45077
  if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
45077
- const overrides = readEnvOverridesFile$2(options.envVars);
45078
+ const overrides = readEnvOverridesFile$3(options.envVars);
45078
45079
  const debugPortBase = options.debugPortBase ? parseDebugPort(options.debugPortBase) : void 0;
45079
45080
  const perLambdaConcurrency = parsePerLambdaConcurrency(options.perLambdaConcurrency);
45080
45081
  const inlineTmpDirs = /* @__PURE__ */ new Set();
@@ -45847,7 +45848,7 @@ function getTemplateEnv$1(resource) {
45847
45848
  return vars;
45848
45849
  }
45849
45850
  /** Read the SAM-shape `--env-vars` JSON file. */
45850
- function readEnvOverridesFile$2(filePath) {
45851
+ function readEnvOverridesFile$3(filePath) {
45851
45852
  if (!filePath) return void 0;
45852
45853
  let raw;
45853
45854
  try {
@@ -46196,14 +46197,38 @@ const execFileAsync$1 = promisify(execFile);
46196
46197
  /** AWS-published sidecar image (latest tag). amd64 is the only image AWS ships. */
46197
46198
  const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:latest-amd64";
46198
46199
  /**
46199
- * Well-known IP for the ECS local-container-endpoints sidecar — matches
46200
- * the documented AWS task-metadata endpoint address. Containers inject
46201
- * `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<container-id>`
46202
- * to reach it.
46200
+ * Default well-known IP for the ECS local-container-endpoints sidecar —
46201
+ * matches the documented AWS task-metadata endpoint address. Containers
46202
+ * inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
46203
+ * to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
46204
+ * start-service` allocates a per-replica subnet (via `subnetOctet`) so
46205
+ * concurrent replicas don't collide on a single docker network range.
46203
46206
  */
46204
46207
  const METADATA_ENDPOINT_IP = "169.254.170.2";
46205
- /** Subnet handed to `docker network create` so the well-known IP is routable. */
46206
- const METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
46208
+ /** Default subnet used when no `subnetOctet` override is supplied. */
46209
+ const DEFAULT_METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
46210
+ /**
46211
+ * Pure-functional subnet allocator. `cdkd local run-task` uses the
46212
+ * default subnet; `cdkd local start-service` walks `subnetOctet=170,
46213
+ * 171, 172, ...` (one per replica) to keep parallel docker networks
46214
+ * from clashing. The link-local 169.254.0.0/16 space is reserved AWS-
46215
+ * wide for cloud metadata so collisions with user workloads are
46216
+ * unlikely, but each replica still gets its own /24 to ensure
46217
+ * docker's `--subnet` allocator does not reject "Pool overlaps".
46218
+ *
46219
+ * `subnetOctet` is the second-from-last byte of the network: 170 →
46220
+ * 169.254.170.0/24 (default), 171 → 169.254.171.0/24, etc. Valid
46221
+ * range is 1..254; the runner clamps to `(170 + replicaIndex) % 84`
46222
+ * + 170 in practice (rolling window) — exported here so the runner
46223
+ * keeps the allocation logic in one place.
46224
+ */
46225
+ function buildEndpointSubnet(subnetOctet) {
46226
+ if (subnetOctet < 1 || subnetOctet > 254 || !Number.isInteger(subnetOctet)) throw new Error(`buildEndpointSubnet: subnetOctet must be an integer in 1..254 (got ${subnetOctet}).`);
46227
+ return {
46228
+ cidr: `169.254.${subnetOctet}.0/24`,
46229
+ sidecarIp: `169.254.${subnetOctet}.2`
46230
+ };
46231
+ }
46207
46232
  /**
46208
46233
  * Create the per-task docker network + start the metadata-endpoints
46209
46234
  * sidecar. The sidecar must come up at the well-known address BEFORE any
@@ -46213,8 +46238,13 @@ const METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
46213
46238
  async function createTaskNetwork(options = {}) {
46214
46239
  const logger = getLogger().child("ecs-network");
46215
46240
  const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
46241
+ const subnetOctet = options.subnetOctet ?? 170;
46242
+ const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
46243
+ cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
46244
+ sidecarIp: METADATA_ENDPOINT_IP
46245
+ } : buildEndpointSubnet(subnetOctet);
46216
46246
  await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
46217
- logger.info(`Creating docker network ${networkName} (subnet ${METADATA_ENDPOINT_SUBNET})...`);
46247
+ logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
46218
46248
  try {
46219
46249
  await execFileAsync$1(getDockerCmd(), [
46220
46250
  "network",
@@ -46222,12 +46252,12 @@ async function createTaskNetwork(options = {}) {
46222
46252
  "--driver",
46223
46253
  "bridge",
46224
46254
  "--subnet",
46225
- METADATA_ENDPOINT_SUBNET,
46255
+ cidr,
46226
46256
  networkName
46227
46257
  ]);
46228
46258
  } catch (err) {
46229
46259
  const e = err;
46230
- throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host may already own subnet ${METADATA_ENDPOINT_SUBNET}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`.`);
46260
+ throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` walks subnetOctet per replica to avoid this; bare \`cdkd local run-task\` uses the default subnet so only one run can be active at a time.`);
46231
46261
  }
46232
46262
  const sidecarArgs = [
46233
46263
  "run",
@@ -46238,7 +46268,7 @@ async function createTaskNetwork(options = {}) {
46238
46268
  "--network",
46239
46269
  networkName,
46240
46270
  "--ip",
46241
- METADATA_ENDPOINT_IP
46271
+ sidecarIp
46242
46272
  ];
46243
46273
  const sidecarEnv = {};
46244
46274
  if (options.credentials) {
@@ -46249,7 +46279,7 @@ async function createTaskNetwork(options = {}) {
46249
46279
  if (options.cluster) sidecarEnv["CLUSTER"] = options.cluster;
46250
46280
  for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
46251
46281
  sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
46252
- logger.info("Starting ECS local-container-endpoints sidecar at 169.254.170.2...");
46282
+ logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
46253
46283
  let sidecarContainerId;
46254
46284
  try {
46255
46285
  const { stdout } = await execFileAsync$1(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
@@ -46261,7 +46291,8 @@ async function createTaskNetwork(options = {}) {
46261
46291
  }
46262
46292
  return {
46263
46293
  networkName,
46264
- sidecarContainerId
46294
+ sidecarContainerId,
46295
+ sidecarIp
46265
46296
  };
46266
46297
  }
46267
46298
  /**
@@ -46276,9 +46307,10 @@ async function createTaskNetwork(options = {}) {
46276
46307
  * to whichever credentials AWS SDK chains find).
46277
46308
  */
46278
46309
  function buildMetadataEnv(opts) {
46310
+ const ip = opts.sidecarIp ?? "169.254.170.2";
46279
46311
  const env = {
46280
- ECS_CONTAINER_METADATA_URI_V4: `http://${METADATA_ENDPOINT_IP}/v4/${opts.containerName}`,
46281
- ECS_CONTAINER_METADATA_URI: `http://${METADATA_ENDPOINT_IP}/v3/${opts.containerName}`
46312
+ ECS_CONTAINER_METADATA_URI_V4: `http://${ip}/v4/${opts.containerName}`,
46313
+ ECS_CONTAINER_METADATA_URI: `http://${ip}/v3/${opts.containerName}`
46282
46314
  };
46283
46315
  if (opts.roleArn) env["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] = `/role/${encodeURIComponent(opts.roleArn)}`;
46284
46316
  if (opts.region) env["AWS_REGION"] = opts.region;
@@ -46527,6 +46559,7 @@ async function runEcsTask(task, options, state) {
46527
46559
  };
46528
46560
  if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
46529
46561
  if (options.cluster) netCreateOpts.cluster = options.cluster;
46562
+ if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
46530
46563
  state.network = await createTaskNetwork(netCreateOpts);
46531
46564
  const volumeByName = await realizeDockerVolumes(task.volumes, state);
46532
46565
  const dockerCmds = /* @__PURE__ */ new Map();
@@ -46544,7 +46577,8 @@ async function runEcsTask(task, options, state) {
46544
46577
  containerHost: options.containerHost,
46545
46578
  roleArn: options.taskRoleArn,
46546
46579
  platformOverride: options.platformOverride,
46547
- region: options.region
46580
+ region: options.region,
46581
+ sidecarIp: state.network.sidecarIp
46548
46582
  }));
46549
46583
  }
46550
46584
  const startedByName = /* @__PURE__ */ new Map();
@@ -46681,7 +46715,7 @@ async function waitForContainerHealthy(containerId, displayName) {
46681
46715
  if (err instanceof EcsTaskRunnerError) throw err;
46682
46716
  logger.debug(`docker inspect on '${displayName}' failed: ${err instanceof Error ? err.message : String(err)}`);
46683
46717
  }
46684
- await sleep(1e3);
46718
+ await sleep$1(1e3);
46685
46719
  }
46686
46720
  throw new EcsTaskRunnerError(`Container '${displayName}' did not become healthy within 5 minutes.`);
46687
46721
  }
@@ -46705,7 +46739,7 @@ async function stopContainer(containerId, graceSeconds) {
46705
46739
  ]);
46706
46740
  } catch {}
46707
46741
  }
46708
- function sleep(ms) {
46742
+ function sleep$1(ms) {
46709
46743
  return new Promise((res) => setTimeout(res, ms));
46710
46744
  }
46711
46745
  /**
@@ -46885,7 +46919,8 @@ function buildDockerRunArgs(opts) {
46885
46919
  const metaEnv = buildMetadataEnv({
46886
46920
  containerName: container.name,
46887
46921
  ...roleArn !== void 0 && { roleArn },
46888
- ...opts.region !== void 0 && { region: opts.region }
46922
+ ...opts.region !== void 0 && { region: opts.region },
46923
+ ...opts.sidecarIp !== void 0 && { sidecarIp: opts.sidecarIp }
46889
46924
  });
46890
46925
  Object.assign(finalEnv, metaEnv);
46891
46926
  Object.assign(finalEnv, container.environment);
@@ -46986,7 +47021,7 @@ async function localRunTaskCommand(target, options) {
46986
47021
  ...Object.keys(context).length > 0 && { context }
46987
47022
  };
46988
47023
  const { stacks } = await synthesizer.synthesize(synthOpts);
46989
- const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
47024
+ const imageContext = await buildEcsImageResolutionContext$1(target, stacks, options);
46990
47025
  const task = resolveEcsTaskTarget(target, stacks, imageContext);
46991
47026
  logger.info(`Target: ${task.stack.stackName}/${task.taskDefinitionLogicalId} (family=${task.family}, containers=${task.containers.length})`);
46992
47027
  const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === task.stack.stackName) ?? task.stack);
@@ -47028,13 +47063,13 @@ async function localRunTaskCommand(target, options) {
47028
47063
  let resolvedRoleArn;
47029
47064
  if (options.assumeTaskRole === true) {
47030
47065
  if (!task.taskRoleArn) throw new Error("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Either the task definition does not set TaskRoleArn, or it points at a resource cdkd cannot resolve to an IAM Role at synth time. Pass the ARN explicitly: --assume-task-role <arn>");
47031
- resolvedRoleArn = await resolvePlaceholderAccount(task.taskRoleArn, options.region);
47032
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
47066
+ resolvedRoleArn = await resolvePlaceholderAccount$1(task.taskRoleArn, options.region);
47067
+ assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
47033
47068
  } else if (typeof options.assumeTaskRole === "string") {
47034
47069
  resolvedRoleArn = options.assumeTaskRole;
47035
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
47070
+ assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
47036
47071
  }
47037
- const envOverrides = readEnvOverridesFile$1(options.envVars);
47072
+ const envOverrides = readEnvOverridesFile$2(options.envVars);
47038
47073
  const runOpts = {
47039
47074
  cluster: options.cluster,
47040
47075
  containerHost: options.containerHost,
@@ -47069,7 +47104,7 @@ async function localRunTaskCommand(target, options) {
47069
47104
  * Lazy: callers should only invoke this when the resolved ARN is actually
47070
47105
  * going to be used (i.e. on the bare `--assume-task-role` path).
47071
47106
  */
47072
- async function resolvePlaceholderAccount(arn, region) {
47107
+ async function resolvePlaceholderAccount$1(arn, region) {
47073
47108
  if (!arn.includes("${AWS::AccountId}")) return arn;
47074
47109
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
47075
47110
  const sts = new STSClient({ ...region && { region } });
@@ -47085,7 +47120,7 @@ async function resolvePlaceholderAccount(arn, region) {
47085
47120
  * Assume `roleArn` and return temp credentials. Mirrors the same flow
47086
47121
  * `cdkd local invoke --assume-role` uses.
47087
47122
  */
47088
- async function assumeTaskRole(roleArn, region) {
47123
+ async function assumeTaskRole$1(roleArn, region) {
47089
47124
  const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
47090
47125
  const sts = new STSClient({ ...region && { region } });
47091
47126
  try {
@@ -47116,9 +47151,9 @@ async function assumeTaskRole(roleArn, region) {
47116
47151
  * for the candidate stack — same warn-and-fall-back error policy as
47117
47152
  * `cdkd local invoke --from-state`.
47118
47153
  */
47119
- async function buildEcsImageResolutionContext(target, stacks, options) {
47154
+ async function buildEcsImageResolutionContext$1(target, stacks, options) {
47120
47155
  const logger = getLogger();
47121
- const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
47156
+ const candidate = pickCandidateStack$1(parseEcsTarget(target).stackPattern, stacks);
47122
47157
  if (!candidate) return void 0;
47123
47158
  const needs = detectEcsImageResolutionNeeds(candidate);
47124
47159
  if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
@@ -47129,7 +47164,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
47129
47164
  if (!region) logger.warn("Resolver references ${AWS::Region} but cdkd could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.");
47130
47165
  let accountId;
47131
47166
  try {
47132
- accountId = await resolveCallerAccountId(region);
47167
+ accountId = await resolveCallerAccountId$1(region);
47133
47168
  } catch (err) {
47134
47169
  logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env / secret entries will be dropped with per-key warnings.`);
47135
47170
  }
@@ -47157,7 +47192,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
47157
47192
  else if (!options.fromState && needs.needsEnvOrSecretSubstitution) logger.warn("Container Environment / Secrets entries contain CloudFormation intrinsics (Ref / Fn::GetAtt / Fn::Sub / Fn::Join). Pass --from-state to substitute them against the deployed cdkd state. Without --from-state these entries are dropped (per-key warnings will follow).");
47158
47193
  return ctx;
47159
47194
  }
47160
- function pickCandidateStack(stackPattern, stacks) {
47195
+ function pickCandidateStack$1(stackPattern, stacks) {
47161
47196
  if (stackPattern === null) {
47162
47197
  if (stacks.length === 1) return stacks[0];
47163
47198
  return;
@@ -47165,7 +47200,7 @@ function pickCandidateStack(stackPattern, stacks) {
47165
47200
  const matched = matchStacks(stacks, [stackPattern]);
47166
47201
  if (matched.length === 1) return matched[0];
47167
47202
  }
47168
- async function resolveCallerAccountId(region) {
47203
+ async function resolveCallerAccountId$1(region) {
47169
47204
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
47170
47205
  const sts = new STSClient({ ...region && { region } });
47171
47206
  try {
@@ -47179,7 +47214,7 @@ async function resolveCallerAccountId(region) {
47179
47214
  * `cdkd local invoke --env-vars`: top-level keys are container names, with
47180
47215
  * `Parameters` reserved for global entries.
47181
47216
  */
47182
- function readEnvOverridesFile$1(filePath) {
47217
+ function readEnvOverridesFile$2(filePath) {
47183
47218
  if (!filePath) return void 0;
47184
47219
  let raw;
47185
47220
  try {
@@ -47208,6 +47243,690 @@ function createLocalRunTaskCommand() {
47208
47243
  return cmd;
47209
47244
  }
47210
47245
 
47246
+ //#endregion
47247
+ //#region src/local/ecs-service-resolver.ts
47248
+ /**
47249
+ * Walk the synth template to locate an `AWS::ECS::Service` by display
47250
+ * path or stack-qualified logical id, resolve its `TaskDefinition`
47251
+ * reference, and chain into the existing `resolveEcsTaskTarget` machinery
47252
+ * to produce a `ResolvedEcsService` carrying both the service knobs and
47253
+ * the underlying task descriptor.
47254
+ *
47255
+ * Target shape mirrors `cdkd local run-task`: `<Stack>/<DisplayPath>` or
47256
+ * `<Stack>:<LogicalId>`; single-stack apps may omit the stack prefix.
47257
+ *
47258
+ * Optional `context` (same as the task resolver) carries the ECR image
47259
+ * substitution data — pseudo parameters (Tier 1) + state-recorded
47260
+ * resources (Tier 2). The CLI builds it lazily when the candidate
47261
+ * service's task definition actually needs substitution.
47262
+ */
47263
+ function resolveEcsServiceTarget(target, stacks, context) {
47264
+ if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
47265
+ const parsed = parseEcsTarget(target);
47266
+ const stack = pickStack(parsed, stacks);
47267
+ const resources = stack.template.Resources ?? {};
47268
+ let serviceLogicalId;
47269
+ let serviceResource;
47270
+ if (parsed.isPath) {
47271
+ const index = buildCdkPathIndex(stack.template);
47272
+ const services = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId: l }) => resources[l]?.Type === "AWS::ECS::Service");
47273
+ if (services.length === 0) throw notFoundError(target, stack, resources);
47274
+ if (services.length > 1) throw new EcsTaskResolutionError(`Target '${target}' matches ${services.length} ECS services in ${stack.stackName}: ` + services.map((s) => s.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
47275
+ serviceLogicalId = services[0].logicalId;
47276
+ serviceResource = resources[serviceLogicalId];
47277
+ } else {
47278
+ serviceResource = resources[parsed.pathOrId];
47279
+ if (!serviceResource) throw notFoundError(target, stack, resources);
47280
+ serviceLogicalId = parsed.pathOrId;
47281
+ }
47282
+ if (!serviceLogicalId || !serviceResource) throw notFoundError(target, stack, resources);
47283
+ if (serviceResource.Type === "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`Resource '${serviceLogicalId}' in ${stack.stackName} is an ECS TaskDefinition, not a Service. Use \`cdkd local run-task\` for one-shot tasks; \`cdkd local start-service\` is Service-only.`);
47284
+ if (serviceResource.Type !== "AWS::ECS::Service") throw new EcsTaskResolutionError(`Resource '${serviceLogicalId}' in ${stack.stackName} is ${serviceResource.Type}, not an AWS::ECS::Service.`);
47285
+ return extractServiceProperties(stack, serviceLogicalId, serviceResource, stacks, context);
47286
+ }
47287
+ /**
47288
+ * Pure-functional extraction from the synth resource. Exposed for unit
47289
+ * testing the per-field resolution rules (DesiredCount default, missing
47290
+ * TaskDefinition, intrinsic shapes).
47291
+ */
47292
+ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, context) {
47293
+ const props = resource.Properties ?? {};
47294
+ const warnings = [];
47295
+ const taskDefRef = props["TaskDefinition"];
47296
+ if (taskDefRef === void 0 || taskDefRef === null) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' in ${stack.stackName} has no TaskDefinition property.`);
47297
+ const taskDefLogicalId = resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId);
47298
+ const task = resolveEcsTaskTarget(`${stack.stackName}:${taskDefLogicalId}`, stacks, context);
47299
+ const desiredCount = parseDesiredCount(props["DesiredCount"], serviceLogicalId);
47300
+ const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
47301
+ const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
47302
+ if (Array.isArray(props["LoadBalancers"]) && props["LoadBalancers"].length > 0) warnings.push(`ECS Service '${serviceLogicalId}' declares LoadBalancers, but local load-balancer emulation is deferred to a follow-up PR. Containers are NOT registered to a local listener; reach them via their published ports.`);
47303
+ if (props["ServiceConnectConfiguration"]) warnings.push(`ECS Service '${serviceLogicalId}' declares ServiceConnectConfiguration, but Service Connect / Cloud Map emulation is deferred (tracked in #460). Cross-service discovery between locally-run services is not provided.`);
47304
+ return {
47305
+ stack,
47306
+ serviceLogicalId,
47307
+ resource,
47308
+ serviceName,
47309
+ desiredCount,
47310
+ healthCheckGracePeriodSeconds,
47311
+ task,
47312
+ warnings
47313
+ };
47314
+ }
47315
+ /**
47316
+ * Resolve `Properties.TaskDefinition` to a logical id in the same stack.
47317
+ * Accepted shapes — verified against real CDK 2.x `cdk synth` output on
47318
+ * 2026-05-22 (per `feedback_verify_cdk_synth_shape_before_resolver.md`):
47319
+ * - `{Ref: '<TaskDefLogicalId>'}` — the CDK-canonical shape emitted by
47320
+ * `new ecs.FargateService({ taskDefinition })`.
47321
+ * - flat string `'<TaskDefLogicalId>'` — accepted defensively but CDK
47322
+ * rarely emits this for cross-resource refs.
47323
+ * Other intrinsic shapes (`Fn::ImportValue` / `Fn::GetAtt` / etc.) are
47324
+ * rejected — cross-stack task definitions and `Fn::GetAtt` shapes have
47325
+ * no clean local resolution and would land here only as user errors.
47326
+ */
47327
+ function resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId) {
47328
+ if (typeof taskDefRef === "string") return taskDefRef;
47329
+ if (taskDefRef && typeof taskDefRef === "object" && !Array.isArray(taskDefRef)) {
47330
+ const refValue = taskDefRef["Ref"];
47331
+ if (typeof refValue === "string") {
47332
+ const target = (stack.template.Resources ?? {})[refValue];
47333
+ if (!target) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' references TaskDefinition '${refValue}' but no such resource exists in ${stack.stackName}.`);
47334
+ if (target.Type !== "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' references '${refValue}' as TaskDefinition but it is of type ${target.Type}, not AWS::ECS::TaskDefinition.`);
47335
+ return refValue;
47336
+ }
47337
+ }
47338
+ throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' has an unsupported TaskDefinition reference shape: ${JSON.stringify(taskDefRef)}. cdkd local start-service v1 supports only Ref to a same-stack AWS::ECS::TaskDefinition; cross-stack TaskDefinitions are deferred.`);
47339
+ }
47340
+ function parseDesiredCount(raw, serviceLogicalId) {
47341
+ if (raw === void 0 || raw === null) return 1;
47342
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
47343
+ if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
47344
+ throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' has an unsupported DesiredCount value: ${JSON.stringify(raw)}. Must be a non-negative integer.`);
47345
+ }
47346
+ function parseHealthCheckGrace(raw, _serviceLogicalId) {
47347
+ if (raw === void 0 || raw === null) return 30;
47348
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
47349
+ if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
47350
+ return 30;
47351
+ }
47352
+ function parseServiceName(raw, serviceLogicalId) {
47353
+ if (typeof raw === "string" && raw.length > 0) return raw;
47354
+ return serviceLogicalId;
47355
+ }
47356
+ /**
47357
+ * Local copy of the same `pickStack` helper used by the task resolver.
47358
+ * Kept in-file rather than exported from `ecs-task-resolver.ts` so future
47359
+ * service-specific extensions (e.g. cross-stack service-to-task refs)
47360
+ * can diverge without breaking the run-task code path.
47361
+ */
47362
+ function pickStack(parsed, stacks) {
47363
+ if (parsed.stackPattern === null) {
47364
+ if (stacks.length === 1) return stacks[0];
47365
+ 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'.`);
47366
+ }
47367
+ const matched = matchStacks(stacks, [parsed.stackPattern]);
47368
+ if (matched.length === 0) throw new EcsTaskResolutionError(`No stack matches '${parsed.stackPattern}'. Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
47369
+ if (matched.length > 1) throw new EcsTaskResolutionError(`Multiple stacks match '${parsed.stackPattern}': ${matched.map((s) => s.stackName).join(", ")}. Refine the pattern.`);
47370
+ return matched[0];
47371
+ }
47372
+ function notFoundError(target, stack, resources) {
47373
+ const services = Object.entries(resources).filter(([, r]) => r.Type === "AWS::ECS::Service").map(([id]) => id);
47374
+ if (services.length === 0) return new EcsTaskResolutionError(`Target '${target}' did not match any resource in ${stack.stackName}, and the stack declares no AWS::ECS::Service resources at all.`);
47375
+ return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
47376
+ }
47377
+
47378
+ //#endregion
47379
+ //#region src/local/ecs-service-runner.ts
47380
+ /**
47381
+ * Phase 2 of #262 — long-running ECS Service emulator. Wraps the existing
47382
+ * `ecs-task-runner` machinery in a replica pool: N concurrent task
47383
+ * instances per `DesiredCount`, each with its own docker network +
47384
+ * metadata sidecar + container set. Tasks that exit non-zero AFTER the
47385
+ * health-check grace period are restarted with exponential backoff so a
47386
+ * crash-looping container does not hammer docker.
47387
+ *
47388
+ * v1 scope (per the issue's PR-split recommendation):
47389
+ * - Replica pool sizing via `DesiredCount` clamped by `--max-tasks`.
47390
+ * - Restart-on-exit with exponential backoff (1s → 30s, capped) +
47391
+ * a per-instance retry counter so a permanently-broken container
47392
+ * stops compounding cleanup work.
47393
+ * - Long-running lifecycle (returns only on shutdown).
47394
+ *
47395
+ * Deferred to follow-up PRs:
47396
+ * - Local load-balancer emulation (LB listener + target-group health
47397
+ * check + round-robin) — separate PR per the issue's PR-split.
47398
+ * - Service Connect / Cloud Map (tracked in #460).
47399
+ * - Rolling deployment (`--reload` / `--watch`).
47400
+ */
47401
+ var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
47402
+ constructor(message) {
47403
+ super(message);
47404
+ this.name = "EcsServiceRunnerError";
47405
+ Object.setPrototypeOf(this, EcsServiceRunnerError.prototype);
47406
+ }
47407
+ };
47408
+ function createServiceRunState() {
47409
+ return {
47410
+ replicas: [],
47411
+ shuttingDown: false
47412
+ };
47413
+ }
47414
+ /**
47415
+ * Compute the effective replica count for a service: the smaller of
47416
+ * `service.desiredCount` and `--max-tasks`, floored at 1. Pure-
47417
+ * functional so the CLI can show the user what cdkd is about to do
47418
+ * before any docker calls fire.
47419
+ */
47420
+ function computeReplicaCount(desiredCount, maxTasks) {
47421
+ if (maxTasks < 1) throw new EcsServiceRunnerError(`--max-tasks must be >= 1 (got ${maxTasks}); local dev needs at least one running replica.`);
47422
+ if (desiredCount <= 0) return 1;
47423
+ return Math.min(desiredCount, maxTasks);
47424
+ }
47425
+ /**
47426
+ * Exponential backoff schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... Used
47427
+ * between restarts of a crash-looping replica so docker is not hammered
47428
+ * by the watcher loop. Exposed for unit testing.
47429
+ */
47430
+ function backoffDelayMs(restartCount) {
47431
+ return Math.min(1e3 * Math.pow(2, Math.min(restartCount, 10)), 3e4);
47432
+ }
47433
+ /**
47434
+ * Decide whether a replica that just exited should restart. Pure-
47435
+ * functional so the watcher loop's policy is easy to unit-test.
47436
+ */
47437
+ function shouldRestart(exitCode, policy) {
47438
+ if (policy === "none") return false;
47439
+ if (policy === "always") return true;
47440
+ return exitCode !== 0;
47441
+ }
47442
+ /**
47443
+ * Long-running entry point. Boots `replicaCount` instances of the
47444
+ * service's task descriptor, returns a controller object the CLI uses
47445
+ * to (1) wait for the first failure that gives up restarting and (2)
47446
+ * shut every replica down on SIGINT / SIGTERM.
47447
+ *
47448
+ * The returned `shutdown()` is idempotent and safe to call from
47449
+ * multiple SIGINT handlers (CLI's single-flight pattern wraps it
47450
+ * anyway).
47451
+ */
47452
+ async function startEcsService(service, options, runState) {
47453
+ const logger = getLogger().child("ecs-service");
47454
+ for (const w of service.warnings) logger.warn(w);
47455
+ const replicaCount = computeReplicaCount(service.desiredCount, options.maxTasks);
47456
+ if (replicaCount < service.desiredCount) logger.warn(`Service '${service.serviceName}' template DesiredCount=${service.desiredCount} exceeds --max-tasks=${options.maxTasks}; running ${replicaCount} replica(s) locally. Raise --max-tasks to lift the cap, or accept the reduced concurrency for local dev.`);
47457
+ logger.info(`Starting ECS service '${service.serviceName}' with ${replicaCount} replica(s) (restartPolicy=${options.restartPolicy})`);
47458
+ for (let i = 0; i < replicaCount; i++) {
47459
+ const instance = {
47460
+ index: i,
47461
+ state: createEcsRunState(),
47462
+ restartCount: 0,
47463
+ shuttingDown: false,
47464
+ inFlightBoot: void 0
47465
+ };
47466
+ runState.replicas.push(instance);
47467
+ const bootPromise = bootReplica(service, options, instance);
47468
+ instance.inFlightBoot = bootPromise;
47469
+ try {
47470
+ await bootPromise;
47471
+ } catch (err) {
47472
+ instance.lastError = err instanceof Error ? err : new Error(String(err));
47473
+ throw new EcsServiceRunnerError(`Failed to boot replica ${i} of service '${service.serviceName}': ${instance.lastError.message}`);
47474
+ } finally {
47475
+ instance.inFlightBoot = void 0;
47476
+ }
47477
+ }
47478
+ for (const instance of runState.replicas) watchReplica(service, options, instance, runState);
47479
+ return new ServiceController(service, runState, options);
47480
+ }
47481
+ /**
47482
+ * Public controller surface. The CLI awaits `controller.waitForShutdown()`
47483
+ * to block until the user ^Cs. `controller.shutdown()` is wired into the
47484
+ * SIGINT / SIGTERM handlers.
47485
+ */
47486
+ var ServiceController = class {
47487
+ service;
47488
+ runState;
47489
+ options;
47490
+ shutdownResolve;
47491
+ shutdownPromise;
47492
+ /**
47493
+ * Single-flight wrapper for `shutdown()` so the fan-out cleanup runs
47494
+ * exactly once even when SIGINT and the CLI's outer `finally` both
47495
+ * fire (the canonical pattern documented in
47496
+ * `feedback_sigint_finally_cleanup_singleflight.md`). Built in the
47497
+ * constructor so every call to `shutdown()` resolves against the same
47498
+ * underlying promise.
47499
+ */
47500
+ runShutdown;
47501
+ constructor(service, runState, options) {
47502
+ this.service = service;
47503
+ this.runState = runState;
47504
+ this.options = options;
47505
+ this.shutdownPromise = new Promise((resolve) => {
47506
+ this.shutdownResolve = resolve;
47507
+ });
47508
+ this.runShutdown = singleFlight(() => this.doShutdown());
47509
+ }
47510
+ /**
47511
+ * Returns the count of currently-active (non-shutting-down) replicas.
47512
+ * Exposed so the CLI can surface a one-line "service is degraded"
47513
+ * banner when restarts stop firing.
47514
+ */
47515
+ activeReplicaCount() {
47516
+ return this.runState.replicas.filter((r) => !r.shuttingDown).length;
47517
+ }
47518
+ /**
47519
+ * Block until `shutdown()` is called. Used by the CLI as the
47520
+ * long-running blocking point — the SIGINT handler resolves it.
47521
+ */
47522
+ waitForShutdown() {
47523
+ return this.shutdownPromise;
47524
+ }
47525
+ /**
47526
+ * Idempotent fan-out shutdown across every active replica. Wired into
47527
+ * both SIGINT and the outer `finally` of the CLI command; the
47528
+ * `singleFlight`-wrapped `runShutdown` collapses concurrent / repeated
47529
+ * callers to one underlying invocation.
47530
+ */
47531
+ async shutdown() {
47532
+ await this.runShutdown();
47533
+ return this.shutdownPromise;
47534
+ }
47535
+ async doShutdown() {
47536
+ this.runState.shuttingDown = true;
47537
+ const logger = getLogger().child("ecs-service");
47538
+ logger.info(`Shutting down service '${this.service.serviceName}'...`);
47539
+ for (const r of this.runState.replicas) r.shuttingDown = true;
47540
+ const inFlightBoots = this.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0);
47541
+ if (inFlightBoots.length > 0) {
47542
+ logger.debug(`Awaiting ${inFlightBoots.length} in-flight bootReplica() call(s) before cleanup...`);
47543
+ await Promise.allSettled(inFlightBoots);
47544
+ }
47545
+ await Promise.allSettled(this.runState.replicas.map(async (instance) => {
47546
+ try {
47547
+ await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
47548
+ } catch (err) {
47549
+ logger.debug(`Replica ${instance.index} cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
47550
+ }
47551
+ }));
47552
+ this.shutdownResolve?.();
47553
+ }
47554
+ };
47555
+ /**
47556
+ * Boot a single replica. Mutates the supplied `instance.state` so the
47557
+ * shutdown path's `cleanupEcsRun(instance.state)` covers every partial
47558
+ * side effect. Network names are suffixed with the replica index so
47559
+ * docker doesn't collide on shared per-task network names when N > 1.
47560
+ */
47561
+ async function bootReplica(service, options, instance) {
47562
+ const logger = getLogger().child("ecs-service");
47563
+ const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
47564
+ const perReplicaSubnetOctet = 170 + instance.index % 84;
47565
+ const perReplicaTaskOptions = {
47566
+ ...options.taskOptions,
47567
+ cluster: perReplicaCluster,
47568
+ subnetOctet: perReplicaSubnetOctet,
47569
+ detach: true
47570
+ };
47571
+ logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
47572
+ await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
47573
+ }
47574
+ /**
47575
+ * Long-running watcher loop for one replica. Polls the essential
47576
+ * container's exit code via `docker wait`; on exit, decides whether to
47577
+ * restart per `restartPolicy` + applies exponential backoff. The loop
47578
+ * exits only when the replica's `shuttingDown` flag is set.
47579
+ */
47580
+ async function watchReplica(service, options, instance, runState) {
47581
+ const logger = getLogger().child("ecs-service");
47582
+ while (!instance.shuttingDown && !runState.shuttingDown) {
47583
+ const essentialId = pickEssentialContainerId(instance, service);
47584
+ if (!essentialId) {
47585
+ await sleep(500);
47586
+ continue;
47587
+ }
47588
+ let exitCode;
47589
+ try {
47590
+ exitCode = await waitForExitImpl(essentialId);
47591
+ } catch (err) {
47592
+ logger.debug(`docker wait failed for replica ${instance.index}: ${err instanceof Error ? err.message : String(err)}`);
47593
+ exitCode = -1;
47594
+ }
47595
+ if (instance.shuttingDown || runState.shuttingDown) return;
47596
+ logger.warn(`Replica ${instance.index} essential container exited with code ${exitCode} (restartCount=${instance.restartCount}).`);
47597
+ if (!shouldRestart(exitCode, options.restartPolicy)) {
47598
+ logger.warn(`Replica ${instance.index} not restarting (policy=${options.restartPolicy}, exit=${exitCode}). Service running in degraded mode.`);
47599
+ instance.shuttingDown = true;
47600
+ return;
47601
+ }
47602
+ const delay = backoffDelayMs(instance.restartCount);
47603
+ logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
47604
+ await sleep(delay);
47605
+ if (instance.shuttingDown || runState.shuttingDown) return;
47606
+ try {
47607
+ await cleanupEcsRun(instance.state, { keepRunning: false });
47608
+ } catch (err) {
47609
+ logger.debug(`Replica ${instance.index} pre-restart cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
47610
+ }
47611
+ instance.state = createEcsRunState();
47612
+ instance.restartCount += 1;
47613
+ const bootPromise = bootReplica(service, options, instance);
47614
+ instance.inFlightBoot = bootPromise;
47615
+ try {
47616
+ await bootPromise;
47617
+ } catch (err) {
47618
+ instance.lastError = err instanceof Error ? err : new Error(String(err));
47619
+ logger.error(`Replica ${instance.index} restart failed: ${instance.lastError.message}. Service running in degraded mode.`);
47620
+ instance.shuttingDown = true;
47621
+ return;
47622
+ } finally {
47623
+ instance.inFlightBoot = void 0;
47624
+ }
47625
+ }
47626
+ }
47627
+ function pickEssentialContainerId(instance, service) {
47628
+ if (service) {
47629
+ const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
47630
+ if (essential) {
47631
+ const started = instance.state.startedContainers.find((c) => c.name === essential.name);
47632
+ if (started) return started.id;
47633
+ }
47634
+ }
47635
+ return instance.state.startedContainers[0]?.id;
47636
+ }
47637
+ /**
47638
+ * Production `docker wait <id>` implementation. Captured once so the
47639
+ * test override can restore it without duplicating the body.
47640
+ */
47641
+ const defaultWaitForExitImpl = async (containerId) => {
47642
+ const { execFile } = await import("node:child_process");
47643
+ const { promisify } = await import("node:util");
47644
+ const { getDockerCmd } = await import("./docker-cmd-EtWSTAje.js").then((n) => n.t);
47645
+ const { stdout } = await promisify(execFile)(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
47646
+ const code = parseInt(stdout.trim(), 10);
47647
+ return Number.isFinite(code) ? code : -1;
47648
+ };
47649
+ /**
47650
+ * `docker wait <id>` returns the exit code on stdout. Extracted as a
47651
+ * test-overridable function so unit tests do not need a real container.
47652
+ */
47653
+ let waitForExitImpl = defaultWaitForExitImpl;
47654
+ function sleep(ms) {
47655
+ return new Promise((resolve) => setTimeout(resolve, ms));
47656
+ }
47657
+
47658
+ //#endregion
47659
+ //#region src/cli/commands/local-start-service.ts
47660
+ /**
47661
+ * `cdkd local start-service <Stack/Service>` — Phase 2 of #262. Spins up
47662
+ * `DesiredCount` task replicas locally (clamped by `--max-tasks`) using
47663
+ * the existing `ecs-task-runner` per replica. Long-running; ^C cleans
47664
+ * every replica + sidecar + per-task network.
47665
+ *
47666
+ * Deferred to follow-up PRs (matches the issue's PR-split):
47667
+ * - Local LB emulator (listener + round-robin + target-group health
47668
+ * check) — PR C of #466.
47669
+ * - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
47670
+ * - Service Connect / Cloud Map — tracked separately in #460.
47671
+ */
47672
+ async function localStartServiceCommand(target, options) {
47673
+ const logger = getLogger();
47674
+ if (options.verbose) logger.setLevel("debug");
47675
+ warnIfDeprecatedRegion(options);
47676
+ const runState = createServiceRunState();
47677
+ let sigintHandler;
47678
+ let sigintCount = 0;
47679
+ let controller;
47680
+ const cleanup = singleFlight(async () => {
47681
+ if (controller) await controller.shutdown();
47682
+ else await Promise.allSettled(runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
47683
+ }, (err) => getLogger().debug(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
47684
+ try {
47685
+ await applyRoleArnIfSet({
47686
+ roleArn: options.roleArn,
47687
+ region: options.region
47688
+ });
47689
+ await ensureDockerAvailable();
47690
+ const appCmd = resolveApp(options.app);
47691
+ if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
47692
+ logger.info("Synthesizing CDK app...");
47693
+ const synthesizer = new Synthesizer();
47694
+ const context = parseContextOptions(options.context);
47695
+ const synthOpts = {
47696
+ app: appCmd,
47697
+ output: options.output,
47698
+ ...options.region && { region: options.region },
47699
+ ...options.profile && { profile: options.profile },
47700
+ ...Object.keys(context).length > 0 && { context }
47701
+ };
47702
+ const { stacks } = await synthesizer.synthesize(synthOpts);
47703
+ const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
47704
+ const service = resolveEcsServiceTarget(target, stacks, imageContext);
47705
+ logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
47706
+ const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
47707
+ if (options.fromState && taskNeeds.needsCrossStackResolver) {
47708
+ const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
47709
+ const built = await buildCrossStackResolver(consumerRegion, {
47710
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
47711
+ statePrefix: options.statePrefix,
47712
+ ...options.region !== void 0 && { region: options.region },
47713
+ ...options.profile !== void 0 && { profile: options.profile }
47714
+ });
47715
+ if (built) try {
47716
+ const subContext = {
47717
+ resources: imageContext?.stateResources ?? {},
47718
+ ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
47719
+ consumerRegion,
47720
+ crossStackResolver: built.resolver
47721
+ };
47722
+ await applyCrossStackResolverToTask(service.task, subContext);
47723
+ } finally {
47724
+ built.dispose();
47725
+ }
47726
+ } else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
47727
+ sigintHandler = () => {
47728
+ sigintCount += 1;
47729
+ if (sigintCount >= 2) {
47730
+ process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
47731
+ process.exit(130);
47732
+ }
47733
+ logger.info("Stopping service...");
47734
+ cleanup().then(() => process.exit(130));
47735
+ };
47736
+ process.on("SIGINT", sigintHandler);
47737
+ process.on("SIGTERM", sigintHandler);
47738
+ let assumedCredentials;
47739
+ let resolvedRoleArn;
47740
+ if (options.assumeTaskRole === true) {
47741
+ if (!service.task.taskRoleArn) throw new LocalStartServiceError("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>");
47742
+ resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
47743
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
47744
+ } else if (typeof options.assumeTaskRole === "string") {
47745
+ resolvedRoleArn = options.assumeTaskRole;
47746
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
47747
+ }
47748
+ const envOverrides = readEnvOverridesFile$1(options.envVars);
47749
+ const taskOpts = {
47750
+ cluster: options.cluster,
47751
+ containerHost: options.containerHost,
47752
+ skipPull: options.pull === false,
47753
+ keepRunning: false,
47754
+ detach: true
47755
+ };
47756
+ if (envOverrides) taskOpts.envOverrides = envOverrides;
47757
+ if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
47758
+ if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
47759
+ if (options.platform) taskOpts.platformOverride = options.platform;
47760
+ if (options.region) taskOpts.region = options.region;
47761
+ if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
47762
+ controller = await startEcsService(service, {
47763
+ maxTasks: options.maxTasks,
47764
+ restartPolicy: options.restartPolicy,
47765
+ taskOptions: taskOpts
47766
+ }, runState);
47767
+ logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
47768
+ await controller.waitForShutdown();
47769
+ } finally {
47770
+ if (sigintHandler) {
47771
+ process.off("SIGINT", sigintHandler);
47772
+ process.off("SIGTERM", sigintHandler);
47773
+ }
47774
+ await cleanup();
47775
+ }
47776
+ }
47777
+ async function resolvePlaceholderAccount(arn, region) {
47778
+ if (!arn.includes("${AWS::AccountId}")) return arn;
47779
+ const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
47780
+ const sts = new STSClient({ ...region && { region } });
47781
+ try {
47782
+ const account = (await sts.send(new GetCallerIdentityCommand({}))).Account;
47783
+ if (!account) throw new LocalStartServiceError(`--assume-task-role: GetCallerIdentity returned no Account; cannot resolve placeholder ARN '${arn}'.`);
47784
+ return arn.split(TASK_ROLE_ACCOUNT_PLACEHOLDER).join(account);
47785
+ } finally {
47786
+ sts.destroy();
47787
+ }
47788
+ }
47789
+ async function assumeTaskRole(roleArn, region) {
47790
+ const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
47791
+ const sts = new STSClient({ ...region && { region } });
47792
+ try {
47793
+ const creds = (await sts.send(new AssumeRoleCommand({
47794
+ RoleArn: roleArn,
47795
+ RoleSessionName: `cdkd-local-start-service-${Date.now()}`,
47796
+ DurationSeconds: 3600
47797
+ }))).Credentials;
47798
+ if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalStartServiceError(`AssumeRole(${roleArn}) returned no usable credentials.`);
47799
+ return {
47800
+ accessKeyId: creds.AccessKeyId,
47801
+ secretAccessKey: creds.SecretAccessKey,
47802
+ sessionToken: creds.SessionToken
47803
+ };
47804
+ } finally {
47805
+ sts.destroy();
47806
+ }
47807
+ }
47808
+ /**
47809
+ * Build the substitution context the ECS resolver consumes. Identical
47810
+ * shape to `local-run-task.ts:buildEcsImageResolutionContext` — only
47811
+ * the candidate stack picker differs because services and tasks share
47812
+ * the same stack-pattern grammar.
47813
+ */
47814
+ async function buildEcsImageResolutionContext(target, stacks, options) {
47815
+ const logger = getLogger();
47816
+ const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
47817
+ if (!candidate) return void 0;
47818
+ const needs = detectEcsImageResolutionNeeds(candidate);
47819
+ if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
47820
+ const ctx = {};
47821
+ const wantsPseudoForEnvOrSecret = options.fromState && needs.needsEnvOrSecretSubstitution;
47822
+ if (needs.needsPseudoParameters || wantsPseudoForEnvOrSecret) {
47823
+ const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
47824
+ if (!region) logger.warn("Resolver references ${AWS::Region} but cdkd could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.");
47825
+ let accountId;
47826
+ try {
47827
+ accountId = await resolveCallerAccountId(region);
47828
+ } catch (err) {
47829
+ logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env / secret entries will be dropped with per-key warnings.`);
47830
+ }
47831
+ const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
47832
+ ctx.pseudoParameters = {
47833
+ ...accountId !== void 0 && { accountId },
47834
+ ...region !== void 0 && { region },
47835
+ ...partitionAndSuffix && {
47836
+ partition: partitionAndSuffix.partition,
47837
+ urlSuffix: partitionAndSuffix.urlSuffix
47838
+ }
47839
+ };
47840
+ }
47841
+ const wantsState = needs.needsStateResources || needs.needsEnvOrSecretSubstitution;
47842
+ if (options.fromState && wantsState) {
47843
+ const loaded = await loadStateForStack(candidate.stackName, candidate.region, {
47844
+ ...options.stackRegion !== void 0 && { stackRegion: options.stackRegion },
47845
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
47846
+ statePrefix: options.statePrefix,
47847
+ ...options.region !== void 0 && { region: options.region },
47848
+ ...options.profile !== void 0 && { profile: options.profile }
47849
+ });
47850
+ if (loaded) ctx.stateResources = loaded.state.resources;
47851
+ } else if (!options.fromState && needs.needsStateResources) logger.warn("Container Image references a same-stack AWS::ECR::Repository. Pass --from-state to substitute the deployed repository URI.");
47852
+ else if (!options.fromState && needs.needsEnvOrSecretSubstitution) logger.warn("Container Environment / Secrets entries contain CloudFormation intrinsics. Pass --from-state to substitute them against the deployed cdkd state.");
47853
+ return ctx;
47854
+ }
47855
+ function pickCandidateStack(stackPattern, stacks) {
47856
+ if (stackPattern === null) {
47857
+ if (stacks.length === 1) return stacks[0];
47858
+ return;
47859
+ }
47860
+ const matched = matchStacks(stacks, [stackPattern]);
47861
+ if (matched.length === 1) return matched[0];
47862
+ }
47863
+ async function resolveCallerAccountId(region) {
47864
+ const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
47865
+ const sts = new STSClient({ ...region && { region } });
47866
+ try {
47867
+ return (await sts.send(new GetCallerIdentityCommand({}))).Account;
47868
+ } finally {
47869
+ sts.destroy();
47870
+ }
47871
+ }
47872
+ function readEnvOverridesFile$1(filePath) {
47873
+ if (!filePath) return void 0;
47874
+ let raw;
47875
+ try {
47876
+ raw = readFileSync(filePath, "utf-8");
47877
+ } catch (err) {
47878
+ throw new LocalStartServiceError(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
47879
+ }
47880
+ let parsed;
47881
+ try {
47882
+ parsed = JSON.parse(raw);
47883
+ } catch (err) {
47884
+ throw new LocalStartServiceError(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
47885
+ }
47886
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new LocalStartServiceError(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
47887
+ return parsed;
47888
+ }
47889
+ function parsePositiveInt(raw, flagName) {
47890
+ const parsed = parseInt(raw, 10);
47891
+ if (!Number.isFinite(parsed) || parsed < 1) throw new LocalStartServiceError(`${flagName} must be a positive integer (got '${raw}').`);
47892
+ return parsed;
47893
+ }
47894
+ /**
47895
+ * Hard cap on `--max-tasks` driven by the per-replica subnet allocator
47896
+ * in `ecs-service-runner.ts:bootReplica` (`170 + (index % 84)`). The
47897
+ * `% 84` modulo wraps at index 84, collapsing replica 84's `/24` onto
47898
+ * replica 0's allocation. Docker rejects the duplicate-subnet network
47899
+ * creation with a cryptic "Pool overlaps with other one on this address
47900
+ * space" error 30s into the boot — by which time some early replicas
47901
+ * may have spent docker-run budget. Reject at parse time so the user
47902
+ * gets an actionable error before any boot work fires.
47903
+ *
47904
+ * 84 is the count of usable link-local /24 octets in the range
47905
+ * `169.254.170.0..169.254.253.0` (255 reserved for broadcast). Raising
47906
+ * this requires extending the allocator to walk a different IP range.
47907
+ */
47908
+ const MAX_TASKS_SUBNET_RANGE_CAP = 84;
47909
+ function parseMaxTasks(raw) {
47910
+ const parsed = parsePositiveInt(raw, "--max-tasks");
47911
+ if (parsed > 84) throw new LocalStartServiceError(`--max-tasks ${parsed} exceeds the per-replica link-local /24 subnet allocator's range (${84}). The allocator in ecs-service-runner.ts assigns each replica its own 169.254.x.0/24 from the range 169.254.170.0..169.254.253.0; replica indices >= ${84} would collide with earlier replicas via modulo wrap. Lower --max-tasks to <= ${84}, or accept reduced local concurrency for high-DesiredCount services.`);
47912
+ return parsed;
47913
+ }
47914
+ function parseRestartPolicy(raw) {
47915
+ if (raw === "on-failure" || raw === "always" || raw === "none") return raw;
47916
+ throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
47917
+ }
47918
+ function createLocalStartServiceCommand() {
47919
+ const cmd = new Command("start-service").description("Run an AWS::ECS::Service locally as a long-running emulator. Spins up DesiredCount task replicas (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Target accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::Service to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed task role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localStartServiceCommand));
47920
+ [
47921
+ ...commonOptions,
47922
+ ...appOptions,
47923
+ ...contextOptions,
47924
+ ...stateOptions
47925
+ ].forEach((opt) => cmd.addOption(opt));
47926
+ cmd.addOption(deprecatedRegionOption);
47927
+ return cmd;
47928
+ }
47929
+
47211
47930
  //#endregion
47212
47931
  //#region src/cli/commands/local-invoke.ts
47213
47932
  /**
@@ -47939,6 +48658,7 @@ function createLocalCommand() {
47939
48658
  local.addCommand(invoke);
47940
48659
  local.addCommand(createLocalStartApiCommand());
47941
48660
  local.addCommand(createLocalRunTaskCommand());
48661
+ local.addCommand(createLocalStartServiceCommand());
47942
48662
  return local;
47943
48663
  }
47944
48664
 
@@ -49311,7 +50031,7 @@ function reorderArgs(argv) {
49311
50031
  */
49312
50032
  async function main() {
49313
50033
  const program = new Command();
49314
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.130.0");
50034
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.131.0");
49315
50035
  program.addCommand(createBootstrapCommand());
49316
50036
  program.addCommand(createSynthCommand());
49317
50037
  program.addCommand(createListCommand());