@fjall/components-infrastructure 7.2.0 → 8.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 +12 -0
- package/dist/lib/app.js +25 -5
- package/dist/lib/config/aws/ipam.js +4 -3
- package/dist/lib/patterns/aws/cdn.d.ts +7 -1
- package/dist/lib/patterns/aws/cdn.js +9 -2
- package/dist/lib/patterns/aws/database.js +4 -3
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +6 -0
- package/dist/lib/patterns/aws/storage.js +3 -0
- package/dist/lib/patterns/aws/targets/fjallTargets.d.ts +9 -4
- package/dist/lib/patterns/aws/targets/fjallTargets.js +11 -5
- package/dist/lib/patterns/aws/targets/targetResolution.d.ts +33 -40
- package/dist/lib/patterns/aws/targets/targetResolution.js +42 -52
- package/dist/lib/resources/aws/backup/backupVault.js +2 -0
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +9 -0
- package/dist/lib/resources/aws/cdn/cloudFront.js +2 -1
- package/dist/lib/resources/aws/compute/ecs.js +4 -3
- package/dist/lib/resources/aws/compute/ecsNetworking.js +29 -15
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +2 -1
- package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +6 -2
- package/dist/lib/resources/aws/database/rdsAurora.js +2 -2
- package/dist/lib/resources/aws/database/rdsAuroraGlobal.js +3 -2
- package/dist/lib/resources/aws/database/rdsHelpers.js +12 -2
- package/dist/lib/resources/aws/database/rdsInstance.js +3 -2
- package/dist/lib/resources/aws/database/rdsProxyOutput.js +2 -1
- package/dist/lib/resources/aws/logging/cloudTrail.js +3 -0
- package/dist/lib/resources/aws/networking/ipamPool.js +2 -1
- package/dist/lib/resources/aws/networking/vpc.js +8 -2
- package/dist/lib/resources/aws/secrets/kms.d.ts +24 -7
- package/dist/lib/resources/aws/secrets/kms.js +29 -6
- package/dist/lib/resources/aws/secrets/parameter.js +3 -1
- package/dist/lib/resources/aws/secrets/secret.js +5 -1
- package/dist/lib/resources/aws/storage/ecr.d.ts +6 -0
- package/dist/lib/resources/aws/storage/ecr.js +8 -4
- package/dist/lib/resources/aws/storage/s3.d.ts +10 -0
- package/dist/lib/resources/aws/storage/s3.js +9 -4
- package/dist/lib/utils/albAliasTargetRegistry.d.ts +44 -0
- package/dist/lib/utils/albAliasTargetRegistry.js +69 -0
- package/dist/lib/utils/env.d.ts +9 -0
- package/dist/lib/utils/env.js +12 -0
- package/dist/lib/utils/exportNaming.d.ts +50 -0
- package/dist/lib/utils/exportNaming.js +52 -0
- package/dist/lib/utils/orgConfigParser.d.ts +3 -8
- package/dist/lib/utils/orgConfigParser.js +4 -14
- package/dist/lib/utils/removalPolicy.d.ts +11 -3
- package/dist/lib/utils/removalPolicy.js +20 -5
- package/dist/lib/utils/resourceNaming.d.ts +50 -0
- package/dist/lib/utils/resourceNaming.js +74 -0
- package/package.json +9 -4
|
@@ -10,6 +10,10 @@ import { AliasRecord } from "../networking/dnsRecord/aliasRecord.js";
|
|
|
10
10
|
import { SecurityGroup } from "../networking/securityGroup.js";
|
|
11
11
|
import { DNS_APEX, isManagedDomainBinding, isWithinZone } from "../../../utils/domainTypes.js";
|
|
12
12
|
import { readInjectedManagedDomainBinding } from "../../../utils/managedDomainContext.js";
|
|
13
|
+
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
14
|
+
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
15
|
+
import { registerAlbAliasTarget } from "../../../utils/albAliasTargetRegistry.js";
|
|
16
|
+
import { albDnsExportName, albHostedZoneIdExportName } from "@fjall/util";
|
|
13
17
|
import { getSafeZoneName, toPascalCase } from "../../../utils/capitaliseString.js";
|
|
14
18
|
import { FjallLogger } from "../../../utils/validationLogger.js";
|
|
15
19
|
import { isServiceEc2 } from "./ecsTaskDefinition.js";
|
|
@@ -18,12 +22,7 @@ import { addRoutingListener } from "./listenerRouting.js";
|
|
|
18
22
|
import { buildRoutingConditions, deterministicHostPriority, getNextPriority } from "./hostHeaderListenerRule.js";
|
|
19
23
|
export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
|
|
20
24
|
const props = ctx.props;
|
|
21
|
-
const
|
|
22
|
-
const supportedNameLength = 32;
|
|
23
|
-
let truncatedLoadBalancerName = defaultLoadBalancerName.length > supportedNameLength
|
|
24
|
-
? defaultLoadBalancerName.substring(0, supportedNameLength)
|
|
25
|
-
: defaultLoadBalancerName;
|
|
26
|
-
truncatedLoadBalancerName = truncatedLoadBalancerName.replace(/-+$/, "");
|
|
25
|
+
const truncatedLoadBalancerName = ResourceNaming.loadBalancerName(props.clusterName);
|
|
27
26
|
const isInternal = props.cluster?.loadBalancer === "internal";
|
|
28
27
|
let loadBalancerSecurityGroup;
|
|
29
28
|
if (anyServiceUsesEc2) {
|
|
@@ -48,27 +47,42 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
|
|
|
48
47
|
});
|
|
49
48
|
new CfnOutput(ctx.scope, `${ctx.outputName}LoadBalancerDnsName`, {
|
|
50
49
|
key: `${ctx.outputName}LoadBalancerDnsName`,
|
|
51
|
-
exportName: `${props.clusterName}LoadBalancerDnsName
|
|
50
|
+
exportName: stackScopedExportName(ctx.scope, `${props.clusterName}LoadBalancerDnsName`),
|
|
52
51
|
value: loadBalancer.loadBalancerDnsName
|
|
53
52
|
});
|
|
54
|
-
// Cross-stack exports consumed by the Domain construct's
|
|
55
|
-
// alias target.
|
|
53
|
+
// Cross-stack exports consumed by the Domain construct's
|
|
54
|
+
// `fjallApp(<app>, <compute>)` alias target. Keyed on the APP name — not
|
|
55
|
+
// `ctx.outputName` (the compute construct id) — because `fjall add compute`
|
|
56
|
+
// names the compute from the user's --name, so an app "my-app" with a
|
|
57
|
+
// compute "api" exported `ApiAlbDnsName` while the Domain asked for
|
|
58
|
+
// `MyAppAlbDnsName`. Qualified by the compute too, because an app may front
|
|
59
|
+
// several ALBs and the app-keyed name alone collides; the unqualified
|
|
60
|
+
// app-level pair is emitted once by App.synth, and only when there is
|
|
61
|
+
// exactly one (albAliasTargetRegistry.ts).
|
|
62
|
+
const targetAppName = props.appName ?? props.clusterName;
|
|
63
|
+
registerAlbAliasTarget({
|
|
64
|
+
appName: targetAppName,
|
|
65
|
+
computeName: props.clusterName,
|
|
66
|
+
scope: ctx.scope,
|
|
67
|
+
dnsName: loadBalancer.loadBalancerDnsName,
|
|
68
|
+
hostedZoneId: loadBalancer.loadBalancerCanonicalHostedZoneId
|
|
69
|
+
});
|
|
56
70
|
new CfnOutput(ctx.scope, `${ctx.outputName}AlbDnsName`, {
|
|
57
71
|
key: `${ctx.outputName}AlbDnsName`,
|
|
58
|
-
exportName:
|
|
72
|
+
exportName: albDnsExportName(targetAppName, props.clusterName),
|
|
59
73
|
value: loadBalancer.loadBalancerDnsName,
|
|
60
74
|
description: `ALB DNS name for ${props.clusterName} (consumed by Domain alias targets)`
|
|
61
75
|
});
|
|
62
76
|
new CfnOutput(ctx.scope, `${ctx.outputName}AlbHostedZoneId`, {
|
|
63
77
|
key: `${ctx.outputName}AlbHostedZoneId`,
|
|
64
|
-
exportName:
|
|
78
|
+
exportName: albHostedZoneIdExportName(targetAppName, props.clusterName),
|
|
65
79
|
value: loadBalancer.loadBalancerCanonicalHostedZoneId,
|
|
66
80
|
description: `ALB canonical hosted zone ID for ${props.clusterName}`
|
|
67
81
|
});
|
|
68
82
|
const customDomain = props.cluster?.domain || props.cluster?.domainConfig?.domainName;
|
|
69
83
|
new CfnOutput(ctx.scope, `${ctx.outputName}LoadBalancerUrl`, {
|
|
70
84
|
key: `${ctx.outputName}LoadBalancerUrl`,
|
|
71
|
-
exportName: `${props.clusterName}LoadBalancerUrl
|
|
85
|
+
exportName: stackScopedExportName(ctx.scope, `${props.clusterName}LoadBalancerUrl`),
|
|
72
86
|
value: customDomain
|
|
73
87
|
? `https://${customDomain}`
|
|
74
88
|
: `http://${loadBalancer.loadBalancerDnsName}`,
|
|
@@ -77,7 +91,7 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
|
|
|
77
91
|
// Export load balancer ARN for monitoring
|
|
78
92
|
new CfnOutput(ctx.scope, `${ctx.outputName}LoadBalancerArn`, {
|
|
79
93
|
key: `${ctx.outputName}LoadBalancerArn`,
|
|
80
|
-
exportName: `${props.clusterName}LoadBalancerArn
|
|
94
|
+
exportName: stackScopedExportName(ctx.scope, `${props.clusterName}LoadBalancerArn`),
|
|
81
95
|
value: loadBalancer.loadBalancerArn,
|
|
82
96
|
description: `Load Balancer ARN for ${props.clusterName}`
|
|
83
97
|
});
|
|
@@ -473,13 +487,13 @@ export function addDirectAccessOutputs(ctx, autoScalingGroup) {
|
|
|
473
487
|
3000;
|
|
474
488
|
new CfnOutput(ctx.scope, `${ctx.outputName}AutoScalingGroupName`, {
|
|
475
489
|
key: `${ctx.outputName}AutoScalingGroupName`,
|
|
476
|
-
exportName: `${props.clusterName}AutoScalingGroupName
|
|
490
|
+
exportName: stackScopedExportName(ctx.scope, `${props.clusterName}AutoScalingGroupName`),
|
|
477
491
|
value: autoScalingGroup.autoScalingGroupName,
|
|
478
492
|
description: `Run: aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names <name> to find instance IP`
|
|
479
493
|
});
|
|
480
494
|
new CfnOutput(ctx.scope, `${ctx.outputName}DirectAccessPort`, {
|
|
481
495
|
key: `${ctx.outputName}DirectAccessPort`,
|
|
482
|
-
exportName: `${props.clusterName}DirectAccessPort
|
|
496
|
+
exportName: stackScopedExportName(ctx.scope, `${props.clusterName}DirectAccessPort`),
|
|
483
497
|
value: String(containerPort),
|
|
484
498
|
description: `Access your app at http://<EC2-PUBLIC-IP>:${containerPort}`
|
|
485
499
|
});
|
|
@@ -10,6 +10,7 @@ import { SecurityGroup } from "../networking/securityGroup.js";
|
|
|
10
10
|
import { Ec2Instance } from "./ec2.js";
|
|
11
11
|
import { vpcHasNatGateways } from "../../../utils/vpcUtils.js";
|
|
12
12
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
13
|
+
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
13
14
|
import { DEFAULT_EC2_INSTANCE_TYPE, DEFAULT_EC2_MIN_CAPACITY, DEFAULT_EC2_MAX_CAPACITY, DEFAULT_EC2_INSTANCE_MONITORING, DEFAULT_WARM_POOL_MIN_SIZE, DEFAULT_WARM_POOL_REUSE_ON_SCALE_IN, DEFAULT_HEALTH_CHECK_GRACE_SECONDS, DEFAULT_MIN_HEALTHY_PERCENT, DEFAULT_MAX_HEALTHY_PERCENT, DEFAULT_DESIRED_COUNT, inferAmiHardwareType } from "./ecsConstants.js";
|
|
14
15
|
import { assertAnchorUnique, assertSharedAsgConfigMatches, assertValidCapacitySlot, getEc2ConfigKey, legacyAnchorFromKey, LEGACY_KEY_FIELDS, resolveCapacityAnchor, resolveCapacitySlot } from "./ecsCapacityConfig.js";
|
|
15
16
|
import { lookupCapacityIdentityPin } from "../../../utils/capacityIdentityContext.js";
|
|
@@ -308,7 +309,7 @@ export function createService(ctx, serviceName, serviceProps, taskDefinition, as
|
|
|
308
309
|
}
|
|
309
310
|
new CfnOutput(ctx.scope, `${ctx.outputName}${toPascalCase(serviceName)}ServiceArn`, {
|
|
310
311
|
key: `${ctx.outputName}${toPascalCase(serviceName)}ServiceArn`,
|
|
311
|
-
exportName: `${ctx.props.clusterName}${serviceName}ServiceArn
|
|
312
|
+
exportName: stackScopedExportName(ctx.scope, `${ctx.props.clusterName}${serviceName}ServiceArn`),
|
|
312
313
|
value: service.serviceArn,
|
|
313
314
|
description: `ECS Service ARN for ${serviceName}`
|
|
314
315
|
});
|
|
@@ -159,9 +159,13 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
if (containerConfig.secrets && containerConfig.secrets.length > 0) {
|
|
162
|
+
// `secrets` is a set of names: a repeated entry (hand-merged arrays,
|
|
163
|
+
// shared-const spread overlap) must not construct a second SSM import —
|
|
164
|
+
// the construct id embeds the name, and a duplicate id fails synth.
|
|
165
|
+
const uniqueSecretNames = [...new Set(containerConfig.secrets)];
|
|
162
166
|
if (containerConfig.secretsImport) {
|
|
163
167
|
const secretsImportKeys = Object.keys(containerConfig.secretsImport);
|
|
164
|
-
const duplicateKeys =
|
|
168
|
+
const duplicateKeys = uniqueSecretNames.filter((key) => secretsImportKeys.includes(key));
|
|
165
169
|
if (duplicateKeys.length > 0) {
|
|
166
170
|
throw new Error(`Container '${containerConfig.name}' in service '${serviceName}' has duplicate secret keys ` +
|
|
167
171
|
`defined in both secrets and secretsImport: ${duplicateKeys.join(", ")}. ` +
|
|
@@ -169,7 +173,7 @@ export function addContainersToTask(ctx, serviceName, serviceProps, taskDefiniti
|
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
175
|
const ssmSecretsPath = deriveSsmSecretsPath(ctx.props, serviceName, serviceProps.ssmSecretsPath);
|
|
172
|
-
for (const secretName of
|
|
176
|
+
for (const secretName of uniqueSecretNames) {
|
|
173
177
|
validateSecretName(secretName, `container '${containerConfig.name}' of service '${serviceName}'`);
|
|
174
178
|
const paramPath = `${ssmSecretsPath}/${secretName}`;
|
|
175
179
|
const param = StringParameter.fromSecureStringParameterAttributes(ctx.scope, `${ctx.props.clusterName}${serviceName}${containerConfig.name}${secretName}SsmParam`, { parameterName: paramPath });
|
|
@@ -83,7 +83,7 @@ export class RdsAurora extends Construct {
|
|
|
83
83
|
: (props.credentials?.username ?? this.engineConfig.defaultUsername);
|
|
84
84
|
// Global secondary clusters import replicated secret instead of creating new
|
|
85
85
|
if (props.isGlobalSecondary) {
|
|
86
|
-
this.databaseCredentials = new Secret(this,
|
|
86
|
+
this.databaseCredentials = new Secret(this, ResourceNaming.credentialsSecretConstructId(this.databaseNameValue), {
|
|
87
87
|
secretName: props.credentialsSecretName ??
|
|
88
88
|
ResourceNaming.credentialsSecretName(id),
|
|
89
89
|
importExisting: true
|
|
@@ -97,7 +97,7 @@ export class RdsAurora extends Construct {
|
|
|
97
97
|
snapshotUsername: props.snapshotUsername,
|
|
98
98
|
assumedUsername: username
|
|
99
99
|
});
|
|
100
|
-
this.databaseCredentials = new Secret(this,
|
|
100
|
+
this.databaseCredentials = new Secret(this, ResourceNaming.credentialsSecretConstructId(this.databaseNameValue), {
|
|
101
101
|
secretName: props.credentialsSecretName ??
|
|
102
102
|
ResourceNaming.credentialsSecretName(id),
|
|
103
103
|
aliasName: props.credentialsKmsAliasName,
|
|
@@ -3,6 +3,7 @@ import { CfnGlobalCluster } from "aws-cdk-lib/aws-rds";
|
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
4
|
import { RdsAurora } from "./rdsAurora.js";
|
|
5
5
|
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
6
|
+
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
6
7
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
7
8
|
export class RdsAuroraGlobal extends Construct {
|
|
8
9
|
connections;
|
|
@@ -95,7 +96,7 @@ export class RdsAuroraGlobal extends Construct {
|
|
|
95
96
|
};
|
|
96
97
|
if (this.props.primaryRegion === this.currentRegion) {
|
|
97
98
|
// primary
|
|
98
|
-
this.primaryCluster = new RdsAurora(this,
|
|
99
|
+
this.primaryCluster = new RdsAurora(this, ResourceNaming.globalPrimaryConstructId(this.props.databaseName), {
|
|
99
100
|
vpc: this.props.vpc,
|
|
100
101
|
databaseName: this.props.databaseName,
|
|
101
102
|
clusterIdentifier: this.getPrimaryClusterIdentifier(),
|
|
@@ -133,7 +134,7 @@ export class RdsAuroraGlobal extends Construct {
|
|
|
133
134
|
const outputName = toPascalCase(this.props.databaseName);
|
|
134
135
|
new CfnOutput(this, `${outputName}GlobalClusterIdentifierOutput`, {
|
|
135
136
|
value: this.globalId,
|
|
136
|
-
exportName: `${outputName}GlobalClusterIdentifier`
|
|
137
|
+
exportName: stackScopedExportName(this, `${outputName}GlobalClusterIdentifier`)
|
|
137
138
|
});
|
|
138
139
|
}
|
|
139
140
|
getCredentials() {
|
|
@@ -32,7 +32,13 @@ const MULTI_USER_ROTATION_APPLICATIONS = {
|
|
|
32
32
|
*/
|
|
33
33
|
export function resolveStorageEncryptionKey(scope, databaseName, storageKey) {
|
|
34
34
|
if (isCMKRequested(storageKey)) {
|
|
35
|
-
return new CustomerManagedKey(scope, `${databaseName}ClusterEncryptionKey`, {
|
|
35
|
+
return new CustomerManagedKey(scope, `${databaseName}ClusterEncryptionKey`, {
|
|
36
|
+
aliasName: ResourceNaming.storageEncryptionKeyAlias(databaseName),
|
|
37
|
+
// RDS is RemovalPolicy.SNAPSHOT, so deleting the stack leaves a final
|
|
38
|
+
// snapshot behind. Destroying its storage key would make that snapshot
|
|
39
|
+
// permanently unrestorable.
|
|
40
|
+
protects: "outlives-stack"
|
|
41
|
+
}).key;
|
|
36
42
|
}
|
|
37
43
|
if (isAwsManagedKey(storageKey) || storageKey === undefined) {
|
|
38
44
|
return undefined;
|
|
@@ -45,7 +51,11 @@ export function resolveStorageEncryptionKey(scope, databaseName, storageKey) {
|
|
|
45
51
|
*/
|
|
46
52
|
export function resolvePerformanceInsightsKey(scope, databaseName, piEnabled, encryptionKey) {
|
|
47
53
|
if (piEnabled && isCMKRequested(encryptionKey)) {
|
|
48
|
-
return new CustomerManagedKey(scope, `${databaseName}PerformanceInsightsKey`, {
|
|
54
|
+
return new CustomerManagedKey(scope, `${databaseName}PerformanceInsightsKey`, {
|
|
55
|
+
aliasName: ResourceNaming.insightsKeyAlias(databaseName),
|
|
56
|
+
// Performance-insights data dies with the instance.
|
|
57
|
+
protects: "stack-scoped"
|
|
58
|
+
}).key;
|
|
49
59
|
}
|
|
50
60
|
return undefined;
|
|
51
61
|
}
|
|
@@ -80,7 +80,7 @@ export class RdsInstance extends Construct {
|
|
|
80
80
|
snapshotUsername: props.snapshotUsername,
|
|
81
81
|
assumedUsername: username
|
|
82
82
|
});
|
|
83
|
-
this.databaseCredentials = new Secret(this,
|
|
83
|
+
this.databaseCredentials = new Secret(this, ResourceNaming.credentialsSecretConstructId(this.databaseNameValue), {
|
|
84
84
|
secretName: ResourceNaming.credentialsSecretName(this.constructId),
|
|
85
85
|
generateSecretString: {
|
|
86
86
|
secretStringTemplate: JSON.stringify({
|
|
@@ -237,7 +237,8 @@ export class RdsInstance extends Construct {
|
|
|
237
237
|
const { piEnabled, piConfig } = resolveDatabaseInsights(props.databaseInsights);
|
|
238
238
|
const readReplicaPerformanceInsightsKey = piEnabled && isCMKRequested(piConfig?.encryptionKey)
|
|
239
239
|
? new CustomerManagedKey(this, `${this.databaseNameValue}ReadReplicaReaderInsightsKey`, {
|
|
240
|
-
aliasName:
|
|
240
|
+
aliasName: ResourceNaming.readReplicaInsightsKeyAlias(this.databaseNameValue),
|
|
241
|
+
protects: "stack-scoped"
|
|
241
242
|
}).key
|
|
242
243
|
: undefined;
|
|
243
244
|
this.readReplicaSecurityGroup = new SecurityGroup(this, `${this.databaseNameValue}ReadReplicaSecurityGroup`, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CfnOutput } from "aws-cdk-lib";
|
|
2
2
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
3
|
+
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
3
4
|
/**
|
|
4
5
|
* Add a CfnOutput for the RDS proxy endpoint.
|
|
5
6
|
* Shared by RdsAurora and RdsInstance constructs.
|
|
@@ -8,7 +9,7 @@ export function addProxyCfnOutput(scope, constructId, databaseName, proxy) {
|
|
|
8
9
|
const outputName = toPascalCase(databaseName);
|
|
9
10
|
new CfnOutput(scope, `${outputName}ProxyEndpointOutput`, {
|
|
10
11
|
key: `${outputName}ProxyEndpoint`,
|
|
11
|
-
exportName: `${outputName}ProxyEndpoint
|
|
12
|
+
exportName: stackScopedExportName(scope, `${outputName}ProxyEndpoint`),
|
|
12
13
|
value: proxy.endpoint
|
|
13
14
|
});
|
|
14
15
|
}
|
|
@@ -29,6 +29,9 @@ export class Trail extends Construct {
|
|
|
29
29
|
? RemovalPolicy.RETAIN
|
|
30
30
|
: RemovalPolicy.DESTROY;
|
|
31
31
|
this.encryptionKey = new CustomerManagedKey(this, `${id}CloudTrailEncryptionKey`, {
|
|
32
|
+
// Bound to the bucket's own policy below — the trail's log objects
|
|
33
|
+
// outlive the stack exactly when the bucket does.
|
|
34
|
+
protects: "outlives-stack",
|
|
32
35
|
aliasName: `cmk/cloudtrail/${id}/encryptionKey`,
|
|
33
36
|
removalPolicy: storagePolicy
|
|
34
37
|
});
|
|
@@ -6,6 +6,7 @@ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
|
6
6
|
import { ResourceShare } from "../utilities/resourceShare.js";
|
|
7
7
|
import { CustomResource } from "../utilities/customResource.js";
|
|
8
8
|
import getAccountId from "../../../utils/getAccountId.js";
|
|
9
|
+
import { IPAM_PRIVATE_DEFAULT_SCOPE_EXPORT_NAME } from "../../../utils/exportNaming.js";
|
|
9
10
|
import { accountConstructKey, findAccountNameCollision } from "../../../utils/capitaliseString.js";
|
|
10
11
|
import { FjallLogger } from "../../../utils/validationLogger.js";
|
|
11
12
|
import { formatIpamPairTagValue, IPAM_OPERATIONS_POOL_TAG_KEY } from "@fjall/util/aws";
|
|
@@ -20,7 +21,7 @@ export class IpamPool extends Construct {
|
|
|
20
21
|
constructor(scope, id, props) {
|
|
21
22
|
super(scope, id);
|
|
22
23
|
// Get the default IPAM scope ID from props, or CFN import
|
|
23
|
-
const ipamScopeId = props.ipamScope || Fn.importValue(
|
|
24
|
+
const ipamScopeId = props.ipamScope || Fn.importValue(IPAM_PRIVATE_DEFAULT_SCOPE_EXPORT_NAME);
|
|
24
25
|
const regions = props.regions;
|
|
25
26
|
if (!regions || regions.length === 0) {
|
|
26
27
|
throw new Error("At least one region must be specified for IPAM pools");
|
|
@@ -3,6 +3,7 @@ import * as ec2 from "aws-cdk-lib/aws-ec2";
|
|
|
3
3
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
4
4
|
import { LogGroup } from "../logging/logGroup.js";
|
|
5
5
|
import { S3Bucket } from "../storage/index.js";
|
|
6
|
+
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
6
7
|
export const FLOW_LOG_TRAFFIC_TYPES = ["ALL", "ACCEPT", "REJECT"];
|
|
7
8
|
// Read at two sites (resources-layer constructor default + patterns-layer natGateways guard) that must agree.
|
|
8
9
|
export const DEFAULT_MAX_AZS = 3;
|
|
@@ -26,9 +27,14 @@ export class Vpc extends ec2.Vpc {
|
|
|
26
27
|
});
|
|
27
28
|
this.hasNatGateways = natGateways !== 0;
|
|
28
29
|
this.addInterfaceEndpoints(id, props, this.hasNatGateways);
|
|
30
|
+
// Export names are unique per account+region, so the id is load-bearing:
|
|
31
|
+
// without it a stack holding two VPCs emits the name twice and
|
|
32
|
+
// CloudFormation rejects the template. The `-vpc-cidr` suffix must stay
|
|
33
|
+
// last — the webapp finds a linked app's CIDR by ListExports + suffix
|
|
34
|
+
// filter (webapp app/.server/services/infrastructure/captureVpcCidr.ts).
|
|
29
35
|
new CfnOutput(this, "VpcCidrBlock", {
|
|
30
36
|
value: this.vpcCidrBlock,
|
|
31
|
-
exportName: `${Stack.of(this).stackName}-vpc-cidr`
|
|
37
|
+
exportName: `${Stack.of(this).stackName}${id}-vpc-cidr`
|
|
32
38
|
});
|
|
33
39
|
}
|
|
34
40
|
static gatewayEndpoints(props) {
|
|
@@ -171,7 +177,7 @@ export class Vpc extends ec2.Vpc {
|
|
|
171
177
|
const trafficType = Vpc.resolveTrafficType(config.trafficType);
|
|
172
178
|
if (config.destination === "s3") {
|
|
173
179
|
const bucket = new S3Bucket(scope, `${id}FlowLogBucket`, {
|
|
174
|
-
bucketName:
|
|
180
|
+
bucketName: ResourceNaming.vpcFlowLogBucketName(id, Stack.of(scope).account),
|
|
175
181
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
176
182
|
lifecycleRules: config.retentionDays
|
|
177
183
|
? [{ expiration: Duration.days(config.retentionDays), enabled: true }]
|
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
import { RemovalPolicy } from "aws-cdk-lib";
|
|
2
2
|
import { Alias, Key } from "aws-cdk-lib/aws-kms";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
|
+
export declare const KEY_PROTECTION_SCOPES: readonly ["stack-scoped", "outlives-stack"];
|
|
5
|
+
export type KeyProtectionScope = (typeof KEY_PROTECTION_SCOPES)[number];
|
|
4
6
|
interface CustomerManagedKeyProps {
|
|
5
7
|
description?: string;
|
|
6
8
|
aliasName?: string;
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
10
|
+
* Whether anything this key encrypts survives deletion of the stack that
|
|
11
|
+
* created it. Required, and deliberately not defaulted: a key destroyed
|
|
12
|
+
* while a resource it encrypted still exists makes that resource
|
|
13
|
+
* permanently unreadable, and nothing downstream can detect it.
|
|
9
14
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* - `"stack-scoped"` — everything the key encrypts goes with the stack (an
|
|
16
|
+
* SSM parameter, a Secrets Manager secret, performance-insights data).
|
|
17
|
+
* The key follows the env-aware default (D17): production RETAINs, every
|
|
18
|
+
* other recognised stage DESTROYs. This is what stops a torn-down dev
|
|
19
|
+
* stack leaving a chargeable key behind forever.
|
|
20
|
+
* - `"outlives-stack"` — at least one encrypted resource survives (an RDS
|
|
21
|
+
* final snapshot under `RemovalPolicy.SNAPSHOT`, a retained audit bucket,
|
|
22
|
+
* a backup vault). The key RETAINs in every environment: ~$1/month is not
|
|
23
|
+
* a reason to render a surviving snapshot unrestorable.
|
|
13
24
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
25
|
+
* A caller that knows better may still override with `removalPolicy` — the
|
|
26
|
+
* teardown paths that delete the survivor first legitimately do.
|
|
27
|
+
*/
|
|
28
|
+
readonly protects: KeyProtectionScope;
|
|
29
|
+
/**
|
|
30
|
+
* Explicit removal policy, overriding what `protects` would resolve to.
|
|
16
31
|
*
|
|
17
|
-
*
|
|
32
|
+
* Prefer binding it to the policy of the thing being encrypted rather than
|
|
33
|
+
* choosing independently — `CloudTrail` passes the same `storagePolicy` to
|
|
34
|
+
* its bucket and its key precisely so the two cannot drift apart.
|
|
18
35
|
*/
|
|
19
36
|
removalPolicy?: RemovalPolicy;
|
|
20
37
|
}
|
|
@@ -2,6 +2,23 @@ import { CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
|
2
2
|
import { Alias, Key } from "aws-cdk-lib/aws-kms";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
4
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
5
|
+
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
6
|
+
import { stackScopedExportName } from "../../../utils/exportNaming.js";
|
|
7
|
+
import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
|
|
8
|
+
export const KEY_PROTECTION_SCOPES = [
|
|
9
|
+
"stack-scoped",
|
|
10
|
+
"outlives-stack"
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* How long a scheduled-for-deletion key stays recoverable.
|
|
14
|
+
*
|
|
15
|
+
* The window must cover the longest recovery affordance of anything the key
|
|
16
|
+
* protects, or the survivor becomes unreadable before its own recovery period
|
|
17
|
+
* expires. The longest in this codebase is Secrets Manager's 30-day recovery
|
|
18
|
+
* window, so the key matches it. (30 days is also AWS's own default and its
|
|
19
|
+
* maximum; the minimum is 7.)
|
|
20
|
+
*/
|
|
21
|
+
const PENDING_DELETION_WINDOW = Duration.days(30);
|
|
5
22
|
export class CustomerManagedKey extends Construct {
|
|
6
23
|
key;
|
|
7
24
|
alias;
|
|
@@ -9,26 +26,32 @@ export class CustomerManagedKey extends Construct {
|
|
|
9
26
|
super(scope, id);
|
|
10
27
|
// Sanitise id for CloudFormation output keys (must be alphanumeric)
|
|
11
28
|
const outputName = toPascalCase(id);
|
|
29
|
+
const removalPolicy = props.removalPolicy ??
|
|
30
|
+
(props.protects === "outlives-stack"
|
|
31
|
+
? RemovalPolicy.RETAIN
|
|
32
|
+
: toRemovalPolicy(envAwareRemovalPolicyDefault()));
|
|
12
33
|
this.key = new Key(this, `${id}Key`, {
|
|
13
34
|
description: props.description || `${id} KMS Key`,
|
|
14
|
-
removalPolicy
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
removalPolicy,
|
|
36
|
+
// Keyed on the RESOLVED policy: reading props.removalPolicy here meant
|
|
37
|
+
// a key that resolved to DESTROY any other way got no window at all.
|
|
38
|
+
pendingWindow: removalPolicy === RemovalPolicy.DESTROY
|
|
39
|
+
? PENDING_DELETION_WINDOW
|
|
17
40
|
: undefined
|
|
18
41
|
});
|
|
19
42
|
new CfnOutput(this, `${outputName}KeyArn`, {
|
|
20
43
|
key: `${outputName}Arn`,
|
|
21
44
|
value: this.key.keyArn,
|
|
22
|
-
exportName: `${outputName}KeyArn`
|
|
45
|
+
exportName: stackScopedExportName(this, `${outputName}KeyArn`)
|
|
23
46
|
});
|
|
24
47
|
this.alias = new Alias(this, `${id}KeyAlias`, {
|
|
25
|
-
aliasName: props.aliasName ||
|
|
48
|
+
aliasName: props.aliasName || ResourceNaming.defaultKeyAlias(id),
|
|
26
49
|
targetKey: this.key
|
|
27
50
|
});
|
|
28
51
|
new CfnOutput(this, `${outputName}KeyAliasArn`, {
|
|
29
52
|
key: `${outputName}AliasArn`,
|
|
30
53
|
value: this.alias.aliasArn,
|
|
31
|
-
exportName: `${outputName}KeyAliasArn`
|
|
54
|
+
exportName: stackScopedExportName(this, `${outputName}KeyAliasArn`)
|
|
32
55
|
});
|
|
33
56
|
}
|
|
34
57
|
}
|
|
@@ -26,7 +26,9 @@ export class SecureStringParameter extends Construct {
|
|
|
26
26
|
else {
|
|
27
27
|
this.cmk = new CustomerManagedKey(this, `${id}Key`, {
|
|
28
28
|
description: props.description || `${id} KMS Key`,
|
|
29
|
-
aliasName: props.aliasName
|
|
29
|
+
aliasName: props.aliasName,
|
|
30
|
+
// The parameter is deleted with the stack; nothing it encrypts survives.
|
|
31
|
+
protects: "stack-scoped"
|
|
30
32
|
});
|
|
31
33
|
}
|
|
32
34
|
this.parameter = new AwsCustomResource(this, `${id}SecureStringParameter`, {
|
|
@@ -2,6 +2,7 @@ import { SecretValue } from "aws-cdk-lib";
|
|
|
2
2
|
import { Secret as CdkSecret } from "aws-cdk-lib/aws-secretsmanager";
|
|
3
3
|
import { Construct } from "constructs";
|
|
4
4
|
import { CustomerManagedKey } from "./kms.js";
|
|
5
|
+
import { ResourceNaming } from "../../../utils/resourceNaming.js";
|
|
5
6
|
/**
|
|
6
7
|
* Context key carrying the deploy-time `secretName -> completeArn` map. Written by
|
|
7
8
|
* @fjall/deploy-core (`CdkArgumentBuilder.buildContextArgs` emits
|
|
@@ -71,7 +72,10 @@ export class Secret extends Construct {
|
|
|
71
72
|
}
|
|
72
73
|
// Create KMS key for new secrets only
|
|
73
74
|
const customerManagedKey = new CustomerManagedKey(this, `${id}CustomerManagedKey`, {
|
|
74
|
-
aliasName: props.aliasName ??
|
|
75
|
+
aliasName: props.aliasName ?? ResourceNaming.defaultKeyAlias(id),
|
|
76
|
+
// The secret goes with the stack. Its 30-day recovery window is
|
|
77
|
+
// covered by the key's own pending-deletion window, not by retention.
|
|
78
|
+
protects: "stack-scoped"
|
|
75
79
|
});
|
|
76
80
|
this.secretsCustomerManagedKey = customerManagedKey;
|
|
77
81
|
/**
|
|
@@ -7,6 +7,12 @@ interface EcrProps {
|
|
|
7
7
|
/** Default IMMUTABLE (supply-chain guard). Dev-slot repos MUST pass MUTABLE —
|
|
8
8
|
* every `fjall dev up` repushes the branch tag (BUG-22). */
|
|
9
9
|
tagMutability?: TagMutability;
|
|
10
|
+
/**
|
|
11
|
+
* Overrides the env-aware default (D17: production → RETAIN, every other
|
|
12
|
+
* recognised environment → DESTROY). RETAIN outlives the stack; DESTROY
|
|
13
|
+
* takes the repository AND every image in it.
|
|
14
|
+
*/
|
|
15
|
+
removalPolicy?: "DESTROY" | "RETAIN";
|
|
10
16
|
}
|
|
11
17
|
export declare class EcrFactory {
|
|
12
18
|
static build(id: string, props?: EcrProps): (app: App, scope: Construct) => Ecr;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Repository, TagMutability } from "aws-cdk-lib/aws-ecr";
|
|
2
2
|
import { CfnOutput, RemovalPolicy } from "aws-cdk-lib";
|
|
3
|
+
import { envAwareRemovalPolicyDefault } from "../../../utils/removalPolicy.js";
|
|
3
4
|
export class EcrFactory {
|
|
4
5
|
static build(id, props) {
|
|
5
6
|
return (app, scope) => {
|
|
@@ -24,14 +25,17 @@ export class Ecr extends Repository {
|
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
27
|
static getRepositoryProps(props) {
|
|
27
|
-
|
|
28
|
-
// on stack teardown — next deploy pulls "not found" for any referenced tag.
|
|
28
|
+
const destroy = (props?.removalPolicy ?? envAwareRemovalPolicyDefault()) === "DESTROY";
|
|
29
29
|
return {
|
|
30
30
|
...(props?.repositoryName && { repositoryName: props.repositoryName }),
|
|
31
31
|
imageScanOnPush: true,
|
|
32
32
|
imageTagMutability: props?.tagMutability ?? TagMutability.IMMUTABLE,
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// CloudFormation refuses to delete a repository still holding images, so
|
|
34
|
+
// DESTROY with `false` is not a gentler DESTROY — it fails the stack
|
|
35
|
+
// delete outright. Gotcha: DESTROY wipes every pushed image, so a
|
|
36
|
+
// redeploy pulls "not found" for any tag the old repository held.
|
|
37
|
+
emptyOnDelete: destroy,
|
|
38
|
+
removalPolicy: destroy ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
41
|
static build(id, props) {
|
|
@@ -36,6 +36,16 @@ export interface S3BucketProps extends BucketProps {
|
|
|
36
36
|
backupVaultTier?: BackupTier;
|
|
37
37
|
publicReadAccess?: boolean;
|
|
38
38
|
websiteHosting?: WebsiteHostingConfig;
|
|
39
|
+
/**
|
|
40
|
+
* Name a `fjallBucket(<name>)` alias target uses to import this bucket's
|
|
41
|
+
* website exports. Defaults to `bucketName ?? id`, which is right for a
|
|
42
|
+
* direct `new S3Bucket(scope, "Assets", …)` — `id` is then the author's own
|
|
43
|
+
* handle. A wrapper that nests the bucket under a derived id MUST pass this:
|
|
44
|
+
* `Storage` builds its bucket at `${id}Bucket`, so without it a
|
|
45
|
+
* `fjallBucket("assets")` record would import `AssetsWebsiteEndpoint`
|
|
46
|
+
* against an exported `AssetsBucketWebsiteEndpoint`.
|
|
47
|
+
*/
|
|
48
|
+
aliasTargetName?: string;
|
|
39
49
|
/**
|
|
40
50
|
* Declarative bucket-policy statements, each appended via
|
|
41
51
|
* `addToResourcePolicy`. The TLS-only `enforceSSL` deny is always applied
|
|
@@ -4,6 +4,7 @@ import { BlockPublicAccess, Bucket } 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
6
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
7
|
+
import { bucketWebsiteEndpointExportName, bucketWebsiteHostedZoneIdExportName } from "@fjall/util";
|
|
7
8
|
import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
|
|
8
9
|
export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
|
|
9
10
|
function shouldAutoVersion(tier) {
|
|
@@ -17,7 +18,7 @@ function toResourcePolicyPrincipal(identifier) {
|
|
|
17
18
|
export class S3Bucket extends Bucket {
|
|
18
19
|
backupVaultTier;
|
|
19
20
|
constructor(scope, id, props = {}) {
|
|
20
|
-
const { websiteHosting, backupVaultTier, resourcePolicyStatements, ...cdkProps } = props;
|
|
21
|
+
const { websiteHosting, backupVaultTier, resourcePolicyStatements, aliasTargetName, ...cdkProps } = props;
|
|
21
22
|
const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
|
|
22
23
|
const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
|
|
23
24
|
const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
|
|
@@ -73,10 +74,14 @@ export class S3Bucket extends Bucket {
|
|
|
73
74
|
Tags.of(this).add(SDK_PRE_EMPTY_TAG_KEY, "true");
|
|
74
75
|
}
|
|
75
76
|
if (websiteHosting) {
|
|
76
|
-
const
|
|
77
|
+
const targetBucketName = aliasTargetName ?? props.bucketName ?? id;
|
|
78
|
+
// Only the export name routes through the contract helper. `safeBucket`
|
|
79
|
+
// stays local so the CfnOutput logical ids are unchanged — unifying the
|
|
80
|
+
// two would silently rename them.
|
|
81
|
+
const safeBucket = toPascalCase(targetBucketName.replace(/[^A-Za-z0-9-]/g, ""));
|
|
77
82
|
new CfnOutput(this, `${safeBucket}WebsiteEndpoint`, {
|
|
78
83
|
key: `${safeBucket}WebsiteEndpoint`,
|
|
79
|
-
exportName:
|
|
84
|
+
exportName: bucketWebsiteEndpointExportName(targetBucketName),
|
|
80
85
|
value: this.bucketWebsiteDomainName,
|
|
81
86
|
description: `S3 website endpoint for ${id} (consumed by Domain alias targets)`
|
|
82
87
|
});
|
|
@@ -88,7 +93,7 @@ export class S3Bucket extends Bucket {
|
|
|
88
93
|
}
|
|
89
94
|
new CfnOutput(this, `${safeBucket}WebsiteHostedZoneId`, {
|
|
90
95
|
key: `${safeBucket}WebsiteHostedZoneId`,
|
|
91
|
-
exportName:
|
|
96
|
+
exportName: bucketWebsiteHostedZoneIdExportName(targetBucketName),
|
|
92
97
|
value: websiteHostedZoneId,
|
|
93
98
|
description: `Route 53 hosted zone id for the S3 website endpoint of ${id}`
|
|
94
99
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Construct } from "constructs";
|
|
2
|
+
/**
|
|
3
|
+
* The app-level half of the `Domain` ALB alias-target contract.
|
|
4
|
+
*
|
|
5
|
+
* `fjallApp(appName)` resolves `<App>AlbDnsName` / `<App>AlbHostedZoneId` from
|
|
6
|
+
* a *different* stack, so the name has to be derivable from the app name
|
|
7
|
+
* alone — the Domain stack cannot see how many ALBs the app has. But an app
|
|
8
|
+
* may legitimately front several: one per ALB-bearing compute. Emitting the
|
|
9
|
+
* app-keyed pair from each of them produced the identical `Outputs` entry N
|
|
10
|
+
* times and CloudFormation rejected the entire template ("The Outputs section
|
|
11
|
+
* contains duplicate Export names"), which is how the compute deploy-test
|
|
12
|
+
* suite — seven ALB-bearing clusters in one app — failed on 2026-08-09.
|
|
13
|
+
*
|
|
14
|
+
* So the two halves are split by who can answer the question:
|
|
15
|
+
*
|
|
16
|
+
* - Each compute emits its own `<App>-<Compute>-Alb…` pair unconditionally.
|
|
17
|
+
* Always unique, always present, addressable as `fjallApp(app, compute)`.
|
|
18
|
+
* - The App emits the unqualified `<App>Alb…` pair at synth time, and ONLY
|
|
19
|
+
* when the app registered exactly one ALB — the sole case where "the
|
|
20
|
+
* app's ALB" names one thing.
|
|
21
|
+
*
|
|
22
|
+
* Two or more and no app-level export is written. A `Domain` using the bare
|
|
23
|
+
* `fjallApp(app)` then fails at CloudFormation with "No export named
|
|
24
|
+
* <App>AlbDnsName found", which names the missing export and is fixed by
|
|
25
|
+
* naming the compute. Picking one of N silently would deploy a domain
|
|
26
|
+
* pointing at an arbitrary load balancer, and would keep working right up
|
|
27
|
+
* until the registration order changed.
|
|
28
|
+
*/
|
|
29
|
+
export interface AlbAliasTargetRegistration {
|
|
30
|
+
readonly appName: string;
|
|
31
|
+
readonly computeName: string;
|
|
32
|
+
/** Stack-attached scope the app-level `CfnOutput` is emitted into. */
|
|
33
|
+
readonly scope: Construct;
|
|
34
|
+
readonly dnsName: string;
|
|
35
|
+
readonly hostedZoneId: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function registerAlbAliasTarget(registration: AlbAliasTargetRegistration): void;
|
|
38
|
+
/**
|
|
39
|
+
* Emit the unqualified app-level alias exports for every app in this tree
|
|
40
|
+
* that registered exactly one ALB. Call once, from `App.synth()`, before
|
|
41
|
+
* `super.synth()` — the construct tree is still mutable there, and that is
|
|
42
|
+
* the same window `applyTagsAspect()` uses.
|
|
43
|
+
*/
|
|
44
|
+
export declare function emitAppLevelAlbAliasExports(root: Construct): void;
|