@fjall/components-infrastructure 3.0.0 → 3.2.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.
Files changed (30) hide show
  1. package/dist/lib/app.d.ts +24 -0
  2. package/dist/lib/app.js +46 -0
  3. package/dist/lib/config/aws/alarmTopic.d.ts +7 -0
  4. package/dist/lib/config/aws/alarmTopic.js +9 -2
  5. package/dist/lib/patterns/aws/apexDomainPattern.d.ts +7 -7
  6. package/dist/lib/patterns/aws/apexDomainPattern.js +10 -14
  7. package/dist/lib/patterns/aws/buildkite/alarms.d.ts +25 -0
  8. package/dist/lib/patterns/aws/buildkite/alarms.js +78 -0
  9. package/dist/lib/patterns/aws/buildkite/buildkite.d.ts +46 -0
  10. package/dist/lib/patterns/aws/buildkite/buildkite.js +240 -0
  11. package/dist/lib/patterns/aws/buildkite/iam.d.ts +35 -0
  12. package/dist/lib/patterns/aws/buildkite/iam.js +177 -0
  13. package/dist/lib/patterns/aws/buildkite/pins.d.ts +69 -0
  14. package/dist/lib/patterns/aws/buildkite/pins.js +80 -0
  15. package/dist/lib/patterns/aws/buildkite/schema.d.ts +81 -0
  16. package/dist/lib/patterns/aws/buildkite/schema.js +155 -0
  17. package/dist/lib/patterns/aws/buildkite/userData.d.ts +58 -0
  18. package/dist/lib/patterns/aws/buildkite/userData.js +226 -0
  19. package/dist/lib/patterns/aws/buildkite.d.ts +11 -59
  20. package/dist/lib/patterns/aws/buildkite.js +11 -310
  21. package/dist/lib/patterns/aws/devSubstrate.d.ts +26 -15
  22. package/dist/lib/patterns/aws/devSubstrate.js +47 -27
  23. package/dist/lib/resources/aws/compute/ec2.d.ts +15 -0
  24. package/dist/lib/resources/aws/compute/ec2.js +20 -1
  25. package/dist/lib/resources/aws/monitoring/buildkiteAlarms.d.ts +29 -0
  26. package/dist/lib/resources/aws/monitoring/buildkiteAlarms.js +90 -0
  27. package/dist/lib/resources/aws/monitoring/index.d.ts +1 -0
  28. package/dist/lib/resources/aws/monitoring/index.js +1 -0
  29. package/dist/lib/resources/aws/networking/hostedZone.js +23 -19
  30. package/package.json +4 -4
