@go-to-k/cdkd 0.137.3 → 0.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -55,7 +55,7 @@ import { AddTagsCommand as AddTagsCommand$1, CloudTrailClient, CreateTrailComman
55
55
  import { BatchGetProjectsCommand, CodeBuildClient, CreateProjectCommand, DeleteProjectCommand, ListProjectsCommand, ResourceNotFoundException as ResourceNotFoundException$9, UpdateProjectCommand } from "@aws-sdk/client-codebuild";
56
56
  import { CreateVectorBucketCommand, DeleteIndexCommand, DeleteVectorBucketCommand, GetVectorBucketCommand, ListIndexesCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$18, ListVectorBucketsCommand, S3VectorsClient } from "@aws-sdk/client-s3vectors";
57
57
  import { CreateNamespaceCommand, CreateTableBucketCommand, CreateTableCommand as CreateTableCommand$2, DeleteNamespaceCommand as DeleteNamespaceCommand$1, DeleteTableBucketCommand, DeleteTableCommand as DeleteTableCommand$2, GetTableBucketCommand, GetTableCommand as GetTableCommand$1, ListNamespacesCommand as ListNamespacesCommand$1, ListTableBucketsCommand, ListTablesCommand as ListTablesCommand$1, ListTagsForResourceCommand as ListTagsForResourceCommand$19, NotFoundException as NotFoundException$5, S3TablesClient } from "@aws-sdk/client-s3tables";
58
- import { AttachTrafficSourcesCommand, AutoScalingClient, CreateAutoScalingGroupCommand, DeleteAutoScalingGroupCommand, DeleteLifecycleHookCommand, DeleteNotificationConfigurationCommand, DescribeAutoScalingGroupsCommand, DescribeLifecycleHooksCommand, DescribeNotificationConfigurationsCommand, DescribeTrafficSourcesCommand, DetachTrafficSourcesCommand, DisableMetricsCollectionCommand, EnableMetricsCollectionCommand, PutLifecycleHookCommand, PutNotificationConfigurationCommand, UpdateAutoScalingGroupCommand } from "@aws-sdk/client-auto-scaling";
58
+ import { AttachLoadBalancerTargetGroupsCommand, AttachLoadBalancersCommand, AttachTrafficSourcesCommand, AutoScalingClient, CreateAutoScalingGroupCommand, CreateOrUpdateTagsCommand, DeleteAutoScalingGroupCommand, DeleteLifecycleHookCommand, DeleteNotificationConfigurationCommand, DeleteTagsCommand as DeleteTagsCommand$1, DescribeAutoScalingGroupsCommand, DescribeLifecycleHooksCommand, DescribeNotificationConfigurationsCommand, DescribeTrafficSourcesCommand, DetachLoadBalancerTargetGroupsCommand, DetachLoadBalancersCommand, DetachTrafficSourcesCommand, DisableMetricsCollectionCommand, EnableMetricsCollectionCommand, PutLifecycleHookCommand, PutNotificationConfigurationCommand, UpdateAutoScalingGroupCommand } from "@aws-sdk/client-auto-scaling";
59
59
  import * as readline from "node:readline/promises";
60
60
  import { Document, Pair, Scalar, YAMLMap, YAMLSeq, parse as parse$1, stringify } from "yaml";
61
61
  import { mkdir, mkdtemp } from "node:fs/promises";
@@ -29963,27 +29963,36 @@ var ECRProvider = class {
29963
29963
  * Groups` already provides.
29964
29964
  *
29965
29965
  * Update has narrower coverage than create: AWS does not support modifying
29966
- * `AutoScalingGroupName` (immutable), `Tags` (those go through `CreateOrUpdate
29967
- * Tags` / `DeleteTags`), or attached LB / target-group references (those go
29968
- * through `Attach*` / `Detach*` calls). Those diffs still surface
29969
- * `ResourceUpdateNotSupportedError` so the caller can `cdkd deploy --replace`.
29970
- * The mutable fields handled in-place via `UpdateAutoScalingGroup` include
29971
- * MinSize / MaxSize / DesiredCapacity / VPCZoneIdentifier / HealthCheckType /
29972
- * HealthCheckGracePeriod / DefaultCooldown / Cooldown / NewInstancesProtected
29973
- * FromScaleIn / MaxInstanceLifetime / TerminationPolicies / CapacityRebalance
29974
- * / ServiceLinkedRoleARN / Context / DesiredCapacityType / DefaultInstance
29975
- * Warmup / AvailabilityZones / AvailabilityZoneDistribution / Availability
29976
- * ZoneImpairmentPolicy / SkipZonalShiftValidation / CapacityReservation
29977
- * Specification / InstanceMaintenancePolicy / DeletionProtection / Mixed
29978
- * InstancesPolicy / LaunchTemplate.
29966
+ * `AutoScalingGroupName` (immutable) that diff still surfaces
29967
+ * `ResourceUpdateNotSupportedError` so the caller can `cdkd deploy
29968
+ * --replace`. The mutable fields handled in-place via
29969
+ * `UpdateAutoScalingGroup` include MinSize / MaxSize / DesiredCapacity /
29970
+ * VPCZoneIdentifier / HealthCheckType / HealthCheckGracePeriod /
29971
+ * DefaultCooldown / Cooldown / NewInstancesProtectedFromScaleIn /
29972
+ * MaxInstanceLifetime / TerminationPolicies / CapacityRebalance /
29973
+ * ServiceLinkedRoleARN / Context / DesiredCapacityType /
29974
+ * DefaultInstanceWarmup / AvailabilityZones / AvailabilityZoneDistribution
29975
+ * / AvailabilityZoneImpairmentPolicy / SkipZonalShiftValidation /
29976
+ * CapacityReservationSpecification / InstanceMaintenancePolicy /
29977
+ * DeletionProtection / MixedInstancesPolicy / LaunchTemplate.
29979
29978
  *
29980
29979
  * Sub-shape diffs are applied via dedicated AWS APIs before the main
29981
- * `UpdateAutoScalingGroup` call: `MetricsCollection` →
29982
- * `EnableMetricsCollection` / `DisableMetricsCollection`,
29983
- * `LifecycleHookSpecificationList` → per-entry `PutLifecycleHook` /
29984
- * `DeleteLifecycleHook`, `TrafficSources` → `AttachTrafficSources` /
29985
- * `DetachTrafficSources`, `NotificationConfigurations` → per-topic
29986
- * `PutNotificationConfiguration` / `DeleteNotificationConfiguration`.
29980
+ * `UpdateAutoScalingGroup` call:
29981
+ * - `Tags` → `CreateOrUpdateTags` / `DeleteTags` (#475)
29982
+ * - `LoadBalancerNames` → `AttachLoadBalancers` /
29983
+ * `DetachLoadBalancers` (#476)
29984
+ * - `TargetGroupARNs` `AttachLoadBalancerTargetGroups` /
29985
+ * `DetachLoadBalancerTargetGroups` (#476)
29986
+ * - `MetricsCollection` → `EnableMetricsCollection` /
29987
+ * `DisableMetricsCollection`
29988
+ * - `LifecycleHookSpecificationList` → per-entry `PutLifecycleHook` /
29989
+ * `DeleteLifecycleHook`
29990
+ * - `TrafficSources` → `AttachTrafficSources` /
29991
+ * `DetachTrafficSources`
29992
+ * - `NotificationConfigurations` → per-topic
29993
+ * `PutNotificationConfiguration` /
29994
+ * `DeleteNotificationConfiguration`
29995
+ *
29987
29996
  * Each helper is a no-op when the before/after JSON is identical.
29988
29997
  */
29989
29998
  var ASGProvider = class {
@@ -30091,10 +30100,10 @@ var ASGProvider = class {
30091
30100
  this.logger.debug(`Updating AutoScalingGroup ${logicalId}: ${physicalId}`);
30092
30101
  const stringEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
30093
30102
  if (!stringEq(properties["AutoScalingGroupName"], previousProperties["AutoScalingGroupName"])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "AutoScalingGroupName is immutable on AWS — UpdateAutoScalingGroup does not accept a new name; the name is fixed at creation. Use cdkd deploy --replace to replace the group.");
30094
- if (!stringEq(properties["Tags"] ?? [], previousProperties["Tags"] ?? [])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "Tags updates on AWS::AutoScaling::AutoScalingGroup are not yet implemented in cdkd (AWS exposes CreateOrUpdateTags / DeleteTags); use cdkd deploy --replace, or update the tags via AWS console / CLI.");
30095
- if (!stringEq(properties["LoadBalancerNames"] ?? [], previousProperties["LoadBalancerNames"] ?? [])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "LoadBalancerNames diffs on AWS::AutoScaling::AutoScalingGroup are not yet implemented in cdkd (AWS exposes AttachLoadBalancers / DetachLoadBalancers); use cdkd deploy --replace.");
30096
- if (!stringEq(properties["TargetGroupARNs"] ?? [], previousProperties["TargetGroupARNs"] ?? [])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "TargetGroupARNs diffs on AWS::AutoScaling::AutoScalingGroup are not yet implemented in cdkd (AWS exposes AttachLoadBalancerTargetGroups / DetachLoadBalancerTargetGroups); use cdkd deploy --replace.");
30097
30103
  try {
30104
+ await this.applyTagsDiff(physicalId, properties["Tags"], previousProperties["Tags"]);
30105
+ await this.applyLoadBalancerNamesDiff(physicalId, properties["LoadBalancerNames"], previousProperties["LoadBalancerNames"]);
30106
+ await this.applyTargetGroupArnsDiff(physicalId, properties["TargetGroupARNs"], previousProperties["TargetGroupARNs"]);
30098
30107
  await this.applyMetricsCollectionDiff(physicalId, properties["MetricsCollection"], previousProperties["MetricsCollection"]);
30099
30108
  await this.applyLifecycleHooksDiff(physicalId, properties["LifecycleHookSpecificationList"], previousProperties["LifecycleHookSpecificationList"]);
30100
30109
  await this.applyTrafficSourcesDiff(physicalId, properties["TrafficSources"], previousProperties["TrafficSources"]);
@@ -30354,6 +30363,99 @@ var ASGProvider = class {
30354
30363
  sleep(ms) {
30355
30364
  return new Promise((resolve) => setTimeout(resolve, ms));
30356
30365
  }
30366
+ /**
30367
+ * Diff and apply changes to the ASG's `Tags` property via the
30368
+ * `CreateOrUpdateTags` / `DeleteTags` AWS APIs (#475). CFn Tags shape is
30369
+ * `[{Key, Value, PropagateAtLaunch}]`; AWS Tag input adds `ResourceId`
30370
+ * (= the ASG name) and `ResourceType: 'auto-scaling-group'`.
30371
+ *
30372
+ * Diff semantics:
30373
+ * - Removed keys → `DeleteTags`.
30374
+ * - Added keys → `CreateOrUpdateTags`.
30375
+ * - Modified value or `PropagateAtLaunch` flag → `CreateOrUpdateTags`
30376
+ * (the AWS API upserts by `(ResourceId, ResourceType, Key)` tuple, so
30377
+ * a single upsert call replaces the old value).
30378
+ *
30379
+ * No-op when before/after JSON is identical.
30380
+ */
30381
+ async applyTagsDiff(physicalId, next, prev) {
30382
+ if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
30383
+ const nextEntries = Array.isArray(next) ? next : [];
30384
+ const prevEntries = Array.isArray(prev) ? prev : [];
30385
+ const nextByKey = /* @__PURE__ */ new Map();
30386
+ for (const t of nextEntries) if (t.Key) nextByKey.set(t.Key, t);
30387
+ const prevByKey = /* @__PURE__ */ new Map();
30388
+ for (const t of prevEntries) if (t.Key) prevByKey.set(t.Key, t);
30389
+ const toDelete = [];
30390
+ for (const [key, tag] of prevByKey) if (!nextByKey.has(key)) toDelete.push(tag);
30391
+ if (toDelete.length > 0) await this.getClient().send(new DeleteTagsCommand$1({ Tags: toDelete.map((t) => ({
30392
+ ResourceId: physicalId,
30393
+ ResourceType: "auto-scaling-group",
30394
+ Key: t.Key
30395
+ })) }));
30396
+ const toUpsert = [];
30397
+ for (const [key, tag] of nextByKey) {
30398
+ const before = prevByKey.get(key);
30399
+ if (JSON.stringify(before) === JSON.stringify(tag)) continue;
30400
+ toUpsert.push(tag);
30401
+ }
30402
+ if (toUpsert.length > 0) await this.getClient().send(new CreateOrUpdateTagsCommand({ Tags: toUpsert.map((t) => ({
30403
+ ResourceId: physicalId,
30404
+ ResourceType: "auto-scaling-group",
30405
+ Key: t.Key,
30406
+ ...t.Value !== void 0 && { Value: t.Value },
30407
+ ...t.PropagateAtLaunch !== void 0 && { PropagateAtLaunch: t.PropagateAtLaunch }
30408
+ })) }));
30409
+ }
30410
+ /**
30411
+ * Diff `LoadBalancerNames` (Classic Load Balancers) and issue
30412
+ * `AttachLoadBalancers` / `DetachLoadBalancers` for the delta (#476).
30413
+ * Names are opaque strings; AWS allows N attached LBs per ASG so this
30414
+ * helper batches every add into one Attach call and every remove into
30415
+ * one Detach call. No-op when before/after JSON is identical.
30416
+ */
30417
+ async applyLoadBalancerNamesDiff(physicalId, next, prev) {
30418
+ if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
30419
+ const nextNames = (Array.isArray(next) ? next : []).filter((n) => typeof n === "string");
30420
+ const prevNames = (Array.isArray(prev) ? prev : []).filter((n) => typeof n === "string");
30421
+ const nextSet = new Set(nextNames);
30422
+ const prevSet = new Set(prevNames);
30423
+ const toAttach = nextNames.filter((n) => !prevSet.has(n));
30424
+ const toDetach = prevNames.filter((n) => !nextSet.has(n));
30425
+ if (toDetach.length > 0) await this.getClient().send(new DetachLoadBalancersCommand({
30426
+ AutoScalingGroupName: physicalId,
30427
+ LoadBalancerNames: toDetach
30428
+ }));
30429
+ if (toAttach.length > 0) await this.getClient().send(new AttachLoadBalancersCommand({
30430
+ AutoScalingGroupName: physicalId,
30431
+ LoadBalancerNames: toAttach
30432
+ }));
30433
+ }
30434
+ /**
30435
+ * Diff `TargetGroupARNs` (ALB / NLB target groups) and issue
30436
+ * `AttachLoadBalancerTargetGroups` /
30437
+ * `DetachLoadBalancerTargetGroups` for the delta (#476). Target-group
30438
+ * ARNs are opaque strings; same per-call batching pattern as
30439
+ * `applyLoadBalancerNamesDiff`. No-op when before/after JSON is
30440
+ * identical.
30441
+ */
30442
+ async applyTargetGroupArnsDiff(physicalId, next, prev) {
30443
+ if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
30444
+ const nextArns = (Array.isArray(next) ? next : []).filter((a) => typeof a === "string");
30445
+ const prevArns = (Array.isArray(prev) ? prev : []).filter((a) => typeof a === "string");
30446
+ const nextSet = new Set(nextArns);
30447
+ const prevSet = new Set(prevArns);
30448
+ const toAttach = nextArns.filter((a) => !prevSet.has(a));
30449
+ const toDetach = prevArns.filter((a) => !nextSet.has(a));
30450
+ if (toDetach.length > 0) await this.getClient().send(new DetachLoadBalancerTargetGroupsCommand({
30451
+ AutoScalingGroupName: physicalId,
30452
+ TargetGroupARNs: toDetach
30453
+ }));
30454
+ if (toAttach.length > 0) await this.getClient().send(new AttachLoadBalancerTargetGroupsCommand({
30455
+ AutoScalingGroupName: physicalId,
30456
+ TargetGroupARNs: toAttach
30457
+ }));
30458
+ }
30357
30459
  async applyMetricsCollectionDiff(physicalId, next, prev) {
30358
30460
  if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
30359
30461
  const nextEntries = Array.isArray(next) ? next : [];
@@ -37995,6 +38097,8 @@ function parseContainerDefinition(raw, idx, taskLogicalId, resources, stack, con
37995
38097
  protocol: pickString(p["Protocol"]) === "udp" ? "udp" : "tcp"
37996
38098
  };
37997
38099
  if (hostPort !== void 0) pm.hostPort = hostPort;
38100
+ const portName = typeof p["Name"] === "string" ? p["Name"] : void 0;
38101
+ if (portName !== void 0) pm.name = portName;
37998
38102
  portMappings.push(pm);
37999
38103
  }
38000
38104
  const mountPoints = [];
@@ -38643,7 +38747,7 @@ function resolveRuntimeCodeMountPath(runtime) {
38643
38747
 
38644
38748
  //#endregion
38645
38749
  //#region src/local/docker-runner.ts
38646
- const execFileAsync$3 = promisify(execFile);
38750
+ const execFileAsync$4 = promisify(execFile);
38647
38751
  /**
38648
38752
  * Wraps `docker pull` / `docker run` / `docker rm` for `cdkd local invoke`.
38649
38753
  *
@@ -38736,7 +38840,7 @@ async function runDetached(opts) {
38736
38840
  args.push(opts.image, ...entryPointTail, ...opts.cmd);
38737
38841
  getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
38738
38842
  try {
38739
- const { stdout } = await execFileAsync$3(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
38843
+ const { stdout } = await execFileAsync$4(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
38740
38844
  return stdout.trim();
38741
38845
  } catch (error) {
38742
38846
  const err = error;
@@ -38773,7 +38877,7 @@ async function removeContainer(containerId) {
38773
38877
  if (!containerId) return;
38774
38878
  const logger = getLogger().child("docker");
38775
38879
  try {
38776
- await execFileAsync$3(getDockerCmd(), [
38880
+ await execFileAsync$4(getDockerCmd(), [
38777
38881
  "rm",
38778
38882
  "-f",
38779
38883
  containerId
@@ -38792,7 +38896,7 @@ async function removeContainer(containerId) {
38792
38896
  async function ensureDockerAvailable() {
38793
38897
  const cmd = getDockerCmd();
38794
38898
  try {
38795
- await execFileAsync$3(cmd, [
38899
+ await execFileAsync$4(cmd, [
38796
38900
  "version",
38797
38901
  "--format",
38798
38902
  "{{.Server.Version}}"
@@ -39898,6 +40002,25 @@ function resolveFnSubInvokeArn(arg) {
39898
40002
  };
39899
40003
  }
39900
40004
 
40005
+ //#endregion
40006
+ //#region src/local/intrinsic-utils.ts
40007
+ /**
40008
+ * If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
40009
+ * logical ID. Otherwise return `null`.
40010
+ *
40011
+ * Shared across the `src/local/*` resolvers (route discovery, authorizer
40012
+ * resolution, stage attachment) so future intrinsic-shape extensions
40013
+ * (e.g. accepting `Fn::Sub`-bound Refs in REST v1 ResourceId / ParentId)
40014
+ * land in one place instead of three.
40015
+ */
40016
+ function pickRefLogicalId(value) {
40017
+ if (value && typeof value === "object" && !Array.isArray(value)) {
40018
+ const ref = value["Ref"];
40019
+ if (typeof ref === "string") return ref;
40020
+ }
40021
+ return null;
40022
+ }
40023
+
39901
40024
  //#endregion
39902
40025
  //#region src/local/httpv2-service-integration.ts
39903
40026
  const logger = getLogger();
@@ -40363,7 +40486,7 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
40363
40486
  const integration = props["Integration"];
40364
40487
  if (!integration) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): missing Integration property`);
40365
40488
  const restApiId = props["RestApiId"];
40366
- const restApiLogicalId = pickRefLogicalId$3(restApiId);
40489
+ const restApiLogicalId = pickRefLogicalId(restApiId);
40367
40490
  if (!restApiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): RestApiId must be a { Ref: '...' } reference (got ${shortJson$1(restApiId)}).`);
