@fjall/components-infrastructure 37.0.0 → 39.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/app.d.ts +52 -12
- package/dist/lib/app.js +60 -24
- package/dist/lib/config/aws/configRecorder.js +3 -0
- package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
- package/dist/lib/config/aws/disasterRecovery.js +167 -45
- package/dist/lib/patterns/aws/buildkite/buildkite.js +7 -2
- package/dist/lib/patterns/aws/cdn.d.ts +19 -11
- package/dist/lib/patterns/aws/cdn.js +53 -25
- package/dist/lib/patterns/aws/clickhouseDatabase.js +8 -4
- package/dist/lib/patterns/aws/computeEcs.js +17 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +30 -4
- package/dist/lib/patterns/aws/computePropApplicability.js +1 -0
- package/dist/lib/patterns/aws/domain.js +3 -3
- package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
- package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
- package/dist/lib/patterns/aws/messaging.d.ts +10 -0
- package/dist/lib/patterns/aws/messaging.js +10 -6
- package/dist/lib/patterns/aws/payload.d.ts +5 -0
- package/dist/lib/patterns/aws/payload.js +23 -11
- package/dist/lib/patterns/aws/storage.d.ts +41 -0
- package/dist/lib/patterns/aws/storage.js +23 -8
- package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
- package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
- package/dist/lib/resources/aws/backup/backupVault.d.ts +27 -2
- package/dist/lib/resources/aws/backup/backupVault.js +49 -6
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
- package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
- package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
- package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
- package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
- package/dist/lib/resources/aws/cdn/index.js +1 -0
- package/dist/lib/resources/aws/compute/ecs.d.ts +7 -4
- package/dist/lib/resources/aws/compute/ecs.js +27 -21
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +3 -0
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +11 -0
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +8 -0
- package/dist/lib/resources/aws/compute/ecsValidation.js +8 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +8 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +2 -0
- package/dist/lib/resources/aws/database/dynamodb.js +2 -1
- package/dist/lib/resources/aws/database/rdsAurora.js +6 -1
- package/dist/lib/resources/aws/database/rdsInstance.js +6 -1
- package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
- package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
- package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +16 -0
- package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +91 -0
- package/dist/lib/resources/aws/messaging/eventBridgeRule.js +36 -5
- package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
- package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
- package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
- package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
- package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
- package/dist/lib/resources/aws/messaging/sqs.d.ts +36 -1
- package/dist/lib/resources/aws/messaging/sqs.js +40 -2
- package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
- package/dist/lib/resources/aws/networking/vpc.js +12 -6
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.d.ts +26 -1
- package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +56 -1
- package/dist/lib/resources/aws/storage/s3.d.ts +21 -1
- package/dist/lib/resources/aws/storage/s3.js +89 -3
- package/dist/lib/utils/backupTierMapping.d.ts +23 -2
- package/dist/lib/utils/backupTierMapping.js +25 -0
- package/dist/lib/utils/cdkContext.d.ts +14 -0
- package/dist/lib/utils/cdkContext.js +38 -1
- package/dist/lib/utils/exportNaming.d.ts +30 -27
- package/dist/lib/utils/exportNaming.js +32 -28
- package/dist/lib/utils/standardTagsAspect.d.ts +8 -18
- package/dist/lib/utils/standardTagsAspect.js +8 -34
- package/package.json +4 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
2
|
import { type IQueue } from "aws-cdk-lib/aws-sqs";
|
|
3
3
|
import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
|
|
4
|
+
import { type IKey } from "aws-cdk-lib/aws-kms";
|
|
4
5
|
import type { ITopic } from "aws-cdk-lib/aws-sns";
|
|
5
6
|
import { type RemovalPolicyString } from "./utils.js";
|
|
6
7
|
import { type SqsAlarmThresholds } from "../monitoring/index.js";
|
|
@@ -57,8 +58,42 @@ export interface SQSQueueProps {
|
|
|
57
58
|
maxReceiveCount?: number;
|
|
58
59
|
queue?: IQueue;
|
|
59
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* Server-side encryption at rest. Defaults to `"SSE_SQS"` — the SQS-owned
|
|
63
|
+
* managed key, which is free, needs no key policy and satisfies
|
|
64
|
+
* `sqs-queue-not-encrypted`.
|
|
65
|
+
*
|
|
66
|
+
* `"SSE_KMS"` encrypts with a customer-managed key: `masterKey` when you
|
|
67
|
+
* bring one, else the stack's shared {@link StackEncryptionKey} with the
|
|
68
|
+
* SQS senders admitted to it. It deliberately does NOT fall through to
|
|
69
|
+
* what CDK would do unaided — mint an implicit `AWS::KMS::Key` that is
|
|
70
|
+
* `DeletionPolicy: Retain` with no alias and no tags, admitting only the
|
|
71
|
+
* account root. Such a key is invisible to every Fjall teardown sweep
|
|
72
|
+
* (they find keys by the alias tag `CustomerManagedKey` stamps), so it
|
|
73
|
+
* outlives the stack as a charge nothing can attribute — and because its
|
|
74
|
+
* policy admits no service principal, no EventBridge rule, S3 notification
|
|
75
|
+
* or SNS subscription could send to the queue either.
|
|
76
|
+
*
|
|
77
|
+
* Unlike SNS, this does not follow the app's backup tier. `"SSE_SQS"` is
|
|
78
|
+
* real encryption at rest at every tier and costs nothing, and the tier's
|
|
79
|
+
* `customerManagedKeys` column enumerates the resources it owns (DynamoDB
|
|
80
|
+
* tables, SNS topics, Lambda environment, ECS Exec, log groups) — queues
|
|
81
|
+
* are not among them, so the opt-in here is the caller's.
|
|
82
|
+
*/
|
|
60
83
|
encryption?: "SSE_SQS" | "SSE_KMS" | "NONE";
|
|
61
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Bring your own KMS key. Implies `encryption: "SSE_KMS"` when no
|
|
86
|
+
* encryption is stated, and is REFUSED beside `"SSE_SQS"` or `"NONE"`
|
|
87
|
+
* rather than silently ignored.
|
|
88
|
+
*
|
|
89
|
+
* Every AWS service principal that sends to the queue needs `kms:Decrypt`
|
|
90
|
+
* + `kms:GenerateDataKey*` on this key's RESOURCE policy or its sends are
|
|
91
|
+
* dropped without an error — the key policy is the only place a service
|
|
92
|
+
* principal can be admitted. The stack key's own admission
|
|
93
|
+
* ({@link StackEncryptionKey.forSqsServicePublishers}) is not applied to a
|
|
94
|
+
* key you brought: it is yours to scope.
|
|
95
|
+
*/
|
|
96
|
+
masterKey?: IKey;
|
|
62
97
|
contentBasedDeduplication?: boolean;
|
|
63
98
|
fifoThroughputLimit?: "perQueue" | "perMessageGroupId";
|
|
64
99
|
deduplicationScope?: "queue" | "messageGroup";
|
|
@@ -2,8 +2,10 @@ import { Construct } from "constructs";
|
|
|
2
2
|
import { CfnOutput, Duration } from "aws-cdk-lib";
|
|
3
3
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
4
4
|
import { Queue, QueueEncryption, FifoThroughputLimit, DeduplicationScope } from "aws-cdk-lib/aws-sqs";
|
|
5
|
+
import { StackEncryptionKey } from "../secrets/stackEncryptionKey.js";
|
|
5
6
|
import { toRemovalPolicy } from "./utils.js";
|
|
6
7
|
import { createSqsDlqAlarms } from "../monitoring/index.js";
|
|
8
|
+
import { validateQueuePolicyQuota } from "./queuePolicyQuota.js";
|
|
7
9
|
/**
|
|
8
10
|
* AWS SQS service limits and defaults.
|
|
9
11
|
* @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html
|
|
@@ -55,6 +57,33 @@ function toEncryption(encryption) {
|
|
|
55
57
|
return QueueEncryption.SQS_MANAGED;
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The queue's encryption mode and key, refusing the two combinations CDK
|
|
62
|
+
* would otherwise resolve quietly.
|
|
63
|
+
*
|
|
64
|
+
* A `masterKey` with no stated encryption means `"SSE_KMS"` — nobody hands a
|
|
65
|
+
* key to an unencrypted queue. A `masterKey` BESIDE `"SSE_SQS"` or `"NONE"`
|
|
66
|
+
* is a contradiction this wrapper used to warn about and then ignore; it
|
|
67
|
+
* throws now, because a queue that discards the key it was handed is
|
|
68
|
+
* indistinguishable at synth from one that honoured it, and the discrepancy
|
|
69
|
+
* only surfaces as an unreadable message months later.
|
|
70
|
+
*/
|
|
71
|
+
function resolveQueueEncryption(scope, props) {
|
|
72
|
+
if (props.masterKey !== undefined &&
|
|
73
|
+
props.encryption !== undefined &&
|
|
74
|
+
props.encryption !== "SSE_KMS") {
|
|
75
|
+
throw new Error(`'masterKey' is only meaningful with encryption: "SSE_KMS", and this ` +
|
|
76
|
+
`queue asks for "${props.encryption}". Set encryption: "SSE_KMS" to ` +
|
|
77
|
+
`encrypt with the key, or drop 'masterKey' to keep "${props.encryption}".`);
|
|
78
|
+
}
|
|
79
|
+
if (props.encryption !== "SSE_KMS" && props.masterKey === undefined) {
|
|
80
|
+
return { encryption: toEncryption(props.encryption) };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
encryption: QueueEncryption.KMS,
|
|
84
|
+
masterKey: props.masterKey ?? StackEncryptionKey.of(scope).forSqsServicePublishers()
|
|
85
|
+
};
|
|
86
|
+
}
|
|
58
87
|
function toFifoThroughputLimit(limit) {
|
|
59
88
|
switch (limit) {
|
|
60
89
|
case "perQueue":
|
|
@@ -84,6 +113,10 @@ export class SQSQueue extends Construct {
|
|
|
84
113
|
this.id = id;
|
|
85
114
|
// Sanitise id for CloudFormation output keys (must be alphanumeric)
|
|
86
115
|
const outputName = toPascalCase(id);
|
|
116
|
+
// Resolved once and shared by the queue and its auto-created DLQ: one
|
|
117
|
+
// key for the pair, not one apiece. A dead-lettered message is the same
|
|
118
|
+
// message, so a second key would buy no isolation and double the charge.
|
|
119
|
+
const { encryption, masterKey } = resolveQueueEncryption(this, props);
|
|
87
120
|
// SQS queues are transient work mediums: their contents (job messages,
|
|
88
121
|
// failed-delivery copies) regenerate from a source of truth held elsewhere
|
|
89
122
|
// (Postgres, the producing schedule/rule), so the wrapper defaults to
|
|
@@ -117,7 +150,8 @@ export class SQSQueue extends Construct {
|
|
|
117
150
|
this.dlq = new Queue(this, `${id}DeadLetterQueue`, {
|
|
118
151
|
queueName: dlqName,
|
|
119
152
|
fifo: isFifo,
|
|
120
|
-
encryption
|
|
153
|
+
encryption,
|
|
154
|
+
encryptionMasterKey: masterKey,
|
|
121
155
|
enforceSSL: true,
|
|
122
156
|
retentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS),
|
|
123
157
|
removalPolicy: resolvedRemovalPolicy
|
|
@@ -156,7 +190,8 @@ export class SQSQueue extends Construct {
|
|
|
156
190
|
? Duration.seconds(props.deliveryDelay)
|
|
157
191
|
: undefined,
|
|
158
192
|
deadLetterQueue,
|
|
159
|
-
encryption
|
|
193
|
+
encryption,
|
|
194
|
+
encryptionMasterKey: masterKey,
|
|
160
195
|
enforceSSL: true,
|
|
161
196
|
contentBasedDeduplication: isFifo
|
|
162
197
|
? (props.contentBasedDeduplication ?? true)
|
|
@@ -169,6 +204,9 @@ export class SQSQueue extends Construct {
|
|
|
169
204
|
: undefined,
|
|
170
205
|
removalPolicy: resolvedRemovalPolicy
|
|
171
206
|
});
|
|
207
|
+
this.node.addValidation({
|
|
208
|
+
validate: () => [this.queue, this.dlq].flatMap((queue) => queue === undefined ? [] : validateQueuePolicyQuota(queue))
|
|
209
|
+
});
|
|
172
210
|
new CfnOutput(this, `${outputName}QueueUrl`, {
|
|
173
211
|
key: `${outputName}QueueUrl`,
|
|
174
212
|
value: this.queue.queueUrl,
|
|
@@ -30,15 +30,16 @@ export interface SubscriptionProps {
|
|
|
30
30
|
/**
|
|
31
31
|
* Optional JSON payload that overrides the matched event detail before
|
|
32
32
|
* delivery to the target (D5 — typed as `Record<string, unknown>`).
|
|
33
|
-
* Translates to `message` (SQS) or `event` (Lambda /
|
|
34
|
-
*
|
|
33
|
+
* Translates to `message` (SQS) or `event` (Lambda / CodeBuild) on the
|
|
34
|
+
* underlying adapter. Values may be `EventField.*` tokens for
|
|
35
35
|
* payload-template substitution (e.g.
|
|
36
36
|
* `EventField.fromPath("$.detail.repository.name")`). When omitted, the
|
|
37
37
|
* full matched event is forwarded verbatim — the common pub/sub case.
|
|
38
38
|
*
|
|
39
39
|
* Not supported on ECS targets — CDK's `EcsTask` adapter ignores arbitrary
|
|
40
40
|
* input; use container command/environment overrides on the task definition
|
|
41
|
-
* instead.
|
|
41
|
+
* instead. Nor on log-group targets, whose metric filters read the matched
|
|
42
|
+
* event untransformed. Passing `payload` against either throws.
|
|
42
43
|
*/
|
|
43
44
|
payload?: Record<string, unknown>;
|
|
44
45
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CfnOutput, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
|
+
import { vpcCidrExportName } from "@fjall/util";
|
|
2
3
|
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
|
3
4
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
4
5
|
import { LogGroup } from "../logging/logGroup.js";
|
|
@@ -30,14 +31,15 @@ export class Vpc extends ec2.Vpc {
|
|
|
30
31
|
});
|
|
31
32
|
this.hasNatGateways = natGateways !== 0;
|
|
32
33
|
this.addInterfaceEndpoints(id, props, this.hasNatGateways);
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// The export name is a contract owned by @fjall/util's stackExportNames
|
|
35
|
+
// module: the fjall.io webapp reads it by DescribeStacks on the app's
|
|
36
|
+
// Network stack and rebuilds this exact name — no ListExports, no suffix
|
|
37
|
+
// filter. The construct id is load-bearing because export names are
|
|
38
|
+
// unique per account+region: a stack holding two VPCs would otherwise
|
|
39
|
+
// emit one name twice and CloudFormation rejects the template.
|
|
38
40
|
new CfnOutput(this, "VpcCidrBlock", {
|
|
39
41
|
value: this.vpcCidrBlock,
|
|
40
|
-
exportName:
|
|
42
|
+
exportName: vpcCidrExportName(Stack.of(this).stackName, id)
|
|
41
43
|
});
|
|
42
44
|
}
|
|
43
45
|
static gatewayEndpoints(props) {
|
|
@@ -184,6 +186,10 @@ export class Vpc extends ec2.Vpc {
|
|
|
184
186
|
bucketName: ResourceNaming.vpcFlowLogBucketName(id, Stack.of(scope).account),
|
|
185
187
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
186
188
|
versioned: true,
|
|
189
|
+
// VPC delivers flow-log objects here continuously and the rules below
|
|
190
|
+
// age them out; a backup plan would copy a log stream that already has
|
|
191
|
+
// its own retention, so the bucket is versioned but not a source.
|
|
192
|
+
backupExemption: "log-delivery",
|
|
187
193
|
// Custom lifecycleRules bypass S3Bucket's automatic
|
|
188
194
|
// noncurrentVersionExpiration pairing — carried explicitly, or
|
|
189
195
|
// noncurrent versions accumulate forever. The no-retentionDays branch
|
|
@@ -23,11 +23,28 @@ import { CustomerManagedKey } from "./kms.js";
|
|
|
23
23
|
* SDK/CDK grants they need ride the resource's own IAM path. Service-side
|
|
24
24
|
* encryptors need a resource-policy admission first: CloudWatch Logs through
|
|
25
25
|
* [[forLogGroups]], SNS service publishers through
|
|
26
|
-
* [[forSnsServicePublishers]]
|
|
26
|
+
* [[forSnsServicePublishers]], SQS senders through
|
|
27
|
+
* [[forSqsServicePublishers]]. Each admission is added once per stack.
|
|
28
|
+
*
|
|
29
|
+
* A resource may also reach the key WITHOUT the tier, through [[of]]: an SQS
|
|
30
|
+
* queue declaring `encryption: "SSE_KMS"` encrypts with the stack key at
|
|
31
|
+
* every tier, because the alternative CDK offers is an implicit key that
|
|
32
|
+
* RETAINs with no alias and no tags and is therefore invisible to every
|
|
33
|
+
* teardown sweep. Queues are not a tier-owned resource — `SSE_SQS` is free
|
|
34
|
+
* encryption at rest and is what the tier column's absence of SQS means —
|
|
35
|
+
* so the opt-in is the caller's, not the tier's.
|
|
36
|
+
*
|
|
37
|
+
* PROBE SCOPE = DELETE SCOPE. The deploy-test teardown releases a retained
|
|
38
|
+
* stack key only after re-reading every resource class that can name it
|
|
39
|
+
* (`probeStackKeyReferrers`, deploy-tests/lib/kmsKeys.mts). A construct that
|
|
40
|
+
* starts encrypting a new kind of resource with this key extends that walk
|
|
41
|
+
* in the same change, or the teardown schedules deletion of a key something
|
|
42
|
+
* still needs.
|
|
27
43
|
*/
|
|
28
44
|
export declare class StackEncryptionKey extends CustomerManagedKey {
|
|
29
45
|
private logsAdmitted;
|
|
30
46
|
private snsPublishersAdmitted;
|
|
47
|
+
private sqsPublishersAdmitted;
|
|
31
48
|
private constructor();
|
|
32
49
|
/** The stack's shared key, created on first use. */
|
|
33
50
|
static of(scope: Construct): StackEncryptionKey;
|
|
@@ -57,4 +74,12 @@ export declare class StackEncryptionKey extends CustomerManagedKey {
|
|
|
57
74
|
* immutable.
|
|
58
75
|
*/
|
|
59
76
|
forSnsServicePublishers(): IKey;
|
|
77
|
+
/**
|
|
78
|
+
* The key, with the SQS-sending AWS services admitted to it
|
|
79
|
+
* ([[SQS_SERVICE_PUBLISHERS]]). Without the admission their sends to an
|
|
80
|
+
* encrypted queue fail silently: SQS drops the message rather than
|
|
81
|
+
* returning an error to the service, so the loss surfaces as a consumer
|
|
82
|
+
* that never runs.
|
|
83
|
+
*/
|
|
84
|
+
forSqsServicePublishers(): IKey;
|
|
60
85
|
}
|
|
@@ -20,6 +20,25 @@ const SNS_SERVICE_PUBLISHERS = [
|
|
|
20
20
|
"cloudwatch.amazonaws.com",
|
|
21
21
|
"s3.amazonaws.com"
|
|
22
22
|
];
|
|
23
|
+
/**
|
|
24
|
+
* AWS services that send to an application's SQS queues on the app's behalf
|
|
25
|
+
* and therefore encrypt the message through the queue's key: EventBridge
|
|
26
|
+
* rule targets and rule dead-letter queues, S3 event notifications, and SNS
|
|
27
|
+
* topic subscriptions. Each needs `kms:Decrypt` + `kms:GenerateDataKey*` on
|
|
28
|
+
* the key's RESOURCE policy for the same reason the SNS set does — an
|
|
29
|
+
* identity policy cannot admit a service principal — and cannot be narrowed
|
|
30
|
+
* by `aws:SourceAccount` / `aws:SourceArn` for the same reason either: a
|
|
31
|
+
* send the key refuses is DROPPED, not rejected, so a condition that fails
|
|
32
|
+
* to match loses messages silently.
|
|
33
|
+
*
|
|
34
|
+
* CloudWatch is deliberately absent, and the difference from the SNS set is
|
|
35
|
+
* the point: an alarm action can target a topic but never a queue.
|
|
36
|
+
*/
|
|
37
|
+
const SQS_SERVICE_PUBLISHERS = [
|
|
38
|
+
"events.amazonaws.com",
|
|
39
|
+
"s3.amazonaws.com",
|
|
40
|
+
"sns.amazonaws.com"
|
|
41
|
+
];
|
|
23
42
|
/**
|
|
24
43
|
* The one customer-managed KMS key an enterprise-tier stack encrypts its
|
|
25
44
|
* tier-owned resources with: DynamoDB tables, SNS topics, Lambda environment
|
|
@@ -41,11 +60,28 @@ const SNS_SERVICE_PUBLISHERS = [
|
|
|
41
60
|
* SDK/CDK grants they need ride the resource's own IAM path. Service-side
|
|
42
61
|
* encryptors need a resource-policy admission first: CloudWatch Logs through
|
|
43
62
|
* [[forLogGroups]], SNS service publishers through
|
|
44
|
-
* [[forSnsServicePublishers]]
|
|
63
|
+
* [[forSnsServicePublishers]], SQS senders through
|
|
64
|
+
* [[forSqsServicePublishers]]. Each admission is added once per stack.
|
|
65
|
+
*
|
|
66
|
+
* A resource may also reach the key WITHOUT the tier, through [[of]]: an SQS
|
|
67
|
+
* queue declaring `encryption: "SSE_KMS"` encrypts with the stack key at
|
|
68
|
+
* every tier, because the alternative CDK offers is an implicit key that
|
|
69
|
+
* RETAINs with no alias and no tags and is therefore invisible to every
|
|
70
|
+
* teardown sweep. Queues are not a tier-owned resource — `SSE_SQS` is free
|
|
71
|
+
* encryption at rest and is what the tier column's absence of SQS means —
|
|
72
|
+
* so the opt-in is the caller's, not the tier's.
|
|
73
|
+
*
|
|
74
|
+
* PROBE SCOPE = DELETE SCOPE. The deploy-test teardown releases a retained
|
|
75
|
+
* stack key only after re-reading every resource class that can name it
|
|
76
|
+
* (`probeStackKeyReferrers`, deploy-tests/lib/kmsKeys.mts). A construct that
|
|
77
|
+
* starts encrypting a new kind of resource with this key extends that walk
|
|
78
|
+
* in the same change, or the teardown schedules deletion of a key something
|
|
79
|
+
* still needs.
|
|
45
80
|
*/
|
|
46
81
|
export class StackEncryptionKey extends CustomerManagedKey {
|
|
47
82
|
logsAdmitted = false;
|
|
48
83
|
snsPublishersAdmitted = false;
|
|
84
|
+
sqsPublishersAdmitted = false;
|
|
49
85
|
constructor(stack) {
|
|
50
86
|
super(stack, STACK_ENCRYPTION_KEY_ID, {
|
|
51
87
|
description: `Encrypts the enterprise-tier resources of ${stack.stackName} (DynamoDB tables, SNS topics, Lambda environment, ECS Exec sessions, log groups)`,
|
|
@@ -129,4 +165,23 @@ export class StackEncryptionKey extends CustomerManagedKey {
|
|
|
129
165
|
}
|
|
130
166
|
return this.key;
|
|
131
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* The key, with the SQS-sending AWS services admitted to it
|
|
170
|
+
* ([[SQS_SERVICE_PUBLISHERS]]). Without the admission their sends to an
|
|
171
|
+
* encrypted queue fail silently: SQS drops the message rather than
|
|
172
|
+
* returning an error to the service, so the loss surfaces as a consumer
|
|
173
|
+
* that never runs.
|
|
174
|
+
*/
|
|
175
|
+
forSqsServicePublishers() {
|
|
176
|
+
if (!this.sqsPublishersAdmitted) {
|
|
177
|
+
this.key.addToResourcePolicy(new PolicyStatement({
|
|
178
|
+
sid: "AllowSqsServicePublishers",
|
|
179
|
+
principals: SQS_SERVICE_PUBLISHERS.map((service) => new ServicePrincipal(service)),
|
|
180
|
+
actions: ["kms:Decrypt", "kms:GenerateDataKey*"],
|
|
181
|
+
resources: ["*"]
|
|
182
|
+
}));
|
|
183
|
+
this.sqsPublishersAdmitted = true;
|
|
184
|
+
}
|
|
185
|
+
return this.key;
|
|
186
|
+
}
|
|
132
187
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type S3VersioningExemption } from "@fjall/util/aws";
|
|
1
|
+
import { type S3BackupExemption, type S3VersioningExemption } from "@fjall/util/aws";
|
|
2
2
|
import { Bucket, type BucketProps } from "aws-cdk-lib/aws-s3";
|
|
3
3
|
import { type Construct } from "constructs";
|
|
4
4
|
import { type BackupTier } from "../../../utils/backupTierMapping.js";
|
|
@@ -72,6 +72,26 @@ export interface S3BucketProps extends BucketProps {
|
|
|
72
72
|
* disposable bucket of their own.
|
|
73
73
|
*/
|
|
74
74
|
versioningExemption?: S3VersioningExemption;
|
|
75
|
+
/**
|
|
76
|
+
* Why this VERSIONED bucket is not a backup source. Stamped as the
|
|
77
|
+
* `fjall:s3:backup-exempt` tag only when versioning resolves on — an
|
|
78
|
+
* unversioned bucket is already outside every plan and has nothing to
|
|
79
|
+
* exempt.
|
|
80
|
+
*
|
|
81
|
+
* It is the counterpart of `versioningExemption`, which cannot express this
|
|
82
|
+
* case: that tag is stamped only when versioning is OFF. A log-delivery
|
|
83
|
+
* bucket must stay versioned and must stay out of the backup plan, and
|
|
84
|
+
* without a marker posture cannot tell it from a bucket that lost its tier
|
|
85
|
+
* by omission — so it raises a finding whose own auto-remediation cannot
|
|
86
|
+
* clear it.
|
|
87
|
+
*
|
|
88
|
+
* It beats a resolved tier, and the pair is not a contradiction: the tier
|
|
89
|
+
* says what the APP is, this says what THIS BUCKET is — the same
|
|
90
|
+
* narrower-statement-wins shape as an explicit `versioned` beating the
|
|
91
|
+
* tier's versioning column. A construct that owns a log-delivery bucket
|
|
92
|
+
* inside a tiered app passes both, and the bucket stays out of the plan.
|
|
93
|
+
*/
|
|
94
|
+
backupExemption?: S3BackupExemption;
|
|
75
95
|
publicReadAccess?: boolean;
|
|
76
96
|
websiteHosting?: WebsiteHostingConfig;
|
|
77
97
|
/**
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { S3_VERSIONING_EXEMPT_TAG_KEY, SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
|
|
1
|
+
import { S3_BACKUP_EXEMPT_TAG_KEY, S3_VERSIONING_EXEMPT_TAG_KEY, SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
|
|
2
2
|
import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
|
|
3
3
|
import { BlockPublicAccess, Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
|
|
4
4
|
import { ArnPrincipal, Effect, PolicyStatement, StarPrincipal } from "aws-cdk-lib/aws-iam";
|
|
5
5
|
import { RegionInfo } from "aws-cdk-lib/region-info";
|
|
6
|
-
import { resolveEffectiveBackupTier, shouldAutoVersion } from "../../../utils/backupTierMapping.js";
|
|
6
|
+
import { resolveEffectiveBackupTier, shouldAutoVersion, tagBackupTier } from "../../../utils/backupTierMapping.js";
|
|
7
7
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
8
8
|
import { bucketWebsiteEndpointExportName, bucketWebsiteHostedZoneIdExportName } from "@fjall/util";
|
|
9
9
|
import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
|
|
@@ -26,10 +26,16 @@ export class S3Bucket extends Bucket {
|
|
|
26
26
|
/** The tier the bucket runs under: `backupVaultTier`, else `appBackupTier`. */
|
|
27
27
|
effectiveBackupTier;
|
|
28
28
|
constructor(scope, id, props = {}) {
|
|
29
|
-
const { websiteHosting, backupVaultTier, appBackupTier, resourcePolicyStatements, aliasTargetName, appName, versioningExemption, ...cdkProps } = props;
|
|
29
|
+
const { websiteHosting, backupVaultTier, appBackupTier, resourcePolicyStatements, aliasTargetName, appName, versioningExemption, backupExemption, ...cdkProps } = props;
|
|
30
30
|
const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
|
|
31
31
|
const effectiveBackupTier = resolveEffectiveBackupTier(backupVaultTier, appBackupTier);
|
|
32
32
|
const versioned = props.versioned ?? shouldAutoVersion(effectiveBackupTier);
|
|
33
|
+
// Read twice — to publish this bucket's object events, and to tag it into
|
|
34
|
+
// the plan. A bucket selected by one and not the other is the silent
|
|
35
|
+
// failure below, so both read one value rather than two copies.
|
|
36
|
+
const isBackupSource = versioned &&
|
|
37
|
+
backupExemption === undefined &&
|
|
38
|
+
effectiveBackupTier !== undefined;
|
|
33
39
|
const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
|
|
34
40
|
// Mirrors the CDK Bucket's own resolution: an encryptionKey with no
|
|
35
41
|
// explicit encryption infers KMS. DSSE is excluded deliberately — S3
|
|
@@ -37,6 +43,21 @@ export class S3Bucket extends Bucket {
|
|
|
37
43
|
const kmsEncryption = props.encryption === BucketEncryption.KMS ||
|
|
38
44
|
props.encryption === BucketEncryption.KMS_MANAGED ||
|
|
39
45
|
(props.encryption === undefined && props.encryptionKey !== undefined);
|
|
46
|
+
// The one shape CDK answers by minting a key nobody asked for: a
|
|
47
|
+
// `DeletionPolicy: Retain` `AWS::KMS::Key` with no alias and no tags,
|
|
48
|
+
// beside a bucket that is usually DESTROY. Every Fjall teardown sweep
|
|
49
|
+
// finds keys by the alias tag `CustomerManagedKey` stamps, so that key
|
|
50
|
+
// is invisible to all of them and outlives the app forever. The
|
|
51
|
+
// patterns layer refuses it in plan vocabulary (`validateStorageProps`);
|
|
52
|
+
// this is the same refusal for anything constructing the bucket
|
|
53
|
+
// directly.
|
|
54
|
+
if (props.encryption === BucketEncryption.KMS &&
|
|
55
|
+
props.encryptionKey === undefined) {
|
|
56
|
+
throw new Error(`BucketEncryption.KMS needs an 'encryptionKey'. Pass the key to ` +
|
|
57
|
+
`encrypt with, or use BucketEncryption.KMS_MANAGED for AWS's own ` +
|
|
58
|
+
`key or BucketEncryption.S3_MANAGED for SSE-S3 — either of which ` +
|
|
59
|
+
`leaves no key behind when the bucket goes.`);
|
|
60
|
+
}
|
|
40
61
|
super(scope, id, {
|
|
41
62
|
...cdkProps,
|
|
42
63
|
enforceSSL: true,
|
|
@@ -62,6 +83,19 @@ export class S3Bucket extends Bucket {
|
|
|
62
83
|
: (props.blockPublicAccess ?? BlockPublicAccess.BLOCK_ALL),
|
|
63
84
|
...(props.bucketKeyEnabled === undefined &&
|
|
64
85
|
kmsEncryption && { bucketKeyEnabled: true }),
|
|
86
|
+
// AWS Backup builds an S3 bucket's point-in-time window from the object
|
|
87
|
+
// events S3 publishes to the default EventBridge bus, and the
|
|
88
|
+
// bucket-level setting that publishes them is off unless asked for.
|
|
89
|
+
// With it off AWS Backup's managed rule stays enabled and receives
|
|
90
|
+
// nothing: the snapshot rules keep succeeding, the plan keeps reporting
|
|
91
|
+
// healthy, and the window stops advancing past the first full backup.
|
|
92
|
+
// AWS documents that as silent — "no notification, alert, or backup job
|
|
93
|
+
// failure is generated by AWS Backup" — and says it cannot detect the
|
|
94
|
+
// setting being turned off, so nothing downstream recovers it either.
|
|
95
|
+
// An explicit prop still wins: turning it off chooses snapshots over a
|
|
96
|
+
// window, and the tier tag the plan selects on is untouched.
|
|
97
|
+
...(props.eventBridgeEnabled === undefined &&
|
|
98
|
+
isBackupSource && { eventBridgeEnabled: true }),
|
|
65
99
|
...(websiteHosting && {
|
|
66
100
|
websiteIndexDocument: websiteHosting.indexDocument,
|
|
67
101
|
websiteErrorDocument: websiteHosting.errorDocument ?? "error.html"
|
|
@@ -115,6 +149,58 @@ export class S3Bucket extends Bucket {
|
|
|
115
149
|
if (versioningExemption !== undefined && !versioned) {
|
|
116
150
|
Tags.of(this).add(S3_VERSIONING_EXEMPT_TAG_KEY, versioningExemption);
|
|
117
151
|
}
|
|
152
|
+
// AWS Backup requires versioning on an S3 source: an unversioned bucket
|
|
153
|
+
// carrying the tag fails every job it is selected for, for ever. So the
|
|
154
|
+
// enrolment follows versioning, not the tier — the standard tier does not
|
|
155
|
+
// version buckets and therefore does not enrol them, and its plan covers
|
|
156
|
+
// the database and volume types instead.
|
|
157
|
+
//
|
|
158
|
+
// A versioned bucket is therefore a source unless it says why not. The
|
|
159
|
+
// three arms below are exhaustive over a versioned bucket by design: it
|
|
160
|
+
// is enrolled, or it is exempt and says so in a tag posture can read, or
|
|
161
|
+
// it is neither and that is a mistake we refuse to make silently. The
|
|
162
|
+
// last arm is the one that matters: `tagBackupTier` no-ops on an
|
|
163
|
+
// undefined tier, so before it existed a construct that simply forgot to
|
|
164
|
+
// thread `appBackupTier` dropped its bucket out of every plan with
|
|
165
|
+
// nothing anywhere to say so.
|
|
166
|
+
if (versioned && backupExemption !== undefined) {
|
|
167
|
+
// `appBackupTier` beside an exemption is the ordinary case and says
|
|
168
|
+
// nothing contradictory: the tier says what the app is, the exemption
|
|
169
|
+
// says what this bucket is. `backupVaultTier` is different — it is a
|
|
170
|
+
// statement about THIS bucket, so the two are opposite answers to one
|
|
171
|
+
// question, and the resolution below (the exemption wins) is a guess at
|
|
172
|
+
// which the author meant. Silently guessing is the class this arm exists
|
|
173
|
+
// to end, so the guess is said out loud.
|
|
174
|
+
if (backupVaultTier !== undefined) {
|
|
175
|
+
Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:backupExemptionBeatsBucketTier", `This bucket declares both \`backupVaultTier: ` +
|
|
176
|
+
`"${backupVaultTier}"\` and \`backupExemption: ` +
|
|
177
|
+
`"${backupExemption}"\`, which say opposite things about the ` +
|
|
178
|
+
`same bucket. The exemption wins: the bucket is tagged ` +
|
|
179
|
+
`${S3_BACKUP_EXEMPT_TAG_KEY} and enrolled in no backup plan, and ` +
|
|
180
|
+
`the tier is not applied. Drop whichever is wrong — an ` +
|
|
181
|
+
`\`appBackupTier\` beside an exemption is not this case.`);
|
|
182
|
+
}
|
|
183
|
+
Tags.of(this).add(S3_BACKUP_EXEMPT_TAG_KEY, backupExemption);
|
|
184
|
+
}
|
|
185
|
+
else if (isBackupSource) {
|
|
186
|
+
tagBackupTier(this, effectiveBackupTier);
|
|
187
|
+
}
|
|
188
|
+
else if (versioned) {
|
|
189
|
+
Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:backupTierUnresolved", `This bucket is versioned but carries no backup tier, so it is in no ` +
|
|
190
|
+
`backup plan. Pass \`appBackupTier\` (the StorageFactory and ` +
|
|
191
|
+
`BuildkiteFactory paths fill it from the App) to enrol it, or pass ` +
|
|
192
|
+
`\`backupExemption\` to record that it is not a backup source.`);
|
|
193
|
+
}
|
|
194
|
+
else if (effectiveBackupTier !== undefined &&
|
|
195
|
+
shouldAutoVersion(effectiveBackupTier) &&
|
|
196
|
+
versioningExemption === undefined) {
|
|
197
|
+
// The carve-out is the cure this message itself names: a bucket that
|
|
198
|
+
// already recorded why it runs unversioned has answered the question.
|
|
199
|
+
Annotations.of(this).addWarningV2("@fjall/components-infrastructure:s3:backupTierNotEnrolled", `versioned: false leaves this bucket out of the ` +
|
|
200
|
+
`${effectiveBackupTier} backup plan — AWS Backup cannot protect an ` +
|
|
201
|
+
`unversioned bucket. Drop the override to restore the tier's ` +
|
|
202
|
+
`versioning, or keep it and record why with versioningExemption.`);
|
|
203
|
+
}
|
|
118
204
|
if (websiteHosting) {
|
|
119
205
|
if (appName === undefined) {
|
|
120
206
|
// Refusing beats falling back to a bucket-only export name: the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type IConstruct } from "constructs";
|
|
1
2
|
import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, type BackupTier, type BackupTierDefaults, tierDefault } from "@fjall/util";
|
|
2
3
|
/**
|
|
3
4
|
* The application backup tier vocabulary, its defaults table, the DR tag key
|
|
@@ -7,8 +8,8 @@ import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TA
|
|
|
7
8
|
* the CLI's synth expectations and the webapp's posture rules (which read the
|
|
8
9
|
* tag back from discovered resources), so every system derives from one
|
|
9
10
|
* declaration. Re-exported here so construct code keeps one import path for
|
|
10
|
-
* tier concerns: `
|
|
11
|
-
* (
|
|
11
|
+
* tier concerns: `tagBackupTier()` (the per-resource tag) and `BackupPlan`
|
|
12
|
+
* (the selection key) read the same pair.
|
|
12
13
|
*/
|
|
13
14
|
export type { BackupTier, BackupTierDefaults };
|
|
14
15
|
export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
|
|
@@ -21,3 +22,23 @@ export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TA
|
|
|
21
22
|
export declare function resolveEffectiveBackupTier(resourceTier: BackupTier | undefined, appTier: BackupTier | undefined): BackupTier | undefined;
|
|
22
23
|
/** Tiers whose S3 buckets version by default. */
|
|
23
24
|
export declare function shouldAutoVersion(tier: BackupTier | undefined): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Stamp the disaster-recovery tier tag that enrols a resource in the matching
|
|
27
|
+
* AWS Backup plan. `BackupSelection` selects on this tag alone — it carries no
|
|
28
|
+
* resource-type filter — so where this is called IS the selection scope, and
|
|
29
|
+
* only the five backup-eligible resources call it: the RDS primary instance,
|
|
30
|
+
* the Aurora cluster, the DynamoDB table, the ClickHouse data volume and a
|
|
31
|
+
* versioned bucket.
|
|
32
|
+
*
|
|
33
|
+
* Pass the construct that must carry the tag. An L2 whose only taggable member
|
|
34
|
+
* is the resource itself (a bucket, a volume, a table) may be passed directly;
|
|
35
|
+
* where the L2 owns taggable siblings the L1 is required, because a tag on the
|
|
36
|
+
* L2 propagates to every child — an Aurora cluster's member instances and an
|
|
37
|
+
* RDS instance's subnet group are the two that matter, and AWS Backup protects
|
|
38
|
+
* the Aurora cluster, never its members.
|
|
39
|
+
*
|
|
40
|
+
* Called at construction, never from an aspect: `Tags.of(...)` inside an
|
|
41
|
+
* aspect's `visit()` is an aspect adding an aspect, which aws-cdk-lib drops
|
|
42
|
+
* silently under `aspectStabilization: false`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function tagBackupTier(resource: IConstruct | undefined, tier: BackupTier | undefined): void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Tags } from "aws-cdk-lib";
|
|
1
2
|
import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault } from "@fjall/util";
|
|
2
3
|
export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
|
|
3
4
|
/**
|
|
@@ -13,3 +14,27 @@ export function resolveEffectiveBackupTier(resourceTier, appTier) {
|
|
|
13
14
|
export function shouldAutoVersion(tier) {
|
|
14
15
|
return tierDefault(tier, "s3Versioned");
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Stamp the disaster-recovery tier tag that enrols a resource in the matching
|
|
19
|
+
* AWS Backup plan. `BackupSelection` selects on this tag alone — it carries no
|
|
20
|
+
* resource-type filter — so where this is called IS the selection scope, and
|
|
21
|
+
* only the five backup-eligible resources call it: the RDS primary instance,
|
|
22
|
+
* the Aurora cluster, the DynamoDB table, the ClickHouse data volume and a
|
|
23
|
+
* versioned bucket.
|
|
24
|
+
*
|
|
25
|
+
* Pass the construct that must carry the tag. An L2 whose only taggable member
|
|
26
|
+
* is the resource itself (a bucket, a volume, a table) may be passed directly;
|
|
27
|
+
* where the L2 owns taggable siblings the L1 is required, because a tag on the
|
|
28
|
+
* L2 propagates to every child — an Aurora cluster's member instances and an
|
|
29
|
+
* RDS instance's subnet group are the two that matter, and AWS Backup protects
|
|
30
|
+
* the Aurora cluster, never its members.
|
|
31
|
+
*
|
|
32
|
+
* Called at construction, never from an aspect: `Tags.of(...)` inside an
|
|
33
|
+
* aspect's `visit()` is an aspect adding an aspect, which aws-cdk-lib drops
|
|
34
|
+
* silently under `aspectStabilization: false`.
|
|
35
|
+
*/
|
|
36
|
+
export function tagBackupTier(resource, tier) {
|
|
37
|
+
if (resource === undefined || tier === undefined)
|
|
38
|
+
return;
|
|
39
|
+
Tags.of(resource).add(BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP[tier]);
|
|
40
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Node } from "constructs";
|
|
2
2
|
import { ACCOUNT_TRAIL_STATES, type AccountTrailState } from "@fjall/util/config";
|
|
3
|
+
import { type BackupVaultVerdict } from "@fjall/util";
|
|
3
4
|
export declare const CDK_CONTEXT_KEYS: {
|
|
4
5
|
readonly ORG_ID: "orgId";
|
|
5
6
|
readonly ROOT_ID: "rootId";
|
|
@@ -7,6 +8,7 @@ export declare const CDK_CONTEXT_KEYS: {
|
|
|
7
8
|
readonly ORG_CONFIG: "orgConfig";
|
|
8
9
|
readonly ACCOUNT_TRAIL_STATE: "fjallAccountTrailState";
|
|
9
10
|
readonly DEV_OU_ID: "fjallDevOuId";
|
|
11
|
+
readonly BACKUP_VAULT: "fjallBackupVault";
|
|
10
12
|
};
|
|
11
13
|
export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
|
|
12
14
|
/**
|
|
@@ -16,6 +18,18 @@ export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
|
|
|
16
18
|
* mid-decommission must fail the synth, not silently re-create a trail.
|
|
17
19
|
*/
|
|
18
20
|
export declare function resolveAccountTrailState(node: Node): AccountTrailState;
|
|
21
|
+
/**
|
|
22
|
+
* Reads the backup-vault ownership verdict the deploy engine injects before
|
|
23
|
+
* synth (`-c fjallBackupVault=<json>`; the wire contract is
|
|
24
|
+
* `BackupVaultVerdictSchema` in @fjall/util). Unset, or the `-c key=`
|
|
25
|
+
* empty-string boundary, is `absent` — the create template, which is what a
|
|
26
|
+
* synth with no engine in front of it (a local `cdk synth`, a construct test)
|
|
27
|
+
* has always produced. Malformed context is a LOUD synth error, never a
|
|
28
|
+
* default: a mis-read verdict would either re-CREATE a colliding fixed-name
|
|
29
|
+
* vault or declare a re-owned vault against the wrong key, and
|
|
30
|
+
* `EncryptionKeyArn` is create-only, so that second mistake replaces the vault.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readBackupVaultVerdict(node: Node): BackupVaultVerdict;
|
|
19
33
|
export declare const DEFAULT_ORG_ID: "default";
|
|
20
34
|
export declare function resolveOrgId(node: Node, fallback: string): string;
|
|
21
35
|
export declare function resolveOrgId(node: Node): string | undefined;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { ACCOUNT_TRAIL_STATES } from "@fjall/util/config";
|
|
2
|
+
import { BACKUP_VAULT_CONTEXT_KEY, BackupVaultVerdictSchema } from "@fjall/util";
|
|
2
3
|
export const CDK_CONTEXT_KEYS = {
|
|
3
4
|
ORG_ID: "orgId",
|
|
4
5
|
ROOT_ID: "rootId",
|
|
5
6
|
MANAGEMENT_ACCOUNT_ID: "managementAccountId",
|
|
6
7
|
ORG_CONFIG: "orgConfig",
|
|
7
8
|
ACCOUNT_TRAIL_STATE: "fjallAccountTrailState",
|
|
8
|
-
DEV_OU_ID: "fjallDevOuId"
|
|
9
|
+
DEV_OU_ID: "fjallDevOuId",
|
|
10
|
+
BACKUP_VAULT: BACKUP_VAULT_CONTEXT_KEY
|
|
9
11
|
};
|
|
10
12
|
export { ACCOUNT_TRAIL_STATES };
|
|
11
13
|
/**
|
|
@@ -25,6 +27,41 @@ export function resolveAccountTrailState(node) {
|
|
|
25
27
|
}
|
|
26
28
|
throw new Error(`${CDK_CONTEXT_KEYS.ACCOUNT_TRAIL_STATE} must be one of ${ACCOUNT_TRAIL_STATES.join(", ")}; received "${String(raw)}"`);
|
|
27
29
|
}
|
|
30
|
+
const ABSENT_BACKUP_VAULT = { state: "absent" };
|
|
31
|
+
/**
|
|
32
|
+
* Reads the backup-vault ownership verdict the deploy engine injects before
|
|
33
|
+
* synth (`-c fjallBackupVault=<json>`; the wire contract is
|
|
34
|
+
* `BackupVaultVerdictSchema` in @fjall/util). Unset, or the `-c key=`
|
|
35
|
+
* empty-string boundary, is `absent` — the create template, which is what a
|
|
36
|
+
* synth with no engine in front of it (a local `cdk synth`, a construct test)
|
|
37
|
+
* has always produced. Malformed context is a LOUD synth error, never a
|
|
38
|
+
* default: a mis-read verdict would either re-CREATE a colliding fixed-name
|
|
39
|
+
* vault or declare a re-owned vault against the wrong key, and
|
|
40
|
+
* `EncryptionKeyArn` is create-only, so that second mistake replaces the vault.
|
|
41
|
+
*/
|
|
42
|
+
export function readBackupVaultVerdict(node) {
|
|
43
|
+
const raw = node.tryGetContext(CDK_CONTEXT_KEYS.BACKUP_VAULT);
|
|
44
|
+
if (raw === undefined || raw === "") {
|
|
45
|
+
return ABSENT_BACKUP_VAULT;
|
|
46
|
+
}
|
|
47
|
+
let value = raw;
|
|
48
|
+
if (typeof raw === "string") {
|
|
49
|
+
// `-c key=value` always arrives as a string; tests may set the object form.
|
|
50
|
+
try {
|
|
51
|
+
value = JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`${CDK_CONTEXT_KEYS.BACKUP_VAULT} context is not valid JSON: ` +
|
|
55
|
+
`${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parsed = BackupVaultVerdictSchema.safeParse(value);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
throw new Error(`${CDK_CONTEXT_KEYS.BACKUP_VAULT} context is malformed — refusing to ` +
|
|
61
|
+
`synth a backup vault whose ownership is unknown: ${parsed.error.message}`);
|
|
62
|
+
}
|
|
63
|
+
return parsed.data;
|
|
64
|
+
}
|
|
28
65
|
export const DEFAULT_ORG_ID = "default";
|
|
29
66
|
export function resolveOrgId(node, fallback) {
|
|
30
67
|
const raw = node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
|