@go-to-k/cdkd 0.138.0 → 0.140.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { _ as withSkipPrefix, a as runDockerStreaming, c as getLogger, d as getLiveRenderer, f as PATTERN_B_NAME_PROPERTIES, g as generateResourceNameWithFallback, h as generateResourceName, i as runDockerForeground, n as formatDockerLoginError, p as PATTERN_B_RESOURCE_TYPES, r as getDockerCmd, u as runStackBuffered, v as withStackName } from "./docker-cmd-EtWSTAje.js";
3
- import { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as MIGRATE_TMP_PREFIX, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, J as AssemblyReader, K as findLargeInlineResources, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, Q as CdkdError, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as CFN_TEMPLATE_BODY_LIMIT, V as resolveStateBucketWithDefaultAndSource, W as CFN_TEMPLATE_URL_LIMIT, X as resolveBucketRegion, _ as normalizeAwsTagsToCfn, _t as normalizeAwsError, a as withRetry, at as MissingCdkCliError, b as CloudControlProvider, c as cyan, ct as ResourceTimeoutError, d as red, dt as StackHasActiveImportsError, f as yellow, ft as StackTerminationProtectionError, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, j as stringifyValue, k as shouldRetainResource, l as gray, lt as ResourceUpdateNotSupportedError, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as LocalMigrateError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as PartialFailureError, p as IAMRoleProvider, q as uploadCfnTemplate, r as DeployEngine, rt as LocalStartServiceError, s as bold, st as ProvisioningError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as LocalInvokeBuildError, u as green, ut as RouteDiscoveryError, v as resolveExplicitPhysicalId, vt as withErrorHandling, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-DZYchTh6.js";
3
+ import { A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as MIGRATE_TMP_PREFIX, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, J as AssemblyReader, K as findLargeInlineResources, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, Q as CdkdError, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as CFN_TEMPLATE_BODY_LIMIT, V as resolveStateBucketWithDefaultAndSource, W as CFN_TEMPLATE_URL_LIMIT, X as resolveBucketRegion, _ as normalizeAwsTagsToCfn, _t as normalizeAwsError, a as withRetry, at as MissingCdkCliError, b as CloudControlProvider, c as cyan, ct as ResourceTimeoutError, d as red, dt as StackHasActiveImportsError, f as yellow, ft as StackTerminationProtectionError, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, j as stringifyValue, k as shouldRetainResource, l as gray, lt as ResourceUpdateNotSupportedError, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as LocalMigrateError, o as IMPLICIT_DELETE_DEPENDENCIES, ot as PartialFailureError, p as IAMRoleProvider, q as uploadCfnTemplate, r as DeployEngine, rt as LocalStartServiceError, s as bold, st as ProvisioningError, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as LocalInvokeBuildError, u as green, ut as RouteDiscoveryError, v as resolveExplicitPhysicalId, vt as withErrorHandling, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-wdfQVR__.js";
4
4
  import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
5
5
  import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
6
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -50,7 +50,7 @@ import { parse, print } from "graphql";
50
50
  import { CreateConnectionCommand, CreateCrawlerCommand, CreateDatabaseCommand, CreateJobCommand, CreateSecurityConfigurationCommand, CreateTableCommand as CreateTableCommand$1, CreateTriggerCommand, CreateWorkflowCommand, DeleteConnectionCommand, DeleteCrawlerCommand, DeleteDatabaseCommand, DeleteJobCommand, DeleteSecurityConfigurationCommand, DeleteTableCommand as DeleteTableCommand$1, DeleteTriggerCommand, DeleteWorkflowCommand, EntityNotFoundException, GetConnectionCommand, GetCrawlerCommand, GetDatabaseCommand, GetDatabasesCommand, GetJobCommand, GetSecurityConfigurationCommand, GetSecurityConfigurationsCommand, GetTableCommand, GetTablesCommand, GetTagsCommand, GetTriggerCommand, GetWorkflowCommand, GlueClient, ListWorkflowsCommand, StartCrawlerScheduleCommand, StartTriggerCommand, StopCrawlerScheduleCommand, StopTriggerCommand, UpdateConnectionCommand, UpdateCrawlerCommand, UpdateDatabaseCommand, UpdateJobCommand, UpdateTableCommand as UpdateTableCommand$1, UpdateTriggerCommand, UpdateWorkflowCommand } from "@aws-sdk/client-glue";
51
51
  import { AddTagsToStreamCommand, CreateStreamCommand, DecreaseStreamRetentionPeriodCommand, DeleteStreamCommand, DeregisterStreamConsumerCommand, DescribeStreamCommand, DescribeStreamConsumerCommand, IncreaseStreamRetentionPeriodCommand, KinesisClient, ListStreamsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$17, ListTagsForStreamCommand, RegisterStreamConsumerCommand, RemoveTagsFromStreamCommand, ResourceNotFoundException as ResourceNotFoundException$7, StartStreamEncryptionCommand, StopStreamEncryptionCommand, TagResourceCommand as TagResourceCommand$14, UntagResourceCommand as UntagResourceCommand$13, UpdateShardCountCommand } from "@aws-sdk/client-kinesis";
52
52
  import { AccessPointNotFound, CreateAccessPointCommand, CreateFileSystemCommand, CreateMountTargetCommand, DeleteAccessPointCommand, DeleteFileSystemCommand, DeleteMountTargetCommand, DescribeAccessPointsCommand, DescribeBackupPolicyCommand, DescribeFileSystemsCommand, DescribeLifecycleConfigurationCommand, DescribeMountTargetSecurityGroupsCommand, DescribeMountTargetsCommand, EFSClient, FileSystemNotFound, ModifyMountTargetSecurityGroupsCommand, MountTargetNotFound, UpdateFileSystemCommand } from "@aws-sdk/client-efs";
53
- import { CreateDeliveryStreamCommand, DeleteDeliveryStreamCommand, DescribeDeliveryStreamCommand, FirehoseClient, ListDeliveryStreamsCommand, ListTagsForDeliveryStreamCommand, ResourceNotFoundException as ResourceNotFoundException$8 } from "@aws-sdk/client-firehose";
53
+ import { CreateDeliveryStreamCommand, DeleteDeliveryStreamCommand, DescribeDeliveryStreamCommand, FirehoseClient, ListDeliveryStreamsCommand, ListTagsForDeliveryStreamCommand, ResourceNotFoundException as ResourceNotFoundException$8, TagDeliveryStreamCommand, UntagDeliveryStreamCommand, UpdateDestinationCommand } from "@aws-sdk/client-firehose";
54
54
  import { AddTagsCommand as AddTagsCommand$1, CloudTrailClient, CreateTrailCommand, DeleteTrailCommand, GetEventSelectorsCommand, GetInsightSelectorsCommand, GetTrailCommand, GetTrailStatusCommand, ListTagsCommand as ListTagsCommand$1, ListTrailsCommand, PutEventSelectorsCommand, PutInsightSelectorsCommand, RemoveTagsCommand as RemoveTagsCommand$1, StartLoggingCommand, StopLoggingCommand, TrailNotFoundException, UpdateTrailCommand } from "@aws-sdk/client-cloudtrail";
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";
@@ -27575,19 +27575,133 @@ var FirehoseProvider = class {
27575
27575
  }
27576
27576
  }
27577
27577
  /**
27578
- * Firehose delivery streams are treated as immutable by cdkd. AWS DOES
27579
- * provide an `UpdateDestination` API, but the per-destination shape
27580
- * matrix (Extended S3 / Redshift / OpenSearch / Splunk / HttpEndpoint /
27581
- * Iceberg / etc.) is deep enough that the deploy engine's immutable-
27582
- * property replacement path covers the common cases more reliably.
27583
- * Treating the type as fully immutable for `cdkd drift --revert` is
27584
- * the conservative choice; users who want in-place destination updates
27585
- * should re-deploy with `cdkd deploy --replace` so the new shape is
27586
- * applied via a fresh `CreateDeliveryStream`. Tracked as a follow-up
27587
- * to issue (#443).
27578
+ * Apply in-place updates to a Firehose delivery stream via the per-shape
27579
+ * AWS APIs (#477):
27580
+ *
27581
+ * - **Tags** (`TagDeliveryStream` / `UntagDeliveryStream`) always
27582
+ * supported; runs first since it is destination-independent.
27583
+ * - **`ExtendedS3DestinationConfiguration`** (`UpdateDestination` with
27584
+ * an `ExtendedS3DestinationUpdate` payload) recovers
27585
+ * `CurrentDeliveryStreamVersionId` + `DestinationId` from a
27586
+ * `DescribeDeliveryStream` call, then applies the diff. Only fires
27587
+ * when the destination shape actually differs.
27588
+ *
27589
+ * Other destination types (`S3DestinationConfiguration`,
27590
+ * `HttpEndpointDestinationConfiguration`, `RedshiftDestinationConfiguration`,
27591
+ * `ElasticsearchDestinationConfiguration`,
27592
+ * `AmazonopensearchserviceDestinationConfiguration`,
27593
+ * `SplunkDestinationConfiguration`,
27594
+ * `AmazonOpenSearchServerlessDestinationConfiguration`) stay rejected
27595
+ * with a tightened error message naming the AWS API. Each one is a
27596
+ * follow-up to (#477) — AWS provides `UpdateDestination` for them too,
27597
+ * but the per-shape reverse-mappers are deep and each warrants its own
27598
+ * focused PR. Re-deploy with `cdkd deploy --replace` until they land.
27599
+ *
27600
+ * Destination-type SWITCHES (e.g. ExtendedS3 → Redshift) are immutable
27601
+ * on AWS; cdkd surfaces `ResourceUpdateNotSupportedError` so the caller
27602
+ * can `cdkd deploy --replace`.
27588
27603
  */
27589
- update(logicalId, _physicalId, resourceType, _properties, _previousProperties) {
27590
- return Promise.reject(new ResourceUpdateNotSupportedError(resourceType, logicalId, "AWS::KinesisFirehose::DeliveryStream in-place update is not implemented in cdkd; AWS provides UpdateDestination but the per-destination shape matrix is large. Re-deploy with cdkd deploy --replace, or destroy + redeploy the stack."));
27604
+ async update(logicalId, physicalId, resourceType, properties, previousProperties) {
27605
+ this.logger.debug(`Updating Firehose delivery stream ${logicalId}: ${physicalId}`);
27606
+ const destKey = this.findDestinationKey(properties);
27607
+ const prevDestKey = this.findDestinationKey(previousProperties);
27608
+ if (destKey && prevDestKey && destKey !== prevDestKey) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `Switching Firehose destination type from '${prevDestKey}' to '${destKey}' is not supported in-place — AWS UpdateDestination requires the same destination type as the original CreateDeliveryStream. Re-deploy with cdkd deploy --replace.`);
27609
+ const activeDest = destKey ?? prevDestKey;
27610
+ if (activeDest && activeDest !== "ExtendedS3DestinationConfiguration") {
27611
+ const nextDest = properties[activeDest] ?? {};
27612
+ const prevDest = previousProperties[activeDest] ?? {};
27613
+ if (JSON.stringify(nextDest) !== JSON.stringify(prevDest)) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, `In-place update for '${activeDest}' on AWS::KinesisFirehose::DeliveryStream is not yet implemented in cdkd (AWS exposes UpdateDestination for it; the per-shape reverse-mapper is a follow-up to #477). Re-deploy with cdkd deploy --replace.`);
27614
+ }
27615
+ await this.applyTagsDiff(physicalId, properties["Tags"], previousProperties["Tags"]);
27616
+ if (activeDest === "ExtendedS3DestinationConfiguration") {
27617
+ const nextDest = properties[activeDest] ?? {};
27618
+ const prevDest = previousProperties[activeDest] ?? {};
27619
+ if (JSON.stringify(nextDest) !== JSON.stringify(prevDest)) await this.applyExtendedS3DestinationUpdate(physicalId, nextDest);
27620
+ }
27621
+ const desc = (await this.getClient().send(new DescribeDeliveryStreamCommand({ DeliveryStreamName: physicalId }))).DeliveryStreamDescription;
27622
+ return {
27623
+ physicalId,
27624
+ wasReplaced: false,
27625
+ attributes: { ...desc?.DeliveryStreamARN !== void 0 && { Arn: desc.DeliveryStreamARN } }
27626
+ };
27627
+ }
27628
+ /**
27629
+ * Recover `CurrentDeliveryStreamVersionId` + `DestinationId` from
27630
+ * `DescribeDeliveryStream` and issue `UpdateDestination` with the new
27631
+ * ExtendedS3 shape. The reverse-mapper at
27632
+ * {@link mapExtendedS3ConfigToUpdate} produces the
27633
+ * `ExtendedS3DestinationUpdate` payload (CFn property names →
27634
+ * SDK camelCase, omitting `Prefix` / `BufferingHints` / etc. when the
27635
+ * source is undefined so an empty diff doesn't clear AWS-side fields).
27636
+ */
27637
+ async applyExtendedS3DestinationUpdate(physicalId, nextConfig) {
27638
+ const desc = (await this.getClient().send(new DescribeDeliveryStreamCommand({ DeliveryStreamName: physicalId }))).DeliveryStreamDescription;
27639
+ const currentVersionId = desc?.VersionId;
27640
+ const destinationId = (desc?.Destinations?.[0])?.DestinationId;
27641
+ if (!currentVersionId || !destinationId) throw new ProvisioningError(`DescribeDeliveryStream for ${physicalId} did not return VersionId or DestinationId; UpdateDestination cannot proceed.`, "AWS::KinesisFirehose::DeliveryStream", physicalId);
27642
+ await this.getClient().send(new UpdateDestinationCommand({
27643
+ DeliveryStreamName: physicalId,
27644
+ CurrentDeliveryStreamVersionId: currentVersionId,
27645
+ DestinationId: destinationId,
27646
+ ExtendedS3DestinationUpdate: this.mapExtendedS3ConfigToUpdate(nextConfig)
27647
+ }));
27648
+ }
27649
+ /**
27650
+ * Diff and apply changes to a Firehose delivery stream's Tags via the
27651
+ * `TagDeliveryStream` / `UntagDeliveryStream` AWS APIs.
27652
+ *
27653
+ * Tags shape is `[{Key, Value}]`. Removed keys go through
27654
+ * `UntagDeliveryStream` (key-only); added / modified entries go through
27655
+ * `TagDeliveryStream` (full {Key, Value}). No-op when before/after JSON
27656
+ * is identical.
27657
+ */
27658
+ async applyTagsDiff(physicalId, next, prev) {
27659
+ if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
27660
+ const nextEntries = Array.isArray(next) ? next : [];
27661
+ const prevEntries = Array.isArray(prev) ? prev : [];
27662
+ const nextByKey = /* @__PURE__ */ new Map();
27663
+ for (const t of nextEntries) if (t.Key) nextByKey.set(t.Key, t);
27664
+ const prevByKey = /* @__PURE__ */ new Map();
27665
+ for (const t of prevEntries) if (t.Key) prevByKey.set(t.Key, t);
27666
+ const tagKeysToRemove = [];
27667
+ for (const key of prevByKey.keys()) if (!nextByKey.has(key)) tagKeysToRemove.push(key);
27668
+ if (tagKeysToRemove.length > 0) await this.getClient().send(new UntagDeliveryStreamCommand({
27669
+ DeliveryStreamName: physicalId,
27670
+ TagKeys: tagKeysToRemove
27671
+ }));
27672
+ const tagsToUpsert = [];
27673
+ for (const [key, tag] of nextByKey) {
27674
+ const before = prevByKey.get(key);
27675
+ if (JSON.stringify(before) === JSON.stringify(tag)) continue;
27676
+ tagsToUpsert.push(tag);
27677
+ }
27678
+ if (tagsToUpsert.length > 0) await this.getClient().send(new TagDeliveryStreamCommand({
27679
+ DeliveryStreamName: physicalId,
27680
+ Tags: tagsToUpsert.map((t) => ({
27681
+ Key: t.Key,
27682
+ ...t.Value !== void 0 && { Value: t.Value }
27683
+ }))
27684
+ }));
27685
+ }
27686
+ /**
27687
+ * Return the key of the destination property present on a properties
27688
+ * record, or `undefined` if none is present. AWS allows exactly one
27689
+ * destination configuration per delivery stream, so the first match is
27690
+ * authoritative; the ordering walks the most-common types first.
27691
+ */
27692
+ findDestinationKey(properties) {
27693
+ for (const key of [
27694
+ "ExtendedS3DestinationConfiguration",
27695
+ "S3DestinationConfiguration",
27696
+ "HttpEndpointDestinationConfiguration",
27697
+ "RedshiftDestinationConfiguration",
27698
+ "ElasticsearchDestinationConfiguration",
27699
+ "AmazonopensearchserviceDestinationConfiguration",
27700
+ "SplunkDestinationConfiguration",
27701
+ "AmazonOpenSearchServerlessDestinationConfiguration",
27702
+ "IcebergDestinationConfiguration",
27703
+ "SnowflakeDestinationConfiguration"
27704
+ ]) if (properties[key] !== void 0) return key;
27591
27705
  }
