@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 +17 -0
- package/dist/lib/app.js +32 -0
- 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/resources/aws/compute/ec2.d.ts +15 -0
- package/dist/lib/resources/aws/compute/ec2.js +20 -1
- 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,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;
|