@fjall/components-infrastructure 3.4.1 → 3.6.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/patterns/aws/apexDomainPattern.js +12 -24
- package/dist/lib/patterns/aws/buildkite/buildkite.d.ts +54 -9
- package/dist/lib/patterns/aws/buildkite/buildkite.js +131 -23
- package/dist/lib/patterns/aws/buildkite/iam.d.ts +8 -1
- package/dist/lib/patterns/aws/buildkite/iam.js +11 -0
- package/dist/lib/patterns/aws/buildkite/userData.d.ts +13 -1
- package/dist/lib/patterns/aws/buildkite/userData.js +8 -3
- package/dist/lib/patterns/aws/buildkite.d.ts +1 -1
- package/dist/lib/patterns/aws/buildkite.js +1 -1
- package/dist/lib/patterns/aws/database.d.ts +6 -0
- package/dist/lib/patterns/aws/database.js +2 -1
- 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 +15 -5
- 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/ecsRoles.d.ts +7 -0
- package/dist/lib/resources/aws/compute/ecsRoles.js +8 -1
- package/dist/lib/resources/aws/database/rdsAurora.d.ts +7 -0
- package/dist/lib/resources/aws/database/rdsAurora.js +2 -1
- package/dist/lib/resources/aws/database/rdsDefaults.d.ts +6 -3
- package/dist/lib/resources/aws/database/rdsDefaults.js +6 -3
- package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.d.ts +10 -0
- package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +27 -1
- package/dist/lib/resources/aws/secrets/secret.d.ts +6 -0
- package/dist/lib/resources/aws/secrets/secret.js +1 -1
- package/dist/lib/utils/domainTypes.d.ts +5 -11
- package/dist/lib/utils/domainTypes.js +7 -4
- package/package.json +4 -4
|
@@ -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
|
-
}
|
|
@@ -161,6 +161,8 @@ export class DevSubstrate extends Construct {
|
|
|
161
161
|
databaseName: DEV_SUBSTRATE_DATABASE_NAME,
|
|
162
162
|
clusterIdentifier: devFenceName(props.appKebab),
|
|
163
163
|
credentialsSecretName: `${devFenceName(props.appKebab)}-master`,
|
|
164
|
+
// Default alias derives from the fixed databaseName — collides account-wide.
|
|
165
|
+
credentialsKmsAliasName: `cmk/${devFenceName(props.appKebab)}-master`,
|
|
164
166
|
engineVersion: props.engineVersion ?? DEV_AURORA_DEFAULTS.ENGINE_VERSION,
|
|
165
167
|
serverlessV2MinCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MIN_CAPACITY,
|
|
166
168
|
serverlessV2MaxCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MAX_CAPACITY,
|
|
@@ -205,8 +207,14 @@ export class DevSubstrate extends Construct {
|
|
|
205
207
|
// createBaseExecutionRole DELIBERATELY omits logs (auto-granted only on the
|
|
206
208
|
// ECS-pattern path via AwsLogDriver.bind()). Slot task-defs reference this
|
|
207
209
|
// shared role by ARN, so that auto-grant never fires — add the log-write grant
|
|
208
|
-
// explicitly.
|
|
209
|
-
|
|
210
|
+
// explicitly. Path + name pin the role inside the /fjall/dev/ fence: the dev
|
|
211
|
+
// deploy role's only iam:PassRole grant is on role/fjall/dev/*, so the CDK
|
|
212
|
+
// defaults (path "/", CFN-generated name) would AccessDeny slot task-def
|
|
213
|
+
// registration.
|
|
214
|
+
const slotExecutionRole = createBaseExecutionRole(this, DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID, {
|
|
215
|
+
path: "/fjall/dev/",
|
|
216
|
+
roleName: `${devFenceName(props.appKebab)}-slot-exec`
|
|
217
|
+
});
|
|
210
218
|
const { partition, region, account } = Stack.of(this);
|
|
211
219
|
slotExecutionRole.addToPolicy(new PolicyStatement({
|
|
212
220
|
effect: Effect.ALLOW,
|
|
@@ -376,9 +384,11 @@ export class DevSubstrate extends Construct {
|
|
|
376
384
|
* the hand-created G2 slot service consumes these to place tasks, register a
|
|
377
385
|
* host-header rule, inject DB creds and point a sleeping slot at the waker.
|
|
378
386
|
*
|
|
379
|
-
* No KMS-alias export:
|
|
380
|
-
*
|
|
381
|
-
*
|
|
387
|
+
* No KMS-alias export: cluster storage and SSM SecureStrings ride AWS-managed
|
|
388
|
+
* default keys, and while the master-credentials secret IS CMK-encrypted (the
|
|
389
|
+
* Secret wrapper mints `cmk/fjall-dev-<app>-master`), no consumer addresses
|
|
390
|
+
* that key by alias — the provisioner's and waker's kms:Decrypt are
|
|
391
|
+
* ViaService-scoped (Secrets Manager / SSM), so there is no alias to export.
|
|
382
392
|
*
|
|
383
393
|
* The Aurora slot-SG ingress (A2.5) makes the Database stack depend on the
|
|
384
394
|
* Compute stack. So the two DB exports are scoped under `databaseScope` (the
|
|
@@ -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
|
+
}
|
|
@@ -86,29 +86,69 @@ function validateRecords(scope, props, effectiveZone) {
|
|
|
86
86
|
}
|
|
87
87
|
function validateCertificates(props, effectiveZone) {
|
|
88
88
|
const certificates = props.certificates ?? [];
|
|
89
|
+
let cloudFrontCount = 0;
|
|
89
90
|
for (const cert of certificates) {
|
|
90
91
|
const resolved = normaliseCertificate(cert);
|
|
91
92
|
assertWithinZone(resolved.domainName, effectiveZone);
|
|
92
93
|
for (const san of resolved.subjectAlternativeNames ?? []) {
|
|
93
94
|
assertWithinZone(san, effectiveZone);
|
|
94
95
|
}
|
|
96
|
+
// D3 CloudFront designation consistency. TS already narrows the prop to
|
|
97
|
+
// boolean; the runtime check catches JS callers and stale generated code.
|
|
98
|
+
if (resolved.cloudFront !== undefined &&
|
|
99
|
+
typeof resolved.cloudFront !== "boolean") {
|
|
100
|
+
throw new Error(`Domain: certificate '${resolved.domainName}' has a non-boolean ` +
|
|
101
|
+
`'cloudFront' (received: ${JSON.stringify(resolved.cloudFront)}). ` +
|
|
102
|
+
`Cure: set cloudFront: true for a CloudFront viewer certificate, ` +
|
|
103
|
+
`or drop the prop.`);
|
|
104
|
+
}
|
|
105
|
+
if (resolved.cloudFront === true) {
|
|
106
|
+
cloudFrontCount += 1;
|
|
107
|
+
if (props.registrar === "external-records") {
|
|
108
|
+
throw new Error(`Domain: certificate '${resolved.domainName}' sets cloudFront: ` +
|
|
109
|
+
`true, which registrar 'external-records' does not support — ` +
|
|
110
|
+
`there is no Fjall-managed hosted zone to DNS-validate the ` +
|
|
111
|
+
`us-east-1 certificate against. Cure: manage the zone with ` +
|
|
112
|
+
`registrar 'route53' or 'external-delegated', or drop ` +
|
|
113
|
+
`cloudFront and provision an app-owned us-east-1 certificate.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (cloudFrontCount > 1) {
|
|
118
|
+
throw new Error(`Domain: at most one certificate may set cloudFront: true (received ` +
|
|
119
|
+
`${cloudFrontCount}) — the zone-level ` +
|
|
120
|
+
`'<zone>-us-east-1-certificate-arn' export is singular. Cure: keep ` +
|
|
121
|
+
`one CloudFront certificate and move the other names into its ` +
|
|
122
|
+
`subjectAlternativeNames.`);
|
|
95
123
|
}
|
|
96
124
|
}
|
|
97
125
|
/**
|
|
98
126
|
* D8 delegation-consistency checks for the child-writes shape.
|
|
99
127
|
*
|
|
100
|
-
* `parentDelegationRoleArn` and `
|
|
101
|
-
* (`external-delegated`) only; on any other registrar
|
|
102
|
-
* hard error rather than a silent no-op. On the
|
|
103
|
-
* ARN must be a LITERAL string (VD2-a): an
|
|
104
|
-
* reached for `Fn.importValue`, which
|
|
105
|
-
* exactly the trap that sank the legacy
|
|
128
|
+
* `parentDelegationRoleArn`, `phase` and `adoptedNameServers` belong to the
|
|
129
|
+
* delegated topology (`external-delegated`) only; on any other registrar
|
|
130
|
+
* their presence is a hard error rather than a silent no-op. On the
|
|
131
|
+
* delegated topology the role ARN must be a LITERAL string (VD2-a): an
|
|
132
|
+
* unresolved token means the caller reached for `Fn.importValue`, which
|
|
133
|
+
* resolves same-account only and is exactly the trap that sank the legacy
|
|
134
|
+
* `DomainDelegation` pattern. Adoption (`hostedZoneId` +
|
|
135
|
+
* `adoptedNameServers`) is both-or-neither: an imported zone exposes no
|
|
136
|
+
* NS attribute, so a one-sided declaration either cannot delegate or
|
|
137
|
+
* cannot name the zone it delegates to.
|
|
106
138
|
*/
|
|
107
139
|
function validateDelegationConfig(props) {
|
|
108
140
|
const arn = props
|
|
109
141
|
.parentDelegationRoleArn;
|
|
110
142
|
const phase = props.phase;
|
|
111
143
|
if (props.registrar !== "external-delegated") {
|
|
144
|
+
const adoptedNameServers = props
|
|
145
|
+
.adoptedNameServers;
|
|
146
|
+
if (adoptedNameServers !== undefined) {
|
|
147
|
+
throw new Error(`Domain: 'adoptedNameServers' is only supported with registrar: ` +
|
|
148
|
+
`'external-delegated' (received registrar: '${props.registrar}'). ` +
|
|
149
|
+
`Cure: adoption of an existing child zone is declared on the ` +
|
|
150
|
+
`delegated child Domain — move the prop there, or drop it.`);
|
|
151
|
+
}
|
|
112
152
|
if (arn !== undefined) {
|
|
113
153
|
throw new Error(`Domain: 'parentDelegationRoleArn' is only supported with registrar: ` +
|
|
114
154
|
`'external-delegated' (received registrar: '${props.registrar}'). ` +
|
|
@@ -129,6 +169,7 @@ function validateDelegationConfig(props) {
|
|
|
129
169
|
`received: '${String(phase)}'. Cure: use 'zone' for step 1 of the ` +
|
|
130
170
|
`two-step delegated deploy, 'full' (or omit) for the complete build.`);
|
|
131
171
|
}
|
|
172
|
+
validateAdoptionConfig(props);
|
|
132
173
|
if (arn === undefined) {
|
|
133
174
|
return;
|
|
134
175
|
}
|
|
@@ -151,13 +192,60 @@ function validateDelegationConfig(props) {
|
|
|
151
192
|
`output (arn:<partition>:iam::<account>:role/<name>).`);
|
|
152
193
|
}
|
|
153
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Child-zone ADOPTION consistency (external-delegated only). `hostedZoneId`
|
|
197
|
+
* and `adoptedNameServers` are both-or-neither, and the name servers must be
|
|
198
|
+
* a non-empty array of literal hostnames: an imported zone exposes no
|
|
199
|
+
* `hostedZoneNameServers` attribute, so these literals are the ONLY source
|
|
200
|
+
* the `Custom::CrossAccountZoneDelegation` UPSERT has for the parent NS row
|
|
201
|
+
* — a token or empty set would delegate the zone to nowhere.
|
|
202
|
+
*/
|
|
203
|
+
function validateAdoptionConfig(props) {
|
|
204
|
+
const hostedZoneId = props.hostedZoneId;
|
|
205
|
+
const adoptedNameServers = props
|
|
206
|
+
.adoptedNameServers;
|
|
207
|
+
if (hostedZoneId === undefined && adoptedNameServers === undefined) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (adoptedNameServers === undefined) {
|
|
211
|
+
throw new Error(`Domain: adopting an existing child zone requires 'adoptedNameServers' ` +
|
|
212
|
+
`alongside 'hostedZoneId' (both-or-neither). An imported zone ` +
|
|
213
|
+
`exposes no name-server attribute at synth, so the parent NS UPSERT ` +
|
|
214
|
+
`needs the live values. Cure: read them once ` +
|
|
215
|
+
`(aws route53 get-hosted-zone --id ${String(hostedZoneId)}) and ` +
|
|
216
|
+
`paste the DelegationSet.NameServers literals.`);
|
|
217
|
+
}
|
|
218
|
+
if (hostedZoneId === undefined) {
|
|
219
|
+
throw new Error(`Domain: 'adoptedNameServers' requires 'hostedZoneId' (both-or-neither) ` +
|
|
220
|
+
`— the literals describe an EXISTING zone to adopt. Cure: set the ` +
|
|
221
|
+
`adopted zone's hostedZoneId, or drop 'adoptedNameServers' to create ` +
|
|
222
|
+
`a fresh child zone.`);
|
|
223
|
+
}
|
|
224
|
+
if (typeof hostedZoneId !== "string" || hostedZoneId.length === 0) {
|
|
225
|
+
throw new Error(`Domain: 'hostedZoneId' must be a non-empty string; received: ` +
|
|
226
|
+
`${JSON.stringify(hostedZoneId)}.`);
|
|
227
|
+
}
|
|
228
|
+
if (!Array.isArray(adoptedNameServers) || adoptedNameServers.length === 0) {
|
|
229
|
+
throw new Error(`Domain: 'adoptedNameServers' must be a non-empty array of NS ` +
|
|
230
|
+
`hostnames; received: ${JSON.stringify(adoptedNameServers)}. Cure: ` +
|
|
231
|
+
`paste the adopted zone's DelegationSet.NameServers.`);
|
|
232
|
+
}
|
|
233
|
+
for (const ns of adoptedNameServers) {
|
|
234
|
+
if (typeof ns !== "string" || ns.length === 0 || Token.isUnresolved(ns)) {
|
|
235
|
+
throw new Error(`Domain: every 'adoptedNameServers' entry must be a literal NS ` +
|
|
236
|
+
`hostname (no CDK tokens) — the delegation UPSERT writes them ` +
|
|
237
|
+
`verbatim into the parent zone. Received: ${JSON.stringify(ns)}.`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
154
241
|
function normaliseCertificate(cert) {
|
|
155
242
|
if (typeof cert === "string") {
|
|
156
243
|
return { domainName: cert };
|
|
157
244
|
}
|
|
158
245
|
return {
|
|
159
246
|
domainName: cert.domainName,
|
|
160
|
-
subjectAlternativeNames: cert.subjectAlternativeNames
|
|
247
|
+
subjectAlternativeNames: cert.subjectAlternativeNames,
|
|
248
|
+
cloudFront: cert.cloudFront
|
|
161
249
|
};
|
|
162
250
|
}
|
|
163
251
|
function assertWithinZone(candidate, zoneName) {
|
|
@@ -39,6 +39,22 @@ export type Certificate = string | {
|
|
|
39
39
|
readonly domainName: string;
|
|
40
40
|
readonly subjectAlternativeNames?: string[];
|
|
41
41
|
readonly transparencyLogging?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* D3 — CloudFront viewer certificate. CloudFront accepts certificates
|
|
44
|
+
* from us-east-1 only, so a `cloudFront: true` entry mints its
|
|
45
|
+
* `DomainCertificate` there — in-stack when the domain stack itself
|
|
46
|
+
* resolves to us-east-1, otherwise in a domain-paired
|
|
47
|
+
* `<StackName>UsEast1Certificates` stack — and publishes the
|
|
48
|
+
* zone-level `<zone>-us-east-1-certificate-arn` export in place of
|
|
49
|
+
* the per-domain regional export. The ARN reaches CloudFront
|
|
50
|
+
* consumers as a LITERAL through
|
|
51
|
+
* `ManagedDomainBinding.usEast1CertificateArn` (D2 DescribeStacks),
|
|
52
|
+
* never via CDK cross-region references. At most one certificate per
|
|
53
|
+
* Domain may set it (the zone-level export is singular — put extra
|
|
54
|
+
* names in `subjectAlternativeNames`); unsupported on registrar
|
|
55
|
+
* `"external-records"`, which has no hosted zone to validate against.
|
|
56
|
+
*/
|
|
57
|
+
readonly cloudFront?: boolean;
|
|
42
58
|
};
|
|
43
59
|
export interface DomainCommonProps {
|
|
44
60
|
readonly zoneName: string;
|
|
@@ -55,6 +71,23 @@ export interface Route53ApexProps extends DomainCommonProps {
|
|
|
55
71
|
export interface ExternalDelegatedProps extends DomainCommonProps {
|
|
56
72
|
readonly registrar: "external-delegated";
|
|
57
73
|
readonly delegatedSubdomain: string;
|
|
74
|
+
/**
|
|
75
|
+
* Child-zone ADOPTION: hosted zone id of an EXISTING zone for
|
|
76
|
+
* `{delegatedSubdomain}.{zoneName}`. When present the pattern IMPORTS the
|
|
77
|
+
* zone instead of creating one. Requires `adoptedNameServers`
|
|
78
|
+
* (both-or-neither — validated): an imported zone exposes no
|
|
79
|
+
* `hostedZoneNameServers` attribute at synth, so the parent NS UPSERT
|
|
80
|
+
* needs the live values as literals.
|
|
81
|
+
*/
|
|
82
|
+
readonly hostedZoneId?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Literal NS hostnames of the adopted zone (from a live
|
|
85
|
+
* `get-hosted-zone` read). Carried verbatim into the
|
|
86
|
+
* `Custom::CrossAccountZoneDelegation` UPSERT — the delegation record in
|
|
87
|
+
* the parent zone must keep pointing at the adopted zone's real name
|
|
88
|
+
* servers. Only valid together with `hostedZoneId`.
|
|
89
|
+
*/
|
|
90
|
+
readonly adoptedNameServers?: string[];
|
|
58
91
|
/**
|
|
59
92
|
* D8 — child-writes delegation. LITERAL ARN of the parent zone's
|
|
60
93
|
* DelegationRole (VD2-a: never `Fn.importValue`, which resolves
|
|
@@ -16,9 +16,16 @@ import type { EcsServiceProps } from "./ecsTypes.js";
|
|
|
16
16
|
* SHARED or IMPORTED role those auto-grants cannot reach — e.g. the dev
|
|
17
17
|
* substrate's slot exec role, referenced by slot task-defs via ARN — MUST add
|
|
18
18
|
* the explicit `logs` / `secretsmanager` statements itself.
|
|
19
|
+
*
|
|
20
|
+
* `path`/`roleName` pin the IAM path and physical name — needed when an IAM
|
|
21
|
+
* grant fences on the role's path (the dev-role `iam:PassRole` grant on
|
|
22
|
+
* `role/fjall/dev/*`), since the CDK default is path `/` + a CFN-generated
|
|
23
|
+
* name no path- or prefix-scoped grant can match.
|
|
19
24
|
*/
|
|
20
25
|
export declare function createBaseExecutionRole(scope: Construct, id: string, opts?: {
|
|
21
26
|
ssmSecretsPath?: string;
|
|
27
|
+
path?: string;
|
|
28
|
+
roleName?: string;
|
|
22
29
|
}): Role;
|
|
23
30
|
/**
|
|
24
31
|
* Creates the execution role for ECS infrastructure operations.
|
|
@@ -15,10 +15,17 @@ import { deriveSsmSecretsPath } from "./ecsTaskDefinition.js";
|
|
|
15
15
|
* SHARED or IMPORTED role those auto-grants cannot reach — e.g. the dev
|
|
16
16
|
* substrate's slot exec role, referenced by slot task-defs via ARN — MUST add
|
|
17
17
|
* the explicit `logs` / `secretsmanager` statements itself.
|
|
18
|
+
*
|
|
19
|
+
* `path`/`roleName` pin the IAM path and physical name — needed when an IAM
|
|
20
|
+
* grant fences on the role's path (the dev-role `iam:PassRole` grant on
|
|
21
|
+
* `role/fjall/dev/*`), since the CDK default is path `/` + a CFN-generated
|
|
22
|
+
* name no path- or prefix-scoped grant can match.
|
|
18
23
|
*/
|
|
19
24
|
export function createBaseExecutionRole(scope, id, opts = {}) {
|
|
20
25
|
const executionRole = new Role(scope, id, {
|
|
21
|
-
assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com")
|
|
26
|
+
assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com"),
|
|
27
|
+
path: opts.path,
|
|
28
|
+
roleName: opts.roleName
|
|
22
29
|
});
|
|
23
30
|
// GetAuthorizationToken is an account-level API that requires resources: ["*"].
|
|
24
31
|
// The image-pull actions also use "*" because ecrRepository can be a string URI
|
|
@@ -20,6 +20,13 @@ interface RdsProps {
|
|
|
20
20
|
clusterIdentifier?: string;
|
|
21
21
|
/** Physical name of the generated master-credentials secret. */
|
|
22
22
|
credentialsSecretName?: string;
|
|
23
|
+
/**
|
|
24
|
+
* KMS alias for the CMK encrypting the master-credentials secret. Aliases are
|
|
25
|
+
* account+region-unique, so a caller with a fixed `databaseName` deployed once
|
|
26
|
+
* per app (e.g. the dev substrate) must pass an app-scoped alias or the second
|
|
27
|
+
* app's deploy fails on the alias collision.
|
|
28
|
+
*/
|
|
29
|
+
credentialsKmsAliasName?: string;
|
|
23
30
|
/** Serverless-v2 floor in ACU. 0 enables scale-to-zero (auto-pause). */
|
|
24
31
|
serverlessV2MinCapacity?: number;
|
|
25
32
|
/** Serverless-v2 ceiling in ACU. */
|
|
@@ -100,6 +100,7 @@ export class RdsAurora extends Construct {
|
|
|
100
100
|
this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
|
|
101
101
|
secretName: props.credentialsSecretName ??
|
|
102
102
|
ResourceNaming.credentialsSecretName(id),
|
|
103
|
+
aliasName: props.credentialsKmsAliasName,
|
|
103
104
|
generateSecretString: {
|
|
104
105
|
secretStringTemplate: JSON.stringify({ username }),
|
|
105
106
|
excludePunctuation: true,
|
|
@@ -151,7 +152,7 @@ export class RdsAurora extends Construct {
|
|
|
151
152
|
const readers = this.buildReaders(props, piEnabled, performanceInsightsKey, performanceInsightsRetention);
|
|
152
153
|
const engine = props.engine ||
|
|
153
154
|
DatabaseClusterEngine.auroraPostgres({
|
|
154
|
-
version: AuroraPostgresEngineVersion.of("16.
|
|
155
|
+
version: AuroraPostgresEngineVersion.of("16.10", "16")
|
|
155
156
|
});
|
|
156
157
|
const parameterGroup = new ParameterGroup(this, `${this.databaseNameValue}ParameterGroup`, {
|
|
157
158
|
engine,
|
|
@@ -16,15 +16,18 @@ export declare const RDS_DEFAULTS: Readonly<{
|
|
|
16
16
|
* (fast dev-envs Phase 3). `min 0` ACU scales the shared cluster to zero when
|
|
17
17
|
* idle; `enableDataApi` lets slot DDL run over HTTPS with no VPC connection, and
|
|
18
18
|
* is the only mode under which min-0 actually saves (a live pooled connection
|
|
19
|
-
* pins the cluster above 0 ACU). Engine 16.
|
|
20
|
-
* floor (see `MIN_AUTO_PAUSE_POSTGRES_VERSION`).
|
|
19
|
+
* pins the cluster above 0 ACU). Engine 16.10 satisfies the auto-pause-capable
|
|
20
|
+
* floor (see `MIN_AUTO_PAUSE_POSTGRES_VERSION`). AWS removes old minors from
|
|
21
|
+
* the creatable list over time (16.6 rejected with "Cannot find version" by
|
|
22
|
+
* 2026-07) — pin a mid-window minor, not the oldest available, and expect this
|
|
23
|
+
* to need a bump roughly yearly.
|
|
21
24
|
*/
|
|
22
25
|
export declare const DEV_AURORA_DEFAULTS: Readonly<{
|
|
23
26
|
readonly SERVERLESS_V2_MIN_CAPACITY: 0;
|
|
24
27
|
readonly SERVERLESS_V2_MAX_CAPACITY: 4;
|
|
25
28
|
readonly AUTO_PAUSE_SECONDS: 1200;
|
|
26
29
|
readonly ENABLE_DATA_API: true;
|
|
27
|
-
readonly ENGINE_VERSION: "16.
|
|
30
|
+
readonly ENGINE_VERSION: "16.10";
|
|
28
31
|
}>;
|
|
29
32
|
/**
|
|
30
33
|
* Bounds AWS enforces on `ServerlessV2ScalingConfiguration.SecondsUntilAutoPause`
|