@kungfu-tech/buildchain 3.0.4 → 3.0.5-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/site/buildchain-contract.json +23 -18
- package/dist/site/buildchain-site.json +29 -24
- package/dist/site/controller-registry.json +6 -2
- package/dist/site/kfd-claims.json +6 -4
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +6 -6
- package/dist/site/node-api-registry.json +96 -5
- package/dist/site/page-registry.json +19 -14
- package/dist/site/public-surface-audit.json +12 -8
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +10 -10
- package/dist/site/workflow-registry.json +10 -6
- package/docs/auditable-demo.md +7 -0
- package/docs/aws-us-elastic-runner-burst-plane.md +32 -1
- package/docs/node-api-reference.md +12 -3
- package/docs/publish-transaction.md +7 -5
- package/docs/release-flow.md +4 -0
- package/docs/release-governance.md +7 -0
- package/docs/release-propagation.md +34 -0
- package/package.json +1 -1
- package/packages/core/release-propagation-capture.js +150 -0
- package/packages/core/release-propagation-stage-evidence.js +1 -1
- package/packages/core/release-propagation.js +5 -0
- package/scripts/auditable-demo-renditions.mjs +2 -3
- package/scripts/aws-macos-jit-controller-core.mjs +389 -0
- package/scripts/aws-macos-jit-controller-runtime.mjs +82 -0
- package/scripts/aws-macos-jit-controller.mjs +558 -0
- package/scripts/aws-macos-jit-job-controller.mjs +401 -0
- package/scripts/capture-package-release-propagation.mjs +240 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertCommitSha,
|
|
3
|
+
} from "./release-propagation-work-control.js";
|
|
4
|
+
import {
|
|
5
|
+
assertExactFields,
|
|
6
|
+
assertPlainObject,
|
|
7
|
+
assertString,
|
|
8
|
+
} from "./release-propagation-common.js";
|
|
9
|
+
import {
|
|
10
|
+
createReleasePropagationWork,
|
|
11
|
+
normalizeReleasePropagationGraph,
|
|
12
|
+
planReleasePropagation,
|
|
13
|
+
verifyReleasePropagationWork,
|
|
14
|
+
} from "./release-propagation.js";
|
|
15
|
+
import { normalizeUpstreamRelease } from "./release-propagation-release.js";
|
|
16
|
+
|
|
17
|
+
export const PACKAGE_RELEASE_PROPAGATION_CONFIG_CONTRACT =
|
|
18
|
+
"kungfu-buildchain-package-release-propagation";
|
|
19
|
+
|
|
20
|
+
function assertAllowedFields(value, allowed, required, label) {
|
|
21
|
+
const object = assertPlainObject(value, label);
|
|
22
|
+
const unknown = Object.keys(object).filter((field) => !allowed.has(field));
|
|
23
|
+
if (unknown.length > 0) {
|
|
24
|
+
throw new Error(`${label} has unknown fields: ${unknown.sort().join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
for (const field of required) {
|
|
27
|
+
if (!(field in object)) throw new Error(`${label}.${field} is required`);
|
|
28
|
+
}
|
|
29
|
+
return object;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assertStrictGraph(graph) {
|
|
33
|
+
const value = assertAllowedFields(
|
|
34
|
+
graph,
|
|
35
|
+
new Set(["schemaVersion", "contract", "channelPolicy", "nodes", "edges"]),
|
|
36
|
+
["schemaVersion", "contract", "nodes", "edges"],
|
|
37
|
+
"package release propagation config graph",
|
|
38
|
+
);
|
|
39
|
+
if (!Array.isArray(value.nodes) || !Array.isArray(value.edges)) {
|
|
40
|
+
throw new Error("package release propagation config graph nodes and edges must be arrays");
|
|
41
|
+
}
|
|
42
|
+
value.nodes.forEach((node, index) => assertAllowedFields(
|
|
43
|
+
node,
|
|
44
|
+
new Set([
|
|
45
|
+
"id", "repository", "package", "lockPath", "baseRef", "workflow",
|
|
46
|
+
"executionProfile",
|
|
47
|
+
]),
|
|
48
|
+
["id", "repository"],
|
|
49
|
+
`package release propagation config graph nodes[${index}]`,
|
|
50
|
+
));
|
|
51
|
+
value.edges.forEach((edge, index) => assertAllowedFields(
|
|
52
|
+
edge,
|
|
53
|
+
new Set([
|
|
54
|
+
"id", "from", "to", "channels", "channelPolicy", "channelMap",
|
|
55
|
+
"lockPath", "prBaseRef",
|
|
56
|
+
]),
|
|
57
|
+
["from", "to"],
|
|
58
|
+
`package release propagation config graph edges[${index}]`,
|
|
59
|
+
));
|
|
60
|
+
return normalizeReleasePropagationGraph(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function normalizePackageReleasePropagationConfig(input) {
|
|
64
|
+
const config = assertExactFields(input, [
|
|
65
|
+
"schemaVersion", "contract", "sourceNode", "graph", "targets",
|
|
66
|
+
], "package release propagation config");
|
|
67
|
+
if (config.schemaVersion !== 1 || config.contract !== PACKAGE_RELEASE_PROPAGATION_CONFIG_CONTRACT) {
|
|
68
|
+
throw new Error(`package release propagation config must use ${PACKAGE_RELEASE_PROPAGATION_CONFIG_CONTRACT} v1`);
|
|
69
|
+
}
|
|
70
|
+
const sourceNode = assertString(config.sourceNode, "package release propagation config sourceNode");
|
|
71
|
+
if (!/^[A-Za-z0-9._-]+$/.test(sourceNode)) {
|
|
72
|
+
throw new Error("package release propagation config sourceNode must be a canonical node id");
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
|
75
|
+
throw new Error("package release propagation config targets must be a non-empty array");
|
|
76
|
+
}
|
|
77
|
+
const targets = config.targets.map((target, index) => {
|
|
78
|
+
const value = assertString(target, `package release propagation config targets[${index}]`);
|
|
79
|
+
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
80
|
+
throw new Error("package release propagation config targets must be canonical node ids");
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
});
|
|
84
|
+
const canonicalTargets = [...new Set(targets)].sort();
|
|
85
|
+
if (JSON.stringify(targets) !== JSON.stringify(canonicalTargets)) {
|
|
86
|
+
throw new Error("package release propagation config targets must be sorted and unique");
|
|
87
|
+
}
|
|
88
|
+
const graph = assertStrictGraph(config.graph);
|
|
89
|
+
const source = graph.nodes.find((node) => node.id === sourceNode);
|
|
90
|
+
if (!source) throw new Error("package release propagation config sourceNode is absent from graph");
|
|
91
|
+
if (!source.package) throw new Error("package release propagation source node must declare a package");
|
|
92
|
+
return {
|
|
93
|
+
schemaVersion: 1,
|
|
94
|
+
contract: PACKAGE_RELEASE_PROPAGATION_CONFIG_CONTRACT,
|
|
95
|
+
sourceNode,
|
|
96
|
+
graph,
|
|
97
|
+
targets: canonicalTargets,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createPackageReleasePropagationCapture({
|
|
102
|
+
config: configInput,
|
|
103
|
+
upstreamRelease: upstreamReleaseInput,
|
|
104
|
+
expectedBaseShas = {},
|
|
105
|
+
} = {}) {
|
|
106
|
+
const config = normalizePackageReleasePropagationConfig(configInput);
|
|
107
|
+
const upstreamRelease = normalizeUpstreamRelease(upstreamReleaseInput);
|
|
108
|
+
const source = config.graph.nodes.find((node) => node.id === config.sourceNode);
|
|
109
|
+
if (source.repository !== upstreamRelease.repository) {
|
|
110
|
+
throw new Error("package release propagation source repository disagrees with release envelope");
|
|
111
|
+
}
|
|
112
|
+
if (source.package !== upstreamRelease.package?.name) {
|
|
113
|
+
throw new Error("package release propagation source package disagrees with release envelope");
|
|
114
|
+
}
|
|
115
|
+
const plan = planReleasePropagation({
|
|
116
|
+
graph: config.graph,
|
|
117
|
+
upstreamRelease,
|
|
118
|
+
sourceNode: config.sourceNode,
|
|
119
|
+
});
|
|
120
|
+
const plannedTargets = plan.targets.map((target) => target.target).sort();
|
|
121
|
+
if (JSON.stringify(plannedTargets) !== JSON.stringify(config.targets)) {
|
|
122
|
+
throw new Error("package release propagation targets must exactly match the release-channel plan");
|
|
123
|
+
}
|
|
124
|
+
const works = plan.targets.map((target) => {
|
|
125
|
+
const expectedBaseSha = expectedBaseShas[target.target]
|
|
126
|
+
|| expectedBaseShas[target.repository]
|
|
127
|
+
|| "";
|
|
128
|
+
assertCommitSha(
|
|
129
|
+
expectedBaseSha,
|
|
130
|
+
`expected downstream base SHA for ${target.target}`,
|
|
131
|
+
);
|
|
132
|
+
const work = createReleasePropagationWork({
|
|
133
|
+
plan,
|
|
134
|
+
target: target.target,
|
|
135
|
+
expectedDownstreamBaseSha: expectedBaseSha,
|
|
136
|
+
});
|
|
137
|
+
const status = verifyReleasePropagationWork(work);
|
|
138
|
+
if (status.lifecycle !== "paused" || status.nextAction?.action !== "claim") {
|
|
139
|
+
throw new Error("captured package propagation work must remain paused at claim");
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
target: target.target,
|
|
143
|
+
repository: target.repository,
|
|
144
|
+
propagationKey: target.propagationKey,
|
|
145
|
+
work,
|
|
146
|
+
status,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
return { config, upstreamRelease, plan, works };
|
|
150
|
+
}
|
|
@@ -58,7 +58,7 @@ function exactClaims(entry, fields, label) {
|
|
|
58
58
|
function releaseClaims(work) {
|
|
59
59
|
return {
|
|
60
60
|
releaseRoot: work.upstream.releaseRoot,
|
|
61
|
-
releaseLockRoot: work.downstream.
|
|
61
|
+
releaseLockRoot: `sha256:${work.downstream.lockSha256}`,
|
|
62
62
|
sourceSha: work.upstream.release.sourceSha,
|
|
63
63
|
tagTargetSha: work.upstream.release.tagTargetSha,
|
|
64
64
|
};
|
|
@@ -471,3 +471,8 @@ export {
|
|
|
471
471
|
recordReleasePropagationStage,
|
|
472
472
|
repairReleasePropagationWork,
|
|
473
473
|
} from "./release-propagation-work-transitions.js";
|
|
474
|
+
export {
|
|
475
|
+
PACKAGE_RELEASE_PROPAGATION_CONFIG_CONTRACT,
|
|
476
|
+
createPackageReleasePropagationCapture,
|
|
477
|
+
normalizePackageReleasePropagationConfig,
|
|
478
|
+
} from "./release-propagation-capture.js";
|
|
@@ -16,7 +16,6 @@ const RENDITION_SET_NON_AUTHORITIES = [
|
|
|
16
16
|
"runtime-authority",
|
|
17
17
|
...TERMINAL_CAPTURE_NON_AUTHORITIES,
|
|
18
18
|
];
|
|
19
|
-
|
|
20
19
|
export function validateTerminalCapture(value, scene, helpers) {
|
|
21
20
|
const { decodeBase64, digestPattern, exactKeys, integer, invariant, maxBytes, maxEvents, text } = helpers;
|
|
22
21
|
exactKeys(
|
|
@@ -53,10 +52,10 @@ export function validateTerminalCapture(value, scene, helpers) {
|
|
|
53
52
|
}
|
|
54
53
|
exactKeys(value.completion, ["schema", "status", "reportRoot", "eventCount"], [], "terminalCapture.completion");
|
|
55
54
|
invariant(
|
|
56
|
-
value.completion.schema
|
|
55
|
+
/^[a-z0-9][a-z0-9._/-]*\/v[1-9][0-9]*$/u.test(value.completion.schema)
|
|
57
56
|
&& value.completion.status === "qualified"
|
|
58
57
|
&& digestPattern.test(value.completion.reportRoot),
|
|
59
|
-
"terminal capture completion sentinel is not a qualified
|
|
58
|
+
"terminal capture completion sentinel is not a qualified versioned result",
|
|
60
59
|
);
|
|
61
60
|
integer(value.completion.eventCount, 1, 100_000, "terminalCapture.completion.eventCount");
|
|
62
61
|
invariant(value.exitCode === 0, "terminal capture exitCode must be zero");
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { digest } from "./aws-runner-burst-core.mjs";
|
|
4
|
+
import {
|
|
5
|
+
MACOS_EC2_JIT,
|
|
6
|
+
macosJitRunnerLabel,
|
|
7
|
+
macosJitRunnerLabels,
|
|
8
|
+
} from "./aws-macos-jit-core.mjs";
|
|
9
|
+
|
|
10
|
+
export const AWS_MACOS_JIT_CONTROLLER_CONTRACT =
|
|
11
|
+
"kungfu-buildchain-aws-macos-jit-controller/v1";
|
|
12
|
+
|
|
13
|
+
function exact(value, pattern, label) {
|
|
14
|
+
const normalized = String(value || "").trim();
|
|
15
|
+
if (!pattern.test(normalized)) throw new Error(`${label} is invalid`);
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function exactSha(value) {
|
|
20
|
+
return exact(value, /^[0-9a-f]{40}$/i, "sourceSha").toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function iso(value, label) {
|
|
24
|
+
const parsed = new Date(value || "");
|
|
25
|
+
if (!Number.isFinite(parsed.getTime())) {
|
|
26
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
27
|
+
}
|
|
28
|
+
return parsed.toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tag(key, value) {
|
|
32
|
+
return { Key: key, Value: String(value) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function repository(value) {
|
|
36
|
+
const resolved = exact(
|
|
37
|
+
value || MACOS_EC2_JIT.repository,
|
|
38
|
+
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/,
|
|
39
|
+
"repository",
|
|
40
|
+
);
|
|
41
|
+
if (resolved !== MACOS_EC2_JIT.repository) {
|
|
42
|
+
throw new Error(`repository must be ${MACOS_EC2_JIT.repository}`);
|
|
43
|
+
}
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function source(values) {
|
|
48
|
+
return {
|
|
49
|
+
sha: exactSha(values.sourceSha),
|
|
50
|
+
ref: exact(
|
|
51
|
+
values.sourceRef,
|
|
52
|
+
/^refs\/heads\/[A-Za-z0-9._/-]+$/,
|
|
53
|
+
"sourceRef",
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function campaignId(value) {
|
|
59
|
+
return exact(value, /^[a-z0-9][a-z0-9-]{2,47}$/, "campaignId");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function qualificationId(value) {
|
|
63
|
+
return exact(value, /^mac-(?:smoke-0[12]|full-01)$/, "qualificationId");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function commonAws(values) {
|
|
67
|
+
const region = exact(
|
|
68
|
+
values.region || MACOS_EC2_JIT.region,
|
|
69
|
+
/^us-[a-z]+-\d$/,
|
|
70
|
+
"region",
|
|
71
|
+
);
|
|
72
|
+
if (region !== MACOS_EC2_JIT.region) {
|
|
73
|
+
throw new Error(`region must be ${MACOS_EC2_JIT.region}`);
|
|
74
|
+
}
|
|
75
|
+
const instanceType = exact(
|
|
76
|
+
values.instanceType || MACOS_EC2_JIT.instanceType,
|
|
77
|
+
/^mac2\.metal$/,
|
|
78
|
+
"instanceType",
|
|
79
|
+
);
|
|
80
|
+
if (instanceType !== MACOS_EC2_JIT.instanceType) {
|
|
81
|
+
throw new Error(`instanceType must be ${MACOS_EC2_JIT.instanceType}`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
region,
|
|
85
|
+
availabilityZone: exact(
|
|
86
|
+
values.availabilityZone,
|
|
87
|
+
/^us-[a-z]+-\d[a-z]$/,
|
|
88
|
+
"availabilityZone",
|
|
89
|
+
),
|
|
90
|
+
instanceType,
|
|
91
|
+
amiId: exact(values.amiId, /^ami-[0-9a-f]+$/, "amiId"),
|
|
92
|
+
amiName: exact(values.amiName, /^[A-Za-z0-9._-]+$/, "amiName"),
|
|
93
|
+
subnetId: exact(values.subnetId, /^subnet-[0-9a-f]+$/, "subnetId"),
|
|
94
|
+
securityGroupId: exact(
|
|
95
|
+
values.securityGroupId,
|
|
96
|
+
/^sg-[0-9a-f]+$/,
|
|
97
|
+
"securityGroupId",
|
|
98
|
+
),
|
|
99
|
+
instanceProfileName: exact(
|
|
100
|
+
values.instanceProfileName,
|
|
101
|
+
/^[A-Za-z0-9+=,.@_-]{1,128}$/,
|
|
102
|
+
"instanceProfileName",
|
|
103
|
+
),
|
|
104
|
+
evidenceBucket: exact(
|
|
105
|
+
values.evidenceBucket,
|
|
106
|
+
/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/,
|
|
107
|
+
"evidenceBucket",
|
|
108
|
+
),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ownershipTags({ id, sourceSha }) {
|
|
113
|
+
return [
|
|
114
|
+
tag("kungfu:owner", "buildchain"),
|
|
115
|
+
tag("kungfu:plane", "aws-us-elastic-runner-burst"),
|
|
116
|
+
tag("kungfu:provider", "macos-ec2-jit"),
|
|
117
|
+
tag("kungfu:campaign-id", id),
|
|
118
|
+
tag("kungfu:source-sha", sourceSha),
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createMacosJitCampaignPlan(values = {}) {
|
|
123
|
+
const id = campaignId(values.campaignId);
|
|
124
|
+
const boundSource = source(values);
|
|
125
|
+
const aws = commonAws(values);
|
|
126
|
+
const tags = ownershipTags({ id, sourceSha: boundSource.sha });
|
|
127
|
+
const createdAt = iso(values.createdAt, "createdAt");
|
|
128
|
+
const plan = {
|
|
129
|
+
schemaVersion: 1,
|
|
130
|
+
contract: AWS_MACOS_JIT_CONTROLLER_CONTRACT,
|
|
131
|
+
kind: "campaign-launch-plan",
|
|
132
|
+
repository: repository(values.repository),
|
|
133
|
+
campaign: { id, createdAt },
|
|
134
|
+
source: boundSource,
|
|
135
|
+
aws: {
|
|
136
|
+
...aws,
|
|
137
|
+
hostTags: tags,
|
|
138
|
+
instanceTags: tags,
|
|
139
|
+
volumeTags: tags,
|
|
140
|
+
hostClientToken: `kungfu-mac-host-${id}`,
|
|
141
|
+
instanceClientToken: `kungfu-mac-instance-${id}`,
|
|
142
|
+
rootVolume: {
|
|
143
|
+
deviceName: "/dev/sda1",
|
|
144
|
+
deleteOnTermination: true,
|
|
145
|
+
encrypted: true,
|
|
146
|
+
volumeSizeGiB: 200,
|
|
147
|
+
volumeType: "gp3",
|
|
148
|
+
},
|
|
149
|
+
metadata: {
|
|
150
|
+
httpEndpoint: "enabled",
|
|
151
|
+
httpTokens: "required",
|
|
152
|
+
httpPutResponseHopLimit: 1,
|
|
153
|
+
instanceMetadataTags: "disabled",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
safety: {
|
|
157
|
+
applyMode: values.execute === true ? "execute" : "dry-run",
|
|
158
|
+
exactSourceRequired: true,
|
|
159
|
+
activeHostCeiling: MACOS_EC2_JIT.maxAcceptedHosts,
|
|
160
|
+
activeInstanceCeiling: 1,
|
|
161
|
+
awsDryRunRequiredBeforeAllocation: true,
|
|
162
|
+
awsDryRunRequiredBeforeLaunch: true,
|
|
163
|
+
retainHostOnInstanceLaunchFailure: true,
|
|
164
|
+
minimumHostAllocationHours: MACOS_EC2_JIT.minimumHostAllocationHours,
|
|
165
|
+
maximumHostAllocationHours: MACOS_EC2_JIT.maximumHostAllocationHours,
|
|
166
|
+
cleanupOwner: "scheduled-card-scoped-reaper",
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
return { ...plan, digest: digest(plan) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function macosAllocateHostsArgs(plan, { dryRun = false } = {}) {
|
|
173
|
+
if (
|
|
174
|
+
plan?.contract !== AWS_MACOS_JIT_CONTROLLER_CONTRACT ||
|
|
175
|
+
plan.kind !== "campaign-launch-plan"
|
|
176
|
+
) {
|
|
177
|
+
throw new Error("macOS JIT campaign launch plan contract is invalid");
|
|
178
|
+
}
|
|
179
|
+
const args = [
|
|
180
|
+
"ec2",
|
|
181
|
+
"allocate-hosts",
|
|
182
|
+
"--availability-zone",
|
|
183
|
+
plan.aws.availabilityZone,
|
|
184
|
+
"--instance-type",
|
|
185
|
+
plan.aws.instanceType,
|
|
186
|
+
"--quantity",
|
|
187
|
+
"1",
|
|
188
|
+
"--auto-placement",
|
|
189
|
+
"off",
|
|
190
|
+
"--client-token",
|
|
191
|
+
plan.aws.hostClientToken,
|
|
192
|
+
"--tag-specifications",
|
|
193
|
+
JSON.stringify([
|
|
194
|
+
{ ResourceType: "dedicated-host", Tags: plan.aws.hostTags },
|
|
195
|
+
]),
|
|
196
|
+
"--output",
|
|
197
|
+
"json",
|
|
198
|
+
];
|
|
199
|
+
if (dryRun) args.splice(2, 0, "--dry-run");
|
|
200
|
+
return args;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function macosRunInstancesArgs(plan, { hostId, dryRun = false } = {}) {
|
|
204
|
+
if (
|
|
205
|
+
plan?.contract !== AWS_MACOS_JIT_CONTROLLER_CONTRACT ||
|
|
206
|
+
plan.kind !== "campaign-launch-plan"
|
|
207
|
+
) {
|
|
208
|
+
throw new Error("macOS JIT campaign launch plan contract is invalid");
|
|
209
|
+
}
|
|
210
|
+
const resolvedHostId = exact(hostId, /^h-[0-9a-f]+$/, "hostId");
|
|
211
|
+
const args = [
|
|
212
|
+
"ec2",
|
|
213
|
+
"run-instances",
|
|
214
|
+
"--image-id",
|
|
215
|
+
plan.aws.amiId,
|
|
216
|
+
"--instance-type",
|
|
217
|
+
plan.aws.instanceType,
|
|
218
|
+
"--count",
|
|
219
|
+
"1",
|
|
220
|
+
"--client-token",
|
|
221
|
+
plan.aws.instanceClientToken,
|
|
222
|
+
"--iam-instance-profile",
|
|
223
|
+
JSON.stringify({ Name: plan.aws.instanceProfileName }),
|
|
224
|
+
"--subnet-id",
|
|
225
|
+
plan.aws.subnetId,
|
|
226
|
+
"--security-group-ids",
|
|
227
|
+
plan.aws.securityGroupId,
|
|
228
|
+
"--associate-public-ip-address",
|
|
229
|
+
"--placement",
|
|
230
|
+
JSON.stringify({ HostId: resolvedHostId, Tenancy: "host" }),
|
|
231
|
+
"--block-device-mappings",
|
|
232
|
+
JSON.stringify([
|
|
233
|
+
{
|
|
234
|
+
DeviceName: plan.aws.rootVolume.deviceName,
|
|
235
|
+
Ebs: {
|
|
236
|
+
DeleteOnTermination: plan.aws.rootVolume.deleteOnTermination,
|
|
237
|
+
Encrypted: plan.aws.rootVolume.encrypted,
|
|
238
|
+
VolumeSize: plan.aws.rootVolume.volumeSizeGiB,
|
|
239
|
+
VolumeType: plan.aws.rootVolume.volumeType,
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
]),
|
|
243
|
+
"--metadata-options",
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
HttpEndpoint: plan.aws.metadata.httpEndpoint,
|
|
246
|
+
HttpTokens: plan.aws.metadata.httpTokens,
|
|
247
|
+
HttpPutResponseHopLimit: plan.aws.metadata.httpPutResponseHopLimit,
|
|
248
|
+
InstanceMetadataTags: plan.aws.metadata.instanceMetadataTags,
|
|
249
|
+
}),
|
|
250
|
+
"--instance-initiated-shutdown-behavior",
|
|
251
|
+
"stop",
|
|
252
|
+
"--tag-specifications",
|
|
253
|
+
JSON.stringify([
|
|
254
|
+
{ ResourceType: "instance", Tags: plan.aws.instanceTags },
|
|
255
|
+
{ ResourceType: "volume", Tags: plan.aws.volumeTags },
|
|
256
|
+
]),
|
|
257
|
+
"--output",
|
|
258
|
+
"json",
|
|
259
|
+
];
|
|
260
|
+
if (dryRun) args.splice(2, 0, "--dry-run");
|
|
261
|
+
return args;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createMacosJitJobPlan(values = {}) {
|
|
265
|
+
const id = campaignId(values.campaignId);
|
|
266
|
+
const boundSource = source(values);
|
|
267
|
+
const qualification = qualificationId(values.qualificationId);
|
|
268
|
+
const runnerLabel = macosJitRunnerLabel(values.runnerLabel);
|
|
269
|
+
const expectedLabel = `${MACOS_EC2_JIT.labelPrefix}${qualification}`;
|
|
270
|
+
if (runnerLabel !== expectedLabel) {
|
|
271
|
+
throw new Error(`runnerLabel must be ${expectedLabel}`);
|
|
272
|
+
}
|
|
273
|
+
const runId = exact(values.runId, /^\d+$/, "runId");
|
|
274
|
+
const runAttempt = exact(
|
|
275
|
+
values.runAttempt || "1",
|
|
276
|
+
/^[1-9]\d*$/,
|
|
277
|
+
"runAttempt",
|
|
278
|
+
);
|
|
279
|
+
const hostAllocatedAt = iso(values.hostAllocatedAt, "hostAllocatedAt");
|
|
280
|
+
const jitParameterName = `${MACOS_EC2_JIT.jitParameterPrefix}${id}/${runId}/${runAttempt}/${qualification}`;
|
|
281
|
+
const plan = {
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
contract: AWS_MACOS_JIT_CONTROLLER_CONTRACT,
|
|
284
|
+
kind: "job-run-plan",
|
|
285
|
+
repository: repository(values.repository),
|
|
286
|
+
campaign: { id },
|
|
287
|
+
source: boundSource,
|
|
288
|
+
github: {
|
|
289
|
+
runId,
|
|
290
|
+
runAttempt,
|
|
291
|
+
jobId: exact(values.jobId, /^\d+$/, "jobId"),
|
|
292
|
+
qualificationId: qualification,
|
|
293
|
+
event: "workflow_dispatch",
|
|
294
|
+
},
|
|
295
|
+
runner: {
|
|
296
|
+
name: exact(
|
|
297
|
+
values.runnerName,
|
|
298
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/,
|
|
299
|
+
"runnerName",
|
|
300
|
+
),
|
|
301
|
+
label: runnerLabel,
|
|
302
|
+
labels: macosJitRunnerLabels(runnerLabel),
|
|
303
|
+
oneJobJit: true,
|
|
304
|
+
},
|
|
305
|
+
aws: {
|
|
306
|
+
...commonAws(values),
|
|
307
|
+
hostId: exact(values.hostId, /^h-[0-9a-f]+$/, "hostId"),
|
|
308
|
+
instanceId: exact(values.instanceId, /^i-[0-9a-f]+$/, "instanceId"),
|
|
309
|
+
hostAllocatedAt,
|
|
310
|
+
jitParameterName,
|
|
311
|
+
},
|
|
312
|
+
safety: {
|
|
313
|
+
applyMode: values.execute === true ? "execute" : "dry-run",
|
|
314
|
+
exactSourceRequired: true,
|
|
315
|
+
queuedJobRequired: true,
|
|
316
|
+
sameCampaignHostRequired: true,
|
|
317
|
+
sameCampaignInstanceRequired: true,
|
|
318
|
+
jitConfigTransport: "0600-temporary-file-to-ssm-secure-string",
|
|
319
|
+
bootstrapTransport: "0600-temporary-file-to-ssm-command",
|
|
320
|
+
jitConfigInBootstrap: false,
|
|
321
|
+
jitConfigInArgv: false,
|
|
322
|
+
cleanupOnCommandFailure: ["ssm-parameter", "github-runner-registration"],
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
return { ...plan, digest: digest(plan) };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function createMacosJitClosePlan(values = {}) {
|
|
329
|
+
const id = campaignId(values.campaignId);
|
|
330
|
+
const boundSource = source(values);
|
|
331
|
+
const hostAllocatedAt = iso(values.hostAllocatedAt, "hostAllocatedAt");
|
|
332
|
+
const observedAt = iso(values.observedAt, "observedAt");
|
|
333
|
+
const allocationHours =
|
|
334
|
+
(new Date(observedAt).getTime() - new Date(hostAllocatedAt).getTime()) /
|
|
335
|
+
3_600_000;
|
|
336
|
+
if (allocationHours < MACOS_EC2_JIT.minimumHostAllocationHours) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Dedicated Host cannot be closed before ${MACOS_EC2_JIT.minimumHostAllocationHours} hours`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const plan = {
|
|
342
|
+
schemaVersion: 1,
|
|
343
|
+
contract: AWS_MACOS_JIT_CONTROLLER_CONTRACT,
|
|
344
|
+
kind: "campaign-close-plan",
|
|
345
|
+
repository: repository(values.repository),
|
|
346
|
+
campaign: { id },
|
|
347
|
+
source: boundSource,
|
|
348
|
+
aws: {
|
|
349
|
+
...commonAws(values),
|
|
350
|
+
hostId: exact(values.hostId, /^h-[0-9a-f]+$/, "hostId"),
|
|
351
|
+
instanceId: exact(values.instanceId, /^i-[0-9a-f]+$/, "instanceId"),
|
|
352
|
+
hostAllocatedAt,
|
|
353
|
+
},
|
|
354
|
+
lifecycle: {
|
|
355
|
+
observedAt,
|
|
356
|
+
allocationHours,
|
|
357
|
+
minimumHostAllocationHours: MACOS_EC2_JIT.minimumHostAllocationHours,
|
|
358
|
+
maximumHostAllocationHours: MACOS_EC2_JIT.maximumHostAllocationHours,
|
|
359
|
+
},
|
|
360
|
+
safety: {
|
|
361
|
+
applyMode: values.execute === true ? "execute" : "dry-run",
|
|
362
|
+
exactCampaignOwnershipRequired: true,
|
|
363
|
+
instanceTerminationRequiredBeforeRelease: true,
|
|
364
|
+
encryptedDeleteOnTerminationVolumeRequired: true,
|
|
365
|
+
awsDryRunRequiredBeforeRelease: true,
|
|
366
|
+
reaperFallback: true,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
return { ...plan, digest: digest(plan) };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function macosReleaseHostsArgs(plan, { dryRun = false } = {}) {
|
|
373
|
+
if (
|
|
374
|
+
plan?.contract !== AWS_MACOS_JIT_CONTROLLER_CONTRACT ||
|
|
375
|
+
plan.kind !== "campaign-close-plan"
|
|
376
|
+
) {
|
|
377
|
+
throw new Error("macOS JIT campaign close plan contract is invalid");
|
|
378
|
+
}
|
|
379
|
+
const args = [
|
|
380
|
+
"ec2",
|
|
381
|
+
"release-hosts",
|
|
382
|
+
"--host-ids",
|
|
383
|
+
plan.aws.hostId,
|
|
384
|
+
"--output",
|
|
385
|
+
"json",
|
|
386
|
+
];
|
|
387
|
+
if (dryRun) args.splice(2, 0, "--dry-run");
|
|
388
|
+
return args;
|
|
389
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export function commandResult(command, args, options = {}) {
|
|
6
|
+
const result = spawnSync(command, args, {
|
|
7
|
+
encoding: "utf8",
|
|
8
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
9
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
10
|
+
input: options.input,
|
|
11
|
+
env: process.env,
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
status: result.status,
|
|
15
|
+
stdout: String(result.stdout || ""),
|
|
16
|
+
stderr: String(result.stderr || result.error?.message || ""),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requireSuccess(result, label) {
|
|
21
|
+
if (result.status !== 0) {
|
|
22
|
+
const detail = (result.stderr || result.stdout).trim().slice(0, 2000);
|
|
23
|
+
throw new Error(`${label} failed${detail ? `: ${detail}` : ""}`);
|
|
24
|
+
}
|
|
25
|
+
return result.stdout;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function jsonResult(result, label) {
|
|
29
|
+
const output = requireSuccess(result, label);
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(output);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`${label} returned invalid JSON`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function awsArgs(plan, profile, serviceArgs) {
|
|
38
|
+
return [
|
|
39
|
+
...(profile ? ["--profile", profile] : []),
|
|
40
|
+
"--region",
|
|
41
|
+
plan.aws.region,
|
|
42
|
+
...serviceArgs,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function awsJson(plan, profile, serviceArgs, label) {
|
|
47
|
+
return jsonResult(
|
|
48
|
+
commandResult("aws", awsArgs(plan, profile, serviceArgs)),
|
|
49
|
+
label,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ghJson(args, input, label) {
|
|
54
|
+
return jsonResult(commandResult("gh", args, { input }), label);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function assertOwnership(resourceTags, plan) {
|
|
58
|
+
const observed = Object.fromEntries(
|
|
59
|
+
(resourceTags || []).map((entry) => [entry.Key, String(entry.Value)]),
|
|
60
|
+
);
|
|
61
|
+
const expected = {
|
|
62
|
+
"kungfu:owner": "buildchain",
|
|
63
|
+
"kungfu:plane": "aws-us-elastic-runner-burst",
|
|
64
|
+
"kungfu:provider": "macos-ec2-jit",
|
|
65
|
+
"kungfu:campaign-id": plan.campaign.id,
|
|
66
|
+
"kungfu:source-sha": plan.source.sha,
|
|
67
|
+
};
|
|
68
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
69
|
+
if (observed[key] !== value) {
|
|
70
|
+
throw new Error(`AWS resource ownership tag ${key} mismatch`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function assertDryRun(result, label) {
|
|
76
|
+
if (
|
|
77
|
+
result.status === 0 ||
|
|
78
|
+
!/DryRunOperation/.test(`${result.stdout}\n${result.stderr}`)
|
|
79
|
+
) {
|
|
80
|
+
throw new Error(`${label} did not return DryRunOperation`);
|
|
81
|
+
}
|
|
82
|
+
}
|