@fjall/components-infrastructure 2.34.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 +36 -2
- package/dist/lib/app.js +78 -3
- package/dist/lib/patterns/aws/apexDomainPattern.d.ts +11 -12
- package/dist/lib/patterns/aws/apexDomainPattern.js +11 -31
- package/dist/lib/patterns/aws/buildkite/alarms.d.ts +20 -0
- package/dist/lib/patterns/aws/buildkite/alarms.js +78 -0
- package/dist/lib/patterns/aws/buildkite/buildkite.d.ts +46 -0
- package/dist/lib/patterns/aws/buildkite/buildkite.js +233 -0
- package/dist/lib/patterns/aws/buildkite/iam.d.ts +35 -0
- package/dist/lib/patterns/aws/buildkite/iam.js +176 -0
- package/dist/lib/patterns/aws/buildkite/pins.d.ts +69 -0
- package/dist/lib/patterns/aws/buildkite/pins.js +80 -0
- package/dist/lib/patterns/aws/buildkite/schema.d.ts +80 -0
- package/dist/lib/patterns/aws/buildkite/schema.js +148 -0
- package/dist/lib/patterns/aws/buildkite/userData.d.ts +58 -0
- package/dist/lib/patterns/aws/buildkite/userData.js +226 -0
- package/dist/lib/patterns/aws/buildkite.d.ts +12 -59
- package/dist/lib/patterns/aws/buildkite.js +12 -310
- package/dist/lib/patterns/aws/cdn.js +25 -10
- package/dist/lib/patterns/aws/computeEcs.js +6 -1
- package/dist/lib/patterns/aws/computeEcsTypes.d.ts +8 -3
- package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +13 -4
- package/dist/lib/patterns/aws/delegatedDomainPattern.js +57 -8
- package/dist/lib/patterns/aws/devSubstrate.d.ts +2 -1
- package/dist/lib/patterns/aws/devSubstrate.js +18 -8
- package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +2 -9
- package/dist/lib/patterns/aws/dnsRecordComposer.js +5 -97
- package/dist/lib/patterns/aws/domain.d.ts +3 -2
- package/dist/lib/patterns/aws/domain.js +3 -2
- package/dist/lib/patterns/aws/domainValidation.js +71 -46
- package/dist/lib/patterns/aws/externalRecordsPattern.js +2 -11
- package/dist/lib/patterns/aws/index.d.ts +1 -3
- package/dist/lib/patterns/aws/index.js +2 -3
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +22 -8
- package/dist/lib/patterns/aws/interfaces/index.d.ts +0 -1
- package/dist/lib/patterns/aws/interfaces/index.js +3 -0
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +55 -10
- package/dist/lib/patterns/aws/patternDomain.d.ts +77 -0
- package/dist/lib/patterns/aws/patternDomain.js +198 -0
- package/dist/lib/patterns/aws/payload.js +23 -30
- package/dist/lib/patterns/aws/staticSite.d.ts +1 -0
- package/dist/lib/patterns/aws/staticSite.js +28 -33
- package/dist/lib/resources/aws/base/awsStack.d.ts +8 -0
- package/dist/lib/resources/aws/base/awsStack.js +20 -17
- package/dist/lib/resources/aws/compute/ec2.d.ts +15 -0
- package/dist/lib/resources/aws/compute/ec2.js +20 -1
- package/dist/lib/resources/aws/compute/ecs.d.ts +1 -1
- package/dist/lib/resources/aws/compute/ecs.js +1 -1
- package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +1 -1
- package/dist/lib/resources/aws/compute/ecsNetworking.js +212 -47
- package/dist/lib/resources/aws/compute/ecsTypes.d.ts +60 -9
- package/dist/lib/resources/aws/compute/ecsValidation.d.ts +14 -1
- package/dist/lib/resources/aws/compute/ecsValidation.js +51 -0
- package/dist/lib/resources/aws/networking/dnsRecord/aRecord.js +3 -2
- package/dist/lib/resources/aws/networking/dnsRecord/aaaaRecord.js +3 -2
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +23 -1
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +14 -3
- package/dist/lib/resources/aws/networking/dnsRecord/caaRecord.js +3 -2
- package/dist/lib/resources/aws/networking/dnsRecord/cnameRecord.js +3 -2
- package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.d.ts +10 -1
- package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +21 -4
- package/dist/lib/resources/aws/networking/dnsRecord/mxRecord.js +3 -2
- package/dist/lib/resources/aws/networking/dnsRecord/nsRecord.js +3 -2
- package/dist/lib/resources/aws/networking/dnsRecord/srvRecord.js +3 -2
- package/dist/lib/resources/aws/networking/dnsRecord/txtRecord.js +3 -2
- package/dist/lib/resources/aws/networking/domainCertificate.d.ts +28 -4
- package/dist/lib/resources/aws/networking/domainCertificate.js +24 -10
- package/dist/lib/resources/aws/networking/hostedZone.d.ts +11 -0
- package/dist/lib/resources/aws/networking/hostedZone.js +6 -0
- package/dist/lib/utils/costAllocationTags.d.ts +9 -0
- package/dist/lib/utils/costAllocationTags.js +6 -1
- package/dist/lib/utils/dnsRecordRegistry.d.ts +52 -0
- package/dist/lib/utils/dnsRecordRegistry.js +64 -0
- package/dist/lib/utils/domainTypes.d.ts +43 -36
- package/dist/lib/utils/domainTypes.js +65 -2
- package/dist/lib/utils/managedDomainContext.d.ts +37 -0
- package/dist/lib/utils/managedDomainContext.js +111 -0
- package/package.json +4 -4
|
@@ -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;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { CompositePrincipal, PolicyDocument, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
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 const BUILDKITE_AGENT_IAM_ACTION_ALLOWLIST = [
|
|
15
|
+
"ssm:GetParameter",
|
|
16
|
+
"kms:Decrypt",
|
|
17
|
+
"s3:GetObject",
|
|
18
|
+
"s3:ListBucket",
|
|
19
|
+
"s3:GetObjectVersion",
|
|
20
|
+
"s3:PutObject",
|
|
21
|
+
"s3:PutObjectAcl",
|
|
22
|
+
"s3:PutObjectVersionAcl",
|
|
23
|
+
"logs:CreateLogGroup",
|
|
24
|
+
"logs:CreateLogStream",
|
|
25
|
+
"logs:PutLogEvents",
|
|
26
|
+
"logs:DescribeLogGroups",
|
|
27
|
+
"logs:DescribeLogStreams",
|
|
28
|
+
"logs:PutRetentionPolicy",
|
|
29
|
+
"ssm:DescribeInstanceProperties",
|
|
30
|
+
"ssm:ListAssociations",
|
|
31
|
+
"ssm:PutInventory",
|
|
32
|
+
"ssm:UpdateInstanceInformation",
|
|
33
|
+
"ssmmessages:CreateControlChannel",
|
|
34
|
+
"ssmmessages:CreateDataChannel",
|
|
35
|
+
"ssmmessages:OpenControlChannel",
|
|
36
|
+
"ssmmessages:OpenDataChannel",
|
|
37
|
+
"ec2messages:AcknowledgeMessage",
|
|
38
|
+
"ec2messages:DeleteMessage",
|
|
39
|
+
"ec2messages:FailMessage",
|
|
40
|
+
"ec2messages:GetEndpoint",
|
|
41
|
+
"ec2messages:GetMessages",
|
|
42
|
+
"ec2messages:SendReply",
|
|
43
|
+
"autoscaling:DescribeAutoScalingInstances",
|
|
44
|
+
"autoscaling:SetInstanceHealth",
|
|
45
|
+
"autoscaling:TerminateInstanceInAutoScalingGroup",
|
|
46
|
+
"cloudwatch:PutMetricData",
|
|
47
|
+
"cloudformation:DescribeStackResource",
|
|
48
|
+
"ec2:DescribeTags"
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* Build the agent instance role. Tighter than the upstream v6.68.1 template
|
|
52
|
+
* on three axes: logs actions are scoped to the `/buildkite/*` group
|
|
53
|
+
* namespace (upstream: `*`), `cloudformation:DescribeStackResource` is
|
|
54
|
+
* scoped to the own stack (upstream: `*`), and there is no ECR/docker-login
|
|
55
|
+
* grant surface at all. The remaining `resources: ["*"]` statements are
|
|
56
|
+
* describe-only or instance-inventory actions with no resource-level
|
|
57
|
+
* support.
|
|
58
|
+
*/
|
|
59
|
+
export function buildAgentRole(scope, id, params) {
|
|
60
|
+
return new Role(scope, id, {
|
|
61
|
+
description: "Buildkite agent instance role — allowlist-only, no deploy capability",
|
|
62
|
+
inlinePolicies: buildAgentPolicyDocuments(params),
|
|
63
|
+
assumedBy: new CompositePrincipal(new ServicePrincipal("autoscaling.amazonaws.com"), new ServicePrincipal("ec2.amazonaws.com"))
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function buildAgentPolicyDocuments(params) {
|
|
67
|
+
const documents = {
|
|
68
|
+
readAgentSecrets: new PolicyDocument({
|
|
69
|
+
statements: [
|
|
70
|
+
new PolicyStatement({
|
|
71
|
+
actions: ["ssm:GetParameter"],
|
|
72
|
+
resources: [
|
|
73
|
+
params.agentTokenParameterArn,
|
|
74
|
+
...(params.fjallApiKeyParameterArn !== undefined
|
|
75
|
+
? [params.fjallApiKeyParameterArn]
|
|
76
|
+
: [])
|
|
77
|
+
]
|
|
78
|
+
}),
|
|
79
|
+
...(params.agentTokenKmsKeyArn !== undefined
|
|
80
|
+
? [
|
|
81
|
+
new PolicyStatement({
|
|
82
|
+
actions: ["kms:Decrypt"],
|
|
83
|
+
resources: [params.agentTokenKmsKeyArn]
|
|
84
|
+
})
|
|
85
|
+
]
|
|
86
|
+
: [])
|
|
87
|
+
]
|
|
88
|
+
}),
|
|
89
|
+
readManagedSecretsBucket: new PolicyDocument({
|
|
90
|
+
statements: [
|
|
91
|
+
new PolicyStatement({
|
|
92
|
+
actions: ["s3:GetObject", "s3:ListBucket"],
|
|
93
|
+
resources: [params.secretsBucketArn, `${params.secretsBucketArn}/*`]
|
|
94
|
+
})
|
|
95
|
+
]
|
|
96
|
+
}),
|
|
97
|
+
artifactBucket: new PolicyDocument({
|
|
98
|
+
statements: [
|
|
99
|
+
new PolicyStatement({
|
|
100
|
+
// The Acl grants pair with the pinned BUILDKITE_S3_ACL env value:
|
|
101
|
+
// S3 evaluates s3:PutObjectAcl whenever a PutObject request carries
|
|
102
|
+
// an x-amz-acl header, so uploads fail AccessDenied without them.
|
|
103
|
+
actions: [
|
|
104
|
+
"s3:GetObject",
|
|
105
|
+
"s3:GetObjectVersion",
|
|
106
|
+
"s3:ListBucket",
|
|
107
|
+
"s3:PutObject",
|
|
108
|
+
"s3:PutObjectAcl",
|
|
109
|
+
"s3:PutObjectVersionAcl"
|
|
110
|
+
],
|
|
111
|
+
resources: [params.artifactBucketArn, `${params.artifactBucketArn}/*`]
|
|
112
|
+
})
|
|
113
|
+
]
|
|
114
|
+
}),
|
|
115
|
+
logging: new PolicyDocument({
|
|
116
|
+
statements: [
|
|
117
|
+
new PolicyStatement({
|
|
118
|
+
actions: [
|
|
119
|
+
"logs:CreateLogGroup",
|
|
120
|
+
"logs:CreateLogStream",
|
|
121
|
+
"logs:PutLogEvents",
|
|
122
|
+
"logs:PutRetentionPolicy"
|
|
123
|
+
],
|
|
124
|
+
resources: [
|
|
125
|
+
params.logGroupArnPattern,
|
|
126
|
+
`${params.logGroupArnPattern}:*`
|
|
127
|
+
]
|
|
128
|
+
}),
|
|
129
|
+
new PolicyStatement({
|
|
130
|
+
actions: ["logs:DescribeLogGroups", "logs:DescribeLogStreams"],
|
|
131
|
+
resources: ["*"]
|
|
132
|
+
})
|
|
133
|
+
]
|
|
134
|
+
}),
|
|
135
|
+
describeInstance: new PolicyDocument({
|
|
136
|
+
statements: [
|
|
137
|
+
new PolicyStatement({
|
|
138
|
+
actions: [
|
|
139
|
+
"autoscaling:DescribeAutoScalingInstances",
|
|
140
|
+
"cloudwatch:PutMetricData",
|
|
141
|
+
"ec2:DescribeTags"
|
|
142
|
+
],
|
|
143
|
+
resources: ["*"]
|
|
144
|
+
}),
|
|
145
|
+
new PolicyStatement({
|
|
146
|
+
actions: ["cloudformation:DescribeStackResource"],
|
|
147
|
+
resources: [params.stackArn]
|
|
148
|
+
})
|
|
149
|
+
]
|
|
150
|
+
}),
|
|
151
|
+
sessionManager: new PolicyDocument({
|
|
152
|
+
statements: [
|
|
153
|
+
new PolicyStatement({
|
|
154
|
+
actions: [
|
|
155
|
+
"ssm:DescribeInstanceProperties",
|
|
156
|
+
"ssm:ListAssociations",
|
|
157
|
+
"ssm:PutInventory",
|
|
158
|
+
"ssm:UpdateInstanceInformation",
|
|
159
|
+
"ssmmessages:CreateControlChannel",
|
|
160
|
+
"ssmmessages:CreateDataChannel",
|
|
161
|
+
"ssmmessages:OpenControlChannel",
|
|
162
|
+
"ssmmessages:OpenDataChannel",
|
|
163
|
+
"ec2messages:AcknowledgeMessage",
|
|
164
|
+
"ec2messages:DeleteMessage",
|
|
165
|
+
"ec2messages:FailMessage",
|
|
166
|
+
"ec2messages:GetEndpoint",
|
|
167
|
+
"ec2messages:GetMessages",
|
|
168
|
+
"ec2messages:SendReply"
|
|
169
|
+
],
|
|
170
|
+
resources: ["*"]
|
|
171
|
+
})
|
|
172
|
+
]
|
|
173
|
+
})
|
|
174
|
+
};
|
|
175
|
+
return documents;
|
|
176
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buildkite Elastic CI Stack version pins — the single moving-together
|
|
3
|
+
* contract for everything the `Buildkite` pattern derives from an upstream
|
|
4
|
+
* release. AMI ids, the elastic-stack version, the scaler version and the
|
|
5
|
+
* secrets-plugin floor MUST only change together, regenerated from the
|
|
6
|
+
* upstream template for the new version (runbook:
|
|
7
|
+
* `aiDocs/runbooks/buildkite-stack-bump-runbook.md`).
|
|
8
|
+
*
|
|
9
|
+
* AMI ids come from the `Mappings.AWSRegion2AMI` block of
|
|
10
|
+
* `https://s3.amazonaws.com/buildkite-aws-stack/<version>/aws-stack.yml` and
|
|
11
|
+
* are owner-verified against `ec2 describe-images` (owner MUST be
|
|
12
|
+
* `BUILDKITE_AMI_OWNER_ACCOUNT_ID`) before landing here. The vendored copy of
|
|
13
|
+
* the template lives at
|
|
14
|
+
* `lib/__tests__/fixtures/buildkite/upstream-aws-stack-<version>.yml` and
|
|
15
|
+
* backs the user-data parity test.
|
|
16
|
+
*
|
|
17
|
+
* Explicit ids rather than `MachineImage.lookup` — a name-pattern lookup
|
|
18
|
+
* floats the AMI underneath a pinned stack version, needs lookup credentials
|
|
19
|
+
* at synth time (unavailable in worker synth), and makes synth
|
|
20
|
+
* non-deterministic. Design:
|
|
21
|
+
* `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md` § D2.
|
|
22
|
+
*/
|
|
23
|
+
export declare const BUILDKITE_AMI_OWNER_ACCOUNT_ID = "172840064832";
|
|
24
|
+
export declare const BUILDKITE_CPU_ARCHITECTURES: readonly ["arm64", "amd64"];
|
|
25
|
+
export type BuildkiteCpuArchitecture = (typeof BUILDKITE_CPU_ARCHITECTURES)[number];
|
|
26
|
+
export declare const BUILDKITE_STACK_PINS: {
|
|
27
|
+
/** Upstream elastic-stack release the AMIs and user-data contract match. */
|
|
28
|
+
readonly elasticStackVersion: "v6.68.1";
|
|
29
|
+
/** buildkite-agent-scaler SAR semantic version. */
|
|
30
|
+
readonly scalerVersion: "1.12.0";
|
|
31
|
+
/**
|
|
32
|
+
* Minimum s3-secrets-hooks plugin version baked into the pinned AMIs.
|
|
33
|
+
* Versions bundled with elastic stack v6.41.0–6.41.3 leak secrets to the
|
|
34
|
+
* build log (GHSA fixed in 2.7.0); any bump below this floor must be
|
|
35
|
+
* rejected at review. v6.68.1 bundles >= 2.7.0.
|
|
36
|
+
*/
|
|
37
|
+
readonly secretsPluginFloor: "2.7.0";
|
|
38
|
+
/**
|
|
39
|
+
* buildkite-agent-scaler SAR application ARNs per architecture, from the
|
|
40
|
+
* upstream template's `Mappings` block. The SAR publisher account is
|
|
41
|
+
* us-east-1-global; the ARN region does not constrain deploy region.
|
|
42
|
+
*/
|
|
43
|
+
readonly scalerSarApplicationArns: {
|
|
44
|
+
readonly amd64: "arn:aws:serverlessrepo:us-east-1:172840064832:applications/buildkite-agent-scaler";
|
|
45
|
+
readonly arm64: "arn:aws:serverlessrepo:us-east-1:172840064832:applications/buildkite-agent-scaler-arm64";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Pinned AMI ids per region per architecture. Single-region today
|
|
49
|
+
* (us-east-1 — the fleet's home); add regions by regenerating from the
|
|
50
|
+
* upstream template, never by hand-picking an AMI. Owner-verified
|
|
51
|
+
* 2026-07-18: both ids owned by 172840064832, arm64 image
|
|
52
|
+
* `buildkite-stack-linux-arm64-2026-07-07T09-03-25Z`.
|
|
53
|
+
*/
|
|
54
|
+
readonly amiIdsByRegion: {
|
|
55
|
+
readonly "us-east-1": {
|
|
56
|
+
readonly arm64: "ami-0ca21e2db030163c8";
|
|
57
|
+
readonly amd64: "ami-0a28a471ffade73ed";
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
export type BuildkiteSupportedRegion = keyof typeof BUILDKITE_STACK_PINS.amiIdsByRegion;
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the pinned AMI id for a concrete region + architecture. Throws a
|
|
64
|
+
* synth-time error with the remediation path when the region is not pinned —
|
|
65
|
+
* adding a region is a pins regeneration, not a call-site workaround.
|
|
66
|
+
*/
|
|
67
|
+
export declare function resolvePinnedAmiId(region: string, architecture: BuildkiteCpuArchitecture): string;
|
|
68
|
+
/** Resolve the architecture-matched buildkite-agent-scaler SAR ARN. */
|
|
69
|
+
export declare function resolveScalerSarApplicationArn(architecture: BuildkiteCpuArchitecture): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buildkite Elastic CI Stack version pins — the single moving-together
|
|
3
|
+
* contract for everything the `Buildkite` pattern derives from an upstream
|
|
4
|
+
* release. AMI ids, the elastic-stack version, the scaler version and the
|
|
5
|
+
* secrets-plugin floor MUST only change together, regenerated from the
|
|
6
|
+
* upstream template for the new version (runbook:
|
|
7
|
+
* `aiDocs/runbooks/buildkite-stack-bump-runbook.md`).
|
|
8
|
+
*
|
|
9
|
+
* AMI ids come from the `Mappings.AWSRegion2AMI` block of
|
|
10
|
+
* `https://s3.amazonaws.com/buildkite-aws-stack/<version>/aws-stack.yml` and
|
|
11
|
+
* are owner-verified against `ec2 describe-images` (owner MUST be
|
|
12
|
+
* `BUILDKITE_AMI_OWNER_ACCOUNT_ID`) before landing here. The vendored copy of
|
|
13
|
+
* the template lives at
|
|
14
|
+
* `lib/__tests__/fixtures/buildkite/upstream-aws-stack-<version>.yml` and
|
|
15
|
+
* backs the user-data parity test.
|
|
16
|
+
*
|
|
17
|
+
* Explicit ids rather than `MachineImage.lookup` — a name-pattern lookup
|
|
18
|
+
* floats the AMI underneath a pinned stack version, needs lookup credentials
|
|
19
|
+
* at synth time (unavailable in worker synth), and makes synth
|
|
20
|
+
* non-deterministic. Design:
|
|
21
|
+
* `aiDocs/designs/2026-07-18-buildkite-selfhosted-agents.md` § D2.
|
|
22
|
+
*/
|
|
23
|
+
export const BUILDKITE_AMI_OWNER_ACCOUNT_ID = "172840064832";
|
|
24
|
+
export const BUILDKITE_CPU_ARCHITECTURES = ["arm64", "amd64"];
|
|
25
|
+
export const BUILDKITE_STACK_PINS = {
|
|
26
|
+
/** Upstream elastic-stack release the AMIs and user-data contract match. */
|
|
27
|
+
elasticStackVersion: "v6.68.1",
|
|
28
|
+
/** buildkite-agent-scaler SAR semantic version. */
|
|
29
|
+
scalerVersion: "1.12.0",
|
|
30
|
+
/**
|
|
31
|
+
* Minimum s3-secrets-hooks plugin version baked into the pinned AMIs.
|
|
32
|
+
* Versions bundled with elastic stack v6.41.0–6.41.3 leak secrets to the
|
|
33
|
+
* build log (GHSA fixed in 2.7.0); any bump below this floor must be
|
|
34
|
+
* rejected at review. v6.68.1 bundles >= 2.7.0.
|
|
35
|
+
*/
|
|
36
|
+
secretsPluginFloor: "2.7.0",
|
|
37
|
+
/**
|
|
38
|
+
* buildkite-agent-scaler SAR application ARNs per architecture, from the
|
|
39
|
+
* upstream template's `Mappings` block. The SAR publisher account is
|
|
40
|
+
* us-east-1-global; the ARN region does not constrain deploy region.
|
|
41
|
+
*/
|
|
42
|
+
scalerSarApplicationArns: {
|
|
43
|
+
amd64: "arn:aws:serverlessrepo:us-east-1:172840064832:applications/buildkite-agent-scaler",
|
|
44
|
+
arm64: "arn:aws:serverlessrepo:us-east-1:172840064832:applications/buildkite-agent-scaler-arm64"
|
|
45
|
+
},
|
|
46
|
+
/**
|
|
47
|
+
* Pinned AMI ids per region per architecture. Single-region today
|
|
48
|
+
* (us-east-1 — the fleet's home); add regions by regenerating from the
|
|
49
|
+
* upstream template, never by hand-picking an AMI. Owner-verified
|
|
50
|
+
* 2026-07-18: both ids owned by 172840064832, arm64 image
|
|
51
|
+
* `buildkite-stack-linux-arm64-2026-07-07T09-03-25Z`.
|
|
52
|
+
*/
|
|
53
|
+
amiIdsByRegion: {
|
|
54
|
+
"us-east-1": {
|
|
55
|
+
arm64: "ami-0ca21e2db030163c8",
|
|
56
|
+
amd64: "ami-0a28a471ffade73ed"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
function isSupportedRegion(region) {
|
|
61
|
+
return region in BUILDKITE_STACK_PINS.amiIdsByRegion;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the pinned AMI id for a concrete region + architecture. Throws a
|
|
65
|
+
* synth-time error with the remediation path when the region is not pinned —
|
|
66
|
+
* adding a region is a pins regeneration, not a call-site workaround.
|
|
67
|
+
*/
|
|
68
|
+
export function resolvePinnedAmiId(region, architecture) {
|
|
69
|
+
if (!isSupportedRegion(region)) {
|
|
70
|
+
throw new Error(`Buildkite: no pinned AMI for region '${region}'. Supported: ` +
|
|
71
|
+
`${Object.keys(BUILDKITE_STACK_PINS.amiIdsByRegion).join(", ")}. ` +
|
|
72
|
+
`Add the region to BUILDKITE_STACK_PINS via the bump runbook ` +
|
|
73
|
+
`(aiDocs/runbooks/buildkite-stack-bump-runbook.md).`);
|
|
74
|
+
}
|
|
75
|
+
return BUILDKITE_STACK_PINS.amiIdsByRegion[region][architecture];
|
|
76
|
+
}
|
|
77
|
+
/** Resolve the architecture-matched buildkite-agent-scaler SAR ARN. */
|
|
78
|
+
export function resolveScalerSarApplicationArn(architecture) {
|
|
79
|
+
return BUILDKITE_STACK_PINS.scalerSarApplicationArns[architecture];
|
|
80
|
+
}
|