@fjall/components-infrastructure 2.33.0 → 3.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.
Files changed (66) hide show
  1. package/dist/lib/app.d.ts +19 -2
  2. package/dist/lib/app.js +61 -4
  3. package/dist/lib/patterns/aws/account.js +6 -1
  4. package/dist/lib/patterns/aws/apexDomainPattern.d.ts +11 -12
  5. package/dist/lib/patterns/aws/apexDomainPattern.js +11 -31
  6. package/dist/lib/patterns/aws/cdn.js +25 -10
  7. package/dist/lib/patterns/aws/computeEcs.js +15 -6
  8. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +13 -4
  9. package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +13 -4
  10. package/dist/lib/patterns/aws/delegatedDomainPattern.js +57 -8
  11. package/dist/lib/patterns/aws/devSubstrate.d.ts +2 -1
  12. package/dist/lib/patterns/aws/devSubstrate.js +18 -8
  13. package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +2 -9
  14. package/dist/lib/patterns/aws/dnsRecordComposer.js +5 -97
  15. package/dist/lib/patterns/aws/domain.d.ts +3 -2
  16. package/dist/lib/patterns/aws/domain.js +3 -2
  17. package/dist/lib/patterns/aws/domainValidation.js +71 -46
  18. package/dist/lib/patterns/aws/externalRecordsPattern.js +2 -11
  19. package/dist/lib/patterns/aws/index.d.ts +1 -3
  20. package/dist/lib/patterns/aws/index.js +2 -3
  21. package/dist/lib/patterns/aws/interfaces/domain.d.ts +22 -8
  22. package/dist/lib/patterns/aws/interfaces/index.d.ts +0 -1
  23. package/dist/lib/patterns/aws/interfaces/index.js +3 -0
  24. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +55 -10
  25. package/dist/lib/patterns/aws/patternDomain.d.ts +77 -0
  26. package/dist/lib/patterns/aws/patternDomain.js +198 -0
  27. package/dist/lib/patterns/aws/payload.js +23 -30
  28. package/dist/lib/patterns/aws/staticSite.d.ts +1 -0
  29. package/dist/lib/patterns/aws/staticSite.js +28 -33
  30. package/dist/lib/resources/aws/base/awsStack.d.ts +8 -0
  31. package/dist/lib/resources/aws/base/awsStack.js +20 -17
  32. package/dist/lib/resources/aws/compute/ecs.d.ts +1 -1
  33. package/dist/lib/resources/aws/compute/ecs.js +1 -1
  34. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +1 -1
  35. package/dist/lib/resources/aws/compute/ecsNetworking.js +212 -47
  36. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +4 -4
  37. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +60 -9
  38. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +14 -1
  39. package/dist/lib/resources/aws/compute/ecsValidation.js +51 -0
  40. package/dist/lib/resources/aws/logging/logGroup.d.ts +6 -0
  41. package/dist/lib/resources/aws/logging/logGroup.js +11 -1
  42. package/dist/lib/resources/aws/networking/dnsRecord/aRecord.js +3 -2
  43. package/dist/lib/resources/aws/networking/dnsRecord/aaaaRecord.js +3 -2
  44. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +23 -1
  45. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +14 -3
  46. package/dist/lib/resources/aws/networking/dnsRecord/caaRecord.js +3 -2
  47. package/dist/lib/resources/aws/networking/dnsRecord/cnameRecord.js +3 -2
  48. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.d.ts +10 -1
  49. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +21 -4
  50. package/dist/lib/resources/aws/networking/dnsRecord/mxRecord.js +3 -2
  51. package/dist/lib/resources/aws/networking/dnsRecord/nsRecord.js +3 -2
  52. package/dist/lib/resources/aws/networking/dnsRecord/srvRecord.js +3 -2
  53. package/dist/lib/resources/aws/networking/dnsRecord/txtRecord.js +3 -2
  54. package/dist/lib/resources/aws/networking/domainCertificate.d.ts +28 -4
  55. package/dist/lib/resources/aws/networking/domainCertificate.js +24 -10
  56. package/dist/lib/resources/aws/networking/hostedZone.d.ts +11 -0
  57. package/dist/lib/resources/aws/networking/hostedZone.js +6 -0
  58. package/dist/lib/utils/costAllocationTags.d.ts +9 -0
  59. package/dist/lib/utils/costAllocationTags.js +6 -1
  60. package/dist/lib/utils/dnsRecordRegistry.d.ts +52 -0
  61. package/dist/lib/utils/dnsRecordRegistry.js +64 -0
  62. package/dist/lib/utils/domainTypes.d.ts +43 -36
  63. package/dist/lib/utils/domainTypes.js +65 -2
  64. package/dist/lib/utils/managedDomainContext.d.ts +37 -0
  65. package/dist/lib/utils/managedDomainContext.js +111 -0
  66. package/package.json +4 -4
