@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
package/dist/lib/app.d.ts CHANGED
@@ -16,6 +16,7 @@ import { type RemovalPolicyString } from "./resources/aws/messaging/utils.js";
16
16
  import { type ServiceRegistrationProps } from "./resources/aws/networking/serviceDiscovery.js";
17
17
  import { type IPrivateDnsNamespace, type IService } from "aws-cdk-lib/aws-servicediscovery";
18
18
  import { type AnyCompute } from "./patterns/aws/compute.js";
19
+ import { Buildkite, type BuildkitePropsInput } from "./patterns/aws/buildkite.js";
19
20
  import { DevSubstrate, type IDevSubstrateProps } from "./patterns/aws/devSubstrate.js";
20
21
  import { type Storage, type StorageFactoryFn } from "./patterns/aws/storage.js";
21
22
  import { type AnyPattern, type PatternFactoryFn } from "./patterns/aws/pattern.js";
@@ -277,6 +278,29 @@ export declare class App extends CdkApp {
277
278
  * would synth and hash but never deploy.
278
279
  */
279
280
  addDevSubstrate(props: IDevSubstrateProps): DevSubstrate;
281
+ /**
282
+ * Instantiate the self-hosted Buildkite agent fleet on the default compute
283
+ * stack (design `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md`).
284
+ *
285
+ * Same placement contract as `addDevSubstrate` above: deploy-core selects
286
+ * stacks by the fixed six-category enum, so scoping under
287
+ * `${stackPrefix}Compute` is what makes the fleet actually deploy. Also the
288
+ * same registry posture — Fjall-managed platform infrastructure,
289
+ * deliberately NOT on the customer create/scaffold surface (design § D3).
290
+ *
291
+ * Cost-allocation environment resolves from `getConfig()` (design § D10) so
292
+ * the fleet's environment tag matches the deploying config rather than the
293
+ * construct's `management` fallback; an explicit
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.
302
+ */
303
+ addBuildkite(props: BuildkitePropsInput): Buildkite;
280
304
  /**
281
305
  * Add a database resource to the default database stack using the factory pattern.
282
306
  * Returns the appropriate database type based on the factory used.
package/dist/lib/app.js CHANGED
@@ -9,11 +9,14 @@ import { NetworkFactory } from "./patterns/aws/network.js";
9
9
  import { MessagingFactory } from "./patterns/aws/messaging.js";
10
10
  import { Schedule } from "./resources/aws/messaging/schedule.js";
11
11
  import { ServiceDiscoveryNamespace } from "./resources/aws/networking/serviceDiscovery.js";
12
+ import { Buildkite } from "./patterns/aws/buildkite.js";
12
13
  import { DevSubstrate } from "./patterns/aws/devSubstrate.js";
13
14
  import { StandardTagsAspect } from "./utils/standardTagsAspect.js";
14
15
  import { BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP } from "./utils/backupTierMapping.js";
15
16
  import { randomBytes } from "crypto";
16
17
  import { getConfig } from "./utils/getConfig.js";
18
+ import { UNKNOWN_ENVIRONMENT } from "./utils/env.js";
19
+ import { SHARED_ALARM_TOPIC_EXPORT_NAME } from "./config/aws/alarmTopic.js";
17
20
  import AuditRoleFactory from "./resources/aws/audit/auditRole.js";
18
21
  import { FJALL_AUDIT_CONFIG } from "./config/audit.js";
19
22
  import { FjallLogger } from "./utils/validationLogger.js";
@@ -480,6 +483,49 @@ export class App extends CdkApp {
480
483
  computeStack.addConstruct(substrate);
481
484
  return substrate;
482
485
  }
486
+ /**
487
+ * Instantiate the self-hosted Buildkite agent fleet on the default compute
488
+ * stack (design `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md`).
489
+ *
490
+ * Same placement contract as `addDevSubstrate` above: deploy-core selects
491
+ * stacks by the fixed six-category enum, so scoping under
492
+ * `${stackPrefix}Compute` is what makes the fleet actually deploy. Also the
493
+ * same registry posture — Fjall-managed platform infrastructure,
494
+ * deliberately NOT on the customer create/scaffold surface (design § D3).
495
+ *
496
+ * Cost-allocation environment resolves from `getConfig()` (design § D10) so
497
+ * the fleet's environment tag matches the deploying config rather than the
498
+ * construct's `management` fallback; an explicit
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.
507
+ */
508
+ addBuildkite(props) {
509
+ const computeStack = this.getDefaultComputeStack();
510
+ const configEnvironment = getConfig().environment;
511
+ const constructProps = { ...props, vpc: this.getVpc() };
512
+ // Post-spread injection so a caller-materialised `costAllocationEnvironment:
513
+ // undefined` counts as absent rather than clobbering the resolved value.
514
+ if (constructProps.costAllocationEnvironment === undefined &&
515
+ configEnvironment !== "" &&
516
+ configEnvironment !== UNKNOWN_ENVIRONMENT) {
517
+ constructProps.costAllocationEnvironment = configEnvironment;
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
+ }
525
+ const fleet = new Buildkite(computeStack.getStack(), "Buildkite", constructProps);
526
+ computeStack.addConstruct(fleet);
527
+ return fleet;
528
+ }
483
529
  /**
484
530
  * Add a database resource to the default database stack using the factory pattern.
485
531
  * Returns the appropriate database type based on the factory used.
@@ -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
  });
@@ -0,0 +1,25 @@
1
+ import type { Construct } from "constructs";
2
+ export interface BuildkiteAlarmParams {
3
+ readonly buildkiteOrgSlug: string;
4
+ readonly buildkiteQueue: string;
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
+ */
11
+ readonly alarmSnsTopicArn?: string;
12
+ }
13
+ /**
14
+ * The fleet's two Phase-1 alarms (design § D14) — the "~zero babysitting"
15
+ * posture is honest only with these:
16
+ *
17
+ * 1. Scaler heartbeat — `ScheduledJobsCount` goes MISSING for 15 minutes.
18
+ * The scaler publishes every poll, so metric absence means the scaler
19
+ * Lambda is dead or failing; `treatMissingData: BREACHING` is the alarm's
20
+ * entire mechanism.
21
+ * 2. Queued with zero capacity — jobs scheduled while the ASG has no
22
+ * in-service instances for 15 minutes: the fleet cannot boot (AMI gone,
23
+ * quota, subnet failure) while work is waiting.
24
+ */
25
+ export declare function addBuildkiteAlarms(scope: Construct, params: BuildkiteAlarmParams): void;
@@ -0,0 +1,78 @@
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 { resolveAlertsTopic } from "../../../utils/resolveAlertsTopic.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 addBuildkiteAlarms(scope, params) {
28
+ const scheduledJobs = new Metric({
29
+ namespace: SCALER_METRICS_NAMESPACE,
30
+ metricName: "ScheduledJobsCount",
31
+ dimensionsMap: {
32
+ Org: params.buildkiteOrgSlug,
33
+ Queue: params.buildkiteQueue
34
+ },
35
+ statistic: "Maximum",
36
+ period: Duration.minutes(5)
37
+ });
38
+ const heartbeatAlarm = new Alarm(scope, "ScalerHeartbeatAlarm", {
39
+ alarmDescription: `Buildkite scaler for queue '${params.buildkiteQueue}' has stopped ` +
40
+ "publishing metrics — scaler Lambda dead or erroring. Jobs will queue " +
41
+ "with no scale-out.",
42
+ metric: scheduledJobs,
43
+ comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
44
+ threshold: 0,
45
+ evaluationPeriods: 3,
46
+ treatMissingData: TreatMissingData.BREACHING
47
+ });
48
+ const inServiceInstances = new Metric({
49
+ namespace: "AWS/AutoScaling",
50
+ metricName: "GroupInServiceInstances",
51
+ dimensionsMap: { AutoScalingGroupName: params.autoScalingGroupName },
52
+ statistic: "Maximum",
53
+ period: Duration.minutes(5)
54
+ });
55
+ const queuedWithZeroCapacity = new MathExpression({
56
+ expression: "IF(scheduled > 0 AND inService == 0, 1, 0)",
57
+ usingMetrics: {
58
+ scheduled: scheduledJobs,
59
+ inService: inServiceInstances
60
+ },
61
+ period: Duration.minutes(5)
62
+ });
63
+ const queuedAlarm = new Alarm(scope, "QueuedWithZeroCapacityAlarm", {
64
+ alarmDescription: `Buildkite queue '${params.buildkiteQueue}' has scheduled jobs but ` +
65
+ "zero in-service agents for 15 minutes — the fleet cannot boot " +
66
+ "(AMI, quota, or subnet failure) while work waits.",
67
+ metric: queuedWithZeroCapacity,
68
+ comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
69
+ threshold: 1,
70
+ evaluationPeriods: 3,
71
+ treatMissingData: TreatMissingData.NOT_BREACHING
72
+ });
73
+ const topic = resolveAlertsTopic(scope, "BuildkiteAlarmTopic", params.alarmSnsTopicArn);
74
+ if (topic !== undefined) {
75
+ heartbeatAlarm.addAlarmAction(new SnsAction(topic));
76
+ queuedAlarm.addAlarmAction(new SnsAction(topic));
77
+ }
78
+ }
@@ -0,0 +1,46 @@
1
+ import { type IVpc } from "aws-cdk-lib/aws-ec2";
2
+ import { Construct } from "constructs";
3
+ import { type BuildkitePropsInput } from "./schema.js";
4
+ export type BuildkiteConstructProps = BuildkitePropsInput & {
5
+ readonly vpc: IVpc;
6
+ };
7
+ /**
8
+ * Self-hosted Buildkite agent fleet on the pinned Elastic CI Stack AMIs —
9
+ * scale-to-zero ASG + buildkite-agent-scaler, no secret ever transiting
10
+ * synth, allowlist-only instance IAM. Design:
11
+ * `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md`.
12
+ *
13
+ * A Construct, not a Stack: instantiate via `App.addBuildkite(props)`, which
14
+ * scopes it under the default compute stack so deploy-core's fixed
15
+ * six-category stack selection actually deploys it (the DevSubstrate
16
+ * placement precedent). Network posture is public-subnet + public-IP +
17
+ * zero-ingress (design § D5): `associatePublicIpAddress` MUST be explicit —
18
+ * the wrapper's `!!keyPair` default silently produced no-egress agents in
19
+ * public subnets pre-refactor.
20
+ */
21
+ export declare class Buildkite extends Construct {
22
+ readonly artifactBucketName: string;
23
+ readonly secretsBucketName: string;
24
+ readonly autoScalingGroupName: string;
25
+ constructor(scope: Construct, id: string, props: BuildkiteConstructProps);
26
+ /**
27
+ * Ship the per-job `env` hook into the managed secrets bucket. The
28
+ * s3-secrets-hooks plugin sources this file at the start of EVERY job, so
29
+ * FJALL_API_KEY is read fresh from SSM per job — rotation takes effect on
30
+ * the next job with no instance replacement (design § D4(iii)). The script
31
+ * is configuration, not a secret: shipping it through a CDK asset is fine;
32
+ * the secret VALUE only ever moves SSM → instance at job runtime.
33
+ */
34
+ private addFjallApiKeyEnvHook;
35
+ }
36
+ /**
37
+ * Object key the s3-secrets-hooks plugin sources at the start of every job —
38
+ * upstream contract: `env` at the secrets-bucket root.
39
+ */
40
+ export declare const FJALL_ENV_HOOK_OBJECT_KEY = "env";
41
+ /**
42
+ * The per-job env hook's script content (design § D4(iii)): FJALL_API_KEY is
43
+ * read fresh from SSM per job, so rotation takes effect on the next job with
44
+ * no instance replacement. Pure — the unit test pins the exact content.
45
+ */
46
+ export declare function buildFjallApiKeyEnvHookScript(parameterName: string, region: string): string;
@@ -0,0 +1,240 @@
1
+ import { CfnOutput, Duration, RemovalPolicy, Stack, Token } from "aws-cdk-lib";
2
+ import { InstanceArchitecture, InstanceType, MachineImage, SubnetType } from "aws-cdk-lib/aws-ec2";
3
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
4
+ import { Source } from "aws-cdk-lib/aws-s3-deployment";
5
+ import { Construct } from "constructs";
6
+ import { safeEbs } from "../../../resources/aws/compute/blockDeviceVolume.js";
7
+ import { Ec2Instance } from "../../../resources/aws/compute/ec2.js";
8
+ import { SamApplication } from "../../../resources/aws/compute/samApplication.js";
9
+ import { Policy } from "../../../resources/aws/iam/index.js";
10
+ import { BucketDeployment, S3Bucket } from "../../../resources/aws/storage/index.js";
11
+ import { createBuildkiteAlarms } from "../../../resources/aws/monitoring/index.js";
12
+ import { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
13
+ import { resolveAlertsTopic } from "../../../utils/resolveAlertsTopic.js";
14
+ import { buildAgentRole } from "./iam.js";
15
+ import { BUILDKITE_STACK_PINS, resolvePinnedAmiId, resolveScalerSarApplicationArn } from "./pins.js";
16
+ import { validateBuildkiteProps } from "./schema.js";
17
+ import { buildBuildkiteUserData } from "./userData.js";
18
+ /**
19
+ * Self-hosted Buildkite agent fleet on the pinned Elastic CI Stack AMIs —
20
+ * scale-to-zero ASG + buildkite-agent-scaler, no secret ever transiting
21
+ * synth, allowlist-only instance IAM. Design:
22
+ * `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md`.
23
+ *
24
+ * A Construct, not a Stack: instantiate via `App.addBuildkite(props)`, which
25
+ * scopes it under the default compute stack so deploy-core's fixed
26
+ * six-category stack selection actually deploys it (the DevSubstrate
27
+ * placement precedent). Network posture is public-subnet + public-IP +
28
+ * zero-ingress (design § D5): `associatePublicIpAddress` MUST be explicit —
29
+ * the wrapper's `!!keyPair` default silently produced no-egress agents in
30
+ * public subnets pre-refactor.
31
+ */
32
+ export class Buildkite extends Construct {
33
+ artifactBucketName;
34
+ secretsBucketName;
35
+ autoScalingGroupName;
36
+ constructor(scope, id, props) {
37
+ super(scope, id);
38
+ const { vpc, ...plainProps } = props;
39
+ const config = validateBuildkiteProps(plainProps);
40
+ const stack = Stack.of(this);
41
+ if (Token.isUnresolved(stack.region)) {
42
+ throw new Error("Buildkite requires a concrete env region at synth (the pinned AMI " +
43
+ "is region-specific). Pass env: { account, region } to the App.");
44
+ }
45
+ const region = stack.region;
46
+ const architecture = deriveCpuArchitecture(config.instanceType);
47
+ const amiId = resolvePinnedAmiId(region, architecture);
48
+ applyCostAllocationTags(this, {
49
+ service: "buildkite",
50
+ domain: "platform",
51
+ ...(config.costAllocationEnvironment !== undefined && {
52
+ environment: config.costAllocationEnvironment
53
+ }),
54
+ ...(config.costAllocationOwner !== undefined && {
55
+ owner: config.costAllocationOwner
56
+ })
57
+ });
58
+ const artifactBucket = new S3Bucket(this, `${id}ArtifactBucket`);
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 });
63
+ if (config.fjallApiKeySsmParameterName !== undefined) {
64
+ this.addFjallApiKeyEnvHook(id, managedSecretsBucket, config.fjallApiKeySsmParameterName, region);
65
+ }
66
+ const parameterArn = (name) => `arn:${stack.partition}:ssm:${region}:${stack.account}:parameter${name}`;
67
+ const agentRole = buildAgentRole(this, `${id}AgentRole`, {
68
+ agentTokenParameterArn: parameterArn(config.agentTokenSsmParameterName),
69
+ ...(config.agentTokenKmsKeyArn !== undefined && {
70
+ agentTokenKmsKeyArn: config.agentTokenKmsKeyArn
71
+ }),
72
+ ...(config.fjallApiKeySsmParameterName !== undefined && {
73
+ fjallApiKeyParameterArn: parameterArn(config.fjallApiKeySsmParameterName)
74
+ }),
75
+ secretsBucketArn: managedSecretsBucket.bucketArn,
76
+ artifactBucketArn: artifactBucket.bucketArn,
77
+ stackArn: stack.stackId,
78
+ logGroupArnPattern: `arn:${stack.partition}:logs:${region}:${stack.account}:log-group:/buildkite/*`
79
+ });
80
+ const userData = buildBuildkiteUserData(config, {
81
+ stackName: stack.stackName,
82
+ region,
83
+ secretsBucketName: managedSecretsBucket.bucketName,
84
+ artifactBucketName: artifactBucket.bucketName
85
+ });
86
+ // Deliberate deviation from upstream's InstanceScaleInProtection
87
+ // (design § D7): the wrapper's `newInstancesProtectedFromScaleIn: false`
88
+ // invariant wins (scale-in-protected instances wedge CFN rollback). Safe
89
+ // because the scaler runs with `DisableScaleIn: "true"` (pinned below) so
90
+ // it never reduces DesiredCapacity — instances leave only by
91
+ // self-termination (scale-in idle / disconnect-after-uptime) or the ASG
92
+ // `maxInstanceLifetime` replacement.
93
+ //
94
+ // `ssmSessionPermissions: false`: the wrapper default attaches
95
+ // `AmazonSSMManagedInstanceCore`, whose account-wide `ssm:GetParameter`
96
+ // would let any build job read every SSM parameter in the account —
97
+ // breaking § D9's allowlist-only posture. The agent role's scoped
98
+ // `sessionManager` inline policy carries the Session Manager actions.
99
+ const ec2Instance = new Ec2Instance(this, `${id}Agent`, {
100
+ serviceName: `${id}Agent`,
101
+ vpc,
102
+ vpcSubnets: { subnetType: SubnetType.PUBLIC },
103
+ associatePublicIpAddress: true,
104
+ instanceType: config.instanceType,
105
+ machineImage: MachineImage.genericLinux({ [region]: amiId }),
106
+ userData,
107
+ role: agentRole,
108
+ ssmSessionPermissions: false,
109
+ blockDevices: [
110
+ {
111
+ deviceName: "/dev/xvda",
112
+ volume: safeEbs(config.agentVolumeSizeGib)
113
+ }
114
+ ],
115
+ minCapacity: config.agentMinInstances,
116
+ maxCapacity: config.agentMaxInstances,
117
+ spotCapacityPercentage: config.spotCapacityPercentage,
118
+ maxInstanceLifetime: Duration.days(config.maxInstanceLifetimeDays),
119
+ tags: {
120
+ Role: "buildkite-agent",
121
+ BuildkiteQueue: config.buildkiteQueue,
122
+ BuildkiteAgentRelease: config.buildkiteAgentRelease,
123
+ AgentsPerInstance: `${config.agentsPerInstance}`
124
+ }
125
+ });
126
+ const autoScalingGroup = ec2Instance.getAutoScalingGroup();
127
+ agentRole.attachInlinePolicy(new Policy(this, `${id}AgentScaleInPolicy`, {
128
+ statements: [
129
+ new PolicyStatement({
130
+ actions: [
131
+ "autoscaling:SetInstanceHealth",
132
+ "autoscaling:TerminateInstanceInAutoScalingGroup"
133
+ ],
134
+ resources: [autoScalingGroup.autoScalingGroupArn]
135
+ })
136
+ ]
137
+ }));
138
+ new SamApplication(this, `${id}AgentScaler`, {
139
+ applicationId: resolveScalerSarApplicationArn(architecture),
140
+ semanticVersion: BUILDKITE_STACK_PINS.scalerVersion,
141
+ parameters: {
142
+ BuildkiteAgentTokenParameter: config.agentTokenSsmParameterName,
143
+ ...(config.agentTokenKmsKeyArn !== undefined && {
144
+ BuildkiteAgentTokenParameterStoreKMSKey: config.agentTokenKmsKeyArn
145
+ }),
146
+ ...(config.rolePermissionsBoundaryArn !== undefined && {
147
+ RolePermissionsBoundaryARN: config.rolePermissionsBoundaryArn
148
+ }),
149
+ BuildkiteQueue: config.buildkiteQueue,
150
+ AgentsPerInstance: `${config.agentsPerInstance}`,
151
+ MinSize: `${config.agentMinInstances}`,
152
+ MaxSize: `${config.agentMaxInstances}`,
153
+ AgentAutoScaleGroup: autoScalingGroup.autoScalingGroupName,
154
+ ScaleOutFactor: config.scaleOutFactor,
155
+ ScaleOutForWaitingJobs: `${config.scaleOutWaitingForJobs}`,
156
+ EventSchedulePeriod: config.scalerEventSchedulePeriod,
157
+ MinPollInterval: config.scalerMinPollInterval,
158
+ LogRetentionDays: `${config.logRetentionDays}`,
159
+ // Pinned, not defaulted: the D7 scale-in posture above is only sound
160
+ // while the scaler never reduces DesiredCapacity. A scaler-side
161
+ // default flip must not change our termination semantics silently.
162
+ DisableScaleIn: "true"
163
+ },
164
+ costAllocationService: "buildkite",
165
+ costAllocationDomain: "buildkite-agent-scaler",
166
+ ...(config.costAllocationEnvironment !== undefined && {
167
+ costAllocationEnvironment: config.costAllocationEnvironment
168
+ })
169
+ });
170
+ const alertsTopic = resolveAlertsTopic(this, "BuildkiteAlarmTopic", config.alarmSnsTopicArn);
171
+ createBuildkiteAlarms({
172
+ scope: this,
173
+ buildkiteOrgSlug: config.buildkiteOrgSlug,
174
+ buildkiteQueue: config.buildkiteQueue,
175
+ autoScalingGroupName: autoScalingGroup.autoScalingGroupName,
176
+ ...(alertsTopic !== undefined && { alarmTopic: alertsTopic }),
177
+ ...(config.applicationId !== undefined && {
178
+ applicationId: config.applicationId
179
+ })
180
+ });
181
+ this.artifactBucketName = artifactBucket.bucketName;
182
+ this.secretsBucketName = managedSecretsBucket.bucketName;
183
+ this.autoScalingGroupName = autoScalingGroup.autoScalingGroupName;
184
+ new CfnOutput(this, "BuildkiteQueueName", {
185
+ value: config.buildkiteQueue
186
+ });
187
+ new CfnOutput(this, "BuildkiteAgentAsgName", {
188
+ value: autoScalingGroup.autoScalingGroupName
189
+ });
190
+ new CfnOutput(this, "BuildkiteSecretsBucketName", {
191
+ value: managedSecretsBucket.bucketName
192
+ });
193
+ new CfnOutput(this, "BuildkiteArtifactBucketName", {
194
+ value: artifactBucket.bucketName
195
+ });
196
+ }
197
+ /**
198
+ * Ship the per-job `env` hook into the managed secrets bucket. The
199
+ * s3-secrets-hooks plugin sources this file at the start of EVERY job, so
200
+ * FJALL_API_KEY is read fresh from SSM per job — rotation takes effect on
201
+ * the next job with no instance replacement (design § D4(iii)). The script
202
+ * is configuration, not a secret: shipping it through a CDK asset is fine;
203
+ * the secret VALUE only ever moves SSM → instance at job runtime.
204
+ */
205
+ addFjallApiKeyEnvHook(id, secretsBucket, parameterName, region) {
206
+ new BucketDeployment(this, `${id}EnvHookDeployment`, {
207
+ sources: [
208
+ Source.data(FJALL_ENV_HOOK_OBJECT_KEY, buildFjallApiKeyEnvHookScript(parameterName, region))
209
+ ],
210
+ destinationBucket: secretsBucket,
211
+ // The secrets bucket also holds out-of-band objects the deployment
212
+ // does not know about (the git deploy key, design § D4(ii)). The CDK
213
+ // default `prune: true` DELETES every bucket object missing from
214
+ // `sources` on each custom-resource execution — wiping those secrets.
215
+ prune: false,
216
+ retainOnDelete: true
217
+ });
218
+ }
219
+ }
220
+ /**
221
+ * Object key the s3-secrets-hooks plugin sources at the start of every job —
222
+ * upstream contract: `env` at the secrets-bucket root.
223
+ */
224
+ export const FJALL_ENV_HOOK_OBJECT_KEY = "env";
225
+ /**
226
+ * The per-job env hook's script content (design § D4(iii)): FJALL_API_KEY is
227
+ * read fresh from SSM per job, so rotation takes effect on the next job with
228
+ * no instance replacement. Pure — the unit test pins the exact content.
229
+ */
230
+ export function buildFjallApiKeyEnvHookScript(parameterName, region) {
231
+ return [
232
+ `FJALL_API_KEY="$(aws ssm get-parameter --name '${parameterName}' --with-decryption --query Parameter.Value --output text --region '${region}')"`,
233
+ "export FJALL_API_KEY",
234
+ ""
235
+ ].join("\n");
236
+ }
237
+ function deriveCpuArchitecture(instanceTypeIdentifier) {
238
+ const architecture = new InstanceType(instanceTypeIdentifier).architecture;
239
+ return architecture === InstanceArchitecture.ARM_64 ? "arm64" : "amd64";
240
+ }
@@ -0,0 +1,35 @@
1
+ import type { Construct } from "constructs";
2
+ import { Role } from "../../../resources/aws/iam/index.js";
3
+ /**
4
+ * Every IAM action the agent instance profile is permitted to carry — the
5
+ * no-deploy-IAM invariant (design § D9). Deploy AWS credentials are
6
+ * server-minted (FJALL_API_KEY → Fjall OIDC → target-account role); the
7
+ * instance profile confers NO deploy capability. Explicitly absent: any
8
+ * `ecr:*` write, `sts:AssumeRole`, any CloudFormation mutation.
9
+ *
10
+ * The allowlist synth test asserts the synthesised role's actions are a
11
+ * subset of this list, so an upstream bump (or a future edit) that grows
12
+ * permissions fails loudly instead of shipping silently.
13
+ */
14
+ export declare const BUILDKITE_AGENT_IAM_ACTION_ALLOWLIST: readonly ["ssm:GetParameter", "kms:Decrypt", "s3:GetObject", "s3:ListBucket", "s3:GetObjectVersion", "s3:PutObject", "s3:PutObjectAcl", "s3:PutObjectVersionAcl", "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogGroups", "logs:DescribeLogStreams", "logs:PutRetentionPolicy", "ssm:DescribeInstanceProperties", "ssm:ListAssociations", "ssm:PutInventory", "ssm:UpdateInstanceInformation", "ssmmessages:CreateControlChannel", "ssmmessages:CreateDataChannel", "ssmmessages:OpenControlChannel", "ssmmessages:OpenDataChannel", "ec2messages:AcknowledgeMessage", "ec2messages:DeleteMessage", "ec2messages:FailMessage", "ec2messages:GetEndpoint", "ec2messages:GetMessages", "ec2messages:SendReply", "autoscaling:DescribeAutoScalingInstances", "autoscaling:SetInstanceHealth", "autoscaling:TerminateInstanceInAutoScalingGroup", "cloudwatch:PutMetricData", "cloudformation:DescribeStackResource", "ec2:DescribeTags"];
15
+ export interface BuildkiteAgentRoleParams {
16
+ readonly agentTokenParameterArn: string;
17
+ readonly agentTokenKmsKeyArn?: string;
18
+ readonly fjallApiKeyParameterArn?: string;
19
+ readonly secretsBucketArn: string;
20
+ readonly artifactBucketArn: string;
21
+ /** Own-stack ARN — `cloudformation:DescribeStackResource` scope. */
22
+ readonly stackArn: string;
23
+ /** `arn:...:log-group:/buildkite/*` — the elastic stack's group namespace. */
24
+ readonly logGroupArnPattern: string;
25
+ }
26
+ /**
27
+ * Build the agent instance role. Tighter than the upstream v6.68.1 template
28
+ * on three axes: logs actions are scoped to the `/buildkite/*` group
29
+ * namespace (upstream: `*`), `cloudformation:DescribeStackResource` is
30
+ * scoped to the own stack (upstream: `*`), and there is no ECR/docker-login
31
+ * grant surface at all. The remaining `resources: ["*"]` statements are
32
+ * describe-only or instance-inventory actions with no resource-level
33
+ * support.
34
+ */
35
+ export declare function buildAgentRole(scope: Construct, id: string, params: BuildkiteAgentRoleParams): Role;