@fjall/components-infrastructure 0.96.0 → 0.99.3
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/lib/app.d.ts +68 -1
- package/dist/lib/app.js +113 -4
- package/dist/lib/config/aws/__t17fixture.d.ts +1 -0
- package/dist/lib/config/aws/__t17fixture.js +3 -0
- package/dist/lib/config/aws/__t17fixtureType.d.ts +2 -0
- package/dist/lib/config/aws/__t17fixtureType.js +1 -0
- package/dist/lib/config/aws/alarmTopic.js +8 -4
- package/dist/lib/config/aws/cloudTrail.js +1 -1
- package/dist/lib/config/aws/disasterRecovery.js +11 -16
- package/dist/lib/config/aws/ecrDefaultImage.d.ts +0 -1
- package/dist/lib/config/aws/ecrDefaultImage.js +13 -23
- package/dist/lib/config/aws/identityCenter.d.ts +10 -3
- package/dist/lib/config/aws/identityCenter.js +101 -37
- package/dist/lib/config/aws/identityCenterGroupMembership.js +8 -2
- package/dist/lib/config/aws/identityCenterMembership.d.ts +11 -0
- package/dist/lib/config/aws/identityCenterMembership.js +61 -0
- package/dist/lib/config/aws/index.d.ts +1 -1
- package/dist/lib/config/aws/index.js +1 -1
- package/dist/lib/config/aws/ipam.js +6 -11
- package/dist/lib/config/aws/oidcConnector.js +5 -1
- package/dist/lib/config/aws/scpPreset.js +4 -1
- package/dist/lib/patterns/aws/_eslint_test_tmp/leak.d.ts +1 -0
- package/dist/lib/patterns/aws/_eslint_test_tmp/leak.js +4 -0
- package/dist/lib/patterns/aws/account.js +2 -4
- package/dist/lib/patterns/aws/apexDomainPattern.js +10 -10
- package/dist/lib/patterns/aws/bastionFactory.d.ts +10 -0
- package/dist/lib/patterns/aws/bastionFactory.js +29 -0
- package/dist/lib/patterns/aws/buildkite.d.ts +2 -2
- package/dist/lib/patterns/aws/buildkite.js +51 -97
- package/dist/lib/patterns/aws/cdn.js +1 -1
- package/dist/lib/patterns/aws/clickhouseDatabase.d.ts +173 -0
- package/dist/lib/patterns/aws/clickhouseDatabase.js +601 -0
- package/dist/lib/patterns/aws/compute.d.ts +4 -6
- package/dist/lib/patterns/aws/compute.js +7 -13
- package/dist/lib/patterns/aws/computeEcs.d.ts +93 -5
- package/dist/lib/patterns/aws/computeEcs.js +867 -37
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +528 -25
- package/dist/lib/patterns/aws/computeEcsTypes.js +10 -0
- package/dist/lib/patterns/aws/computeLambda.d.ts +0 -5
- package/dist/lib/patterns/aws/computeLambda.js +1 -2
- package/dist/lib/patterns/aws/database.d.ts +50 -8
- package/dist/lib/patterns/aws/database.js +183 -27
- package/dist/lib/patterns/aws/domain.js +6 -4
- package/dist/lib/patterns/aws/index.d.ts +1 -0
- package/dist/lib/patterns/aws/index.js +1 -0
- package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -1
- package/dist/lib/patterns/aws/interfaces/database.d.ts +187 -8
- package/dist/lib/patterns/aws/interfaces/database.js +17 -3
- package/dist/lib/patterns/aws/interfaces/index.d.ts +2 -1
- package/dist/lib/patterns/aws/interfaces/index.js +3 -1
- package/dist/lib/patterns/aws/interfaces/messaging.d.ts +7 -0
- package/dist/lib/patterns/aws/interfaces/migrationContributor.d.ts +47 -0
- package/dist/lib/patterns/aws/interfaces/migrationContributor.js +9 -0
- package/dist/lib/patterns/aws/messaging.d.ts +66 -10
- package/dist/lib/patterns/aws/messaging.js +115 -20
- package/dist/lib/patterns/aws/network.js +16 -7
- package/dist/lib/patterns/aws/organisation.d.ts +4 -0
- package/dist/lib/patterns/aws/organisation.js +22 -4
- package/dist/lib/patterns/aws/storage.d.ts +1 -2
- package/dist/lib/patterns/aws/storage.js +3 -2
- package/dist/lib/patterns/aws/vpcPeer.js +3 -1
- package/dist/lib/resources/aws/analytics/clickhouse.js +18 -9
- package/dist/lib/resources/aws/analytics/clickhouseAlarms.d.ts +24 -9
- package/dist/lib/resources/aws/analytics/clickhouseAlarms.js +61 -10
- package/dist/lib/resources/aws/analytics/clickhouseConstants.d.ts +3 -3
- package/dist/lib/resources/aws/analytics/clickhouseConstants.js +3 -3
- package/dist/lib/resources/aws/analytics/clickhouseTypes.d.ts +7 -1
- package/dist/lib/resources/aws/analytics/clickhouseUserData.d.ts +1 -1
- package/dist/lib/resources/aws/analytics/clickhouseUserData.js +53 -3
- package/dist/lib/resources/aws/base/awsStack.js +4 -2
- package/dist/lib/resources/aws/compute/__tmp__/regression-shape.d.ts +2 -0
- package/dist/lib/resources/aws/compute/__tmp__/regression-shape.js +11 -0
- package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.d.ts +52 -0
- package/dist/lib/resources/aws/compute/asgInlineLifecycleHook.js +60 -0
- package/dist/lib/resources/aws/compute/blockDeviceVolume.d.ts +8 -0
- package/dist/lib/resources/aws/compute/blockDeviceVolume.js +10 -0
- package/dist/lib/resources/aws/compute/ec2.d.ts +132 -12
- package/dist/lib/resources/aws/compute/ec2.js +163 -23
- package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.d.ts +41 -0
- package/dist/lib/resources/aws/compute/ec2GracefulTerminationHandler.js +194 -0
- package/dist/lib/resources/aws/compute/ec2GracefulTerminationLambda.source.cjs +458 -0
- package/dist/lib/resources/aws/compute/ecs.d.ts +27 -1
- package/dist/lib/resources/aws/compute/ecs.js +42 -2
- package/dist/lib/resources/aws/compute/ecsConstants.d.ts +9 -0
- package/dist/lib/resources/aws/compute/ecsConstants.js +16 -0
- package/dist/lib/resources/aws/compute/ecsImages.js +32 -20
- package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.d.ts +96 -0
- package/dist/lib/resources/aws/compute/ecsLifecycleHookMigration.js +113 -0
- package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -1
- package/dist/lib/resources/aws/compute/ecsNetworking.js +18 -6
- package/dist/lib/resources/aws/compute/ecsServiceFactory.d.ts +13 -4
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +155 -33
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.d.ts +31 -1
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +102 -6
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +173 -13
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +9 -0
- package/dist/lib/resources/aws/compute/ecsValidation.js +63 -0
- package/dist/lib/resources/aws/compute/index.d.ts +2 -0
- package/dist/lib/resources/aws/compute/index.js +2 -0
- package/dist/lib/resources/aws/compute/lambda.d.ts +7 -13
- package/dist/lib/resources/aws/compute/lambda.js +30 -38
- package/dist/lib/resources/aws/compute/lifecycleHookLambda.source.cjs +192 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +104 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +245 -0
- package/dist/lib/resources/aws/compute/persistentDataVolumeLambda.source.cjs +398 -0
- package/dist/lib/resources/aws/compute/samApplication.d.ts +15 -0
- package/dist/lib/resources/aws/compute/samApplication.js +27 -0
- package/dist/lib/resources/aws/database/clickhouseConstants.d.ts +159 -0
- package/dist/lib/resources/aws/database/clickhouseConstants.js +181 -0
- package/dist/lib/resources/aws/database/clickhouseSchemas.d.ts +71 -0
- package/dist/lib/resources/aws/database/clickhouseSchemas.js +160 -0
- package/dist/lib/resources/aws/database/clickhouseSecurityGroup.d.ts +14 -0
- package/dist/lib/resources/aws/database/clickhouseSecurityGroup.js +23 -0
- package/dist/lib/resources/aws/database/clickhouseUserData.d.ts +69 -0
- package/dist/lib/resources/aws/database/clickhouseUserData.js +371 -0
- package/dist/lib/resources/aws/database/clickhouseXmlRenderer.d.ts +56 -0
- package/dist/lib/resources/aws/database/clickhouseXmlRenderer.js +112 -0
- package/dist/lib/resources/aws/database/rdsAurora.d.ts +8 -1
- package/dist/lib/resources/aws/database/rdsAurora.js +42 -32
- package/dist/lib/resources/aws/database/rdsAuroraGlobal.d.ts +15 -2
- package/dist/lib/resources/aws/database/rdsAuroraGlobal.js +39 -43
- package/dist/lib/resources/aws/database/rdsDefaults.d.ts +6 -0
- package/dist/lib/resources/aws/database/rdsDefaults.js +7 -1
- package/dist/lib/resources/aws/database/rdsHelpers.d.ts +3 -3
- package/dist/lib/resources/aws/database/rdsHelpers.js +1 -0
- package/dist/lib/resources/aws/database/rdsInstance.d.ts +8 -1
- package/dist/lib/resources/aws/database/rdsInstance.js +51 -34
- package/dist/lib/resources/aws/database/rdsProxyOutput.d.ts +1 -1
- package/dist/lib/resources/aws/database/rdsProxyOutput.js +1 -1
- package/dist/lib/resources/aws/iam/delegationRole.js +1 -1
- package/dist/lib/resources/aws/iam/identityCenter/groupMembership.d.ts +9 -0
- package/dist/lib/resources/aws/iam/identityCenter/groupMembership.js +12 -0
- package/dist/lib/resources/aws/iam/identityCenter/index.d.ts +1 -0
- package/dist/lib/resources/aws/iam/identityCenter/index.js +1 -0
- package/dist/lib/resources/aws/iam/identityCenter/permissionSet.d.ts +1 -0
- package/dist/lib/resources/aws/iam/identityCenter/permissionSet.js +1 -0
- package/dist/lib/resources/aws/logging/logGroup.d.ts +0 -8
- package/dist/lib/resources/aws/logging/logGroup.js +0 -11
- package/dist/lib/resources/aws/messaging/defaultEventBus.d.ts +7 -0
- package/dist/lib/resources/aws/messaging/defaultEventBus.js +21 -0
- package/dist/lib/resources/aws/messaging/eventBridgeRule.d.ts +96 -0
- package/dist/lib/resources/aws/messaging/eventBridgeRule.js +110 -0
- package/dist/lib/resources/aws/messaging/eventTargets.d.ts +84 -0
- package/dist/lib/resources/aws/messaging/eventTargets.js +152 -0
- package/dist/lib/resources/aws/messaging/eventbridge.d.ts +25 -2
- package/dist/lib/resources/aws/messaging/eventbridge.js +22 -10
- package/dist/lib/resources/aws/messaging/index.d.ts +5 -0
- package/dist/lib/resources/aws/messaging/index.js +2 -0
- package/dist/lib/resources/aws/messaging/schedule.d.ts +118 -0
- package/dist/lib/resources/aws/messaging/schedule.js +64 -0
- package/dist/lib/resources/aws/messaging/sns.d.ts +2 -1
- package/dist/lib/resources/aws/messaging/sqs.d.ts +2 -1
- package/dist/lib/resources/aws/messaging/subscription.d.ts +112 -0
- package/dist/lib/resources/aws/messaging/subscription.js +67 -0
- package/dist/lib/resources/aws/messaging/utils.d.ts +6 -0
- package/dist/lib/resources/aws/messaging/utils.js +10 -0
- package/dist/lib/resources/aws/monitoring/clickhouseAlarms.d.ts +60 -0
- package/dist/lib/resources/aws/monitoring/clickhouseAlarms.js +139 -0
- package/dist/lib/resources/aws/monitoring/index.d.ts +2 -0
- package/dist/lib/resources/aws/monitoring/index.js +2 -0
- package/dist/lib/resources/aws/monitoring/scheduleAlarms.d.ts +47 -0
- package/dist/lib/resources/aws/monitoring/scheduleAlarms.js +106 -0
- package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +6 -4
- package/dist/lib/resources/aws/networking/crossAccountReturnRoutes.js +17 -13
- package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +7 -5
- package/dist/lib/resources/aws/networking/domainCertificate.d.ts +2 -2
- package/dist/lib/resources/aws/networking/domainCertificate.js +6 -4
- package/dist/lib/resources/aws/networking/hostedZone.js +6 -5
- package/dist/lib/resources/aws/networking/serviceDiscovery.d.ts +96 -0
- package/dist/lib/resources/aws/networking/serviceDiscovery.js +96 -0
- package/dist/lib/resources/aws/networking/vpc.d.ts +4 -1
- package/dist/lib/resources/aws/networking/vpc.js +4 -1
- package/dist/lib/resources/aws/networking/vpcPeeringConnection.js +21 -3
- package/dist/lib/resources/aws/organisation/costAllocationTagActivator.d.ts +16 -5
- package/dist/lib/resources/aws/organisation/costAllocationTagActivator.js +17 -3
- package/dist/lib/resources/aws/organisation/index.d.ts +1 -1
- package/dist/lib/resources/aws/organisation/organisationPolicy.d.ts +2 -0
- package/dist/lib/resources/aws/organisation/organisationPolicy.js +3 -2
- package/dist/lib/resources/aws/secrets/secret.d.ts +7 -0
- package/dist/lib/resources/aws/secrets/secret.js +4 -3
- package/dist/lib/resources/aws/storage/bucketDeployment.d.ts +16 -0
- package/dist/lib/resources/aws/storage/bucketDeployment.js +17 -0
- package/dist/lib/resources/aws/storage/ecr.js +5 -5
- package/dist/lib/resources/aws/storage/index.d.ts +1 -0
- package/dist/lib/resources/aws/storage/index.js +1 -0
- package/dist/lib/resources/aws/storage/s3.js +10 -3
- package/dist/lib/resources/aws/utilities/customResource.js +18 -9
- package/dist/lib/synth_dump.d.ts +1 -0
- package/dist/lib/synth_dump.js +42 -0
- package/dist/lib/utils/cdkContext.d.ts +2 -0
- package/dist/lib/utils/cdkContext.js +4 -2
- package/dist/lib/utils/connections.js +6 -0
- package/dist/lib/utils/connector.d.ts +12 -0
- package/dist/lib/utils/costAllocationTags.d.ts +9 -0
- package/dist/lib/utils/costAllocationTags.js +11 -1
- package/dist/lib/utils/databaseTypes.d.ts +14 -0
- package/dist/lib/utils/getConfig.d.ts +2 -0
- package/dist/lib/utils/getConfig.js +2 -0
- package/dist/lib/utils/index.d.ts +1 -0
- package/dist/lib/utils/index.js +1 -0
- package/dist/lib/utils/manifestWriter.d.ts +6 -89
- package/dist/lib/utils/manifestWriter.js +36 -23
- package/dist/lib/utils/migrationVersionResolvers.d.ts +2 -0
- package/dist/lib/utils/migrationVersionResolvers.js +2 -0
- package/dist/lib/utils/orgConfigParser.js +2 -1
- package/dist/lib/utils/resolveAlertsTopic.d.ts +14 -0
- package/dist/lib/utils/resolveAlertsTopic.js +30 -0
- package/dist/lib/utils/validationLogger.js +6 -3
- package/package.json +22 -19
|
@@ -1,11 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { AwsLogDriver, ContainerImage, DeploymentLifecycleStage, Secret as EcsSecret } from "aws-cdk-lib/aws-ecs";
|
|
2
|
+
import { Peer, Port, SubnetType } from "aws-cdk-lib/aws-ec2";
|
|
3
|
+
import { Effect, Grant, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import { Annotations, Stack } from "aws-cdk-lib";
|
|
5
|
+
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
6
|
+
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
|
|
7
|
+
import { StringParameter } from "aws-cdk-lib/aws-ssm";
|
|
3
8
|
import { Construct } from "constructs";
|
|
4
|
-
import {
|
|
9
|
+
import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
|
|
10
|
+
import { EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, isDatabase, isRelationalDatabase } from "./interfaces/database.js";
|
|
11
|
+
import { isConnectionConfig } from "../../utils/connector.js";
|
|
12
|
+
import { isMigrationContributor } from "./interfaces/migrationContributor.js";
|
|
13
|
+
import App from "../../app.js";
|
|
5
14
|
import EcsCluster from "../../resources/aws/compute/ecs.js";
|
|
15
|
+
import { createScheduledTaskDefinition, createMigrationTaskDefinition } from "../../resources/aws/compute/ecsTaskDefinition.js";
|
|
16
|
+
import { EcsLifecycleHookMigration } from "../../resources/aws/compute/ecsLifecycleHookMigration.js";
|
|
17
|
+
import { Role } from "../../resources/aws/iam/role.js";
|
|
18
|
+
import { LogGroup } from "../../resources/aws/logging/logGroup.js";
|
|
19
|
+
import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
|
|
20
|
+
import { vpcHasNatGateways } from "../../utils/vpcUtils.js";
|
|
21
|
+
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
6
22
|
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
7
23
|
import { VALIDATION_PATTERNS } from "@fjall/generator";
|
|
8
24
|
import { COMPUTE_DEFAULTS } from "./compute.js";
|
|
25
|
+
import { isHookMigrations } from "./computeEcsTypes.js";
|
|
9
26
|
export { ScalingType } from "./computeEcsTypes.js";
|
|
10
27
|
import { ScalingType } from "./computeEcsTypes.js";
|
|
11
28
|
export const ECS_CAPACITY_PROVIDER_CONFIG = {
|
|
@@ -25,6 +42,35 @@ export const ECS_CAPACITY_PROVIDER_CONFIG = {
|
|
|
25
42
|
export function getEcsCapacityProviderConfig(provider) {
|
|
26
43
|
return ECS_CAPACITY_PROVIDER_CONFIG[provider];
|
|
27
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Fargate task-size matrix (CPU → valid memory range in MiB).
|
|
47
|
+
*
|
|
48
|
+
* @see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/fargate-tasks-services.html#fargate-tasks-size
|
|
49
|
+
*/
|
|
50
|
+
const FARGATE_CPU_MEMORY_MATRIX = {
|
|
51
|
+
256: { min: 512, max: 2048 },
|
|
52
|
+
512: { min: 1024, max: 4096 },
|
|
53
|
+
1024: { min: 2048, max: 8192 },
|
|
54
|
+
2048: { min: 4096, max: 16384 },
|
|
55
|
+
4096: { min: 8192, max: 30720 },
|
|
56
|
+
8192: { min: 16384, max: 61440 },
|
|
57
|
+
16384: { min: 32768, max: 122880 }
|
|
58
|
+
};
|
|
59
|
+
const FARGATE_VALID_CPUS = Object.keys(FARGATE_CPU_MEMORY_MATRIX)
|
|
60
|
+
.map((k) => parseInt(k, 10))
|
|
61
|
+
.sort((a, b) => a - b);
|
|
62
|
+
function validateSeparateTaskDef(serviceName, separateTaskDef) {
|
|
63
|
+
const matrix = FARGATE_CPU_MEMORY_MATRIX[separateTaskDef.cpu];
|
|
64
|
+
if (!matrix) {
|
|
65
|
+
throw new Error(`Service '${serviceName}': migrations.separateTaskDef.cpu must be one of ${FARGATE_VALID_CPUS.join(", ")} ` +
|
|
66
|
+
`(got ${separateTaskDef.cpu}). See AWS Fargate task-size matrix.`);
|
|
67
|
+
}
|
|
68
|
+
if (separateTaskDef.memoryLimitMiB < matrix.min ||
|
|
69
|
+
separateTaskDef.memoryLimitMiB > matrix.max) {
|
|
70
|
+
throw new Error(`Service '${serviceName}': migrations.separateTaskDef.memoryLimitMiB (${separateTaskDef.memoryLimitMiB}) ` +
|
|
71
|
+
`is incompatible with cpu=${separateTaskDef.cpu} (allowed range: ${matrix.min}-${matrix.max} MiB).`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
28
74
|
/**
|
|
29
75
|
* Validates ECS-specific props.
|
|
30
76
|
* Extracted for clarity and detail parity with database/network patterns.
|
|
@@ -80,35 +126,397 @@ export function validateEcsProps(props) {
|
|
|
80
126
|
throw new Error(`Service '${service.name}' uses EC2 capacity provider but no ec2Config is defined. ` +
|
|
81
127
|
"Provide ec2Config on the service.");
|
|
82
128
|
}
|
|
83
|
-
// Warn if service ec2Config is defined but capacityProvider is not EC2
|
|
84
129
|
if (service.ec2Config && service.capacityProvider !== "EC2") {
|
|
85
130
|
FjallLogger.warn(`Service '${service.name}' has ec2Config but capacityProvider is not 'EC2'. ` +
|
|
86
131
|
"The ec2Config will be ignored unless capacityProvider is set to 'EC2'.");
|
|
87
132
|
}
|
|
133
|
+
if (service.deployment !== undefined) {
|
|
134
|
+
const min = service.deployment.minHealthyPercent;
|
|
135
|
+
const max = service.deployment.maxHealthyPercent;
|
|
136
|
+
if (min !== undefined && (min < 0 || min > 100)) {
|
|
137
|
+
throw new Error(`Service '${service.name}': deployment.minHealthyPercent must be between 0 and 100 (got ${min}).`);
|
|
138
|
+
}
|
|
139
|
+
if (max !== undefined && (max < 100 || max > 200)) {
|
|
140
|
+
throw new Error(`Service '${service.name}': deployment.maxHealthyPercent must be between 100 and 200 (got ${max}).`);
|
|
141
|
+
}
|
|
142
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
143
|
+
throw new Error(`Service '${service.name}': deployment.minHealthyPercent (${min}) must be <= maxHealthyPercent (${max}).`);
|
|
144
|
+
}
|
|
145
|
+
if (min === 100 && max === 100) {
|
|
146
|
+
throw new Error(`Service '${service.name}': deployment.minHealthyPercent and maxHealthyPercent cannot both be 100 ` +
|
|
147
|
+
"(no capacity to drain or expand — deploys would never roll forward).");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (service.migrations !== undefined &&
|
|
151
|
+
isHookMigrations(service.migrations) &&
|
|
152
|
+
service.migrations.separateTaskDef !== undefined) {
|
|
153
|
+
validateSeparateTaskDef(service.name, service.migrations.separateTaskDef);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (props.cluster?.directAccess === true) {
|
|
157
|
+
const hasEc2Service = props.services.some((s) => s.capacityProvider === "EC2");
|
|
158
|
+
if (!hasEc2Service) {
|
|
159
|
+
throw new Error("cluster.directAccess: true requires at least one service with capacityProvider: 'EC2'. " +
|
|
160
|
+
"directAccess opens host-network ports on EC2 instances and has no effect on Fargate services.");
|
|
161
|
+
}
|
|
162
|
+
if (props.cluster.securityGroup !== undefined) {
|
|
163
|
+
throw new Error("cluster.directAccess: true cannot be combined with a caller-supplied cluster.securityGroup. " +
|
|
164
|
+
"directAccess adds 0.0.0.0/0 ingress rules to the ASG security group; routing those rules onto an " +
|
|
165
|
+
"externally-owned security group couples this construct's permissions onto a SG whose lifecycle it " +
|
|
166
|
+
"does not own. Either omit cluster.securityGroup (a Fjall-owned SG will be created) or set directAccess: false.");
|
|
167
|
+
}
|
|
88
168
|
}
|
|
89
169
|
}
|
|
170
|
+
const DEFAULT_MIGRATE_CONTAINER_NAME = "migrate";
|
|
171
|
+
function lifecycleStageForHookMode(mode) {
|
|
172
|
+
switch (mode) {
|
|
173
|
+
case "lifecycle-hook":
|
|
174
|
+
return DeploymentLifecycleStage.PRE_SCALE_UP;
|
|
175
|
+
case "post-deploy":
|
|
176
|
+
return DeploymentLifecycleStage.POST_SCALE_UP;
|
|
177
|
+
default: {
|
|
178
|
+
const _exhaustive = mode;
|
|
179
|
+
throw new Error(`Unsupported hook migration mode: ${String(_exhaustive)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Expand a service's `migrations` sugar into a synthetic init container plus
|
|
185
|
+
* auto-injected `dependsOn` entries on every other container.
|
|
186
|
+
*
|
|
187
|
+
* - Synthesises a non-essential container that runs the migration command and exits.
|
|
188
|
+
* - Inherits image / environment / secrets / secretsImport from the primary container
|
|
189
|
+
* (first container with a port, or first container if none have a port).
|
|
190
|
+
* - Auto-wires every other container to wait on the migrate container's `SUCCESS`,
|
|
191
|
+
* skipping containers that already declare the dependency to keep user overrides intact.
|
|
192
|
+
* - Throws on name collision when a user-defined container shares the migrate name.
|
|
193
|
+
*
|
|
194
|
+
* When `migrations.mode` is a lambda-hook variant (`"lifecycle-hook"` /
|
|
195
|
+
* `"post-deploy"`), this helper is a no-op: the task
|
|
196
|
+
* definition stays unmodified and the migration is run by a deployment lifecycle
|
|
197
|
+
* hook synthesised separately at the pattern layer.
|
|
198
|
+
*
|
|
199
|
+
* @internal Exported for testing only
|
|
200
|
+
*/
|
|
201
|
+
export function expandMigrationsSugar(service, userContainers) {
|
|
202
|
+
const migrations = service.migrations;
|
|
203
|
+
if (!migrations) {
|
|
204
|
+
return userContainers ?? [];
|
|
205
|
+
}
|
|
206
|
+
if (isHookMigrations(migrations)) {
|
|
207
|
+
return userContainers ?? [];
|
|
208
|
+
}
|
|
209
|
+
const migrateName = migrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME;
|
|
210
|
+
const containers = userContainers ?? [];
|
|
211
|
+
for (const c of containers) {
|
|
212
|
+
if (c.name === migrateName) {
|
|
213
|
+
throw new Error(`Service '${service.name}': container '${migrateName}' collides with the synthetic ` +
|
|
214
|
+
`migrate container created by 'migrations'. Set 'migrations.name' to a different value.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const primary = containers.find((c) => c.port !== undefined) ?? containers[0];
|
|
218
|
+
const migrateContainer = {
|
|
219
|
+
name: migrateName,
|
|
220
|
+
essential: false,
|
|
221
|
+
command: migrations.command,
|
|
222
|
+
image: migrations.image ?? primary?.image,
|
|
223
|
+
environment: migrations.environment ?? primary?.environment,
|
|
224
|
+
secrets: migrations.secrets ?? primary?.secrets,
|
|
225
|
+
secretsImport: migrations.secretsImport ?? primary?.secretsImport
|
|
226
|
+
};
|
|
227
|
+
const wired = containers.map((c) => {
|
|
228
|
+
const existing = c.dependsOn ?? [];
|
|
229
|
+
const alreadyDepends = existing.some((d) => d.container === migrateName);
|
|
230
|
+
if (alreadyDepends)
|
|
231
|
+
return c;
|
|
232
|
+
return {
|
|
233
|
+
...c,
|
|
234
|
+
dependsOn: [
|
|
235
|
+
...existing,
|
|
236
|
+
{ container: migrateName, condition: "SUCCESS" }
|
|
237
|
+
]
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
return [migrateContainer, ...wired];
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Resolve the relational database carrying `migrations:` from a service's
|
|
244
|
+
* `connections` graph. Returns `undefined` when no connected database declares
|
|
245
|
+
* migrations. Throws when two or more do — the conflict names both databases
|
|
246
|
+
* so the author can pick a winner via service split or `schemaGate: false`.
|
|
247
|
+
*
|
|
248
|
+
* The author-explicit-wins arbitration for the env value itself lives in
|
|
249
|
+
* `buildContainerConfigs` — this helper only chooses the database.
|
|
250
|
+
*/
|
|
251
|
+
function resolveMigrationDatabaseForService(service) {
|
|
252
|
+
const connections = service.connections;
|
|
253
|
+
if (connections === undefined || connections.length === 0)
|
|
254
|
+
return undefined;
|
|
255
|
+
const matches = [];
|
|
256
|
+
for (const spec of connections) {
|
|
257
|
+
const resource = isConnectionConfig(spec) ? spec.resource : spec;
|
|
258
|
+
if (!isDatabase(resource))
|
|
259
|
+
continue;
|
|
260
|
+
if (!isRelationalDatabase(resource))
|
|
261
|
+
continue;
|
|
262
|
+
if (resource.getMigrationsConfig() !== undefined)
|
|
263
|
+
matches.push(resource);
|
|
264
|
+
}
|
|
265
|
+
if (matches.length === 0)
|
|
266
|
+
return undefined;
|
|
267
|
+
if (matches.length > 1) {
|
|
268
|
+
const names = matches.map((d) => d.node.id).join(", ");
|
|
269
|
+
throw new Error(`Service '${service.name}': connections include two or more relational ` +
|
|
270
|
+
`databases declaring \`migrations:\` (${names}). The schema-version ` +
|
|
271
|
+
`gate cannot inject ${EXPECTED_SCHEMA_VERSION_ENV} unambiguously. ` +
|
|
272
|
+
`Split the service so each consumes a single migrated database, or ` +
|
|
273
|
+
`set \`schemaGate: false\` and inject the env manually.`);
|
|
274
|
+
}
|
|
275
|
+
return matches[0];
|
|
276
|
+
}
|
|
277
|
+
function getResourceLabel(resource) {
|
|
278
|
+
if (resource !== null &&
|
|
279
|
+
typeof resource === "object" &&
|
|
280
|
+
"node" in resource &&
|
|
281
|
+
resource.node !== null &&
|
|
282
|
+
typeof resource.node === "object" &&
|
|
283
|
+
"id" in resource.node &&
|
|
284
|
+
typeof resource.node.id === "string") {
|
|
285
|
+
return resource.node.id;
|
|
286
|
+
}
|
|
287
|
+
return resource?.constructor?.name ?? "<unknown>";
|
|
288
|
+
}
|
|
289
|
+
function isSecurityGroupPeer(peer) {
|
|
290
|
+
return typeof peer.addIngressRule === "function";
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Walks a service's `connections:` array, dispatches through
|
|
294
|
+
* `isMigrationContributor`, and accumulates a single merged
|
|
295
|
+
* `MigrationContributions` describing what the migration task should inherit.
|
|
296
|
+
*
|
|
297
|
+
* Author-explicit-wins is enforced separately by the consumer
|
|
298
|
+
* (`mergeContributionsIntoMigrations` / `mergeContributionsIntoSeparateTaskDef`
|
|
299
|
+
* below). Here we ONLY arbitrate between contributors themselves: if two
|
|
300
|
+
* contributors disagree on an env key or `secretsImport` entry (two databases
|
|
301
|
+
* each contributing `DATABASE_HOST` with different values, or two ClickHouse
|
|
302
|
+
* instances each contributing `SCHEMA_ADMIN_PASSWORD` from different secrets),
|
|
303
|
+
* synth throws naming both resources so the author resolves via
|
|
304
|
+
* `migrations.environment.<key>` / `migrations.secretsImport.<key>` or by
|
|
305
|
+
* splitting the service. `taskRolePolicies` and `egress` are additive — no
|
|
306
|
+
* conflict resolution needed. Two-DB conflicts on the schema-version gate
|
|
307
|
+
* already throw upstream in `resolveMigrationDatabaseForService`.
|
|
308
|
+
*/
|
|
309
|
+
function collectMigrationContributions(service) {
|
|
310
|
+
const merged = {
|
|
311
|
+
environment: {},
|
|
312
|
+
secretsImport: {},
|
|
313
|
+
taskRolePolicies: [],
|
|
314
|
+
egress: []
|
|
315
|
+
};
|
|
316
|
+
const envOrigin = new Map();
|
|
317
|
+
const secretOrigin = new Map();
|
|
318
|
+
const connections = service.connections;
|
|
319
|
+
if (connections === undefined || connections.length === 0)
|
|
320
|
+
return merged;
|
|
321
|
+
for (const spec of connections) {
|
|
322
|
+
const resource = isConnectionConfig(spec) ? spec.resource : spec;
|
|
323
|
+
if (!isMigrationContributor(resource))
|
|
324
|
+
continue;
|
|
325
|
+
const contribution = resource.getMigrationContributions();
|
|
326
|
+
if (contribution === undefined)
|
|
327
|
+
continue;
|
|
328
|
+
const label = getResourceLabel(resource);
|
|
329
|
+
if (contribution.environment !== undefined) {
|
|
330
|
+
for (const [key, value] of Object.entries(contribution.environment)) {
|
|
331
|
+
const prior = envOrigin.get(key);
|
|
332
|
+
if (prior !== undefined && merged.environment[key] !== value) {
|
|
333
|
+
throw new Error(`Service '${service.name}': contributors '${prior}' and '${label}' ` +
|
|
334
|
+
`both contribute migration env '${key}' with conflicting values ` +
|
|
335
|
+
`('${merged.environment[key]}' vs '${value}'). Resolve by setting ` +
|
|
336
|
+
`migrations.environment.${key} explicitly on the service.`);
|
|
337
|
+
}
|
|
338
|
+
merged.environment[key] = value;
|
|
339
|
+
envOrigin.set(key, prior ?? label);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (contribution.secretsImport !== undefined) {
|
|
343
|
+
for (const [key, value] of Object.entries(contribution.secretsImport)) {
|
|
344
|
+
const prior = secretOrigin.get(key);
|
|
345
|
+
if (prior !== undefined &&
|
|
346
|
+
merged.secretsImport[key]?.name !== value.name) {
|
|
347
|
+
throw new Error(`Service '${service.name}': contributors '${prior}' and '${label}' ` +
|
|
348
|
+
`both contribute migration secretsImport '${key}' with conflicting ` +
|
|
349
|
+
`values (secret '${merged.secretsImport[key]?.name ?? ""}' vs ` +
|
|
350
|
+
`'${value.name}'). Resolve by setting migrations.secretsImport.${key} ` +
|
|
351
|
+
`explicitly on the service.`);
|
|
352
|
+
}
|
|
353
|
+
merged.secretsImport[key] = value;
|
|
354
|
+
secretOrigin.set(key, prior ?? label);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (contribution.taskRolePolicies !== undefined) {
|
|
358
|
+
merged.taskRolePolicies.push(...contribution.taskRolePolicies);
|
|
359
|
+
}
|
|
360
|
+
if (contribution.egress !== undefined) {
|
|
361
|
+
merged.egress.push(...contribution.egress);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return merged;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Merge contributor-supplied env/secrets into the author's explicit
|
|
368
|
+
* `migrations:` block. Author-explicit wins on key collisions with a synth
|
|
369
|
+
* warning so the author can audit what was overridden.
|
|
370
|
+
*/
|
|
371
|
+
function mergeContributionsIntoMigrations(migrations, contributions, scope, serviceName) {
|
|
372
|
+
const contributedEnv = contributions.environment ?? {};
|
|
373
|
+
const contributedSecrets = contributions.secretsImport ?? {};
|
|
374
|
+
const authoredEnv = migrations.environment ?? {};
|
|
375
|
+
const authoredSecrets = migrations.secretsImport ?? {};
|
|
376
|
+
const mergedEnv = { ...contributedEnv };
|
|
377
|
+
for (const [key, value] of Object.entries(authoredEnv)) {
|
|
378
|
+
if (contributedEnv[key] !== undefined && contributedEnv[key] !== value) {
|
|
379
|
+
Annotations.of(scope).addWarning(`Service '${serviceName}': author-set migrations.environment.${key}` +
|
|
380
|
+
`='${value}' overrides contributor value ` +
|
|
381
|
+
`('${contributedEnv[key]}').`);
|
|
382
|
+
}
|
|
383
|
+
mergedEnv[key] = value;
|
|
384
|
+
}
|
|
385
|
+
const mergedSecrets = { ...contributedSecrets };
|
|
386
|
+
for (const [key, value] of Object.entries(authoredSecrets)) {
|
|
387
|
+
if (contributedSecrets[key] !== undefined &&
|
|
388
|
+
contributedSecrets[key]?.name !== value.name) {
|
|
389
|
+
Annotations.of(scope).addWarning(`Service '${serviceName}': author-set migrations.secretsImport.${key} ` +
|
|
390
|
+
`(secret '${value.name}') overrides contributor secret ` +
|
|
391
|
+
`('${contributedSecrets[key]?.name ?? ""}').`);
|
|
392
|
+
}
|
|
393
|
+
mergedSecrets[key] = value;
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
...migrations,
|
|
397
|
+
...(Object.keys(mergedEnv).length > 0 && { environment: mergedEnv }),
|
|
398
|
+
...(Object.keys(mergedSecrets).length > 0 && {
|
|
399
|
+
secretsImport: mergedSecrets
|
|
400
|
+
})
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Merge contributor-supplied IAM policies and egress entries into the author's
|
|
405
|
+
* `separateTaskDef` block. Both fields are additive — contributor entries are
|
|
406
|
+
* appended to the author's list.
|
|
407
|
+
*/
|
|
408
|
+
function mergeContributionsIntoSeparateTaskDef(separateTaskDef, contributions) {
|
|
409
|
+
const contributedPolicies = contributions.taskRolePolicies ?? [];
|
|
410
|
+
const contributedEgress = contributions.egress ?? [];
|
|
411
|
+
const mergedPolicies = [
|
|
412
|
+
...contributedPolicies,
|
|
413
|
+
...(separateTaskDef.taskRolePolicies ?? [])
|
|
414
|
+
];
|
|
415
|
+
const mergedEgress = [
|
|
416
|
+
...contributedEgress,
|
|
417
|
+
...(separateTaskDef.egressTo ?? [])
|
|
418
|
+
];
|
|
419
|
+
return {
|
|
420
|
+
...separateTaskDef,
|
|
421
|
+
...(mergedPolicies.length > 0 && { taskRolePolicies: mergedPolicies }),
|
|
422
|
+
...(mergedEgress.length > 0 && { egressTo: mergedEgress })
|
|
423
|
+
};
|
|
424
|
+
}
|
|
90
425
|
/**
|
|
91
426
|
* Build container configurations for an ECS service.
|
|
92
427
|
* Converts user-facing EcsContainerConfig to internal EcsClusterProps format.
|
|
428
|
+
*
|
|
429
|
+
* @param service Service config from EcsComputeProps.
|
|
430
|
+
* @param schemaVersionEnv Pre-resolved `{ EXPECTED_SCHEMA_VERSION: <ver> }`
|
|
431
|
+
* from a connected database's `migrations:` config, or
|
|
432
|
+
* `undefined` when the service has no migrated DB or
|
|
433
|
+
* opted out via `schemaGate: false`.
|
|
434
|
+
* @param annotationsScope Construct used as the source for synth-time
|
|
435
|
+
* warnings when the author has set `EXPECTED_SCHEMA_VERSION`
|
|
436
|
+
* themselves and the resolved value differs.
|
|
93
437
|
* @internal Exported for testing only
|
|
94
438
|
*/
|
|
95
|
-
export function buildContainerConfigs(service) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
439
|
+
export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope) {
|
|
440
|
+
const userContainers = service.containers && service.containers.length > 0
|
|
441
|
+
? service.containers
|
|
442
|
+
: undefined;
|
|
443
|
+
const expanded = service.migrations
|
|
444
|
+
? expandMigrationsSugar(service, userContainers)
|
|
445
|
+
: userContainers;
|
|
446
|
+
const mergeSchemaEnv = (authored) => {
|
|
447
|
+
if (schemaVersionEnv === undefined)
|
|
448
|
+
return authored;
|
|
449
|
+
const authoredValue = authored?.[EXPECTED_SCHEMA_VERSION_ENV];
|
|
450
|
+
if (authoredValue !== undefined) {
|
|
451
|
+
const resolvedValue = schemaVersionEnv[EXPECTED_SCHEMA_VERSION_ENV];
|
|
452
|
+
if (authoredValue !== resolvedValue && annotationsScope !== undefined) {
|
|
453
|
+
Annotations.of(annotationsScope).addWarning(`Service '${service.name}': author-set ${EXPECTED_SCHEMA_VERSION_ENV}` +
|
|
454
|
+
`='${authoredValue}' overrides the value resolved from the connected ` +
|
|
455
|
+
`database's \`migrations:\` config ('${resolvedValue}'). The author ` +
|
|
456
|
+
`value wins; set \`schemaGate: false\` to silence this warning. Note ` +
|
|
457
|
+
`that ${EXPECTED_SCHEMA_VERSION_TOOL_ENV} will still be auto-injected ` +
|
|
458
|
+
`to ensure the runtime gate passes — both envs must emit together.`);
|
|
459
|
+
}
|
|
460
|
+
// Runtime gate rejects startup if VERSION is present but TOOL is absent.
|
|
461
|
+
return {
|
|
462
|
+
[EXPECTED_SCHEMA_VERSION_TOOL_ENV]: schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV],
|
|
463
|
+
...authored
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const merged = { ...(authored ?? {}) };
|
|
467
|
+
for (const [key, value] of Object.entries(schemaVersionEnv)) {
|
|
468
|
+
if (merged[key] === undefined)
|
|
469
|
+
merged[key] = value;
|
|
470
|
+
}
|
|
471
|
+
return merged;
|
|
472
|
+
};
|
|
473
|
+
if (expanded) {
|
|
474
|
+
return expanded.map((c, index) => {
|
|
475
|
+
return {
|
|
476
|
+
name: c.name || `${service.name}Container${index > 0 ? index : ""}`,
|
|
477
|
+
image: c.image,
|
|
478
|
+
port: c.port,
|
|
479
|
+
environment: mergeSchemaEnv(c.environment),
|
|
480
|
+
secrets: c.secrets,
|
|
481
|
+
secretsImport: c.secretsImport,
|
|
482
|
+
command: c.command,
|
|
483
|
+
entryPoint: c.entryPoint,
|
|
484
|
+
essential: c.essential,
|
|
485
|
+
healthCheck: c.healthCheck,
|
|
486
|
+
dependsOn: c.dependsOn,
|
|
487
|
+
portMappings: c.portMappings,
|
|
488
|
+
volumes: c.volumes,
|
|
489
|
+
stopTimeout: c.stopTimeout
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
const fallbackEnv = mergeSchemaEnv(undefined);
|
|
494
|
+
return [
|
|
495
|
+
{
|
|
496
|
+
name: `${service.name}Container`,
|
|
497
|
+
...(fallbackEnv !== undefined && { environment: fallbackEnv })
|
|
498
|
+
}
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
function resolveMigrationImage(svcConfig, migrations, inheritedImage) {
|
|
502
|
+
if (migrations.image !== undefined)
|
|
503
|
+
return migrations.image;
|
|
504
|
+
// Order dependency: inheritedImage carries the <ServiceName>ImageTag
|
|
505
|
+
// CfnParameter token; checked before raw svcConfig.image strings.
|
|
506
|
+
if (inheritedImage !== undefined)
|
|
507
|
+
return inheritedImage;
|
|
508
|
+
const primary = svcConfig.containers?.find((c) => c.port !== undefined) ??
|
|
509
|
+
svcConfig.containers?.[0];
|
|
510
|
+
const containerImage = primary?.image ?? svcConfig.image;
|
|
511
|
+
if (containerImage !== undefined && typeof containerImage !== "string") {
|
|
512
|
+
throw new Error(`Service '${svcConfig.name}': migrations.separateTaskDef requires a string image URI ` +
|
|
513
|
+
`(got CDK Repository). Pass migrations.image as a string.`);
|
|
514
|
+
}
|
|
515
|
+
if (containerImage === undefined) {
|
|
516
|
+
throw new Error(`Service '${svcConfig.name}': migrations.separateTaskDef requires a resolvable image — ` +
|
|
517
|
+
`set migrations.image, service.image, or a container.image (string URI).`);
|
|
109
518
|
}
|
|
110
|
-
|
|
111
|
-
return [{ name: `${service.name}Container` }];
|
|
519
|
+
return containerImage;
|
|
112
520
|
}
|
|
113
521
|
/**
|
|
114
522
|
* Resolve scaling configuration from service props.
|
|
@@ -144,12 +552,26 @@ export class EcsCompute extends Construct {
|
|
|
144
552
|
computeType = "ecs";
|
|
145
553
|
connections;
|
|
146
554
|
ecsCluster;
|
|
555
|
+
clusterId;
|
|
556
|
+
appName;
|
|
557
|
+
migrationTaskDefinitions = new Map();
|
|
147
558
|
constructor(scope, id, props) {
|
|
148
559
|
super(scope, id);
|
|
149
|
-
|
|
560
|
+
validateEcsProps(props);
|
|
561
|
+
this.clusterId = id;
|
|
562
|
+
this.appName = props.appName;
|
|
150
563
|
const services = props.services.map((service) => {
|
|
151
|
-
const
|
|
564
|
+
const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
|
|
565
|
+
const containers = buildContainerConfigs(service, schemaVersionEnv, this);
|
|
152
566
|
const { scalingType, minCapacity, maxCapacity } = resolveScalingConfig(service.scaling);
|
|
567
|
+
const cloudMapService = service.serviceDiscovery !== undefined
|
|
568
|
+
? App.getInstance().registerService({
|
|
569
|
+
name: service.serviceDiscovery.name,
|
|
570
|
+
...(service.serviceDiscovery.dnsRecordType !== undefined && {
|
|
571
|
+
dnsRecordType: service.serviceDiscovery.dnsRecordType
|
|
572
|
+
})
|
|
573
|
+
})
|
|
574
|
+
: undefined;
|
|
153
575
|
return {
|
|
154
576
|
name: service.name,
|
|
155
577
|
image: service.image,
|
|
@@ -168,30 +590,36 @@ export class EcsCompute extends Construct {
|
|
|
168
590
|
capacityProvider: service.capacityProvider,
|
|
169
591
|
ec2Config: service.ec2Config,
|
|
170
592
|
ssmSecretsPath: service.ssmSecretsPath,
|
|
171
|
-
|
|
172
|
-
alarms: service.alarms
|
|
593
|
+
docker: service.docker,
|
|
594
|
+
alarms: service.alarms,
|
|
595
|
+
circuitBreaker: service.circuitBreaker,
|
|
596
|
+
...(service.deployment !== undefined && {
|
|
597
|
+
deployment: service.deployment
|
|
598
|
+
}),
|
|
599
|
+
...(cloudMapService !== undefined && { cloudMapService }),
|
|
600
|
+
...(service.serviceDiscovery?.dnsRecordType !== undefined && {
|
|
601
|
+
cloudMapDnsRecordType: service.serviceDiscovery.dnsRecordType
|
|
602
|
+
}),
|
|
603
|
+
...(service.networkMode !== undefined && {
|
|
604
|
+
networkMode: service.networkMode
|
|
605
|
+
}),
|
|
606
|
+
...(service.securityGroups !== undefined && {
|
|
607
|
+
securityGroups: service.securityGroups
|
|
608
|
+
})
|
|
173
609
|
};
|
|
174
610
|
});
|
|
175
|
-
// Build cluster config
|
|
176
611
|
const cluster = props.cluster
|
|
177
612
|
? {
|
|
178
613
|
domain: props.cluster.domain,
|
|
179
614
|
loadBalancer: props.cluster.loadBalancer,
|
|
180
615
|
directAccess: props.cluster.directAccess,
|
|
181
|
-
domainConfig: props.cluster.domainConfig
|
|
616
|
+
domainConfig: props.cluster.domainConfig,
|
|
617
|
+
...(props.cluster.securityGroup !== undefined && {
|
|
618
|
+
securityGroup: props.cluster.securityGroup
|
|
619
|
+
})
|
|
182
620
|
}
|
|
183
621
|
: undefined;
|
|
184
|
-
|
|
185
|
-
let resolvedAlertsTopic;
|
|
186
|
-
if (typeof props.alertsTopic === "string") {
|
|
187
|
-
const arn = props.alertsTopic.startsWith("import:")
|
|
188
|
-
? Fn.importValue(props.alertsTopic.slice("import:".length))
|
|
189
|
-
: props.alertsTopic;
|
|
190
|
-
resolvedAlertsTopic = Topic.fromTopicArn(this, "AlertsTopic", arn);
|
|
191
|
-
}
|
|
192
|
-
else {
|
|
193
|
-
resolvedAlertsTopic = props.alertsTopic;
|
|
194
|
-
}
|
|
622
|
+
const resolvedAlertsTopic = resolveAlertsTopic(this, `${id}AlertsTopic`, props.alertsTopic);
|
|
195
623
|
const ecsProps = {
|
|
196
624
|
clusterName: id,
|
|
197
625
|
appName: props.appName,
|
|
@@ -204,6 +632,379 @@ export class EcsCompute extends Construct {
|
|
|
204
632
|
};
|
|
205
633
|
this.ecsCluster = new EcsCluster(this, `${id}Ecs`, ecsProps);
|
|
206
634
|
this.connections = this.ecsCluster.connections;
|
|
635
|
+
this.wireLifecycleHookMigrations(props.services);
|
|
636
|
+
this.materialiseScheduledTasks(id, props);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Walks a service's `connections:` for a relational database carrying a
|
|
640
|
+
* `migrations:` config. Returns the schema-version gate env entries to
|
|
641
|
+
* thread into the service's containers, or `undefined` when the service is
|
|
642
|
+
* not gated.
|
|
643
|
+
*
|
|
644
|
+
* - `schemaGate: false` → returns `undefined` (auditable opt-out)
|
|
645
|
+
* - No migrated DB in connections → returns `undefined`
|
|
646
|
+
* - Exactly one migrated DB → returns `{ EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_VERSION_TOOL }`
|
|
647
|
+
* - Two or more migrated DBs → throws via `resolveMigrationDatabaseForService`
|
|
648
|
+
*
|
|
649
|
+
* Both envs always emit together. The tool sibling lets the runtime gate
|
|
650
|
+
* dispatch to the matching resolver (or refuse on unknown tool) instead of
|
|
651
|
+
* hardcoding one.
|
|
652
|
+
*/
|
|
653
|
+
resolveSchemaVersionEnv(service) {
|
|
654
|
+
if (service.schemaGate === false)
|
|
655
|
+
return undefined;
|
|
656
|
+
const db = resolveMigrationDatabaseForService(service);
|
|
657
|
+
if (db === undefined)
|
|
658
|
+
return undefined;
|
|
659
|
+
const version = db.getExpectedSchemaVersion();
|
|
660
|
+
if (version === undefined)
|
|
661
|
+
return undefined;
|
|
662
|
+
const config = db.getMigrationsConfig();
|
|
663
|
+
if (config === undefined) {
|
|
664
|
+
throw new Error(`Service '${service.name}': schema-version-gate produced a version ` +
|
|
665
|
+
`but the connected database returned no migrations config — these ` +
|
|
666
|
+
`must move together.`);
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
[EXPECTED_SCHEMA_VERSION_ENV]: version,
|
|
670
|
+
[EXPECTED_SCHEMA_VERSION_TOOL_ENV]: config.tool
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
materialiseScheduledTasks(id, props) {
|
|
674
|
+
const scheduledTasks = props.cluster?.scheduledTasks;
|
|
675
|
+
if (!scheduledTasks || scheduledTasks.length === 0)
|
|
676
|
+
return;
|
|
677
|
+
const app = App.getInstance();
|
|
678
|
+
for (const entry of scheduledTasks) {
|
|
679
|
+
const taskDef = this.buildScheduledTaskDefinition(id, entry);
|
|
680
|
+
this.ecsCluster.registerScheduledTaskDefinition(entry.name, taskDef);
|
|
681
|
+
app.addSchedule(`${id}${toPascalCase(entry.name)}Schedule`, {
|
|
682
|
+
schedule: entry.schedule,
|
|
683
|
+
target: {
|
|
684
|
+
ecs: this,
|
|
685
|
+
serviceName: entry.name,
|
|
686
|
+
taskCount: 1
|
|
687
|
+
},
|
|
688
|
+
stackPlacement: "compute"
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
buildScheduledTaskDefinition(id, entry) {
|
|
693
|
+
const taskDef = createScheduledTaskDefinition(this, `${id}${toPascalCase(entry.name)}TaskDefinition`, {
|
|
694
|
+
family: `${id}-${entry.name}`,
|
|
695
|
+
...(entry.networkMode !== undefined && {
|
|
696
|
+
networkMode: entry.networkMode
|
|
697
|
+
})
|
|
698
|
+
});
|
|
699
|
+
if (entry.volumes) {
|
|
700
|
+
for (const volume of entry.volumes) {
|
|
701
|
+
taskDef.addVolume({
|
|
702
|
+
name: volume.name,
|
|
703
|
+
...(volume.hostSourcePath !== undefined && {
|
|
704
|
+
host: { sourcePath: volume.hostSourcePath }
|
|
705
|
+
})
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const logging = new AwsLogDriver({
|
|
710
|
+
streamPrefix: `/ecs-scheduled/${id}/${entry.name}`,
|
|
711
|
+
...(entry.logGroup !== undefined && { logGroup: entry.logGroup }),
|
|
712
|
+
...(entry.logGroup === undefined &&
|
|
713
|
+
entry.logRetention !== undefined && {
|
|
714
|
+
logRetention: entry.logRetention
|
|
715
|
+
})
|
|
716
|
+
});
|
|
717
|
+
const container = taskDef.addContainer(`${id}${toPascalCase(entry.name)}Container`, {
|
|
718
|
+
image: entry.image,
|
|
719
|
+
cpu: entry.cpu,
|
|
720
|
+
memoryLimitMiB: entry.memoryLimitMiB,
|
|
721
|
+
...(entry.command !== undefined && { command: entry.command }),
|
|
722
|
+
...(entry.secrets !== undefined && { secrets: entry.secrets }),
|
|
723
|
+
logging
|
|
724
|
+
});
|
|
725
|
+
if (entry.volumes) {
|
|
726
|
+
for (const volume of entry.volumes) {
|
|
727
|
+
container.addMountPoints({
|
|
728
|
+
sourceVolume: volume.name,
|
|
729
|
+
containerPath: volume.mountPath,
|
|
730
|
+
readOnly: volume.readOnly ?? false
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return taskDef;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* For each service whose `migrations.mode` is a lambda-hook variant
|
|
738
|
+
* (`"lifecycle-hook"` for PRE_SCALE_UP, `"post-deploy"` for POST_SCALE_UP),
|
|
739
|
+
* synthesise the Lambda + IAM role + log group that backs the deployment
|
|
740
|
+
* lifecycle hook. The init-container path is unaffected — services without
|
|
741
|
+
* `mode` (or with `mode: "init-container"`) still get the synthetic
|
|
742
|
+
* migrate container injected by `expandMigrationsSugar`.
|
|
743
|
+
*/
|
|
744
|
+
wireLifecycleHookMigrations(services) {
|
|
745
|
+
for (const svcConfig of services) {
|
|
746
|
+
const migrations = svcConfig.migrations;
|
|
747
|
+
if (!migrations || !isHookMigrations(migrations))
|
|
748
|
+
continue;
|
|
749
|
+
const modeLabel = migrations.mode;
|
|
750
|
+
if (svcConfig.capacityProvider === "EC2") {
|
|
751
|
+
throw new Error(`Service '${svcConfig.name}': migrations.mode === "${modeLabel}" requires FARGATE or FARGATE_SPOT capacity (got "EC2"). ` +
|
|
752
|
+
`Use migrations.mode === "init-container" (the default) for EC2 services.`);
|
|
753
|
+
}
|
|
754
|
+
if (migrations.image !== undefined &&
|
|
755
|
+
typeof migrations.image !== "string") {
|
|
756
|
+
throw new Error(`Service '${svcConfig.name}': migrations.image must be a string in ${modeLabel} mode. ` +
|
|
757
|
+
`CDK Repository constructs are not supported here — pass the image URI as a string.`);
|
|
758
|
+
}
|
|
759
|
+
const service = this.ecsCluster.getService(svcConfig.name);
|
|
760
|
+
if (!service) {
|
|
761
|
+
throw new Error(`Service '${svcConfig.name}' not found in cluster after synthesis — cannot attach lifecycle hook.`);
|
|
762
|
+
}
|
|
763
|
+
const taskDefinition = service.taskDefinition;
|
|
764
|
+
if (!taskDefinition.executionRole) {
|
|
765
|
+
throw new Error(`Service '${svcConfig.name}': task definition has no execution role — cannot grant iam:PassRole for the migrate Lambda.`);
|
|
766
|
+
}
|
|
767
|
+
const cluster = this.ecsCluster.getCluster();
|
|
768
|
+
const hasNat = vpcHasNatGateways(cluster.vpc);
|
|
769
|
+
const subnets = cluster.vpc.selectSubnets({
|
|
770
|
+
subnetType: hasNat ? SubnetType.PRIVATE_WITH_EGRESS : SubnetType.PUBLIC
|
|
771
|
+
});
|
|
772
|
+
const securityGroupIds = service.connections.securityGroups.map((sg) => sg.securityGroupId);
|
|
773
|
+
if (securityGroupIds.length === 0) {
|
|
774
|
+
throw new Error(`EcsCompute: service "${svcConfig.name}" has no security groups; lifecycle-hook migration RunTask requires at least one.`);
|
|
775
|
+
}
|
|
776
|
+
const contributions = collectMigrationContributions(svcConfig);
|
|
777
|
+
const effectiveMigrations = mergeContributionsIntoMigrations(migrations, contributions, this, svcConfig.name);
|
|
778
|
+
const effectiveSeparateTaskDef = migrations.separateTaskDef !== undefined
|
|
779
|
+
? mergeContributionsIntoSeparateTaskDef(migrations.separateTaskDef, contributions)
|
|
780
|
+
: undefined;
|
|
781
|
+
const schemaVersionEnv = this.resolveSchemaVersionEnv(svcConfig);
|
|
782
|
+
const migrationTaskDef = effectiveSeparateTaskDef !== undefined
|
|
783
|
+
? this.synthesiseMigrationTaskDef(svcConfig, effectiveMigrations, effectiveSeparateTaskDef, service, cluster.vpc, schemaVersionEnv)
|
|
784
|
+
: undefined;
|
|
785
|
+
const effectiveSecurityGroupIds = migrationTaskDef?.securityGroupIds ?? securityGroupIds;
|
|
786
|
+
new EcsLifecycleHookMigration(this, `${toPascalCase(svcConfig.name)}Migrate`, {
|
|
787
|
+
service,
|
|
788
|
+
clusterArn: cluster.clusterArn,
|
|
789
|
+
taskDefinitionArn: taskDefinition.taskDefinitionArn,
|
|
790
|
+
taskDefinitionFamily: taskDefinition.family,
|
|
791
|
+
taskExecutionRoleArn: taskDefinition.executionRole.roleArn,
|
|
792
|
+
taskRoleArn: taskDefinition.taskRole.roleArn,
|
|
793
|
+
command: effectiveMigrations.command,
|
|
794
|
+
containerName: effectiveMigrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME,
|
|
795
|
+
image: effectiveMigrations.image,
|
|
796
|
+
...(effectiveMigrations.entryPoint !== undefined && {
|
|
797
|
+
entryPoint: effectiveMigrations.entryPoint
|
|
798
|
+
}),
|
|
799
|
+
environment: effectiveMigrations.environment,
|
|
800
|
+
timeoutSeconds: effectiveMigrations.timeoutSeconds,
|
|
801
|
+
networkConfiguration: {
|
|
802
|
+
subnetIds: subnets.subnetIds,
|
|
803
|
+
securityGroupIds: effectiveSecurityGroupIds,
|
|
804
|
+
assignPublicIp: !hasNat
|
|
805
|
+
},
|
|
806
|
+
...(migrationTaskDef !== undefined && {
|
|
807
|
+
migrationTaskDef: {
|
|
808
|
+
definitionArn: migrationTaskDef.taskDefinitionArn,
|
|
809
|
+
family: migrationTaskDef.family,
|
|
810
|
+
taskRoleArn: migrationTaskDef.taskRoleArn,
|
|
811
|
+
executionRoleArn: migrationTaskDef.executionRoleArn
|
|
812
|
+
}
|
|
813
|
+
}),
|
|
814
|
+
lifecycleStage: lifecycleStageForHookMode(effectiveMigrations.mode)
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Synthesise a dedicated migration task definition for a lifecycle-hook
|
|
820
|
+
* migration when `separateTaskDef` is set. Creates the migration's own
|
|
821
|
+
* execution + task roles, log group, security group (when `egressTo` is
|
|
822
|
+
* present, else reuses the service's SGs), and the Fargate task definition
|
|
823
|
+
* with the migration container baked in.
|
|
824
|
+
*/
|
|
825
|
+
synthesiseMigrationTaskDef(svcConfig, migrations, separateTaskDef, service, vpc, schemaVersionEnv) {
|
|
826
|
+
const stack = Stack.of(this);
|
|
827
|
+
const idPrefix = `${toPascalCase(svcConfig.name)}Migrate`;
|
|
828
|
+
const containerName = migrations.name ?? DEFAULT_MIGRATE_CONTAINER_NAME;
|
|
829
|
+
const family = `${this.clusterId}-${svcConfig.name}-migrate`;
|
|
830
|
+
const inheritedImage = service.taskDefinition.defaultContainer?.imageName;
|
|
831
|
+
const image = resolveMigrationImage(svcConfig, migrations, inheritedImage);
|
|
832
|
+
const logGroup = new LogGroup(this, `${idPrefix}LogGroup`, {
|
|
833
|
+
retention: RetentionDays.ONE_MONTH
|
|
834
|
+
});
|
|
835
|
+
const executionRole = new Role(this, `${idPrefix}ExecutionRole`, {
|
|
836
|
+
assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com")
|
|
837
|
+
});
|
|
838
|
+
executionRole.addToPolicy(new PolicyStatement({
|
|
839
|
+
effect: Effect.ALLOW,
|
|
840
|
+
actions: [
|
|
841
|
+
"ecr:GetAuthorizationToken",
|
|
842
|
+
"ecr:BatchCheckLayerAvailability",
|
|
843
|
+
"ecr:GetDownloadUrlForLayer",
|
|
844
|
+
"ecr:BatchGetImage"
|
|
845
|
+
],
|
|
846
|
+
resources: ["*"]
|
|
847
|
+
}));
|
|
848
|
+
logGroup.grantWrite(executionRole);
|
|
849
|
+
const taskRole = new Role(this, `${idPrefix}TaskRole`, {
|
|
850
|
+
assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com")
|
|
851
|
+
});
|
|
852
|
+
taskRole.addToPolicy(new PolicyStatement({
|
|
853
|
+
effect: Effect.ALLOW,
|
|
854
|
+
actions: [
|
|
855
|
+
"ssmmessages:CreateControlChannel",
|
|
856
|
+
"ssmmessages:CreateDataChannel",
|
|
857
|
+
"ssmmessages:OpenControlChannel",
|
|
858
|
+
"ssmmessages:OpenDataChannel"
|
|
859
|
+
],
|
|
860
|
+
resources: ["*"]
|
|
861
|
+
}));
|
|
862
|
+
if (separateTaskDef.taskRolePolicies) {
|
|
863
|
+
for (const statement of separateTaskDef.taskRolePolicies) {
|
|
864
|
+
taskRole.addToPolicy(statement);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (separateTaskDef.extraSecretReads) {
|
|
868
|
+
const stackHere = Stack.of(this);
|
|
869
|
+
const secretArns = separateTaskDef.extraSecretReads.map((entry) => entry.secretName.startsWith("arn:")
|
|
870
|
+
? entry.secretName
|
|
871
|
+
: `arn:${stackHere.partition}:secretsmanager:${stackHere.region}:${stackHere.account}:secret:${entry.secretName}-*`);
|
|
872
|
+
taskRole.addToPolicy(new PolicyStatement({
|
|
873
|
+
effect: Effect.ALLOW,
|
|
874
|
+
actions: ["secretsmanager:GetSecretValue"],
|
|
875
|
+
resources: secretArns
|
|
876
|
+
}));
|
|
877
|
+
}
|
|
878
|
+
const taskDefinition = createMigrationTaskDefinition(this, `${idPrefix}TaskDefinition`, {
|
|
879
|
+
family,
|
|
880
|
+
cpu: separateTaskDef.cpu,
|
|
881
|
+
memoryLimitMiB: separateTaskDef.memoryLimitMiB,
|
|
882
|
+
executionRole,
|
|
883
|
+
taskRole
|
|
884
|
+
});
|
|
885
|
+
const { secretsResolved, secretArns, ssmPath } = this.resolveMigrationSecrets(svcConfig, migrations, idPrefix);
|
|
886
|
+
const authoredEnvironment = migrations.environment ?? {};
|
|
887
|
+
const containerEnvironment = {
|
|
888
|
+
...authoredEnvironment
|
|
889
|
+
};
|
|
890
|
+
if (schemaVersionEnv !== undefined) {
|
|
891
|
+
for (const [key, value] of Object.entries(schemaVersionEnv)) {
|
|
892
|
+
if (containerEnvironment[key] === undefined) {
|
|
893
|
+
containerEnvironment[key] = value;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
taskDefinition.addContainer(`${idPrefix}Container`, {
|
|
898
|
+
containerName,
|
|
899
|
+
image: ContainerImage.fromRegistry(image),
|
|
900
|
+
command: migrations.command,
|
|
901
|
+
...(migrations.entryPoint !== undefined && {
|
|
902
|
+
entryPoint: migrations.entryPoint
|
|
903
|
+
}),
|
|
904
|
+
environment: containerEnvironment,
|
|
905
|
+
secrets: secretsResolved,
|
|
906
|
+
logging: new AwsLogDriver({
|
|
907
|
+
streamPrefix: `/ecs-migrate/${svcConfig.name}`,
|
|
908
|
+
logGroup
|
|
909
|
+
})
|
|
910
|
+
});
|
|
911
|
+
if (secretArns.length > 0) {
|
|
912
|
+
executionRole.addToPolicy(new PolicyStatement({
|
|
913
|
+
effect: Effect.ALLOW,
|
|
914
|
+
actions: [
|
|
915
|
+
"secretsmanager:GetSecretValue",
|
|
916
|
+
"secretsmanager:DescribeSecret"
|
|
917
|
+
],
|
|
918
|
+
resources: secretArns
|
|
919
|
+
}));
|
|
920
|
+
}
|
|
921
|
+
if (ssmPath !== undefined) {
|
|
922
|
+
executionRole.addToPolicy(new PolicyStatement({
|
|
923
|
+
effect: Effect.ALLOW,
|
|
924
|
+
actions: ["ssm:GetParameters", "ssm:GetParameter"],
|
|
925
|
+
resources: [
|
|
926
|
+
`arn:${stack.partition}:ssm:${stack.region}:${stack.account}:parameter${ssmPath}/*`
|
|
927
|
+
]
|
|
928
|
+
}));
|
|
929
|
+
}
|
|
930
|
+
if (secretArns.length > 0 || ssmPath !== undefined) {
|
|
931
|
+
executionRole.addToPolicy(new PolicyStatement({
|
|
932
|
+
effect: Effect.ALLOW,
|
|
933
|
+
actions: ["kms:Decrypt"],
|
|
934
|
+
resources: ["*"],
|
|
935
|
+
conditions: {
|
|
936
|
+
StringEquals: {
|
|
937
|
+
"kms:ViaService": [
|
|
938
|
+
`ssm.${stack.region}.amazonaws.com`,
|
|
939
|
+
`secretsmanager.${stack.region}.amazonaws.com`
|
|
940
|
+
]
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}));
|
|
944
|
+
}
|
|
945
|
+
let securityGroupIds;
|
|
946
|
+
if (separateTaskDef.egressTo !== undefined &&
|
|
947
|
+
separateTaskDef.egressTo.length > 0) {
|
|
948
|
+
const migrationSg = new SecurityGroup(this, `${idPrefix}SecurityGroup`, {
|
|
949
|
+
vpc,
|
|
950
|
+
description: `Migration SG for ${svcConfig.name}`,
|
|
951
|
+
allowAllOutbound: false
|
|
952
|
+
});
|
|
953
|
+
for (const [i, egress] of separateTaskDef.egressTo.entries()) {
|
|
954
|
+
migrationSg.addEgressRule(egress.peer, egress.port, egress.description);
|
|
955
|
+
if (isSecurityGroupPeer(egress.peer)) {
|
|
956
|
+
// Cross-stack: addIngressRule on the peer directly cycles
|
|
957
|
+
// (peer stack would back-ref migrationSg). Import locally.
|
|
958
|
+
const localPeer = SecurityGroup.fromSecurityGroupId(this, `${idPrefix}MigratePeer${i}`, egress.peer.securityGroupId, { mutable: true });
|
|
959
|
+
localPeer.addIngressRule(migrationSg, egress.port, egress.description);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Fargate agent pre-container 443 fetches; ECR API needs NAT egress.
|
|
963
|
+
migrationSg.addEgressRule(Peer.anyIpv4(), Port.tcp(443), "Fargate agent HTTPS for ECR / secrets / logs");
|
|
964
|
+
securityGroupIds = [migrationSg.securityGroupId];
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
securityGroupIds = service.connections.securityGroups.map((sg) => sg.securityGroupId);
|
|
968
|
+
}
|
|
969
|
+
this.migrationTaskDefinitions.set(svcConfig.name, taskDefinition);
|
|
970
|
+
return {
|
|
971
|
+
taskDefinitionArn: taskDefinition.taskDefinitionArn,
|
|
972
|
+
family: taskDefinition.family,
|
|
973
|
+
taskRoleArn: taskRole.roleArn,
|
|
974
|
+
executionRoleArn: executionRole.roleArn,
|
|
975
|
+
securityGroupIds
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
resolveMigrationSecrets(svcConfig, migrations, idPrefix) {
|
|
979
|
+
const stack = Stack.of(this);
|
|
980
|
+
const secretsResolved = {};
|
|
981
|
+
const secretArns = [];
|
|
982
|
+
if (migrations.secretsImport) {
|
|
983
|
+
for (const [key, secretImport] of Object.entries(migrations.secretsImport)) {
|
|
984
|
+
const secret = Secret.fromSecretNameV2(this, `${idPrefix}${key}Secret`, secretImport.name);
|
|
985
|
+
secretsResolved[key] = EcsSecret.fromSecretsManager(secret, secretImport.field);
|
|
986
|
+
secretArns.push(`arn:${stack.partition}:secretsmanager:${stack.region}:${stack.account}:secret:${secretImport.name}-*`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
let ssmPath;
|
|
990
|
+
if (migrations.secrets && migrations.secrets.length > 0) {
|
|
991
|
+
if (svcConfig.ssmSecretsPath !== undefined) {
|
|
992
|
+
ssmPath = svcConfig.ssmSecretsPath;
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
if (this.appName === undefined || this.appName === "") {
|
|
996
|
+
throw new Error(`Service '${svcConfig.name}' declares migration secrets but no ssmSecretsPath ` +
|
|
997
|
+
`is set and EcsComputeProps.appName is missing — set one to enable ` +
|
|
998
|
+
`SSM path derivation (/<appName>/<clusterName>/<serviceName>).`);
|
|
999
|
+
}
|
|
1000
|
+
ssmPath = `/${this.appName}/${this.clusterId}/${svcConfig.name}`;
|
|
1001
|
+
}
|
|
1002
|
+
for (const secretName of migrations.secrets) {
|
|
1003
|
+
const param = StringParameter.fromSecureStringParameterAttributes(this, `${idPrefix}${secretName}SsmParam`, { parameterName: `${ssmPath}/${secretName}` });
|
|
1004
|
+
secretsResolved[secretName] = EcsSecret.fromSsmParameter(param);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return { secretsResolved, secretArns, ssmPath };
|
|
207
1008
|
}
|
|
208
1009
|
/** Get the ECS cluster. */
|
|
209
1010
|
getCluster() {
|
|
@@ -222,6 +1023,18 @@ export class EcsCompute extends Construct {
|
|
|
222
1023
|
const servicesMap = this.ecsCluster.getServices();
|
|
223
1024
|
return Array.from(servicesMap.values());
|
|
224
1025
|
}
|
|
1026
|
+
getTaskDefinition(serviceName) {
|
|
1027
|
+
return this.ecsCluster.getTaskDefinition(serviceName);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Get the migration task definition for a service. Returns `undefined` when
|
|
1031
|
+
* the service has no `migrations: { mode: "lifecycle-hook", separateTaskDef }`
|
|
1032
|
+
* configured — escape hatch for callers that need to attach grants (e.g.
|
|
1033
|
+
* `bucket.grantReadWrite(td.taskRole)`) to the migration task role.
|
|
1034
|
+
*/
|
|
1035
|
+
getMigrationTaskDefinition(serviceName) {
|
|
1036
|
+
return this.migrationTaskDefinitions.get(serviceName);
|
|
1037
|
+
}
|
|
225
1038
|
/** Get the security group for the cluster. */
|
|
226
1039
|
getSecurityGroup() {
|
|
227
1040
|
const sg = this.connections.securityGroups[0];
|
|
@@ -257,4 +1070,21 @@ export class EcsCompute extends Construct {
|
|
|
257
1070
|
resourceArns: ["*"]
|
|
258
1071
|
});
|
|
259
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Get the EC2 instance role for the cluster's underlying ASG. EC2-mode
|
|
1075
|
+
* clusters return the ASG instance role; Fargate-only clusters return
|
|
1076
|
+
* `undefined`. Use to attach S3 grants etc. that must reach the host
|
|
1077
|
+
* process (D10 — single accessor; no separate `getAutoScalingGroup()`).
|
|
1078
|
+
*/
|
|
1079
|
+
getInstanceRole() {
|
|
1080
|
+
return this.ecsCluster.getInstanceRole();
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Get the underlying ASG's `autoScalingGroupName` token. String-only —
|
|
1084
|
+
* D10 forbids exposing the ASG construct itself. Used by alarm helpers
|
|
1085
|
+
* that need a CloudWatch dimension value.
|
|
1086
|
+
*/
|
|
1087
|
+
getAutoScalingGroupName() {
|
|
1088
|
+
return this.ecsCluster.getAutoScalingGroupName();
|
|
1089
|
+
}
|
|
260
1090
|
}
|