@kungfu-tech/buildchain 3.0.5-alpha.0 → 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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "contract": "kungfu-buildchain-publication-release-registry",
4
- "generatedAt": "2026-08-02T01:14:59.679Z",
5
- "publishedAt": "2026-08-02T01:14:59.679Z",
4
+ "generatedAt": "2026-08-02T02:26:15.287Z",
5
+ "publishedAt": "2026-08-02T02:26:15.287Z",
6
6
  "reproducible": true,
7
7
  "timestampPolicy": "ci-injected",
8
8
  "deterministicInputs": [
@@ -19,7 +19,7 @@
19
19
  "declared Buildchain surface manifest contract"
20
20
  ],
21
21
  "sourceDateEpoch": "0",
22
- "sourceRevision": "99ce7a8de4cd35f81d743b63d723bee2d4c26f80",
22
+ "sourceRevision": "8b96fb02e587124bad0f9055f9de6eb7c6b8081c",
23
23
  "timestampPolicyDetails": {
24
24
  "contract": "kungfu-buildchain-surface-timestamp-policy",
25
25
  "timestampFields": [
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "package": {
34
34
  "name": "@kungfu-tech/buildchain",
35
- "version": "3.0.5-alpha.0",
35
+ "version": "3.0.5-alpha.1",
36
36
  "versionSource": "package.json#version"
37
37
  },
38
38
  "sourceKind": "package-site-bundle",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "contract": "kungfu-buildchain-site-manifest",
4
- "generatedAt": "2026-08-02T01:14:59.679Z",
5
- "publishedAt": "2026-08-02T01:14:59.679Z",
4
+ "generatedAt": "2026-08-02T02:26:15.287Z",
5
+ "publishedAt": "2026-08-02T02:26:15.287Z",
6
6
  "reproducible": true,
7
7
  "timestampPolicy": "ci-injected",
8
8
  "deterministicInputs": [
@@ -19,7 +19,7 @@
19
19
  "declared Buildchain surface manifest contract"
20
20
  ],
21
21
  "sourceDateEpoch": "0",
22
- "sourceRevision": "99ce7a8de4cd35f81d743b63d723bee2d4c26f80",
22
+ "sourceRevision": "8b96fb02e587124bad0f9055f9de6eb7c6b8081c",
23
23
  "timestampPolicyDetails": {
24
24
  "contract": "kungfu-buildchain-surface-timestamp-policy",
25
25
  "timestampFields": [
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "package": {
39
39
  "name": "@kungfu-tech/buildchain",
40
- "version": "3.0.5-alpha.0",
40
+ "version": "3.0.5-alpha.1",
41
41
  "versionSource": "package.json#version"
42
42
  },
43
43
  "entrypoint": "buildchain-site.json",
@@ -285,7 +285,7 @@
285
285
  "path": "docs/publish-transaction.md",
286
286
  "plane": "verify",
287
287
  "exists": true,
288
- "digest": "sha256:9b830727e5b0d2192c937f19106c11ea5064dde77e30d290825f343df47f291b"
288
+ "digest": "sha256:3a9ef099d5d93d9b558c7b6a0aa4403dfcce1479ce5d6fc36ecb3e34cdd5bb6d"
289
289
  },
290
290
  {
291
291
  "id": "release-governance",
@@ -293,7 +293,7 @@
293
293
  "path": "docs/release-governance.md",
294
294
  "plane": "why",
295
295
  "exists": true,
296
- "digest": "sha256:60ec90d3294818d3c53e403312801a8ab47be7c0f34810002676fbfcdbceaa60"
296
+ "digest": "sha256:96d78f800cd7d43d3a47a11863d4813711e360418ba33a4284688389c7e4b70c"
297
297
  },
298
298
  {
299
299
  "id": "release-flow",
@@ -301,7 +301,7 @@
301
301
  "path": "docs/release-flow.md",
302
302
  "plane": "verify",
303
303
  "exists": true,
304
- "digest": "sha256:bac959565c36cf39c3cd30dfce00dbbb59b73ed21b852a8f24a6cae281cb5f05"
304
+ "digest": "sha256:dc54b8a264489341984ca6157b07d1956e934366f937b7412a21df7f025d2d8d"
305
305
  },
306
306
  {
307
307
  "id": "runtime-train-validation",
@@ -8,7 +8,12 @@ confidence: high
8
8
  sensitivity: public
9
9
  evidence_grade: A
10
10
  review_state: unreviewed
11
- last_reviewed: 2026-07-30
11
+ last_reviewed: 2026-08-01
12
+ ai_provenance:
13
+ model_family: GPT-5
14
+ product: Codex
15
+ generated_at: 2026-08-01
16
+ invisible_information: No hidden model checkpoint, parameters, or private training data were available.
12
17
  ---
13
18
 
14
19
  # AWS US elastic runner burst plane
@@ -226,6 +231,32 @@ host, including at least one full run, plus proof that:
226
231
  - AWS has no active campaign instance or allocated campaign host;
227
232
  - actual incremental spend remained below USD 25.
228
233
 
234
+ ### Phase 3 lifecycle controller
235
+
236
+ `scripts/aws-macos-jit-controller.mjs` is the operator boundary for the paid
237
+ campaign. It has three explicit mutation modes:
238
+
239
+ - `launch-campaign` binds the exact repository source, AMI, availability zone,
240
+ tagged Dedicated Host, and reusable instance. It rejects pre-existing Mac
241
+ capacity and requires successful `AllocateHosts` and `RunInstances` DryRuns
242
+ before either real call.
243
+ - `run-job` binds one queued exact-source GitHub job to the existing campaign
244
+ host and instance. It writes the repository JIT configuration through a
245
+ mode-0600 temporary file into a distinct SSM SecureString, sends only the
246
+ credential-free bootstrap through SSM, and removes the parameter plus runner
247
+ registration if command delivery fails.
248
+ - `close-campaign` refuses execution before the provider's 24-hour minimum,
249
+ verifies the encrypted delete-on-termination root volume, removes scoped JIT
250
+ residue, terminates the exact instance, and requires a `ReleaseHosts` DryRun
251
+ before release. If Apple host scrubbing is still in progress, it reports
252
+ `release-pending`; the ten-minute card-scoped reaper remains the bounded
253
+ retry path.
254
+
255
+ Every execute mode requires the exact source SHA and campaign id to be repeated
256
+ through `--confirm-source-sha` and `--confirm-campaign-id`. `run-job` also
257
+ requires `--confirm-run-id`. Omitting `--execute` emits a deterministic plan
258
+ without changing AWS or GitHub state.
259
+
229
260
  ## Provider lifecycle
230
261
 
231
262
  The three infrastructure templates live under
@@ -415,9 +415,10 @@ first run can stop at `finalizing` after registry publication. If the reviewed
415
415
  merge commit later contains that exact transaction release material but the
416
416
  exact tag is still absent, a retry performs finalization only: it reloads the
417
417
  same durable source, release material, tooling, evidence, version, and target
418
- bindings; creates the exact and floating tags at the transaction release SHA;
419
- and completes the passport from the transaction source tree. It does not rerun
420
- the provider mutation and does not authorize the newer composite channel tree
418
+ bindings; creates the exact tag at the transaction source SHA; moves floating
419
+ refs to the transaction release SHA; and completes the passport from the
420
+ transaction source tree. It does not rerun the provider mutation and does not
421
+ authorize the newer composite channel tree
421
422
  as published material. A different source tree still requires a new version and
422
423
  a fresh release candidate.
423
424
 
@@ -536,8 +537,9 @@ inside the mutation boundary.
536
537
 
537
538
  If finalization fails after an exact Git tag is created, the next run reads the
538
539
  durable `finalizing` state, verifies the exact tag points at the recorded
539
- release SHA, and retries the remaining floating refs. An exact tag at a
540
- different SHA is a material conflict and blocks recovery.
540
+ source SHA (while accepting legacy release/material targets for recovery), and
541
+ retries the remaining floating refs. An exact tag at an unrelated SHA is a
542
+ material conflict and blocks recovery.
541
543
 
542
544
  ## CLI Recovery
543
545
 
@@ -96,6 +96,10 @@ published artifacts are already durable.
96
96
  The intended governance split is:
97
97
 
98
98
  - exact tags such as `v3.0.2` and `v3.0.3-alpha.0` are immutable audit refs;
99
+ - for publish transactions, the exact tag points to the transaction
100
+ `source_sha`, matching package-registry source metadata such as npm
101
+ `gitHead`; generated version-state commits remain on protected branches and
102
+ floating channel refs;
99
103
  - floating tags such as `v3`, `v3.0`, `v3.0-alpha`, and `v3-alpha` are mutable channel refs
100
104
  owned by the Buildchain promotion token;
101
105
  - protected branches still require reviewed channel PRs before Buildchain can
@@ -831,6 +831,13 @@ must be absent or already point at accepted release material. This preserves an
831
831
  immutable tag that represents a completed transaction while allowing a tag
832
832
  collision discovered after registry publication to recover without republishing.
833
833
 
834
+ For package publish transactions, the immutable public version tag points to
835
+ the transaction `source_sha`, so registry source metadata such as npm `gitHead`
836
+ and the Git tag identify the same source commit. Protected branches and mutable
837
+ channel tags continue to point to the generated `release_sha`. Recovery accepts
838
+ older completed transactions whose exact tags already point to recorded release
839
+ or release-material SHAs, but new tags are source-bound.
840
+
834
841
  Every Buildchain publish model that can run registry side effects must bind the
835
842
  publish entrypoint to an immutable `publish-gate/*` source lock. The reusable
836
843
  `release-candidate-promote.yml@v3` wrapper creates or updates that gate ref and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "3.0.5-alpha.0",
3
+ "version": "3.0.5-alpha.1",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -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
+ }