40368
40491
  const resourceId = props["ResourceId"];
40369
40492
  const path = buildRestV1Path(resourceId, restApiLogicalId, template, stackName, logicalId);
@@ -40723,7 +40846,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
40723
40846
  if (Array.isArray(arg) && arg.length === 2 && arg[1] === "RootResourceId") return "/";
40724
40847
  }
40725
40848
  }
40726
- const resourceLogicalId = pickRefLogicalId$3(resourceIdIntrinsic);
40849
+ const resourceLogicalId = pickRefLogicalId(resourceIdIntrinsic);
40727
40850
  if (!resourceLogicalId) throw new Error(`${stackName}/${methodLogicalId}: ResourceId must be { Ref: '...' } or { 'Fn::GetAtt': [..., 'RootResourceId'] } (got ${shortJson$1(resourceIdIntrinsic)}).`);
40728
40851
  const segments = [];
40729
40852
  const visited = /* @__PURE__ */ new Set();
@@ -40743,7 +40866,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
40743
40866
  const arg = parentId["Fn::GetAtt"];
40744
40867
  if (Array.isArray(arg) && arg[1] === "RootResourceId") break;
40745
40868
  }
40746
- cursor = pickRefLogicalId$3(parentId) ?? void 0;
40869
+ cursor = pickRefLogicalId(parentId) ?? void 0;
40747
40870
  }
40748
40871
  return "/" + segments.join("/");
40749
40872
  }