27592
27706
  /**
27593
27707
  * Delete a Firehose delivery stream
@@ -27662,6 +27776,67 @@ var FirehoseProvider = class {
27662
27776
  return result;
27663
27777
  }
27664
27778
  /**
27779
+ * Map CFn `ExtendedS3DestinationConfiguration` to the
27780
+ * `ExtendedS3DestinationUpdate` shape expected by AWS
27781
+ * `UpdateDestinationCommand` (#477). Shape is structurally identical
27782
+ * to `ExtendedS3DestinationConfiguration` but every field is optional
27783
+ * — only the fields present in `config` are forwarded so undefined
27784
+ * keys do not clobber AWS-side state.
27785
+ *
27786
+ * `S3BackupConfiguration` is mapped through {@link mapS3ConfigToUpdate}
27787
+ * so its own optional fields likewise round-trip cleanly.
27788
+ */
27789
+ mapExtendedS3ConfigToUpdate(config) {
27790
+ const result = {};
27791
+ const bucketArn = config["BucketArn"] ?? config["BucketARN"];
27792
+ if (bucketArn !== void 0) result.BucketARN = bucketArn;
27793
+ const roleArn = config["RoleArn"] ?? config["RoleARN"];
27794
+ if (roleArn !== void 0) result.RoleARN = roleArn;
27795
+ if (config["Prefix"] !== void 0) result.Prefix = config["Prefix"];
27796
+ if (config["ErrorOutputPrefix"] !== void 0) result.ErrorOutputPrefix = config["ErrorOutputPrefix"];
27797
+ if (config["CompressionFormat"] !== void 0) result.CompressionFormat = config["CompressionFormat"];
27798
+ if (config["BufferingHints"] !== void 0) {
27799
+ const hints = config["BufferingHints"];
27800
+ result.BufferingHints = {
27801
+ ...hints["SizeInMBs"] !== void 0 && { SizeInMBs: hints["SizeInMBs"] },
27802
+ ...hints["IntervalInSeconds"] !== void 0 && { IntervalInSeconds: hints["IntervalInSeconds"] }
27803
+ };
27804
+ }
27805
+ if (config["EncryptionConfiguration"] !== void 0) result.EncryptionConfiguration = config["EncryptionConfiguration"];
27806
+ if (config["CloudWatchLoggingOptions"] !== void 0) result.CloudWatchLoggingOptions = config["CloudWatchLoggingOptions"];
27807
+ if (config["ProcessingConfiguration"] !== void 0) result.ProcessingConfiguration = config["ProcessingConfiguration"];
27808
+ if (config["S3BackupMode"] !== void 0) result.S3BackupMode = config["S3BackupMode"];
27809
+ if (config["S3BackupConfiguration"] !== void 0) result.S3BackupUpdate = this.mapS3ConfigToUpdate(config["S3BackupConfiguration"]);
27810
+ if (config["DataFormatConversionConfiguration"] !== void 0) result.DataFormatConversionConfiguration = config["DataFormatConversionConfiguration"];
27811
+ return result;
27812
+ }
27813
+ /**
27814
+ * Map CFn `S3DestinationConfiguration` to the `S3DestinationUpdate`
27815
+ * shape used by AWS `UpdateDestinationCommand` (#477; consumed by
27816
+ * {@link mapExtendedS3ConfigToUpdate} for the `S3BackupUpdate` field).
27817
+ * Every field optional — only present keys are forwarded.
27818
+ */
27819
+ mapS3ConfigToUpdate(config) {
27820
+ const result = {};
27821
+ const bucketArn = config["BucketArn"] ?? config["BucketARN"];
27822
+ if (bucketArn !== void 0) result.BucketARN = bucketArn;
27823
+ const roleArn = config["RoleArn"] ?? config["RoleARN"];
27824
+ if (roleArn !== void 0) result.RoleARN = roleArn;
27825
+ if (config["Prefix"] !== void 0) result.Prefix = config["Prefix"];
27826
+ if (config["ErrorOutputPrefix"] !== void 0) result.ErrorOutputPrefix = config["ErrorOutputPrefix"];
27827
+ if (config["CompressionFormat"] !== void 0) result.CompressionFormat = config["CompressionFormat"];
27828
+ if (config["BufferingHints"] !== void 0) {
27829
+ const hints = config["BufferingHints"];
27830
+ result.BufferingHints = {
27831
+ ...hints["SizeInMBs"] !== void 0 && { SizeInMBs: hints["SizeInMBs"] },
27832
+ ...hints["IntervalInSeconds"] !== void 0 && { IntervalInSeconds: hints["IntervalInSeconds"] }
27833
+ };
27834
+ }
27835
+ if (config["EncryptionConfiguration"] !== void 0) result.EncryptionConfiguration = config["EncryptionConfiguration"];
27836
+ if (config["CloudWatchLoggingOptions"] !== void 0) result.CloudWatchLoggingOptions = config["CloudWatchLoggingOptions"];
27837
+ return result;
27838
+ }
27839
+ /**
27665
27840
  * Adopt an existing Kinesis Firehose delivery stream into cdkd state.
27666
27841
  *
27667
27842
  * Lookup order:
@@ -38097,6 +38272,8 @@ function parseContainerDefinition(raw, idx, taskLogicalId, resources, stack, con
38097
38272
  protocol: pickString(p["Protocol"]) === "udp" ? "udp" : "tcp"
38098
38273
  };
38099
38274
  if (hostPort !== void 0) pm.hostPort = hostPort;
38275
+ const portName = typeof p["Name"] === "string" ? p["Name"] : void 0;
38276
+ if (portName !== void 0) pm.name = portName;
38100
38277
  portMappings.push(pm);
38101
38278
  }
38102
38279
  const mountPoints = [];
@@ -38745,7 +38922,7 @@ function resolveRuntimeCodeMountPath(runtime) {
38745
38922
 
38746
38923
  //#endregion
38747
38924
  //#region src/local/docker-runner.ts
38748
- const execFileAsync$3 = promisify(execFile);
38925
+ const execFileAsync$4 = promisify(execFile);
38749
38926
  /**
38750
38927
  * Wraps `docker pull` / `docker run` / `docker rm` for `cdkd local invoke`.
38751
38928
  *
@@ -38838,7 +39015,7 @@ async function runDetached(opts) {
38838
39015
  args.push(opts.image, ...entryPointTail, ...opts.cmd);
38839
39016
  getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
38840
39017
  try {
38841
- const { stdout } = await execFileAsync$3(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
39018
+ const { stdout } = await execFileAsync$4(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
38842
39019
  return stdout.trim();
38843
39020
  } catch (error) {
38844
39021
  const err = error;
@@ -38875,7 +39052,7 @@ async function removeContainer(containerId) {
38875
39052
  if (!containerId) return;
38876
39053
  const logger = getLogger().child("docker");
38877
39054
  try {
38878
- await execFileAsync$3(getDockerCmd(), [
39055
+ await execFileAsync$4(getDockerCmd(), [
38879
39056
  "rm",
38880
39057
  "-f",
38881
39058
  containerId
@@ -38894,7 +39071,7 @@ async function removeContainer(containerId) {
38894
39071
  async function ensureDockerAvailable() {
38895
39072
  const cmd = getDockerCmd();
38896
39073
  try {
38897
- await execFileAsync$3(cmd, [
39074
+ await execFileAsync$4(cmd, [
38898
39075
  "version",
38899
39076
  "--format",
38900
39077
  "{{.Server.Version}}"
@@ -49340,7 +49517,7 @@ function createLocalStartApiCommand() {
49340
49517
 
49341
49518
  //#endregion
49342
49519
  //#region src/local/ecs-network.ts
49343
- const execFileAsync$2 = promisify(execFile);
49520
+ const execFileAsync$3 = promisify(execFile);
49344
49521
  /**
49345
49522
  * Docker network + AWS-published metadata-endpoints sidecar lifecycle for
49346
49523
  * `cdkd local run-task`. The sidecar (a small Go binary maintained by
@@ -49358,8 +49535,10 @@ const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:lat
49358
49535
  * matches the documented AWS task-metadata endpoint address. Containers
49359
49536
  * inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
49360
49537
  * to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
49361
- * start-service` allocates a per-replica subnet (via `subnetOctet`) so
49362
- * concurrent replicas don't collide on a single docker network range.
49538
+ * start-service` creates ONE shared network at CLI startup (design
49539
+ * § 5 Option A) the shared sidecar lives at `169.254.171.2` (see
49540
+ * `SHARED_SVC_SUBNET_OCTET` below), one octet up so the two CLI
49541
+ * variants can run on the same host without bridge-pool collision.
49363
49542
  */
