@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.
@@ -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": 82,
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": 85,
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-01
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 resolves exactly
147
- one Windows x64 native lane. The reusable trust gate still runs on a
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. GitHub's JIT endpoint does
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. The runner process exits after
177
- one job, Windows shuts down, and EC2's instance-initiated shutdown behavior is
178
- set to `terminate`. A five-minute reaper terminates card-owned stopped or
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, six accepted three-hour instances reserve USD 26.10. The
183
- two-instance race envelope reserves another USD 8.70, producing a USD 34.80
184
- worst case below the dedicated USD 40 budget. Budget notifications at 80% and
185
- 95% invoke the same card-scoped global kill switch.
186
-
187
- Qualification requires one runner-profile smoke, three trusted exact-source
188
- full Windows jobs, independent cancellation and timeout cleanup exercises, and
189
- zero repository runner, EC2 instance, disposable volume, min capacity, and
190
- desired capacity within 15 minutes of the final job.
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.1",
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
+ }