package/dist/lib/app.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type CloudAssembly } from "aws-cdk-lib/cx-api";
3
3
  import { type Construct } from "constructs";
4
4
  import { type IVpc } from "aws-cdk-lib/aws-ec2";
5
5
  import { type Role } from "aws-cdk-lib/aws-iam";
6
- import { AwsStack } from "./resources/index.js";
6
+ import { AwsStack, type AwsStackProps } from "./resources/index.js";
7
7
  import { type Ecr } from "./resources/aws/storage/ecr.js";
8
8
  import { type AnyDatabase } from "./patterns/aws/database.js";
9
9
  import { type INetworkProps, type Network } from "./patterns/aws/network.js";
@@ -123,8 +123,10 @@ export declare class App extends CdkApp {
123
123
  *
124
124
  * @param key - The key of the stack
125
125
  * @param dependencies - The stack(s) that this stack depends on
126
+ * @param props - Stack props applied on first creation only (cached
127
+ * stacks are returned as-is)
126
128
  */
127
- getStack(key: string, dependencies?: AwsStack | AwsStack[]): AwsStack;
129
+ getStack(key: string, dependencies?: AwsStack | AwsStack[], props?: AwsStackProps): AwsStack;
128
130
  /**
129
131
  * Retrieve default compute stack - named as `${this.name}Compute`
130
132
  *
@@ -162,6 +164,21 @@ export declare class App extends CdkApp {
162
164
  * require VPC, but we maintain consistent stack dependency patterns.
163
165
  */
164
166
  getDefaultMessagingStack(): AwsStack;
167
+ /**
168
+ * Retrieve the app-owned us-east-1 certificate stack — named
169
+ * `${this.name}UsEast1Certificates` (domain gold-plating D3, BYO path).
170
+ *
171
+ * CloudFront accepts viewer certificates from us-east-1 only, so
172
+ * CloudFront-consuming patterns (staticsite, payload) whose app deploys to
173
+ * another region provision their DomainCertificate here; the distribution
174
+ * in the Cdn stack consumes the ARN through CDK cross-region references.
175
+ *
176
+ * `crossRegionReferences: true` must be set on BOTH the producing and the
177
+ * consuming stack; both get it at construction (the Cdn stack in
178
+ * getDefaultCdnStack, this stack here) and the emitted
179
+ * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
180
+ */
181
+ getUsEast1CertificateStack(): AwsStack;
165
182
  /**
166
183
  * Get a VPC by name. If no name is provided, returns the default VPC.
167
184
  *
package/dist/lib/app.js CHANGED
@@ -18,8 +18,10 @@ import AuditRoleFactory from "./resources/aws/audit/auditRole.js";
18
18
  import { FJALL_AUDIT_CONFIG } from "./config/audit.js";
19
19
  import { FjallLogger } from "./utils/validationLogger.js";
20
20
  import { getManifestCollector, writeManifest } from "./utils/manifestWriter.js";
21
+ import { resetDnsRecordRegistry } from "./utils/dnsRecordRegistry.js";
21
22
  import { toPascalCase, toKebab } from "./utils/capitaliseString.js";
22
23
  import { COST_ALLOCATION_TAGS } from "./utils/costAllocationTags.js";
24
+ import { DEFAULT_ORG_ID, resolveOrgId } from "./utils/cdkContext.js";
23
25
  /**
24
26
  * The basic corner-stone of all Fjall-hosted applications.
25
27
  * This class is a singleton and should be used to create and manage
@@ -167,6 +169,10 @@ export class App extends CdkApp {
167
169
  }
168
170
  static resetForTesting() {
169
171
  App.instance = null;
172
+ // App-scoped synth state resets with the App singleton — the DNS
173
+ // record-collision registry (D5) follows the manifest-collector lifecycle
174
+ // without requiring a third per-test reset call.
175
+ resetDnsRecordRegistry();
170
176
  }
171
177
  /**
172
178
  * Retrieve a stack by key. If the stack does not exist, it will be created.
@@ -174,8 +180,10 @@ export class App extends CdkApp {
174
180
  *
175
181
  * @param key - The key of the stack
176
182
  * @param dependencies - The stack(s) that this stack depends on
183
+ * @param props - Stack props applied on first creation only (cached
184
+ * stacks are returned as-is)
177
185
  */
178
- getStack(key, dependencies) {
186
+ getStack(key, dependencies, props) {
179
187
  // Apply the aspect once before creating the first stack
180
188
  if (!this.aspectApplied &&
181
189
  Object.keys(this.stacks).length === 0 &&
@@ -183,7 +191,7 @@ export class App extends CdkApp {
183
191
  this.applyTagsAspect();
184
192
  }
185
193
  if (!this.stacks[key]) {
186
- this.stacks[key] = new AwsStack(key, dependencies);
194
+ this.stacks[key] = new AwsStack(key, dependencies, props);
187
195
  }
188
196
  return this.stacks[key];
189
197
  }
@@ -224,7 +232,10 @@ export class App extends CdkApp {
224
232
  * stack is synthesised.
225
233
  */
226
234
  getDefaultCdnStack() {
227
- return this.getStack(`${this.stackPrefix}Cdn`, this.networkDisabled ? undefined : this.getDefaultNetworkStack());
235
+ // crossRegionReferences is enabled at creation so the Cdn stack can
236
+ // consume a us-east-1 certificate (domain gold-plating D3, BYO path).
237
+ // The flag is inert unless a cross-region reference is synthesised.
238
+ return this.getStack(`${this.stackPrefix}Cdn`, this.networkDisabled ? undefined : this.getDefaultNetworkStack(), { crossRegionReferences: true });
228
239
  }
229
240
  /**
230
241
  * Retrieve default messaging stack - named as `${this.name}Messaging`
@@ -236,6 +247,39 @@ export class App extends CdkApp {
236
247
  getDefaultMessagingStack() {
237
248
  return this.getStack(`${this.stackPrefix}Messaging`, this.getDefaultNetworkStack());
238
249
  }
250
+ /**
251
+ * Retrieve the app-owned us-east-1 certificate stack — named
252
+ * `${this.name}UsEast1Certificates` (domain gold-plating D3, BYO path).
253
+ *
254
+ * CloudFront accepts viewer certificates from us-east-1 only, so
255
+ * CloudFront-consuming patterns (staticsite, payload) whose app deploys to
256
+ * another region provision their DomainCertificate here; the distribution
257
+ * in the Cdn stack consumes the ARN through CDK cross-region references.
258
+ *
259
+ * `crossRegionReferences: true` must be set on BOTH the producing and the
260
+ * consuming stack; both get it at construction (the Cdn stack in
261
+ * getDefaultCdnStack, this stack here) and the emitted
262
+ * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
263
+ */
264
+ getUsEast1CertificateStack() {
265
+ const key = `${this.stackPrefix}UsEast1Certificates`;
266
+ if (!this.stacks[key]) {
267
+ const account = process.env.CDK_DEFAULT_ACCOUNT;
268
+ if (!account) {
269
+ throw new Error(`App '${this.name}': the us-east-1 certificate stack needs a ` +
270
+ "concrete account (got CDK_DEFAULT_ACCOUNT undefined). " +
271
+ "Cross-region references resolve only between env-pinned stacks " +
272
+ "— deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT " +
273
+ "and CDK_DEFAULT_REGION before synthesising.");
274
+ }
275
+ this.stacks[key] = new AwsStack(key, undefined, {
276
+ accountId: account,
277
+ region: "us-east-1",
278
+ crossRegionReferences: true
279
+ });
280
+ }
281
+ return this.stacks[key];
282
+ }
239
283
  /**
240
284
  * Get a VPC by name. If no name is provided, returns the default VPC.
241
285
  *
@@ -702,6 +746,15 @@ export class App extends CdkApp {
702
746
  }
703
747
  applyTagsAspect() {
704
748
  if (!this.aspectApplied && Object.keys(this.globalTags).length > 0) {
749
+ // Owner fallback resolves lazily HERE, not in initialiseStandardTags:
750
+ // at construction the orgId context may not be set yet, and an explicit
751
+ // addTags owner must win — by this point it is already in globalTags.
752
+ if (this.globalTags[COST_ALLOCATION_TAGS.OWNER] === undefined) {
753
+ const orgId = resolveOrgId(this.node);
754
+ if (orgId !== undefined && orgId !== DEFAULT_ORG_ID) {
755
+ this.globalTags[COST_ALLOCATION_TAGS.OWNER] = orgId;
756
+ }
757
+ }
705
758
  // Apply standard tags using Tags.of(this).add()
706
759
  for (const [key, value] of Object.entries(this.globalTags)) {
707
760
  Tags.of(this).add(key, value);
@@ -748,7 +801,11 @@ export class App extends CdkApp {
748
801
  return this.manifestCollector;
749
802
  }
750
803
  synth(options) {
751
- // Call parent synth first
804
+ // Tagging is an invariant of synthesis, not of getStack(): governance
805
+ // stacks (Organisation/Account/Platform) and raw `extends Stack` classes
806
+ // attach directly to the App and never call getStack(), so without this
807
+ // they synthesise untagged. Idempotent via the aspectApplied guard.
808
+ this.applyTagsAspect();
752
809
  const assembly = super.synth(options);
753
810
  // After synthesis, write Fjall manifest to cdk.out
754
811
  try {
@@ -1,4 +1,4 @@
1
- import { CfnOutput, Stack } from "aws-cdk-lib";
1
+ import { CfnOutput, Stack, Tags } from "aws-cdk-lib";
2
2
  import { EcrDefaultImage, SharedAlarmTopic } from "../../config/aws/index.js";
3
3
  import { ManagementEventsTrail } from "../../config/aws/cloudTrail.js";
4
4
  import { OidcConnector } from "../../config/aws/oidcConnector.js";
@@ -6,6 +6,7 @@ import { AccountMonitoringRole } from "../../config/aws/accountMonitoringRole.js
6
6
  import { AccountAuditRole } from "../../config/aws/accountAuditRole.js";
7
7
  import { CDK_CONTEXT_KEYS, resolveAccountTrailState } from "../../utils/cdkContext.js";
8
8
  import { getConfig } from "../../utils/getConfig.js";
9
+ import { COST_ALLOCATION_TAGS } from "../../utils/costAllocationTags.js";
9
10
  import { DisasterRecovery } from "../../config/aws/disasterRecovery.js";
10
11
  import { S3BlockPublicAccess } from "../../config/aws/s3BlockPublicAccess.js";
11
12
  import { EbsDefaultEncryption } from "../../config/aws/ebsDefaultEncryption.js";
@@ -28,6 +29,10 @@ export class Account extends Stack {
28
29
  }
29
30
  const env = props.env ?? { region, account: accountId };
30
31
  super(scope, id, { ...props, env });
32
+ // Governance apps never name the App, so the App-level service tag would
33
+ // read "FjallApp"; the stack-scope tag wins over App scope and names the
34
+ // governance tier instead ("Organisation"/"Account"/"Platform").
35
+ Tags.of(this).add(COST_ALLOCATION_TAGS.SERVICE, id);
31
36
  this.resolvedRegion = region ?? this.region;
32
37
  const orgId = this.node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
33
38
  if (orgId) {
@@ -1,26 +1,25 @@
1
1
  import type { Construct } from "constructs";
2
2
  import type { IHostedZone } from "aws-cdk-lib/aws-route53";
3
3
  import type { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
4
- import type { ManualRecord, Route53ApexProps, SubdomainDelegation } from "./interfaces/domain.js";
4
+ import type { ManualRecord, Route53ApexProps } from "./interfaces/domain.js";
5
5
  export interface ApexDomainPatternResult {
6
6
  readonly hostedZone: IHostedZone;
7
7
  readonly certificates: Map<string, ICertificate>;
8
8
  readonly nameServers: string[] | undefined;
9
9
  readonly manualRecords: ManualRecord[];
10
- readonly delegations: SubdomainDelegation[];
11
10
  }
12
11
  /**
13
12
  * Composition for `registrar: "route53"`. Creates (or imports) the apex
14
- * `HostedZone`, wires child-account delegations via `NsRecord` (pointing at
15
- * each child's nameservers published as exports), and composes all user
16
- * records + certificates.
13
+ * `HostedZone` and composes all user records + certificates.
17
14
  *
18
- * Delegation mechanism: the child Fjall account scaffolds its own
19
- * `HostedZone`, and the parent simply writes NS records pointing at the
20
- * child's nameservers (Fn.importValue of the child stack's nameservers
21
- * output). This differs from the legacy `DomainDelegation` pattern, which
22
- * uses `CrossAccountZoneDelegationRecord` to create the child HZ from the
23
- * parent via IAM assume-role. Both patterns are valid for their respective
24
- * semantics see R10 in the Phase 1 plan.
15
+ * Delegation direction (D8): child writes NS. The apex zone's contribution
16
+ * to delegation is the org-gated `DelegationRole` the `HostedZone` wrapper
17
+ * creates on the create path — the delegated child `Domain` assumes that
18
+ * role by LITERAL ARN (`parentDelegationRoleArn`) and UPSERTs its own NS
19
+ * records into this zone via `CrossAccountZoneDelegationRecord`. The old
20
+ * parent-writes `delegations[]` path (NS records from `Fn.importValue` of
21
+ * child nameserver exports) was removed with the H13 legacy layer: it was
22
+ * export-locked, child-first-deploy-ordered, and removal required a parent
23
+ * redeploy.
25
24
  */
26
25
  export declare function composeApexDomain(scope: Construct, props: Route53ApexProps): ApexDomainPatternResult;
@@ -1,24 +1,21 @@
1
- import { Fn } from "aws-cdk-lib";
2
- import { getDomainExportNames } from "@fjall/util";
3
1
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
4
2
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
5
- import { NsRecord } from "../../resources/aws/networking/dnsRecord/index.js";
6
3
  import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
7
4
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
8
5
  import { resolveOrgId } from "../../utils/cdkContext.js";
9
6
  /**
10
7
  * Composition for `registrar: "route53"`. Creates (or imports) the apex
11
- * `HostedZone`, wires child-account delegations via `NsRecord` (pointing at
12
- * each child's nameservers published as exports), and composes all user
13
- * records + certificates.
8
+ * `HostedZone` and composes all user records + certificates.
14
9
  *
15
- * Delegation mechanism: the child Fjall account scaffolds its own
16
- * `HostedZone`, and the parent simply writes NS records pointing at the
17
- * child's nameservers (Fn.importValue of the child stack's nameservers
18
- * output). This differs from the legacy `DomainDelegation` pattern, which
19
- * uses `CrossAccountZoneDelegationRecord` to create the child HZ from the
20
- * parent via IAM assume-role. Both patterns are valid for their respective
21
- * semantics see R10 in the Phase 1 plan.
10
+ * Delegation direction (D8): child writes NS. The apex zone's contribution
11
+ * to delegation is the org-gated `DelegationRole` the `HostedZone` wrapper
12
+ * creates on the create path — the delegated child `Domain` assumes that
13
+ * role by LITERAL ARN (`parentDelegationRoleArn`) and UPSERTs its own NS
14
+ * records into this zone via `CrossAccountZoneDelegationRecord`. The old
15
+ * parent-writes `delegations[]` path (NS records from `Fn.importValue` of
16
+ * child nameserver exports) was removed with the H13 legacy layer: it was
17
+ * export-locked, child-first-deploy-ordered, and removal required a parent
18
+ * redeploy.
22
19
  */
23
20
  export function composeApexDomain(scope, props) {
24
21
  const safeZone = toPascalCase(getSafeZoneName(props.zoneName));
@@ -51,22 +48,6 @@ export function composeApexDomain(scope, props) {
51
48
  if (props.records && props.records.length > 0) {
52
49
  composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records);
53
50
  }
54
- const delegations = props.delegations ?? [];
55
- delegations.forEach((delegation, index) => {
56
- const childZoneName = `${delegation.subdomain}.${props.zoneName}`;
57
- const safeChild = toPascalCase(getSafeZoneName(childZoneName));
58
- const childExports = getDomainExportNames(childZoneName);
59
- // Cross-phase dependency: child stack must deploy first; CFN fails here
60
- // at deploy if the nameservers export does not yet exist.
61
- const nameserversToken = Fn.importValue(childExports.nameservers);
62
- new NsRecord(scope, `${safeZone}Delegation${safeChild}${index}`, {
63
- zone: hostedZoneConstruct.hostedZone,
64
- zoneName: props.zoneName,
65
- recordName: delegation.subdomain,
66
- // Fn.split returns a list token suitable for a multi-value NS record.
67
- values: Fn.split(",", nameserversToken)
68
- });
69
- });
70
51
  return {
71
52
  hostedZone: hostedZoneConstruct.hostedZone,
72
53
  certificates,
@@ -75,8 +56,7 @@ export function composeApexDomain(scope, props) {
75
56
  nameServers: hostedZoneConstruct.isImported
76
57
  ? undefined
77
58
  : (hostedZoneConstruct.nameServers ?? undefined),
78
- manualRecords: [],
79
- delegations
59
+ manualRecords: []
80
60
  };
81
61
  }
82
62
  function normaliseCertificate(cert) {
@@ -1,23 +1,38 @@
1
- import { Fn } from "aws-cdk-lib";
1
+ import { Fn, Token } from "aws-cdk-lib";
2
2
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
3
3
  import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
4
4
  import { isStorage } from "./storage.js";
5
5
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
6
- import { FjallLogger } from "../../utils/validationLogger.js";
7
6
  /**
8
- * Validates CDN props and logs warnings for misconfigured options.
7
+ * Validates CDN props synth-time hard errors for shapes CloudFormation
8
+ * would only reject at deploy.
9
9
  */
10
10
  function validateCdnProps(props) {
11
11
  const hasCertificate = !!(props.certificate || props.certificateArn);
12
- // Validate certificate region warning
13
- if (hasCertificate && props.domainNames && props.domainNames.length > 0) {
14
- FjallLogger.info("CloudFront certificates must be in us-east-1 region. " +
15
- "Ensure your certificate was created in us-east-1.");
12
+ // CloudFront accepts viewer certificates from us-east-1 only. A literal
13
+ // ARN is inspectable at synth (design H3/D3 the old advisory log is
14
+ // replaced by real us-east-1 provisioning in the patterns; this assert
15
+ // covers the BYO-ARN escape hatch), so fail here with the cure rather
16
+ // than at CloudFormation with InvalidViewerCertificate.
17
+ if (props.certificateArn !== undefined &&
18
+ !Token.isUnresolved(props.certificateArn)) {
19
+ const certificateRegion = props.certificateArn.split(":")[3];
20
+ if (certificateRegion !== "us-east-1") {
21
+ throw new Error(`CDN 'certificateArn' is in region '${certificateRegion}' ` +
22
+ `(got '${props.certificateArn}'). CloudFront accepts viewer ` +
23
+ "certificates from us-east-1 only — pass the ARN of a us-east-1 " +
24
+ "certificate, or set the pattern's domain zone identity so it " +
25
+ "provisions one.");
26
+ }
16
27
  }
17
- // Validate domain names require certificate
28
+ // Aliases without a viewer certificate fail at CloudFormation deploy; fail
29
+ // at synth with the cure instead.
18
30
  if (props.domainNames && props.domainNames.length > 0 && !hasCertificate) {
19
- FjallLogger.warn("'domainNames' provided but no 'certificate' specified. " +
20
- "Custom domain names require an ACM certificate in us-east-1.");
31
+ throw new Error(`CDN 'domainNames' [${props.domainNames.join(", ")}] were provided ` +
32
+ "without a certificate. CloudFront serves custom domain names only " +
33
+ "with a us-east-1 ACM certificate — pass 'certificate' or " +
34
+ "'certificateArn', or configure the pattern's 'domain' with a zone " +
35
+ "identity so it provisions one.");
21
36
  }
22
37
  // Validate ALB origin
23
38
  if (props.originType === "alb") {
@@ -13,6 +13,7 @@ import { resolveImportedSecret } from "../../resources/aws/secrets/index.js";
13
13
  import App from "../../app.js";
14
14
  import EcsCluster from "../../resources/aws/compute/ecs.js";
15
15
  import { createScheduledTaskDefinition, createMigrationTaskDefinition } from "../../resources/aws/compute/ecsTaskDefinition.js";
16
+ import { DEFAULT_LOG_RETENTION } from "../../resources/aws/compute/ecsConstants.js";
16
17
  import { EcsLifecycleHookMigration } from "../../resources/aws/compute/ecsLifecycleHookMigration.js";
17
18
  import { Role } from "../../resources/aws/iam/role.js";
18
19
  import { LogGroup } from "../../resources/aws/logging/logGroup.js";
@@ -24,7 +25,7 @@ import { FjallLogger } from "../../utils/validationLogger.js";
24
25
  import { VALIDATION_PATTERNS } from "@fjall/generator";
25
26
  import { evaluateBakeGuard } from "@fjall/util/docker";
26
27
  import { toKebab, buildParameterPath } from "@fjall/util";
27
- import { validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
28
+ import { validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
28
29
  import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
29
30
  import { isHookMigrations } from "./computeEcsTypes.js";
30
31
  export { ScalingType } from "./computeEcsTypes.js";
@@ -80,6 +81,11 @@ function validateSeparateTaskDef(serviceName, separateTaskDef) {
80
81
  * Extracted for clarity and detail parity with database/network patterns.
81
82
  */
82
83
  export function validateEcsProps(props) {
84
+ // Mirror of the resources-layer domainConfig checks (H8 legacy `region`
85
+ // rejection, routingPolicy shape) — defence-in-depth per generator-standards
86
+ // § "Validate at the Lowest Layer the Field Belongs To". The cluster name is
87
+ // the construct id, unavailable here, so errors prefix with the domain name.
88
+ validateEcsDomainConfig(props.cluster?.domainConfig);
83
89
  // Validate services array exists and is not empty
84
90
  if (!props.services || props.services.length === 0) {
85
91
  throw new Error("At least one service must be specified in 'services' array.");
@@ -924,13 +930,16 @@ export class EcsCompute extends Construct {
924
930
  });
925
931
  }
926
932
  }
933
+ // `logRetention` must not reach the driver: AwsLogDriver.bind() creates
934
+ // a raw RETAIN LogGroup inside aws-cdk-lib, escaping the env-aware
935
+ // removal default (D17).
936
+ const logGroup = entry.logGroup ??
937
+ new LogGroup(this, `${id}${toPascalCase(entry.name)}LogGroup`, {
938
+ retention: entry.logRetention ?? DEFAULT_LOG_RETENTION
939
+ });
927
940
  const logging = new AwsLogDriver({
928
941
  streamPrefix: `/ecs-scheduled/${id}/${entry.name}`,
929
- ...(entry.logGroup !== undefined && { logGroup: entry.logGroup }),
930
- ...(entry.logGroup === undefined &&
931
- entry.logRetention !== undefined && {
932
- logRetention: entry.logRetention
933
- })
942
+ logGroup
934
943
  });
935
944
  const container = taskDef.addContainer(`${id}${toPascalCase(entry.name)}Container`, {
936
945
  image: entry.image,
@@ -501,7 +501,11 @@ export interface EcsScheduledTaskConfig {
501
501
  secrets?: Record<string, EcsSecret>;
502
502
  /** Pre-existing CDK log group. Mutually exclusive with `logRetention`. */
503
503
  logGroup?: ILogGroup;
504
- /** When `logGroup` is omitted, the awsLogs driver creates one with this retention. */
504
+ /**
505
+ * When `logGroup` is omitted, an explicit framework log group is created
506
+ * with this retention (default: `DEFAULT_LOG_RETENTION`) and the env-aware
507
+ * removal default (D17).
508
+ */
505
509
  logRetention?: RetentionDays;
506
510
  securityGroups?: ISecurityGroup[];
507
511
  subnetSelection?: SubnetSelection;
@@ -518,6 +522,10 @@ export interface EcsClusterConfig {
518
522
  * Domain for HTTPS access.
519
523
  * - Omit: ALB created with default DNS (*.elb.amazonaws.com)
520
524
  * - Specified: Creates ACM certificate + Route53 DNS A record
525
+ *
526
+ * Requires `domainConfig` to name a zone source (`managedDomain` or
527
+ * `hostedZone`) — Fjall never creates a hosted zone inside an app stack
528
+ * (design D1/H1), so a bare `domain` with no zone source throws at synth.
521
529
  */
522
530
  domain?: string;
523
531
  /**
@@ -534,9 +542,10 @@ export interface EcsClusterConfig {
534
542
  */
535
543
  directAccess?: boolean;
536
544
  /**
537
- * Advanced domain configuration for routing policies (latency, weighted, geo).
538
- * Only used when domain is specified.
539
- * Allows for multi-region deployments with advanced DNS routing.
545
+ * Domain configuration: zone source (managed domain or imported zone),
546
+ * certificate, and routing policy (latency via `routingPolicy`, weighted,
547
+ * geo). Required for any custom domain; enables multi-region deployments
548
+ * with advanced DNS routing.
540
549
  */
541
550
  domainConfig?: DomainConfig;
542
551
  /**
@@ -9,9 +9,18 @@ export interface DelegatedDomainPatternResult {
9
9
  readonly manualRecords: ManualRecord[];
10
10
  }
11
11
  /**
12
- * Composition for `registrar: "external-delegated"`. Creates a hosted zone
13
- * for `{delegatedSubdomain}.{zoneName}` — the user adds NS records at their
14
- * external registrar to delegate the subdomain to Fjall. No cross-account
15
- * delegation role is required because Fjall is not creating the parent zone.
12
+ * Composition for `registrar: "external-delegated"` the delegated-child
13
+ * topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}` and,
14
+ * when `parentDelegationRoleArn` is present, UPSERTs the child NS into the
15
+ * parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
16
+ * delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
17
+ * literal — validated in `validateDomainProps` (VD2-a). When absent, the
18
+ * user delegates out of band (external registrar / manual NS) and a synth
19
+ * warning flags that the zone will not resolve until they do.
20
+ *
21
+ * `phase: "zone"` early-returns before certificate issuance (R2 cert-hang
22
+ * guard, shared `DomainDeployPhase` contract with `DevSubstrate`): issuing a
23
+ * DNS-validated certificate before the delegation has propagated hangs ACM
24
+ * until CloudFormation rolls back (~1 h).
16
25
  */
17
26
  export declare function composeDelegatedDomain(scope: Construct, props: ExternalDelegatedProps): DelegatedDomainPatternResult;
@@ -1,22 +1,75 @@
1
+ import { Annotations } from "aws-cdk-lib";
2
+ import { Role } from "../../resources/aws/iam/index.js";
1
3
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
4
+ import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
2
5
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
3
6
  import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
4
7
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
8
+ import { DOMAIN_DEPLOY_DEFAULT_PHASE } from "../../utils/domainTypes.js";
5
9
  /**
6
- * Composition for `registrar: "external-delegated"`. Creates a hosted zone
7
- * for `{delegatedSubdomain}.{zoneName}` — the user adds NS records at their
8
- * external registrar to delegate the subdomain to Fjall. No cross-account
9
- * delegation role is required because Fjall is not creating the parent zone.
10
+ * Composition for `registrar: "external-delegated"` the delegated-child
11
+ * topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}` and,
12
+ * when `parentDelegationRoleArn` is present, UPSERTs the child NS into the
13
+ * parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
14
+ * delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
15
+ * literal — validated in `validateDomainProps` (VD2-a). When absent, the
16
+ * user delegates out of band (external registrar / manual NS) and a synth
17
+ * warning flags that the zone will not resolve until they do.
18
+ *
19
+ * `phase: "zone"` early-returns before certificate issuance (R2 cert-hang
20
+ * guard, shared `DomainDeployPhase` contract with `DevSubstrate`): issuing a
21
+ * DNS-validated certificate before the delegation has propagated hangs ACM
22
+ * until CloudFormation rolls back (~1 h).
10
23
  */
11
24
  export function composeDelegatedDomain(scope, props) {
12
25
  const effectiveZone = `${props.delegatedSubdomain}.${props.zoneName}`;
13
26
  const safeZone = toPascalCase(getSafeZoneName(effectiveZone));
14
27
  const hostedZoneConstruct = new HostedZone(scope, `${safeZone}HostedZone`, {
15
28
  zoneName: effectiveZone,
29
+ // The PARENT account owns the delegation role — this is the child.
16
30
  createDelegationRole: false,
17
31
  costAllocationEnvironment: props.costAllocationEnvironment,
18
32
  costAllocationDomain: props.zoneName
19
33
  });
34
+ // D8 child-writes delegation. The in-stack custom resource assumes the
35
+ // parent account's DelegationRole by LITERAL ARN (Role.fromRoleArn) — NOT
36
+ // Fn.importValue, which resolves same-account only (VD2-a). The parent
37
+ // DelegationRole trusts the whole org (OrganizationPrincipal), so a child
38
+ // account can assume it. The CLI injects the ARN via
39
+ // ManagedDomainBinding.delegationRoleArn at deploy time.
40
+ if (props.parentDelegationRoleArn !== undefined) {
41
+ const parentRole = Role.fromRoleArn(scope, `${safeZone}ParentDelegationRole`, props.parentDelegationRoleArn);
42
+ new CrossAccountDelegationRecord(scope, `${safeZone}DelegationRecord`, {
43
+ delegationRole: parentRole,
44
+ delegatedZone: hostedZoneConstruct.hostedZone,
45
+ delegatedZoneName: effectiveZone,
46
+ parentHostedZoneName: props.zoneName,
47
+ costAllocationEnvironment: props.costAllocationEnvironment,
48
+ costAllocationDomain: props.zoneName
49
+ });
50
+ }
51
+ else {
52
+ Annotations.of(scope).addWarning(`Zone '${effectiveZone}' will not resolve until it is delegated: no ` +
53
+ `parentDelegationRoleArn was provided, so no NS records are written ` +
54
+ `into '${props.zoneName}'. Deploy via the Fjall CLI (which injects ` +
55
+ `the parent DelegationRole ARN from the managed-domain binding) or ` +
56
+ `add the zone's NS records at the parent registrar manually.`);
57
+ }
58
+ const nameServers = hostedZoneConstruct.nameServers ?? [];
59
+ const records = props.records ?? [];
60
+ if (records.length > 0) {
61
+ composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records);
62
+ }
63
+ // Step 2 of the R2 two-step gate: issuing the cert before the delegation
64
+ // NS has propagated hangs ACM DNS-validation, so "zone" stops here.
65
+ if ((props.phase ?? DOMAIN_DEPLOY_DEFAULT_PHASE) !== "full") {
66
+ return {
67
+ hostedZone: hostedZoneConstruct.hostedZone,
68
+ certificates: new Map(),
69
+ nameServers,
70
+ manualRecords: []
71
+ };
72
+ }
20
73
  const certificates = new Map();
21
74
  (props.certificates ?? []).forEach((cert, index) => {
22
75
  const normalised = normaliseCertificate(cert);
@@ -31,10 +84,6 @@ export function composeDelegatedDomain(scope, props) {
31
84
  });
32
85
  certificates.set(normalised.domainName, dc.certificate);
33
86
  });
34
- if (props.records && props.records.length > 0) {
35
- composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, props.records);
36
- }
37
- const nameServers = hostedZoneConstruct.nameServers ?? [];
38
87
  return {
39
88
  hostedZone: hostedZoneConstruct.hostedZone,
40
89
  certificates,
@@ -1,5 +1,6 @@
1
1
  import { Construct } from "constructs";
2
2
  import type App from "../../app.js";
3
+ import { type DomainDeployPhase } from "../../utils/domainTypes.js";
3
4
  /**
4
5
  * Props for the internal `DevSubstrate` construct.
5
6
  *
@@ -44,7 +45,7 @@ export interface IDevSubstrateProps {
44
45
  * only after the delegated NS has propagated, else ACM DNS-validation hangs.
45
46
  * Defaults to `"full"`. Inert unless `domain` is set.
46
47
  */
47
- readonly phase?: "zone" | "full";
48
+ readonly phase?: DomainDeployPhase;
48
49
  /**
49
50
  * Public domain the substrate hangs a `dev.<appDomain>` child zone under. When
50
51
  * omitted, the substrate stands up no zone/cert (early milestone — G2 reaches