@kungfu-tech/buildchain 3.0.7 → 3.0.8
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/contracts/engineering-housekeeper-v1.schema.json +11 -1
- package/contracts/fixtures/engineering-housekeeper-v1/cases.json +10 -0
- package/contracts/release-cut-v1.schema.json +70 -0
- package/contracts/release-train-transition-v1.schema.json +85 -0
- package/contracts/release-train-v1.schema.json +82 -0
- package/dist/site/buildchain-contract.json +302 -16
- package/dist/site/buildchain-site.json +83 -27
- package/dist/site/capability-registry.json +4 -3
- package/dist/site/kfd-claims.json +45 -8
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +22 -6
- package/dist/site/node-api-registry.json +1218 -489
- package/dist/site/page-registry.json +65 -17
- package/dist/site/public-surface-audit.json +13 -8
- package/dist/site/publication-authority-registry.json +2 -4
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/site-manifest.json +18 -10
- package/dist/site/workflow-registry.json +10 -9
- package/docs/MAP.md +1 -1
- package/docs/aws-us-elastic-runner-burst-plane.md +20 -7
- package/docs/engineering-housekeeper.md +61 -28
- package/docs/node-api-reference.md +109 -77
- package/docs/release-governance.md +13 -10
- package/docs/release-train.md +102 -0
- package/docs/reusable-build-surface.md +16 -6
- package/docs/site-bundle-contract.md +5 -1
- package/docs/versioning.md +1 -0
- package/package.json +2 -1
- package/packages/core/buildchain-agent-manuals.js +1 -0
- package/packages/core/buildchain-compatibility-proof.js +577 -0
- package/packages/core/buildchain-contract.js +46 -181
- package/packages/core/engineering-housekeeper-github-client.js +18 -1
- package/packages/core/engineering-housekeeper-github.js +247 -41
- package/packages/core/engineering-housekeeper.js +15 -0
- package/packages/core/release-train.js +520 -0
- package/scripts/aws-macos-jit-controller-core.mjs +33 -11
- package/scripts/aws-macos-jit-controller-runtime.mjs +208 -0
- package/scripts/aws-macos-jit-controller.mjs +50 -37
- package/scripts/aws-macos-jit-core.mjs +41 -3
- package/scripts/buildchain-contract-lock.mjs +6 -0
- package/scripts/dispatch-artifact-signing-authority.mjs +4 -7
- package/scripts/engineering-housekeeper-workflow.mjs +13 -6
- package/scripts/generate-site-bundle.mjs +2 -0
- package/scripts/site-capability-metadata.mjs +1 -0
|
@@ -8,6 +8,7 @@ export const HOUSEKEEPER_REASON_CODES = Object.freeze({
|
|
|
8
8
|
ELIGIBLE_MERGED_BRANCH: "eligible.merged-branch",
|
|
9
9
|
PROTECTED_BRANCH: "branch.protected",
|
|
10
10
|
RETAINED_BRANCH: "branch.retained",
|
|
11
|
+
NOT_TEMPORARY_DEVELOPMENT: "branch.not-temporary-development",
|
|
11
12
|
DEFAULT_BRANCH: "branch.default",
|
|
12
13
|
TARGET_BRANCH: "branch.target",
|
|
13
14
|
OPEN_PR_HEAD: "branch.open-pr-head",
|
|
@@ -28,6 +29,14 @@ export const HOUSEKEEPER_REASON_CODES = Object.freeze({
|
|
|
28
29
|
export const DEFAULT_HOUSEKEEPER_POLICY = Object.freeze({
|
|
29
30
|
protectedPatterns: ["dev/**", "alpha/**", "release/**", "publish-gate/**"],
|
|
30
31
|
retainedPatterns: ["train/**", "authority/**"],
|
|
32
|
+
temporaryBranchPatterns: [
|
|
33
|
+
"feature/**",
|
|
34
|
+
"fix/**",
|
|
35
|
+
"chore/**",
|
|
36
|
+
"docs/**",
|
|
37
|
+
"ci/**",
|
|
38
|
+
"refactor/**",
|
|
39
|
+
],
|
|
31
40
|
pullRequests: Object.freeze({
|
|
32
41
|
reportStale: true,
|
|
33
42
|
label: "",
|
|
@@ -77,6 +86,10 @@ function normalizePolicy(policy = {}) {
|
|
|
77
86
|
...(policy.retainedPatterns ||
|
|
78
87
|
DEFAULT_HOUSEKEEPER_POLICY.retainedPatterns),
|
|
79
88
|
].sort(),
|
|
89
|
+
temporaryBranchPatterns: [
|
|
90
|
+
...(policy.temporaryBranchPatterns ||
|
|
91
|
+
DEFAULT_HOUSEKEEPER_POLICY.temporaryBranchPatterns),
|
|
92
|
+
].sort(),
|
|
80
93
|
pullRequests: {
|
|
81
94
|
reportStale: policy.pullRequests?.reportStale !== false,
|
|
82
95
|
label: String(policy.pullRequests?.label || ""),
|
|
@@ -102,6 +115,8 @@ export function classifyHousekeeperBranch(branch, policyInput = {}) {
|
|
|
102
115
|
reasons.push(HOUSEKEEPER_REASON_CODES.PROTECTED_BRANCH);
|
|
103
116
|
if (matchesAny(policy.retainedPatterns, branch.name))
|
|
104
117
|
reasons.push(HOUSEKEEPER_REASON_CODES.RETAINED_BRANCH);
|
|
118
|
+
if (!matchesAny(policy.temporaryBranchPatterns, branch.name))
|
|
119
|
+
reasons.push(HOUSEKEEPER_REASON_CODES.NOT_TEMPORARY_DEVELOPMENT);
|
|
105
120
|
if (branch.sourceRepository && branch.sourceRepository !== branch.repository)
|
|
106
121
|
reasons.push(HOUSEKEEPER_REASON_CODES.CROSS_REPOSITORY);
|
|
107
122
|
if ((branch.openPullRequestNumbers || []).length > 0)
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const RELEASE_CUT_CONTRACT = "kungfu-buildchain-release-cut/v1";
|
|
6
|
+
export const RELEASE_TRAIN_CONTRACT = "kungfu-buildchain-release-train/v1";
|
|
7
|
+
export const RELEASE_TRAIN_TRANSITION_CONTRACT =
|
|
8
|
+
"kungfu-buildchain-release-train-transition/v1";
|
|
9
|
+
export const RELEASE_TRAIN_OBSERVATION_CONTRACT =
|
|
10
|
+
"kungfu-buildchain-release-train-observation/v1";
|
|
11
|
+
export const LEGACY_DEV_ALPHA_CANDIDATE_STATE_SCHEMA =
|
|
12
|
+
"kungfu-buildchain-dev-alpha-candidate-state/v1";
|
|
13
|
+
|
|
14
|
+
export const RELEASE_TRAIN_STATES = Object.freeze([
|
|
15
|
+
"preparing",
|
|
16
|
+
"building",
|
|
17
|
+
"repair-required",
|
|
18
|
+
"publication-blocked",
|
|
19
|
+
"publishable",
|
|
20
|
+
"superseded",
|
|
21
|
+
"terminal",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const RELEASE_TRAIN_SUPERSESSION_CAUSES = Object.freeze([
|
|
25
|
+
"incompatible-semantics",
|
|
26
|
+
"alpha-base-incompatibility",
|
|
27
|
+
"invalid-authority",
|
|
28
|
+
"severe-security",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
32
|
+
const ROOT = /^sha256:[0-9a-f]{64}$/u;
|
|
33
|
+
const ABSENT_ROOT = `sha256:${"0".repeat(64)}`;
|
|
34
|
+
const SUPERSESSION_CAUSES = new Set(RELEASE_TRAIN_SUPERSESSION_CAUSES);
|
|
35
|
+
const TRANSITIONS = new Map([
|
|
36
|
+
["preparing", new Set(["building", "repair-required", "superseded"])],
|
|
37
|
+
[
|
|
38
|
+
"building",
|
|
39
|
+
new Set([
|
|
40
|
+
"repair-required",
|
|
41
|
+
"publication-blocked",
|
|
42
|
+
"publishable",
|
|
43
|
+
"superseded",
|
|
44
|
+
]),
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
"repair-required",
|
|
48
|
+
new Set(["building", "publication-blocked", "superseded"]),
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
"publication-blocked",
|
|
52
|
+
new Set(["repair-required", "publishable", "superseded"]),
|
|
53
|
+
],
|
|
54
|
+
["publishable", new Set(["terminal", "repair-required", "superseded"])],
|
|
55
|
+
["superseded", new Set()],
|
|
56
|
+
["terminal", new Set()],
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
function canonical(value) {
|
|
60
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
61
|
+
if (value && typeof value === "object") {
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
Object.entries(value)
|
|
64
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
65
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function releaseTrainRoot(value) {
|
|
72
|
+
return `sha256:${crypto
|
|
73
|
+
.createHash("sha256")
|
|
74
|
+
.update(JSON.stringify(canonical(value)))
|
|
75
|
+
.digest("hex")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clone(value) {
|
|
79
|
+
return structuredClone(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function text(value, label) {
|
|
83
|
+
const normalized = String(value ?? "").trim();
|
|
84
|
+
if (!normalized) throw new Error(`${label} is required`);
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function exactSha(value, label) {
|
|
89
|
+
const normalized = text(value, label).toLowerCase();
|
|
90
|
+
if (!SHA.test(normalized))
|
|
91
|
+
throw new Error(`${label} must be an exact 40-character Git SHA`);
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function contentRoot(value, label) {
|
|
96
|
+
const normalized = text(value, label).toLowerCase();
|
|
97
|
+
if (!ROOT.test(normalized))
|
|
98
|
+
throw new Error(`${label} must be a sha256 content root`);
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function positiveInteger(value, label) {
|
|
103
|
+
const normalized = Number(value);
|
|
104
|
+
if (!Number.isSafeInteger(normalized) || normalized < 1)
|
|
105
|
+
throw new Error(`${label} must be a positive integer`);
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function timestamp(value, label) {
|
|
110
|
+
const normalized = text(value, label);
|
|
111
|
+
const milliseconds = Date.parse(normalized);
|
|
112
|
+
if (!Number.isFinite(milliseconds))
|
|
113
|
+
throw new Error(`${label} must be an ISO-8601 timestamp`);
|
|
114
|
+
const canonicalTimestamp = new Date(milliseconds).toISOString();
|
|
115
|
+
if (normalized !== canonicalTimestamp)
|
|
116
|
+
throw new Error(`${label} must be a canonical ISO-8601 timestamp`);
|
|
117
|
+
return normalized;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function repository(value) {
|
|
121
|
+
const normalized = text(value, "repository");
|
|
122
|
+
if (!/^[^/\s]+\/[^/\s]+$/u.test(normalized))
|
|
123
|
+
throw new Error("repository must be owner/repo");
|
|
124
|
+
return normalized;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function branch(value, label) {
|
|
128
|
+
const normalized = text(value, label).replace(/^refs\/heads\//u, "");
|
|
129
|
+
if (/\s/u.test(normalized))
|
|
130
|
+
throw new Error(`${label} must not contain spaces`);
|
|
131
|
+
return normalized;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sortedRoots(values, label) {
|
|
135
|
+
if (!Array.isArray(values) || values.length === 0)
|
|
136
|
+
throw new Error(`${label} must be a non-empty array`);
|
|
137
|
+
const normalized = values.map((value, index) =>
|
|
138
|
+
contentRoot(value, `${label}[${index}]`),
|
|
139
|
+
);
|
|
140
|
+
const expected = [...new Set(normalized)].sort();
|
|
141
|
+
if (
|
|
142
|
+
normalized.length !== expected.length ||
|
|
143
|
+
normalized.some((value, index) => value !== expected[index])
|
|
144
|
+
) {
|
|
145
|
+
throw new Error(`${label} must be sorted and duplicate-free`);
|
|
146
|
+
}
|
|
147
|
+
return normalized;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assertExactFields(value, expected, label) {
|
|
151
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
152
|
+
throw new Error(`${label} must be an object`);
|
|
153
|
+
const actual = Object.keys(value).sort();
|
|
154
|
+
const keys = [...expected].sort();
|
|
155
|
+
if (
|
|
156
|
+
actual.length !== keys.length ||
|
|
157
|
+
actual.some((key, index) => key !== keys[index])
|
|
158
|
+
) {
|
|
159
|
+
throw new Error(`${label} has an invalid field set`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeSupersession(value, generation) {
|
|
164
|
+
if (generation === 1) {
|
|
165
|
+
if (value !== null && value !== undefined)
|
|
166
|
+
throw new Error("generation 1 cannot supersede a prior Release Cut");
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
assertExactFields(value, ["cause", "priorCutRoot"], "supersession");
|
|
170
|
+
const cause = text(value.cause, "supersession.cause");
|
|
171
|
+
if (!SUPERSESSION_CAUSES.has(cause))
|
|
172
|
+
throw new Error(`unsupported Release Cut supersession cause: ${cause}`);
|
|
173
|
+
return {
|
|
174
|
+
cause,
|
|
175
|
+
priorCutRoot: contentRoot(value.priorCutRoot, "supersession.priorCutRoot"),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createReleaseCut(input = {}) {
|
|
180
|
+
const generation = positiveInteger(input.generation ?? 1, "generation");
|
|
181
|
+
const body = {
|
|
182
|
+
schemaVersion: 1,
|
|
183
|
+
contract: RELEASE_CUT_CONTRACT,
|
|
184
|
+
repository: repository(input.repository),
|
|
185
|
+
sourceBranch: branch(input.sourceBranch, "sourceBranch"),
|
|
186
|
+
targetBranch: branch(input.targetBranch, "targetBranch"),
|
|
187
|
+
originDevSha: exactSha(input.originDevSha, "originDevSha"),
|
|
188
|
+
candidateSha: exactSha(input.candidateSha, "candidateSha"),
|
|
189
|
+
candidateTreeSha: exactSha(input.candidateTreeSha, "candidateTreeSha"),
|
|
190
|
+
alphaBaseSha: exactSha(input.alphaBaseSha, "alphaBaseSha"),
|
|
191
|
+
buildchainRuntimeSha: exactSha(
|
|
192
|
+
input.buildchainRuntimeSha,
|
|
193
|
+
"buildchainRuntimeSha",
|
|
194
|
+
),
|
|
195
|
+
generation,
|
|
196
|
+
authorityRoots: sortedRoots(input.authorityRoots, "authorityRoots"),
|
|
197
|
+
supersession: normalizeSupersession(input.supersession, generation),
|
|
198
|
+
createdAt: timestamp(input.createdAt, "createdAt"),
|
|
199
|
+
};
|
|
200
|
+
return { ...body, cutRoot: releaseTrainRoot(body) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function initialState(releaseCut) {
|
|
204
|
+
const body = {
|
|
205
|
+
status: "preparing",
|
|
206
|
+
generation: releaseCut.generation,
|
|
207
|
+
priorStateRoot: ABSENT_ROOT,
|
|
208
|
+
transitionRoot: ABSENT_ROOT,
|
|
209
|
+
};
|
|
210
|
+
return { ...body, stateRoot: releaseTrainRoot(body) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createReleaseTrain(input = {}) {
|
|
214
|
+
const releaseCut = createReleaseCut(input.releaseCut || input);
|
|
215
|
+
const identity = {
|
|
216
|
+
schemaVersion: 1,
|
|
217
|
+
contract: RELEASE_TRAIN_CONTRACT,
|
|
218
|
+
releaseCut,
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
...identity,
|
|
222
|
+
trainRoot: releaseTrainRoot(identity),
|
|
223
|
+
state: initialState(releaseCut),
|
|
224
|
+
transitions: [],
|
|
225
|
+
observations: [],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function validateReleaseCut(cut) {
|
|
230
|
+
assertExactFields(
|
|
231
|
+
cut,
|
|
232
|
+
[
|
|
233
|
+
"schemaVersion",
|
|
234
|
+
"contract",
|
|
235
|
+
"repository",
|
|
236
|
+
"sourceBranch",
|
|
237
|
+
"targetBranch",
|
|
238
|
+
"originDevSha",
|
|
239
|
+
"candidateSha",
|
|
240
|
+
"candidateTreeSha",
|
|
241
|
+
"alphaBaseSha",
|
|
242
|
+
"buildchainRuntimeSha",
|
|
243
|
+
"generation",
|
|
244
|
+
"authorityRoots",
|
|
245
|
+
"supersession",
|
|
246
|
+
"createdAt",
|
|
247
|
+
"cutRoot",
|
|
248
|
+
],
|
|
249
|
+
"Release Cut",
|
|
250
|
+
);
|
|
251
|
+
const normalized = createReleaseCut(cut);
|
|
252
|
+
if (cut.cutRoot !== normalized.cutRoot)
|
|
253
|
+
throw new Error("Release Cut root does not match its content");
|
|
254
|
+
return normalized;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function transitionRequest(input, train) {
|
|
258
|
+
const to = text(input.to, "transition.to");
|
|
259
|
+
if (!RELEASE_TRAIN_STATES.includes(to))
|
|
260
|
+
throw new Error(`unsupported Release Train state: ${to}`);
|
|
261
|
+
const expectedStateRoot = contentRoot(
|
|
262
|
+
input.expectedStateRoot,
|
|
263
|
+
"transition.expectedStateRoot",
|
|
264
|
+
);
|
|
265
|
+
const superseding = to === "superseded";
|
|
266
|
+
const cause = input.supersessionCause
|
|
267
|
+
? text(input.supersessionCause, "transition.supersessionCause")
|
|
268
|
+
: "";
|
|
269
|
+
if (superseding && !SUPERSESSION_CAUSES.has(cause))
|
|
270
|
+
throw new Error("superseded transitions require an enumerated cause");
|
|
271
|
+
if (
|
|
272
|
+
!superseding &&
|
|
273
|
+
(cause || input.replacementCutRoot || input.replacementCandidateSha)
|
|
274
|
+
)
|
|
275
|
+
throw new Error(
|
|
276
|
+
"supersession fields are only valid for superseded transitions",
|
|
277
|
+
);
|
|
278
|
+
return {
|
|
279
|
+
contract: RELEASE_TRAIN_TRANSITION_CONTRACT,
|
|
280
|
+
trainRoot: train.trainRoot,
|
|
281
|
+
expectedStateRoot,
|
|
282
|
+
to,
|
|
283
|
+
event: text(input.event, "transition.event"),
|
|
284
|
+
reason: text(input.reason, "transition.reason"),
|
|
285
|
+
authorityRoots: sortedRoots(
|
|
286
|
+
input.authorityRoots,
|
|
287
|
+
"transition.authorityRoots",
|
|
288
|
+
),
|
|
289
|
+
...(superseding
|
|
290
|
+
? {
|
|
291
|
+
supersessionCause: cause,
|
|
292
|
+
replacementCutRoot: contentRoot(
|
|
293
|
+
input.replacementCutRoot,
|
|
294
|
+
"transition.replacementCutRoot",
|
|
295
|
+
),
|
|
296
|
+
replacementCandidateSha: exactSha(
|
|
297
|
+
input.replacementCandidateSha,
|
|
298
|
+
"transition.replacementCandidateSha",
|
|
299
|
+
),
|
|
300
|
+
}
|
|
301
|
+
: {}),
|
|
302
|
+
recordedAt: timestamp(input.recordedAt, "transition.recordedAt"),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function applyTransition(train, input) {
|
|
307
|
+
const request = transitionRequest(input, train);
|
|
308
|
+
const requestRoot = releaseTrainRoot(request);
|
|
309
|
+
const last = train.transitions.at(-1);
|
|
310
|
+
if (last?.requestRoot === requestRoot) return train;
|
|
311
|
+
if (request.expectedStateRoot !== train.state.stateRoot)
|
|
312
|
+
throw new Error("Release Train transition compare-and-swap failed");
|
|
313
|
+
if (!TRANSITIONS.get(train.state.status)?.has(request.to))
|
|
314
|
+
throw new Error(
|
|
315
|
+
`invalid Release Train transition: ${train.state.status} -> ${request.to}`,
|
|
316
|
+
);
|
|
317
|
+
const transition = {
|
|
318
|
+
...request,
|
|
319
|
+
from: train.state.status,
|
|
320
|
+
requestRoot,
|
|
321
|
+
};
|
|
322
|
+
transition.transitionRoot = releaseTrainRoot(transition);
|
|
323
|
+
const stateBody = {
|
|
324
|
+
status: request.to,
|
|
325
|
+
generation: train.releaseCut.generation,
|
|
326
|
+
priorStateRoot: train.state.stateRoot,
|
|
327
|
+
transitionRoot: transition.transitionRoot,
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
...train,
|
|
331
|
+
state: { ...stateBody, stateRoot: releaseTrainRoot(stateBody) },
|
|
332
|
+
transitions: [...train.transitions, transition],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function transitionReleaseTrain(trainInput, input = {}) {
|
|
337
|
+
return applyTransition(validateReleaseTrain(trainInput), input);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function applyObservation(train, input) {
|
|
341
|
+
const body = {
|
|
342
|
+
contract: RELEASE_TRAIN_OBSERVATION_CONTRACT,
|
|
343
|
+
trainRoot: train.trainRoot,
|
|
344
|
+
observedDevSha: exactSha(input.observedDevSha, "observedDevSha"),
|
|
345
|
+
observedAt: timestamp(input.observedAt, "observedAt"),
|
|
346
|
+
};
|
|
347
|
+
const observation = { ...body, observationRoot: releaseTrainRoot(body) };
|
|
348
|
+
if (
|
|
349
|
+
train.observations.some(
|
|
350
|
+
(entry) => entry.observationRoot === observation.observationRoot,
|
|
351
|
+
)
|
|
352
|
+
) {
|
|
353
|
+
return train;
|
|
354
|
+
}
|
|
355
|
+
return { ...train, observations: [...train.observations, observation] };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function observeReleaseTrain(trainInput, input = {}) {
|
|
359
|
+
return applyObservation(validateReleaseTrain(trainInput), input);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function validateReleaseTrain(input) {
|
|
363
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
364
|
+
throw new Error("Release Train must be an object");
|
|
365
|
+
if (
|
|
366
|
+
input.contract !== RELEASE_TRAIN_CONTRACT ||
|
|
367
|
+
Number(input.schemaVersion) !== 1
|
|
368
|
+
) {
|
|
369
|
+
throw new Error(`Release Train must use ${RELEASE_TRAIN_CONTRACT}`);
|
|
370
|
+
}
|
|
371
|
+
assertExactFields(
|
|
372
|
+
input,
|
|
373
|
+
[
|
|
374
|
+
"schemaVersion",
|
|
375
|
+
"contract",
|
|
376
|
+
"releaseCut",
|
|
377
|
+
"trainRoot",
|
|
378
|
+
"state",
|
|
379
|
+
"transitions",
|
|
380
|
+
"observations",
|
|
381
|
+
],
|
|
382
|
+
"Release Train",
|
|
383
|
+
);
|
|
384
|
+
const releaseCut = validateReleaseCut(input.releaseCut);
|
|
385
|
+
const identity = {
|
|
386
|
+
schemaVersion: 1,
|
|
387
|
+
contract: RELEASE_TRAIN_CONTRACT,
|
|
388
|
+
releaseCut,
|
|
389
|
+
};
|
|
390
|
+
if (input.trainRoot !== releaseTrainRoot(identity))
|
|
391
|
+
throw new Error("Release Train root does not match its frozen Release Cut");
|
|
392
|
+
if (!RELEASE_TRAIN_STATES.includes(input.state?.status))
|
|
393
|
+
throw new Error("Release Train state is invalid");
|
|
394
|
+
assertExactFields(
|
|
395
|
+
input.state,
|
|
396
|
+
["status", "generation", "priorStateRoot", "transitionRoot", "stateRoot"],
|
|
397
|
+
"Release Train state",
|
|
398
|
+
);
|
|
399
|
+
if (input.state.generation !== releaseCut.generation)
|
|
400
|
+
throw new Error(
|
|
401
|
+
"Release Train state generation drifted from its Release Cut",
|
|
402
|
+
);
|
|
403
|
+
const stateBody = {
|
|
404
|
+
status: input.state.status,
|
|
405
|
+
generation: input.state.generation,
|
|
406
|
+
priorStateRoot: contentRoot(
|
|
407
|
+
input.state.priorStateRoot,
|
|
408
|
+
"state.priorStateRoot",
|
|
409
|
+
),
|
|
410
|
+
transitionRoot: contentRoot(
|
|
411
|
+
input.state.transitionRoot,
|
|
412
|
+
"state.transitionRoot",
|
|
413
|
+
),
|
|
414
|
+
};
|
|
415
|
+
if (input.state.stateRoot !== releaseTrainRoot(stateBody))
|
|
416
|
+
throw new Error("Release Train state root does not match its content");
|
|
417
|
+
if (!Array.isArray(input.transitions) || !Array.isArray(input.observations))
|
|
418
|
+
throw new Error(
|
|
419
|
+
"Release Train transitions and observations must be arrays",
|
|
420
|
+
);
|
|
421
|
+
const replay = createReleaseTrain({ releaseCut });
|
|
422
|
+
let rebuilt = replay;
|
|
423
|
+
for (const [index, transition] of input.transitions.entries()) {
|
|
424
|
+
assertExactFields(
|
|
425
|
+
transition,
|
|
426
|
+
[
|
|
427
|
+
"contract",
|
|
428
|
+
"trainRoot",
|
|
429
|
+
"expectedStateRoot",
|
|
430
|
+
"to",
|
|
431
|
+
"event",
|
|
432
|
+
"reason",
|
|
433
|
+
"authorityRoots",
|
|
434
|
+
...(transition.to === "superseded"
|
|
435
|
+
? [
|
|
436
|
+
"supersessionCause",
|
|
437
|
+
"replacementCutRoot",
|
|
438
|
+
"replacementCandidateSha",
|
|
439
|
+
]
|
|
440
|
+
: []),
|
|
441
|
+
"recordedAt",
|
|
442
|
+
"from",
|
|
443
|
+
"requestRoot",
|
|
444
|
+
"transitionRoot",
|
|
445
|
+
],
|
|
446
|
+
`Release Train transition ${index}`,
|
|
447
|
+
);
|
|
448
|
+
rebuilt = applyTransition(rebuilt, transition);
|
|
449
|
+
const replayed = rebuilt.transitions.at(-1);
|
|
450
|
+
if (
|
|
451
|
+
rebuilt.transitions.length !== index + 1 ||
|
|
452
|
+
releaseTrainRoot(replayed) !== releaseTrainRoot(transition)
|
|
453
|
+
) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
"Release Train transition root does not match its content",
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (rebuilt.state.stateRoot !== input.state.stateRoot)
|
|
460
|
+
throw new Error(
|
|
461
|
+
"Release Train state chain does not replay to the current state",
|
|
462
|
+
);
|
|
463
|
+
for (const [index, observation] of input.observations.entries()) {
|
|
464
|
+
assertExactFields(
|
|
465
|
+
observation,
|
|
466
|
+
[
|
|
467
|
+
"contract",
|
|
468
|
+
"trainRoot",
|
|
469
|
+
"observedDevSha",
|
|
470
|
+
"observedAt",
|
|
471
|
+
"observationRoot",
|
|
472
|
+
],
|
|
473
|
+
`Release Train observation ${index}`,
|
|
474
|
+
);
|
|
475
|
+
rebuilt = applyObservation(rebuilt, observation);
|
|
476
|
+
const replayed = rebuilt.observations.at(-1);
|
|
477
|
+
if (
|
|
478
|
+
rebuilt.observations.length !== index + 1 ||
|
|
479
|
+
releaseTrainRoot(replayed) !== releaseTrainRoot(observation)
|
|
480
|
+
) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"Release Train observation root does not match its content",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (rebuilt.observations.length !== input.observations.length)
|
|
487
|
+
throw new Error("Release Train observations must be unique");
|
|
488
|
+
return clone(input);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function readReleaseTrain(input) {
|
|
492
|
+
if (input?.contract === RELEASE_TRAIN_CONTRACT) {
|
|
493
|
+
return {
|
|
494
|
+
format: "release-train-v1",
|
|
495
|
+
authoritative: true,
|
|
496
|
+
train: validateReleaseTrain(input),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (input?.schema === LEGACY_DEV_ALPHA_CANDIDATE_STATE_SCHEMA) {
|
|
500
|
+
const generation = positiveInteger(input.generation, "legacy generation");
|
|
501
|
+
const candidate = input.activeCandidate || input.nextCandidate;
|
|
502
|
+
return {
|
|
503
|
+
format: "legacy-dev-alpha-candidate-state-v1",
|
|
504
|
+
authoritative: false,
|
|
505
|
+
train: null,
|
|
506
|
+
legacy: {
|
|
507
|
+
generation,
|
|
508
|
+
candidateSha: candidate
|
|
509
|
+
? exactSha(candidate.sourceSha, "legacy candidate sourceSha")
|
|
510
|
+
: "",
|
|
511
|
+
stateRoot: ROOT.test(String(input.stateRoot || ""))
|
|
512
|
+
? String(input.stateRoot)
|
|
513
|
+
: "",
|
|
514
|
+
},
|
|
515
|
+
reason:
|
|
516
|
+
"legacy candidate state can be read but cannot manufacture Release Cut authority roots",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
throw new Error("unsupported Release Train record");
|
|
520
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { digest } from "./aws-runner-burst-core.mjs";
|
|
4
4
|
import {
|
|
5
5
|
MACOS_EC2_JIT,
|
|
6
|
+
macosJitRegionConfig,
|
|
6
7
|
macosJitRunnerLabel,
|
|
7
8
|
macosJitRunnerLabels,
|
|
8
9
|
} from "./aws-macos-jit-core.mjs";
|
|
@@ -69,9 +70,7 @@ function commonAws(values) {
|
|
|
69
70
|
/^us-[a-z]+-\d$/,
|
|
70
71
|
"region",
|
|
71
72
|
);
|
|
72
|
-
|
|
73
|
-
throw new Error(`region must be ${MACOS_EC2_JIT.region}`);
|
|
74
|
-
}
|
|
73
|
+
const regionConfig = macosJitRegionConfig(region);
|
|
75
74
|
const instanceType = exact(
|
|
76
75
|
values.instanceType || MACOS_EC2_JIT.instanceType,
|
|
77
76
|
/^mac2\.metal$/,
|
|
@@ -80,13 +79,18 @@ function commonAws(values) {
|
|
|
80
79
|
if (instanceType !== MACOS_EC2_JIT.instanceType) {
|
|
81
80
|
throw new Error(`instanceType must be ${MACOS_EC2_JIT.instanceType}`);
|
|
82
81
|
}
|
|
82
|
+
const availabilityZone = exact(
|
|
83
|
+
values.availabilityZone,
|
|
84
|
+
/^us-[a-z]+-\d[a-z]$/,
|
|
85
|
+
"availabilityZone",
|
|
86
|
+
);
|
|
87
|
+
if (!availabilityZone.startsWith(region)) {
|
|
88
|
+
throw new Error(`availabilityZone must belong to ${region}`);
|
|
89
|
+
}
|
|
83
90
|
return {
|
|
84
91
|
region,
|
|
85
|
-
availabilityZone
|
|
86
|
-
|
|
87
|
-
/^us-[a-z]+-\d[a-z]$/,
|
|
88
|
-
"availabilityZone",
|
|
89
|
-
),
|
|
92
|
+
availabilityZone,
|
|
93
|
+
controlPlaneStack: regionConfig.stack,
|
|
90
94
|
instanceType,
|
|
91
95
|
amiId: exact(values.amiId, /^ami-[0-9a-f]+$/, "amiId"),
|
|
92
96
|
amiName: exact(values.amiName, /^[A-Za-z0-9._-]+$/, "amiName"),
|
|
@@ -123,6 +127,7 @@ export function createMacosJitCampaignPlan(values = {}) {
|
|
|
123
127
|
const id = campaignId(values.campaignId);
|
|
124
128
|
const boundSource = source(values);
|
|
125
129
|
const aws = commonAws(values);
|
|
130
|
+
const regionConfig = macosJitRegionConfig(aws.region);
|
|
126
131
|
const tags = ownershipTags({ id, sourceSha: boundSource.sha });
|
|
127
132
|
const createdAt = iso(values.createdAt, "createdAt");
|
|
128
133
|
const plan = {
|
|
@@ -130,6 +135,13 @@ export function createMacosJitCampaignPlan(values = {}) {
|
|
|
130
135
|
contract: AWS_MACOS_JIT_CONTROLLER_CONTRACT,
|
|
131
136
|
kind: "campaign-launch-plan",
|
|
132
137
|
repository: repository(values.repository),
|
|
138
|
+
account: {
|
|
139
|
+
id: exact(values.accountId, /^\d{12}$/, "accountId"),
|
|
140
|
+
},
|
|
141
|
+
github: {
|
|
142
|
+
workflowId: MACOS_EC2_JIT.workflowId,
|
|
143
|
+
requiredState: "disabled_manually",
|
|
144
|
+
},
|
|
133
145
|
campaign: { id, createdAt },
|
|
134
146
|
source: boundSource,
|
|
135
147
|
aws: {
|
|
@@ -158,18 +170,29 @@ export function createMacosJitCampaignPlan(values = {}) {
|
|
|
158
170
|
exactSourceRequired: true,
|
|
159
171
|
activeHostCeiling: MACOS_EC2_JIT.maxAcceptedHosts,
|
|
160
172
|
activeInstanceCeiling: 1,
|
|
161
|
-
|
|
173
|
+
awsPermissionSimulationRequiredBeforeAllocation: true,
|
|
162
174
|
awsDryRunRequiredBeforeLaunch: true,
|
|
163
175
|
retainHostOnInstanceLaunchFailure: true,
|
|
164
176
|
minimumHostAllocationHours: MACOS_EC2_JIT.minimumHostAllocationHours,
|
|
165
177
|
maximumHostAllocationHours: MACOS_EC2_JIT.maximumHostAllocationHours,
|
|
166
178
|
cleanupOwner: "scheduled-card-scoped-reaper",
|
|
179
|
+
budget: {
|
|
180
|
+
name: regionConfig.budgetName,
|
|
181
|
+
limitUsd: MACOS_EC2_JIT.budgetLimitUsd,
|
|
182
|
+
metrics: ["UnblendedCost"],
|
|
183
|
+
dimensionFilter: {
|
|
184
|
+
usageTypes: regionConfig.budgetUsageTypes,
|
|
185
|
+
operation: MACOS_EC2_JIT.budgetOperation,
|
|
186
|
+
regions: regionConfig.budgetRegions,
|
|
187
|
+
},
|
|
188
|
+
requiredActualThresholds: [80, 95],
|
|
189
|
+
},
|
|
167
190
|
},
|
|
168
191
|
};
|
|
169
192
|
return { ...plan, digest: digest(plan) };
|
|
170
193
|
}
|
|
171
194
|
|
|
172
|
-
export function macosAllocateHostsArgs(plan
|
|
195
|
+
export function macosAllocateHostsArgs(plan) {
|
|
173
196
|
if (
|
|
174
197
|
plan?.contract !== AWS_MACOS_JIT_CONTROLLER_CONTRACT ||
|
|
175
198
|
plan.kind !== "campaign-launch-plan"
|
|
@@ -196,7 +219,6 @@ export function macosAllocateHostsArgs(plan, { dryRun = false } = {}) {
|
|
|
196
219
|
"--output",
|
|
197
220
|
"json",
|
|
198
221
|
];
|
|
199
|
-
if (dryRun) args.splice(2, 0, "--dry-run");
|
|
200
222
|
return args;
|
|
201
223
|
}
|
|
202
224
|
|