@@ -40,27 +40,38 @@ export interface IDevSubstrateProps {
40
40
  readonly adoptSlotEcr?: boolean;
41
41
  /**
42
42
  * Deploy phase for the R2 two-step cert-hang guard. `"zone"` synthesises the
43
- * `dev.<appDomain>` child zone + cross-account NS delegation only; `"full"`
44
- * additionally builds the wildcard cert + :443 listener (A6) — deploy that
45
- * only after the delegated NS has propagated, else ACM DNS-validation hangs.
46
- * Defaults to `"full"`. Inert unless `domain` is set.
43
+ * `devDomain` zone + cross-account NS delegation only; `"full"` additionally
44
+ * builds the wildcard cert + :443 listener (A6) — deploy that only after the
45
+ * delegated NS has propagated, else ACM DNS-validation hangs. Defaults to
46
+ * `"full"`. Inert unless `domain` is set.
47
47
  */
48
48
  readonly phase?: DomainDeployPhase;
49
49
  /**
50
- * Public domain the substrate hangs a `dev.<appDomain>` child zone under. When
51
- * omitted, the substrate stands up no zone/cert (early milestone — G2 reaches
52
- * slots via `curl --resolve`).
50
+ * The zone slots serve under an EXPLICIT name (`devDomain`, e.g.
51
+ * `development.fjall.io`), never derived by prefixing. When omitted, the
52
+ * substrate stands up no zone/cert (early milestone — G2 reaches slots via
53
+ * `curl --resolve`).
53
54
  *
54
- * `parentDelegationRoleArn`, when present, wires the same-org **Case-1** in-stack
55
- * NS delegation (design VD2-a): the child stack's `CrossAccountZoneDelegationRecord`
56
- * assumes the parent account's `DelegationRole` by LITERAL ARN and UPSERTs the
57
- * child NS into the parent zone. Omit it for a plain externally-owned parent
58
- * (Case 3 — manual one-off delegation) or a connected different-org parent
59
- * (Case 2 worker UPSERT, B3b, deferred): the substrate then emits only the
60
- * zone + its NS export for out-of-band delegation.
55
+ * Adopt mode (`hostedZoneId` present): the substrate adopts an existing zone
56
+ * (e.g. the account's delegated Domain) by import no zone resource, no
57
+ * delegation record, no NS export. Mutually exclusive with `parentDomain` /
58
+ * `parentDelegationRoleArn`.
59
+ *
60
+ * Create mode (no `hostedZoneId`): the substrate creates the `devDomain`
61
+ * zone. `parentDelegationRoleArn`, when present, wires the same-org **Case-1**
62
+ * in-stack NS delegation (design VD2-a): the child stack's
63
+ * `CrossAccountZoneDelegationRecord` assumes the parent account's
64
+ * `DelegationRole` by LITERAL ARN and UPSERTs the child NS into the
65
+ * `parentDomain` zone — `parentDomain` (a proper DNS suffix of `devDomain`)
66
+ * MUST accompany it. Omit both for a plain externally-owned parent (Case 3 —
67
+ * manual one-off delegation) or a connected different-org parent (Case 2 —
68
+ * worker UPSERT, B3b, deferred): the substrate then emits only the zone + its
69
+ * NS export for out-of-band delegation.
61
70
  */
62
71
  readonly domain?: {
63
- readonly appDomain: string;
72
+ readonly devDomain: string;
73
+ readonly hostedZoneId?: string;
74
+ readonly parentDomain?: string;
64
75
  readonly parentDelegationRoleArn?: string;
65
76
  };
66
77
  }
@@ -4,6 +4,7 @@ import { Cluster } from "aws-cdk-lib/aws-ecs";
4
4
  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
+ import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
7
8
  import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
8
9
  import { Construct } from "constructs";
9
10
  import { readFileSync } from "node:fs";
@@ -48,7 +49,7 @@ const DEV_SUBSTRATE_WAKER_ID = "DevSubstrateWaker";
48
49
  const DEV_SUBSTRATE_WAKER_TG_ID = "DevSubstrateWakerTargetGroup";
49
50
  const DEV_SUBSTRATE_WAKER_SOURCE_FILE = "devSubstrate.waker.source.cjs";
50
51
  const DEV_SUBSTRATE_WAKER_TIMEOUT_SECONDS = 30;
51
- /** Construct ids for the substrate's delegated `dev.<appDomain>` child zone. */
52
+ /** Construct ids for the substrate's `devDomain` slot zone (created or adopted). */
52
53
  const DEV_SUBSTRATE_ZONE_ID = "DevSubstrateZone";
53
54
  const DEV_SUBSTRATE_DELEGATION_ID = "DevSubstrateDelegation";
54
55
  const DEV_SUBSTRATE_PARENT_ROLE_ID = "DevSubstrateParentDelegationRole";
