@fjall/components-infrastructure 3.5.2 → 3.6.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/patterns/aws/apexDomainPattern.js +12 -24
- package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
- package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +6 -2
- package/dist/lib/patterns/aws/delegatedDomainPattern.js +28 -27
- package/dist/lib/patterns/aws/devSubstrate.js +28 -10
- package/dist/lib/patterns/aws/domainCertificateComposer.d.ts +48 -0
- package/dist/lib/patterns/aws/domainCertificateComposer.js +179 -0
- package/dist/lib/patterns/aws/domainValidation.js +95 -7
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +33 -0
- package/dist/lib/resources/aws/compute/ecs.js +7 -3
- package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +24 -2
- package/dist/lib/resources/aws/compute/ecsNetworking.js +136 -10
- package/dist/lib/resources/aws/compute/ecsServiceFactory.js +7 -1
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +37 -0
- package/dist/lib/resources/aws/compute/ecsValidation.js +45 -0
- package/dist/lib/resources/aws/compute/listenerRouting.d.ts +10 -1
- package/dist/lib/resources/aws/compute/listenerRouting.js +13 -1
- package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +7 -0
- package/dist/lib/resources/aws/compute/persistentDataVolume.js +6 -0
- package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.d.ts +10 -0
- package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +27 -1
- package/dist/lib/utils/domainTypes.d.ts +5 -11
- package/dist/lib/utils/domainTypes.js +7 -4
- package/package.json +4 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
|
|
2
|
-
import {
|
|
2
|
+
import { composeDomainCertificates } from "./domainCertificateComposer.js";
|
|
3
3
|
import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
|
|
4
4
|
import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
|
|
5
5
|
/**
|
|
@@ -27,19 +27,17 @@ export function composeApexDomain(scope, props) {
|
|
|
27
27
|
costAllocationEnvironment: props.costAllocationEnvironment,
|
|
28
28
|
costAllocationDomain: props.zoneName
|
|
29
29
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
certificates.set(normalised.domainName, dc.certificate);
|
|
30
|
+
// Regional entries mint beside the zone as before; cloudFront entries
|
|
31
|
+
// mint in us-east-1 (in-stack here, or in the domain-paired
|
|
32
|
+
// UsEast1Certificates stack) and publish the zone-level export (D3).
|
|
33
|
+
const certificates = composeDomainCertificates(scope, {
|
|
34
|
+
certificates: props.certificates,
|
|
35
|
+
effectiveZoneName: props.zoneName,
|
|
36
|
+
safeZone,
|
|
37
|
+
hostedZone: hostedZoneConstruct.hostedZone,
|
|
38
|
+
hostedZoneId: props.hostedZoneId,
|
|
39
|
+
costAllocationEnvironment: props.costAllocationEnvironment,
|
|
40
|
+
costAllocationDomain: props.zoneName
|
|
43
41
|
});
|
|
44
42
|
if (props.records && props.records.length > 0) {
|
|
45
43
|
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records);
|
|
@@ -55,13 +53,3 @@ export function composeApexDomain(scope, props) {
|
|
|
55
53
|
manualRecords: []
|
|
56
54
|
};
|
|
57
55
|
}
|
|
58
|
-
function normaliseCertificate(cert) {
|
|
59
|
-
if (typeof cert === "string") {
|
|
60
|
-
return { domainName: cert };
|
|
61
|
-
}
|
|
62
|
-
return {
|
|
63
|
-
domainName: cert.domainName,
|
|
64
|
-
subjectAlternativeNames: cert.subjectAlternativeNames,
|
|
65
|
-
transparencyLogging: cert.transparencyLogging
|
|
66
|
-
};
|
|
67
|
-
}
|
|
@@ -361,6 +361,7 @@ export class ClickHouseDatabase extends Construct {
|
|
|
361
361
|
clickHouseContainerSecrets[userPasswordEnvName(name)] =
|
|
362
362
|
userSecret.getImport("password");
|
|
363
363
|
}
|
|
364
|
+
const resolvedAlertsTopic = resolveAlertsTopic(this, "AlertsTopic", props.alertsTopic);
|
|
364
365
|
const ecsCompute = new EcsCompute(this, "Compute", {
|
|
365
366
|
type: "ecs",
|
|
366
367
|
vpc,
|
|
@@ -393,7 +394,10 @@ export class ClickHouseDatabase extends Construct {
|
|
|
393
394
|
deviceName: CLICKHOUSE_EBS_DEVICE_NAME,
|
|
394
395
|
availabilityZone: dataAz,
|
|
395
396
|
iops: CLICKHOUSE_EBS_IOPS,
|
|
396
|
-
throughputMbps: CLICKHOUSE_EBS_THROUGHPUT_MBPS
|
|
397
|
+
throughputMbps: CLICKHOUSE_EBS_THROUGHPUT_MBPS,
|
|
398
|
+
...(resolvedAlertsTopic !== undefined && {
|
|
399
|
+
alarmTopic: resolvedAlertsTopic
|
|
400
|
+
})
|
|
397
401
|
},
|
|
398
402
|
userData,
|
|
399
403
|
desiredCapacity: 1,
|
|
@@ -492,7 +496,6 @@ export class ClickHouseDatabase extends Construct {
|
|
|
492
496
|
// asgName is the CloudWatch dimension the host metrics key off, so without
|
|
493
497
|
// it the alarms cannot be built; no topic is the default, so skip silently
|
|
494
498
|
// rather than throw.
|
|
495
|
-
const resolvedAlertsTopic = resolveAlertsTopic(this, "AlertsTopic", props.alertsTopic);
|
|
496
499
|
const asgName = ecsCompute.getAutoScalingGroupName();
|
|
497
500
|
if (resolvedAlertsTopic !== undefined && asgName !== undefined) {
|
|
498
501
|
createClickHouseAlarms({
|
|
@@ -10,8 +10,12 @@ export interface DelegatedDomainPatternResult {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* Composition for `registrar: "external-delegated"` — the delegated-child
|
|
13
|
-
* topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}`
|
|
14
|
-
*
|
|
13
|
+
* topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}` —
|
|
14
|
+
* or ADOPTS an existing one when `hostedZoneId` + `adoptedNameServers` are
|
|
15
|
+
* set (both-or-neither, validated): the zone is imported instead of created
|
|
16
|
+
* and the delegation UPSERT carries the adopted NS literals, since an
|
|
17
|
+
* imported zone exposes no `hostedZoneNameServers` attribute. When
|
|
18
|
+
* `parentDelegationRoleArn` is present, the child NS are UPSERTed into the
|
|
15
19
|
* parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
|
|
16
20
|
* delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
|
|
17
21
|
* literal — validated in `validateDomainProps` (VD2-a). When absent, the
|
|
@@ -2,14 +2,18 @@ import { Annotations } from "aws-cdk-lib";
|
|
|
2
2
|
import { Role } from "../../resources/aws/iam/index.js";
|
|
3
3
|
import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
|
|
4
4
|
import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
|
|
5
|
-
import {
|
|
5
|
+
import { composeDomainCertificates } from "./domainCertificateComposer.js";
|
|
6
6
|
import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
|
|
7
7
|
import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
|
|
8
8
|
import { DOMAIN_DEPLOY_DEFAULT_PHASE } from "../../utils/domainTypes.js";
|
|
9
9
|
/**
|
|
10
10
|
* Composition for `registrar: "external-delegated"` — the delegated-child
|
|
11
|
-
* topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}`
|
|
12
|
-
*
|
|
11
|
+
* topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}` —
|
|
12
|
+
* or ADOPTS an existing one when `hostedZoneId` + `adoptedNameServers` are
|
|
13
|
+
* set (both-or-neither, validated): the zone is imported instead of created
|
|
14
|
+
* and the delegation UPSERT carries the adopted NS literals, since an
|
|
15
|
+
* imported zone exposes no `hostedZoneNameServers` attribute. When
|
|
16
|
+
* `parentDelegationRoleArn` is present, the child NS are UPSERTed into the
|
|
13
17
|
* parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
|
|
14
18
|
* delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
|
|
15
19
|
* literal — validated in `validateDomainProps` (VD2-a). When absent, the
|
|
@@ -26,6 +30,9 @@ export function composeDelegatedDomain(scope, props) {
|
|
|
26
30
|
const safeZone = toPascalCase(getSafeZoneName(effectiveZone));
|
|
27
31
|
const hostedZoneConstruct = new HostedZone(scope, `${safeZone}HostedZone`, {
|
|
28
32
|
zoneName: effectiveZone,
|
|
33
|
+
// Adoption path: an existing child zone is imported by id rather than
|
|
34
|
+
// created (both-or-neither with adoptedNameServers, validated upstream).
|
|
35
|
+
hostedZoneId: props.hostedZoneId,
|
|
29
36
|
// The PARENT account owns the delegation role — this is the child.
|
|
30
37
|
createDelegationRole: false,
|
|
31
38
|
costAllocationEnvironment: props.costAllocationEnvironment,
|
|
@@ -44,6 +51,9 @@ export function composeDelegatedDomain(scope, props) {
|
|
|
44
51
|
delegatedZone: hostedZoneConstruct.hostedZone,
|
|
45
52
|
delegatedZoneName: effectiveZone,
|
|
46
53
|
parentHostedZoneName: props.zoneName,
|
|
54
|
+
// Adoption: the imported zone has no NS attribute — the UPSERT uses
|
|
55
|
+
// the user-declared literals (undefined on the create path).
|
|
56
|
+
adoptedNameServers: props.adoptedNameServers,
|
|
47
57
|
costAllocationEnvironment: props.costAllocationEnvironment,
|
|
48
58
|
costAllocationDomain: props.zoneName
|
|
49
59
|
});
|
|
@@ -55,7 +65,10 @@ export function composeDelegatedDomain(scope, props) {
|
|
|
55
65
|
`the parent DelegationRole ARN from the managed-domain binding) or ` +
|
|
56
66
|
`add the zone's NS records at the parent registrar manually.`);
|
|
57
67
|
}
|
|
58
|
-
|
|
68
|
+
// Created zones surface their synth-time NS tokens; adopted zones surface
|
|
69
|
+
// the declared literals (authoritative — the same values the delegation
|
|
70
|
+
// record UPSERTs into the parent).
|
|
71
|
+
const nameServers = hostedZoneConstruct.nameServers ?? props.adoptedNameServers ?? [];
|
|
59
72
|
const records = props.records ?? [];
|
|
60
73
|
if (records.length > 0) {
|
|
61
74
|
composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records);
|
|
@@ -70,19 +83,17 @@ export function composeDelegatedDomain(scope, props) {
|
|
|
70
83
|
manualRecords: []
|
|
71
84
|
};
|
|
72
85
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
85
|
-
certificates.set(normalised.domainName, dc.certificate);
|
|
86
|
+
// Regional entries mint beside the zone as before; cloudFront entries
|
|
87
|
+
// mint in us-east-1 (in-stack here, or in the domain-paired
|
|
88
|
+
// UsEast1Certificates stack) and publish the zone-level export (D3).
|
|
89
|
+
const certificates = composeDomainCertificates(scope, {
|
|
90
|
+
certificates: props.certificates,
|
|
91
|
+
effectiveZoneName: effectiveZone,
|
|
92
|
+
safeZone,
|
|
93
|
+
hostedZone: hostedZoneConstruct.hostedZone,
|
|
94
|
+
hostedZoneId: props.hostedZoneId,
|
|
95
|
+
costAllocationEnvironment: props.costAllocationEnvironment,
|
|
96
|
+
costAllocationDomain: props.zoneName
|
|
86
97
|
});
|
|
87
98
|
return {
|
|
88
99
|
hostedZone: hostedZoneConstruct.hostedZone,
|
|
@@ -91,13 +102,3 @@ export function composeDelegatedDomain(scope, props) {
|
|
|
91
102
|
manualRecords: []
|
|
92
103
|
};
|
|
93
104
|
}
|
|
94
|
-
function normaliseCertificate(cert) {
|
|
95
|
-
if (typeof cert === "string") {
|
|
96
|
-
return { domainName: cert };
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
domainName: cert.domainName,
|
|
100
|
-
subjectAlternativeNames: cert.subjectAlternativeNames,
|
|
101
|
-
transparencyLogging: cert.transparencyLogging
|
|
102
|
-
};
|
|
103
|
-
}
|
|
@@ -5,6 +5,7 @@ import { Repository } from "aws-cdk-lib/aws-ecr";
|
|
|
5
5
|
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
6
6
|
import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
7
7
|
import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
|
|
8
|
+
import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
8
9
|
import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
|
|
9
10
|
import { Construct } from "constructs";
|
|
10
11
|
import { readFileSync } from "node:fs";
|
|
@@ -20,6 +21,7 @@ import { createLambdaTargetGroup } from "../../resources/aws/compute/lambdaAlbTa
|
|
|
20
21
|
import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
|
|
21
22
|
import { Ecr } from "../../resources/aws/storage/ecr.js";
|
|
22
23
|
import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
|
|
24
|
+
import { AliasRecord } from "../../resources/aws/networking/dnsRecord/aliasRecord.js";
|
|
23
25
|
import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
|
|
24
26
|
import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
|
|
25
27
|
import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
|
|
@@ -56,6 +58,7 @@ const DEV_SUBSTRATE_PARENT_ROLE_ID = "DevSubstrateParentDelegationRole";
|
|
|
56
58
|
/** Construct ids for the substrate's wildcard cert + :443 HTTPS listener (A6). */
|
|
57
59
|
const DEV_SUBSTRATE_CERT_ID = "DevSubstrateCertificate";
|
|
58
60
|
const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
|
|
61
|
+
const DEV_SUBSTRATE_WILDCARD_ALIAS_ID = "DevSubstrateWildcardAlias";
|
|
59
62
|
// Default deploy phase when `phase` is omitted — the cert + :443 listener
|
|
60
63
|
// build. Shared with the delegated `Domain` topology via
|
|
61
64
|
// `DOMAIN_DEPLOY_DEFAULT_PHASE` (utils/domainTypes.ts): the R2 two-step guard
|
|
@@ -68,13 +71,6 @@ const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
|
|
|
68
71
|
* `point-at-existing` (GAP-7) is deferred, so this is a fixed value for now.
|
|
69
72
|
*/
|
|
70
73
|
const DEV_SUBSTRATE_DATABASE_MODE = "aurora";
|
|
71
|
-
/**
|
|
72
|
-
* The container port slots serve HTTP on. Fjall owns the slot image, so this is a
|
|
73
|
-
* fixed contract: the ALB → slot-SG ingress opens exactly this port and the
|
|
74
|
-
* host-header rules a slot registers (§5.4) forward to it. The public :80 ALB
|
|
75
|
-
* front listener is separate.
|
|
76
|
-
*/
|
|
77
|
-
const DEV_SUBSTRATE_SLOT_PORT = 8080;
|
|
78
74
|
/**
|
|
79
75
|
* Physical-name prefix pinning every substrate resource inside the `fjall-dev-*`
|
|
80
76
|
* fence. Load-bearing: the dev-tier IAM grants (`@fjall/generator devRolePolicies`)
|
|
@@ -210,17 +206,24 @@ export class DevSubstrate extends Construct {
|
|
|
210
206
|
// explicitly. Path + name pin the role inside the /fjall/dev/ fence: the dev
|
|
211
207
|
// deploy role's only iam:PassRole grant is on role/fjall/dev/*, so the CDK
|
|
212
208
|
// defaults (path "/", CFN-generated name) would AccessDeny slot task-def
|
|
213
|
-
// registration.
|
|
209
|
+
// registration. ssmSecretsPath grants the slot parameter tree — the worker
|
|
210
|
+
// writes DATABASE_URL + FJALL_* under /fjall/dev/<app>/<slot>/ and slot
|
|
211
|
+
// task-defs inject them via this role; without it the task dies at
|
|
212
|
+
// ResourceInitializationError on ssm:GetParameters.
|
|
214
213
|
const slotExecutionRole = createBaseExecutionRole(this, DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID, {
|
|
214
|
+
ssmSecretsPath: `/fjall/dev/${props.appKebab}`,
|
|
215
215
|
path: "/fjall/dev/",
|
|
216
216
|
roleName: `${devFenceName(props.appKebab)}-slot-exec`
|
|
217
217
|
});
|
|
218
218
|
const { partition, region, account } = Stack.of(this);
|
|
219
|
+
// Whole app dev-fence subtree, not /slots/* — the worker names each slot's
|
|
220
|
+
// log group /fjall/dev/<app>/<slot> (the slot base path), so a /slots/*
|
|
221
|
+
// scope would deny CreateLogStream at task start.
|
|
219
222
|
slotExecutionRole.addToPolicy(new PolicyStatement({
|
|
220
223
|
effect: Effect.ALLOW,
|
|
221
224
|
actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
|
|
222
225
|
resources: [
|
|
223
|
-
`arn:${partition}:logs:${region}:${account}:log-group:/fjall/dev/${props.appKebab}
|
|
226
|
+
`arn:${partition}:logs:${region}:${account}:log-group:/fjall/dev/${props.appKebab}/*:*`
|
|
224
227
|
]
|
|
225
228
|
}));
|
|
226
229
|
// Slot-SG → Aurora ingress. Dormant in Data-API mode (Data API reaches the
|
|
@@ -258,7 +261,11 @@ export class DevSubstrate extends Construct {
|
|
|
258
261
|
// fence-pinned name — the reaper finds it by the subtree fence tags. A pinned
|
|
259
262
|
// name would only reintroduce the 32-char ALB-name truncation dance.
|
|
260
263
|
const loadBalancer = createApplicationLoadBalancer(this, DEV_SUBSTRATE_ALB_ID, { vpc, internetFacing: true, securityGroup: albSecurityGroup });
|
|
261
|
-
|
|
264
|
+
// All-TCP, not a pinned port: the worker registers each slot's target group
|
|
265
|
+
// on the per-slot `buildSpec.containerPort` (default 3000, `--port`
|
|
266
|
+
// overridable), chosen at provision time — synth cannot know it. Scope stays
|
|
267
|
+
// tight because the source is the substrate's own ALB SG.
|
|
268
|
+
slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.allTcp(),
|
|
262
269
|
// EC2 SG-rule descriptions reject non-ASCII (allowed set is
|
|
263
270
|
// `a-zA-Z0-9. _-:/()#,@[]+=&;{}!$*` — no arrow, em-dash, or even `>`).
|
|
264
271
|
"Dev substrate ALB to slot tasks");
|
|
@@ -376,6 +383,17 @@ export class DevSubstrate extends Construct {
|
|
|
376
383
|
// Unconditional 404 as on :80 (per-slot rules attach out of band, §5.4). open
|
|
377
384
|
// (CDK default) adds the :443 ingress to the ALB SG.
|
|
378
385
|
const httpsListener = addRoutingListener(loadBalancer, DEV_SUBSTRATE_HTTPS_LISTENER_ID, { port: 443, certificate: certificate.certificate, default404: true });
|
|
386
|
+
// GAP-4: one wildcard ALIAS resolves every slot host to the ALB — routing
|
|
387
|
+
// happens per-slot via host-header rules, so no per-slot DNS write is ever
|
|
388
|
+
// needed. Phase-gated with the cert (the record is useless before :443
|
|
389
|
+
// exists) and created in adopt mode too: the adopted zone predates the
|
|
390
|
+
// substrate, but the record targets the substrate's own ALB.
|
|
391
|
+
new AliasRecord(this, DEV_SUBSTRATE_WILDCARD_ALIAS_ID, {
|
|
392
|
+
zone: childZone,
|
|
393
|
+
zoneName: childZoneName,
|
|
394
|
+
recordName: "*",
|
|
395
|
+
target: new LoadBalancerTarget(loadBalancer)
|
|
396
|
+
});
|
|
379
397
|
return { devDomain: childZoneName, httpsListener };
|
|
380
398
|
}
|
|
381
399
|
/**
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Construct } from "constructs";
|
|
2
|
+
import { type IHostedZone } from "aws-cdk-lib/aws-route53";
|
|
3
|
+
import type { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
4
|
+
import type { Certificate } from "./interfaces/domain.js";
|
|
5
|
+
export interface DomainCertificateComposition {
|
|
6
|
+
readonly certificates: readonly Certificate[] | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Zone the export names derive from — the apex `zoneName`, or the
|
|
9
|
+
* delegated child's `${delegatedSubdomain}.${zoneName}` (mirrors the
|
|
10
|
+
* Domain construct's `resolveEffectiveZoneName`).
|
|
11
|
+
*/
|
|
12
|
+
readonly effectiveZoneName: string;
|
|
13
|
+
/** PascalCase construct-ID prefix shared with the calling composer. */
|
|
14
|
+
readonly safeZone: string;
|
|
15
|
+
readonly hostedZone: IHostedZone;
|
|
16
|
+
/**
|
|
17
|
+
* Literal zone id when the zone is ADOPTED — the paired us-east-1 stack
|
|
18
|
+
* re-imports the zone by attributes instead of a runtime lookup (a created
|
|
19
|
+
* zone's id is a cross-stack token the paired stack must not consume).
|
|
20
|
+
*/
|
|
21
|
+
readonly hostedZoneId: string | undefined;
|
|
22
|
+
readonly costAllocationEnvironment: string | undefined;
|
|
23
|
+
readonly costAllocationDomain: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Certificate composition shared by `composeApexDomain` and
|
|
27
|
+
* `composeDelegatedDomain`.
|
|
28
|
+
*
|
|
29
|
+
* Regional entries mint a `DomainCertificate` beside the zone exactly as
|
|
30
|
+
* before (byte-stable construct IDs `${safeZone}${safeCertName}Cert${index}`,
|
|
31
|
+
* per-domain `<domain>-certificate-arn` export). A `cloudFront: true` entry
|
|
32
|
+
* (D3) instead mints in us-east-1 — the only region CloudFront accepts
|
|
33
|
+
* viewer certificates from — and publishes the zone-level
|
|
34
|
+
* `<zone>-us-east-1-certificate-arn` export in place of the per-domain one:
|
|
35
|
+
*
|
|
36
|
+
* - domain stack already in us-east-1 (the fjall.io case): certificate and
|
|
37
|
+
* export stay IN-STACK, no extra stack;
|
|
38
|
+
* - any other resolved region: certificate and export live in a
|
|
39
|
+
* domain-paired `<StackName>UsEast1Certificates` stack. NO CDK
|
|
40
|
+
* cross-region references — the ARN is consumed as a LITERAL via D2
|
|
41
|
+
* DescribeStacks (`ManagedDomainBinding.usEast1CertificateArn`), so
|
|
42
|
+
* neither stack synthesises the export writer/reader machinery.
|
|
43
|
+
*
|
|
44
|
+
* An unresolved region or account is a synth-time error naming the cures:
|
|
45
|
+
* where the certificate must live is unknowable, and guessing would strand
|
|
46
|
+
* the export CloudFront consumers (patternDomain) fail without.
|
|
47
|
+
*/
|
|
48
|
+
export declare function composeDomainCertificates(scope: Construct, composition: DomainCertificateComposition): Map<string, ICertificate>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { CfnOutput, Stack, Stage, Token } from "aws-cdk-lib";
|
|
2
|
+
import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
|
|
3
|
+
import { getDomainExportNames, getDomainUsEast1CertificatesStackName } from "@fjall/util";
|
|
4
|
+
import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
|
|
5
|
+
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
6
|
+
const US_EAST_1 = "us-east-1";
|
|
7
|
+
/**
|
|
8
|
+
* Certificate composition shared by `composeApexDomain` and
|
|
9
|
+
* `composeDelegatedDomain`.
|
|
10
|
+
*
|
|
11
|
+
* Regional entries mint a `DomainCertificate` beside the zone exactly as
|
|
12
|
+
* before (byte-stable construct IDs `${safeZone}${safeCertName}Cert${index}`,
|
|
13
|
+
* per-domain `<domain>-certificate-arn` export). A `cloudFront: true` entry
|
|
14
|
+
* (D3) instead mints in us-east-1 — the only region CloudFront accepts
|
|
15
|
+
* viewer certificates from — and publishes the zone-level
|
|
16
|
+
* `<zone>-us-east-1-certificate-arn` export in place of the per-domain one:
|
|
17
|
+
*
|
|
18
|
+
* - domain stack already in us-east-1 (the fjall.io case): certificate and
|
|
19
|
+
* export stay IN-STACK, no extra stack;
|
|
20
|
+
* - any other resolved region: certificate and export live in a
|
|
21
|
+
* domain-paired `<StackName>UsEast1Certificates` stack. NO CDK
|
|
22
|
+
* cross-region references — the ARN is consumed as a LITERAL via D2
|
|
23
|
+
* DescribeStacks (`ManagedDomainBinding.usEast1CertificateArn`), so
|
|
24
|
+
* neither stack synthesises the export writer/reader machinery.
|
|
25
|
+
*
|
|
26
|
+
* An unresolved region or account is a synth-time error naming the cures:
|
|
27
|
+
* where the certificate must live is unknowable, and guessing would strand
|
|
28
|
+
* the export CloudFront consumers (patternDomain) fail without.
|
|
29
|
+
*/
|
|
30
|
+
export function composeDomainCertificates(scope, composition) {
|
|
31
|
+
const certificates = new Map();
|
|
32
|
+
(composition.certificates ?? []).forEach((cert, index) => {
|
|
33
|
+
const normalised = normaliseCertificate(cert);
|
|
34
|
+
const safeCertName = toPascalCase(normalised.domainName.split(".").join(""));
|
|
35
|
+
const certId = `${composition.safeZone}${safeCertName}Cert${index}`;
|
|
36
|
+
if (normalised.cloudFront !== true) {
|
|
37
|
+
const dc = new DomainCertificate(scope, certId, {
|
|
38
|
+
domainName: normalised.domainName,
|
|
39
|
+
subjectAlternativeNames: normalised.subjectAlternativeNames,
|
|
40
|
+
transparencyLogging: normalised.transparencyLogging,
|
|
41
|
+
hostedZone: composition.hostedZone,
|
|
42
|
+
costAllocationEnvironment: composition.costAllocationEnvironment,
|
|
43
|
+
costAllocationDomain: composition.costAllocationDomain
|
|
44
|
+
});
|
|
45
|
+
certificates.set(normalised.domainName, dc.certificate);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
certificates.set(normalised.domainName, mintCloudFrontCertificate(scope, composition, normalised, certId));
|
|
49
|
+
});
|
|
50
|
+
return certificates;
|
|
51
|
+
}
|
|
52
|
+
function mintCloudFrontCertificate(scope, composition, cert, certId) {
|
|
53
|
+
const domainStack = Stack.of(scope);
|
|
54
|
+
const region = Token.isUnresolved(domainStack.region)
|
|
55
|
+
? undefined
|
|
56
|
+
: domainStack.region;
|
|
57
|
+
if (region === undefined) {
|
|
58
|
+
throw new Error(`Domain '${composition.effectiveZoneName}': cannot place the ` +
|
|
59
|
+
`CloudFront certificate for '${cert.domainName}' — the domain ` +
|
|
60
|
+
"stack's region is unresolved at synth (got a region token), so " +
|
|
61
|
+
"whether it already deploys to us-east-1 is unknowable. Deploy " +
|
|
62
|
+
"through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT and " +
|
|
63
|
+
"CDK_DEFAULT_REGION before synthesising.");
|
|
64
|
+
}
|
|
65
|
+
if (region === US_EAST_1) {
|
|
66
|
+
// In-stack placement (the fjall.io case): the domain stack's home region
|
|
67
|
+
// already satisfies CloudFront, so no paired stack is minted and the
|
|
68
|
+
// zone-level export publishes beside the zone.
|
|
69
|
+
const dc = new DomainCertificate(scope, certId, {
|
|
70
|
+
domainName: cert.domainName,
|
|
71
|
+
subjectAlternativeNames: cert.subjectAlternativeNames,
|
|
72
|
+
transparencyLogging: cert.transparencyLogging,
|
|
73
|
+
hostedZone: composition.hostedZone,
|
|
74
|
+
costAllocationEnvironment: composition.costAllocationEnvironment,
|
|
75
|
+
costAllocationDomain: composition.costAllocationDomain,
|
|
76
|
+
// The zone-level us-east-1 export below replaces the per-domain
|
|
77
|
+
// regional export: CloudFront consumers resolve the ARN through
|
|
78
|
+
// ManagedDomainBinding.usEast1CertificateArn, and a viewer
|
|
79
|
+
// certificate has no regional (ALB/ECS) consumers to export for.
|
|
80
|
+
exportCertificateArn: false
|
|
81
|
+
});
|
|
82
|
+
emitUsEast1Export(scope, composition, dc);
|
|
83
|
+
return dc.certificate;
|
|
84
|
+
}
|
|
85
|
+
const pairedStack = resolvePairedUsEast1Stack(domainStack, composition, cert);
|
|
86
|
+
// Re-create the zone reference INSIDE the paired stack: an adopted zone's
|
|
87
|
+
// literal id travels as-is, while a created zone's id is a cross-stack
|
|
88
|
+
// token the paired stack must not consume (that would demand
|
|
89
|
+
// crossRegionReferences) — it re-resolves by lookup on the declared zone
|
|
90
|
+
// name instead (same split as patternDomain's BYO path).
|
|
91
|
+
const certZone = composition.hostedZoneId !== undefined
|
|
92
|
+
? AWSHostedZone.fromHostedZoneAttributes(pairedStack, `${composition.safeZone}UsEast1CertificateZone`, {
|
|
93
|
+
hostedZoneId: composition.hostedZoneId,
|
|
94
|
+
zoneName: composition.effectiveZoneName
|
|
95
|
+
})
|
|
96
|
+
: AWSHostedZone.fromLookup(pairedStack, `${composition.safeZone}UsEast1CertificateZone`, { domainName: composition.effectiveZoneName });
|
|
97
|
+
const dc = new DomainCertificate(pairedStack, certId, {
|
|
98
|
+
domainName: cert.domainName,
|
|
99
|
+
subjectAlternativeNames: cert.subjectAlternativeNames,
|
|
100
|
+
transparencyLogging: cert.transparencyLogging,
|
|
101
|
+
hostedZone: certZone,
|
|
102
|
+
costAllocationEnvironment: composition.costAllocationEnvironment,
|
|
103
|
+
costAllocationDomain: composition.costAllocationDomain,
|
|
104
|
+
exportCertificateArn: false
|
|
105
|
+
});
|
|
106
|
+
emitUsEast1Export(pairedStack, composition, dc);
|
|
107
|
+
return dc.certificate;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The zone-level `<zone>-us-east-1-certificate-arn` export
|
|
111
|
+
* (`getDomainExportNames(...).usEast1CertificateArn` — the SSOT
|
|
112
|
+
* `DomainService.resolveDomainForApp` reads into
|
|
113
|
+
* `ManagedDomainBinding.usEast1CertificateArn` for CloudFront consumers).
|
|
114
|
+
* Scope is the stack that owns the certificate: the domain stack in-region,
|
|
115
|
+
* the paired stack otherwise.
|
|
116
|
+
*/
|
|
117
|
+
function emitUsEast1Export(scope, composition, dc) {
|
|
118
|
+
const exports = getDomainExportNames(composition.effectiveZoneName);
|
|
119
|
+
new CfnOutput(scope, `${composition.safeZone}UsEast1CertificateArn`, {
|
|
120
|
+
key: `${composition.safeZone}UsEast1CertificateArn`,
|
|
121
|
+
value: dc.certificateArn,
|
|
122
|
+
exportName: exports.usEast1CertificateArn
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The domain-paired us-east-1 certificate stack,
|
|
127
|
+
* `<StackName>UsEast1Certificates` — the name contract
|
|
128
|
+
* `DomainService.resolveDomainForApp` derives when the domain stack itself
|
|
129
|
+
* is not us-east-1. Env-pinned to the domain stack's concrete account:
|
|
130
|
+
* without one the paired stack cannot be addressed, so an unresolved
|
|
131
|
+
* account fails synth with the cures.
|
|
132
|
+
*/
|
|
133
|
+
function resolvePairedUsEast1Stack(domainStack, composition, cert) {
|
|
134
|
+
if (Token.isUnresolved(domainStack.account)) {
|
|
135
|
+
throw new Error(`Domain '${composition.effectiveZoneName}': the CloudFront ` +
|
|
136
|
+
`certificate for '${cert.domainName}' needs the paired us-east-1 ` +
|
|
137
|
+
"certificate stack, which needs a concrete account (got an " +
|
|
138
|
+
"unresolved account token). Deploy through the Fjall CLI, or " +
|
|
139
|
+
"export CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION before " +
|
|
140
|
+
"synthesising.");
|
|
141
|
+
}
|
|
142
|
+
const container = Stage.of(domainStack);
|
|
143
|
+
if (container === undefined) {
|
|
144
|
+
throw new Error(`Domain '${composition.effectiveZoneName}': the paired us-east-1 ` +
|
|
145
|
+
"certificate stack must be created beside the domain stack, but " +
|
|
146
|
+
"the domain stack has no enclosing App/Stage. Synthesise the " +
|
|
147
|
+
"domain under a CDK App (the Fjall CLI always does).");
|
|
148
|
+
}
|
|
149
|
+
// SSOT name contract with the read side (DomainService.resolveDomainForApp
|
|
150
|
+
// describes this stack with a us-east-1 CloudFormation client).
|
|
151
|
+
const pairedId = getDomainUsEast1CertificatesStackName(domainStack.stackName);
|
|
152
|
+
const existing = container.node.tryFindChild(pairedId);
|
|
153
|
+
if (existing !== undefined) {
|
|
154
|
+
if (!Stack.isStack(existing)) {
|
|
155
|
+
throw new Error(`Domain '${composition.effectiveZoneName}': construct '${pairedId}' ` +
|
|
156
|
+
"already exists beside the domain stack but is not a Stack — the " +
|
|
157
|
+
"paired us-east-1 certificate stack name is reserved by the D3 " +
|
|
158
|
+
"contract. Rename the colliding construct.");
|
|
159
|
+
}
|
|
160
|
+
return existing;
|
|
161
|
+
}
|
|
162
|
+
// Plain env-pinned stack, deliberately WITHOUT crossRegionReferences: the
|
|
163
|
+
// ARN leaves this stack as a literal through DescribeStacks, never as a
|
|
164
|
+
// CDK token, so no writer/reader machinery may synthesise.
|
|
165
|
+
return new Stack(container, pairedId, {
|
|
166
|
+
env: { account: domainStack.account, region: US_EAST_1 }
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function normaliseCertificate(cert) {
|
|
170
|
+
if (typeof cert === "string") {
|
|
171
|
+
return { domainName: cert };
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
domainName: cert.domainName,
|
|
175
|
+
subjectAlternativeNames: cert.subjectAlternativeNames,
|
|
176
|
+
transparencyLogging: cert.transparencyLogging,
|
|
177
|
+
cloudFront: cert.cloudFront
|
|
178
|
+
};
|
|
179
|
+
}
|