@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-
|
|
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
|
-
*
|
|
27579
|
-
*
|
|
27580
|
-
*
|
|
27581
|
-
*
|
|
27582
|
-
*
|
|
27583
|
-
*
|
|
27584
|
-
*
|
|
27585
|
-
*
|
|
27586
|
-
*
|
|
27587
|
-
*
|
|
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,
|
|
27590
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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`
|
|
49362
|
-
*
|
|
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
|
-
*
|
|
49391
|
-
*
|
|
49392
|
-
*
|
|
49393
|
-
*
|
|
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
|
-
|
|
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
|
|
49398
|
-
|
|
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$
|
|
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
|
|
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 (
|
|
49432
|
-
sidecarEnv["AWS_ACCESS_KEY_ID"] =
|
|
49433
|
-
sidecarEnv["AWS_SECRET_ACCESS_KEY"] =
|
|
49434
|
-
if (
|
|
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 (
|
|
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$
|
|
49443
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
49714
|
-
|
|
49715
|
-
|
|
49716
|
-
};
|
|
49717
|
-
|
|
49718
|
-
|
|
49719
|
-
|
|
49720
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
50463
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
50728
|
-
|
|
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(
|
|
51652
|
+
async function localStartServiceCommand(targets, options) {
|
|
50834
51653
|
const logger = getLogger();
|
|
50835
51654
|
if (options.verbose) logger.setLevel("debug");
|
|
50836
51655
|
warnIfDeprecatedRegion(options);
|
|
50837
|
-
|
|
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
|
|
51663
|
+
let sharedNetwork;
|
|
50841
51664
|
const cleanup = singleFlight(async () => {
|
|
50842
|
-
|
|
50843
|
-
|
|
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
|
|
50867
|
-
const
|
|
50868
|
-
|
|
50869
|
-
|
|
50870
|
-
|
|
50871
|
-
|
|
50872
|
-
|
|
50873
|
-
|
|
50874
|
-
|
|
50875
|
-
|
|
50876
|
-
|
|
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
|
-
|
|
50879
|
-
|
|
50880
|
-
|
|
50881
|
-
|
|
50882
|
-
|
|
50883
|
-
|
|
50884
|
-
|
|
50885
|
-
|
|
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
|
-
|
|
50902
|
-
|
|
50903
|
-
|
|
50904
|
-
|
|
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
|
|
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.
|
|
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());
|