@fjall/components-infrastructure 3.1.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.
package/dist/lib/app.d.ts CHANGED
@@ -292,6 +292,13 @@ export declare class App extends CdkApp {
292
292
  * the fleet's environment tag matches the deploying config rather than the
293
293
  * construct's `management` fallback; an explicit
294
294
  * `props.costAllocationEnvironment` still wins.
295
+ *
296
+ * Alarm actions default to the account's `SharedAlarmTopicArn` export
297
+ * (design § D14 — alerting ships WITH the fleet): every fjall-governed
298
+ * account's Account/Platform stack produces that export, so alarms page
299
+ * out of the box. An explicit `props.alarmSnsTopicArn` still wins.
300
+ * `applicationId` defaults to the app name so the alarm webhook can map
301
+ * pages to the application; an explicit prop still wins.
295
302
  */
296
303
  addBuildkite(props: BuildkitePropsInput): Buildkite;
297
304
  /**
package/dist/lib/app.js CHANGED
@@ -16,6 +16,7 @@ import { BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP } from "./utils/backupTierMapp
16
16
  import { randomBytes } from "crypto";
17
17
  import { getConfig } from "./utils/getConfig.js";
18
18
  import { UNKNOWN_ENVIRONMENT } from "./utils/env.js";
19
+ import { SHARED_ALARM_TOPIC_EXPORT_NAME } from "./config/aws/alarmTopic.js";
19
20
  import AuditRoleFactory from "./resources/aws/audit/auditRole.js";
20
21
  import { FJALL_AUDIT_CONFIG } from "./config/audit.js";
21
22
  import { FjallLogger } from "./utils/validationLogger.js";
@@ -496,6 +497,13 @@ export class App extends CdkApp {
496
497
  * the fleet's environment tag matches the deploying config rather than the
497
498
  * construct's `management` fallback; an explicit
498
499
  * `props.costAllocationEnvironment` still wins.
500
+ *
501
+ * Alarm actions default to the account's `SharedAlarmTopicArn` export
502
+ * (design § D14 — alerting ships WITH the fleet): every fjall-governed
503
+ * account's Account/Platform stack produces that export, so alarms page
504
+ * out of the box. An explicit `props.alarmSnsTopicArn` still wins.
505
+ * `applicationId` defaults to the app name so the alarm webhook can map
506
+ * pages to the application; an explicit prop still wins.
499
507
  */
500
508
  addBuildkite(props) {
501
509
  const computeStack = this.getDefaultComputeStack();
@@ -508,6 +516,12 @@ export class App extends CdkApp {
508
516
  configEnvironment !== UNKNOWN_ENVIRONMENT) {
509
517
  constructProps.costAllocationEnvironment = configEnvironment;
510
518
  }
519
+ if (constructProps.alarmSnsTopicArn === undefined) {
520
+ constructProps.alarmSnsTopicArn = `import:${SHARED_ALARM_TOPIC_EXPORT_NAME}`;
521
+ }
522
+ if (constructProps.applicationId === undefined) {
523
+ constructProps.applicationId = this.getName();
524
+ }
511
525
  const fleet = new Buildkite(computeStack.getStack(), "Buildkite", constructProps);
512
526
  computeStack.addConstruct(fleet);
513
527
  return fleet;
@@ -1,6 +1,13 @@
1
1
  import { CfnOutput } from "aws-cdk-lib";
2
2
  import type { ITopic } from "aws-cdk-lib/aws-sns";
3
3
  import { Construct } from "constructs";
4
+ /**
5
+ * The well-known cross-stack export name for the account's shared alarm
6
+ * topic. Consumers build `"import:" + SHARED_ALARM_TOPIC_EXPORT_NAME`
7
+ * strings for `resolveAlertsTopic`; the CfnOutput below is the single
8
+ * producer.
9
+ */
10
+ export declare const SHARED_ALARM_TOPIC_EXPORT_NAME = "SharedAlarmTopicArn";
4
11
  export declare class SharedAlarmTopic extends Construct {
5
12
  readonly topic: ITopic;
6
13
  readonly topicArn: CfnOutput;
@@ -1,6 +1,13 @@
1
1
  import { CfnOutput } from "aws-cdk-lib";
2
2
  import { Construct } from "constructs";
3
3
  import { SNSTopic } from "../../resources/aws/messaging/sns.js";
4
+ /**
5
+ * The well-known cross-stack export name for the account's shared alarm
6
+ * topic. Consumers build `"import:" + SHARED_ALARM_TOPIC_EXPORT_NAME`
7
+ * strings for `resolveAlertsTopic`; the CfnOutput below is the single
8
+ * producer.
9
+ */
10
+ export const SHARED_ALARM_TOPIC_EXPORT_NAME = "SharedAlarmTopicArn";
4
11
  export class SharedAlarmTopic extends Construct {
5
12
  topic;
6
13
  topicArn;
@@ -15,9 +22,9 @@ export class SharedAlarmTopic extends Construct {
15
22
  // thinking it duplicates the wrapper's AlarmNotificationsTopicArn —
16
23
  // the wrapper's auto-output has no exportName and cannot be imported.
17
24
  this.topicArn = new CfnOutput(this, "SharedAlarmTopicArn", {
18
- key: "SharedAlarmTopicArn",
25
+ key: SHARED_ALARM_TOPIC_EXPORT_NAME,
19
26
  value: wrapped.getTopicArn(),
20
- exportName: "SharedAlarmTopicArn"
27
+ exportName: SHARED_ALARM_TOPIC_EXPORT_NAME
21
28
  });
22
29
  }
23
30
  }
@@ -14,12 +14,12 @@ export interface ApexDomainPatternResult {
14
14
  *
15
15
  * Delegation direction (D8): child writes NS. The apex zone's contribution
16
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.
17
+ * creates for created AND adopted zones — the delegated child `Domain`
18
+ * assumes that role by LITERAL ARN (`parentDelegationRoleArn`) and UPSERTs
19
+ * its own NS records into this zone via `CrossAccountZoneDelegationRecord`.
20
+ * The old parent-writes `delegations[]` path (NS records from
21
+ * `Fn.importValue` of child nameserver exports) was removed with the H13
22
+ * legacy layer: it was export-locked, child-first-deploy-ordered, and
23
+ * removal required a parent redeploy.
24
24
  */
25
25
  export declare function composeApexDomain(scope: Construct, props: Route53ApexProps): ApexDomainPatternResult;
@@ -2,32 +2,28 @@ import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
2
2
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
3
3
  import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
4
4
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
5
- import { resolveOrgId } from "../../utils/cdkContext.js";
6
5
  /**
7
6
  * Composition for `registrar: "route53"`. Creates (or imports) the apex
8
7
  * `HostedZone` and composes all user records + certificates.
9
8
  *
10
9
  * Delegation direction (D8): child writes NS. The apex zone's contribution
11
10
  * 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.
11
+ * creates for created AND adopted zones — the delegated child `Domain`
12
+ * assumes that role by LITERAL ARN (`parentDelegationRoleArn`) and UPSERTs
13
+ * its own NS records into this zone via `CrossAccountZoneDelegationRecord`.
14
+ * The old parent-writes `delegations[]` path (NS records from
15
+ * `Fn.importValue` of child nameserver exports) was removed with the H13
16
+ * legacy layer: it was export-locked, child-first-deploy-ordered, and
17
+ * removal required a parent redeploy.
19
18
  */
20
19
  export function composeApexDomain(scope, props) {
21
20
  const safeZone = toPascalCase(getSafeZoneName(props.zoneName));
22
- // Gotcha: the cross-account DelegationRole imports CFN export `OrganisationId`
23
- // from the org/account stack only published when an `orgId` CDK context
24
- // value is set. Single-account deploys would roll back at synth without
25
- // this gate.
26
- const inOrganisation = resolveOrgId(scope.node) !== undefined;
21
+ // Gotcha: `createDelegationRole` is deliberately omitted the wrapper's
22
+ // org-gated default gives created AND adopted apex zones the DelegationRole;
23
+ // re-adding a create-path-only condition would strand adopted apexes (D8).
27
24
  const hostedZoneConstruct = new HostedZone(scope, `${safeZone}HostedZone`, {
28
25
  zoneName: props.zoneName,
29
26
  hostedZoneId: props.hostedZoneId,
30
- createDelegationRole: props.hostedZoneId === undefined && inOrganisation,
31
27
  costAllocationEnvironment: props.costAllocationEnvironment,
32
28
  costAllocationDomain: props.zoneName
33
29
  });
@@ -3,6 +3,11 @@ export interface BuildkiteAlarmParams {
3
3
  readonly buildkiteOrgSlug: string;
4
4
  readonly buildkiteQueue: string;
5
5
  readonly autoScalingGroupName: string;
6
+ /**
7
+ * Alarm-action destination, in either `resolveAlertsTopic` string shape:
8
+ * a literal `arn:...` or `"import:<ExportName>"` (e.g.
9
+ * `"import:SharedAlarmTopicArn"`). Omitted → alarms exist but page nobody.
10
+ */
6
11
  readonly alarmSnsTopicArn?: string;
7
12
  }
8
13
  /**
@@ -1,7 +1,7 @@
1
1
  import { Duration } from "aws-cdk-lib";
2
2
  import { Alarm, ComparisonOperator, MathExpression, Metric, TreatMissingData } from "aws-cdk-lib/aws-cloudwatch";
3
3
  import { SnsAction } from "aws-cdk-lib/aws-cloudwatch-actions";
4
- import { Topic } from "aws-cdk-lib/aws-sns";
4
+ import { resolveAlertsTopic } from "../../../utils/resolveAlertsTopic.js";
5
5
  /**
6
6
  * Metrics namespace the buildkite-agent-scaler publishes to, dimensioned by
7
7
  * {Org, Queue} — BOTH dimensions are required; querying Queue alone reads no
@@ -70,8 +70,8 @@ export function addBuildkiteAlarms(scope, params) {
70
70
  evaluationPeriods: 3,
71
71
  treatMissingData: TreatMissingData.NOT_BREACHING
72
72
  });
73
- if (params.alarmSnsTopicArn !== undefined) {
74
- const topic = Topic.fromTopicArn(scope, "BuildkiteAlarmTopic", params.alarmSnsTopicArn);
73
+ const topic = resolveAlertsTopic(scope, "BuildkiteAlarmTopic", params.alarmSnsTopicArn);
74
+ if (topic !== undefined) {
75
75
  heartbeatAlarm.addAlarmAction(new SnsAction(topic));
76
76
  queuedAlarm.addAlarmAction(new SnsAction(topic));
77
77
  }
@@ -1,4 +1,4 @@
1
- import { CfnOutput, Duration, Stack, Token } from "aws-cdk-lib";
1
+ import { CfnOutput, Duration, RemovalPolicy, Stack, Token } from "aws-cdk-lib";
2
2
  import { InstanceArchitecture, InstanceType, MachineImage, SubnetType } from "aws-cdk-lib/aws-ec2";
3
3
  import { PolicyStatement } from "aws-cdk-lib/aws-iam";
4
4
  import { Source } from "aws-cdk-lib/aws-s3-deployment";
@@ -8,8 +8,9 @@ import { Ec2Instance } from "../../../resources/aws/compute/ec2.js";
8
8
  import { SamApplication } from "../../../resources/aws/compute/samApplication.js";
9
9
  import { Policy } from "../../../resources/aws/iam/index.js";
10
10
  import { BucketDeployment, S3Bucket } from "../../../resources/aws/storage/index.js";
11
+ import { createBuildkiteAlarms } from "../../../resources/aws/monitoring/index.js";
11
12
  import { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
12
- import { addBuildkiteAlarms } from "./alarms.js";
13
+ import { resolveAlertsTopic } from "../../../utils/resolveAlertsTopic.js";
13
14
  import { buildAgentRole } from "./iam.js";
14
15
  import { BUILDKITE_STACK_PINS, resolvePinnedAmiId, resolveScalerSarApplicationArn } from "./pins.js";
15
16
  import { validateBuildkiteProps } from "./schema.js";
@@ -55,7 +56,10 @@ export class Buildkite extends Construct {
55
56
  })
56
57
  });
57
58
  const artifactBucket = new S3Bucket(this, `${id}ArtifactBucket`);
58
- const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`);
59
+ // Secrets (SSH deploy keys, git credentials) must survive stack teardown
60
+ // and accidental overwrite; the artifact bucket is scratch output and
61
+ // keeps the wrapper's env-aware default (DESTROY + pre-empty off prod).
62
+ const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`, { versioned: true, removalPolicy: RemovalPolicy.RETAIN });
59
63
  if (config.fjallApiKeySsmParameterName !== undefined) {
60
64
  this.addFjallApiKeyEnvHook(id, managedSecretsBucket, config.fjallApiKeySsmParameterName, region);
61
65
  }
@@ -163,12 +167,15 @@ export class Buildkite extends Construct {
163
167
  costAllocationEnvironment: config.costAllocationEnvironment
164
168
  })
165
169
  });
166
- addBuildkiteAlarms(this, {
170
+ const alertsTopic = resolveAlertsTopic(this, "BuildkiteAlarmTopic", config.alarmSnsTopicArn);
171
+ createBuildkiteAlarms({
172
+ scope: this,
167
173
  buildkiteOrgSlug: config.buildkiteOrgSlug,
168
174
  buildkiteQueue: config.buildkiteQueue,
169
175
  autoScalingGroupName: autoScalingGroup.autoScalingGroupName,
170
- ...(config.alarmSnsTopicArn !== undefined && {
171
- alarmSnsTopicArn: config.alarmSnsTopicArn
176
+ ...(alertsTopic !== undefined && { alarmTopic: alertsTopic }),
177
+ ...(config.applicationId !== undefined && {
178
+ applicationId: config.applicationId
172
179
  })
173
180
  });
174
181
  this.artifactBucketName = artifactBucket.bucketName;
@@ -58,7 +58,8 @@ export const BUILDKITE_AGENT_IAM_ACTION_ALLOWLIST = [
58
58
  */
59
59
  export function buildAgentRole(scope, id, params) {
60
60
  return new Role(scope, id, {
61
- description: "Buildkite agent instance roleallowlist-only, no deploy capability",
61
+ // IAM descriptions only accept [\t\n\r\x20-\x7E\xA1-\xFF] — no em-dashes
62
+ description: "Buildkite agent instance role - allowlist-only, no deploy capability",
62
63
  inlinePolicies: buildAgentPolicyDocuments(params),
63
64
  assumedBy: new CompositePrincipal(new ServicePrincipal("autoscaling.amazonaws.com"), new ServicePrincipal("ec2.amazonaws.com"))
64
65
  });
@@ -66,6 +66,7 @@ export declare const BuildkitePropsSchema: z.ZodObject<{
66
66
  scaleOutWaitingForJobs: z.ZodDefault<z.ZodBoolean>;
67
67
  rolePermissionsBoundaryArn: z.ZodOptional<z.ZodString>;
68
68
  alarmSnsTopicArn: z.ZodOptional<z.ZodString>;
69
+ applicationId: z.ZodOptional<z.ZodString>;
69
70
  costAllocationEnvironment: z.ZodOptional<z.ZodString>;
70
71
  costAllocationOwner: z.ZodOptional<z.ZodString>;
71
72
  }, z.core.$strict>;
@@ -124,6 +124,13 @@ export const BuildkitePropsSchema = z
124
124
  * actions (visible on the console, silent).
125
125
  */
126
126
  alarmSnsTopicArn: z.string().min(1).optional(),
127
+ /**
128
+ * Fjall application the fleet belongs to — appended to alarm
129
+ * descriptions and tagged (`fjall:applicationId`) so the alarm webhook
130
+ * can map pages to the application. `App.addBuildkite` injects the app
131
+ * name automatically; set only to override.
132
+ */
133
+ applicationId: z.string().min(1).optional(),
127
134
  costAllocationEnvironment: z.string().min(1).optional(),
128
135
  costAllocationOwner: z.string().min(1).optional()
129
136
  })
@@ -9,4 +9,3 @@ export { BUILDKITE_AMI_OWNER_ACCOUNT_ID, BUILDKITE_CPU_ARCHITECTURES, BUILDKITE_
9
9
  export { BuildkitePropsSchema, validateBuildkiteProps, type BuildkiteProps, type BuildkitePropsInput } from "./buildkite/schema.js";
10
10
  export { BUILDKITE_AGENT_IAM_ACTION_ALLOWLIST, buildAgentRole, type BuildkiteAgentRoleParams } from "./buildkite/iam.js";
11
11
  export { BUILDKITE_DOCKER_PART_ENV_KEYS, BUILDKITE_MOUNT_PART_ENV_KEYS, BUILDKITE_USER_DATA_ENV_KEYS, buildBuildkiteDockerCommands, buildBuildkiteInstallCommands, buildBuildkiteMountCommands, buildBuildkiteUserData, type BuildkiteUserDataContext } from "./buildkite/userData.js";
12
- export { addBuildkiteAlarms, type BuildkiteAlarmParams } from "./buildkite/alarms.js";
@@ -9,4 +9,3 @@ export { BUILDKITE_AMI_OWNER_ACCOUNT_ID, BUILDKITE_CPU_ARCHITECTURES, BUILDKITE_
9
9
  export { BuildkitePropsSchema, validateBuildkiteProps } from "./buildkite/schema.js";
10
10
  export { BUILDKITE_AGENT_IAM_ACTION_ALLOWLIST, buildAgentRole } from "./buildkite/iam.js";
11
11
  export { BUILDKITE_DOCKER_PART_ENV_KEYS, BUILDKITE_MOUNT_PART_ENV_KEYS, BUILDKITE_USER_DATA_ENV_KEYS, buildBuildkiteDockerCommands, buildBuildkiteInstallCommands, buildBuildkiteMountCommands, buildBuildkiteUserData } from "./buildkite/userData.js";
12
- export { addBuildkiteAlarms } from "./buildkite/alarms.js";
@@ -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
@@ -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.1.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.1.0",
71
- "@fjall/util": "^3.1.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": "0f27cc5e260fdf218d33de546ba8610be89e1d81"
85
+ "gitHead": "1a249298df7bf3104d9d49b47700234db815ebb1"
86
86
  }