@@ -306,32 +307,51 @@ export class DevSubstrate extends Construct {
306
307
  #createDomain(props, loadBalancer) {
307
308
  if (props.domain === undefined)
308
309
  return {};
309
- const { appDomain, parentDelegationRoleArn } = props.domain;
310
- const childZoneName = `dev.${appDomain}`;
311
- // The delegated child zone: createDelegationRole:false because the PARENT
312
- // account owns the delegation role (this is the child). The HostedZone
313
- // wrapper's create path auto-emits the `<zone>-nameservers` CFN export the
314
- // parent consumes to point NS records at us no manual export needed.
315
- const childZone = new HostedZone(this, DEV_SUBSTRATE_ZONE_ID, {
316
- zoneName: childZoneName,
317
- createDelegationRole: false
318
- });
319
- // Case-1 same-org delegation fires only when a parent role ARN is supplied.
320
- // The in-stack custom resource assumes the parent account's DelegationRole by
321
- // LITERAL ARN (Role.fromRoleArn) NOT Fn.importValue, which resolves
322
- // same-account only and is exactly why the DomainDelegation pattern cannot go
323
- // cross-account (design VD2-a). The parent DelegationRole trusts the whole
324
- // org (OrganizationPrincipal), so a sibling dev account can assume it. Absent
325
- // ARN Case 2 (worker UPSERT, B3b) / Case 3 (manual), handled out of band:
326
- // the zone + NS export above is all the substrate emits.
327
- if (parentDelegationRoleArn !== undefined) {
328
- const parentRole = Role.fromRoleArn(this, DEV_SUBSTRATE_PARENT_ROLE_ID, parentDelegationRoleArn);
329
- new CrossAccountDelegationRecord(this, DEV_SUBSTRATE_DELEGATION_ID, {
330
- delegationRole: parentRole,
331
- delegatedZone: childZone.hostedZone,
332
- delegatedZoneName: childZoneName,
333
- parentHostedZoneName: appDomain
310
+ const { devDomain, hostedZoneId, parentDomain, parentDelegationRoleArn } = props.domain;
311
+ const childZoneName = devDomain;
312
+ let childZone;
313
+ if (hostedZoneId !== undefined) {
314
+ // Adopt mode: import only no zone resource, no delegation record, no NS
315
+ // export (the zone's NS was delegated when the account Domain deployed).
316
+ // Plain CDK import, NOT the HostedZone wrapper: its import path re-emits
317
+ // the global `<zone>-hosted-zone-id` export, colliding with the owning
318
+ // Domain stack's.
319
+ childZone = PublicHostedZone.fromHostedZoneAttributes(this, DEV_SUBSTRATE_ZONE_ID, { hostedZoneId, zoneName: childZoneName });
320
+ }
321
+ else {
322
+ // Create mode: createDelegationRole:false because the PARENT account owns
323
+ // the delegation role (this is the child). The HostedZone wrapper's create
324
+ // path auto-emits the `<zone>-nameservers` CFN export the parent consumes
325
+ // to point NS records at us no manual export needed.
326
+ const createdZone = new HostedZone(this, DEV_SUBSTRATE_ZONE_ID, {
327
+ zoneName: childZoneName,
328
+ createDelegationRole: false
334
329
  });
330
+ childZone = createdZone.hostedZone;
331
+ // Case-1 same-org delegation fires only when a parent role ARN is supplied.
332
+ // The in-stack custom resource assumes the parent account's DelegationRole by
333
+ // LITERAL ARN (Role.fromRoleArn) — NOT Fn.importValue, which resolves
334
+ // same-account only and is exactly why the DomainDelegation pattern cannot go
335
+ // cross-account (design VD2-a). The parent DelegationRole trusts the whole
336
+ // org (OrganizationPrincipal), so a sibling dev account can assume it. Absent
337
+ // ARN ⇒ Case 2 (worker UPSERT, B3b) / Case 3 (manual), handled out of band:
338
+ // the zone + NS export above is all the substrate emits.
339
+ if (parentDelegationRoleArn !== undefined) {
340
+ // Re-checked here because direct construct calls bypass the Zod schema.
341
+ if (parentDomain === undefined) {
342
+ throw new Error(`DevSubstrate domain "${devDomain}": parentDelegationRoleArn was ` +
343
+ `supplied without parentDomain. Set domain.parentDomain to the ` +
344
+ `parent zone the DelegationRole belongs to (a proper DNS suffix ` +
345
+ `of devDomain), or omit parentDelegationRoleArn.`);
346
+ }
347
+ const parentRole = Role.fromRoleArn(this, DEV_SUBSTRATE_PARENT_ROLE_ID, parentDelegationRoleArn);
348
+ new CrossAccountDelegationRecord(this, DEV_SUBSTRATE_DELEGATION_ID, {
349
+ delegationRole: parentRole,
350
+ delegatedZone: childZone,
351
+ delegatedZoneName: childZoneName,
352
+ parentHostedZoneName: parentDomain
353
+ });
354
+ }
335
355
  }
336
356
  // Step 2 of the R2 two-step gate: synthesising the cert before the step-1 NS
337
357
  // has propagated hangs ACM DNS-validation for hours, so "zone" stops here.
@@ -342,7 +362,7 @@ export class DevSubstrate extends Construct {
342
362
  // would otherwise render an invalid CFN export name.
343
363
  const certificate = new DomainCertificate(this, DEV_SUBSTRATE_CERT_ID, {
344
364
  domainName: `*.${childZoneName}`,
345
- hostedZone: childZone.hostedZone,
365
+ hostedZone: childZone,
346
366
  exportCertificateArn: false
347
367
  });
348
368
  // Unconditional 404 as on :80 (per-slot rules attach out of band, §5.4). open
@@ -122,6 +122,21 @@ export interface Ec2InstanceProps extends StackProps {
122
122
  * keys or values are rejected by `validateEc2InstanceProps`.
123
123
  */
124
124
  tags?: Record<string, string>;
125
+ /**
126
+ * CDK `AutoScalingGroupProps.maxInstanceLifetime` verbatim — ASG replaces
127
+ * instances older than this. CDK enforces the AWS 1–365 day bounds at
128
+ * synth. Absent → no lifetime cap (AWS default).
129
+ */
130
+ maxInstanceLifetime?: Duration;
131
+ /**
132
+ * CDK `AutoScalingGroupProps.ssmSessionPermissions`. Absent → `true`
133
+ * (existing wrapper behaviour). `true` attaches the
134
+ * `AmazonSSMManagedInstanceCore` managed policy to the instance role —
135
+ * which carries account-wide `ssm:GetParameter`. Least-privilege consumers
136
+ * that supply their own role with scoped Session Manager statements
137
+ * (Buildkite agents) pass `false` and keep the role allowlist-only.
138
+ */
139
+ ssmSessionPermissions?: boolean;
125
140
  }
126
141
  export declare class Ec2Instance extends Construct implements IConnectable {
127
142
  private launchTemplate;
@@ -151,6 +151,9 @@ export class Ec2Instance extends Construct {
151
151
  ...(props.capacityRebalance !== undefined && {
152
152
  capacityRebalance: props.capacityRebalance
153
153
  }),
154
+ ...(props.maxInstanceLifetime !== undefined && {
155
+ maxInstanceLifetime: props.maxInstanceLifetime
156
+ }),
154
157
  cooldown: Duration.seconds(60),
155
158
  groupMetrics: [GroupMetrics.all()],
156
159
  ...(resolvedUpdatePolicy !== undefined && {
@@ -165,7 +168,7 @@ export class Ec2Instance extends Construct {
165
168
  TerminationPolicy.OLDEST_LAUNCH_CONFIGURATION,
166
169
  TerminationPolicy.CLOSEST_TO_NEXT_INSTANCE_HOUR
167
170
  ],
168
- ssmSessionPermissions: true
171
+ ssmSessionPermissions: props.ssmSessionPermissions ?? true
169
172
  };
170
173
  // Support spot instances via mixed instances policy
171
174
  if (props.spotCapacityPercentage !== undefined &&
@@ -262,6 +265,22 @@ export class Ec2Instance extends Construct {
262
265
  }
263
266
  }
264
267
  function validateEc2InstanceProps(props) {
268
+ if (props.spotCapacityPercentage !== undefined) {
269
+ if (!Number.isInteger(props.spotCapacityPercentage) ||
270
+ props.spotCapacityPercentage < 0 ||
271
+ props.spotCapacityPercentage > 100) {
272
+ throw new Error(`Ec2Instance.spotCapacityPercentage must be an integer between 0 and 100; got ${props.spotCapacityPercentage}`);
273
+ }
274
+ }
275
+ if (props.minCapacity !== undefined &&
276
+ (!Number.isInteger(props.minCapacity) || props.minCapacity < 0)) {
277
+ throw new Error(`Ec2Instance.minCapacity must be a non-negative integer; got ${props.minCapacity}`);
278
+ }
279
+ if (props.maxCapacity !== undefined &&
280
+ props.minCapacity !== undefined &&
281
+ props.maxCapacity < props.minCapacity) {
282
+ throw new Error(`Ec2Instance.maxCapacity (${props.maxCapacity}) must be >= minCapacity (${props.minCapacity})`);
283
+ }
265
284
  if (props.persistentDataVolume !== undefined) {
266
285
  const azs = props.vpcSubnets?.availabilityZones;
267
286
  if (azs === undefined || azs.length !== 1) {
@@ -0,0 +1,29 @@
1
+ import { Alarm } from "aws-cdk-lib/aws-cloudwatch";
2
+ import type { ITopic } from "aws-cdk-lib/aws-sns";
3
+ import type { Construct } from "constructs";
4
+ export interface BuildkiteAlarmsProps {
5
+ scope: Construct;
6
+ buildkiteOrgSlug: string;
7
+ buildkiteQueue: string;
8
+ autoScalingGroupName: string;
9
+ /**
10
+ * Omitted → the alarms exist on the console but page nobody. Unlike the
11
+ * sibling builders the topic is optional: `App.addBuildkite` defaults it to
12
+ * the shared platform topic, and a bare construct must still synth.
13
+ */
14
+ alarmTopic?: ITopic;
15
+ applicationId?: string;
16
+ }
17
+ /**
18
+ * The fleet's two Phase-1 alarms (design § D14) — the "~zero babysitting"
19
+ * posture is honest only with these:
20
+ *
21
+ * 1. Scaler heartbeat — `ScheduledJobsCount` goes MISSING for 15 minutes.
22
+ * The scaler publishes every poll, so metric absence means the scaler
23
+ * Lambda is dead or failing; `treatMissingData: BREACHING` is the alarm's
24
+ * entire mechanism.
25
+ * 2. Queued with zero capacity — jobs scheduled while the ASG has no
26
+ * in-service instances for 15 minutes: the fleet cannot boot (AMI gone,
27
+ * quota, subnet failure) while work is waiting.
28
+ */
29
+ export declare function createBuildkiteAlarms(props: BuildkiteAlarmsProps): Alarm[];
@@ -0,0 +1,90 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import { Alarm, ComparisonOperator, MathExpression, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
3
+ import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
4
+ import { buildAlarmDescription, registerAlarm, tagAlarmsWithApplicationId } from "./alarmDefaults.js";
5
+ /**
6
+ * Metrics namespace the buildkite-agent-scaler publishes to, dimensioned by
7
+ * {Org, Queue} — BOTH dimensions are required; querying Queue alone reads no
8
+ * data (CloudWatch dimension matching is exact-set), leaving the heartbeat
9
+ * permanently ALARM and the queued alarm permanently inert. Phase-1b rollout
10
+ * gate: verify both metrics carry data after the first build before trusting
11
+ * the alarms (design § D13/D14) — a namespace/dimension mismatch here is
12
+ * invisible to the synth tests.
13
+ */
14
+ const SCALER_METRICS_NAMESPACE = "Buildkite";
15
+ /**
16
+ * The fleet's two Phase-1 alarms (design § D14) — the "~zero babysitting"
17
+ * posture is honest only with these:
18
+ *
19
+ * 1. Scaler heartbeat — `ScheduledJobsCount` goes MISSING for 15 minutes.
20
+ * The scaler publishes every poll, so metric absence means the scaler
21
+ * Lambda is dead or failing; `treatMissingData: BREACHING` is the alarm's
22
+ * entire mechanism.
23
+ * 2. Queued with zero capacity — jobs scheduled while the ASG has no
24
+ * in-service instances for 15 minutes: the fleet cannot boot (AMI gone,
25
+ * quota, subnet failure) while work is waiting.
26
+ */
27
+ export function createBuildkiteAlarms(props) {
28
+ const { scope, buildkiteOrgSlug, buildkiteQueue, autoScalingGroupName, alarmTopic, applicationId } = props;
29
+ const alarms = [];
30
+ const snsAction = alarmTopic !== undefined ? new SnsAction(alarmTopic) : undefined;
31
+ const scheduledJobs = new Metric({
32
+ namespace: SCALER_METRICS_NAMESPACE,
33
+ metricName: "ScheduledJobsCount",
34
+ dimensionsMap: {
35
+ Org: buildkiteOrgSlug,
36
+ Queue: buildkiteQueue
37
+ },
38
+ statistic: "Maximum",
39
+ period: Duration.minutes(5)
40
+ });
41
+ const heartbeatAlarm = new Alarm(scope, "ScalerHeartbeatAlarm", {
42
+ alarmDescription: buildAlarmDescription(`Buildkite scaler for queue '${buildkiteQueue}' has stopped ` +
43
+ "publishing metrics — scaler Lambda dead or erroring. Jobs will " +
44
+ "queue with no scale-out.", applicationId),
45
+ metric: scheduledJobs,
46
+ comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
47
+ threshold: 0,
48
+ evaluationPeriods: 3,
49
+ treatMissingData: TreatMissingData.BREACHING
50
+ });
51
+ if (snsAction !== undefined) {
52
+ registerAlarm(heartbeatAlarm, snsAction, alarms);
53
+ }
54
+ else {
55
+ alarms.push(heartbeatAlarm);
56
+ }
57
+ const inServiceInstances = new Metric({
58
+ namespace: "AWS/AutoScaling",
59
+ metricName: "GroupInServiceInstances",
60
+ dimensionsMap: { AutoScalingGroupName: autoScalingGroupName },
61
+ statistic: "Maximum",
62
+ period: Duration.minutes(5)
63
+ });
64
+ const queuedWithZeroCapacity = new MathExpression({
65
+ expression: "IF(scheduled > 0 AND inService == 0, 1, 0)",
66
+ usingMetrics: {
67
+ scheduled: scheduledJobs,
68
+ inService: inServiceInstances
69
+ },
70
+ period: Duration.minutes(5)
71
+ });
72
+ const queuedAlarm = new Alarm(scope, "QueuedWithZeroCapacityAlarm", {
73
+ alarmDescription: buildAlarmDescription(`Buildkite queue '${buildkiteQueue}' has scheduled jobs but zero ` +
74
+ "in-service agents for 15 minutes — the fleet cannot boot (AMI, " +
75
+ "quota, or subnet failure) while work waits.", applicationId),
76
+ metric: queuedWithZeroCapacity,
77
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
78
+ threshold: 1,
79
+ evaluationPeriods: 3,
80
+ treatMissingData: TreatMissingData.NOT_BREACHING
81
+ });
82
+ if (snsAction !== undefined) {
83
+ registerAlarm(queuedAlarm, snsAction, alarms);
84
+ }
85
+ else {
86
+ alarms.push(queuedAlarm);
87
+ }
88
+ tagAlarmsWithApplicationId(alarms, applicationId);
89
+ return alarms;
90
+ }
@@ -4,5 +4,6 @@ export { createRdsAlarms, type RdsAlarmThresholds, type RdsAlarmsProps } from ".
4
4
  export { createLambdaAlarms, type LambdaAlarmThresholds, type LambdaAlarmsProps } from "./lambdaAlarms.js";
5
5
  export { createScheduleAlarms, type ScheduleAlarmThresholds, type CreateScheduleAlarmsProps } from "./scheduleAlarms.js";
6
6
  export { createClickHouseAlarms, type ClickHouseAlarmThresholds, type ClickHouseAlarmsProps } from "./clickhouseAlarms.js";
7
+ export { createBuildkiteAlarms, type BuildkiteAlarmsProps } from "./buildkiteAlarms.js";
7
8
  export { createLogPatternAlarms, type LogPatternAlarmSpec, type LogPatternAlarmsProps } from "./logPatternAlarms.js";
8
9
  export { METRIC_NAMESPACE, type MetricNamespace } from "./metricNamespaces.js";
@@ -4,5 +4,6 @@ export { createRdsAlarms } from "./rdsAlarms.js";
4
4
  export { createLambdaAlarms } from "./lambdaAlarms.js";
5
5
  export { createScheduleAlarms } from "./scheduleAlarms.js";
6
6
  export { createClickHouseAlarms } from "./clickhouseAlarms.js";
7
+ export { createBuildkiteAlarms } from "./buildkiteAlarms.js";
7
8
  export { createLogPatternAlarms } from "./logPatternAlarms.js";
8
9
  export { METRIC_NAMESPACE } from "./metricNamespaces.js";
@@ -13,6 +13,9 @@ export class HostedZoneFactory {
13
13
  return new HostedZone(stack.getStack(), `${safeZone}HostedZone`, {
14
14
  hostedZoneId,
15
15
  zoneName,
16
+ // BYO app-stack imports never mint the role: its fixed RoleName would
17
+ // collide with the domain stack's own DelegationRole in the same account.
18
+ createDelegationRole: false,
16
19
  costAllocationEnvironment: opts?.costAllocationEnvironment,
17
20
  costAllocationDomain: opts?.costAllocationDomain,
18
21
  description: opts?.description
@@ -56,25 +59,6 @@ export class HostedZone extends Construct {
56
59
  value: Fn.join(",", created.hostedZoneNameServers ?? []),
57
60
  exportName: exportNames.nameservers
58
61
  });
59
- // Org-gate: a single account has no OrganisationId export, so an org-trusting
60
- // delegation role would leave an unresolved Fn::ImportValue and roll the stack
61
- // back. Default follows org presence; explicit `true` opts in (and fails fast).
62
- const inOrganisation = resolveOrgId(this.node) !== undefined;
63
- if (props.createDelegationRole === true && !inOrganisation) {
64
- throw new Error(`HostedZone "${props.zoneName}": createDelegationRole was requested but ` +
65
- `this account is not part of an AWS Organization (no "orgId" context). ` +
66
- `Cross-account DNS delegation requires an organisation — omit ` +
67
- `createDelegationRole for single-account setups, or connect an organisation.`);
68
- }
69
- if (props.createDelegationRole ?? inOrganisation) {
70
- this.delegationRole = new DelegationRole(this, `${safeZone}DelegationRole`, {
71
- zoneName: props.zoneName,
72
- hostedZone: created,
73
- organisationIdExportName: props.organisationIdExportName,
74
- costAllocationEnvironment: props.costAllocationEnvironment,
75
- costAllocationDomain: props.costAllocationDomain
76
- });
77
- }
78
62
  }
79
63
  else {
80
64
  // Import path — `fromHostedZoneAttributes` creates a reference, not a
@@ -88,6 +72,26 @@ export class HostedZone extends Construct {
88
72
  this.isImported = true;
89
73
  Tags.of(this).add("fjall:description", this.description);
90
74
  }
75
+ // Org-gate: a single account has no OrganisationId export, so an org-trusting
76
+ // delegation role would leave an unresolved Fn::ImportValue and roll the stack
77
+ // back. Default follows org presence; explicit `true` opts in (and fails fast).
78
+ // Runs for created AND adopted zones — `grantDelegation` is on IHostedZone.
79
+ const inOrganisation = resolveOrgId(this.node) !== undefined;
80
+ if (props.createDelegationRole === true && !inOrganisation) {
81
+ throw new Error(`HostedZone "${props.zoneName}": createDelegationRole was requested but ` +
82
+ `this account is not part of an AWS Organization (no "orgId" context). ` +
83
+ `Cross-account DNS delegation requires an organisation — omit ` +
84
+ `createDelegationRole for single-account setups, or connect an organisation.`);
85
+ }
86
+ if (props.createDelegationRole ?? inOrganisation) {
87
+ this.delegationRole = new DelegationRole(this, `${safeZone}DelegationRole`, {
88
+ zoneName: props.zoneName,
89
+ hostedZone: this.hostedZone,
90
+ organisationIdExportName: props.organisationIdExportName,
91
+ costAllocationEnvironment: props.costAllocationEnvironment,
92
+ costAllocationDomain: props.costAllocationDomain
93
+ });
94
+ }
91
95
  applyCostAllocationTags(this, {
92
96
  service: "hostedZone",
93
97
  domain: props.costAllocationDomain ?? props.zoneName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "3.0.0",
3
+ "version": "3.2.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@aws-sdk/client-organizations": "^3.1038.0",
70
- "@fjall/generator": "^3.0.0",
71
- "@fjall/util": "^3.0.0",
70
+ "@fjall/generator": "^3.2.1",
71
+ "@fjall/util": "^3.2.1",
72
72
  "constructs": "^10.6.0"
73
73
  },
74
74
  "overrides": {
@@ -82,5 +82,5 @@
82
82
  "engines": {
83
83
  "node": ">=18.0.0"
84
84
  },
85
- "gitHead": "848a386e3e2244401b76faabd02818c682d231e8"
85
+ "gitHead": "1a249298df7bf3104d9d49b47700234db815ebb1"
86
86
  }