@kungfu-tech/buildchain 3.0.5 → 3.0.6-alpha.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.
Files changed (83) hide show
  1. package/README.md +31 -0
  2. package/actions/macos-credential-island/README.md +8 -0
  3. package/bin/buildchain.mjs +1 -1
  4. package/contracts/auditable-demo-media-profiles-v1.json +61 -0
  5. package/contracts/auditable-demo-scenario-v1.schema.json +164 -0
  6. package/contracts/evidence/auditable-demo-responsive-web-delivery-v1.json +3 -3
  7. package/contracts/evidence/auditable-demo-web-delivery-v1.json +3 -3
  8. package/dist/site/buildchain-contract.json +107 -26
  9. package/dist/site/buildchain-site.json +121 -49
  10. package/dist/site/capability-registry.json +3 -3
  11. package/dist/site/cli-registry.json +9 -3
  12. package/dist/site/controller-registry.json +27 -3
  13. package/dist/site/kfd-claims.json +101 -15
  14. package/dist/site/kfd-upstream-aggregate.json +1 -1
  15. package/dist/site/manual-registry.json +10 -9
  16. package/dist/site/node-api-registry.json +540 -31
  17. package/dist/site/page-registry.json +105 -29
  18. package/dist/site/public-surface-audit.json +184 -11
  19. package/dist/site/publication-authority-registry.json +62 -1
  20. package/dist/site/publication-registry.json +4 -4
  21. package/dist/site/site-manifest.json +13 -13
  22. package/dist/site/workflow-registry.json +118 -4
  23. package/docs/MAP.md +4 -1
  24. package/docs/auditable-demo.md +90 -7
  25. package/docs/aws-us-elastic-runner-burst-plane.md +151 -16
  26. package/docs/cli-reference.md +35 -2
  27. package/docs/dev-alpha-candidate-patrol.md +14 -5
  28. package/docs/dev-qualification-patrol.md +108 -0
  29. package/docs/node-api-reference.md +123 -72
  30. package/docs/publish-transaction.md +7 -5
  31. package/docs/release-flow.md +4 -0
  32. package/docs/release-governance.md +39 -0
  33. package/docs/release-propagation.md +93 -0
  34. package/docs/reusable-build-surface.md +53 -12
  35. package/docs/stable-candidate-patrol.md +5 -2
  36. package/package.json +1 -1
  37. package/packages/core/buildchain-contract.js +36 -1
  38. package/packages/core/buildchain-publication-authority.js +3 -0
  39. package/packages/core/public-surface-audit.js +4 -35
  40. package/packages/core/release-propagation-agent-entry.js +185 -0
  41. package/packages/core/release-propagation-pickup.js +387 -0
  42. package/packages/core/release-propagation-push.js +293 -0
  43. package/packages/core/release-propagation-release.js +61 -18
  44. package/packages/core/release-propagation-stage-evidence.js +36 -0
  45. package/packages/core/release-propagation-work.js +8 -4
  46. package/packages/core/release-propagation.js +27 -2
  47. package/packages/core/workflow-call-contract.js +308 -0
  48. package/packages/core/workflow-yaml-contract.js +272 -0
  49. package/scripts/artifact-signing-controller-core.mjs +525 -0
  50. package/scripts/artifact-signing-controller.mjs +302 -0
  51. package/scripts/artifact-signing-delegation.mjs +51 -3
  52. package/scripts/auditable-demo-platform.mjs +564 -0
  53. package/scripts/auditable-demo-renditions.mjs +7 -2
  54. package/scripts/auditable-demo-transport-smoke.mjs +176 -0
  55. package/scripts/auditable-demo.mjs +27 -21
  56. package/scripts/aws-macos-jit-controller-core.mjs +389 -0
  57. package/scripts/aws-macos-jit-controller-runtime.mjs +82 -0
  58. package/scripts/aws-macos-jit-controller.mjs +558 -0
  59. package/scripts/aws-macos-jit-job-controller.mjs +401 -0
  60. package/scripts/aws-windows-jit-campaign-core.mjs +296 -0
  61. package/scripts/aws-windows-jit-campaign.mjs +202 -0
  62. package/scripts/aws-windows-jit-controller-core.mjs +20 -2
  63. package/scripts/aws-windows-jit-controller.mjs +184 -109
  64. package/scripts/aws-windows-jit-core.mjs +21 -3
  65. package/scripts/aws-windows-jit.mjs +2 -0
  66. package/scripts/build-standalone-binary.mjs +6 -0
  67. package/scripts/buildchain-cli-help.mjs +2 -1
  68. package/scripts/capture-package-release-propagation.mjs +24 -1
  69. package/scripts/check-inventory.mjs +6 -0
  70. package/scripts/compiler-cache-evidence.mjs +90 -7
  71. package/scripts/dev-alpha-candidate-patrol.mjs +80 -5
  72. package/scripts/dev-pr-auto-merge.mjs +4 -4
  73. package/scripts/dev-qualification-patrol.mjs +594 -0
  74. package/scripts/dispatch-artifact-signing-authority.mjs +312 -61
  75. package/scripts/finalize-native-artifact-signing-result.mjs +37 -15
  76. package/scripts/generate-site-bundle.mjs +5 -0
  77. package/scripts/inspect-artifact-signing-requests.mjs +12 -0
  78. package/scripts/locked-source-checkout.mjs +17 -3
  79. package/scripts/release-propagation.mjs +150 -2
  80. package/scripts/run-lifecycle-core.mjs +25 -0
  81. package/scripts/site-capability-metadata.mjs +1 -1
  82. package/scripts/stable-candidate-patrol.mjs +30 -4
  83. package/scripts/workflow-call-contract.mjs +133 -0
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { spawnSync } from "node:child_process";
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+
11
+ import {
12
+ prepareArtifact,
13
+ validateScenario,
14
+ } from "./auditable-demo-platform.mjs";
15
+
16
+ const NON_AUTHORITIES = [
17
+ "first-party-identity",
18
+ "system-identity",
19
+ "kfd-compliance",
20
+ "product-system-metadata",
21
+ "package-metadata",
22
+ "registry-history",
23
+ "scan-output",
24
+ "standalone-generation",
25
+ ];
26
+
27
+ function fail(message) {
28
+ throw new Error(`auditable demo transport smoke: ${message}`);
29
+ }
30
+
31
+ function inside(root, relative, label) {
32
+ if (typeof relative !== "string" || !relative || path.isAbsolute(relative))
33
+ fail(`${label} must be relative`);
34
+ const resolvedRoot = path.resolve(root);
35
+ const resolved = path.resolve(resolvedRoot, relative);
36
+ if (
37
+ resolved === resolvedRoot ||
38
+ !resolved.startsWith(`${resolvedRoot}${path.sep}`)
39
+ )
40
+ fail(`${label} escapes its root`);
41
+ return resolved;
42
+ }
43
+
44
+ function removeExecuteBits(root) {
45
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
46
+ const file = path.join(root, entry.name);
47
+ if (entry.isDirectory()) removeExecuteBits(file);
48
+ else if (entry.isFile())
49
+ fs.chmodSync(file, fs.statSync(file).mode & ~0o111);
50
+ }
51
+ }
52
+
53
+ function isolatedEnvironment(home, declared) {
54
+ const env = {
55
+ PATH: "/usr/local/bin:/usr/bin:/bin",
56
+ HOME: home,
57
+ XDG_CACHE_HOME: path.join(home, ".cache"),
58
+ XDG_CONFIG_HOME: path.join(home, ".config"),
59
+ XDG_DATA_HOME: path.join(home, ".local/share"),
60
+ XDG_STATE_HOME: path.join(home, ".local/state"),
61
+ LANG: "C.UTF-8",
62
+ LC_ALL: "C.UTF-8",
63
+ TZ: "UTC",
64
+ CI: "true",
65
+ TERM: "xterm-256color",
66
+ COLORTERM: "truecolor",
67
+ FORCE_COLOR: "3",
68
+ };
69
+ for (const [key, value] of Object.entries(declared)) {
70
+ if (Object.hasOwn(env, key))
71
+ fail(
72
+ `scenario environment may not override the isolated baseline: ${key}`,
73
+ );
74
+ env[key] = value;
75
+ }
76
+ return env;
77
+ }
78
+
79
+ export function runTransportSmoke({ artifactRoot, scenarioPath }) {
80
+ if (process.platform === "win32")
81
+ fail("transport smoke currently requires a POSIX host");
82
+ const root = path.resolve(artifactRoot);
83
+ const scenario = validateScenario(
84
+ JSON.parse(fs.readFileSync(path.resolve(scenarioPath), "utf8")),
85
+ );
86
+ if (!scenario.transportSmoke) fail("scenario.transportSmoke is required");
87
+ const distributionRelative = path.posix.dirname(
88
+ scenario.artifact.metadataPath,
89
+ );
90
+ const sourceDistribution =
91
+ distributionRelative === "."
92
+ ? root
93
+ : inside(root, distributionRelative, "distribution root");
94
+ const temporaryRoot = fs.mkdtempSync(
95
+ path.join(os.tmpdir(), "buildchain-demo-transport-"),
96
+ );
97
+ try {
98
+ const transportedRoot = path.join(temporaryRoot, "artifact");
99
+ const transportedDistribution =
100
+ distributionRelative === "."
101
+ ? transportedRoot
102
+ : path.join(transportedRoot, ...distributionRelative.split("/"));
103
+ fs.mkdirSync(path.dirname(transportedDistribution), { recursive: true });
104
+ fs.cpSync(sourceDistribution, transportedDistribution, {
105
+ recursive: true,
106
+ dereference: false,
107
+ preserveTimestamps: true,
108
+ verbatimSymlinks: true,
109
+ });
110
+ removeExecuteBits(transportedDistribution);
111
+ const prepared = prepareArtifact({
112
+ artifactRoot: transportedRoot,
113
+ scenarioPath,
114
+ });
115
+ const binary = inside(
116
+ transportedRoot,
117
+ scenario.artifact.binaryPath,
118
+ "transport smoke binary",
119
+ );
120
+ const workspace = path.join(temporaryRoot, "workspace");
121
+ const home = path.join(temporaryRoot, "home");
122
+ fs.mkdirSync(workspace);
123
+ fs.mkdirSync(home);
124
+ const smoke = scenario.transportSmoke;
125
+ const result = spawnSync(binary, smoke.argv, {
126
+ cwd: workspace,
127
+ env: isolatedEnvironment(home, scenario.execution.environment),
128
+ encoding: "utf8",
129
+ timeout: smoke.timeoutSeconds * 1000,
130
+ maxBuffer: 4 * 1024 * 1024,
131
+ });
132
+ const output = `${result.stdout || ""}${result.stderr || ""}`;
133
+ if (result.error) fail(`could not execute: ${result.error.message}`);
134
+ if (!smoke.expectedExitCodes.includes(result.status))
135
+ fail(`exited with ${result.status}; output: ${output.slice(-4096)}`);
136
+ for (const expected of smoke.stdoutIncludes)
137
+ if (!output.includes(expected)) fail(`output is missing: ${expected}`);
138
+ return {
139
+ schema: "buildchain.declarative-demo-transport-smoke/v1",
140
+ status: "passed",
141
+ executableFiles: prepared.executableFiles,
142
+ argv: smoke.argv,
143
+ exitCode: result.status,
144
+ outputRoot: `sha256:${crypto.createHash("sha256").update(output).digest("hex")}`,
145
+ authority: {
146
+ classification: "pre-upload-transport-diagnostic",
147
+ grants: [],
148
+ nonAuthorities: NON_AUTHORITIES,
149
+ },
150
+ };
151
+ } finally {
152
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
153
+ }
154
+ }
155
+
156
+ function parseArgs(argv) {
157
+ const values = {};
158
+ for (let index = 0; index < argv.length; index += 2)
159
+ values[argv[index].replace(/^--/u, "")] = argv[index + 1];
160
+ return values;
161
+ }
162
+
163
+ if (
164
+ process.argv[1] &&
165
+ import.meta.url === pathToFileURL(process.argv[1]).href
166
+ ) {
167
+ try {
168
+ const args = parseArgs(process.argv.slice(2));
169
+ process.stdout.write(
170
+ `${JSON.stringify({ ok: true, ...runTransportSmoke({ artifactRoot: args["artifact-root"], scenarioPath: args.scenario }) }, null, 2)}\n`,
171
+ );
172
+ } catch (error) {
173
+ console.error(error instanceof Error ? error.message : String(error));
174
+ process.exitCode = 1;
175
+ }
176
+ }
@@ -36,6 +36,10 @@ const OPTIONAL_ADAPTER_FILES = [
36
36
  ];
