@fjall/components-infrastructure 0.96.0 → 0.99.1
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 +172 -0
- package/dist/lib/patterns/aws/clickhouseDatabase.js +600 -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 +157 -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
|
@@ -6,9 +6,8 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { SqsEventSource, DynamoEventSource, S3EventSource } from "aws-cdk-lib/aws-lambda-event-sources";
|
|
7
7
|
import { EventType } from "aws-cdk-lib/aws-s3";
|
|
8
8
|
import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
9
|
+
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
10
|
+
import { LogGroup } from "../logging/logGroup.js";
|
|
12
11
|
import { Secret } from "aws-cdk-lib/aws-secretsmanager";
|
|
13
12
|
import { v4 as uuid } from "uuid";
|
|
14
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -20,6 +19,18 @@ function addPoliciesToRole(target, statements) {
|
|
|
20
19
|
target.addToRolePolicy(statement);
|
|
21
20
|
}
|
|
22
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* CDK's auto-generated Lambda execution role does not accept a description
|
|
24
|
+
* through FunctionProps; reach for the L1 CfnRole and set it directly so
|
|
25
|
+
* SOC2 audits see a meaningful purpose on every role.
|
|
26
|
+
*/
|
|
27
|
+
function applyRoleDescription(fn, description) {
|
|
28
|
+
if (description === undefined)
|
|
29
|
+
return;
|
|
30
|
+
const cfnRole = fn.role?.node.defaultChild;
|
|
31
|
+
if (cfnRole !== undefined)
|
|
32
|
+
cfnRole.description = description;
|
|
33
|
+
}
|
|
23
34
|
/**
|
|
24
35
|
* AWS Parameters and Secrets Lambda Extension configuration.
|
|
25
36
|
* @see https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html
|
|
@@ -30,19 +41,26 @@ const SECRETS_EXTENSION = {
|
|
|
30
41
|
/** Cache TTL in seconds - 60s supports secret rotation while reducing API calls */
|
|
31
42
|
CACHE_TTL_SECONDS: "60"
|
|
32
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* Default Lambda timeout in seconds. Coupled across the singleton constructor,
|
|
46
|
+
* the standard constructor, and the alarm wiring — drift would silently
|
|
47
|
+
* mis-tune alarms relative to runtime behaviour.
|
|
48
|
+
*/
|
|
49
|
+
const LAMBDA_DEFAULT_TIMEOUT_SECONDS = 300;
|
|
33
50
|
export class SingletonFunction extends singletonFunction {
|
|
34
51
|
constructor(scope, id, props) {
|
|
35
52
|
super(scope, id, {
|
|
36
53
|
...props,
|
|
37
54
|
uuid: props.uuid ?? uuid(),
|
|
38
|
-
timeout: Duration.seconds(
|
|
39
|
-
description: props.lambdaDescription
|
|
55
|
+
timeout: Duration.seconds(props.timeout ?? LAMBDA_DEFAULT_TIMEOUT_SECONDS),
|
|
56
|
+
description: props.lambdaDescription ?? `${id} singleton lambda`,
|
|
40
57
|
runtime: props.runtime,
|
|
41
58
|
ephemeralStorageSize: props.ephemeralStorageSize
|
|
42
59
|
? Size.mebibytes(props.ephemeralStorageSize)
|
|
43
60
|
: undefined
|
|
44
61
|
});
|
|
45
62
|
addPoliciesToRole(this, props.inlinePolicy);
|
|
63
|
+
applyRoleDescription(this, props.roleDescription);
|
|
46
64
|
}
|
|
47
65
|
/**
|
|
48
66
|
* The Lambda's execution role (auto-generated by CDK)
|
|
@@ -60,26 +78,25 @@ export class LambdaFunction extends Function {
|
|
|
60
78
|
super(scope, id, {
|
|
61
79
|
...props,
|
|
62
80
|
vpcSubnets,
|
|
63
|
-
timeout: props.timeout
|
|
64
|
-
|
|
65
|
-
: Duration.seconds(300),
|
|
66
|
-
memorySize: props.memorySize || 128,
|
|
81
|
+
timeout: Duration.seconds(props.timeout ?? LAMBDA_DEFAULT_TIMEOUT_SECONDS),
|
|
82
|
+
memorySize: props.memorySize ?? 128,
|
|
67
83
|
ephemeralStorageSize: props.ephemeralStorageSize
|
|
68
84
|
? Size.mebibytes(props.ephemeralStorageSize)
|
|
69
85
|
: undefined,
|
|
70
|
-
description: props.lambdaDescription
|
|
86
|
+
description: props.lambdaDescription ?? `${id} Lambda`,
|
|
71
87
|
environment: props.environment,
|
|
72
88
|
logGroup: new LogGroup(scope, `${id}LogGroup`, {
|
|
73
|
-
retention: RetentionDays.ONE_WEEK
|
|
89
|
+
retention: props.logGroupRetention ?? RetentionDays.ONE_WEEK
|
|
74
90
|
})
|
|
75
91
|
});
|
|
76
92
|
addPoliciesToRole(this, props.inlinePolicy);
|
|
93
|
+
applyRoleDescription(this, props.roleDescription);
|
|
77
94
|
this.addSecretsSupport(props.secrets, props.ssmSecretsPath, props.secretsImport, props.appName, props.functionName, props.architecture);
|
|
78
95
|
// Sanitise id for CloudFormation output keys (must be alphanumeric)
|
|
79
96
|
const outputName = toPascalCase(id);
|
|
80
97
|
if (props.enableFunctionUrl) {
|
|
81
98
|
const functionUrl = this.addFunctionUrl({
|
|
82
|
-
authType: props.functionUrlAuthType
|
|
99
|
+
authType: props.functionUrlAuthType ?? FunctionUrlAuthType.AWS_IAM,
|
|
83
100
|
cors: props.functionUrlCors,
|
|
84
101
|
invokeMode: props.functionUrlInvokeMode
|
|
85
102
|
});
|
|
@@ -95,14 +112,8 @@ export class LambdaFunction extends Function {
|
|
|
95
112
|
value: this.functionArn,
|
|
96
113
|
description: `${id} Function ARN`
|
|
97
114
|
});
|
|
98
|
-
if (props.scheduleExpression) {
|
|
99
|
-
const rule = new Rule(this, `${id}ScheduleRule`, {
|
|
100
|
-
schedule: Schedule.expression(props.scheduleExpression)
|
|
101
|
-
});
|
|
102
|
-
rule.addTarget(new LambdaTarget(this));
|
|
103
|
-
}
|
|
104
115
|
if (props.alertsTopic && props.alarms !== false) {
|
|
105
|
-
const timeoutSeconds = props.timeout ??
|
|
116
|
+
const timeoutSeconds = props.timeout ?? LAMBDA_DEFAULT_TIMEOUT_SECONDS;
|
|
106
117
|
createLambdaAlarms({
|
|
107
118
|
scope: this,
|
|
108
119
|
functionName: id,
|
|
@@ -194,25 +205,6 @@ export class LambdaFunction extends Function {
|
|
|
194
205
|
const eventSource = new S3EventSource(bucket, s3EventSourceProps);
|
|
195
206
|
this.addEventSource(eventSource);
|
|
196
207
|
}
|
|
197
|
-
/**
|
|
198
|
-
* Add an EventBridge rule as an event source for this Lambda function.
|
|
199
|
-
* This will trigger the Lambda when events matching the pattern are published.
|
|
200
|
-
* Useful for scheduled jobs, cross-service event handling, and custom event patterns.
|
|
201
|
-
*/
|
|
202
|
-
addEventBridgeEventSource(ruleId, options) {
|
|
203
|
-
if (!options.schedule && !options.eventPattern) {
|
|
204
|
-
throw new Error("EventBridge rule requires either schedule or eventPattern");
|
|
205
|
-
}
|
|
206
|
-
const rule = new Rule(this, ruleId, {
|
|
207
|
-
schedule: options.schedule
|
|
208
|
-
? Schedule.expression(options.schedule)
|
|
209
|
-
: undefined,
|
|
210
|
-
eventPattern: options.eventPattern,
|
|
211
|
-
description: options.description
|
|
212
|
-
});
|
|
213
|
-
rule.addTarget(new LambdaTarget(this));
|
|
214
|
-
return rule;
|
|
215
|
-
}
|
|
216
208
|
/**
|
|
217
209
|
* Add secrets support using AWS Parameters and Secrets Lambda Extension.
|
|
218
210
|
*
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECS deployment lifecycle hook (PRE_SCALE_UP) runner. Inlined into a Lambda
|
|
3
|
+
* at synth time by ecsLifecycleHookMigration.ts.
|
|
4
|
+
*
|
|
5
|
+
* Re-invocation safety: ECS hook responses do NOT carry state between
|
|
6
|
+
* IN_PROGRESS invocations. On every invocation we reconstruct the
|
|
7
|
+
* deterministic startedBy tag from the event's targetServiceRevisionArn and
|
|
8
|
+
* use ListTasks to find an existing running migration. RunTask fires only on
|
|
9
|
+
* the first invocation.
|
|
10
|
+
*
|
|
11
|
+
* CommonJS rather than ESM because Code.fromInline lands the source as
|
|
12
|
+
* `index.js`, which Lambda treats as CommonJS by default.
|
|
13
|
+
*/
|
|
14
|
+
const {
|
|
15
|
+
ECSClient,
|
|
16
|
+
RunTaskCommand,
|
|
17
|
+
DescribeTasksCommand,
|
|
18
|
+
ListTasksCommand
|
|
19
|
+
} = require("@aws-sdk/client-ecs");
|
|
20
|
+
|
|
21
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
22
|
+
const MAX_POLLS_PER_INVOCATION = 20;
|
|
23
|
+
const HOOK_CALLBACK_DELAY_SECONDS = 30;
|
|
24
|
+
|
|
25
|
+
let _defaultClient;
|
|
26
|
+
function getDefaultClient() {
|
|
27
|
+
if (!_defaultClient) _defaultClient = new ECSClient({});
|
|
28
|
+
return _defaultClient;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function startedByTag(targetServiceRevisionArn) {
|
|
32
|
+
// ECS startedBy is bounded to 36 chars; revision id alone is shorter.
|
|
33
|
+
const suffix =
|
|
34
|
+
String(targetServiceRevisionArn || "")
|
|
35
|
+
.split("/")
|
|
36
|
+
.pop() || "unknown";
|
|
37
|
+
return `fjall-migrate-${suffix}`.slice(0, 36);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildContainerOverrides(
|
|
41
|
+
name,
|
|
42
|
+
command,
|
|
43
|
+
image,
|
|
44
|
+
entryPoint,
|
|
45
|
+
environment
|
|
46
|
+
) {
|
|
47
|
+
const override = { name, command };
|
|
48
|
+
if (image) override.image = image;
|
|
49
|
+
if (entryPoint && entryPoint.length > 0) override.entryPoint = entryPoint;
|
|
50
|
+
if (environment && Object.keys(environment).length > 0) {
|
|
51
|
+
override.environment = Object.entries(environment).map(([key, value]) => ({
|
|
52
|
+
name: key,
|
|
53
|
+
value
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
return [override];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function findExistingTaskArn(client, clusterArn, startedBy) {
|
|
60
|
+
const result = await client.send(
|
|
61
|
+
new ListTasksCommand({ cluster: clusterArn, startedBy })
|
|
62
|
+
);
|
|
63
|
+
if (!result.taskArns || result.taskArns.length === 0) return undefined;
|
|
64
|
+
return result.taskArns[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function pollTaskUntilStopped(client, clusterArn, taskArn, sleep) {
|
|
68
|
+
const sleepFn =
|
|
69
|
+
sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
70
|
+
for (let i = 0; i < MAX_POLLS_PER_INVOCATION; i++) {
|
|
71
|
+
const desc = await client.send(
|
|
72
|
+
new DescribeTasksCommand({ cluster: clusterArn, tasks: [taskArn] })
|
|
73
|
+
);
|
|
74
|
+
const task = desc.tasks && desc.tasks[0];
|
|
75
|
+
if (!task) {
|
|
76
|
+
return { hookStatus: "FAILED", reason: "DescribeTasks returned no task" };
|
|
77
|
+
}
|
|
78
|
+
if (task.lastStatus === "STOPPED") {
|
|
79
|
+
const exitCode =
|
|
80
|
+
task.containers && task.containers[0]
|
|
81
|
+
? task.containers[0].exitCode
|
|
82
|
+
: undefined;
|
|
83
|
+
if (exitCode === 0) return { hookStatus: "SUCCEEDED" };
|
|
84
|
+
return {
|
|
85
|
+
hookStatus: "FAILED",
|
|
86
|
+
reason: `Task exited ${exitCode}: ${task.stoppedReason || "no reason"}`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (i < MAX_POLLS_PER_INVOCATION - 1) {
|
|
90
|
+
await sleepFn(POLL_INTERVAL_MS);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
hookStatus: "IN_PROGRESS",
|
|
95
|
+
callBackDelay: HOOK_CALLBACK_DELAY_SECONDS
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runHandler(event, deps) {
|
|
100
|
+
const client = (deps && deps.client) || getDefaultClient();
|
|
101
|
+
const sleep = deps && deps.sleep;
|
|
102
|
+
const config = JSON.parse(
|
|
103
|
+
(deps && deps.migrateConfig) || process.env.MIGRATE_CONFIG
|
|
104
|
+
);
|
|
105
|
+
const startedBy = startedByTag(event && event.targetServiceRevisionArn);
|
|
106
|
+
|
|
107
|
+
const existingTaskArn = await findExistingTaskArn(
|
|
108
|
+
client,
|
|
109
|
+
config.clusterArn,
|
|
110
|
+
startedBy
|
|
111
|
+
);
|
|
112
|
+
if (existingTaskArn) {
|
|
113
|
+
return pollTaskUntilStopped(
|
|
114
|
+
client,
|
|
115
|
+
config.clusterArn,
|
|
116
|
+
existingTaskArn,
|
|
117
|
+
sleep
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result = await client.send(
|
|
122
|
+
new RunTaskCommand({
|
|
123
|
+
cluster: config.clusterArn,
|
|
124
|
+
taskDefinition: config.taskDefinitionArn,
|
|
125
|
+
launchType: "FARGATE",
|
|
126
|
+
networkConfiguration: {
|
|
127
|
+
awsvpcConfiguration: config.networkConfiguration
|
|
128
|
+
},
|
|
129
|
+
overrides: {
|
|
130
|
+
containerOverrides: buildContainerOverrides(
|
|
131
|
+
config.name,
|
|
132
|
+
config.command,
|
|
133
|
+
config.hasSeparateMigrationTaskDef ? undefined : config.image,
|
|
134
|
+
config.hasSeparateMigrationTaskDef ? undefined : config.entryPoint,
|
|
135
|
+
config.environment
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
startedBy
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
const taskArn =
|
|
142
|
+
result.tasks && result.tasks[0] ? result.tasks[0].taskArn : undefined;
|
|
143
|
+
if (!taskArn) {
|
|
144
|
+
return {
|
|
145
|
+
hookStatus: "FAILED",
|
|
146
|
+
reason: "RunTask did not return a task ARN"
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return pollTaskUntilStopped(client, config.clusterArn, taskArn, sleep);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
exports.handler = async (event) => {
|
|
154
|
+
// Log invocation context + outcome — ECS hooks only surface "hook execution
|
|
155
|
+
// failure" to the deployment; without these the Lambda log is silent and
|
|
156
|
+
// the failure cause is unrecoverable.
|
|
157
|
+
console.log(
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
msg: "lifecycle-hook-invoked",
|
|
160
|
+
targetServiceRevisionArn:
|
|
161
|
+
(event && event.targetServiceRevisionArn) || "(missing)",
|
|
162
|
+
executionId:
|
|
163
|
+
(event &&
|
|
164
|
+
event.executionDetails &&
|
|
165
|
+
event.executionDetails.executionId) ||
|
|
166
|
+
"(missing)"
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
try {
|
|
170
|
+
const result = await runHandler(event);
|
|
171
|
+
console.log(JSON.stringify({ msg: "lifecycle-hook-result", ...result }));
|
|
172
|
+
return result;
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const reason = err && err.message ? err.message : String(err);
|
|
175
|
+
const stack = err && err.stack ? err.stack : undefined;
|
|
176
|
+
console.error(
|
|
177
|
+
JSON.stringify({ msg: "lifecycle-hook-crashed", reason, stack })
|
|
178
|
+
);
|
|
179
|
+
return { hookStatus: "FAILED", reason: `Runner crashed: ${reason}` };
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
exports._internals = {
|
|
184
|
+
startedByTag,
|
|
185
|
+
buildContainerOverrides,
|
|
186
|
+
findExistingTaskArn,
|
|
187
|
+
pollTaskUntilStopped,
|
|
188
|
+
runHandler,
|
|
189
|
+
POLL_INTERVAL_MS,
|
|
190
|
+
MAX_POLLS_PER_INVOCATION,
|
|
191
|
+
HOOK_CALLBACK_DELAY_SECONDS
|
|
192
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type AutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
|
|
2
|
+
import { Alarm } from "aws-cdk-lib/aws-cloudwatch";
|
|
3
|
+
import { EbsDeviceVolumeType, Volume } from "aws-cdk-lib/aws-ec2";
|
|
4
|
+
import { Construct } from "constructs";
|
|
5
|
+
import { LambdaFunction } from "./lambda.js";
|
|
6
|
+
import { SQSQueue } from "../messaging/sqs.js";
|
|
7
|
+
/**
|
|
8
|
+
* Description prefix baked into the LAUNCHING Lambda + execution-role
|
|
9
|
+
* descriptions. Tests grep CFN output for this needle to locate the function
|
|
10
|
+
* across stacks; keep it here as the single source of truth so a rename does
|
|
11
|
+
* not silently break the test query.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION = "PersistentDataVolume launching handler";
|
|
14
|
+
export declare const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE = "fjall:Lifecycle";
|
|
15
|
+
export declare const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE = "data-volume";
|
|
16
|
+
export declare const PERSISTENT_DATA_VOLUME_TAG_OWNER_LOGICAL_ID = "fjall:OwnerLogicalId";
|
|
17
|
+
export declare const PERSISTENT_DATA_VOLUME_TAG_STACK_ID = "fjall:StackId";
|
|
18
|
+
export interface PersistentDataVolumeProps {
|
|
19
|
+
/** ASG whose EC2_INSTANCE_LAUNCHING transitions trigger the re-attach. */
|
|
20
|
+
autoScalingGroup: AutoScalingGroup;
|
|
21
|
+
/** Size in GiB. */
|
|
22
|
+
sizeGb: number;
|
|
23
|
+
/** Device path the bootstrap script expects (e.g. /dev/xvdf). */
|
|
24
|
+
deviceName: string;
|
|
25
|
+
/**
|
|
26
|
+
* Availability zone the volume lives in. MUST match the ASG's single AZ —
|
|
27
|
+
* standalone EBS volumes are AZ-local and cannot follow a multi-AZ ASG.
|
|
28
|
+
* Pass `Stack.of(this).availabilityZones[0]` from a single-AZ-constrained
|
|
29
|
+
* consumer.
|
|
30
|
+
*/
|
|
31
|
+
availabilityZone: string;
|
|
32
|
+
/** EBS volume type. Defaults to gp3. */
|
|
33
|
+
volumeType?: EbsDeviceVolumeType;
|
|
34
|
+
/** Provisioned IOPS (gp3/io1/io2 only). */
|
|
35
|
+
iops?: number;
|
|
36
|
+
/** Throughput in MiB/s (gp3 only). */
|
|
37
|
+
throughputMbps?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Standalone EBS data volume + ASG `EC2_INSTANCE_LAUNCHING` hook that
|
|
41
|
+
* re-attaches the same physical volume across instance refreshes.
|
|
42
|
+
*
|
|
43
|
+
* Pairs with the TERMINATING-side DetachVolume branch in
|
|
44
|
+
* `ec2GracefulTerminationLambda.source.cjs` (see `DetachVolumeCommand` import
|
|
45
|
+
* + invocation) wired by `ec2GracefulTerminationHandler.ts` when
|
|
46
|
+
* `dataVolumeOwnerLogicalId` is forwarded from `Ec2Instance`. The two
|
|
47
|
+
* Lambdas form a closed re-attach contract.
|
|
48
|
+
*
|
|
49
|
+
* Volume tagging: three tags carry the re-attach identity —
|
|
50
|
+
* `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
|
|
51
|
+
* `fjall:OwnerLogicalId = <this.node.path>` (per-construct, stable across
|
|
52
|
+
* instance refreshes in one stack so the Lambda re-finds the same volume)
|
|
53
|
+
* `fjall:StackId = <Aws.STACK_ID>` (per-stack-creation discriminator;
|
|
54
|
+
* CloudFormation issues a fresh StackId for every CREATE, so orphans
|
|
55
|
+
* from prior failed deploys or aborted `cdk destroy` runs carry a stale
|
|
56
|
+
* StackId and cannot collide with the new deploy's volume)
|
|
57
|
+
*
|
|
58
|
+
* The LAUNCHING Lambda filters DescribeVolumes by all three tags AND ABANDONs
|
|
59
|
+
* on multi-match. The TERMINATING handler MUST be configured with the same
|
|
60
|
+
* `ownerLogicalId` (and is in the same stack, so shares the StackId
|
|
61
|
+
* automatically) to locate the volume for detach.
|
|
62
|
+
*
|
|
63
|
+
* Why three tags. The first two are stable per logical construct; without
|
|
64
|
+
* the StackId discriminator, every prior-iteration orphan (CREATE_ROLLBACK,
|
|
65
|
+
* UPDATE_ROLLBACK_FAILED, DELETE_FAILED, manual interruption) that CFN
|
|
66
|
+
* cannot guarantee to delete on rollback would multi-match the LAUNCHING
|
|
67
|
+
* filter and force ABANDON on the next deploy. The StackId tag makes the
|
|
68
|
+
* system orphan-tolerant by construction — orphans simply don't match.
|
|
69
|
+
*
|
|
70
|
+
* Removal policy `SNAPSHOT` is independent: it covers data preservation
|
|
71
|
+
* across clean `cdk destroy`, not orphan defence. Recovery from a snapshot
|
|
72
|
+
* after destroy is still a manual restore-with-new-tags step (intentionally
|
|
73
|
+
* undocumented as a runbook — `cdk destroy` of a stateful stack is rare
|
|
74
|
+
* enough that ad-hoc recovery is acceptable).
|
|
75
|
+
*/
|
|
76
|
+
export declare class PersistentDataVolume extends Construct {
|
|
77
|
+
readonly volume: Volume;
|
|
78
|
+
readonly lambda: LambdaFunction;
|
|
79
|
+
readonly queue: SQSQueue;
|
|
80
|
+
/**
|
|
81
|
+
* Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
|
|
82
|
+
* `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
|
|
83
|
+
* value to the TERMINATING handler so it locates the same volume for
|
|
84
|
+
* detach. Derived from `this.node.path`; renaming or reparenting any
|
|
85
|
+
* ancestor changes the value, which breaks the re-attach chain (Lambda
|
|
86
|
+
* filter no longer matches, new instance fails its launch hook). Rename
|
|
87
|
+
* across an existing deployment is a manual snapshot-restore step.
|
|
88
|
+
*/
|
|
89
|
+
readonly ownerLogicalId: string;
|
|
90
|
+
readonly attachFailureAlarm: Alarm;
|
|
91
|
+
constructor(scope: Construct, id: string, props: PersistentDataVolumeProps);
|
|
92
|
+
}
|
|
93
|
+
declare function validatePersistentDataVolumeProps(props: PersistentDataVolumeProps): void;
|
|
94
|
+
export declare const _internals: {
|
|
95
|
+
LAUNCHING_LAMBDA_SOURCE_FILE: string;
|
|
96
|
+
LAMBDA_TIMEOUT_SECONDS: number;
|
|
97
|
+
LAUNCHING_HOOK_HEARTBEAT_SECONDS: number;
|
|
98
|
+
QUEUE_VISIBILITY_TIMEOUT_SECONDS: number;
|
|
99
|
+
ALARM_EVALUATION_PERIODS: number;
|
|
100
|
+
ALARM_DATAPOINTS_TO_ALARM: number;
|
|
101
|
+
ALARM_THRESHOLD: number;
|
|
102
|
+
validatePersistentDataVolumeProps: typeof validatePersistentDataVolumeProps;
|
|
103
|
+
};
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Aws, Duration, RemovalPolicy, Size, Stack, Tags } from "aws-cdk-lib";
|
|
5
|
+
import { DefaultResult, LifecycleTransition } from "aws-cdk-lib/aws-autoscaling";
|
|
6
|
+
import { Alarm, ComparisonOperator, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
|
|
7
|
+
import { EbsDeviceVolumeType, Volume } from "aws-cdk-lib/aws-ec2";
|
|
8
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
9
|
+
import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
10
|
+
import { RetentionDays } from "aws-cdk-lib/aws-logs";
|
|
11
|
+
import { Construct } from "constructs";
|
|
12
|
+
import { attachInlineAsgLifecycleHook } from "./asgInlineLifecycleHook.js";
|
|
13
|
+
import { LambdaFunction } from "./lambda.js";
|
|
14
|
+
import { SQSQueue } from "../messaging/sqs.js";
|
|
15
|
+
import { Subscription } from "../messaging/subscription.js";
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const LAUNCHING_LAMBDA_SOURCE_FILE = "persistentDataVolumeLambda.source.cjs";
|
|
18
|
+
/**
|
|
19
|
+
* Description prefix baked into the LAUNCHING Lambda + execution-role
|
|
20
|
+
* descriptions. Tests grep CFN output for this needle to locate the function
|
|
21
|
+
* across stacks; keep it here as the single source of truth so a rename does
|
|
22
|
+
* not silently break the test query.
|
|
23
|
+
*/
|
|
24
|
+
export const PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION = "PersistentDataVolume launching handler";
|
|
25
|
+
const LAMBDA_TIMEOUT_SECONDS = 180;
|
|
26
|
+
// Heartbeat must comfortably exceed the Lambda's 90s attach deadline.
|
|
27
|
+
const LAUNCHING_HOOK_HEARTBEAT_SECONDS = 300;
|
|
28
|
+
const QUEUE_VISIBILITY_TIMEOUT_SECONDS = 240;
|
|
29
|
+
export const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE = "fjall:Lifecycle";
|
|
30
|
+
export const PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE = "data-volume";
|
|
31
|
+
export const PERSISTENT_DATA_VOLUME_TAG_OWNER_LOGICAL_ID = "fjall:OwnerLogicalId";
|
|
32
|
+
export const PERSISTENT_DATA_VOLUME_TAG_STACK_ID = "fjall:StackId";
|
|
33
|
+
const ALARM_EVALUATION_PERIODS = 3;
|
|
34
|
+
const ALARM_DATAPOINTS_TO_ALARM = 3;
|
|
35
|
+
const ALARM_THRESHOLD = 1;
|
|
36
|
+
/**
|
|
37
|
+
* Standalone EBS data volume + ASG `EC2_INSTANCE_LAUNCHING` hook that
|
|
38
|
+
* re-attaches the same physical volume across instance refreshes.
|
|
39
|
+
*
|
|
40
|
+
* Pairs with the TERMINATING-side DetachVolume branch in
|
|
41
|
+
* `ec2GracefulTerminationLambda.source.cjs` (see `DetachVolumeCommand` import
|
|
42
|
+
* + invocation) wired by `ec2GracefulTerminationHandler.ts` when
|
|
43
|
+
* `dataVolumeOwnerLogicalId` is forwarded from `Ec2Instance`. The two
|
|
44
|
+
* Lambdas form a closed re-attach contract.
|
|
45
|
+
*
|
|
46
|
+
* Volume tagging: three tags carry the re-attach identity —
|
|
47
|
+
* `fjall:Lifecycle = data-volume` (constant; gates IAM scoping)
|
|
48
|
+
* `fjall:OwnerLogicalId = <this.node.path>` (per-construct, stable across
|
|
49
|
+
* instance refreshes in one stack so the Lambda re-finds the same volume)
|
|
50
|
+
* `fjall:StackId = <Aws.STACK_ID>` (per-stack-creation discriminator;
|
|
51
|
+
* CloudFormation issues a fresh StackId for every CREATE, so orphans
|
|
52
|
+
* from prior failed deploys or aborted `cdk destroy` runs carry a stale
|
|
53
|
+
* StackId and cannot collide with the new deploy's volume)
|
|
54
|
+
*
|
|
55
|
+
* The LAUNCHING Lambda filters DescribeVolumes by all three tags AND ABANDONs
|
|
56
|
+
* on multi-match. The TERMINATING handler MUST be configured with the same
|
|
57
|
+
* `ownerLogicalId` (and is in the same stack, so shares the StackId
|
|
58
|
+
* automatically) to locate the volume for detach.
|
|
59
|
+
*
|
|
60
|
+
* Why three tags. The first two are stable per logical construct; without
|
|
61
|
+
* the StackId discriminator, every prior-iteration orphan (CREATE_ROLLBACK,
|
|
62
|
+
* UPDATE_ROLLBACK_FAILED, DELETE_FAILED, manual interruption) that CFN
|
|
63
|
+
* cannot guarantee to delete on rollback would multi-match the LAUNCHING
|
|
64
|
+
* filter and force ABANDON on the next deploy. The StackId tag makes the
|
|
65
|
+
* system orphan-tolerant by construction — orphans simply don't match.
|
|
66
|
+
*
|
|
67
|
+
* Removal policy `SNAPSHOT` is independent: it covers data preservation
|
|
68
|
+
* across clean `cdk destroy`, not orphan defence. Recovery from a snapshot
|
|
69
|
+
* after destroy is still a manual restore-with-new-tags step (intentionally
|
|
70
|
+
* undocumented as a runbook — `cdk destroy` of a stateful stack is rare
|
|
71
|
+
* enough that ad-hoc recovery is acceptable).
|
|
72
|
+
*/
|
|
73
|
+
export class PersistentDataVolume extends Construct {
|
|
74
|
+
volume;
|
|
75
|
+
lambda;
|
|
76
|
+
queue;
|
|
77
|
+
/**
|
|
78
|
+
* Identifier used as the `fjall:OwnerLogicalId` tag on the volume and the
|
|
79
|
+
* `OWNER_LOGICAL_ID` env on the Lambda. Consumers MUST pass this exact
|
|
80
|
+
* value to the TERMINATING handler so it locates the same volume for
|
|
81
|
+
* detach. Derived from `this.node.path`; renaming or reparenting any
|
|
82
|
+
* ancestor changes the value, which breaks the re-attach chain (Lambda
|
|
83
|
+
* filter no longer matches, new instance fails its launch hook). Rename
|
|
84
|
+
* across an existing deployment is a manual snapshot-restore step.
|
|
85
|
+
*/
|
|
86
|
+
ownerLogicalId;
|
|
87
|
+
attachFailureAlarm;
|
|
88
|
+
constructor(scope, id, props) {
|
|
89
|
+
super(scope, id);
|
|
90
|
+
validatePersistentDataVolumeProps(props);
|
|
91
|
+
this.ownerLogicalId = this.node.path;
|
|
92
|
+
this.volume = new Volume(this, "Volume", {
|
|
93
|
+
availabilityZone: props.availabilityZone,
|
|
94
|
+
size: Size.gibibytes(props.sizeGb),
|
|
95
|
+
volumeType: props.volumeType ?? EbsDeviceVolumeType.GP3,
|
|
96
|
+
encrypted: true,
|
|
97
|
+
...(props.iops !== undefined && { iops: props.iops }),
|
|
98
|
+
...(props.throughputMbps !== undefined && {
|
|
99
|
+
throughput: props.throughputMbps
|
|
100
|
+
})
|
|
101
|
+
});
|
|
102
|
+
this.volume.applyRemovalPolicy(RemovalPolicy.SNAPSHOT);
|
|
103
|
+
Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE, PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE);
|
|
104
|
+
Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_OWNER_LOGICAL_ID, this.ownerLogicalId);
|
|
105
|
+
Tags.of(this.volume).add(PERSISTENT_DATA_VOLUME_TAG_STACK_ID, Aws.STACK_ID);
|
|
106
|
+
this.queue = new SQSQueue(this, `${id}Queue`, {
|
|
107
|
+
visibilityTimeout: QUEUE_VISIBILITY_TIMEOUT_SECONDS,
|
|
108
|
+
deadLetterQueue: { enabled: true, maxReceiveCount: 5 }
|
|
109
|
+
});
|
|
110
|
+
const sourcePath = path.resolve(__dirname, LAUNCHING_LAMBDA_SOURCE_FILE);
|
|
111
|
+
const source = readFileSync(sourcePath, "utf-8");
|
|
112
|
+
const stack = Stack.of(this);
|
|
113
|
+
const volumeArnPattern = `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:volume/*`;
|
|
114
|
+
const instanceArnPattern = `arn:${stack.partition}:ec2:${stack.region}:${stack.account}:instance/*`;
|
|
115
|
+
// Account/region-scoped wildcard rather than the specific ASG ARN. The
|
|
116
|
+
// specific ARN would create an implicit CFN Ref to the ASG, forcing the
|
|
117
|
+
// Lambda's IAM Policy to wait for ASG CREATE_COMPLETE. The ASG cannot
|
|
118
|
+
// complete until the Lambda calls CompleteLifecycleAction on its first
|
|
119
|
+
// instance — the wildcard breaks the deadlock without widening blast
|
|
120
|
+
// radius: the Lambda is only invoked by its own SQS queue, which is fed
|
|
121
|
+
// by an EventBridge rule discriminating by the stack-scoped
|
|
122
|
+
// LifecycleHookName below.
|
|
123
|
+
const asgArnWildcard = `arn:${stack.partition}:autoscaling:${stack.region}:${stack.account}:autoScalingGroup:*:autoScalingGroupName/*`;
|
|
124
|
+
const describeVolumesPolicy = new PolicyStatement({
|
|
125
|
+
effect: Effect.ALLOW,
|
|
126
|
+
actions: ["ec2:DescribeVolumes"],
|
|
127
|
+
resources: ["*"]
|
|
128
|
+
});
|
|
129
|
+
// Split volume + instance: StringEqualsIfExists granted AttachVolume on
|
|
130
|
+
// any untagged resource (incl. arbitrary instances). Use StringEquals on
|
|
131
|
+
// the volume side; instances launched by the ASG carry no lifecycle tag.
|
|
132
|
+
const attachVolumeOnTaggedVolumePolicy = new PolicyStatement({
|
|
133
|
+
effect: Effect.ALLOW,
|
|
134
|
+
actions: ["ec2:AttachVolume"],
|
|
135
|
+
resources: [volumeArnPattern],
|
|
136
|
+
conditions: {
|
|
137
|
+
StringEquals: {
|
|
138
|
+
[`aws:ResourceTag/${PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE}`]: PERSISTENT_DATA_VOLUME_TAG_LIFECYCLE_VALUE,
|
|
139
|
+
[`aws:ResourceTag/${PERSISTENT_DATA_VOLUME_TAG_STACK_ID}`]: Aws.STACK_ID
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
const attachVolumeOnInstancePolicy = new PolicyStatement({
|
|
144
|
+
effect: Effect.ALLOW,
|
|
145
|
+
actions: ["ec2:AttachVolume"],
|
|
146
|
+
resources: [instanceArnPattern]
|
|
147
|
+
});
|
|
148
|
+
const completeLifecyclePolicy = new PolicyStatement({
|
|
149
|
+
effect: Effect.ALLOW,
|
|
150
|
+
actions: ["autoscaling:CompleteLifecycleAction"],
|
|
151
|
+
resources: [asgArnWildcard]
|
|
152
|
+
});
|
|
153
|
+
this.lambda = new LambdaFunction(this, `${id}Fn`, {
|
|
154
|
+
runtime: Runtime.NODEJS_22_X,
|
|
155
|
+
handler: "index.handler",
|
|
156
|
+
code: Code.fromInline(source),
|
|
157
|
+
lambdaDescription: `${id} ${PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION}`,
|
|
158
|
+
roleDescription: `Execution role for ${id} ${PERSISTENT_DATA_VOLUME_LAUNCHING_DESCRIPTION}`,
|
|
159
|
+
timeout: LAMBDA_TIMEOUT_SECONDS,
|
|
160
|
+
memorySize: 256,
|
|
161
|
+
logGroupRetention: RetentionDays.ONE_MONTH,
|
|
162
|
+
environment: {
|
|
163
|
+
OWNER_LOGICAL_ID: this.ownerLogicalId,
|
|
164
|
+
STACK_ID: Aws.STACK_ID,
|
|
165
|
+
DEVICE_NAME: props.deviceName
|
|
166
|
+
},
|
|
167
|
+
inlinePolicy: [
|
|
168
|
+
describeVolumesPolicy,
|
|
169
|
+
attachVolumeOnTaggedVolumePolicy,
|
|
170
|
+
attachVolumeOnInstancePolicy,
|
|
171
|
+
completeLifecyclePolicy
|
|
172
|
+
]
|
|
173
|
+
});
|
|
174
|
+
this.lambda.addSqsEventSource(this.queue.getQueue());
|
|
175
|
+
// Hook name embeds Aws.STACK_NAME (a pseudo-parameter Ref, NOT a resource
|
|
176
|
+
// Ref — does not create a CFN dep) so that LifecycleHookName alone is
|
|
177
|
+
// sufficient to discriminate across stacks in the EB pattern below. The
|
|
178
|
+
// sibling TERMINATING hook uses "-terminating" so the two never collide
|
|
179
|
+
// within a single stack either.
|
|
180
|
+
const launchingHookName = `${Aws.STACK_NAME}-${id}-launching`;
|
|
181
|
+
attachInlineAsgLifecycleHook(this, `${id}LaunchingHook`, {
|
|
182
|
+
autoScalingGroup: props.autoScalingGroup,
|
|
183
|
+
hookName: launchingHookName,
|
|
184
|
+
lifecycleTransition: LifecycleTransition.INSTANCE_LAUNCHING,
|
|
185
|
+
defaultResult: DefaultResult.ABANDON,
|
|
186
|
+
heartbeatTimeoutSeconds: LAUNCHING_HOOK_HEARTBEAT_SECONDS
|
|
187
|
+
});
|
|
188
|
+
// Pattern discriminates by LifecycleHookName only — the hook name carries
|
|
189
|
+
// Aws.STACK_NAME so it is unique per stack. AutoScalingGroupName would
|
|
190
|
+
// also discriminate but creates an implicit Ref to the ASG, blocking the
|
|
191
|
+
// EB rule on ASG CREATE_COMPLETE (which can't happen without this Lambda
|
|
192
|
+
// running — the deadlock that motivated the wildcard above).
|
|
193
|
+
new Subscription(this, `${id}LaunchingSub`, {
|
|
194
|
+
pattern: {
|
|
195
|
+
source: ["aws.autoscaling"],
|
|
196
|
+
detailType: ["EC2 Instance-launch Lifecycle Action"],
|
|
197
|
+
detail: {
|
|
198
|
+
LifecycleHookName: [launchingHookName]
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
target: this.queue
|
|
202
|
+
});
|
|
203
|
+
this.attachFailureAlarm = new Alarm(this, "AttachFailureAlarm", {
|
|
204
|
+
alarmDescription: `${id} PersistentDataVolume LAUNCHING Lambda errors — ` +
|
|
205
|
+
`volume re-attach has failed; new instances will not see existing data.`,
|
|
206
|
+
metric: new Metric({
|
|
207
|
+
namespace: "AWS/Lambda",
|
|
208
|
+
metricName: "Errors",
|
|
209
|
+
dimensionsMap: { FunctionName: this.lambda.functionName },
|
|
210
|
+
statistic: "Sum",
|
|
211
|
+
period: Duration.minutes(1)
|
|
212
|
+
}),
|
|
213
|
+
threshold: ALARM_THRESHOLD,
|
|
214
|
+
evaluationPeriods: ALARM_EVALUATION_PERIODS,
|
|
215
|
+
datapointsToAlarm: ALARM_DATAPOINTS_TO_ALARM,
|
|
216
|
+
comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
|
217
|
+
treatMissingData: TreatMissingData.NOT_BREACHING
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function validatePersistentDataVolumeProps(props) {
|
|
222
|
+
if (props.sizeGb <= 0 || !Number.isFinite(props.sizeGb)) {
|
|
223
|
+
throw new Error(`PersistentDataVolume.sizeGb must be a positive number; got ${props.sizeGb}`);
|
|
224
|
+
}
|
|
225
|
+
if (props.deviceName === "") {
|
|
226
|
+
throw new Error("PersistentDataVolume.deviceName must be non-empty");
|
|
227
|
+
}
|
|
228
|
+
if (props.availabilityZone === "") {
|
|
229
|
+
throw new Error("PersistentDataVolume.availabilityZone must be non-empty");
|
|
230
|
+
}
|
|
231
|
+
if (props.throughputMbps !== undefined &&
|
|
232
|
+
(props.volumeType ?? EbsDeviceVolumeType.GP3) !== EbsDeviceVolumeType.GP3) {
|
|
233
|
+
throw new Error("PersistentDataVolume.throughputMbps is only valid for gp3 volumes");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export const _internals = {
|
|
237
|
+
LAUNCHING_LAMBDA_SOURCE_FILE,
|
|
238
|
+
LAMBDA_TIMEOUT_SECONDS,
|
|
239
|
+
LAUNCHING_HOOK_HEARTBEAT_SECONDS,
|
|
240
|
+
QUEUE_VISIBILITY_TIMEOUT_SECONDS,
|
|
241
|
+
ALARM_EVALUATION_PERIODS,
|
|
242
|
+
ALARM_DATAPOINTS_TO_ALARM,
|
|
243
|
+
ALARM_THRESHOLD,
|
|
244
|
+
validatePersistentDataVolumeProps
|
|
245
|
+
};
|