49364
49543
  const METADATA_ENDPOINT_IP = "169.254.170.2";
49365
49544
  /** Default subnet — used when no `subnetOctet` override is supplied. */
@@ -49387,23 +49566,58 @@ function buildEndpointSubnet(subnetOctet) {
49387
49566
  };
49388
49567
  }
49389
49568
  /**
49390
- * Create the per-task docker network + start the metadata-endpoints
49391
- * sidecar. The sidecar must come up at the well-known address BEFORE any
49392
- * user container starts so the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
49393
- * lookup at container start doesn't race.
49569
+ * Subnet octet for the shared-service docker network used by
49570
+ * `cdkd local start-service`. One octet up from `cdkd local run-task`'s
49571
+ * default (170 171) so the two CLI variants can run on the same host
49572
+ * without docker rejecting the second `--subnet`. The shared-service
49573
+ * network reuses the same `createTaskNetwork` machinery; the sidecar at
49574
+ * `169.254.171.2` serves the same metadata-endpoint API to every
49575
+ * container that joins this one network.
49394
49576
  */
49395
- async function createTaskNetwork(options = {}) {
49577
+ const SHARED_SVC_SUBNET_OCTET = 171;
49578
+ /**
49579
+ * Create the one shared docker network + metadata-endpoints sidecar
49580
+ * used by every service-replica boot in a single
49581
+ * `cdkd local start-service` invocation. This is design doc § 5
49582
+ * Option A — one network per CLI invocation instead of one network
49583
+ * per task — so peer services can reach each other by IP / network
49584
+ * alias without docker `--network connect` choreography (Option B,
49585
+ * rejected in design § 5 as "unwieldy and racy"). The returned
49586
+ * `TaskNetwork` carries `ownedByCaller: true` so `cleanupEcsRun()`
49587
+ * (called per replica by the service runner) does NOT teardown — the
49588
+ * CLI tears down ONCE at the end of the run.
49589
+ */
49590
+ async function createSharedSvcNetwork(options = {}) {
49591
+ const networkName = `${options.prefix ?? "cdkd-local"}-svc-${randomBytes(4).toString("hex")}`;
49592
+ const { cidr, sidecarIp } = buildEndpointSubnet(171);
49593
+ return {
49594
+ networkName,
49595
+ sidecarContainerId: await createNetworkAndSidecar({
49596
+ networkName,
49597
+ cidr,
49598
+ sidecarIp,
49599
+ skipPull: options.skipPull ?? false,
49600
+ ...options.credentials !== void 0 ? { credentials: options.credentials } : {},
49601
+ ...options.cluster !== void 0 ? { cluster: options.cluster } : {}
49602
+ }),
49603
+ sidecarIp,
49604
+ ownedByCaller: true
49605
+ };
49606
+ }
49607
+ /**
49608
+ * Internal helper shared by `createTaskNetwork` (per-task) and
49609
+ * `createSharedSvcNetwork` (per-CLI-run). Creates the docker network,
49610
+ * pulls the sidecar image, and starts the sidecar at the documented
49611
+ * IP. Throws `DockerRunnerError` with a hint when the network already
49612
+ * exists (the typical "leftover from previous run" path).
49613
+ */
49614
+ async function createNetworkAndSidecar(args) {
49396
49615
  const logger = getLogger().child("ecs-network");
49397
- const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
49398
- const subnetOctet = options.subnetOctet ?? 170;
49399
- const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
49400
- cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
49401
- sidecarIp: METADATA_ENDPOINT_IP
49402
- } : buildEndpointSubnet(subnetOctet);
49403
- await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
49616
+ const { networkName, cidr, sidecarIp, credentials, cluster, skipPull } = args;
49617
+ await pullImage(METADATA_ENDPOINT_IMAGE, skipPull);
49404
49618
  logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
49405
49619
  try {
49406
- await execFileAsync$2(getDockerCmd(), [
49620
+ await execFileAsync$3(getDockerCmd(), [
49407
49621
  "network",
49408
49622
  "create",
49409
49623
  "--driver",
@@ -49414,7 +49628,7 @@ async function createTaskNetwork(options = {}) {
49414
49628
  ]);
49415
49629
  } catch (err) {
49416
49630
  const e = err;
49417
- throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` walks subnetOctet per replica to avoid this; bare \`cdkd local run-task\` uses the default subnet so only one run can be active at a time.`);
49631
+ throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` shares one network across every service in the run; bare \`cdkd local run-task\` uses a per-task network so only one run can be active at a time.`);
49418
49632
  }
49419
49633
  const sidecarArgs = [
49420
49634
  "run",
@@ -49428,27 +49642,46 @@ async function createTaskNetwork(options = {}) {
49428
49642
  sidecarIp
49429
49643
  ];
49430
49644
  const sidecarEnv = {};
49431
- if (options.credentials) {
49432
- sidecarEnv["AWS_ACCESS_KEY_ID"] = options.credentials.accessKeyId;
49433
- sidecarEnv["AWS_SECRET_ACCESS_KEY"] = options.credentials.secretAccessKey;
49434
- if (options.credentials.sessionToken) sidecarEnv["AWS_SESSION_TOKEN"] = options.credentials.sessionToken;
49645
+ if (credentials) {
49646
+ sidecarEnv["AWS_ACCESS_KEY_ID"] = credentials.accessKeyId;
49647
+ sidecarEnv["AWS_SECRET_ACCESS_KEY"] = credentials.secretAccessKey;
49648
+ if (credentials.sessionToken) sidecarEnv["AWS_SESSION_TOKEN"] = credentials.sessionToken;
49435
49649
  }
49436
- if (options.cluster) sidecarEnv["CLUSTER"] = options.cluster;
49650
+ if (cluster) sidecarEnv["CLUSTER"] = cluster;
49437
49651
  for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
49438
49652
  sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
49439
49653
  logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
49440
- let sidecarContainerId;
49441
49654
  try {
49442
- const { stdout } = await execFileAsync$2(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
49443
- sidecarContainerId = stdout.trim();
49655
+ const { stdout } = await execFileAsync$3(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
49656
+ return stdout.trim();
49444
49657
  } catch (err) {
49445
49658
  await destroyNetworkOnly(networkName);
49446
49659
  const e = err;
49447
49660
  throw new DockerRunnerError(`Failed to start metadata-endpoints sidecar: ${e.stderr?.trim() || e.message || String(err)}`);
49448
49661
  }
49662
+ }
49663
+ /**
49664
+ * Create the per-task docker network + start the metadata-endpoints
49665
+ * sidecar. The sidecar must come up at the well-known address BEFORE any
49666
+ * user container starts so the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
49667
+ * lookup at container start doesn't race.
49668
+ */
49669
+ async function createTaskNetwork(options = {}) {
49670
+ const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
49671
+ const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
49672
+ cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
49673
+ sidecarIp: METADATA_ENDPOINT_IP
49674
+ } : buildEndpointSubnet(options.subnetOctet);
49449
49675
  return {
49450
49676
  networkName,
49451
- sidecarContainerId,
49677
+ sidecarContainerId: await createNetworkAndSidecar({
49678
+ networkName,
49679
+ cidr,
49680
+ sidecarIp,
49681
+ skipPull: options.skipPull ?? false,
49682
+ ...options.credentials !== void 0 ? { credentials: options.credentials } : {},
49683
+ ...options.cluster !== void 0 ? { cluster: options.cluster } : {}
49684
+ }),
49452
49685
  sidecarIp
49453
49686
  };
49454
49687
  }
@@ -49487,7 +49720,7 @@ async function destroyNetworkOnly(networkName) {
49487
49720
  if (!networkName) return;
49488
49721
  const logger = getLogger().child("ecs-network");
49489
49722
  try {
49490
- await execFileAsync$2(getDockerCmd(), [
49723
+ await execFileAsync$3(getDockerCmd(), [
49491
49724
  "network",
49492
49725
  "rm",
49493
49726
  networkName
@@ -49620,7 +49853,7 @@ async function resolveSsm(entry, shape, client) {
49620
49853
 
49621
49854
  //#endregion
49622
49855
  //#region src/local/ecs-task-runner.ts
49623
- const execFileAsync$1 = promisify(execFile);
49856
+ const execFileAsync$2 = promisify(execFile);
49624
49857
  /**
49625
49858
  * Top-level orchestrator for `cdkd local run-task`. Coordinates image
49626
49859
  * preparation, secret resolution, docker-network bring-up, container
@@ -49677,10 +49910,10 @@ async function cleanupEcsRun(state, options) {
49677
49910
  }
49678
49911
  state.startedContainers = [];
49679
49912
  }
49680
- await destroyTaskNetwork(state.network);
49913
+ if (state.network && !state.network.ownedByCaller) await destroyTaskNetwork(state.network);
49681
49914
  state.network = void 0;
49682
49915
  for (const v of state.dockerVolumeNames) try {
49683
- await execFileAsync$1(getDockerCmd(), [
49916
+ await execFileAsync$2(getDockerCmd(), [
49684
49917
  "volume",
49685
49918
  "rm",
49686
49919
  v
@@ -49710,14 +49943,20 @@ async function runEcsTask(task, options, state) {
49710
49943
  valueFrom: s.valueFrom
49711
49944
  });
49712
49945
  const secretsByContainer = groupSecretsByContainer(await resolveEcsSecrets(allSecrets, { ...options.region !== void 0 && { region: options.region } }));
49713
- const netCreateOpts = {
49714
- prefix: options.cluster,
49715
- skipPull: options.skipPull
49716
- };
49717
- if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
49718
- if (options.cluster) netCreateOpts.cluster = options.cluster;
49719
- if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
49720
- state.network = await createTaskNetwork(netCreateOpts);
49946
+ if (options.existingNetwork) state.network = {
49947
+ ...options.existingNetwork,
49948
+ ownedByCaller: true
49949
+ };
49950
+ else {
49951
+ const netCreateOpts = {
49952
+ prefix: options.cluster,
49953
+ skipPull: options.skipPull
49954
+ };
49955
+ if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
49956
+ if (options.cluster) netCreateOpts.cluster = options.cluster;
49957
+ if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
49958
+ state.network = await createTaskNetwork(netCreateOpts);
49959
+ }
49721
49960
  const volumeByName = await realizeDockerVolumes(task.volumes, state);
49722
49961
  const dockerCmds = /* @__PURE__ */ new Map();
49723
49962
  for (const container of task.containers) {
@@ -49735,7 +49974,9 @@ async function runEcsTask(task, options, state) {
49735
49974
  roleArn: options.taskRoleArn,
49736
49975
  platformOverride: options.platformOverride,
49737
49976
  region: options.region,
49738
- sidecarIp: state.network.sidecarIp
49977
+ sidecarIp: state.network.sidecarIp,
49978
+ ...options.addHostFlags && options.addHostFlags.length > 0 ? { addHostFlags: options.addHostFlags } : {},
49979
+ ...(options.networkAliasesByContainer?.get(container.name)?.length ?? 0) > 0 ? { networkAliases: options.networkAliasesByContainer.get(container.name) } : {}
49739
49980
  }));
49740
49981
  }
49741
49982
  const startedByName = /* @__PURE__ */ new Map();
@@ -49746,7 +49987,7 @@ async function runEcsTask(task, options, state) {
49746
49987
  logger.info(`Starting container '${container.name}' (image=${imagePlan.get(container.name)})`);
49747
49988
  let id;
49748
49989
  try {
49749
- const { stdout } = await execFileAsync$1(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
49990
+ const { stdout } = await execFileAsync$2(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
49750
49991
  id = stdout.trim();
49751
49992
  } catch (err) {
49752
49993
  const e = err;
@@ -49855,7 +50096,7 @@ async function waitForContainerHealthy(containerId, displayName) {
49855
50096
  let lastStatus = "";
49856
50097
  while (Date.now() < deadline) {
49857
50098
  try {
49858
- const { stdout } = await execFileAsync$1(getDockerCmd(), [
50099
+ const { stdout } = await execFileAsync$2(getDockerCmd(), [
49859
50100
  "inspect",
49860
50101
  "--format",
49861
50102
  "{{.State.Health.Status}}",
@@ -49878,7 +50119,7 @@ async function waitForContainerHealthy(containerId, displayName) {
49878
50119
  }
49879
50120
  async function waitForContainerExit(containerId) {
49880
50121
  try {
49881
- const { stdout } = await execFileAsync$1(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
50122
+ const { stdout } = await execFileAsync$2(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
49882
50123
  const code = Number.parseInt(stdout.trim(), 10);
49883
50124
  return Number.isFinite(code) ? code : 1;
49884
50125
  } catch (err) {
@@ -49888,7 +50129,7 @@ async function waitForContainerExit(containerId) {
49888
50129
  }
49889
50130
  async function stopContainer(containerId, graceSeconds) {
49890
50131
  try {
49891
- await execFileAsync$1(getDockerCmd(), [
50132
+ await execFileAsync$2(getDockerCmd(), [
49892
50133
  "stop",
49893
50134
  "-t",
49894
50135
  String(graceSeconds),
@@ -50013,7 +50254,7 @@ async function realizeDockerVolumes(volumes, state) {
50013
50254
  const dockerVolumeName = `cdkd-local-${v.name}-${randHex(4)}`;
50014
50255
  args.push(dockerVolumeName);
50015
50256
  try {
50016
- await execFileAsync$1(getDockerCmd(), args);
50257
+ await execFileAsync$2(getDockerCmd(), args);
50017
50258
  state.dockerVolumeNames.push(dockerVolumeName);
50018
50259
  logger.debug(`Created docker volume ${dockerVolumeName} for task volume '${v.name}'`);
50019
50260
  } catch (err) {
@@ -50053,6 +50294,14 @@ function buildDockerRunArgs(opts) {
50053
50294
  args.push("--name", `cdkd-local-${task.family}-${container.name}-${randHex(3)}`);
50054
50295
  args.push("--network", network);
50055
50296
  args.push("--network-alias", container.name);
50297
+ if (opts.networkAliases && opts.networkAliases.length > 0) {
50298
+ const seen = new Set([container.name]);
50299
+ for (const a of opts.networkAliases) if (!seen.has(a)) {
50300
+ args.push("--network-alias", a);
50301
+ seen.add(a);
50302
+ }
50303
+ }
50304
+ if (opts.addHostFlags && opts.addHostFlags.length > 0) for (const f of opts.addHostFlags) args.push(f);
50056
50305
  if (opts.platformOverride) args.push("--platform", opts.platformOverride);
50057
50306
  else if (task.runtimePlatform) args.push("--platform", task.runtimePlatform.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64");
50058
50307
  for (const pm of container.portMappings) {
@@ -50459,8 +50708,8 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
50459
50708
  const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
50460
50709
  const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
50461
50710
  if (Array.isArray(props["LoadBalancers"]) && props["LoadBalancers"].length > 0) warnings.push(`ECS Service '${serviceLogicalId}' declares LoadBalancers, but local load-balancer emulation is deferred to a follow-up PR. Containers are NOT registered to a local listener; reach them via their published ports.`);
50462
- if (props["ServiceConnectConfiguration"]) warnings.push(`ECS Service '${serviceLogicalId}' declares ServiceConnectConfiguration, but Service Connect / Cloud Map emulation is deferred (tracked in #460). Cross-service discovery between locally-run services is not provided.`);
50463
- return {
50711
+ const serviceConnect = extractServiceConnect(props["ServiceConnectConfiguration"], task);
50712
+ const out = {
50464
50713
  stack,
50465
50714
  serviceLogicalId,
50466
50715
  resource,
@@ -50468,8 +50717,103 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
50468
50717
  desiredCount,
50469
50718
  healthCheckGracePeriodSeconds,
50470
50719
  task,
50720
+ serviceRegistries: extractServiceRegistries(props["ServiceRegistries"]),
50471
50721
  warnings
50472
50722
  };
50723
+ if (serviceConnect) out.serviceConnect = serviceConnect;
50724
+ return out;
50725
+ }
50726
+ /**
50727
+ * Parse `ServiceConnectConfiguration` against the producer TaskDef.
50728
+ * Returns `undefined` when the block is missing OR `Enabled: false`.
50729
+ *
50730
+ * Reject conditions (surface as resolver-time errors so the user sees
50731
+ * them BEFORE the docker network is created):
50732
+ * - `Namespace` is not a literal string. CDK 2.x always emits a
50733
+ * literal string here (verified 2026-05-22); cross-stack /
50734
+ * intrinsic shapes are out of scope.
50735
+ * - `Services[].PortName` doesn't match any of the TaskDef's
50736
+ * `ContainerDefinitions[].PortMappings[].Name` entries.
50737
+ *
50738
+ * Note on `clientAliases[]` shape: each ClientAlias can declare a
50739
+ * `DnsName` (the bare short-name peers connect to, e.g. `orders`) AND
50740
+ * a `Port` (the listening port the alias maps to inside the consumer).
50741
+ * cdkd surfaces both verbatim; the registry / `--add-host` overlay
50742
+ * publishes each `DnsName` as a bare alias pointing at the same IP as
50743
+ * the canonical fqdn.
50744
+ */
50745
+ function extractServiceConnect(raw, task) {
50746
+ if (!raw || typeof raw !== "object") return void 0;
50747
+ const cfg = raw;
50748
+ if (cfg["Enabled"] === false) return void 0;
50749
+ const namespaceName = pickServiceConnectNamespace(cfg["Namespace"]);
50750
+ 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.`);
50751
+ const rawServices = cfg["Services"];
50752
+ if (!Array.isArray(rawServices) || rawServices.length === 0) return {
50753
+ namespaceName,
50754
+ services: []
50755
+ };
50756
+ const portByName = /* @__PURE__ */ new Map();
50757
+ for (const c of task.containers) for (const pm of c.portMappings) if (pm.name) portByName.set(pm.name, pm.containerPort);
50758
+ const services = [];
50759
+ for (const entry of rawServices) {
50760
+ if (!entry || typeof entry !== "object") continue;
50761
+ const e = entry;
50762
+ const portName = typeof e["PortName"] === "string" ? e["PortName"] : void 0;
50763
+ if (!portName) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Services[] entry has no PortName: ${JSON.stringify(entry)}. Every Service entry must reference a producer-side PortMappings[].Name.`);
50764
+ const containerPort = portByName.get(portName);
50765
+ 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)"}).`);
50766
+ const clientAliases = [];
50767
+ if (Array.isArray(e["ClientAliases"])) for (const ca of e["ClientAliases"]) {
50768
+ if (!ca || typeof ca !== "object") continue;
50769
+ const caObj = ca;
50770
+ const dnsName = typeof caObj["DnsName"] === "string" ? caObj["DnsName"] : void 0;
50771
+ const aliasEntry = { port: typeof caObj["Port"] === "number" ? caObj["Port"] : containerPort };
50772
+ if (dnsName !== void 0) aliasEntry.dnsName = dnsName;
50773
+ clientAliases.push(aliasEntry);
50774
+ }
50775
+ const discoveryName = clientAliases.find((c) => c.dnsName !== void 0)?.dnsName ?? portName;
50776
+ services.push({
50777
+ portName,
50778
+ containerPort,
50779
+ discoveryName,
50780
+ clientAliases
50781
+ });
50782
+ }
50783
+ return {
50784
+ namespaceName,
50785
+ services
50786
+ };
50787
+ }
50788
+ /**
50789
+ * Parse `ServiceRegistries[]`. Each entry's `RegistryArn` is the
50790
+ * canonical `Fn::GetAtt: [<CloudMapServiceLogicalId>, 'Arn']` shape;
50791
+ * cdkd surfaces the logical id (the AWS-side ARN is irrelevant
50792
+ * locally — the registry is in-process).
50793
+ */
50794
+ function extractServiceRegistries(raw) {
50795
+ if (!Array.isArray(raw)) return [];
50796
+ const out = [];
50797
+ for (const entry of raw) {
50798
+ if (!entry || typeof entry !== "object") continue;
50799
+ const e = entry;
50800
+ const registryArn = e["RegistryArn"];
50801
+ let cloudMapServiceLogicalId;
50802
+ if (typeof registryArn === "string") continue;
50803
+ if (registryArn && typeof registryArn === "object" && !Array.isArray(registryArn)) {
50804
+ const getAtt = registryArn["Fn::GetAtt"];
50805
+ if (Array.isArray(getAtt) && typeof getAtt[0] === "string") cloudMapServiceLogicalId = getAtt[0];
50806
+ }
50807
+ if (!cloudMapServiceLogicalId) continue;
50808
+ const reg = { cloudMapServiceLogicalId };
50809
+ if (typeof e["ContainerName"] === "string") reg.containerName = e["ContainerName"];
50810
+ if (typeof e["ContainerPort"] === "number") reg.containerPort = e["ContainerPort"];
50811
+ out.push(reg);
50812
+ }
50813
+ return out;
50814
+ }
50815
+ function pickServiceConnectNamespace(raw) {
50816
+ if (typeof raw === "string" && raw.length > 0) return raw;
50473
50817
  }
50474
50818
  /**
50475
50819
  * Resolve `Properties.TaskDefinition` to a logical id in the same stack.
@@ -50534,6 +50878,42 @@ function notFoundError(target, stack, resources) {
50534
50878
  return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
50535
50879
  }
50536
50880
 
50881
+ //#endregion
50882
+ //#region src/local/docker-inspect.ts
50883
+ const execFileAsync$1 = promisify(execFile);
50884
+ /**
50885
+ * Phase 3 of #262 (Issue #460) helper — query the docker network IP
50886
+ * assigned to a freshly-started container so the Cloud Map registry
50887
+ * can publish reachable endpoints for peer discovery.
50888
+ *
50889
+ * Returns `undefined` when:
50890
+ * - The container is not found (docker rm raced us).
50891
+ * - The container is not attached to the named network.
50892
+ * - The IP is empty (docker hasn't fully wired the network yet —
50893
+ * caller should retry-or-skip; the helper deliberately does NOT
50894
+ * embed a retry loop to keep the caller in charge of timing).
50895
+ *
50896
+ * Errors propagate verbatim to the caller (`DockerRunnerError`-style
50897
+ * wrapping is the caller's concern; this helper is structurally a
50898
+ * simple inspect wrapper).
50899
+ */
50900
+ async function getContainerNetworkIp(containerId, networkName) {
50901
+ const format = `{{with index .NetworkSettings.Networks "${networkName}"}}{{.IPAddress}}{{end}}`;
50902
+ try {
50903
+ const { stdout } = await execFileAsync$1(getDockerCmd(), [
50904
+ "inspect",
50905
+ "--format",
50906
+ format,
50907
+ containerId
50908
+ ]);
50909
+ const ip = stdout.trim();
50910
+ if (!ip) return void 0;
50911
+ return ip;
50912
+ } catch {
50913
+ return;
50914
+ }
50915
+ }
50916
+
50537
50917
  //#endregion
50538
50918
  //#region src/local/ecs-service-runner.ts
50539
50919
  /**
@@ -50551,10 +50931,20 @@ function notFoundError(target, stack, resources) {
50551
50931
  * stops compounding cleanup work.
50552
50932
  * - Long-running lifecycle (returns only on shutdown).
50553
50933
  *
50934
+ * Phase 3 of #262 (Issue #460) — Cloud Map / Service Connect peer
50935
+ * discovery is wired through `ServiceRunnerOptions.discovery`. When
50936
+ * supplied, every booted replica discovers its docker IP, registers
50937
+ * itself into the shared in-process `CloudMapRegistry`, and emits
50938
+ * `--add-host` flags so consumer containers reach peer services via
50939
+ * the canonical `<discoveryName>.<namespace>` fqdn. Envoy L7 sidecar
50940
+ * emulation (design Layer B) is deferred to a follow-up PR per the
50941
+ * design's §O5 "--no-envoy by default" recommendation.
50942
+ *
50554
50943
  * Deferred to follow-up PRs:
50555
50944
  * - Local load-balancer emulation (LB listener + target-group health
50556
50945
  * check + round-robin) — separate PR per the issue's PR-split.
50557
- * - Service Connect / Cloud Map (tracked in #460).
50946
+ * - Envoy sidecar for Service Connect L7 routing / retries / circuit
50947
+ * breaking (Cloud Map DNS-only mode ships now).
50558
50948
  * - Rolling deployment (`--reload` / `--watch`).
50559
50949
  */
50560
50950
  var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
@@ -50620,7 +51010,8 @@ async function startEcsService(service, options, runState) {
50620
51010
  state: createEcsRunState(),
50621
51011
  restartCount: 0,
50622
51012
  shuttingDown: false,
50623
- inFlightBoot: void 0
51013
+ inFlightBoot: void 0,
51014
+ cloudMapHandles: []
50624
51015
  };
50625
51016
  runState.replicas.push(instance);
50626
51017
  const bootPromise = bootReplica(service, options, instance);
@@ -50702,6 +51093,12 @@ var ServiceController = class {
50702
51093
  await Promise.allSettled(inFlightBoots);
50703
51094
  }
50704
51095
  await Promise.allSettled(this.runState.replicas.map(async (instance) => {
51096
+ if (this.options.discovery) {
51097
+ for (const handle of instance.cloudMapHandles) try {
51098
+ this.options.discovery.registry.unregister(handle);
51099
+ } catch {}
51100
+ instance.cloudMapHandles = [];
51101
+ }
50705
51102
  try {
50706
51103
  await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
50707
51104
  } catch (err) {
@@ -50712,6 +51109,39 @@ var ServiceController = class {
50712
51109
  }
50713
51110
  };
50714
51111
  /**
51112
+ * Build the `--network-alias` map for one service's containers (design
51113
+ * doc § 5 Option A). For every Service Connect entry, attach the
51114
+ * fqdn (`<discoveryName>.<namespaceName>`), the bare discoveryName,
51115
+ * AND every ClientAlias DnsName to the container that owns the
51116
+ * matching PortName. Other containers in the task get NO extra
51117
+ * aliases (only their default `--name`-derived alias from
51118
+ * `buildDockerRunArgs`).
51119
+ *
51120
+ * Aliases per container are de-duplicated so docker doesn't reject
51121
+ * a `--network-alias X` repeated against the same container.
51122
+ *
51123
+ * Returns an empty map when the service has no Service Connect — the
51124
+ * runner's `... .size > 0 ? { networkAliasesByContainer } : {}` guard
51125
+ * short-circuits in that case so backward-compat callers pay no cost.
51126
+ */
51127
+ function buildNetworkAliasesByContainer(service) {
51128
+ const out = /* @__PURE__ */ new Map();
51129
+ const sc = service.serviceConnect;
51130
+ if (!sc) return out;
51131
+ for (const entry of sc.services) {
51132
+ const owner = service.task.containers.find((c) => c.portMappings.some((pm) => pm.name === entry.portName));
51133
+ if (!owner) continue;
51134
+ const aliases = [];
51135
+ aliases.push(entry.discoveryName);
51136
+ aliases.push(`${entry.discoveryName}.${sc.namespaceName}`);
51137
+ for (const ca of entry.clientAliases) if (ca.dnsName) aliases.push(ca.dnsName);
51138
+ const existing = out.get(owner.name) ?? [];
51139
+ for (const a of aliases) if (!existing.includes(a)) existing.push(a);
51140
+ out.set(owner.name, existing);
51141
+ }
51142
+ return out;
51143
+ }
51144
+ /**
50715
51145
  * Boot a single replica. Mutates the supplied `instance.state` so the
50716
51146
  * shutdown path's `cleanupEcsRun(instance.state)` covers every partial
50717
51147
  * side effect. Network names are suffixed with the replica index so
@@ -50720,15 +51150,99 @@ var ServiceController = class {
50720
51150
  async function bootReplica(service, options, instance) {
50721
51151
  const logger = getLogger().child("ecs-service");
50722
51152
  const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
50723
- const perReplicaSubnetOctet = 170 + instance.index % 84;
51153
+ const ownerKeyPrefix = `${service.serviceLogicalId}:r${instance.index}`;
51154
+ const addHostFlags = options.discovery?.registry ? options.discovery.registry.buildAddHostFlags(ownerKeyPrefix) : [];
51155
+ const sharedNetwork = options.discovery?.sharedNetwork;
51156
+ const networkAliasesByContainer = buildNetworkAliasesByContainer(service);
50724
51157
  const perReplicaTaskOptions = {
50725
51158
  ...options.taskOptions,
50726
51159
  cluster: perReplicaCluster,
50727
- subnetOctet: perReplicaSubnetOctet,
50728
- detach: true
51160
+ detach: true,
51161
+ ...sharedNetwork ? { existingNetwork: sharedNetwork } : { subnetOctet: 170 + instance.index % 84 },
51162
+ ...addHostFlags.length > 0 ? { addHostFlags } : {},
51163
+ ...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {}
50729
51164
  };
50730
51165
  logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
50731
51166
  await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
51167
+ if (options.discovery) await publishReplicaToCloudMap(service, instance, options.discovery, ownerKeyPrefix);
51168
+ }
51169
+ /**
51170
+ * After the replica's main container is up, discover its docker
51171
+ * network IP and publish the configured Service Connect + Cloud Map
51172
+ * endpoints into the shared registry. The handles are tracked on the
51173
+ * instance so the shutdown / restart path can unregister symmetrically.
51174
+ *
51175
+ * Errors here are best-effort: docker inspect can fail right after run
51176
+ * (container vanished, network not fully wired), and the registry is
51177
+ * advisory — losing one replica's registration means peer services
51178
+ * can't reach it via the overlay, but it doesn't break that replica's
51179
+ * own work or AWS SDK calls.
51180
+ */
51181
+ async function publishReplicaToCloudMap(service, instance, discovery, ownerKeyPrefix) {
51182
+ const logger = getLogger().child("ecs-service");
51183
+ const networkName = instance.state.network?.networkName;
51184
+ if (!networkName) return;
51185
+ const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
51186
+ if (!essential) return;
51187
+ const started = instance.state.startedContainers.find((c) => c.name === essential.name);
51188
+ if (!started) return;
51189
+ let ip;
51190
+ try {
51191
+ ip = await getContainerNetworkIp(started.id, networkName);
51192
+ } catch (err) {
51193
+ logger.warn(`Replica ${instance.index}: docker inspect failed before Cloud Map publish: ${err instanceof Error ? err.message : String(err)}`);
51194
+ return;
51195
+ }
51196
+ if (!ip) {
51197
+ logger.warn(`Replica ${instance.index}: no docker IP discovered on network ${networkName}; skipping Cloud Map publish for this replica.`);
51198
+ return;
51199
+ }
51200
+ if (service.serviceConnect) {
51201
+ const ns = service.serviceConnect.namespaceName;
51202
+ const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
51203
+ 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.`);
51204
+ let i = 0;
51205
+ for (const entry of service.serviceConnect.services) {
51206
+ const ownerKey = `${ownerKeyPrefix}:sc:${i}`;
51207
+ const handle = discovery.registry.register(ns, entry.discoveryName, {
51208
+ ip,
51209
+ port: entry.containerPort,
51210
+ ownerKey
51211
+ });
51212
+ instance.cloudMapHandles.push(handle);
51213
+ for (const alias of entry.clientAliases) if (alias.dnsName) discovery.registry.registerAlias(alias.dnsName, handle.fqdn);
51214
+ i++;
51215
+ }
51216
+ }
51217
+ if (service.serviceRegistries.length > 0) {
51218
+ const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
51219
+ if (!index) {
51220
+ logger.warn(`ECS Service '${service.serviceLogicalId}' declares ServiceRegistries[] but cdkd has no Cloud Map index for stack ${service.stack.stackName}. Skipping registration.`);
51221
+ return;
51222
+ }
51223
+ let j = 0;
51224
+ for (const reg of service.serviceRegistries) {
51225
+ const cm = index.servicesByLogicalId.get(reg.cloudMapServiceLogicalId);
51226
+ if (!cm) {
51227
+ 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.`);
51228
+ continue;
51229
+ }
51230
+ let port = reg.containerPort;
51231
+ if (port === void 0 && essential.portMappings.length > 0) port = essential.portMappings[0].containerPort;
51232
+ if (port === void 0) {
51233
+ logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceRegistries[] entry for Cloud Map service '${cm.logicalId}' has no resolvable container port; skipping.`);
51234
+ continue;
51235
+ }
51236
+ const ownerKey = `${ownerKeyPrefix}:sr:${j}`;
51237
+ const handle = discovery.registry.register(cm.namespaceName, cm.name, {
51238
+ ip,
51239
+ port,
51240
+ ownerKey
51241
+ });
51242
+ instance.cloudMapHandles.push(handle);
51243
+ j++;
51244
+ }
51245
+ }
50732
51246
  }
50733
51247
  /**
50734
51248
  * Long-running watcher loop for one replica. Polls the essential
@@ -50762,6 +51276,12 @@ async function watchReplica(service, options, instance, runState) {
50762
51276
  logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
50763
51277
  await sleep(delay);
50764
51278
  if (instance.shuttingDown || runState.shuttingDown) return;
51279
+ if (options.discovery) {
51280
+ for (const handle of instance.cloudMapHandles) try {
51281
+ options.discovery.registry.unregister(handle);
51282
+ } catch {}
51283
+ instance.cloudMapHandles = [];
51284
+ }
50765
51285
  try {
50766
51286
  await cleanupEcsRun(instance.state, { keepRunning: false });
50767
51287
  } catch (err) {
@@ -50816,6 +51336,305 @@ function sleep(ms) {
50816
51336
  return sleepImpl(ms);
50817
51337
  }
50818
51338
 
51339
+ //#endregion
51340
+ //#region src/local/cloud-map-registry.ts
51341
+ /**
51342
+ * In-process registry. `register()` is called by the service runner
51343
+ * after each replica's main container boot; `unregister()` is called by
51344
+ * the shutdown / restart paths. `lookupHosts()` produces the
51345
+ * `--add-host` flag list a consumer task's `docker run` injects so
51346
+ * `<discoveryName>.<namespace>` and (when registered as an alias)
51347
+ * bare `<discoveryName>` both resolve to a registered endpoint inside
51348
+ * the consumer container.
51349
+ *
51350
+ * Concurrency note: docker-run callers are not concurrent for the same
51351
+ * replica (the runner boots replicas sequentially), but `lookupHosts()`
51352
+ * MAY be called concurrently with `register()` / `unregister()` of an
51353
+ * unrelated service. The implementation uses synchronous Map mutations
51354
+ * so a stale read returns the previous snapshot — never a partially-
51355
+ * mutated one. No async / mutex needed.
51356
+ */
51357
+ var CloudMapRegistry = class {
51358
+ /** Map<fqdn, RegistryEntry[]> — one per replica registered under that fqdn. */
51359
+ byFqdn = /* @__PURE__ */ new Map();
51360
+ /**
51361
+ * Map<alias, fqdn> — secondary index for ClientAlias short forms.
51362
+ * Multiple aliases can point at the same fqdn; one alias only points
51363
+ * at one fqdn (last write wins, with a warn surfaced by the resolver
51364
+ * when a cross-namespace collision is detected — see design §O6).
51365
+ */
51366
+ aliasIndex = /* @__PURE__ */ new Map();
51367
+ /**
51368
+ * Register a replica's endpoint under `{namespace}/{discoveryName}`.
51369
+ *
51370
+ * @param namespace Cloud Map namespace name, e.g. `cdkd-local.local`.
51371
+ * An empty string is rejected — Cloud Map requires a
51372
+ * named namespace.
51373
+ * @param discoveryName Cloud Map service name, e.g. `orders`. Rejected
51374
+ * when empty.
51375
+ * @param endpoint Reachable endpoint + owner key for symmetric
51376
+ * unregister.
51377
+ * @returns A handle the caller stores for later `unregister(handle)`.
51378
+ */
51379
+ register(namespace, discoveryName, endpoint) {
51380
+ if (!namespace) throw new Error("CloudMapRegistry.register: namespace must be a non-empty string.");
51381
+ if (!discoveryName) throw new Error("CloudMapRegistry.register: discoveryName must be a non-empty string.");
51382
+ const fqdn = `${discoveryName}.${namespace}`;
51383
+ const filtered = (this.byFqdn.get(fqdn) ?? []).filter((e) => e.endpoint.ownerKey !== endpoint.ownerKey);
51384
+ filtered.push({
51385
+ namespace,
51386
+ discoveryName,
51387
+ endpoint
51388
+ });
51389
+ this.byFqdn.set(fqdn, filtered);
51390
+ return {
51391
+ fqdn,
51392
+ ownerKey: endpoint.ownerKey
51393
+ };
51394
+ }
51395
+ /**
51396
+ * Register a bare-name alias (`<discoveryName>` without the namespace
51397
+ * suffix). Cloud Map / Service Connect does NOT auto-create such
51398
+ * aliases — they're populated by `ClientAliases[].DnsName` entries in
51399
+ * the consumer service's `ServiceConnectConfiguration`. Aliases are
51400
+ * scoped per-CLI-invocation and last-write-wins on collision (the
51401
+ * resolver surfaces a `warn` when this happens — see §O6).
51402
+ *
51403
+ * @param alias The bare discovery name (e.g. `orders` for an alias to
51404
+ * `orders.cdkd-local.local`).
51405
+ * @param targetFqdn The full `{discoveryName}.{namespace}` the alias
51406
+ * resolves to.
51407
+ */
51408
+ registerAlias(alias, targetFqdn) {
51409
+ if (!alias) throw new Error("CloudMapRegistry.registerAlias: alias must be a non-empty string.");
51410
+ if (!targetFqdn) throw new Error("CloudMapRegistry.registerAlias: targetFqdn must be a non-empty string.");
51411
+ this.aliasIndex.set(alias, targetFqdn);
51412
+ }
51413
+ /**
51414
+ * Remove a single endpoint registered under the supplied handle.
51415
+ * Idempotent — unknown handles return false without throwing.
51416
+ */
51417
+ unregister(handle) {
51418
+ const entries = this.byFqdn.get(handle.fqdn);
51419
+ if (!entries) return false;
51420
+ const filtered = entries.filter((e) => e.endpoint.ownerKey !== handle.ownerKey);
51421
+ if (filtered.length === entries.length) return false;
51422
+ if (filtered.length === 0) this.byFqdn.delete(handle.fqdn);
51423
+ else this.byFqdn.set(handle.fqdn, filtered);
51424
+ return true;
51425
+ }
51426
+ /**
51427
+ * Drop every registration with the supplied owner key (e.g. teardown
51428
+ * of every replica of a service). Used by the service controller's
51429
+ * shutdown path; complementary to per-replica `unregister`.
51430
+ */
51431
+ unregisterByOwner(ownerKeyPrefix) {
51432
+ let removed = 0;
51433
+ for (const [fqdn, entries] of [...this.byFqdn.entries()]) {
51434
+ const filtered = entries.filter((e) => !e.endpoint.ownerKey.startsWith(ownerKeyPrefix));
51435
+ removed += entries.length - filtered.length;
51436
+ if (filtered.length === 0) this.byFqdn.delete(fqdn);
51437
+ else this.byFqdn.set(fqdn, filtered);
51438
+ }
51439
+ return removed;
51440
+ }
51441
+ /**
51442
+ * Look up every endpoint registered under `{discoveryName}.{namespace}`.
51443
+ * Returns `undefined` when no endpoint exists (which is the consumer
51444
+ * runner's signal to log a warn — the user likely forgot to start the
51445
+ * producer). Returns the underlying array verbatim so callers cannot
51446
+ * accidentally mutate registry state — call `.slice()` if you need a
51447
+ * detachable copy.
51448
+ */
51449
+ lookup(namespace, discoveryName) {
51450
+ const fqdn = `${discoveryName}.${namespace}`;
51451
+ const entries = this.byFqdn.get(fqdn);
51452
+ if (!entries || entries.length === 0) return void 0;
51453
+ return entries.map((e) => e.endpoint);
51454
+ }
51455
+ /**
51456
+ * Resolve a bare alias to its target endpoints. Returns the same
51457
+ * shape as `lookup` for the alias's target fqdn, or `undefined` when
51458
+ * no such alias exists (which is distinct from "alias known but
51459
+ * target has no live replica" — caller may want different warns).
51460
+ */
51461
+ lookupAlias(alias) {
51462
+ const fqdn = this.aliasIndex.get(alias);
51463
+ if (!fqdn) return void 0;
51464
+ const entries = this.byFqdn.get(fqdn);
51465
+ if (!entries || entries.length === 0) return void 0;
51466
+ return entries.map((e) => e.endpoint);
51467
+ }
51468
+ /**
51469
+ * Build the `--add-host` flag list for a consumer container's
51470
+ * `docker run`. Returns each unique `(hostname, ip)` pair as a flat
51471
+ * `['--add-host', 'name:ip', ...]` array consumable verbatim by the
51472
+ * docker-runner. Includes both the fqdn AND every alias mapped to it.
51473
+ *
51474
+ * Multiple replicas per fqdn cannot be expressed as multiple
51475
+ * `--add-host` entries with the same name (docker's resolver takes
51476
+ * the *last* entry on duplicate keys per `getent hosts` semantics),
51477
+ * so this returns the **first** registered endpoint per fqdn /
51478
+ * alias. Multi-instance round-robin via the static `--add-host`
51479
+ * shape is structurally impossible; a true rotation requires the
51480
+ * DNS-sidecar option (deferred). Documented as a v1 limitation.
51481
+ */
51482
+ buildAddHostFlags(excludeOwnerKeyPrefix) {
51483
+ const flags = [];
51484
+ const seen = /* @__PURE__ */ new Set();
51485
+ for (const [fqdn, entries] of this.byFqdn.entries()) {
51486
+ const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
51487
+ if (!candidate) continue;
51488
+ if (seen.has(fqdn)) continue;
51489
+ flags.push("--add-host", `${fqdn}:${candidate.endpoint.ip}`);
51490
+ seen.add(fqdn);
51491
+ }
51492
+ for (const [alias, targetFqdn] of this.aliasIndex.entries()) {
51493
+ if (seen.has(alias)) continue;
51494
+ const entries = this.byFqdn.get(targetFqdn);
51495
+ if (!entries || entries.length === 0) continue;
51496
+ const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
51497
+ if (!candidate) continue;
51498
+ flags.push("--add-host", `${alias}:${candidate.endpoint.ip}`);
51499
+ seen.add(alias);
51500
+ }
51501
+ return flags;
51502
+ }
51503
+ /**
51504
+ * Diagnostic snapshot used by the boot banner / test assertions.
51505
+ * Stable iteration order (insertion-order is preserved by JS Maps).
51506
+ */
51507
+ list() {
51508
+ const out = [];
51509
+ for (const [, entries] of this.byFqdn.entries()) {
51510
+ if (entries.length === 0) continue;
51511
+ const first = entries[0];
51512
+ out.push({
51513
+ namespace: first.namespace,
51514
+ discoveryName: first.discoveryName,
51515
+ endpoints: entries.map((e) => e.endpoint),
51516
+ isAlias: false
51517
+ });
51518
+ }
51519
+ for (const [alias, fqdn] of this.aliasIndex.entries()) {
51520
+ const entries = this.byFqdn.get(fqdn);
51521
+ if (!entries || entries.length === 0) continue;
51522
+ out.push({
51523
+ namespace: "",
51524
+ discoveryName: alias,
51525
+ endpoints: entries.map((e) => e.endpoint),
51526
+ isAlias: true
51527
+ });
51528
+ }
51529
+ return out;
51530
+ }
51531
+ /** True when no endpoint is registered. Used by the runner to short-circuit. */
51532
+ isEmpty() {
51533
+ return this.byFqdn.size === 0;
51534
+ }
51535
+ };
51536
+
51537
+ //#endregion
51538
+ //#region src/local/cloud-map-resolver.ts
51539
+ /**
51540
+ * Build the `CloudMapIndex` for one stack's template. Empty index when
51541
+ * the stack declares no `AWS::ServiceDiscovery::*` resources.
51542
+ *
51543
+ * Hard-reject errors (throw `EcsTaskResolutionError`):
51544
+ * - `AWS::ServiceDiscovery::PublicDnsNamespace` — defeats "local" semantics.
51545
+ * - `AWS::ServiceDiscovery::HttpNamespace` — DiscoverInstances-only,
51546
+ * no DNS, would require shimming the AWS SDK inside every container.
51547
+ * - An `AWS::ServiceDiscovery::Service` with a `NamespaceId` that
51548
+ * doesn't resolve to a same-stack `PrivateDnsNamespace`.
51549
+ */
51550
+ function buildCloudMapIndex(stack) {
51551
+ const namespacesByLogicalId = /* @__PURE__ */ new Map();
51552
+ const namespacesByName = /* @__PURE__ */ new Map();
51553
+ const servicesByLogicalId = /* @__PURE__ */ new Map();
51554
+ const warnings = [];
51555
+ const resources = stack.template.Resources ?? {};
51556
+ for (const [logicalId, resource] of Object.entries(resources)) {
51557
+ 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.`);
51558
+ 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.`);
51559
+ if (resource.Type !== "AWS::ServiceDiscovery::PrivateDnsNamespace") continue;
51560
+ const props = resource.Properties ?? {};
51561
+ const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
51562
+ 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).`);
51563
+ const entry = {
51564
+ logicalId,
51565
+ name
51566
+ };
51567
+ namespacesByLogicalId.set(logicalId, entry);
51568
+ 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.`);
51569
+ else namespacesByName.set(name, entry);
51570
+ }
51571
+ for (const [logicalId, resource] of Object.entries(resources)) {
51572
+ if (resource.Type !== "AWS::ServiceDiscovery::Service") continue;
51573
+ const props = resource.Properties ?? {};
51574
+ const namespaceLogicalId = resolveNamespaceIdRef(props["NamespaceId"] ?? props["DnsConfig"]?.["NamespaceId"], stack.stackName, logicalId);
51575
+ const ns = namespacesByLogicalId.get(namespaceLogicalId);
51576
+ 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.`);
51577
+ const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
51578
+ 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.`);
51579
+ const dnsRecords = extractDnsRecords(props);
51580
+ servicesByLogicalId.set(logicalId, {
51581
+ logicalId,
51582
+ namespaceLogicalId,
51583
+ namespaceName: ns.name,
51584
+ name,
51585
+ dnsRecords
51586
+ });
51587
+ }
51588
+ return {
51589
+ namespacesByLogicalId,
51590
+ namespacesByName,
51591
+ servicesByLogicalId,
51592
+ warnings
51593
+ };
51594
+ }
51595
+ /**
51596
+ * Resolve `NamespaceId` to the parent's logical id. CDK 2.x synthesizes
51597
+ * this as `{Fn::GetAtt: ['<NsLogicalId>', 'Id']}` (verified via real
51598
+ * `cdk synth` on 2026-05-22). `Ref` is also accepted defensively
51599
+ * (returns the namespace's physical id, but inside one synth template
51600
+ * the Ref target IS the logical id we want).
51601
+ *
51602
+ * Cross-stack / intrinsic shapes that we cannot resolve at synth time
51603
+ * are hard-rejected — cdkd would otherwise silently route to no
51604
+ * namespace and the consumer would get an unhelpful "DNS lookup failed"
51605
+ * at runtime.
51606
+ */
51607
+ function resolveNamespaceIdRef(raw, stackName, serviceLogicalId) {
51608
+ if (typeof raw === "string") return raw;
51609
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
51610
+ const obj = raw;
51611
+ if (typeof obj["Ref"] === "string") return obj["Ref"];
51612
+ const getAtt = obj["Fn::GetAtt"];
51613
+ if (Array.isArray(getAtt) && typeof getAtt[0] === "string") return getAtt[0];
51614
+ }
51615
+ 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.`);
51616
+ }
51617
+ function extractDnsRecords(serviceProps) {
51618
+ const dnsConfig = serviceProps["DnsConfig"];
51619
+ if (!dnsConfig || typeof dnsConfig !== "object") return [];
51620
+ const records = dnsConfig["DnsRecords"];
51621
+ if (!Array.isArray(records)) return [];
51622
+ const out = [];
51623
+ for (const r of records) {
51624
+ if (!r || typeof r !== "object") continue;
51625
+ const obj = r;
51626
+ const type = obj["Type"];
51627
+ if (type !== "A" && type !== "SRV") continue;
51628
+ const ttl = obj["TTL"];
51629
+ const ttlSeconds = typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? Math.floor(ttl) : typeof ttl === "string" && /^\d+$/.test(ttl) ? parseInt(ttl, 10) : 60;
51630
+ out.push({
51631
+ type,
51632
+ ttlSeconds
51633
+ });
51634
+ }
51635
+ return out;
51636
+ }
51637
+
50819
51638
  //#endregion
50820
51639
  //#region src/cli/commands/local-start-service.ts
50821
51640
  /**
@@ -50830,17 +51649,34 @@ function sleep(ms) {
50830
51649
  * - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
50831
51650
  * - Service Connect / Cloud Map — tracked separately in #460.
50832
51651
  */
50833
- async function localStartServiceCommand(target, options) {
51652
+ async function localStartServiceCommand(targets, options) {
50834
51653
  const logger = getLogger();
50835
51654
  if (options.verbose) logger.setLevel("debug");
50836
51655
  warnIfDeprecatedRegion(options);
50837
- const runState = createServiceRunState();
51656
+ 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'.");
51657
+ const perTarget = targets.map((t) => ({
51658
+ target: t,
51659
+ runState: createServiceRunState()
51660
+ }));
50838
51661
  let sigintHandler;
50839
51662
  let sigintCount = 0;
50840
- let controller;
51663
+ let sharedNetwork;
50841
51664
  const cleanup = singleFlight(async () => {
50842
- if (controller) await controller.shutdown();
50843
- else await Promise.allSettled(runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
51665
+ await Promise.allSettled(perTarget.map(async (pt) => {
51666
+ if (pt.controller) await pt.controller.shutdown();
51667
+ else {
51668
+ await Promise.allSettled(pt.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0));
51669
+ await Promise.allSettled(pt.runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
51670
+ }
51671
+ }));
51672
+ if (sharedNetwork) {
51673
+ try {
51674
+ await destroyTaskNetwork(sharedNetwork);
51675
+ } catch (err) {
51676
+ getLogger().warn(`shared service network teardown failed: ${err instanceof Error ? err.message : String(err)}`);
51677
+ }
51678
+ sharedNetwork = void 0;
51679
+ }
50844
51680
  }, (err) => getLogger().warn(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
50845
51681
  try {
50846
51682
  await applyRoleArnIfSet({
@@ -50863,72 +51699,42 @@ async function localStartServiceCommand(target, options) {
50863
51699
  ...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
50864
51700
  };
50865
51701
  const { stacks } = await synthesizer.synthesize(synthOpts);
50866
- const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
50867
- const service = resolveEcsServiceTarget(target, stacks, imageContext);
50868
- logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
50869
- const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
50870
- if (options.fromState && taskNeeds.needsCrossStackResolver) {
50871
- const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
50872
- const built = await buildCrossStackResolver(consumerRegion, {
50873
- ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
50874
- statePrefix: options.statePrefix,
50875
- ...options.region !== void 0 && { region: options.region },
50876
- ...options.profile !== void 0 && { profile: options.profile }
51702
+ const cloudMapIndexByStack = /* @__PURE__ */ new Map();
51703
+ for (const stack of stacks) {
51704
+ const index = buildCloudMapIndex(stack);
51705
+ cloudMapIndexByStack.set(stack.stackName, index);
51706
+ for (const w of index.warnings) logger.warn(w);
51707
+ }
51708
+ const registry = new CloudMapRegistry();
51709
+ try {
51710
+ sharedNetwork = await createSharedSvcNetwork({
51711
+ prefix: options.cluster,
51712
+ skipPull: options.pull === false,
51713
+ cluster: options.cluster
50877
51714
  });
50878
- if (built) try {
50879
- const subContext = {
50880
- resources: imageContext?.stateResources ?? {},
50881
- ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
50882
- consumerRegion,
50883
- crossStackResolver: built.resolver
50884
- };
50885
- await applyCrossStackResolverToTask(service.task, subContext);
50886
- } finally {
50887
- built.dispose();
50888
- }
50889
- } else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
51715
+ } catch (err) {
51716
+ throw new LocalStartServiceError(`Failed to create shared service network: ${err instanceof Error ? err.message : String(err)}`);
51717
+ }
51718
+ const discovery = {
51719
+ registry,
51720
+ cloudMapIndexByStack,
51721
+ sharedNetwork
51722
+ };
50890
51723
  sigintHandler = () => {
50891
51724
  sigintCount += 1;
50892
51725
  if (sigintCount >= 2) {
50893
51726
  process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
50894
51727
  process.exit(130);
50895
51728
  }
50896
- logger.info("Stopping service...");
51729
+ logger.info("Stopping service(s)...");
50897
51730
  cleanup().then(() => process.exit(130));
50898
51731
  };
50899
51732
  process.on("SIGINT", sigintHandler);
50900
51733
  process.on("SIGTERM", sigintHandler);
50901
- let assumedCredentials;
50902
- let resolvedRoleArn;
50903
- if (options.assumeTaskRole === true) {
50904
- if (!service.task.taskRoleArn) throw new LocalStartServiceError("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>");
50905
- resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
50906
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
50907
- } else if (typeof options.assumeTaskRole === "string") {
50908
- resolvedRoleArn = options.assumeTaskRole;
50909
- assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
50910
- }
50911
- const envOverrides = readEnvOverridesFile$1(options.envVars);
50912
- const taskOpts = {
50913
- cluster: options.cluster,
50914
- containerHost: options.containerHost,
50915
- skipPull: options.pull === false,
50916
- keepRunning: false,
50917
- detach: true
50918
- };
50919
- if (envOverrides) taskOpts.envOverrides = envOverrides;
50920
- if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
50921
- if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
50922
- if (options.platform) taskOpts.platformOverride = options.platform;
50923
- if (options.region) taskOpts.region = options.region;
50924
- if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
50925
- controller = await startEcsService(service, {
50926
- maxTasks: options.maxTasks,
50927
- restartPolicy: options.restartPolicy,
50928
- taskOptions: taskOpts
50929
- }, runState);
50930
- logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
50931
- await controller.waitForShutdown();
51734
+ for (const pt of perTarget) pt.controller = await bootOneTarget(pt.target, pt.runState, stacks, options, discovery);
51735
+ const summary = perTarget.map((pt) => `${pt.controller.service.serviceName} (${pt.controller.activeReplicaCount()} replica(s))`).join(", ");
51736
+ logger.info(`Service(s) running: ${summary}. Press ^C to shut down.`);
51737
+ await Promise.all(perTarget.map((pt) => pt.controller.waitForShutdown()));
50932
51738
  } finally {
50933
51739
  if (sigintHandler) {
50934
51740
  process.off("SIGINT", sigintHandler);
@@ -50937,6 +51743,72 @@ async function localStartServiceCommand(target, options) {
50937
51743
  await cleanup();
50938
51744
  }
50939
51745
  }
51746
+ /**
51747
+ * Boot one target. Extracted from the loop so each per-service block
51748
+ * (image context, cross-stack resolver, task-role credentials, runner
51749
+ * options) is scoped locally. Returns the started controller for the
51750
+ * outer code to wait + tear down.
51751
+ */
51752
+ async function bootOneTarget(target, runState, stacks, options, discovery) {
51753
+ const logger = getLogger();
51754
+ const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
51755
+ const service = resolveEcsServiceTarget(target, stacks, imageContext);
51756
+ logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
51757
+ for (const w of service.warnings) logger.warn(w);
51758
+ if (service.serviceConnect) logger.info(`Service Connect: namespace='${service.serviceConnect.namespaceName}', ${service.serviceConnect.services.length} service(s) registered for peer discovery.`);
51759
+ if (service.serviceRegistries.length > 0) logger.info(`Cloud Map: ${service.serviceRegistries.length} ServiceRegistry binding(s).`);
51760
+ const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
51761
+ if (options.fromState && taskNeeds.needsCrossStackResolver) {
51762
+ const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
51763
+ const built = await buildCrossStackResolver(consumerRegion, {
51764
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
51765
+ statePrefix: options.statePrefix,
51766
+ ...options.region !== void 0 && { region: options.region },
51767
+ ...options.profile !== void 0 && { profile: options.profile }
51768
+ });
51769
+ if (built) try {
51770
+ const subContext = {
51771
+ resources: imageContext?.stateResources ?? {},
51772
+ ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
51773
+ consumerRegion,
51774
+ crossStackResolver: built.resolver
51775
+ };
51776
+ await applyCrossStackResolverToTask(service.task, subContext);
51777
+ } finally {
51778
+ built.dispose();
51779
+ }
51780
+ } 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.");
51781
+ let assumedCredentials;
51782
+ let resolvedRoleArn;
51783
+ if (options.assumeTaskRole === true) {
51784
+ 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>`);
51785
+ resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
51786
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
51787
+ } else if (typeof options.assumeTaskRole === "string") {
51788
+ resolvedRoleArn = options.assumeTaskRole;
51789
+ assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
51790
+ }
51791
+ const envOverrides = readEnvOverridesFile$1(options.envVars);
51792
+ const taskOpts = {
51793
+ cluster: options.cluster,
51794
+ containerHost: options.containerHost,
51795
+ skipPull: options.pull === false,
51796
+ keepRunning: false,
51797
+ detach: true
51798
+ };
51799
+ if (envOverrides) taskOpts.envOverrides = envOverrides;
51800
+ if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
51801
+ if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
51802
+ if (options.platform) taskOpts.platformOverride = options.platform;
51803
+ if (options.region) taskOpts.region = options.region;
51804
+ if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
51805
+ return startEcsService(service, {
51806
+ maxTasks: options.maxTasks,
51807
+ restartPolicy: options.restartPolicy,
51808
+ taskOptions: taskOpts,
51809
+ discovery
51810
+ }, runState);
51811
+ }
50940
51812
  async function resolvePlaceholderAccount(arn, region) {
50941
51813
  if (!arn.includes("${AWS::AccountId}")) return arn;
50942
51814
  const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
@@ -51079,7 +51951,7 @@ function parseRestartPolicy(raw) {
51079
51951
  throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
51080
51952
  }
51081
51953
  function createLocalStartServiceCommand() {
51082
- const cmd = new Command("start-service").description("Run an AWS::ECS::Service locally as a long-running emulator. Spins up DesiredCount task replicas (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Target accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::Service to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed task role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localStartServiceCommand));
51954
+ const cmd = new Command("start-service").description("Run one or more AWS::ECS::Service resources locally as a long-running emulator. Spins up DesiredCount task replicas per service (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Each <target> accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix. When two or more <target>s are supplied, every service is booted into a shared Cloud Map / Service Connect registry so peer services discover each other via docker --add-host overlay (Issue #460).").argument("<targets...>", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ECS::Service resources to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed task role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localStartServiceCommand));
51083
51955
  [
51084
51956
  ...commonOptions,
51085
51957
  ...appOptions,
@@ -54262,7 +55134,7 @@ function reorderArgs(argv) {
54262
55134
  */
54263
55135
  async function main() {
54264
55136
  const program = new Command();
54265
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.138.0");
55137
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.140.0");
54266
55138
  program.addCommand(createBootstrapCommand());
54267
55139
  program.addCommand(createSynthCommand());
54268
55140
  program.addCommand(createListCommand());