37
37
  const MAX_TERMINAL_CAPTURE_BYTES = 4 * 1024 * 1024;
38
38
  const MAX_TERMINAL_CAPTURE_EVENTS = 10_000;
39
+ const STANDARD_MAX_DURATION_MS = 60_000;
40
+ const LONG_FORM_MAX_DURATION_MS = 180_000;
41
+ const LONG_FORM_MAX_FPS = 10;
42
+ const MAX_RENDER_FRAMES = 1_800;
39
43
 
40
44
  function invariant(condition, message) {
41
45
  if (!condition) throw new Error(message);
@@ -186,15 +190,20 @@ function validateScene(value) {
186
190
  exactKeys(
187
191
  value,
188
192
  ["schema", "id", "width", "height", "fps", "durationMs", "title"],
189
- ["commandLabel", "background", "accent"],
193
+ ["durationClass", "commandLabel", "background", "accent"],
190
194
  "scene",
191
195
  );
192
196
  invariant(value.schema === "build-images.demo-scene/v1", "unsupported scene schema");
193
197
  invariant(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value.id), "scene.id is invalid");
194
198
  integer(value.width, 640, 1920, "scene.width");
195
199
  integer(value.height, 360, 1080, "scene.height");
196
- integer(value.fps, 1, 30, "scene.fps");
197
- integer(value.durationMs, 500, 60000, "scene.durationMs");
200
+ const durationClass = value.durationClass ?? "standard";
201
+ invariant(durationClass === "standard" || durationClass === "long-form", "scene.durationClass is invalid");
202
+ const maximumDurationMs = durationClass === "long-form" ? LONG_FORM_MAX_DURATION_MS : STANDARD_MAX_DURATION_MS;
203
+ const maximumFps = durationClass === "long-form" ? LONG_FORM_MAX_FPS : 30;
204
+ integer(value.fps, 1, maximumFps, "scene.fps");
205
+ integer(value.durationMs, 500, maximumDurationMs, "scene.durationMs");
206
+ invariant(Math.ceil((value.durationMs / 1000) * value.fps) <= MAX_RENDER_FRAMES, "scene exceeds the deterministic source-frame bound");
198
207
  text(value.title, 1, 120, "scene.title");
199
208
  if (value.commandLabel !== undefined) text(value.commandLabel, 0, 160, "scene.commandLabel");
200
209
  for (const key of ["background", "accent"]) {
@@ -206,6 +215,7 @@ function validateScene(value) {
206
215
  width: value.width,
207
216
  height: value.height,
208
217
  fps: value.fps,
218
+ ...(value.durationClass === undefined ? {} : { durationClass }),
209
219
  durationMs: value.durationMs,
210
220
  title: value.title,
211
221
  commandLabel: value.commandLabel ?? "",
@@ -524,7 +534,7 @@ function validateBudgetBasis(entry, label) {
524
534
  MAX_BUNDLE_MEMBER_BYTES,
525
535
  `${label}.budgetBasis.observedBytes`,
526
536
  );
527
- const multiplier = integer(entry.budgetBasis.multiplier, 1, 64, `${label}.budgetBasis.multiplier`);
537
+ const multiplier = integer(entry.budgetBasis.multiplier, 1, 128, `${label}.budgetBasis.multiplier`);
528
538
  invariant(entry.budgetBasis.rounding === "next-power-of-two", `${label}.budgetBasis.rounding is unsupported`);
529
539
  const expected = 2 ** Math.ceil(Math.log2(observedBytes * multiplier));
530
540
  invariant(entry.maximumBytes === expected, `${label}.maximumBytes does not match its measured budget basis`);
@@ -1017,6 +1027,15 @@ function verifyRendererOutput(renderOutput, expectedImage, expectedInputs, optio
1017
1027
  return { manifest, probe, qualification };
1018
1028
  }
1019
1029
 
1030
+ function renditionInputRoots(root, renditions) {
1031
+ return renditions.map((rendition) => ({
1032
+ id: rendition.id, role: rendition.role, captureRoot: rendition.captureRoot,
1033
+ sceneRoot: sha256(readRegular(path.join(root, rendition.files.scene), `${rendition.id} scene`)),
1034
+ transcriptRoot: sha256(readRegular(path.join(root, rendition.files.transcript), `${rendition.id} transcript`)),
1035
+ projectionRoot: sha256(readRegular(path.join(root, rendition.files.projection), `${rendition.id} projection`)),
1036
+ }));
1037
+ }
1038
+
1020
1039
  function finalizeGate(values) {
1021
1040
  const adapterOutput = path.resolve(required(values, "--adapter-output"));
1022
1041
  const smokeInput = path.resolve(required(values, "--smoke-input"));
@@ -1109,14 +1128,7 @@ function finalizeGate(values) {
1109
1128
  renditionSet: {
1110
1129
  schema: normalized.renditionSet.schema,
1111
1130
  root: sha256(readRegular(path.join(output, "rendition-set.json"), "rendition set")),
1112
- renditions: normalized.renditionSet.renditions.map((rendition) => ({
1113
- id: rendition.id,
1114
- role: rendition.role,
1115
- captureRoot: rendition.captureRoot,
1116
- sceneRoot: sha256(readRegular(path.join(output, rendition.scene), `${rendition.id} scene`)),
1117
- transcriptRoot: sha256(readRegular(path.join(output, rendition.transcript), `${rendition.id} transcript`)),
1118
- projectionRoot: sha256(readRegular(path.join(output, rendition.projection), `${rendition.id} projection`)),
1119
- })),
1131
+ renditions: renditionInputRoots(output, normalized.renditionSet.renditions),
1120
1132
  },
1121
1133
  }
1122
1134
  : {}),
@@ -1179,15 +1191,8 @@ function verifyGate(values) {
1179
1191
  invariant(
1180
1192
  qualifiedRenditionSet.schema === normalized.renditionSet.schema
1181
1193
  && qualifiedRenditionSet.root === sha256(readRegular(path.join(bundle, "rendition-set.json"), "rendition set"))
1182
- && JSON.stringify(qualifiedRenditionSet.renditions)
1183
- === JSON.stringify(normalized.renditionSet.renditions.map((rendition) => ({
1184
- id: rendition.id,
1185
- role: rendition.role,
1186
- captureRoot: rendition.captureRoot,
1187
- sceneRoot: sha256(readRegular(path.join(bundle, rendition.scene), `${rendition.id} scene`)),
1188
- transcriptRoot: sha256(readRegular(path.join(bundle, rendition.transcript), `${rendition.id} transcript`)),
1189
- projectionRoot: sha256(readRegular(path.join(bundle, rendition.projection), `${rendition.id} projection`)),
1190
- }))),
1194
+ && stableJson(qualifiedRenditionSet.renditions)
1195
+ === stableJson(renditionInputRoots(bundle, normalized.renditionSet.renditions)),
1191
1196
  "gate native rendition roots mismatch",
1192
1197
  );
1193
1198
  }
@@ -1311,6 +1316,7 @@ export {
1311
1316
  inspectRendererMedia,
1312
1317
  parseAdapterArguments,
1313
1318
  qualifyMediaFixture,
1319
+ renditionInputRoots,
1314
1320
  prepareSmoke,
1315
1321
  runAdapter,
1316
1322
  sha256,
@@ -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
+ }