@fjall/components-infrastructure 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/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,22 @@ 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
+ addBuildkite(props: BuildkitePropsInput): Buildkite;
280
297
  /**
281
298
  * Add a database resource to the default database stack using the factory pattern.
282
299
  * Returns the appropriate database type based on the factory used.
package/dist/lib/app.js CHANGED
@@ -9,11 +9,13 @@ 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";
17
19
  import AuditRoleFactory from "./resources/aws/audit/auditRole.js";
18
20
  import { FJALL_AUDIT_CONFIG } from "./config/audit.js";
19
21
  import { FjallLogger } from "./utils/validationLogger.js";
@@ -480,6 +482,36 @@ export class App extends CdkApp {
480
482
  computeStack.addConstruct(substrate);
481
483
  return substrate;
482
484
  }
485
+ /**
486
+ * Instantiate the self-hosted Buildkite agent fleet on the default compute
487
+ * stack (design `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md`).
488
+ *
489
+ * Same placement contract as `addDevSubstrate` above: deploy-core selects
490
+ * stacks by the fixed six-category enum, so scoping under
491
+ * `${stackPrefix}Compute` is what makes the fleet actually deploy. Also the
492
+ * same registry posture — Fjall-managed platform infrastructure,
493
+ * deliberately NOT on the customer create/scaffold surface (design § D3).
494
+ *
495
+ * Cost-allocation environment resolves from `getConfig()` (design § D10) so
496
+ * the fleet's environment tag matches the deploying config rather than the
497
+ * construct's `management` fallback; an explicit
498
+ * `props.costAllocationEnvironment` still wins.
499
+ */
500
+ addBuildkite(props) {
501
+ const computeStack = this.getDefaultComputeStack();
502
+ const configEnvironment = getConfig().environment;
503
+ const constructProps = { ...props, vpc: this.getVpc() };
504
+ // Post-spread injection so a caller-materialised `costAllocationEnvironment:
505
+ // undefined` counts as absent rather than clobbering the resolved value.
506
+ if (constructProps.costAllocationEnvironment === undefined &&
507
+ configEnvironment !== "" &&
508
+ configEnvironment !== UNKNOWN_ENVIRONMENT) {
509
+ constructProps.costAllocationEnvironment = configEnvironment;
510
+ }
511
+ const fleet = new Buildkite(computeStack.getStack(), "Buildkite", constructProps);
512
+ computeStack.addConstruct(fleet);
513
+ return fleet;
514
+ }
483
515
  /**
484
516
  * Add a database resource to the default database stack using the factory pattern.
485
517
  * Returns the appropriate database type based on the factory used.
@@ -0,0 +1,20 @@
1
+ import type { Construct } from "constructs";
2
+ export interface BuildkiteAlarmParams {
3
+ readonly buildkiteOrgSlug: string;
4
+ readonly buildkiteQueue: string;
5
+ readonly autoScalingGroupName: string;
6
+ readonly alarmSnsTopicArn?: string;
7
+ }
8
+ /**
9
+ * The fleet's two Phase-1 alarms (design § D14) — the "~zero babysitting"
10
+ * posture is honest only with these:
11
+ *
12
+ * 1. Scaler heartbeat — `ScheduledJobsCount` goes MISSING for 15 minutes.
13
+ * The scaler publishes every poll, so metric absence means the scaler
14
+ * Lambda is dead or failing; `treatMissingData: BREACHING` is the alarm's
15
+ * entire mechanism.
16
+ * 2. Queued with zero capacity — jobs scheduled while the ASG has no
17
+ * in-service instances for 15 minutes: the fleet cannot boot (AMI gone,
18
+ * quota, subnet failure) while work is waiting.
19
+ */
20
+ 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 { Topic } from "aws-cdk-lib/aws-sns";
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
+ if (params.alarmSnsTopicArn !== undefined) {
74
+ const topic = Topic.fromTopicArn(scope, "BuildkiteAlarmTopic", params.alarmSnsTopicArn);
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,233 @@
1
+ import { CfnOutput, Duration, 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 { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
12
+ import { addBuildkiteAlarms } from "./alarms.js";
13
+ import { buildAgentRole } from "./iam.js";
14
+ import { BUILDKITE_STACK_PINS, resolvePinnedAmiId, resolveScalerSarApplicationArn } from "./pins.js";
15
+ import { validateBuildkiteProps } from "./schema.js";
16
+ import { buildBuildkiteUserData } from "./userData.js";
17
+ /**
18
+ * Self-hosted Buildkite agent fleet on the pinned Elastic CI Stack AMIs —
19
+ * scale-to-zero ASG + buildkite-agent-scaler, no secret ever transiting
20
+ * synth, allowlist-only instance IAM. Design:
21
+ * `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md`.
22
+ *
23
+ * A Construct, not a Stack: instantiate via `App.addBuildkite(props)`, which
24
+ * scopes it under the default compute stack so deploy-core's fixed
25
+ * six-category stack selection actually deploys it (the DevSubstrate
26
+ * placement precedent). Network posture is public-subnet + public-IP +
27
+ * zero-ingress (design § D5): `associatePublicIpAddress` MUST be explicit —
28
+ * the wrapper's `!!keyPair` default silently produced no-egress agents in
29
+ * public subnets pre-refactor.
30
+ */
31
+ export class Buildkite extends Construct {
32
+ artifactBucketName;
33
+ secretsBucketName;
34
+ autoScalingGroupName;
35
+ constructor(scope, id, props) {
36
+ super(scope, id);
37
+ const { vpc, ...plainProps } = props;
38
+ const config = validateBuildkiteProps(plainProps);
39
+ const stack = Stack.of(this);
40
+ if (Token.isUnresolved(stack.region)) {
41
+ throw new Error("Buildkite requires a concrete env region at synth (the pinned AMI " +
42
+ "is region-specific). Pass env: { account, region } to the App.");
43
+ }
44
+ const region = stack.region;
45
+ const architecture = deriveCpuArchitecture(config.instanceType);
46
+ const amiId = resolvePinnedAmiId(region, architecture);
47
+ applyCostAllocationTags(this, {
48
+ service: "buildkite",
49
+ domain: "platform",
50
+ ...(config.costAllocationEnvironment !== undefined && {
51
+ environment: config.costAllocationEnvironment
52
+ }),
53
+ ...(config.costAllocationOwner !== undefined && {
54
+ owner: config.costAllocationOwner
55
+ })
56
+ });
57
+ const artifactBucket = new S3Bucket(this, `${id}ArtifactBucket`);
58
+ const managedSecretsBucket = new S3Bucket(this, `${id}ManagedSecretsBucket`);
59
+ if (config.fjallApiKeySsmParameterName !== undefined) {
60
+ this.addFjallApiKeyEnvHook(id, managedSecretsBucket, config.fjallApiKeySsmParameterName, region);
61
+ }
62
+ const parameterArn = (name) => `arn:${stack.partition}:ssm:${region}:${stack.account}:parameter${name}`;
63
+ const agentRole = buildAgentRole(this, `${id}AgentRole`, {
64
+ agentTokenParameterArn: parameterArn(config.agentTokenSsmParameterName),
65
+ ...(config.agentTokenKmsKeyArn !== undefined && {
66
+ agentTokenKmsKeyArn: config.agentTokenKmsKeyArn
67
+ }),
68
+ ...(config.fjallApiKeySsmParameterName !== undefined && {
69
+ fjallApiKeyParameterArn: parameterArn(config.fjallApiKeySsmParameterName)
70
+ }),
71
+ secretsBucketArn: managedSecretsBucket.bucketArn,
72
+ artifactBucketArn: artifactBucket.bucketArn,
73
+ stackArn: stack.stackId,
74
+ logGroupArnPattern: `arn:${stack.partition}:logs:${region}:${stack.account}:log-group:/buildkite/*`
75
+ });
76
+ const userData = buildBuildkiteUserData(config, {
77
+ stackName: stack.stackName,
78
+ region,
79
+ secretsBucketName: managedSecretsBucket.bucketName,
80
+ artifactBucketName: artifactBucket.bucketName
81
+ });
82
+ // Deliberate deviation from upstream's InstanceScaleInProtection
83
+ // (design § D7): the wrapper's `newInstancesProtectedFromScaleIn: false`
84
+ // invariant wins (scale-in-protected instances wedge CFN rollback). Safe
85
+ // because the scaler runs with `DisableScaleIn: "true"` (pinned below) so
86
+ // it never reduces DesiredCapacity — instances leave only by
87
+ // self-termination (scale-in idle / disconnect-after-uptime) or the ASG
88
+ // `maxInstanceLifetime` replacement.
89
+ //
90
+ // `ssmSessionPermissions: false`: the wrapper default attaches
91
+ // `AmazonSSMManagedInstanceCore`, whose account-wide `ssm:GetParameter`
92
+ // would let any build job read every SSM parameter in the account —
93
+ // breaking § D9's allowlist-only posture. The agent role's scoped
94
+ // `sessionManager` inline policy carries the Session Manager actions.
95
+ const ec2Instance = new Ec2Instance(this, `${id}Agent`, {
96
+ serviceName: `${id}Agent`,
97
+ vpc,
98
+ vpcSubnets: { subnetType: SubnetType.PUBLIC },
99
+ associatePublicIpAddress: true,
100
+ instanceType: config.instanceType,
101
+ machineImage: MachineImage.genericLinux({ [region]: amiId }),
102
+ userData,
103
+ role: agentRole,
104
+ ssmSessionPermissions: false,
105
+ blockDevices: [
106
+ {
107
+ deviceName: "/dev/xvda",
108
+ volume: safeEbs(config.agentVolumeSizeGib)
109
+ }
110
+ ],
111
+ minCapacity: config.agentMinInstances,
112
+ maxCapacity: config.agentMaxInstances,
113
+ spotCapacityPercentage: config.spotCapacityPercentage,
114
+ maxInstanceLifetime: Duration.days(config.maxInstanceLifetimeDays),
115
+ tags: {
116
+ Role: "buildkite-agent",
117
+ BuildkiteQueue: config.buildkiteQueue,
118
+ BuildkiteAgentRelease: config.buildkiteAgentRelease,
119
+ AgentsPerInstance: `${config.agentsPerInstance}`
120
+ }
121
+ });
122
+ const autoScalingGroup = ec2Instance.getAutoScalingGroup();
123
+ agentRole.attachInlinePolicy(new Policy(this, `${id}AgentScaleInPolicy`, {
124
+ statements: [
125
+ new PolicyStatement({
126
+ actions: [
127
+ "autoscaling:SetInstanceHealth",
128
+ "autoscaling:TerminateInstanceInAutoScalingGroup"
129
+ ],
130
+ resources: [autoScalingGroup.autoScalingGroupArn]
131
+ })
132
+ ]
133
+ }));
134
+ new SamApplication(this, `${id}AgentScaler`, {
135
+ applicationId: resolveScalerSarApplicationArn(architecture),
136
+ semanticVersion: BUILDKITE_STACK_PINS.scalerVersion,
137
+ parameters: {
138
+ BuildkiteAgentTokenParameter: config.agentTokenSsmParameterName,
139
+ ...(config.agentTokenKmsKeyArn !== undefined && {
140
+ BuildkiteAgentTokenParameterStoreKMSKey: config.agentTokenKmsKeyArn
141
+ }),
142
+ ...(config.rolePermissionsBoundaryArn !== undefined && {
143
+ RolePermissionsBoundaryARN: config.rolePermissionsBoundaryArn
144
+ }),
145
+ BuildkiteQueue: config.buildkiteQueue,
146
+ AgentsPerInstance: `${config.agentsPerInstance}`,
147
+ MinSize: `${config.agentMinInstances}`,
148
+ MaxSize: `${config.agentMaxInstances}`,
149
+ AgentAutoScaleGroup: autoScalingGroup.autoScalingGroupName,
150
+ ScaleOutFactor: config.scaleOutFactor,
151
+ ScaleOutForWaitingJobs: `${config.scaleOutWaitingForJobs}`,
152
+ EventSchedulePeriod: config.scalerEventSchedulePeriod,
153
+ MinPollInterval: config.scalerMinPollInterval,
154
+ LogRetentionDays: `${config.logRetentionDays}`,
155
+ // Pinned, not defaulted: the D7 scale-in posture above is only sound
156
+ // while the scaler never reduces DesiredCapacity. A scaler-side
157
+ // default flip must not change our termination semantics silently.
158
+ DisableScaleIn: "true"
159
+ },
160
+ costAllocationService: "buildkite",
161
+ costAllocationDomain: "buildkite-agent-scaler",
162
+ ...(config.costAllocationEnvironment !== undefined && {
163
+ costAllocationEnvironment: config.costAllocationEnvironment
164
+ })
165
+ });
166
+ addBuildkiteAlarms(this, {
167
+ buildkiteOrgSlug: config.buildkiteOrgSlug,
168
+ buildkiteQueue: config.buildkiteQueue,
169
+ autoScalingGroupName: autoScalingGroup.autoScalingGroupName,
170
+ ...(config.alarmSnsTopicArn !== undefined && {
171
+ alarmSnsTopicArn: config.alarmSnsTopicArn
172
+ })
173
+ });
174
+ this.artifactBucketName = artifactBucket.bucketName;
175
+ this.secretsBucketName = managedSecretsBucket.bucketName;
176
+ this.autoScalingGroupName = autoScalingGroup.autoScalingGroupName;
177
+ new CfnOutput(this, "BuildkiteQueueName", {
178
+ value: config.buildkiteQueue
179
+ });
180
+ new CfnOutput(this, "BuildkiteAgentAsgName", {
181
+ value: autoScalingGroup.autoScalingGroupName
182
+ });
183
+ new CfnOutput(this, "BuildkiteSecretsBucketName", {
184
+ value: managedSecretsBucket.bucketName
185
+ });
186
+ new CfnOutput(this, "BuildkiteArtifactBucketName", {
187
+ value: artifactBucket.bucketName
188
+ });
189
+ }
190
+ /**
191
+ * Ship the per-job `env` hook into the managed secrets bucket. The
192
+ * s3-secrets-hooks plugin sources this file at the start of EVERY job, so
193
+ * FJALL_API_KEY is read fresh from SSM per job — rotation takes effect on
194
+ * the next job with no instance replacement (design § D4(iii)). The script
195
+ * is configuration, not a secret: shipping it through a CDK asset is fine;
196
+ * the secret VALUE only ever moves SSM → instance at job runtime.
197
+ */
198
+ addFjallApiKeyEnvHook(id, secretsBucket, parameterName, region) {
199
+ new BucketDeployment(this, `${id}EnvHookDeployment`, {
200
+ sources: [
201
+ Source.data(FJALL_ENV_HOOK_OBJECT_KEY, buildFjallApiKeyEnvHookScript(parameterName, region))
202
+ ],
203
+ destinationBucket: secretsBucket,
204
+ // The secrets bucket also holds out-of-band objects the deployment
205
+ // does not know about (the git deploy key, design § D4(ii)). The CDK
206
+ // default `prune: true` DELETES every bucket object missing from
207
+ // `sources` on each custom-resource execution — wiping those secrets.
208
+ prune: false,
209
+ retainOnDelete: true
210
+ });
211
+ }
212
+ }
213
+ /**
214
+ * Object key the s3-secrets-hooks plugin sources at the start of every job —
215
+ * upstream contract: `env` at the secrets-bucket root.
216
+ */
217
+ export const FJALL_ENV_HOOK_OBJECT_KEY = "env";
218
+ /**
219
+ * The per-job env hook's script content (design § D4(iii)): FJALL_API_KEY is
220
+ * read fresh from SSM per job, so rotation takes effect on the next job with
221
+ * no instance replacement. Pure — the unit test pins the exact content.
222
+ */
223
+ export function buildFjallApiKeyEnvHookScript(parameterName, region) {
224
+ return [
225
+ `FJALL_API_KEY="$(aws ssm get-parameter --name '${parameterName}' --with-decryption --query Parameter.Value --output text --region '${region}')"`,
226
+ "export FJALL_API_KEY",
227
+ ""
228
+ ].join("\n");
229
+ }
230
+ function deriveCpuArchitecture(instanceTypeIdentifier) {
231
+ const architecture = new InstanceType(instanceTypeIdentifier).architecture;
232
+ return architecture === InstanceArchitecture.ARM_64 ? "arm64" : "amd64";
233
+ }
@@ -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;