@kungfu-tech/buildchain 3.0.5-alpha.1 → 3.0.5-alpha.2
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 +30 -20
- package/dist/site/buildchain-site.json +14 -9
- package/dist/site/controller-registry.json +11 -3
- package/dist/site/kfd-claims.json +6 -4
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/page-registry.json +9 -4
- package/dist/site/public-surface-audit.json +6 -4
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +4 -2
- package/docs/aws-us-elastic-runner-burst-plane.md +102 -17
- package/docs/reusable-build-surface.md +9 -0
- package/package.json +1 -1
- package/scripts/aws-windows-jit-campaign-core.mjs +241 -0
- package/scripts/aws-windows-jit-campaign.mjs +167 -0
- package/scripts/aws-windows-jit-controller-core.mjs +20 -2
- package/scripts/aws-windows-jit-controller.mjs +184 -109
- package/scripts/aws-windows-jit-core.mjs +18 -0
- package/scripts/aws-windows-jit.mjs +2 -0
- package/scripts/locked-source-checkout.mjs +17 -3
|
@@ -126,6 +126,7 @@
|
|
|
126
126
|
"checkout-cache-mode",
|
|
127
127
|
"checkout-cache-reference-repository-template",
|
|
128
128
|
"checkout-cache-timeout-seconds",
|
|
129
|
+
"checkout-history-mode",
|
|
129
130
|
"compiler-cache-platforms-json",
|
|
130
131
|
"compiler-cache-provider",
|
|
131
132
|
"compiler-cache-required",
|
|
@@ -175,7 +176,7 @@
|
|
|
175
176
|
"verify-command",
|
|
176
177
|
"working-directory"
|
|
177
178
|
],
|
|
178
|
-
"inputCount":
|
|
179
|
+
"inputCount": 83,
|
|
179
180
|
"secrets": [
|
|
180
181
|
"BUILDCHAIN_ARTIFACT_RELAY_S3_DOWNLOAD_ROLE_ARN",
|
|
181
182
|
"BUILDCHAIN_ARTIFACT_RELAY_S3_ROLE_ARN",
|
|
@@ -1155,6 +1156,7 @@
|
|
|
1155
1156
|
"checkout-cache-mode",
|
|
1156
1157
|
"checkout-cache-reference-repository-template",
|
|
1157
1158
|
"checkout-cache-timeout-seconds",
|
|
1159
|
+
"checkout-history-mode",
|
|
1158
1160
|
"compiler-cache-platforms-json",
|
|
1159
1161
|
"compiler-cache-provider",
|
|
1160
1162
|
"compiler-cache-required",
|
|
@@ -1204,7 +1206,7 @@
|
|
|
1204
1206
|
"verify-command",
|
|
1205
1207
|
"working-directory"
|
|
1206
1208
|
],
|
|
1207
|
-
"inputCount":
|
|
1209
|
+
"inputCount": 86,
|
|
1208
1210
|
"secrets": [
|
|
1209
1211
|
"BUILDCHAIN_ARTIFACT_RELAY_S3_DOWNLOAD_ROLE_ARN",
|
|
1210
1212
|
"BUILDCHAIN_ARTIFACT_RELAY_S3_ROLE_ARN",
|
|
@@ -8,7 +8,7 @@ confidence: high
|
|
|
8
8
|
sensitivity: public
|
|
9
9
|
evidence_grade: A
|
|
10
10
|
review_state: unreviewed
|
|
11
|
-
last_reviewed: 2026-08-
|
|
11
|
+
last_reviewed: 2026-08-02
|
|
12
12
|
ai_provenance:
|
|
13
13
|
model_family: GPT-5
|
|
14
14
|
product: Codex
|
|
@@ -143,14 +143,16 @@ The source-bound evidence and deterministic phase receipt are:
|
|
|
143
143
|
|
|
144
144
|
The Windows phase uses the explicit `aws-us-ec2-windows-jit` runner preset.
|
|
145
145
|
Its caller supplies one bounded label under
|
|
146
|
-
`aws-us-ec2-windows-jit-<qualification-id>`, and Buildchain
|
|
147
|
-
one Windows x64 native lane. The reusable trust gate still
|
|
148
|
-
GitHub-hosted runner before the JIT label can select EC2.
|
|
146
|
+
`aws-us-ec2-windows-jit-<campaign-id>-<qualification-id>`, and Buildchain
|
|
147
|
+
resolves exactly one Windows x64 native lane. The reusable trust gate still
|
|
148
|
+
runs on a GitHub-hosted runner before the JIT label can select EC2.
|
|
149
149
|
|
|
150
150
|
The provider creates repository-level GitHub JIT configuration for
|
|
151
151
|
`kungfu-systems/kungfu`. Its `labels` request must contain all four scheduling
|
|
152
152
|
labels: `self-hosted`, `Windows`, `X64`, and the card-scoped
|
|
153
|
-
`aws-us-ec2-windows-jit-<qualification-id>` label.
|
|
153
|
+
`aws-us-ec2-windows-jit-<campaign-id>-<qualification-id>` label. The workflow
|
|
154
|
+
display title also carries both identities, allowing the launch controller to
|
|
155
|
+
verify the queued run against its campaign plan. GitHub's JIT endpoint does
|
|
154
156
|
not infer the default OS and architecture labels when they are omitted. The
|
|
155
157
|
encoded configuration is never placed in EC2 user data, a tag, a command log,
|
|
156
158
|
or an artifact. The operator writes it to a card-scoped SSM SecureString under
|
|
@@ -173,21 +175,104 @@ Each runner uses:
|
|
|
173
175
|
security-group rule, no key pair, and no warm Auto Scaling capacity.
|
|
174
176
|
|
|
175
177
|
Runner diagnostics and a redacted lifecycle record are uploaded to the
|
|
176
|
-
provider's encrypted, private evidence bucket
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
provider's encrypted, private evidence bucket under the exact campaign, run,
|
|
179
|
+
attempt, and instance identity. The runner process exits after one job, Windows
|
|
180
|
+
shuts down, and EC2's instance-initiated shutdown behavior is set to
|
|
181
|
+
`terminate`. A five-minute reaper terminates card-owned stopped or
|
|
179
182
|
three-hour-old instances and deletes only their dedicated JIT parameter.
|
|
180
183
|
|
|
181
184
|
At the 2026-07-29 AWS Price List rate of USD 1.45 per Windows
|
|
182
|
-
`c7i.4xlarge` hour,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
`c7i.4xlarge` hour, each accepted instance reserves its complete three-hour
|
|
186
|
+
USD 4.35 fail-closed lifetime before `RunInstances`. A DynamoDB transaction
|
|
187
|
+
binds the exact campaign and source, creates an idempotent run ledger entry,
|
|
188
|
+
and atomically refuses a seventh accepted instance. Six accepted instances
|
|
189
|
+
therefore reserve at most USD 26.10 under the dedicated USD 40 decision.
|
|
190
|
+
|
|
191
|
+
The campaign starts unarmed and expires within 24 hours. Its `CONTROL` record
|
|
192
|
+
can be created only once: a killed or expired campaign cannot be re-armed by
|
|
193
|
+
the campaign tool. A budget notification or any instance lifetime violation
|
|
194
|
+
persists `KILLED` before cleanup, so later workflow dispatches fail before a
|
|
195
|
+
paid launch. Reservations are never refunded: a controller crash, ambiguous
|
|
196
|
+
launch, or successful launch all remain charged to the campaign, favoring a
|
|
197
|
+
false stop over an accidental budget overrun.
|
|
198
|
+
|
|
199
|
+
The tag-filtered AWS Budget is defense in depth, not the authoritative launch
|
|
200
|
+
gate. It is disabled by default because a linked account cannot activate a
|
|
201
|
+
cost-allocation tag. Set `EnableTagFilteredBudget=true` only after the AWS
|
|
202
|
+
Organizations management account has activated `kungfu:provider` and a Cost
|
|
203
|
+
Explorer readback proves that `windows-ec2-jit` spend is visible. The DynamoDB
|
|
204
|
+
reservation cap remains mandatory in either mode.
|
|
205
|
+
|
|
206
|
+
Qualification requires one runner-profile smoke and three trusted exact-source
|
|
207
|
+
full Windows jobs all bound to the same campaign, independent cancellation and
|
|
208
|
+
timeout cleanup exercises, and zero repository runner, EC2 instance,
|
|
209
|
+
disposable volume, min capacity, and desired capacity within 15 minutes of the
|
|
210
|
+
final job.
|
|
211
|
+
|
|
212
|
+
### Phase 2 campaign controller
|
|
213
|
+
|
|
214
|
+
`scripts/aws-windows-jit-campaign.mjs` is the one-shot operator boundary.
|
|
215
|
+
Without a mutation mode it emits the arm plan. `arm-campaign` requires the
|
|
216
|
+
campaign id, exact source SHA, and state table to be repeated as confirmations,
|
|
217
|
+
then creates `CONTROL` and `CAMPAIGN#<id>` with `attribute_not_exists`
|
|
218
|
+
conditions. DynamoDB therefore refuses a second campaign in the same retained
|
|
219
|
+
state table. The operator can always use `kill-campaign`; there is deliberately
|
|
220
|
+
no clear or re-arm operation.
|
|
221
|
+
|
|
222
|
+
Every `scripts/aws-windows-jit-controller.mjs --execute` call must provide the
|
|
223
|
+
same `--campaign-id`, `--confirm-campaign-id`, `--state-table`, and
|
|
224
|
+
`--confirm-state-table`. After the GitHub, AMI, active-instance, SSM, and EC2
|
|
225
|
+
DryRun checks pass, the controller
|
|
226
|
+
atomically reserves one run. Duplicate run-attempt-qualification identities,
|
|
227
|
+
source mismatch, expiry, `KILLED`, the seventh accepted instance, or a
|
|
228
|
+
reservation over the USD 40 ceiling all fail closed before `RunInstances`.
|
|
229
|
+
|
|
230
|
+
Example dry-run and arm boundary (do not execute without a new campaign budget
|
|
231
|
+
decision):
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
windows_campaign=win-REPLACE_WITH_CAMPAIGN_ID
|
|
235
|
+
windows_source=REPLACE_WITH_EXACT_40_CHARACTER_SHA
|
|
236
|
+
windows_state_table=REPLACE_WITH_CAMPAIGN_STATE_TABLE
|
|
237
|
+
windows_expires_at=REPLACE_WITH_ISO_TIMESTAMP_WITHIN_24_HOURS
|
|
238
|
+
|
|
239
|
+
node scripts/aws-windows-jit-campaign.mjs plan-arm \
|
|
240
|
+
--campaign-id "$windows_campaign" \
|
|
241
|
+
--source-sha "$windows_source" \
|
|
242
|
+
--state-table "$windows_state_table" \
|
|
243
|
+
--expires-at "$windows_expires_at"
|
|
244
|
+
|
|
245
|
+
node scripts/aws-windows-jit-campaign.mjs arm-campaign \
|
|
246
|
+
--campaign-id "$windows_campaign" \
|
|
247
|
+
--confirm-campaign-id "$windows_campaign" \
|
|
248
|
+
--source-sha "$windows_source" \
|
|
249
|
+
--confirm-source-sha "$windows_source" \
|
|
250
|
+
--state-table "$windows_state_table" \
|
|
251
|
+
--confirm-state-table "$windows_state_table" \
|
|
252
|
+
--expires-at "$windows_expires_at"
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Arming creates a permanent one-shot control record and admits up to six paid
|
|
256
|
+
instances. Its rollback is fail-closed, not deletion: `kill-campaign` first
|
|
257
|
+
persists `KILLED`, then publishes to the dedicated SNS topic so the reaper
|
|
258
|
+
terminates active card-owned instances and removes their JIT parameters. The
|
|
259
|
+
command is idempotent, but the operator must read back DynamoDB, EC2, SSM, and
|
|
260
|
+
GitHub runners before treating cleanup as complete:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
windows_kill_topic=REPLACE_WITH_DEDICATED_KILL_SWITCH_TOPIC_ARN
|
|
264
|
+
|
|
265
|
+
node scripts/aws-windows-jit-campaign.mjs kill-campaign \
|
|
266
|
+
--campaign-id "$windows_campaign" \
|
|
267
|
+
--confirm-campaign-id "$windows_campaign" \
|
|
268
|
+
--source-sha "$windows_source" \
|
|
269
|
+
--confirm-source-sha "$windows_source" \
|
|
270
|
+
--state-table "$windows_state_table" \
|
|
271
|
+
--confirm-state-table "$windows_state_table" \
|
|
272
|
+
--kill-switch-topic "$windows_kill_topic" \
|
|
273
|
+
--confirm-kill-switch-topic "$windows_kill_topic" \
|
|
274
|
+
--reason operator-kill
|
|
275
|
+
```
|
|
191
276
|
|
|
192
277
|
## Phase 3 contract
|
|
193
278
|
|
|
@@ -439,6 +439,7 @@ jobs:
|
|
|
439
439
|
checkout-cache-timeout-seconds: 60
|
|
440
440
|
checkout-cache-github-timeout-seconds: 600
|
|
441
441
|
checkout-cache-fetch-attempts: 3
|
|
442
|
+
checkout-history-mode: shallow
|
|
442
443
|
```
|
|
443
444
|
|
|
444
445
|
`checkout-cache-mode` accepts:
|
|
@@ -449,6 +450,14 @@ jobs:
|
|
|
449
450
|
| `auto` | Try the trusted cache first; on miss, record the miss and fall back according to `checkout-cache-fallback`. |
|
|
450
451
|
| `require` | Require the cache to provide the locked commit and fail before lifecycle work if unavailable. |
|
|
451
452
|
|
|
453
|
+
`checkout-history-mode` defaults to `shallow`, preserving the bounded single-
|
|
454
|
+
commit transport used by ordinary builds. Set it to `full` only when a
|
|
455
|
+
consumer gate must inspect source ancestry, for example when an Alpha pull
|
|
456
|
+
request qualifies GitHub's synthetic merge ref while retained evidence is
|
|
457
|
+
bound to an ancestor of the source-lock head. Full mode still verifies the
|
|
458
|
+
resolved immutable `HEAD` and tree; it changes only whether the advertised
|
|
459
|
+
source ref is fetched with depth one or with its reachable history.
|
|
460
|
+
|
|
452
461
|
The cache can be a local/LAN mirror URL template or a runner-local bare
|
|
453
462
|
reference repository template. Templates support `{owner}`, `{repo}`,
|
|
454
463
|
`{repository}`, `{repositorySlug}`, and `{sha}`. The workflow also reads
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kungfu-tech/buildchain",
|
|
3
|
-
"version": "3.0.5-alpha.
|
|
3
|
+
"version": "3.0.5-alpha.2",
|
|
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,241 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
WINDOWS_EC2_JIT,
|
|
5
|
+
windowsJitCampaignId,
|
|
6
|
+
} from "./aws-windows-jit-core.mjs";
|
|
7
|
+
|
|
8
|
+
export const AWS_WINDOWS_JIT_CAMPAIGN_CONTRACT =
|
|
9
|
+
"kungfu-buildchain-aws-windows-jit-campaign/v1";
|
|
10
|
+
|
|
11
|
+
function exact(value, pattern, label) {
|
|
12
|
+
const normalized = String(value || "").trim();
|
|
13
|
+
if (!pattern.test(normalized)) throw new Error(`${label} is invalid`);
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function exactSha(value) {
|
|
18
|
+
return exact(value, /^[0-9a-f]{40}$/i, "sourceSha").toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function epoch(value, label) {
|
|
22
|
+
const milliseconds = new Date(value || "").getTime();
|
|
23
|
+
if (!Number.isFinite(milliseconds)) {
|
|
24
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
25
|
+
}
|
|
26
|
+
return Math.floor(milliseconds / 1000);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tableName(value) {
|
|
30
|
+
return exact(
|
|
31
|
+
value,
|
|
32
|
+
/^kungfu-buildchain-windows-jit(?:-[A-Za-z0-9_.-]+)?$/,
|
|
33
|
+
"stateTable",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function number(value) {
|
|
38
|
+
return { N: String(value) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function string(value) {
|
|
42
|
+
return { S: String(value) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runKey(plan) {
|
|
46
|
+
return `RUN#${plan.github.runId}#${plan.github.runAttempt}#${plan.github.qualificationId}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createWindowsJitCampaignArmPlan(values = {}) {
|
|
50
|
+
const armedAt = epoch(values.armedAt, "armedAt");
|
|
51
|
+
const expiresAt = epoch(values.expiresAt, "expiresAt");
|
|
52
|
+
if (expiresAt <= armedAt || expiresAt - armedAt > 86400) {
|
|
53
|
+
throw new Error("campaign expiry must be within 24 hours after arming");
|
|
54
|
+
}
|
|
55
|
+
const reservationUsd =
|
|
56
|
+
(WINDOWS_EC2_JIT.pricePerHourUsd *
|
|
57
|
+
WINDOWS_EC2_JIT.maximumInstanceLifetimeMinutes) /
|
|
58
|
+
60;
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: 1,
|
|
61
|
+
contract: AWS_WINDOWS_JIT_CAMPAIGN_CONTRACT,
|
|
62
|
+
kind: "campaign-arm-plan",
|
|
63
|
+
campaign: {
|
|
64
|
+
id: windowsJitCampaignId(values.campaignId),
|
|
65
|
+
armedAt,
|
|
66
|
+
expiresAt,
|
|
67
|
+
},
|
|
68
|
+
source: { sha: exactSha(values.sourceSha) },
|
|
69
|
+
aws: {
|
|
70
|
+
region: exact(
|
|
71
|
+
values.region || WINDOWS_EC2_JIT.region,
|
|
72
|
+
/^us-[a-z]+-\d$/,
|
|
73
|
+
"region",
|
|
74
|
+
),
|
|
75
|
+
stateTable: tableName(values.stateTable),
|
|
76
|
+
},
|
|
77
|
+
limits: {
|
|
78
|
+
maxAcceptedInstances: WINDOWS_EC2_JIT.maxAcceptedInstances,
|
|
79
|
+
reservationUsd,
|
|
80
|
+
budgetLimitUsd: WINDOWS_EC2_JIT.budgetLimitUsd,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function windowsCampaignArmItems(plan) {
|
|
86
|
+
if (plan?.contract !== AWS_WINDOWS_JIT_CAMPAIGN_CONTRACT) {
|
|
87
|
+
throw new Error("Windows JIT campaign arm plan contract is invalid");
|
|
88
|
+
}
|
|
89
|
+
const table = plan.aws.stateTable;
|
|
90
|
+
const campaignPk = `CAMPAIGN#${plan.campaign.id}`;
|
|
91
|
+
return [
|
|
92
|
+
{
|
|
93
|
+
Put: {
|
|
94
|
+
TableName: table,
|
|
95
|
+
Item: {
|
|
96
|
+
pk: string("CONTROL"),
|
|
97
|
+
state: string("ARMED"),
|
|
98
|
+
campaign_id: string(plan.campaign.id),
|
|
99
|
+
source_sha: string(plan.source.sha),
|
|
100
|
+
armed_at: number(plan.campaign.armedAt),
|
|
101
|
+
expires_epoch: number(plan.campaign.expiresAt),
|
|
102
|
+
},
|
|
103
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
Put: {
|
|
108
|
+
TableName: table,
|
|
109
|
+
Item: {
|
|
110
|
+
pk: string(campaignPk),
|
|
111
|
+
state: string("ARMED"),
|
|
112
|
+
source_sha: string(plan.source.sha),
|
|
113
|
+
accepted_instances: number(0),
|
|
114
|
+
reserved_usd: number(0),
|
|
115
|
+
max_accepted_instances: number(plan.limits.maxAcceptedInstances),
|
|
116
|
+
reservation_usd: number(plan.limits.reservationUsd),
|
|
117
|
+
budget_limit_usd: number(plan.limits.budgetLimitUsd),
|
|
118
|
+
armed_at: number(plan.campaign.armedAt),
|
|
119
|
+
expires_epoch: number(plan.campaign.expiresAt),
|
|
120
|
+
},
|
|
121
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function windowsCampaignReservationItems(plan, observedAt) {
|
|
128
|
+
const now = epoch(observedAt, "observedAt");
|
|
129
|
+
const runPk = runKey(plan);
|
|
130
|
+
const campaignPk = `CAMPAIGN#${plan.campaign.id}`;
|
|
131
|
+
const maxAccepted = plan.safety.campaignAcceptedInstanceCeiling;
|
|
132
|
+
const reservation = plan.safety.campaignReservationUsd;
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
ConditionCheck: {
|
|
136
|
+
TableName: plan.aws.stateTable,
|
|
137
|
+
Key: { pk: string("CONTROL") },
|
|
138
|
+
ConditionExpression:
|
|
139
|
+
"#state = :armed AND campaign_id = :campaign AND source_sha = :source AND expires_epoch >= :now",
|
|
140
|
+
ExpressionAttributeNames: { "#state": "state" },
|
|
141
|
+
ExpressionAttributeValues: {
|
|
142
|
+
":armed": string("ARMED"),
|
|
143
|
+
":campaign": string(plan.campaign.id),
|
|
144
|
+
":source": string(plan.source.sha),
|
|
145
|
+
":now": number(now),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
Put: {
|
|
151
|
+
TableName: plan.aws.stateTable,
|
|
152
|
+
Item: {
|
|
153
|
+
pk: string(runPk),
|
|
154
|
+
state: string("RESERVED"),
|
|
155
|
+
campaign_id: string(plan.campaign.id),
|
|
156
|
+
source_sha: string(plan.source.sha),
|
|
157
|
+
github_run_id: string(plan.github.runId),
|
|
158
|
+
github_run_attempt: string(plan.github.runAttempt),
|
|
159
|
+
qualification_id: string(plan.github.qualificationId),
|
|
160
|
+
reserved_usd: number(reservation),
|
|
161
|
+
observed_at: number(now),
|
|
162
|
+
expires_at: number(now + 1209600),
|
|
163
|
+
},
|
|
164
|
+
ConditionExpression: "attribute_not_exists(pk)",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
Update: {
|
|
169
|
+
TableName: plan.aws.stateTable,
|
|
170
|
+
Key: { pk: string(campaignPk) },
|
|
171
|
+
UpdateExpression:
|
|
172
|
+
"ADD accepted_instances :one, reserved_usd :reservation SET updated_at = :now",
|
|
173
|
+
ConditionExpression:
|
|
174
|
+
"#state = :armed AND source_sha = :source AND accepted_instances < :max AND reserved_usd <= :remaining",
|
|
175
|
+
ExpressionAttributeNames: { "#state": "state" },
|
|
176
|
+
ExpressionAttributeValues: {
|
|
177
|
+
":armed": string("ARMED"),
|
|
178
|
+
":source": string(plan.source.sha),
|
|
179
|
+
":one": number(1),
|
|
180
|
+
":reservation": number(reservation),
|
|
181
|
+
":max": number(maxAccepted),
|
|
182
|
+
":remaining": number(
|
|
183
|
+
plan.safety.campaignBudgetLimitUsd - reservation,
|
|
184
|
+
),
|
|
185
|
+
":now": number(now),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function windowsCampaignMarkLaunchedArgs(plan, instanceId, observedAt) {
|
|
193
|
+
return [
|
|
194
|
+
"dynamodb",
|
|
195
|
+
"update-item",
|
|
196
|
+
"--table-name",
|
|
197
|
+
plan.aws.stateTable,
|
|
198
|
+
"--key",
|
|
199
|
+
JSON.stringify({ pk: string(runKey(plan)) }),
|
|
200
|
+
"--update-expression",
|
|
201
|
+
"SET #state = :launched, instance_id = :instance, launched_at = :now",
|
|
202
|
+
"--condition-expression",
|
|
203
|
+
"#state = :reserved",
|
|
204
|
+
"--expression-attribute-names",
|
|
205
|
+
JSON.stringify({ "#state": "state" }),
|
|
206
|
+
"--expression-attribute-values",
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
":launched": string("LAUNCHED"),
|
|
209
|
+
":reserved": string("RESERVED"),
|
|
210
|
+
":instance": string(instanceId),
|
|
211
|
+
":now": number(epoch(observedAt, "observedAt")),
|
|
212
|
+
}),
|
|
213
|
+
"--output",
|
|
214
|
+
"json",
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function windowsCampaignKillArgs(stateTable, reason, observedAt) {
|
|
219
|
+
return [
|
|
220
|
+
"dynamodb",
|
|
221
|
+
"update-item",
|
|
222
|
+
"--table-name",
|
|
223
|
+
tableName(stateTable),
|
|
224
|
+
"--key",
|
|
225
|
+
JSON.stringify({ pk: string("CONTROL") }),
|
|
226
|
+
"--update-expression",
|
|
227
|
+
"SET #state = :killed, reason = :reason, killed_at = :now",
|
|
228
|
+
"--expression-attribute-names",
|
|
229
|
+
JSON.stringify({ "#state": "state" }),
|
|
230
|
+
"--expression-attribute-values",
|
|
231
|
+
JSON.stringify({
|
|
232
|
+
":killed": string("KILLED"),
|
|
233
|
+
":reason": string(
|
|
234
|
+
exact(reason, /^[a-z0-9][a-z0-9-]{2,63}$/, "reason"),
|
|
235
|
+
),
|
|
236
|
+
":now": number(epoch(observedAt, "observedAt")),
|
|
237
|
+
}),
|
|
238
|
+
"--output",
|
|
239
|
+
"json",
|
|
240
|
+
];
|
|
241
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
createWindowsJitCampaignArmPlan,
|
|
8
|
+
windowsCampaignArmItems,
|
|
9
|
+
windowsCampaignKillArgs,
|
|
10
|
+
} from "./aws-windows-jit-campaign-core.mjs";
|
|
11
|
+
|
|
12
|
+
function arg(name, fallback = "") {
|
|
13
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
14
|
+
return index === -1 ? fallback : process.argv[index + 1] || "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function aws(plan, serviceArgs) {
|
|
18
|
+
const profile = arg("aws-profile");
|
|
19
|
+
const result = spawnSync(
|
|
20
|
+
"aws",
|
|
21
|
+
[
|
|
22
|
+
...(profile ? ["--profile", profile] : []),
|
|
23
|
+
"--region",
|
|
24
|
+
plan.aws.region,
|
|
25
|
+
...serviceArgs,
|
|
26
|
+
],
|
|
27
|
+
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
|
28
|
+
);
|
|
29
|
+
if (result.status !== 0) {
|
|
30
|
+
const detail = String(result.stderr || result.stdout || "")
|
|
31
|
+
.trim()
|
|
32
|
+
.slice(0, 2000);
|
|
33
|
+
throw new Error(`AWS campaign mutation failed${detail ? `: ${detail}` : ""}`);
|
|
34
|
+
}
|
|
35
|
+
return result.stdout ? JSON.parse(result.stdout) : {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function armPlan() {
|
|
39
|
+
return createWindowsJitCampaignArmPlan({
|
|
40
|
+
campaignId: arg("campaign-id"),
|
|
41
|
+
sourceSha: arg("source-sha"),
|
|
42
|
+
stateTable: arg("state-table"),
|
|
43
|
+
region: arg("region", "us-east-1"),
|
|
44
|
+
armedAt: arg("armed-at", new Date().toISOString()),
|
|
45
|
+
expiresAt: arg("expires-at"),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function confirm(plan) {
|
|
50
|
+
if (arg("confirm-campaign-id") !== plan.campaign.id) {
|
|
51
|
+
throw new Error("--confirm-campaign-id must equal the campaign id");
|
|
52
|
+
}
|
|
53
|
+
if (arg("confirm-source-sha") !== plan.source.sha) {
|
|
54
|
+
throw new Error("--confirm-source-sha must equal the exact source SHA");
|
|
55
|
+
}
|
|
56
|
+
if (arg("confirm-state-table") !== plan.aws.stateTable) {
|
|
57
|
+
throw new Error("--confirm-state-table must equal the campaign state table");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function killSwitchTopic() {
|
|
62
|
+
const topic = arg("kill-switch-topic");
|
|
63
|
+
if (
|
|
64
|
+
!/^arn:aws:sns:us-east-1:\d{12}:kungfu-buildchain-windows-jit-[A-Za-z0-9_-]+$/.test(
|
|
65
|
+
topic,
|
|
66
|
+
)
|
|
67
|
+
) {
|
|
68
|
+
throw new Error("--kill-switch-topic must be the dedicated Windows JIT SNS ARN");
|
|
69
|
+
}
|
|
70
|
+
if (arg("confirm-kill-switch-topic") !== topic) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"--confirm-kill-switch-topic must equal the dedicated kill-switch topic",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return topic;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function main() {
|
|
79
|
+
const mode = process.argv[2] || "plan-arm";
|
|
80
|
+
if (["plan-arm", "arm-campaign"].includes(mode)) {
|
|
81
|
+
const plan = armPlan();
|
|
82
|
+
if (mode === "plan-arm") {
|
|
83
|
+
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
84
|
+
return plan;
|
|
85
|
+
}
|
|
86
|
+
confirm(plan);
|
|
87
|
+
aws(plan, [
|
|
88
|
+
"dynamodb",
|
|
89
|
+
"transact-write-items",
|
|
90
|
+
"--transact-items",
|
|
91
|
+
JSON.stringify(windowsCampaignArmItems(plan)),
|
|
92
|
+
"--output",
|
|
93
|
+
"json",
|
|
94
|
+
]);
|
|
95
|
+
const result = {
|
|
96
|
+
contract: plan.contract,
|
|
97
|
+
kind: "campaign-arm-result",
|
|
98
|
+
status: "armed",
|
|
99
|
+
campaign: plan.campaign,
|
|
100
|
+
source: plan.source,
|
|
101
|
+
aws: plan.aws,
|
|
102
|
+
limits: plan.limits,
|
|
103
|
+
};
|
|
104
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
if (mode === "kill-campaign") {
|
|
108
|
+
const now = new Date();
|
|
109
|
+
const plan = createWindowsJitCampaignArmPlan({
|
|
110
|
+
campaignId: arg("campaign-id"),
|
|
111
|
+
sourceSha: arg("source-sha"),
|
|
112
|
+
stateTable: arg("state-table"),
|
|
113
|
+
region: arg("region", "us-east-1"),
|
|
114
|
+
armedAt: now.toISOString(),
|
|
115
|
+
expiresAt: new Date(now.getTime() + 1000).toISOString(),
|
|
116
|
+
});
|
|
117
|
+
confirm(plan);
|
|
118
|
+
const topic = killSwitchTopic();
|
|
119
|
+
aws(
|
|
120
|
+
plan,
|
|
121
|
+
windowsCampaignKillArgs(
|
|
122
|
+
plan.aws.stateTable,
|
|
123
|
+
arg("reason", "operator-kill"),
|
|
124
|
+
new Date().toISOString(),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
const notification = aws(plan, [
|
|
128
|
+
"sns",
|
|
129
|
+
"publish",
|
|
130
|
+
"--topic-arn",
|
|
131
|
+
topic,
|
|
132
|
+
"--message",
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
contract: plan.contract,
|
|
135
|
+
action: "kill-campaign",
|
|
136
|
+
campaignId: plan.campaign.id,
|
|
137
|
+
sourceSha: plan.source.sha,
|
|
138
|
+
}),
|
|
139
|
+
"--output",
|
|
140
|
+
"json",
|
|
141
|
+
]);
|
|
142
|
+
const result = {
|
|
143
|
+
contract: plan.contract,
|
|
144
|
+
kind: "campaign-kill-result",
|
|
145
|
+
status: "killed",
|
|
146
|
+
campaign: plan.campaign,
|
|
147
|
+
source: plan.source,
|
|
148
|
+
killSwitchTopic: topic,
|
|
149
|
+
notificationMessageId: notification.MessageId || "",
|
|
150
|
+
};
|
|
151
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
throw new Error(`unsupported Windows JIT campaign mode: ${mode}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (
|
|
158
|
+
process.argv[1] &&
|
|
159
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
160
|
+
) {
|
|
161
|
+
try {
|
|
162
|
+
main();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error(`::error::${error.message || error}`);
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
}
|
|
167
|
+
}
|