@@ -40758,7 +40881,7 @@ function pickRestV1Stage(restApiLogicalId, template) {
40758
40881
  for (const [, resource] of Object.entries(resources)) {
40759
40882
  if (resource.Type !== "AWS::ApiGateway::Stage") continue;
40760
40883
  const props = resource.Properties ?? {};
40761
- if (pickRefLogicalId$3(props["RestApiId"]) === restApiLogicalId) {
40884
+ if (pickRefLogicalId(props["RestApiId"]) === restApiLogicalId) {
40762
40885
  const stageName = props["StageName"];
40763
40886
  if (typeof stageName === "string") return stageName;
40764
40887
  }
@@ -40777,7 +40900,7 @@ function pickRestV1Stage(restApiLogicalId, template) {
40777
40900
  function discoverHttpApiRoute(logicalId, resource, template, stackName) {
40778
40901
  const props = resource.Properties ?? {};
40779
40902
  const apiId = props["ApiId"];
40780
- const apiLogicalId = pickRefLogicalId$3(apiId);
40903
+ const apiLogicalId = pickRefLogicalId(apiId);
40781
40904
  if (!apiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): ApiId must be { Ref: '...' } (got ${shortJson$1(apiId)}).`);
40782
40905
  const routeKey = props["RouteKey"];
40783
40906
  if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): RouteKey must be a string`);
@@ -40990,11 +41113,11 @@ function parseHttpApiTargetIntegration(target, location) {
40990
41113
  const sep = join[0];
40991
41114
  const parts = join[1];
40992
41115
  if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
40993
- const ref = pickRefLogicalId$3(parts[1]);
41116
+ const ref = pickRefLogicalId(parts[1]);
40994
41117
  if (ref) return ref;
40995
41118
  }
40996
41119
  if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
40997
- const ref = pickRefLogicalId$3(parts[1]);
41120
+ const ref = pickRefLogicalId(parts[1]);
40998
41121
  if (ref) return ref;
40999
41122
  }
41000
41123
  }
@@ -41014,7 +41137,7 @@ function parseHttpApiTargetIntegration(target, location) {
41014
41137
  if (m) {
41015
41138
  const bound = bindings[m[1]];
41016
41139
  if (bound !== void 0) {
41017
- const ref = pickRefLogicalId$3(bound);
41140
+ const ref = pickRefLogicalId(bound);
41018
41141
  if (ref) return ref;
41019
41142
  }
41020
41143
  }
@@ -41040,17 +41163,6 @@ function parseRouteKey(routeKey) {
41040
41163
  };
41041
41164
  }
41042
41165
  /**
41043
- * If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
41044
- * logical ID. Otherwise return `null`.
41045
- */
41046
- function pickRefLogicalId$3(value) {
41047
- if (value && typeof value === "object" && !Array.isArray(value)) {
41048
- const ref = value["Ref"];
41049
- if (typeof ref === "string") return ref;
41050
- }
41051
- return null;
41052
- }
41053
- /**
41054
41166
  * Compact JSON for error messages — caps long objects so a malformed
41055
41167
  * intrinsic doesn't dump the whole template into a stderr line.
41056
41168
  */
@@ -41142,7 +41254,7 @@ function collectAuthRoutesForApi(apiLogicalId, template, _stackName) {
41142
41254
  for (const [, resource] of Object.entries(resources)) {
41143
41255
  if (resource.Type !== "AWS::ApiGatewayV2::Route") continue;
41144
41256
  const props = resource.Properties ?? {};
41145
- if (pickRefLogicalId$2(props["ApiId"]) !== apiLogicalId) continue;
41257
+ if (pickRefLogicalId(props["ApiId"]) !== apiLogicalId) continue;
41146
41258
  const authType = props["AuthorizationType"];
41147
41259
  if (authType === void 0) continue;
41148
41260
  const routeKey = props["RouteKey"];
@@ -41194,7 +41306,7 @@ function pickStage$1(apiLogicalId, template) {
41194
41306
  for (const [, resource] of Object.entries(resources)) {
41195
41307
  if (resource.Type !== "AWS::ApiGatewayV2::Stage") continue;
41196
41308
  const props = resource.Properties ?? {};
41197
- if (pickRefLogicalId$2(props["ApiId"]) === apiLogicalId) {
41309
+ if (pickRefLogicalId(props["ApiId"]) === apiLogicalId) {
41198
41310
  const stageName = props["StageName"];
41199
41311
  if (typeof stageName === "string" && stageName.length > 0) return stageName;
41200
41312
  }
@@ -41215,7 +41327,7 @@ function collectRoutesForApi(apiLogicalId, template, stackName) {
41215
41327
  for (const [routeLogicalId, resource] of Object.entries(resources)) {
41216
41328
  if (resource.Type !== "AWS::ApiGatewayV2::Route") continue;
41217
41329
  const props = resource.Properties ?? {};
41218
- if (pickRefLogicalId$2(props["ApiId"]) !== apiLogicalId) continue;
41330
+ if (pickRefLogicalId(props["ApiId"]) !== apiLogicalId) continue;
41219
41331
  const declaredAt = `${stackName}/${routeLogicalId}`;
41220
41332
  const routeKey = props["RouteKey"];
41221
41333
  if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${declaredAt}: RouteKey must be a non-empty string.`);
@@ -41263,11 +41375,11 @@ function parseRouteTarget(target, location) {
41263
41375
  const sep = join[0];
41264
41376
  const parts = join[1];
41265
41377
  if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
41266
- const ref = pickRefLogicalId$2(parts[1]);
41378
+ const ref = pickRefLogicalId(parts[1]);
41267
41379
  if (ref) return ref;
41268
41380
  }
41269
41381
  if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
41270
- const ref = pickRefLogicalId$2(parts[1]);
41382
+ const ref = pickRefLogicalId(parts[1]);
41271
41383
  if (ref) return ref;
41272
41384
  }
41273
41385
  }
@@ -41288,7 +41400,7 @@ function parseRouteTarget(target, location) {
41288
41400
  if (m) {
41289
41401
  const bound = bindings[m[1]];
41290
41402
  if (bound !== void 0) {
41291
- const ref = pickRefLogicalId$2(bound);
41403
+ const ref = pickRefLogicalId(bound);
41292
41404
  if (ref) return ref;
41293
41405
  }
41294
41406
  }
@@ -41297,13 +41409,6 @@ function parseRouteTarget(target, location) {
41297
41409
  }
41298
41410
  throw new Error(`${location}: Target must be 'integrations/<id>' literal, Fn::Join with the documented shapes, or Fn::Sub with an 'integrations/\${...}' template.`);
41299
41411
  }
41300
- function pickRefLogicalId$2(value) {
41301
- if (value && typeof value === "object" && !Array.isArray(value)) {
41302
- const ref = value["Ref"];
41303
- if (typeof ref === "string") return ref;
41304
- }
41305
- return null;
41306
- }
41307
41412
  function readApiCdkPath(logicalId, template) {
41308
41413
  const resource = template.Resources?.[logicalId];
41309
41414
  if (!resource) return void 0;
@@ -45119,11 +45224,22 @@ function parseCognitoIssuer(issuer) {
45119
45224
  };
45120
45225
  }
45121
45226
  /**
45122
- * Pull a string out of a {Ref} / literal entry under `ProviderARNs`.
45123
- * CDK's CognitoUserPoolsAuthorizer emits a literal array of `Fn::GetAtt:
45124
- * [<UserPool>, 'Arn']` entries we accept both. The `location` argument
45125
- * carries the full `<stack>/<authorizer>.ProviderARNs[<idx>]` path so the
45126
- * error names the offending entry exactly.
45227
+ * Pull a string out of a literal / `Fn::GetAtt` entry under `ProviderARNs`.
45228
+ *
45229
+ * CDK's `apigateway.CognitoUserPoolsAuthorizer` emits a `Fn::GetAtt:
45230
+ * [<UserPool>, 'Arn']` reference, which is the canonical shape any user
45231
+ * who writes `new CognitoUserPoolsAuthorizer(this, 'auth', { cognitoUserPools: [pool] })`
45232
+ * ends up with (#470). Without `--from-state` we cannot resolve the
45233
+ * deployed pool ARN, so we synthesize an obviously-unreachable placeholder
45234
+ * pointing at a non-existent pool id — the JWKS fetch will fail and
45235
+ * cognito-jwt.ts's pass-through fallback (PR #234) admits every JWT
45236
+ * without signature verification. The warn log names the affected
45237
+ * authorizer + the recommended explicit `providerArns` workaround so
45238
+ * developers who DO want real verification know how to switch over.
45239
+ *
45240
+ * The `location` argument carries the full
45241
+ * `<stack>/<authorizer>.ProviderARNs[<idx>]` path so the warn / error
45242
+ * names the offending entry exactly.
45127
45243
  */
45128
45244
  function pickStringFromArn(value, location) {
45129
45245
  if (typeof value === "string") return value;
@@ -45131,7 +45247,11 @@ function pickStringFromArn(value, location) {
45131
45247
  const obj = value;
45132
45248
  if ("Fn::GetAtt" in obj) {
45133
45249
  const arg = obj["Fn::GetAtt"];
45134
- if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string" && arg[1] === "Arn") throw new RouteDiscoveryError(`${location}: uses Fn::GetAtt against logical ID '${arg[0]}'. cdkd local start-api needs the literal ARN string to derive the JWKS URL — set the user pool ARN explicitly via 'authorizer.providerArns' on the CDK construct, or upgrade to JWT (HTTP v2) which encodes the pool in the Issuer URL.`);
45250
+ if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string" && arg[1] === "Arn") {
45251
+ const logicalId = arg[0];
45252
+ getLogger().warn(`${location}: uses Fn::GetAtt against logical ID '${logicalId}'. cdkd local start-api cannot resolve the deployed user pool ARN — synthesizing an unreachable placeholder so JWKS pass-through admits every token. For real signature verification, set 'providerArns: [pool.userPoolArn]' explicitly on the CDK construct.`);
45253
+ return `arn:aws:cognito-idp:us-east-1:000000000000:userpool/us-east-1_cdkdplaceholder${logicalId}`;
45254
+ }
45135
45255
  }
45136
45256
  }
45137
45257
  throw new RouteDiscoveryError(`${location}: must be a literal string (got ${shortJson(value)}).`);
@@ -45218,7 +45338,7 @@ function detectRestV1Authorizer(methodResource, methodLogicalId, stack) {
45218
45338
  declaredAt: `${stack.stackName}/${methodLogicalId}`
45219
45339
  };
45220
45340
  const authorizerId = props["AuthorizerId"];
45221
- const refLogicalId = pickRefLogicalId$1(authorizerId);
45341
+ const refLogicalId = pickRefLogicalId(authorizerId);
45222
45342
  if (!refLogicalId) throw new RouteDiscoveryError(`${stack.stackName}/${methodLogicalId}: AuthorizationType='${stringifyValue(authType)}' but AuthorizerId is missing or not a {Ref:...}.`);
45223
45343
  return resolveRestV1Authorizer(refLogicalId, stack.template, stack.stackName, `${stack.stackName}/${methodLogicalId}`);
45224
45344
  }
@@ -45227,18 +45347,11 @@ function detectHttpApiAuthorizer(routeResource, routeLogicalId, stack) {
45227
45347
  const authType = props["AuthorizationType"];
45228
45348
  if (authType === void 0 || authType === "NONE") return void 0;
45229
45349
  const authorizerId = props["AuthorizerId"];
45230
- const refLogicalId = pickRefLogicalId$1(authorizerId);
45350
+ const refLogicalId = pickRefLogicalId(authorizerId);
45231
45351
  if (!refLogicalId) throw new RouteDiscoveryError(`${stack.stackName}/${routeLogicalId}: AuthorizationType='${stringifyValue(authType)}' but AuthorizerId is missing or not a {Ref:...}.`);
45232
45352
  const scopesRaw = props["AuthorizationScopes"];
45233
45353
  return resolveHttpApiAuthorizer(refLogicalId, Array.isArray(scopesRaw) ? scopesRaw.filter((s) => typeof s === "string") : void 0, stack.template, stack.stackName, `${stack.stackName}/${routeLogicalId}`);
45234
45354
  }
45235
- function pickRefLogicalId$1(value) {
45236
- if (value && typeof value === "object" && !Array.isArray(value)) {
45237
- const ref = value["Ref"];
45238
- if (typeof ref === "string") return ref;
45239
- }
45240
- return null;
45241
- }
45242
45355
  function shortJson(value) {
45243
45356
  try {
45244
45357
  const s = JSON.stringify(value);
@@ -47809,19 +47922,6 @@ function attachStageContext(routes, stageMap) {
47809
47922
  route.stage = stage.stageName;
47810
47923
  }
47811
47924
  }
47812
- /**
47813
- * If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
47814
- * logical ID. Otherwise return `null`. (Duplicated structurally from
47815
- * `route-discovery.ts` — both modules walk the template independently
47816
- * and shouldn't grow a coupling for a 5-line helper.)
47817
- */
47818
- function pickRefLogicalId(value) {
47819
- if (value && typeof value === "object" && !Array.isArray(value)) {
47820
- const ref = value["Ref"];
47821
- if (typeof ref === "string") return ref;
47822
- }
47823
- return null;
47824
- }
47825
47925
 
47826
47926
  //#endregion
47827
47927
  //#region src/local/file-watcher.ts
@@ -49242,7 +49342,7 @@ function createLocalStartApiCommand() {
49242
49342
 
49243
49343
  //#endregion
49244
49344
  //#region src/local/ecs-network.ts
49245
- const execFileAsync$2 = promisify(execFile);
49345
+ const execFileAsync$3 = promisify(execFile);
49246
49346
  /**
49247
49347
  * Docker network + AWS-published metadata-endpoints sidecar lifecycle for
49248
49348
  * `cdkd local run-task`. The sidecar (a small Go binary maintained by
@@ -49260,8 +49360,10 @@ const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:lat
49260
49360
  * matches the documented AWS task-metadata endpoint address. Containers
49261
49361
  * inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
49262
49362
  * to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
49263
- * start-service` allocates a per-replica subnet (via `subnetOctet`) so
49264
- * concurrent replicas don't collide on a single docker network range.
49363
+ * start-service` creates ONE shared network at CLI startup (design
49364
+ * § 5 Option A) the shared sidecar lives at `169.254.171.2` (see
49365
+ * `SHARED_SVC_SUBNET_OCTET` below), one octet up so the two CLI
49366
+ * variants can run on the same host without bridge-pool collision.
49265
49367
  */
49266
49368
  const METADATA_ENDPOINT_IP = "169.254.170.2";
49267
49369
  /** Default subnet — used when no `subnetOctet` override is supplied. */
@@ -49289,23 +49391,58 @@ function buildEndpointSubnet(subnetOctet) {
49289
49391
  };
49290
49392
  }
49291
49393
  /**
49292
- * Create the per-task docker network + start the metadata-endpoints
49293
- * sidecar. The sidecar must come up at the well-known address BEFORE any
49294
- * user container starts so the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
49295
- * lookup at container start doesn't race.
49394
+ * Subnet octet for the shared-service docker network used by
49395
+ * `cdkd local start-service`. One octet up from `cdkd local run-task`'s
49396
+ * default (170 171) so the two CLI variants can run on the same host
49397
+ * without docker rejecting the second `--subnet`. The shared-service
49398
+ * network reuses the same `createTaskNetwork` machinery; the sidecar at
49399
+ * `169.254.171.2` serves the same metadata-endpoint API to every
49400
+ * container that joins this one network.
49296
49401
  */
49297
- async function createTaskNetwork(options = {}) {
49402
+ const SHARED_SVC_SUBNET_OCTET = 171;
49403
+ /**
49404
+ * Create the one shared docker network + metadata-endpoints sidecar
49405
+ * used by every service-replica boot in a single
49406
+ * `cdkd local start-service` invocation. This is design doc § 5
49407
+ * Option A — one network per CLI invocation instead of one network
49408
+ * per task — so peer services can reach each other by IP / network
49409
+ * alias without docker `--network connect` choreography (Option B,
49410
+ * rejected in design § 5 as "unwieldy and racy"). The returned
49411
+ * `TaskNetwork` carries `ownedByCaller: true` so `cleanupEcsRun()`
49412
+ * (called per replica by the service runner) does NOT teardown — the
49413
+ * CLI tears down ONCE at the end of the run.
49414
+ */
49415
+ async function createSharedSvcNetwork(options = {}) {
49416
+ const networkName = `${options.prefix ?? "cdkd-local"}-svc-${randomBytes(4).toString("hex")}`;
49417
+ const { cidr, sidecarIp } = buildEndpointSubnet(171);
49418
+ return {
49419
+ networkName,
49420
+ sidecarContainerId: await createNetworkAndSidecar({
49421
+ networkName,
49422
+ cidr,
49423
+ sidecarIp,
49424
+ skipPull: options.skipPull ?? false,
49425
+ ...options.credentials !== void 0 ? { credentials: options.credentials } : {},
49426
+ ...options.cluster !== void 0 ? { cluster: options.cluster } : {}
49427
+ }),
49428
+ sidecarIp,
49429
+ ownedByCaller: true
49430
+ };
49431
+ }
49432
+ /**
49433
+ * Internal helper shared by `createTaskNetwork` (per-task) and
49434
+ * `createSharedSvcNetwork` (per-CLI-run). Creates the docker network,
49435
+ * pulls the sidecar image, and starts the sidecar at the documented
49436
+ * IP. Throws `DockerRunnerError` with a hint when the network already
49437
+ * exists (the typical "leftover from previous run" path).
49438
+ */
49439
+ async function createNetworkAndSidecar(args) {
49298
49440
  const logger = getLogger().child("ecs-network");
49299
- const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
49300
- const subnetOctet = options.subnetOctet ?? 170;
49301
- const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
49302
- cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
49303
- sidecarIp: METADATA_ENDPOINT_IP
49304
- } : buildEndpointSubnet(subnetOctet);
49305
- await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
49441
+ const { networkName, cidr, sidecarIp, credentials, cluster, skipPull } = args;
49442
+ await pullImage(METADATA_ENDPOINT_IMAGE, skipPull);
49306
49443
  logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
49307
49444
  try {
49308
- await execFileAsync$2(getDockerCmd(), [
49445
+ await execFileAsync$3(getDockerCmd(), [
49309
49446
  "network",
49310
49447
  "create",
49311
49448
  "--driver",
@@ -49316,7 +49453,7 @@ async function createTaskNetwork(options = {}) {
49316
49453
  ]);
49317
49454
  } catch (err) {
49318
49455
  const e = err;
49319
- throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` walks subnetOctet per replica to avoid this; bare \`cdkd local run-task\` uses the default subnet so only one run can be active at a time.`);
49456
+ throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` shares one network across every service in the run; bare \`cdkd local run-task\` uses a per-task network so only one run can be active at a time.`);
49320
49457
  }
49321
49458
  const sidecarArgs = [
49322
49459
  "run",
@@ -49330,27 +49467,46 @@ async function createTaskNetwork(options = {}) {
49330
49467
  sidecarIp
49331
49468
  ];
49332
49469
  const sidecarEnv = {};
49333
- if (options.credentials) {
49334
- sidecarEnv["AWS_ACCESS_KEY_ID"] = options.credentials.accessKeyId;
49335
- sidecarEnv["AWS_SECRET_ACCESS_KEY"] = options.credentials.secretAccessKey;
49336
- if (options.credentials.sessionToken) sidecarEnv["AWS_SESSION_TOKEN"] = options.credentials.sessionToken;
49470
+ if (credentials) {
49471
+ sidecarEnv["AWS_ACCESS_KEY_ID"] = credentials.accessKeyId;
49472
+ sidecarEnv["AWS_SECRET_ACCESS_KEY"] = credentials.secretAccessKey;
49473
+ if (credentials.sessionToken) sidecarEnv["AWS_SESSION_TOKEN"] = credentials.sessionToken;
49337
49474
  }
49338
- if (options.cluster) sidecarEnv["CLUSTER"] = options.cluster;
49475
+ if (cluster) sidecarEnv["CLUSTER"] = cluster;
49339
49476
  for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
49340
49477
  sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
49341
49478
  logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
49342
- let sidecarContainerId;
49343
49479
  try {
49344
- const { stdout } = await execFileAsync$2(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
49345
- sidecarContainerId = stdout.trim();
49480
+ const { stdout } = await execFileAsync$3(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
49481
+ return stdout.trim();
49346
49482
  } catch (err) {
49347
49483
  await destroyNetworkOnly(networkName);
49348
49484
  const e = err;
49349
49485
  throw new DockerRunnerError(`Failed to start metadata-endpoints sidecar: ${e.stderr?.trim() || e.message || String(err)}`);
49350
49486
  }
49487
+ }
49488
+ /**
49489
+ * Create the per-task docker network + start the metadata-endpoints
49490
+ * sidecar. The sidecar must come up at the well-known address BEFORE any
49491
+ * user container starts so the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
49492
+ * lookup at container start doesn't race.
49493
+ */
49494
+ async function createTaskNetwork(options = {}) {
49495
+ const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
49496
+ const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
49497
+ cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
49498
+ sidecarIp: METADATA_ENDPOINT_IP
49499
+ } : buildEndpointSubnet(options.subnetOctet);
49351
49500
  return {
49352
49501
  networkName,
49353
- sidecarContainerId,
49502
+ sidecarContainerId: await createNetworkAndSidecar({
49503
+ networkName,
49504
+ cidr,
49505
+ sidecarIp,
49506
+ skipPull: options.skipPull ?? false,
49507
+ ...options.credentials !== void 0 ? { credentials: options.credentials } : {},
49508
+ ...options.cluster !== void 0 ? { cluster: options.cluster } : {}
49509
+ }),
49354
49510
  sidecarIp
49355
49511
  };
49356
49512
  }
@@ -49389,7 +49545,7 @@ async function destroyNetworkOnly(networkName) {
49389
49545
  if (!networkName) return;
49390
49546
  const logger = getLogger().child("ecs-network");
49391
49547
  try {
49392
- await execFileAsync$2(getDockerCmd(), [
49548
+ await execFileAsync$3(getDockerCmd(), [
49393
49549
  "network",
49394
49550
  "rm",
49395
49551
  networkName
@@ -49522,7 +49678,7 @@ async function resolveSsm(entry, shape, client) {
49522
49678
 
49523
49679
  //#endregion
49524
49680
  //#region src/local/ecs-task-runner.ts
49525
- const execFileAsync$1 = promisify(execFile);
49681
+ const execFileAsync$2 = promisify(execFile);
49526
49682
  /**
49527
49683
  * Top-level orchestrator for `cdkd local run-task`. Coordinates image
49528
49684
  * preparation, secret resolution, docker-network bring-up, container
@@ -49579,10 +49735,10 @@ async function cleanupEcsRun(state, options) {
49579
49735
  }
49580
49736
  state.startedContainers = [];
49581
49737
  }
49582
- await destroyTaskNetwork(state.network);
49738
+ if (state.network && !state.network.ownedByCaller) await destroyTaskNetwork(state.network);
49583
49739
  state.network = void 0;
49584
49740
  for (const v of state.dockerVolumeNames) try {
49585
- await execFileAsync$1(getDockerCmd(), [
49741
+ await execFileAsync$2(getDockerCmd(), [
49586
49742
  "volume",
49587
49743
  "rm",
49588
49744
  v
@@ -49612,14 +49768,20 @@ async function runEcsTask(task, options, state) {
49612
49768
  valueFrom: s.valueFrom
49613
49769
  });
49614
49770
  const secretsByContainer = groupSecretsByContainer(await resolveEcsSecrets(allSecrets, { ...options.region !== void 0 && { region: options.region } }));
49615
- const netCreateOpts = {
49616
- prefix: options.cluster,
49617
- skipPull: options.skipPull
49618
- };
49619
- if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
49620
- if (options.cluster) netCreateOpts.cluster = options.cluster;
49621
- if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
49622
- state.network = await createTaskNetwork(netCreateOpts);
49771
+ if (options.existingNetwork) state.network = {
49772
+ ...options.existingNetwork,
49773
+ ownedByCaller: true
49774
+ };
49775
+ else {
49776
+ const netCreateOpts = {
49777
+ prefix: options.cluster,
49778
+ skipPull: options.skipPull
49779
+ };
49780
+ if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
49781
+ if (options.cluster) netCreateOpts.cluster = options.cluster;
49782
+ if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
49783
+ state.network = await createTaskNetwork(netCreateOpts);
49784
+ }
49623
49785
  const volumeByName = await realizeDockerVolumes(task.volumes, state);
49624
49786
  const dockerCmds = /* @__PURE__ */ new Map();
49625
49787
  for (const container of task.containers) {
@@ -49637,7 +49799,9 @@ async function runEcsTask(task, options, state) {
49637
49799
  roleArn: options.taskRoleArn,
49638
49800
  platformOverride: options.platformOverride,
49639
49801
  region: options.region,
49640
- sidecarIp: state.network.sidecarIp
49802
+ sidecarIp: state.network.sidecarIp,
49803
+ ...options.addHostFlags && options.addHostFlags.length > 0 ? { addHostFlags: options.addHostFlags } : {},
49804
+ ...(options.networkAliasesByContainer?.get(container.name)?.length ?? 0) > 0 ? { networkAliases: options.networkAliasesByContainer.get(container.name) } : {}
49641
49805
  }));
49642
49806
  }
49643
49807
  const startedByName = /* @__PURE__ */ new Map();
@@ -49648,7 +49812,7 @@ async function runEcsTask(task, options, state) {
49648
49812
  logger.info(`Starting container '${container.name}' (image=${imagePlan.get(container.name)})`);
49649
49813
  let id;
49650
49814
  try {
49651
- const { stdout } = await execFileAsync$1(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
49815
+ const { stdout } = await execFileAsync$2(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
49652
49816
  id = stdout.trim();
49653
49817
  } catch (err) {
49654
49818
  const e = err;
@@ -49757,7 +49921,7 @@ async function waitForContainerHealthy(containerId, displayName) {
49757
49921
  let lastStatus = "";
49758
49922
  while (Date.now() < deadline) {
49759
49923
  try {
49760
- const { stdout } = await execFileAsync$1(getDockerCmd(), [
49924
+ const { stdout } = await execFileAsync$2(getDockerCmd(), [
49761
49925
  "inspect",
49762
49926
  "--format",
49763
49927
  "{{.State.Health.Status}}",
@@ -49780,7 +49944,7 @@ async function waitForContainerHealthy(containerId, displayName) {
49780
49944
  }
49781
49945
  async function waitForContainerExit(containerId) {
49782
49946
  try {
49783
- const { stdout } = await execFileAsync$1(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
49947
+ const { stdout } = await execFileAsync$2(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
49784
49948
  const code = Number.parseInt(stdout.trim(), 10);
49785
49949
  return Number.isFinite(code) ? code : 1;
49786
49950
  } catch (err) {
@@ -49790,7 +49954,7 @@ async function waitForContainerExit(containerId) {
49790
49954
  }
49791
49955
  async function stopContainer(containerId, graceSeconds) {
49792
49956
  try {
49793
- await execFileAsync$1(getDockerCmd(), [
49957
+ await execFileAsync$2(getDockerCmd(), [
49794
49958
  "stop",
49795
49959
  "-t",
49796
49960
  String(graceSeconds),
@@ -49915,7 +50079,7 @@ async function realizeDockerVolumes(volumes, state) {
49915
50079
  const dockerVolumeName = `cdkd-local-${v.name}-${randHex(4)}`;
49916
50080
  args.push(dockerVolumeName);
49917
50081
  try {
49918
- await execFileAsync$1(getDockerCmd(), args);
50082
+ await execFileAsync$2(getDockerCmd(), args);
49919
50083
  state.dockerVolumeNames.push(dockerVolumeName);
49920
50084
  logger.debug(`Created docker volume ${dockerVolumeName} for task volume '${v.name}'`);
49921
50085
  } catch (err) {
@@ -49955,6 +50119,14 @@ function buildDockerRunArgs(opts) {
49955
50119
  args.push("--name", `cdkd-local-${task.family}-${container.name}-${randHex(3)}`);
49956
50120
  args.push("--network", network);
49957
50121
  args.push("--network-alias", container.name);
50122
+ if (opts.networkAliases && opts.networkAliases.length > 0) {
50123
+ const seen = new Set([container.name]);
50124
+ for (const a of opts.networkAliases) if (!seen.has(a)) {
50125
+ args.push("--network-alias", a);
50126
+ seen.add(a);
50127
+ }
50128
+ }
50129
+ if (opts.addHostFlags && opts.addHostFlags.length > 0) for (const f of opts.addHostFlags) args.push(f);
49958
50130
  if (opts.platformOverride) args.push("--platform", opts.platformOverride);
49959
50131
  else if (task.runtimePlatform) args.push("--platform", task.runtimePlatform.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64");
49960
50132
  for (const pm of container.portMappings) {
@@ -50361,8 +50533,8 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
50361
50533
  const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
50362
50534
  const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
50363
50535
  if (Array.isArray(props["LoadBalancers"]) && props["LoadBalancers"].length > 0) warnings.push(`ECS Service '${serviceLogicalId}' declares LoadBalancers, but local load-balancer emulation is deferred to a follow-up PR. Containers are NOT registered to a local listener; reach them via their published ports.`);
50364
- 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.`);
50365
- return {
50536
+ const serviceConnect = extractServiceConnect(props["ServiceConnectConfiguration"], task);
50537
+ const out = {
50366
50538
  stack,
50367
50539
  serviceLogicalId,
50368
50540
  resource,
@@ -50370,8 +50542,103 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
50370
50542
  desiredCount,
50371
50543
  healthCheckGracePeriodSeconds,
50372
50544
  task,
50545
+ serviceRegistries: extractServiceRegistries(props["ServiceRegistries"]),
50373
50546
  warnings
50374
50547
  };
50548
+ if (serviceConnect) out.serviceConnect = serviceConnect;
50549
+ return out;
50550
+ }
50551
+ /**
50552
+ * Parse `ServiceConnectConfiguration` against the producer TaskDef.
50553
+ * Returns `undefined` when the block is missing OR `Enabled: false`.
50554
+ *
50555
+ * Reject conditions (surface as resolver-time errors so the user sees
50556
+ * them BEFORE the docker network is created):
50557
+ * - `Namespace` is not a literal string. CDK 2.x always emits a
50558
+ * literal string here (verified 2026-05-22); cross-stack /
50559
+ * intrinsic shapes are out of scope.
50560
+ * - `Services[].PortName` doesn't match any of the TaskDef's
50561
+ * `ContainerDefinitions[].PortMappings[].Name` entries.
50562
+ *
50563
+ * Note on `clientAliases[]` shape: each ClientAlias can declare a
50564
+ * `DnsName` (the bare short-name peers connect to, e.g. `orders`) AND
50565
+ * a `Port` (the listening port the alias maps to inside the consumer).
50566
+ * cdkd surfaces both verbatim; the registry / `--add-host` overlay
50567
+ * publishes each `DnsName` as a bare alias pointing at the same IP as
50568
+ * the canonical fqdn.
50569
+ */
50570
+ function extractServiceConnect(raw, task) {
50571
+ if (!raw || typeof raw !== "object") return void 0;
50572
+ const cfg = raw;
50573
+ if (cfg["Enabled"] === false) return void 0;
50574
+ const namespaceName = pickServiceConnectNamespace(cfg["Namespace"]);
50575
+ if (!namespaceName) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Namespace must be a literal string (the Cloud Map namespace name like 'cdkd-local.local'); got ${JSON.stringify(cfg["Namespace"])}. Intrinsic / cross-stack namespace references are not supported in v1.`);
50576
+ const rawServices = cfg["Services"];
50577
+ if (!Array.isArray(rawServices) || rawServices.length === 0) return {
50578
+ namespaceName,
50579
+ services: []
50580
+ };
50581
+ const portByName = /* @__PURE__ */ new Map();
50582
+ for (const c of task.containers) for (const pm of c.portMappings) if (pm.name) portByName.set(pm.name, pm.containerPort);
50583
+ const services = [];
50584
+ for (const entry of rawServices) {
50585
+ if (!entry || typeof entry !== "object") continue;
50586
+ const e = entry;
50587
+ const portName = typeof e["PortName"] === "string" ? e["PortName"] : void 0;
50588
+ if (!portName) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Services[] entry has no PortName: ${JSON.stringify(entry)}. Every Service entry must reference a producer-side PortMappings[].Name.`);
50589
+ const containerPort = portByName.get(portName);
50590
+ if (containerPort === void 0) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Services[].PortName='${portName}' does not match any PortMappings[].Name on the producer TaskDef (available: ${[...portByName.keys()].join(", ") || "(none)"}).`);
50591
+ const clientAliases = [];
50592
+ if (Array.isArray(e["ClientAliases"])) for (const ca of e["ClientAliases"]) {
50593
+ if (!ca || typeof ca !== "object") continue;
50594
+ const caObj = ca;
50595
+ const dnsName = typeof caObj["DnsName"] === "string" ? caObj["DnsName"] : void 0;
50596
+ const aliasEntry = { port: typeof caObj["Port"] === "number" ? caObj["Port"] : containerPort };
50597
+ if (dnsName !== void 0) aliasEntry.dnsName = dnsName;
50598
+ clientAliases.push(aliasEntry);
50599
+ }
50600
+ const discoveryName = clientAliases.find((c) => c.dnsName !== void 0)?.dnsName ?? portName;
50601
+ services.push({
50602
+ portName,
50603
+ containerPort,
50604
+ discoveryName,
50605
+ clientAliases
50606
+ });
50607
+ }
50608
+ return {
50609
+ namespaceName,
50610
+ services
50611
+ };
50612
+ }
50613
+ /**
50614
+ * Parse `ServiceRegistries[]`. Each entry's `RegistryArn` is the
50615
+ * canonical `Fn::GetAtt: [<CloudMapServiceLogicalId>, 'Arn']` shape;
50616
+ * cdkd surfaces the logical id (the AWS-side ARN is irrelevant
50617
+ * locally — the registry is in-process).
50618
+ */
50619
+ function extractServiceRegistries(raw) {
50620
+ if (!Array.isArray(raw)) return [];
50621
+ const out = [];
50622
+ for (const entry of raw) {
50623
+ if (!entry || typeof entry !== "object") continue;
50624
+ const e = entry;
50625
+ const registryArn = e["RegistryArn"];
50626
+ let cloudMapServiceLogicalId;
50627
+ if (typeof registryArn === "string") continue;
50628
+ if (registryArn && typeof registryArn === "object" && !Array.isArray(registryArn)) {
50629
+ const getAtt = registryArn["Fn::GetAtt"];
50630
+ if (Array.isArray(getAtt) && typeof getAtt[0] === "string") cloudMapServiceLogicalId = getAtt[0];
50631
+ }
50632
+ if (!cloudMapServiceLogicalId) continue;
50633
+ const reg = { cloudMapServiceLogicalId };
50634
+ if (typeof e["ContainerName"] === "string") reg.containerName = e["ContainerName"];
50635
+ if (typeof e["ContainerPort"] === "number") reg.containerPort = e["ContainerPort"];
50636
+ out.push(reg);
50637
+ }
50638
+ return out;
50639
+ }
50640
+ function pickServiceConnectNamespace(raw) {
50641
+ if (typeof raw === "string" && raw.length > 0) return raw;
50375
50642
  }
50376
50643
  /**
50377
50644
  * Resolve `Properties.TaskDefinition` to a logical id in the same stack.
@@ -50436,6 +50703,42 @@ function notFoundError(target, stack, resources) {
50436
50703
  return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
50437
50704
  }
50438
50705
 
50706
+ //#endregion
50707
+ //#region src/local/docker-inspect.ts
50708
+ const execFileAsync$1 = promisify(execFile);
50709
+ /**
50710
+ * Phase 3 of #262 (Issue #460) helper — query the docker network IP
50711
+ * assigned to a freshly-started container so the Cloud Map registry
50712
+ * can publish reachable endpoints for peer discovery.
50713
+ *
50714
+ * Returns `undefined` when:
50715
+ * - The container is not found (docker rm raced us).
50716
+ * - The container is not attached to the named network.
50717
+ * - The IP is empty (docker hasn't fully wired the network yet —
50718
+ * caller should retry-or-skip; the helper deliberately does NOT
50719
+ * embed a retry loop to keep the caller in charge of timing).
50720
+ *
50721
+ * Errors propagate verbatim to the caller (`DockerRunnerError`-style
50722
+ * wrapping is the caller's concern; this helper is structurally a
50723
+ * simple inspect wrapper).
50724
+ */
50725
+ async function getContainerNetworkIp(containerId, networkName) {
50726
+ const format = `{{with index .NetworkSettings.Networks "${networkName}"}}{{.IPAddress}}{{end}}`;
50727
+ try {
50728
+ const { stdout } = await execFileAsync$1(getDockerCmd(), [
50729
+ "inspect",
50730
+ "--format",
50731
+ format,
50732
+ containerId
50733
+ ]);
50734
+ const ip = stdout.trim();
50735
+ if (!ip) return void 0;
50736
+ return ip;
50737
+ } catch {
50738
+ return;
50739
+ }
50740
+ }
50741
+
50439
50742
  //#endregion
50440
50743
  //#region src/local/ecs-service-runner.ts
50441
50744
  /**
@@ -50453,10 +50756,20 @@ function notFoundError(target, stack, resources) {
50453
50756
  * stops compounding cleanup work.
50454
50757
  * - Long-running lifecycle (returns only on shutdown).
50455
50758
  *
50759
+ * Phase 3 of #262 (Issue #460) — Cloud Map / Service Connect peer
50760
+ * discovery is wired through `ServiceRunnerOptions.discovery`. When
50761
+ * supplied, every booted replica discovers its docker IP, registers
50762
+ * itself into the shared in-process `CloudMapRegistry`, and emits
50763
+ * `--add-host` flags so consumer containers reach peer services via
50764
+ * the canonical `<discoveryName>.<namespace>` fqdn. Envoy L7 sidecar
50765
+ * emulation (design Layer B) is deferred to a follow-up PR per the
50766
+ * design's §O5 "--no-envoy by default" recommendation.
50767
+ *
50456
50768
  * Deferred to follow-up PRs:
50457
50769
  * - Local load-balancer emulation (LB listener + target-group health
50458
50770
  * check + round-robin) — separate PR per the issue's PR-split.
50459
- * - Service Connect / Cloud Map (tracked in #460).
50771
+ * - Envoy sidecar for Service Connect L7 routing / retries / circuit
50772
+ * breaking (Cloud Map DNS-only mode ships now).
50460
50773
  * - Rolling deployment (`--reload` / `--watch`).
50461
50774
  */
50462
50775
  var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
@@ -50522,7 +50835,8 @@ async function startEcsService(service, options, runState) {
50522
50835
  state: createEcsRunState(),
50523
50836
  restartCount: 0,
50524
50837
  shuttingDown: false,
50525
- inFlightBoot: void 0
50838
+ inFlightBoot: void 0,
50839
+ cloudMapHandles: []
50526
50840
  };
50527
50841
  runState.replicas.push(instance);
50528
50842
  const bootPromise = bootReplica(service, options, instance);
@@ -50604,6 +50918,12 @@ var ServiceController = class {
50604
50918
  await Promise.allSettled(inFlightBoots);
50605
50919
  }
50606
50920
  await Promise.allSettled(this.runState.replicas.map(async (instance) => {
50921
+ if (this.options.discovery) {
50922
+ for (const handle of instance.cloudMapHandles) try {
50923
+ this.options.discovery.registry.unregister(handle);
50924
+ } catch {}
50925
+ instance.cloudMapHandles = [];
50926
+ }
50607
50927
  try {
50608
50928
  await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
50609
50929
  } catch (err) {
@@ -50614,6 +50934,39 @@ var ServiceController = class {
50614
50934
  }
50615
50935
  };
50616
50936
  /**
50937
+ * Build the `--network-alias` map for one service's containers (design
50938
+ * doc § 5 Option A). For every Service Connect entry, attach the
50939
+ * fqdn (`<discoveryName>.<namespaceName>`), the bare discoveryName,
50940
+ * AND every ClientAlias DnsName to the container that owns the
50941
+ * matching PortName. Other containers in the task get NO extra
50942
+ * aliases (only their default `--name`-derived alias from
50943
+ * `buildDockerRunArgs`).
50944
+ *
50945
+ * Aliases per container are de-duplicated so docker doesn't reject
50946
+ * a `--network-alias X` repeated against the same container.
50947
+ *
50948
+ * Returns an empty map when the service has no Service Connect — the
50949
+ * runner's `... .size > 0 ? { networkAliasesByContainer } : {}` guard
50950
+ * short-circuits in that case so backward-compat callers pay no cost.
50951
+ */
50952
+ function buildNetworkAliasesByContainer(service) {
50953
+ const out = /* @__PURE__ */ new Map();
50954
+ const sc = service.serviceConnect;
50955
+ if (!sc) return out;
50956
+ for (const entry of sc.services) {
50957
+ const owner = service.task.containers.find((c) => c.portMappings.some((pm) => pm.name === entry.portName));
50958
+ if (!owner) continue;
50959
+ const aliases = [];
50960
+ aliases.push(entry.discoveryName);
50961
+ aliases.push(`${entry.discoveryName}.${sc.namespaceName}`);
50962
+ for (const ca of entry.clientAliases) if (ca.dnsName) aliases.push(ca.dnsName);
50963
+ const existing = out.get(owner.name) ?? [];
50964
+ for (const a of aliases) if (!existing.includes(a)) existing.push(a);
50965
+ out.set(owner.name, existing);
50966
+ }
50967
+ return out;
50968
+ }
50969
+ /**
50617
50970
  * Boot a single replica. Mutates the supplied `instance.state` so the
50618
50971
  * shutdown path's `cleanupEcsRun(instance.state)` covers every partial
50619
50972
  * side effect. Network names are suffixed with the replica index so
@@ -50622,15 +50975,99 @@ var ServiceController = class {
50622
50975
  async function bootReplica(service, options, instance) {
50623
50976
  const logger = getLogger().child("ecs-service");
50624
50977
  const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
50625
- const perReplicaSubnetOctet = 170 + instance.index % 84;
50978
+ const ownerKeyPrefix = `${service.serviceLogicalId}:r${instance.index}`;
50979
+ const addHostFlags = options.discovery?.registry ? options.discovery.registry.buildAddHostFlags(ownerKeyPrefix) : [];
50980
+ const sharedNetwork = options.discovery?.sharedNetwork;
50981
+ const networkAliasesByContainer = buildNetworkAliasesByContainer(service);
50626
50982
  const perReplicaTaskOptions = {
50627
50983
  ...options.taskOptions,
50628
50984
  cluster: perReplicaCluster,
50629
- subnetOctet: perReplicaSubnetOctet,
50630
- detach: true
50985
+ detach: true,
50986
+ ...sharedNetwork ? { existingNetwork: sharedNetwork } : { subnetOctet: 170 + instance.index % 84 },
50987
+ ...addHostFlags.length > 0 ? { addHostFlags } : {},
50988
+ ...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {}
50631
50989
  };
50632
50990
  logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
50633
50991
  await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
50992
+ if (options.discovery) await publishReplicaToCloudMap(service, instance, options.discovery, ownerKeyPrefix);
50993
+ }
50994
+ /**
50995
+ * After the replica's main container is up, discover its docker
50996
+ * network IP and publish the configured Service Connect + Cloud Map
50997
+ * endpoints into the shared registry. The handles are tracked on the
50998
+ * instance so the shutdown / restart path can unregister symmetrically.
50999
+ *
51000
+ * Errors here are best-effort: docker inspect can fail right after run
51001
+ * (container vanished, network not fully wired), and the registry is
51002
+ * advisory — losing one replica's registration means peer services
51003
+ * can't reach it via the overlay, but it doesn't break that replica's
51004
+ * own work or AWS SDK calls.
51005
+ */
51006
+ async function publishReplicaToCloudMap(service, instance, discovery, ownerKeyPrefix) {
51007
+ const logger = getLogger().child("ecs-service");
51008
+ const networkName = instance.state.network?.networkName;
51009
+ if (!networkName) return;
51010
+ const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
51011
+ if (!essential) return;
51012
+ const started = instance.state.startedContainers.find((c) => c.name === essential.name);
51013
+ if (!started) return;
51014
+ let ip;
51015
+ try {
51016
+ ip = await getContainerNetworkIp(started.id, networkName);
51017
+ } catch (err) {
51018
+ logger.warn(`Replica ${instance.index}: docker inspect failed before Cloud Map publish: ${err instanceof Error ? err.message : String(err)}`);
51019
+ return;
51020
+ }
51021
+ if (!ip) {
51022
+ logger.warn(`Replica ${instance.index}: no docker IP discovered on network ${networkName}; skipping Cloud Map publish for this replica.`);
51023
+ return;
51024
+ }
51025
+ if (service.serviceConnect) {
51026
+ const ns = service.serviceConnect.namespaceName;
51027
+ const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
51028
+ if (index && !index.namespacesByName.has(ns)) logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceConnectConfiguration.Namespace='${ns}' does not match any AWS::ServiceDiscovery::PrivateDnsNamespace declared in stack ${service.stack.stackName}. Publishing under the literal name anyway; peer services using the same literal will still discover this endpoint.`);
51029
+ let i = 0;
51030
+ for (const entry of service.serviceConnect.services) {
51031
+ const ownerKey = `${ownerKeyPrefix}:sc:${i}`;
51032
+ const handle = discovery.registry.register(ns, entry.discoveryName, {
51033
+ ip,
51034
+ port: entry.containerPort,
51035
+ ownerKey
51036
+ });
51037
+ instance.cloudMapHandles.push(handle);
51038
+ for (const alias of entry.clientAliases) if (alias.dnsName) discovery.registry.registerAlias(alias.dnsName, handle.fqdn);
51039
+ i++;
51040
+ }
51041
+ }
51042
+ if (service.serviceRegistries.length > 0) {
51043
+ const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
51044
+ if (!index) {
51045
+ logger.warn(`ECS Service '${service.serviceLogicalId}' declares ServiceRegistries[] but cdkd has no Cloud Map index for stack ${service.stack.stackName}. Skipping registration.`);
51046
+ return;
51047
+ }
51048
+ let j = 0;
51049
+ for (const reg of service.serviceRegistries) {
51050
+ const cm = index.servicesByLogicalId.get(reg.cloudMapServiceLogicalId);
51051
+ if (!cm) {
51052
+ logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceRegistries[].cloudMapServiceLogicalId='${reg.cloudMapServiceLogicalId}' did not resolve to an AWS::ServiceDiscovery::Service in stack ${service.stack.stackName}. Skipping this registration.`);
51053
+ continue;
51054
+ }
51055
+ let port = reg.containerPort;
51056
+ if (port === void 0 && essential.portMappings.length > 0) port = essential.portMappings[0].containerPort;
51057
+ if (port === void 0) {
51058
+ logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceRegistries[] entry for Cloud Map service '${cm.logicalId}' has no resolvable container port; skipping.`);
51059
+ continue;
51060
+ }
51061
+ const ownerKey = `${ownerKeyPrefix}:sr:${j}`;
51062
+ const handle = discovery.registry.register(cm.namespaceName, cm.name, {
51063
+ ip,
51064
+ port,
51065
+ ownerKey
51066
+ });
51067
+ instance.cloudMapHandles.push(handle);
51068
+ j++;
51069
+ }
51070
+ }
50634
51071
  }
50635
51072
  /**
50636
51073
  * Long-running watcher loop for one replica. Polls the essential
@@ -50664,6 +51101,12 @@ async function watchReplica(service, options, instance, runState) {
50664
51101
  logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
50665
51102
  await sleep(delay);
50666
51103
  if (instance.shuttingDown || runState.shuttingDown) return;
51104
+ if (options.discovery) {
51105
+ for (const handle of instance.cloudMapHandles) try {
51106
+ options.discovery.registry.unregister(handle);
51107
+ } catch {}
51108
+ instance.cloudMapHandles = [];
51109
+ }
50667
51110
  try {
50668
51111
  await cleanupEcsRun(instance.state, { keepRunning: false });
50669
51112
  } catch (err) {
@@ -50718,6 +51161,305 @@ function sleep(ms) {
50718
51161
  return sleepImpl(ms);
50719
51162
  }
50720
51163
 
51164
+ //#endregion
51165
+ //#region src/local/cloud-map-registry.ts
51166
+ /**
51167
+ * In-process registry. `register()` is called by the service runner
51168
+ * after each replica's main container boot; `unregister()` is called by
51169
+ * the shutdown / restart paths. `lookupHosts()` produces the
51170
+ * `--add-host` flag list a consumer task's `docker run` injects so
51171
+ * `<discoveryName>.<namespace>` and (when registered as an alias)
51172
+ * bare `<discoveryName>` both resolve to a registered endpoint inside
51173
+ * the consumer container.
51174
+ *
51175
+ * Concurrency note: docker-run callers are not concurrent for the same
51176
+ * replica (the runner boots replicas sequentially), but `lookupHosts()`
51177
+ * MAY be called concurrently with `register()` / `unregister()` of an
51178
+ * unrelated service. The implementation uses synchronous Map mutations
51179
+ * so a stale read returns the previous snapshot — never a partially-
51180
+ * mutated one. No async / mutex needed.
51181
+ */
51182
+ var CloudMapRegistry = class {
51183
+ /** Map<fqdn, RegistryEntry[]> — one per replica registered under that fqdn. */
51184
+ byFqdn = /* @__PURE__ */ new Map();
51185
+ /**
51186
+ * Map<alias, fqdn> — secondary index for ClientAlias short forms.
51187
+ * Multiple aliases can point at the same fqdn; one alias only points
51188
+ * at one fqdn (last write wins, with a warn surfaced by the resolver
51189
+ * when a cross-namespace collision is detected — see design §O6).
51190
+ */
51191
+ aliasIndex = /* @__PURE__ */ new Map();
51192
+ /**
51193
+ * Register a replica's endpoint under `{namespace}/{discoveryName}`.
51194
+ *
51195
+ * @param namespace Cloud Map namespace name, e.g. `cdkd-local.local`.
51196
+ * An empty string is rejected — Cloud Map requires a
51197
+ * named namespace.
51198
+ * @param discoveryName Cloud Map service name, e.g. `orders`. Rejected
51199
+ * when empty.
51200
+ * @param endpoint Reachable endpoint + owner key for symmetric
51201
+ * unregister.
51202
+ * @returns A handle the caller stores for later `unregister(handle)`.
51203
+ */
51204
+ register(namespace, discoveryName, endpoint) {
51205
+ if (!namespace) throw new Error("CloudMapRegistry.register: namespace must be a non-empty string.");
51206
+ if (!discoveryName) throw new Error("CloudMapRegistry.register: discoveryName must be a non-empty string.");
51207
+ const fqdn = `${discoveryName}.${namespace}`;
51208
+ const filtered = (this.byFqdn.get(fqdn) ?? []).filter((e) => e.endpoint.ownerKey !== endpoint.ownerKey);
51209
+ filtered.push({
51210
+ namespace,
51211
+ discoveryName,
51212
+ endpoint
51213
+ });
51214
+ this.byFqdn.set(fqdn, filtered);
51215
+ return {
51216
+ fqdn,
51217
+ ownerKey: endpoint.ownerKey
51218
+ };
51219
+ }
51220
+ /**
51221
+ * Register a bare-name alias (`<discoveryName>` without the namespace
51222
+ * suffix). Cloud Map / Service Connect does NOT auto-create such
51223
+ * aliases — they're populated by `ClientAliases[].DnsName` entries in
51224
+ * the consumer service's `ServiceConnectConfiguration`. Aliases are
51225
+ * scoped per-CLI-invocation and last-write-wins on collision (the
51226
+ * resolver surfaces a `warn` when this happens — see §O6).
51227
+ *
51228
+ * @param alias The bare discovery name (e.g. `orders` for an alias to
51229
+ * `orders.cdkd-local.local`).
51230
+ * @param targetFqdn The full `{discoveryName}.{namespace}` the alias
51231
+ * resolves to.
51232
+ */
51233
+ registerAlias(alias, targetFqdn) {
51234
+ if (!alias) throw new Error("CloudMapRegistry.registerAlias: alias must be a non-empty string.");
51235
+ if (!targetFqdn) throw new Error("CloudMapRegistry.registerAlias: targetFqdn must be a non-empty string.");
51236
+ this.aliasIndex.set(alias, targetFqdn);
51237
+ }
51238
+ /**
51239
+ * Remove a single endpoint registered under the supplied handle.
51240
+ * Idempotent — unknown handles return false without throwing.
51241
+ */
51242
+ unregister(handle) {
51243
+ const entries = this.byFqdn.get(handle.fqdn);
51244
+ if (!entries) return false;
51245
+ const filtered = entries.filter((e) => e.endpoint.ownerKey !== handle.ownerKey);
51246
+ if (filtered.length === entries.length) return false;
51247
+ if (filtered.length === 0) this.byFqdn.delete(handle.fqdn);
51248
+ else this.byFqdn.set(handle.fqdn, filtered);
51249
+ return true;
51250
+ }
51251
+ /**
51252
+ * Drop every registration with the supplied owner key (e.g. teardown
51253
+ * of every replica of a service). Used by the service controller's
51254
+ * shutdown path; complementary to per-replica `unregister`.
51255
+ */
51256
+ unregisterByOwner(ownerKeyPrefix) {
51257
+ let removed = 0;
51258
+ for (const [fqdn, entries] of [...this.byFqdn.entries()]) {
51259
+ const filtered = entries.filter((e) => !e.endpoint.ownerKey.startsWith(ownerKeyPrefix));
51260
+ removed += entries.length - filtered.length;
51261
+ if (filtered.length === 0) this.byFqdn.delete(fqdn);
51262
+ else this.byFqdn.set(fqdn, filtered);
51263
+ }
51264
+ return removed;
51265
+ }
51266
+ /**
51267
+ * Look up every endpoint registered under `{discoveryName}.{namespace}`.
51268
+ * Returns `undefined` when no endpoint exists (which is the consumer
51269
+ * runner's signal to log a warn — the user likely forgot to start the
51270
+ * producer). Returns the underlying array verbatim so callers cannot
51271
+ * accidentally mutate registry state — call `.slice()` if you need a
51272
+ * detachable copy.
51273
+ */
51274
+ lookup(namespace, discoveryName) {
51275
+ const fqdn = `${discoveryName}.${namespace}`;
51276
+ const entries = this.byFqdn.get(fqdn);
51277
+ if (!entries || entries.length === 0) return void 0;
51278
+ return entries.map((e) => e.endpoint);
51279
+ }
51280
+ /**
51281
+ * Resolve a bare alias to its target endpoints. Returns the same
51282
+ * shape as `lookup` for the alias's target fqdn, or `undefined` when
51283
+ * no such alias exists (which is distinct from "alias known but
51284
+ * target has no live replica" — caller may want different warns).
51285
+ */
51286
+ lookupAlias(alias) {
51287
+ const fqdn = this.aliasIndex.get(alias);
51288
+ if (!fqdn) return void 0;
51289
+ const entries = this.byFqdn.get(fqdn);
51290
+ if (!entries || entries.length === 0) return void 0;
51291
+ return entries.map((e) => e.endpoint);
51292
+ }
51293
+ /**
51294
+ * Build the `--add-host` flag list for a consumer container's
51295
+ * `docker run`. Returns each unique `(hostname, ip)` pair as a flat
51296
+ * `['--add-host', 'name:ip', ...]` array consumable verbatim by the
51297
+ * docker-runner. Includes both the fqdn AND every alias mapped to it.
51298
+ *
51299
+ * Multiple replicas per fqdn cannot be expressed as multiple
51300
+ * `--add-host` entries with the same name (docker's resolver takes
51301
+ * the *last* entry on duplicate keys per `getent hosts` semantics),
51302
+ * so this returns the **first** registered endpoint per fqdn /
51303
+ * alias. Multi-instance round-robin via the static `--add-host`
51304
+ * shape is structurally impossible; a true rotation requires the
51305
+ * DNS-sidecar option (deferred). Documented as a v1 limitation.
51306
+ */
51307
+ buildAddHostFlags(excludeOwnerKeyPrefix) {
51308
+ const flags = [];
51309
+ const seen = /* @__PURE__ */ new Set();
51310
+ for (const [fqdn, entries] of this.byFqdn.entries()) {
51311
+ const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
51312
+ if (!candidate) continue;
51313
+ if (seen.has(fqdn)) continue;
51314
+ flags.push("--add-host", `${fqdn}:${candidate.endpoint.ip}`);
51315
+ seen.add(fqdn);
51316
+ }
51317
+ for (const [alias, targetFqdn] of this.aliasIndex.entries()) {
51318
+ if (seen.has(alias)) continue;
51319
+ const entries = this.byFqdn.get(targetFqdn);
51320
+ if (!entries || entries.length === 0) continue;
51321
+ const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
51322
+ if (!candidate) continue;
51323
+ flags.push("--add-host", `${alias}:${candidate.endpoint.ip}`);
51324
+ seen.add(alias);
51325
+ }
51326
+ return flags;
51327
+ }
51328
+ /**
51329
+ * Diagnostic snapshot used by the boot banner / test assertions.
51330
+ * Stable iteration order (insertion-order is preserved by JS Maps).
51331
+ */
51332
+ list() {
51333
+ const out = [];
51334
+ for (const [, entries] of this.byFqdn.entries()) {
51335
+ if (entries.length === 0) continue;
51336
+ const first = entries[0];
51337
+ out.push({
51338
+ namespace: first.namespace,
51339
+ discoveryName: first.discoveryName,
51340
+ endpoints: entries.map((e) => e.endpoint),
51341
+ isAlias: false
51342
+ });
51343
+ }
51344
+ for (const [alias, fqdn] of this.aliasIndex.entries()) {
51345
+ const entries = this.byFqdn.get(fqdn);
51346
+ if (!entries || entries.length === 0) continue;
51347
+ out.push({
51348
+ namespace: "",
51349
+ discoveryName: alias,
51350
+ endpoints: entries.map((e) => e.endpoint),
51351
+ isAlias: true
51352
+ });
51353
+ }
51354
+ return out;
51355
+ }
51356
+ /** True when no endpoint is registered. Used by the runner to short-circuit. */
51357
+ isEmpty() {
51358
+ return this.byFqdn.size === 0;
51359
+ }
51360
+ };
51361
+
51362
+ //#endregion
51363
+ //#region src/local/cloud-map-resolver.ts
51364
+ /**
51365
+ * Build the `CloudMapIndex` for one stack's template. Empty index when
51366
+ * the stack declares no `AWS::ServiceDiscovery::*` resources.
51367
+ *
51368
+ * Hard-reject errors (throw `EcsTaskResolutionError`):
51369
+ * - `AWS::ServiceDiscovery::PublicDnsNamespace` — defeats "local" semantics.
51370
+ * - `AWS::ServiceDiscovery::HttpNamespace` — DiscoverInstances-only,
51371
+ * no DNS, would require shimming the AWS SDK inside every container.
51372
+ * - An `AWS::ServiceDiscovery::Service` with a `NamespaceId` that
51373
+ * doesn't resolve to a same-stack `PrivateDnsNamespace`.
51374
+ */
51375
+ function buildCloudMapIndex(stack) {
51376
+ const namespacesByLogicalId = /* @__PURE__ */ new Map();
51377
+ const namespacesByName = /* @__PURE__ */ new Map();
51378
+ const servicesByLogicalId = /* @__PURE__ */ new Map();
51379
+ const warnings = [];
51380
+ const resources = stack.template.Resources ?? {};
51381
+ for (const [logicalId, resource] of Object.entries(resources)) {
51382
+ if (resource.Type === "AWS::ServiceDiscovery::PublicDnsNamespace") throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::PublicDnsNamespace '${logicalId}' is not supported by local emulation — public DNS defeats the "local" point. Use a PrivateDnsNamespace for cdkd local start-service.`);
51383
+ if (resource.Type === "AWS::ServiceDiscovery::HttpNamespace") throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::HttpNamespace '${logicalId}' is not supported by local emulation — HttpNamespace uses the AWS Cloud Map DiscoverInstances API directly (no DNS), which would require shimming the AWS SDK inside every container. Use a PrivateDnsNamespace + DnsConfig instead.`);
51384
+ if (resource.Type !== "AWS::ServiceDiscovery::PrivateDnsNamespace") continue;
51385
+ const props = resource.Properties ?? {};
51386
+ const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
51387
+ if (!name) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: PrivateDnsNamespace '${logicalId}' has no literal Name property. Intrinsic-valued names are not supported (cross-stack / dynamic namespace names require deploy-state resolution which is out of scope for v1).`);
51388
+ const entry = {
51389
+ logicalId,
51390
+ name
51391
+ };
51392
+ namespacesByLogicalId.set(logicalId, entry);
51393
+ if (namespacesByName.has(name)) warnings.push(`Stack ${stack.stackName}: two PrivateDnsNamespace resources share Name='${name}' ('${namespacesByName.get(name).logicalId}' and '${logicalId}'). Local emulation routes registrations to the first; the second will silently shadow.`);
51394
+ else namespacesByName.set(name, entry);
51395
+ }
51396
+ for (const [logicalId, resource] of Object.entries(resources)) {
51397
+ if (resource.Type !== "AWS::ServiceDiscovery::Service") continue;
51398
+ const props = resource.Properties ?? {};
51399
+ const namespaceLogicalId = resolveNamespaceIdRef(props["NamespaceId"] ?? props["DnsConfig"]?.["NamespaceId"], stack.stackName, logicalId);
51400
+ const ns = namespacesByLogicalId.get(namespaceLogicalId);
51401
+ if (!ns) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::Service '${logicalId}' references NamespaceId '${namespaceLogicalId}' but no PrivateDnsNamespace with that logical id exists in this stack. Cross-stack Cloud Map namespaces are not supported in v1.`);
51402
+ const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
51403
+ if (!name) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::Service '${logicalId}' has no literal Name property. Intrinsic-valued names are not supported in v1.`);
51404
+ const dnsRecords = extractDnsRecords(props);
51405
+ servicesByLogicalId.set(logicalId, {
51406
+ logicalId,
51407
+ namespaceLogicalId,
51408
+ namespaceName: ns.name,
51409
+ name,
51410
+ dnsRecords
51411
+ });
51412
+ }
51413
+ return {
51414
+ namespacesByLogicalId,
51415
+ namespacesByName,
51416
+ servicesByLogicalId,
51417
+ warnings
51418
+ };
51419
+ }
51420
+ /**
51421
+ * Resolve `NamespaceId` to the parent's logical id. CDK 2.x synthesizes
51422
+ * this as `{Fn::GetAtt: ['<NsLogicalId>', 'Id']}` (verified via real
51423
+ * `cdk synth` on 2026-05-22). `Ref` is also accepted defensively
51424
+ * (returns the namespace's physical id, but inside one synth template
51425
+ * the Ref target IS the logical id we want).
51426
+ *
51427
+ * Cross-stack / intrinsic shapes that we cannot resolve at synth time
51428
+ * are hard-rejected — cdkd would otherwise silently route to no
51429
+ * namespace and the consumer would get an unhelpful "DNS lookup failed"
51430
+ * at runtime.
51431
+ */
51432
+ function resolveNamespaceIdRef(raw, stackName, serviceLogicalId) {
51433
+ if (typeof raw === "string") return raw;
51434
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
51435
+ const obj = raw;
51436
+ if (typeof obj["Ref"] === "string") return obj["Ref"];
51437
+ const getAtt = obj["Fn::GetAtt"];
51438
+ if (Array.isArray(getAtt) && typeof getAtt[0] === "string") return getAtt[0];
51439
+ }
51440
+ throw new EcsTaskResolutionError(`Stack ${stackName}: AWS::ServiceDiscovery::Service '${serviceLogicalId}' has an unsupported NamespaceId reference shape: ${JSON.stringify(raw)}. Accepted shapes are {Fn::GetAtt: [<NsLogicalId>, 'Id']} or {Ref: <NsLogicalId>} pointing at a same-stack PrivateDnsNamespace.`);
51441
+ }
51442
+ function extractDnsRecords(serviceProps) {
51443
+ const dnsConfig = serviceProps["DnsConfig"];
51444
+ if (!dnsConfig || typeof dnsConfig !== "object") return [];
51445
+ const records = dnsConfig["DnsRecords"];
51446
+ if (!Array.isArray(records)) return [];
51447
+ const out = [];
51448
+ for (const r of records) {
51449
+ if (!r || typeof r !== "object") continue;
51450
+ const obj = r;
51451
+ const type = obj["Type"];
51452
+ if (type !== "A" && type !== "SRV") continue;
51453
+ const ttl = obj["TTL"];
51454
+ const ttlSeconds = typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? Math.floor(ttl) : typeof ttl === "string" && /^\d+$/.test(ttl) ? parseInt(ttl, 10) : 60;
51455
+ out.push({
51456
+ type,
51457
+ ttlSeconds
51458
+ });
51459
+ }
51460
+ return out;
51461
+ }
51462
+
50721
51463
  //#endregion
50722
51464
  //#region src/cli/commands/local-start-service.ts
50723
51465
  /**
@@ -50732,17 +51474,34 @@ function sleep(ms) {
50732
51474
  * - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
50733
51475
  * - Service Connect / Cloud Map — tracked separately in #460.
50734
51476
  */
50735
- async function localStartServiceCommand(target, options) {
51477
+ async function localStartServiceCommand(targets, options) {
50736
51478
  const logger = getLogger();
50737
51479
  if (options.verbose) logger.setLevel("debug");
50738
51480
  warnIfDeprecatedRegion(options);
50739
- const runState = createServiceRunState();
51481
+ if (!targets || targets.length === 0) throw new LocalStartServiceError("cdkd local start-service requires at least one <target>. Pass one or more service paths like 'Stack/Orders' 'Stack/Frontend'.");
51482
+ const perTarget = targets.map((t) => ({
51483
+ target: t,
51484
+ runState: createServiceRunState()
51485
+ }));
50740
51486
  let sigintHandler;
50741
51487
  let sigintCount = 0;
50742
- let controller;
51488
+ let sharedNetwork;
50743
51489
  const cleanup = singleFlight(async () => {
50744
- if (controller) await controller.shutdown();
50745
- else await Promise.allSettled(runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
51490
+ await Promise.allSettled(perTarget.map(async (pt) => {
51491
+ if (pt.controller) await pt.controller.shutdown();
51492
+ else {
51493
+ await Promise.allSettled(pt.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0));
51494
+ await Promise.allSettled(pt.runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
51495
+ }
51496
+ }));
51497
+ if (sharedNetwork) {
51498
+ try {
51499
+ await destroyTaskNetwork(sharedNetwork);
51500
+ } catch (err) {
51501
+ getLogger().warn(`shared service network teardown failed: ${err instanceof Error ? err.message : String(err)}`);
51502
+ }
51503
+ sharedNetwork = void 0;
51504
+ }
50746
51505
  }, (err) => getLogger().warn(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
50747
51506
  try {
50748
51507
  await applyRoleArnIfSet({
@@ -50765,72 +51524,42 @@ async function localStartServiceCommand(target, options) {
50765
51524
  ...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
50766
51525
  };
50767
51526
  const { stacks } = await synthesizer.synthesize(synthOpts);
50768
- const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
50769
- const service = resolveEcsServiceTarget(target, stacks, imageContext);
50770
- logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
50771
- const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
50772
- if (options.fromState && taskNeeds.needsCrossStackResolver) {
50773
- const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
50774
- const built = await buildCrossStackResolver(consumerRegion, {
50775
- ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
50776
- statePrefix: options.statePrefix,
50777
- ...options.region !== void 0 && { region: options.region },
50778
- ...options.profile !== void 0 && { profile: options.profile }
51527
+ const cloudMapIndexByStack = /* @__PURE__ */ new Map();
51528
+ for (const stack of stacks) {
51529
+ const index = buildCloudMapIndex(stack);
51530
+ cloudMapIndexByStack.set(stack.stackName, index);
51531
+ for (const w of index.warnings) logger.warn(w);
51532
+ }
51533
+ const registry = new CloudMapRegistry();
51534
+ try {
51535
+ sharedNetwork = await createSharedSvcNetwork({
51536
+ prefix: options.cluster,
51537
+ skipPull: options.pull === false,
51538
+ cluster: options.cluster
50779
51539
  });
50780
- if (built) try {
50781
- const subContext = {
50782
- resources: imageContext?.stateResources ?? {},
50783
- ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
50784
- consumerRegion,
50785
- crossStackResolver: built.resolver
50786
- };
50787
- await applyCrossStackResolverToTask(service.task, subContext);
50788
- } finally {
50789
- built.dispose();
50790
- }
50791
- } else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
51540
+ } catch (err) {
51541
+ throw new LocalStartServiceError(`Failed to create shared service network: ${err instanceof Error ? err.message : String(err)}`);
51542
+ }
51543
+ const discovery = {
51544
+ registry,
51545
+ cloudMapIndexByStack,
51546
+ sharedNetwork
51547
+ };
50792
51548
  sigintHandler = () => {
50793
51549
  sigintCount += 1;
50794
51550
  if (sigintCount >= 2) {
50795
51551
  process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
50796
51552
  process.exit(130);
50797
51553
  }
50798
- logger.info("Stopping service...");
51554
+ logger.info("Stopping service(s)...");
50799
51555
  cleanup().then(() => process.exit(130));
50800
51556
  };
50801
51557
  process.on("SIGINT", sigintHandler);
50802
51558
  process.on("SIGTERM", sigintHandler);
50803
- let assumedCredentials;
50804
- let resolvedRoleArn;
50805
- if (options.assumeTaskRole === true) {
50806
- 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>");
50807
- resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
50808
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
50809
- } else if (typeof options.assumeTaskRole === "string") {
50810
- resolvedRoleArn = options.assumeTaskRole;
50811
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
50812
- }
50813
- const envOverrides = readEnvOverridesFile$1(options.envVars);
50814
- const taskOpts = {
50815
- cluster: options.cluster,
50816
- containerHost: options.containerHost,
50817
- skipPull: options.pull === false,
50818
- keepRunning: false,
50819
- detach: true
50820
- };
50821
- if (envOverrides) taskOpts.envOverrides = envOverrides;
50822
- if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
50823
- if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
50824
- if (options.platform) taskOpts.platformOverride = options.platform;
50825
- if (options.region) taskOpts.region = options.region;
50826
- if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
50827
- controller = await startEcsService(service, {
50828
- maxTasks: options.maxTasks,
50829
- restartPolicy: options.restartPolicy,
50830
- taskOptions: taskOpts
50831
- }, runState);
50832
- logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
50833
- await controller.waitForShutdown();
51559
+ for (const pt of perTarget) pt.controller = await bootOneTarget(pt.target, pt.runState, stacks, options, discovery);
51560
+ const summary = perTarget.map((pt) => `${pt.controller.service.serviceName} (${pt.controller.activeReplicaCount()} replica(s))`).join(", ");
51561
+ logger.info(`Service(s) running: ${summary}. Press ^C to shut down.`);
51562
+ await Promise.all(perTarget.map((pt) => pt.controller.waitForShutdown()));
50834
51563
  } finally {
50835
51564
  if (sigintHandler) {
50836
51565
  process.off("SIGINT", sigintHandler);
@@ -50839,6 +51568,72 @@ async function localStartServiceCommand(target, options) {
50839
51568
  await cleanup();
50840
51569
  }
50841
51570
  }
51571
+ /**
51572
+ * Boot one target. Extracted from the loop so each per-service block
51573
+ * (image context, cross-stack resolver, task-role credentials, runner
51574
+ * options) is scoped locally. Returns the started controller for the
51575
+ * outer code to wait + tear down.
51576
+ */
51577
+ async function bootOneTarget(target, runState, stacks, options, discovery) {
51578
+ const logger = getLogger();
51579
+ const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
51580
+ const service = resolveEcsServiceTarget(target, stacks, imageContext);
51581
+ logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
51582
+ for (const w of service.warnings) logger.warn(w);
51583
+ if (service.serviceConnect) logger.info(`Service Connect: namespace='${service.serviceConnect.namespaceName}', ${service.serviceConnect.services.length} service(s) registered for peer discovery.`);
51584
+ if (service.serviceRegistries.length > 0) logger.info(`Cloud Map: ${service.serviceRegistries.length} ServiceRegistry binding(s).`);
51585
+ const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
51586
+ if (options.fromState && taskNeeds.needsCrossStackResolver) {
51587
+ const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
51588
+ const built = await buildCrossStackResolver(consumerRegion, {
51589
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
51590
+ statePrefix: options.statePrefix,
51591
+ ...options.region !== void 0 && { region: options.region },
51592
+ ...options.profile !== void 0 && { profile: options.profile }
51593
+ });
51594
+ if (built) try {
51595
+ const subContext = {
51596
+ resources: imageContext?.stateResources ?? {},
51597
+ ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
51598
+ consumerRegion,
51599
+ crossStackResolver: built.resolver
51600
+ };
51601
+ await applyCrossStackResolverToTask(service.task, subContext);
51602
+ } finally {
51603
+ built.dispose();
51604
+ }
51605
+ } else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
51606
+ let assumedCredentials;
51607
+ let resolvedRoleArn;
51608
+ if (options.assumeTaskRole === true) {
51609
+ if (!service.task.taskRoleArn) throw new LocalStartServiceError(`--assume-task-role passed without an ARN but service '${service.serviceLogicalId}' has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>`);
51610
+ resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
51611
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
51612
+ } else if (typeof options.assumeTaskRole === "string") {
51613
+ resolvedRoleArn = options.assumeTaskRole;
51614
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
51615
+ }
51616
+ const envOverrides = readEnvOverridesFile$1(options.envVars);
51617
+ const taskOpts = {
51618
+ cluster: options.cluster,
51619
+ containerHost: options.containerHost,
51620
+ skipPull: options.pull === false,
51621
+ keepRunning: false,
51622
+ detach: true
51623
+ };
51624
+ if (envOverrides) taskOpts.envOverrides = envOverrides;
51625
+ if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
51626
+ if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
51627
+ if (options.platform) taskOpts.platformOverride = options.platform;
51628
+ if (options.region) taskOpts.region = options.region;
51629
+ if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
51630
+ return startEcsService(service, {
51631
+ maxTasks: options.maxTasks,
51632
+ restartPolicy: options.restartPolicy,
51633
+ taskOptions: taskOpts,
51634
+ discovery
51635
+ }, runState);
51636
+ }
50842
51637
  async function resolvePlaceholderAccount(arn, region) {
50843
51638
  if (!arn.includes("${AWS::AccountId}")) return arn;
50844
51639
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
@@ -50981,7 +51776,7 @@ function parseRestartPolicy(raw) {
50981
51776
  throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
50982
51777
  }
50983
51778
  function createLocalStartServiceCommand() {
50984
- const cmd = new Command("start-service").description("Run an AWS::ECS::Service locally as a long-running emulator. Spins up DesiredCount task replicas (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Target accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::Service to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed task role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localStartServiceCommand));
51779
+ const cmd = new Command("start-service").description("Run one or more AWS::ECS::Service resources locally as a long-running emulator. Spins up DesiredCount task replicas per service (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Each <target> accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix. When two or more <target>s are supplied, every service is booted into a shared Cloud Map / Service Connect registry so peer services discover each other via docker --add-host overlay (Issue #460).").argument("<targets...>", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ECS::Service resources to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed task role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localStartServiceCommand));
50985
51780
  [
50986
51781
  ...commonOptions,
50987
51782
  ...appOptions,
@@ -54164,7 +54959,7 @@ function reorderArgs(argv) {
54164
54959
  */
54165
54960
  async function main() {
54166
54961
  const program = new Command();
54167
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.137.3");
54962
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.139.0");
54168
54963
  program.addCommand(createBootstrapCommand());
54169
54964
  program.addCommand(createSynthCommand());
54170
54965
  program.addCommand(createListCommand());