deepline 0.3.90 → 0.3.92

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 (87) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +101 -6
  2. package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
  3. package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
  4. package/dist/bundling-sources/sdk/src/release.ts +7 -12
  5. package/dist/bundling-sources/sdk/src/types.ts +6 -1
  6. package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
  7. package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
  8. package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
  9. package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
  10. package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
  11. package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +5 -1
  12. package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
  13. package/dist/bundling-sources/shared_libs/monitors/package.json +107 -5
  14. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +12 -7
  15. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +48 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +1 -1
  17. package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
  29. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
  35. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
  36. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
  42. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +46 -3
  45. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +1 -1
  46. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
  47. package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
  48. package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
  49. package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
  50. package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
  51. package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
  52. package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
  53. package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
  54. package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
  55. package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
  56. package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
  57. package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
  58. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +79 -6
  59. package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
  60. package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
  61. package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
  62. package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
  63. package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
  64. package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
  65. package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
  66. package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
  67. package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
  68. package/dist/cli/index.js +369 -162
  69. package/dist/cli/index.mjs +369 -162
  70. package/dist/{compiler-manifest-BaJfE-5H.d.mts → compiler-manifest-x14KL83E.d.mts} +1 -1
  71. package/dist/{compiler-manifest-BaJfE-5H.d.ts → compiler-manifest-x14KL83E.d.ts} +1 -1
  72. package/dist/index.d.mts +45 -8
  73. package/dist/index.d.ts +45 -8
  74. package/dist/index.js +61 -14
  75. package/dist/index.mjs +61 -14
  76. package/dist/install-integrity.json +54 -2
  77. package/dist/monitor-fleet-contract.d.mts +3 -10
  78. package/dist/monitor-fleet-contract.d.ts +3 -10
  79. package/dist/monitor-fleet-contract.js +2 -0
  80. package/dist/monitor-fleet-contract.mjs +1 -1
  81. package/dist/plays/bundle-play-file.d.mts +2 -2
  82. package/dist/plays/bundle-play-file.d.ts +2 -2
  83. package/dist/release.d.mts +3 -3
  84. package/dist/release.d.ts +3 -3
  85. package/dist/release.js +7 -12
  86. package/dist/release.mjs +7 -12
  87. package/package.json +2 -2
@@ -0,0 +1,1378 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Small, Fly-native receipt gateway rollout.
5
+ *
6
+ * Fly is the operation record: an immutable image tag plus deterministic,
7
+ * cordoned Machine names and metadata. There is no second controller state
8
+ * machine to reconcile with the provider.
9
+ *
10
+ * prepare: build image, clone the serving fleet as cordoned Machines, and
11
+ * wait for Fly's service checks to prove boot readiness.
12
+ * prove: run the signed internal fixture through every still-cordoned green
13
+ * Machine. The fixture's transport asserts that Fly did not fall back
14
+ * to another gateway.
15
+ * activate: uncordon an already-proven green fleet, verify normal routing,
16
+ * then cordon blue. It never starts a Play or retires blue.
17
+ * drain: retire exact, already-cordoned blue sources only after their own
18
+ * live-work metric reads zero.
19
+ * cleanup: remove only still-cordoned candidates for this SHA.
20
+ */
21
+ import { spawn, spawnSync } from 'node:child_process';
22
+ import { resolve } from 'node:path';
23
+ import { FlyMachinesClient, requireFlyToken } from '../fly-machines';
24
+ import {
25
+ RECEIPT_GATEWAY_DRAIN_TIMEOUT,
26
+ stopAndDestroyCordonedMachines,
27
+ type FlyMachine,
28
+ } from '../../deployment/fly-machine-lifecycle';
29
+ import {
30
+ assertGatewayRouteProbe,
31
+ exactFlyMachineRouteHeaders,
32
+ formatGatewayRouteProbeRetry,
33
+ waitForGatewayRouteProbe,
34
+ } from '../fly-gateway-route-probe';
35
+ import { reconcileGatewaySourceDrain } from '../../deployment/runtime-gateway-drain';
36
+ import {
37
+ gatewayMachineConfig,
38
+ productionGatewayTopology,
39
+ RUNTIME_TOPOLOGY,
40
+ } from '../../deployment/runtime-topology';
41
+
42
+ const topology = productionGatewayTopology('receipt');
43
+ const fleet = {
44
+ app: RUNTIME_TOPOLOGY.production.components.receiptGateway.app,
45
+ config: RUNTIME_TOPOLOGY.production.components.receiptGateway.image.config,
46
+ dockerfile:
47
+ RUNTIME_TOPOLOGY.production.components.receiptGateway.image.dockerfile,
48
+ minimumMachines: topology.minimumMachines,
49
+ primaryRegion: topology.region,
50
+ processGroup: topology.processGroup,
51
+ };
52
+ const HEALTH_TIMEOUT_MS = 10 * 60_000;
53
+
54
+ const reportGatewayRouteProbeRetry = (
55
+ retry: Parameters<typeof formatGatewayRouteProbeRetry>[0],
56
+ ): void => {
57
+ console.warn(formatGatewayRouteProbeRetry(retry));
58
+ };
59
+ const POLL_MS = 5_000;
60
+ const CANDIDATE_WAIT_PROGRESS_INTERVAL_MS = 30_000;
61
+ const FLY_MACHINE_INVENTORY_TIMEOUT_MS = 30_000;
62
+ const RECEIPT_GATEWAY_OPERATION_PHASE_METADATA_KEY =
63
+ 'deepline_gateway_operation_phase';
64
+
65
+ /**
66
+ * This is persisted on every candidate in Fly metadata before the next
67
+ * externally-visible transition. The string is only the Fly boundary codec;
68
+ * controller code consumes this enum rather than an unvalidated state string.
69
+ */
70
+ export enum ReceiptGatewayOperationPhase {
71
+ Prepared = 'prepared',
72
+ Proven = 'proven',
73
+ Activating = 'activating',
74
+ }
75
+
76
+ /** Provider-observed routing state for a single SHA-fenced candidate fleet. */
77
+ export enum ReceiptGatewayCandidateRouting {
78
+ Cordoned = 'cordoned',
79
+ Mixed = 'mixed',
80
+ Public = 'public',
81
+ }
82
+
83
+ export enum ReceiptGatewayPreparationAction {
84
+ Prepare = 'prepare',
85
+ ReconcileActivation = 'reconcile-activation',
86
+ ResumeActivation = 'resume-activation',
87
+ }
88
+
89
+ type Flags = Map<string, string | boolean>;
90
+
91
+ function flags(argv: string[]): Flags {
92
+ const result = new Map<string, string | boolean>();
93
+ for (let index = 0; index < argv.length; index += 1) {
94
+ const token = argv[index]!;
95
+ if (!token.startsWith('--')) throw new Error(`Unexpected ${token}.`);
96
+ const key = token.slice(2);
97
+ const value = argv[index + 1];
98
+ if (!value || value.startsWith('--')) result.set(key, true);
99
+ else {
100
+ result.set(key, value);
101
+ index += 1;
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+
107
+ function sha(input: Flags): string {
108
+ const value = input.get('sha');
109
+ if (typeof value !== 'string' || !/^[a-f0-9]{7,40}$/i.test(value)) {
110
+ throw new Error('--sha must be a 7-40 character git SHA.');
111
+ }
112
+ return value.toLowerCase();
113
+ }
114
+
115
+ function requireApply(input: Flags): void {
116
+ if (
117
+ input.get('environment') !== 'production' ||
118
+ input.get('apply') !== true
119
+ ) {
120
+ throw new Error('Requires --environment production --apply.');
121
+ }
122
+ }
123
+
124
+ type ReceiptGatewayCommandResult = Readonly<{
125
+ error?: Error & { code?: string };
126
+ status: number | null;
127
+ stdout?: string | null;
128
+ stderr?: string | null;
129
+ }>;
130
+
131
+ type ReceiptGatewayCommandSpawn = (
132
+ command: string,
133
+ args: readonly string[],
134
+ options: Readonly<{
135
+ encoding: 'utf8';
136
+ env: NodeJS.ProcessEnv;
137
+ timeout?: number;
138
+ }>,
139
+ ) => ReceiptGatewayCommandResult;
140
+
141
+ export function runReceiptGatewayCommand(
142
+ command: string,
143
+ args: string[],
144
+ env = process.env,
145
+ options: {
146
+ echoStdout?: boolean;
147
+ timeoutMs?: number;
148
+ spawnCommand?: ReceiptGatewayCommandSpawn;
149
+ } = {},
150
+ ): string {
151
+ const spawnCommand: ReceiptGatewayCommandSpawn =
152
+ options.spawnCommand ??
153
+ ((executable, commandArgs, spawnOptions) =>
154
+ spawnSync(executable, commandArgs, spawnOptions));
155
+ const result = spawnCommand(command, args, {
156
+ encoding: 'utf8',
157
+ env,
158
+ ...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }),
159
+ });
160
+ if (result.stdout && options.echoStdout !== false)
161
+ process.stdout.write(result.stdout);
162
+ if (result.stderr) process.stderr.write(result.stderr);
163
+ if (result.error) {
164
+ if (result.error.code === 'ETIMEDOUT' && options.timeoutMs !== undefined) {
165
+ throw new Error(
166
+ `${command} ${args.join(' ')} timed out after ${options.timeoutMs}ms.`,
167
+ );
168
+ }
169
+ throw result.error;
170
+ }
171
+ if (result.status !== 0) {
172
+ throw new Error(
173
+ `${command} ${args.join(' ')} failed with ${result.status}.`,
174
+ );
175
+ }
176
+ return result.stdout ?? '';
177
+ }
178
+
179
+ function run(
180
+ command: string,
181
+ args: string[],
182
+ env = process.env,
183
+ options: { echoStdout?: boolean } = {},
184
+ ): string {
185
+ return runReceiptGatewayCommand(command, args, env, options);
186
+ }
187
+
188
+ function runAsync(
189
+ command: string,
190
+ args: string[],
191
+ env = process.env,
192
+ cwd?: string,
193
+ ): Promise<void> {
194
+ return new Promise((resolve, reject) => {
195
+ const child = spawn(command, args, { cwd, env, stdio: 'inherit' });
196
+ child.once('error', reject);
197
+ child.once('close', (status) => {
198
+ if (status === 0) {
199
+ resolve();
200
+ return;
201
+ }
202
+ reject(
203
+ new Error(`${command} failed with ${status ?? 'no'} exit status.`),
204
+ );
205
+ });
206
+ });
207
+ }
208
+
209
+ function receiptGatewayImage(operationId: string): string {
210
+ return `registry.fly.io/${fleet.app}:git-${operationId}`;
211
+ }
212
+
213
+ /**
214
+ * One bounded line per Machine. Echoing the raw `flyctl machines list --json`
215
+ * payload printed thousands of lines of config, image labels, and event
216
+ * history on every poll, which buried the actual failure underneath it. The
217
+ * full config is never printed.
218
+ */
219
+ let lastPrintedInventorySignature: string | null = null;
220
+
221
+ export function formatReceiptGatewayInventory(
222
+ inventory: readonly FlyMachine[],
223
+ ): string[] {
224
+ return inventory.map((machine) => {
225
+ const checks = machine.checks ?? [];
226
+ const rendered = checks.length
227
+ ? checks.map((check) => formatFlyMachineCheck(check)).join(' ')
228
+ : 'checks=none';
229
+ return [
230
+ machine.id ?? '<no-id>',
231
+ machine.name ?? '<no-name>',
232
+ `state=${machine.state ?? '<no-state>'}`,
233
+ `cordoned=${machine.cordoned ?? '<unknown>'}`,
234
+ `image=${imageTag(machine)}`,
235
+ rendered,
236
+ ].join(' ');
237
+ });
238
+ }
239
+
240
+ function receiptGatewayCandidateWaitDetails(
241
+ candidates: readonly FlyMachine[],
242
+ ): string {
243
+ const details = formatReceiptGatewayInventory(candidates).join(' | ');
244
+ return details.length <= 2_000 ? details : `${details.slice(0, 2_000)}…`;
245
+ }
246
+
247
+ function formatFlyMachineCheck(check: {
248
+ name?: string;
249
+ status?: string;
250
+ output?: string;
251
+ }): string {
252
+ const output = check.output?.trim();
253
+ return `check ${check.name ?? '<unnamed>'}=${check.status ?? '<no-status>'}${
254
+ output ? ` "${output}"` : ''
255
+ }`;
256
+ }
257
+
258
+ /** Only the image tag; the registry path is identical on every Machine. */
259
+ function imageTag(machine: FlyMachine): string {
260
+ const image = machine.config?.image;
261
+ if (!image) return '<no-image>';
262
+ const tag = image.slice(image.lastIndexOf(':') + 1);
263
+ return tag || image;
264
+ }
265
+
266
+ function machines(): FlyMachine[] {
267
+ const output = runReceiptGatewayCommand(
268
+ 'flyctl',
269
+ ['machines', 'list', '-a', fleet.app, '--json'],
270
+ process.env,
271
+ {
272
+ echoStdout: false,
273
+ timeoutMs: FLY_MACHINE_INVENTORY_TIMEOUT_MS,
274
+ },
275
+ );
276
+ const parsed: unknown = JSON.parse(output);
277
+ if (!Array.isArray(parsed))
278
+ throw new Error('Fly Machine inventory is invalid.');
279
+ const inventory = parsed as FlyMachine[];
280
+ // Printed before anything can throw on this inventory, so the failure that
281
+ // follows is the last thing in the log rather than the first. Polls repeat
282
+ // every few seconds; only a changed fleet state is worth a new line.
283
+ const lines = formatReceiptGatewayInventory(inventory);
284
+ const signature = lines.join('\n');
285
+ if (signature !== lastPrintedInventorySignature) {
286
+ lastPrintedInventorySignature = signature;
287
+ for (const line of lines) console.info(line);
288
+ }
289
+ return inventory;
290
+ }
291
+
292
+ function active(machine: FlyMachine): boolean {
293
+ return !['destroyed', 'destroying'].includes(machine.state ?? '');
294
+ }
295
+
296
+ function healthy(machine: FlyMachine): boolean {
297
+ return (
298
+ machine.state === 'started' &&
299
+ (machine.checks?.length ?? 0) > 0 &&
300
+ machine.checks!.every((check) => check.status === 'passing')
301
+ );
302
+ }
303
+
304
+ function candidateName(operationId: string, index: number): string {
305
+ return `receipt-${operationId.slice(0, 12)}-${index + 1}`;
306
+ }
307
+
308
+ function candidatesFor(
309
+ operationId: string,
310
+ inventory = machines(),
311
+ ): FlyMachine[] {
312
+ return inventory.filter(
313
+ (machine) =>
314
+ active(machine) &&
315
+ machine.config?.metadata?.deepline_gateway_role === 'receipt' &&
316
+ machine.config.metadata.deepline_gateway_rollout === operationId,
317
+ );
318
+ }
319
+
320
+ /**
321
+ * A prepared candidate may be activated only against the exact serving
322
+ * generation it cloned. Another release can prepare concurrently, but once a
323
+ * different release has cut over, this candidate is stale and must be cleaned
324
+ * up or re-prepared. This prevents a late job from uncordoning an older image.
325
+ */
326
+ export function assertCandidateSourceGeneration(input: {
327
+ candidates: FlyMachine[];
328
+ serving: FlyMachine[];
329
+ }): string[] {
330
+ const sourceIds = candidateSourceIds(input.candidates);
331
+ const servingIds = input.serving
332
+ .map((machine) => machine.id)
333
+ .filter((id): id is string => Boolean(id));
334
+ if (
335
+ sourceIds.length !== servingIds.length ||
336
+ sourceIds.some((id) => !servingIds.includes(id))
337
+ ) {
338
+ throw new Error(
339
+ 'Receipt candidates were prepared against a superseded serving generation; clean them up and prepare again.',
340
+ );
341
+ }
342
+ return sourceIds;
343
+ }
344
+
345
+ export function candidateSourceIds(candidates: FlyMachine[]): string[] {
346
+ const sourceIds = [
347
+ ...new Set(
348
+ candidates
349
+ .map(
350
+ (machine) =>
351
+ machine.config?.metadata?.deepline_gateway_source_machine,
352
+ )
353
+ .filter((id): id is string => Boolean(id)),
354
+ ),
355
+ ];
356
+ if (sourceIds.length !== candidates.length) {
357
+ throw new Error(
358
+ 'Receipt candidates do not carry an exact source Machine identity.',
359
+ );
360
+ }
361
+ return sourceIds;
362
+ }
363
+
364
+ function candidatePhase(machine: FlyMachine): ReceiptGatewayOperationPhase {
365
+ const stored =
366
+ machine.config?.metadata?.[RECEIPT_GATEWAY_OPERATION_PHASE_METADATA_KEY];
367
+ if (stored === undefined && machine.cordoned === true) {
368
+ // Existing cordoned candidates predate the durable phase marker. They are
369
+ // compatible with a fresh proof, but never with activation or recovery.
370
+ return ReceiptGatewayOperationPhase.Prepared;
371
+ }
372
+ if (
373
+ !Object.values(ReceiptGatewayOperationPhase).includes(
374
+ stored as ReceiptGatewayOperationPhase,
375
+ )
376
+ ) {
377
+ throw new Error(
378
+ `Receipt candidate ${machine.id ?? '<missing>'} has an invalid durable operation phase.`,
379
+ );
380
+ }
381
+ return stored as ReceiptGatewayOperationPhase;
382
+ }
383
+
384
+ export function receiptGatewayCandidateRouting(
385
+ candidates: readonly FlyMachine[],
386
+ ): ReceiptGatewayCandidateRouting {
387
+ if (candidates.length === 0) {
388
+ throw new Error('Receipt rollout has no Fly candidates.');
389
+ }
390
+ const cordoned = candidates.filter((machine) => machine.cordoned === true);
391
+ if (cordoned.length === candidates.length) {
392
+ return ReceiptGatewayCandidateRouting.Cordoned;
393
+ }
394
+ if (cordoned.length === 0) {
395
+ return ReceiptGatewayCandidateRouting.Public;
396
+ }
397
+ return ReceiptGatewayCandidateRouting.Mixed;
398
+ }
399
+
400
+ function assertCandidatePhase(input: {
401
+ candidates: readonly FlyMachine[];
402
+ allowed: readonly ReceiptGatewayOperationPhase[];
403
+ }): ReceiptGatewayOperationPhase {
404
+ const phases = candidatePhases(input.candidates);
405
+ if (phases.size !== 1) {
406
+ throw new Error(
407
+ 'Receipt candidates disagree on their durable operation phase.',
408
+ );
409
+ }
410
+ const [phase] = phases;
411
+ if (!phase || !input.allowed.includes(phase)) {
412
+ throw new Error(
413
+ `Receipt candidates are not in an allowed durable operation phase: ${phase ?? 'missing'}.`,
414
+ );
415
+ }
416
+ return phase;
417
+ }
418
+
419
+ function candidatePhases(
420
+ candidates: readonly FlyMachine[],
421
+ ): ReadonlySet<ReceiptGatewayOperationPhase> {
422
+ return new Set(candidates.map(candidatePhase));
423
+ }
424
+
425
+ export type ReceiptGatewayCandidateOperation = Readonly<{
426
+ phase: ReceiptGatewayOperationPhase;
427
+ routing: ReceiptGatewayCandidateRouting;
428
+ }>;
429
+
430
+ /**
431
+ * Decode Fly's candidate metadata and admission state once. A caller can only
432
+ * resume a non-cordoned fleet when every candidate carries the durable
433
+ * `Activating` fence written before the first uncordon.
434
+ */
435
+ export function classifyReceiptGatewayCandidateOperation(
436
+ candidates: readonly FlyMachine[],
437
+ ): ReceiptGatewayCandidateOperation {
438
+ const phase = assertCandidatePhase({
439
+ candidates,
440
+ allowed: Object.values(ReceiptGatewayOperationPhase),
441
+ });
442
+ const routing = receiptGatewayCandidateRouting(candidates);
443
+ if (
444
+ routing !== ReceiptGatewayCandidateRouting.Cordoned &&
445
+ phase !== ReceiptGatewayOperationPhase.Activating
446
+ ) {
447
+ throw new Error(
448
+ `Receipt candidates are not in an allowed durable operation phase for ${routing} routing: ${phase}.`,
449
+ );
450
+ }
451
+ return { phase, routing };
452
+ }
453
+
454
+ export function receiptGatewayPreparationAction(
455
+ candidates: readonly FlyMachine[],
456
+ ): ReceiptGatewayPreparationAction {
457
+ const routing = receiptGatewayCandidateRouting(candidates);
458
+ const phases = candidatePhases(candidates);
459
+ const hasOnly = (...expected: ReceiptGatewayOperationPhase[]): boolean =>
460
+ [...phases].every((phase) => expected.includes(phase));
461
+ if (routing !== ReceiptGatewayCandidateRouting.Cordoned) {
462
+ if (
463
+ phases.size === 1 &&
464
+ phases.has(ReceiptGatewayOperationPhase.Activating)
465
+ ) {
466
+ return ReceiptGatewayPreparationAction.ResumeActivation;
467
+ }
468
+ throw new Error(
469
+ 'Public receipt candidates require a complete durable activation fence.',
470
+ );
471
+ }
472
+ if (
473
+ phases.size === 1 &&
474
+ phases.has(ReceiptGatewayOperationPhase.Activating)
475
+ ) {
476
+ return ReceiptGatewayPreparationAction.ResumeActivation;
477
+ }
478
+ if (
479
+ phases.has(ReceiptGatewayOperationPhase.Activating) &&
480
+ hasOnly(
481
+ ReceiptGatewayOperationPhase.Proven,
482
+ ReceiptGatewayOperationPhase.Activating,
483
+ )
484
+ ) {
485
+ // A metadata POST may have succeeded for only part of the cordoned fleet.
486
+ // Complete the same intent before considering any other transition.
487
+ return ReceiptGatewayPreparationAction.ReconcileActivation;
488
+ }
489
+ if (
490
+ hasOnly(
491
+ ReceiptGatewayOperationPhase.Prepared,
492
+ ReceiptGatewayOperationPhase.Proven,
493
+ )
494
+ ) {
495
+ // Partial proof markers carry no activation authority. Re-run the exact
496
+ // cordoned fixture proof rather than inferring success from a subset.
497
+ return ReceiptGatewayPreparationAction.Prepare;
498
+ }
499
+ throw new Error(
500
+ 'Receipt candidates have an unrecoverable durable phase set.',
501
+ );
502
+ }
503
+
504
+ function assertCandidateFleet(input: {
505
+ candidates: readonly FlyMachine[];
506
+ operationId: string;
507
+ count: number;
508
+ routing?: ReceiptGatewayCandidateRouting;
509
+ }): 'ready' | 'starting' {
510
+ if (input.candidates.length !== input.count) return 'starting';
511
+ for (const machine of input.candidates) {
512
+ if (!machine.id) throw new Error('Receipt candidate has no identity.');
513
+ if (
514
+ !machine.name?.startsWith(`receipt-${input.operationId.slice(0, 12)}-`)
515
+ ) {
516
+ throw new Error(
517
+ `Receipt candidate ${machine.id} has no deterministic operation name.`,
518
+ );
519
+ }
520
+ if (
521
+ ['failed', 'stopped', 'suspended', 'destroying', 'destroyed'].includes(
522
+ machine.state ?? '',
523
+ )
524
+ ) {
525
+ throw new Error(
526
+ `Receipt candidate ${machine.id} entered terminal state ${machine.state}.`,
527
+ );
528
+ }
529
+ if (machine.config?.image !== receiptGatewayImage(input.operationId)) {
530
+ throw new Error(
531
+ `Receipt candidate ${machine.id} has the wrong immutable image.`,
532
+ );
533
+ }
534
+ if (!healthy(machine)) return 'starting';
535
+ }
536
+ if (input.routing !== undefined) {
537
+ const observed = receiptGatewayCandidateRouting(input.candidates);
538
+ if (observed !== input.routing) return 'starting';
539
+ }
540
+ return 'ready';
541
+ }
542
+
543
+ /**
544
+ * "not healthy" alone cannot be acted on. Name every serving Machine that is
545
+ * unhealthy and the exact check that says so, so the release log answers
546
+ * "which Machine, which check, and what did it report?" without a second Fly
547
+ * query.
548
+ */
549
+ export function describeUnhealthyServingFleet(input: {
550
+ serving: readonly FlyMachine[];
551
+ minimumMachines: number;
552
+ }): string | null {
553
+ const reasons = input.serving
554
+ .filter((machine) => !healthy(machine))
555
+ .map((machine) => {
556
+ const identity = `${machine.id ?? '<no-id>'} (${machine.name ?? '<no-name>'})`;
557
+ const checks = machine.checks ?? [];
558
+ if (machine.state !== 'started') {
559
+ return `${identity} state=${machine.state ?? '<no-state>'}`;
560
+ }
561
+ if (checks.length === 0) return `${identity} reported no health checks`;
562
+ return `${identity} ${checks
563
+ .filter((check) => check.status !== 'passing')
564
+ .map((check) => formatFlyMachineCheck(check))
565
+ .join(' ')}`;
566
+ });
567
+ if (input.serving.length < input.minimumMachines) {
568
+ reasons.unshift(
569
+ `expected at least ${input.minimumMachines} serving Machine(s), found ${input.serving.length}`,
570
+ );
571
+ }
572
+ return reasons.length > 0 ? reasons.join('; ') : null;
573
+ }
574
+
575
+ function serving(inventory = machines()): FlyMachine[] {
576
+ const result = inventory.filter(
577
+ (machine) => active(machine) && machine.cordoned === false,
578
+ );
579
+ const unhealthy = describeUnhealthyServingFleet({
580
+ serving: result,
581
+ minimumMachines: fleet.minimumMachines,
582
+ });
583
+ if (unhealthy) {
584
+ throw new Error(
585
+ `Receipt gateway serving fleet is not healthy: ${unhealthy}`,
586
+ );
587
+ }
588
+ const images = new Set(
589
+ result.map((machine) => machine.config?.image).filter(Boolean),
590
+ );
591
+ if (images.size !== 1)
592
+ throw new Error('Receipt gateway serving fleet has mixed images.');
593
+ return result;
594
+ }
595
+
596
+ /**
597
+ * A single Fly check flapping on one blue Machine for a few seconds must not
598
+ * fail a release. Poll the serving generation until it is coherent or the
599
+ * health budget is spent, then surface the last actionable reason. Only a
600
+ * transient fleet condition is retried; every other failure is immediate.
601
+ */
602
+ const RETRYABLE_SERVING_FAILURE =
603
+ /serving fleet is not healthy|serving fleet has mixed images/;
604
+
605
+ export async function waitForServingGeneration(input: {
606
+ serving: () => FlyMachine[];
607
+ sleep: (milliseconds: number) => Promise<void>;
608
+ now?: () => number;
609
+ timeoutMs?: number;
610
+ pollMs?: number;
611
+ }): Promise<FlyMachine[]> {
612
+ const now = input.now ?? Date.now;
613
+ const pollMs = input.pollMs ?? POLL_MS;
614
+ const deadline = now() + (input.timeoutMs ?? HEALTH_TIMEOUT_MS);
615
+ for (;;) {
616
+ try {
617
+ return input.serving();
618
+ } catch (error) {
619
+ const message = error instanceof Error ? error.message : String(error);
620
+ if (!RETRYABLE_SERVING_FAILURE.test(message)) throw error;
621
+ if (now() >= deadline) throw error;
622
+ await input.sleep(pollMs);
623
+ }
624
+ }
625
+ }
626
+
627
+ export function readinessOfCordonedCandidates(input: {
628
+ candidates: FlyMachine[];
629
+ operationId: string;
630
+ count: number;
631
+ }): 'ready' | 'starting' {
632
+ return assertCandidateFleet({
633
+ ...input,
634
+ routing: ReceiptGatewayCandidateRouting.Cordoned,
635
+ });
636
+ }
637
+
638
+ /**
639
+ * Fly only reports candidate health through its Machine inventory. That
640
+ * inventory may remain unchanged while a Machine is still booting, so emit a
641
+ * bounded heartbeat with the latest reason instead of leaving a release log
642
+ * silent for the full health budget.
643
+ */
644
+ export async function waitForReceiptGatewayCandidates(input: {
645
+ operationId: string;
646
+ count: number;
647
+ candidates: () => FlyMachine[];
648
+ readiness: (candidates: FlyMachine[]) => 'ready' | 'starting';
649
+ sleep: (milliseconds: number) => Promise<void>;
650
+ now?: () => number;
651
+ timeoutMs?: number;
652
+ pollMs?: number;
653
+ progressIntervalMs?: number;
654
+ reportProgress?: (message: string) => void;
655
+ shouldFailImmediately?: (failure: string) => boolean;
656
+ }): Promise<FlyMachine[]> {
657
+ const now = input.now ?? Date.now;
658
+ const timeoutMs = input.timeoutMs ?? HEALTH_TIMEOUT_MS;
659
+ const pollMs = input.pollMs ?? POLL_MS;
660
+ const progressIntervalMs =
661
+ input.progressIntervalMs ?? CANDIDATE_WAIT_PROGRESS_INTERVAL_MS;
662
+ const startedAt = now();
663
+ const deadline = startedAt + timeoutMs;
664
+ let lastProgressAt: number | undefined;
665
+ let observedCandidates: FlyMachine[] = [];
666
+ let observedCount = 0;
667
+ let failure = 'starting';
668
+
669
+ while (now() < deadline) {
670
+ try {
671
+ const candidates = input.candidates();
672
+ observedCandidates = candidates;
673
+ observedCount = candidates.length;
674
+ if (candidates.length !== input.count) {
675
+ failure = `expected ${input.count} candidates; found ${candidates.length}`;
676
+ } else if (input.readiness(candidates) === 'starting') {
677
+ failure = 'receipt candidate is still starting';
678
+ } else {
679
+ return candidates;
680
+ }
681
+ } catch (error) {
682
+ failure = error instanceof Error ? error.message : String(error);
683
+ if (input.shouldFailImmediately?.(failure)) throw error;
684
+ }
685
+
686
+ const current = now();
687
+ if (
688
+ lastProgressAt === undefined ||
689
+ current - lastProgressAt >= progressIntervalMs
690
+ ) {
691
+ const elapsedSeconds = Math.floor((current - startedAt) / 1_000);
692
+ const remainingSeconds = Math.max(
693
+ 0,
694
+ Math.ceil((deadline - current) / 1_000),
695
+ );
696
+ input.reportProgress?.(
697
+ `[receipt-gateway] still waiting for candidates: operation=${input.operationId} elapsed=${elapsedSeconds}s remaining=${remainingSeconds}s candidates=${observedCount}/${input.count} reason=${JSON.stringify(failure)} details=${JSON.stringify(receiptGatewayCandidateWaitDetails(observedCandidates))}`,
698
+ );
699
+ lastProgressAt = current;
700
+ }
701
+ await input.sleep(pollMs);
702
+ }
703
+
704
+ throw new Error(
705
+ `Receipt candidates did not become ready after ${Math.ceil(timeoutMs / 1_000)}s: ${failure} (last observed ${observedCount}/${input.count} candidates).`,
706
+ );
707
+ }
708
+
709
+ async function waitForCandidates(
710
+ operationId: string,
711
+ count: number,
712
+ ): Promise<FlyMachine[]> {
713
+ return waitForReceiptGatewayCandidates({
714
+ operationId,
715
+ count,
716
+ candidates: () => candidatesFor(operationId),
717
+ readiness: (candidates) =>
718
+ readinessOfCordonedCandidates({
719
+ candidates,
720
+ operationId,
721
+ count,
722
+ }),
723
+ sleep: (milliseconds) =>
724
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
725
+ reportProgress: console.info,
726
+ shouldFailImmediately: (failure) =>
727
+ /wrong immutable image|became public|terminal state|has no identity|deterministic operation name|did not serve its public route probe/.test(
728
+ failure,
729
+ ),
730
+ });
731
+ }
732
+
733
+ async function waitForCandidateFleet(input: {
734
+ operationId: string;
735
+ count: number;
736
+ routing: ReceiptGatewayCandidateRouting;
737
+ allowedPhases: readonly ReceiptGatewayOperationPhase[];
738
+ }): Promise<FlyMachine[]> {
739
+ return waitForReceiptGatewayCandidates({
740
+ operationId: input.operationId,
741
+ count: input.count,
742
+ candidates: () => candidatesFor(input.operationId),
743
+ readiness: (candidates) => {
744
+ const readiness = assertCandidateFleet({
745
+ candidates,
746
+ operationId: input.operationId,
747
+ count: input.count,
748
+ routing: input.routing,
749
+ });
750
+ if (readiness === 'ready') {
751
+ assertCandidatePhase({
752
+ candidates,
753
+ allowed: input.allowedPhases,
754
+ });
755
+ }
756
+ return readiness;
757
+ },
758
+ sleep: (milliseconds) =>
759
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
760
+ reportProgress: console.info,
761
+ shouldFailImmediately: (failure) =>
762
+ /wrong immutable image|terminal state|has no identity|deterministic operation name|invalid durable operation phase|disagree on their durable operation phase|not in an allowed durable operation phase/.test(
763
+ failure,
764
+ ),
765
+ });
766
+ }
767
+
768
+ async function persistCandidatePhase(input: {
769
+ candidates: readonly FlyMachine[];
770
+ phase: ReceiptGatewayOperationPhase;
771
+ }): Promise<void> {
772
+ const client = new FlyMachinesClient(fleet.app, requireFlyToken(), fetch);
773
+ await Promise.all(
774
+ input.candidates.map(async (candidate) => {
775
+ if (!candidate.id) throw new Error('Receipt candidate has no identity.');
776
+ await client.setMachineMetadata(
777
+ candidate.id,
778
+ RECEIPT_GATEWAY_OPERATION_PHASE_METADATA_KEY,
779
+ input.phase,
780
+ );
781
+ }),
782
+ );
783
+ }
784
+
785
+ async function assertPublic(machineIds: readonly string[]): Promise<void> {
786
+ const url = `https://${fleet.app}.fly.dev/readyz`;
787
+ for (const machineId of machineIds) {
788
+ const response = await fetch(url, {
789
+ headers: exactFlyMachineRouteHeaders(machineId),
790
+ signal: AbortSignal.timeout(10_000),
791
+ });
792
+ assertGatewayRouteProbe({
793
+ status: response.status,
794
+ body: await response.text(),
795
+ expectedMachineId: machineId,
796
+ actualMachineId: response.headers.get('x-deepline-gateway-machine-id'),
797
+ });
798
+ }
799
+ const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
800
+ assertGatewayRouteProbe({
801
+ status: response.status,
802
+ body: await response.text(),
803
+ actualMachineId: response.headers.get('x-deepline-gateway-machine-id'),
804
+ });
805
+ }
806
+
807
+ async function removeCordoned(operationId: string): Promise<void> {
808
+ const targets = candidatesFor(operationId);
809
+ if (targets.some((machine) => !machine.id)) {
810
+ throw new Error(
811
+ 'Receipt candidate cleanup requires exact Fly Machine identities.',
812
+ );
813
+ }
814
+ if (targets.some((machine) => machine.cordoned !== true)) {
815
+ throw new Error(
816
+ 'Refusing to remove a receipt candidate that received public traffic.',
817
+ );
818
+ }
819
+ const client = new FlyMachinesClient(fleet.app, requireFlyToken(), fetch);
820
+ await stopAndDestroyCordonedMachines({
821
+ machineIds: targets.map((machine) => machine.id!).filter(Boolean),
822
+ readMachines: machines,
823
+ listProviderMachineIds: async () =>
824
+ (await client.listMachines()).map((machine) => machine.id),
825
+ stopMachine: (machineId) =>
826
+ client.stopMachine(machineId, {
827
+ signal: 'SIGTERM',
828
+ timeout: RECEIPT_GATEWAY_DRAIN_TIMEOUT,
829
+ }),
830
+ deleteMachine: (machineId) => client.deleteMachine(machineId, false),
831
+ });
832
+ }
833
+
834
+ /** The public green candidates are the durable Fly operation record. Their
835
+ * source metadata is the only authority for destructive blue retirement. */
836
+ async function drain(operationId: string): Promise<void> {
837
+ const client = new FlyMachinesClient(fleet.app, requireFlyToken(), fetch);
838
+ const green = candidatesFor(operationId, await client.listMachines());
839
+ if (
840
+ green.length < fleet.minimumMachines ||
841
+ green.some(
842
+ (machine) =>
843
+ !machine.id || !healthy(machine) || machine.cordoned !== false,
844
+ )
845
+ ) {
846
+ throw new Error(
847
+ 'Receipt gateway drain requires the exact rollout to remain a complete healthy public generation.',
848
+ );
849
+ }
850
+ const outcome = await reconcileGatewaySourceDrain({
851
+ app: fleet.app,
852
+ client,
853
+ sourceMachineIds: candidateSourceIds(green),
854
+ });
855
+ console.info(JSON.stringify({ operationId, ...outcome }));
856
+ }
857
+
858
+ async function prepare(operationId: string): Promise<void> {
859
+ // Different immutable releases may prepare cordoned candidates in parallel.
860
+ // Never let one release delete another release's fenced evidence; the
861
+ // topology cleanup command owns that exact-SHA operation explicitly.
862
+ const existing = candidatesFor(operationId);
863
+ if (existing.length > 0) {
864
+ const action = receiptGatewayPreparationAction(existing);
865
+ if (action !== ReceiptGatewayPreparationAction.Prepare) {
866
+ const routing = receiptGatewayCandidateRouting(existing);
867
+ // The durable activation fence is written before any uncordon. A retry
868
+ // must observe and resume this exact fleet, not re-evaluate the current
869
+ // public generation, create replacements, or attempt compensation.
870
+ if (action === ReceiptGatewayPreparationAction.ReconcileActivation) {
871
+ await persistCandidatePhase({
872
+ candidates: existing,
873
+ phase: ReceiptGatewayOperationPhase.Activating,
874
+ });
875
+ }
876
+ const resumed = await waitForCandidateFleet({
877
+ operationId,
878
+ count: existing.length,
879
+ routing,
880
+ allowedPhases: [ReceiptGatewayOperationPhase.Activating],
881
+ });
882
+ candidateSourceIds(resumed);
883
+ console.info(
884
+ `Resuming receipt gateway activation for ${operationId} from ${routing}.`,
885
+ );
886
+ return;
887
+ }
888
+ }
889
+ run('bun', [
890
+ 'scripts/ensure-runtime-image.ts',
891
+ '--kind',
892
+ 'gateway',
893
+ '--app',
894
+ fleet.app,
895
+ '--config',
896
+ fleet.config,
897
+ '--dockerfile',
898
+ fleet.dockerfile,
899
+ '--sha',
900
+ operationId,
901
+ ]);
902
+ const blue = serving();
903
+ if (existing.length > 0) {
904
+ assertCandidateSourceGeneration({ candidates: existing, serving: blue });
905
+ }
906
+ const client = new FlyMachinesClient(fleet.app, requireFlyToken(), fetch);
907
+ for (const [index, source] of blue.entries()) {
908
+ const name = candidateName(operationId, index);
909
+ if (existing.some((machine) => machine.name === name)) continue;
910
+ if (!source.id) throw new Error('Serving Machine has no identity.');
911
+ await client.createMachine({
912
+ name,
913
+ region: fleet.primaryRegion,
914
+ skip_service_registration: true,
915
+ config: gatewayMachineConfig({
916
+ profile: 'production',
917
+ role: 'receipt',
918
+ image: receiptGatewayImage(operationId),
919
+ metadata: {
920
+ deepline_gateway_role: 'receipt',
921
+ deepline_gateway_rollout: operationId,
922
+ deepline_gateway_source_machine: source.id,
923
+ [RECEIPT_GATEWAY_OPERATION_PHASE_METADATA_KEY]:
924
+ ReceiptGatewayOperationPhase.Prepared,
925
+ fly_process_group: fleet.processGroup,
926
+ },
927
+ }),
928
+ });
929
+ }
930
+ const ready = await waitForCandidates(operationId, blue.length);
931
+ await assertPublic(blue.map((machine) => machine.id!));
932
+ console.info(
933
+ JSON.stringify({
934
+ operationId,
935
+ candidates: ready.map((machine) => machine.id),
936
+ }),
937
+ );
938
+ }
939
+
940
+ /**
941
+ * Gateway policy code can live in a separately checked-out revision from the
942
+ * candidate application. The fixture is application code, so its imports and
943
+ * generated artifacts must resolve from the application checkout instead of
944
+ * from the policy command's current directory.
945
+ */
946
+ export function resolveReceiptFixtureApplicationDirectory(
947
+ applicationDirectory?: string,
948
+ ): string {
949
+ return applicationDirectory?.trim()
950
+ ? resolve(applicationDirectory)
951
+ : process.cwd();
952
+ }
953
+
954
+ export function receiptFixtureCanaryInvocation(input: {
955
+ url: string;
956
+ machineId: string;
957
+ applicationDirectory?: string;
958
+ }): Readonly<{
959
+ command: 'bun';
960
+ args: readonly string[];
961
+ cwd: string;
962
+ }> {
963
+ return {
964
+ command: 'bun',
965
+ args: [
966
+ 'scripts/run-production-release-canary.ts',
967
+ '--host',
968
+ input.url,
969
+ '--label',
970
+ `receipt-${input.machineId}`,
971
+ '--mode',
972
+ 'receipt-candidate',
973
+ ],
974
+ cwd: resolveReceiptFixtureApplicationDirectory(input.applicationDirectory),
975
+ };
976
+ }
977
+
978
+ function canary(
979
+ url: string,
980
+ machineId: string,
981
+ applicationDirectory?: string,
982
+ ): Promise<void> {
983
+ const invocation = receiptFixtureCanaryInvocation({
984
+ url,
985
+ machineId,
986
+ applicationDirectory,
987
+ });
988
+ return runAsync(
989
+ invocation.command,
990
+ [...invocation.args],
991
+ {
992
+ ...process.env,
993
+ DEEPLINE_PREFERRED_RECEIPT_GATEWAY_MACHINE_ID: machineId,
994
+ },
995
+ invocation.cwd,
996
+ );
997
+ }
998
+
999
+ /**
1000
+ * The proof boundary deliberately has no routing mutation. A fixture is
1001
+ * force-routed only by the already-authorized internal runtime transport; the
1002
+ * helper re-reads the cordon invariant before and after every exact-Machine
1003
+ * proof so a concurrent or accidental uncordon cannot be mistaken for a
1004
+ * cordoned proof.
1005
+ */
1006
+ export async function proveCordonedReceiptCandidates(input: {
1007
+ greenMachineIds: readonly string[];
1008
+ assertCordoned: (machineIds: readonly string[]) => Promise<void>;
1009
+ canary: (machineId: string) => void | Promise<void>;
1010
+ }): Promise<void> {
1011
+ await input.assertCordoned(input.greenMachineIds);
1012
+ const outcomes = await Promise.allSettled(
1013
+ input.greenMachineIds.map(
1014
+ async (machineId) => await input.canary(machineId),
1015
+ ),
1016
+ );
1017
+ await input.assertCordoned(input.greenMachineIds);
1018
+ const failed = outcomes.find(
1019
+ (outcome): outcome is PromiseRejectedResult =>
1020
+ outcome.status === 'rejected',
1021
+ );
1022
+ if (failed) {
1023
+ throw failed.reason;
1024
+ }
1025
+ }
1026
+
1027
+ export async function activateCordonedReceiptCandidates(input: {
1028
+ greenMachineIds: readonly string[];
1029
+ blueMachineIds: readonly string[];
1030
+ uncordonMachineIds?: readonly string[];
1031
+ uncordon: (machineId: string) => void | Promise<void>;
1032
+ cordon: (machineId: string) => void | Promise<void>;
1033
+ assertPublic: (machineIds: readonly string[]) => Promise<void>;
1034
+ sleep: (milliseconds: number) => Promise<void>;
1035
+ }): Promise<void> {
1036
+ try {
1037
+ for (const machineId of input.uncordonMachineIds ?? input.greenMachineIds)
1038
+ await input.uncordon(machineId);
1039
+ await input.sleep(POLL_MS);
1040
+ await waitForGatewayRouteProbe({
1041
+ description: 'Receipt gateway green generation',
1042
+ probe: () => input.assertPublic(input.greenMachineIds),
1043
+ sleep: input.sleep,
1044
+ onRetry: reportGatewayRouteProbeRetry,
1045
+ });
1046
+ } catch (error) {
1047
+ for (const machineId of input.greenMachineIds)
1048
+ await input.cordon(machineId);
1049
+ await input.sleep(POLL_MS);
1050
+ await waitForGatewayRouteProbe({
1051
+ description: 'Receipt gateway rollback generation',
1052
+ probe: () => input.assertPublic(input.blueMachineIds),
1053
+ sleep: input.sleep,
1054
+ onRetry: reportGatewayRouteProbeRetry,
1055
+ });
1056
+ throw error;
1057
+ }
1058
+ }
1059
+
1060
+ /**
1061
+ * Restore the exact source generation after a public candidate failed a later
1062
+ * tuple check. Source Machines are uncordoned and proven first; only then is
1063
+ * the failed generation removed from admission. This is intentionally the
1064
+ * inverse of `activateCordonedReceiptCandidates`, not a new deployment.
1065
+ */
1066
+ export async function restoreReceiptGatewaySourceGeneration(input: {
1067
+ greenMachineIds: readonly string[];
1068
+ blueMachineIds: readonly string[];
1069
+ uncordon: (machineId: string) => void | Promise<void>;
1070
+ cordon: (machineId: string) => void | Promise<void>;
1071
+ assertPublic: (machineIds: readonly string[]) => Promise<void>;
1072
+ sleep: (milliseconds: number) => Promise<void>;
1073
+ }): Promise<void> {
1074
+ if (input.greenMachineIds.length === 0 || input.blueMachineIds.length === 0) {
1075
+ throw new Error(
1076
+ 'Gateway rollback requires exact green and blue Machine identities.',
1077
+ );
1078
+ }
1079
+ for (const machineId of input.blueMachineIds) await input.uncordon(machineId);
1080
+ await input.sleep(POLL_MS);
1081
+ await waitForGatewayRouteProbe({
1082
+ description: 'Receipt gateway rollback source generation',
1083
+ probe: () => input.assertPublic(input.blueMachineIds),
1084
+ sleep: input.sleep,
1085
+ onRetry: reportGatewayRouteProbeRetry,
1086
+ });
1087
+ for (const machineId of input.greenMachineIds) await input.cordon(machineId);
1088
+ await input.sleep(POLL_MS);
1089
+ await waitForGatewayRouteProbe({
1090
+ description: 'Receipt gateway restored source generation',
1091
+ probe: () => input.assertPublic(input.blueMachineIds),
1092
+ sleep: input.sleep,
1093
+ onRetry: reportGatewayRouteProbeRetry,
1094
+ });
1095
+ }
1096
+
1097
+ function assertExactCordonedCandidates(input: {
1098
+ operationId: string;
1099
+ expectedMachineIds: readonly string[];
1100
+ }): Promise<void> {
1101
+ const candidates = candidatesFor(input.operationId);
1102
+ const candidateIds = candidates
1103
+ .map((machine) => machine.id)
1104
+ .filter((machineId): machineId is string => Boolean(machineId));
1105
+ if (
1106
+ candidateIds.length !== input.expectedMachineIds.length ||
1107
+ input.expectedMachineIds.some(
1108
+ (machineId) => !candidateIds.includes(machineId),
1109
+ )
1110
+ ) {
1111
+ throw new Error(
1112
+ 'Receipt proof candidates no longer match the SHA-fenced Fly operation.',
1113
+ );
1114
+ }
1115
+ if (candidates.some((machine) => machine.cordoned !== true)) {
1116
+ throw new Error(
1117
+ 'Receipt candidate became public before the proof completed.',
1118
+ );
1119
+ }
1120
+ return Promise.resolve();
1121
+ }
1122
+
1123
+ async function prove(
1124
+ operationId: string,
1125
+ stagedUrl: string,
1126
+ fixtureApplicationDirectory?: string,
1127
+ ): Promise<void> {
1128
+ let green = candidatesFor(operationId);
1129
+ if (green.length === 0) {
1130
+ throw new Error('Receipt rollout has no Fly candidates to prove.');
1131
+ }
1132
+ const greenMachineIds = green
1133
+ .map((machine) => machine.id)
1134
+ .filter((machineId): machineId is string => Boolean(machineId));
1135
+ if (greenMachineIds.length !== green.length) {
1136
+ throw new Error('Receipt proof candidate has no Fly Machine identity.');
1137
+ }
1138
+ const routing = receiptGatewayCandidateRouting(green);
1139
+ if (routing === ReceiptGatewayCandidateRouting.Cordoned) {
1140
+ const action = receiptGatewayPreparationAction(green);
1141
+ if (action !== ReceiptGatewayPreparationAction.Prepare) {
1142
+ // A worker died after recording activation intent but before the first
1143
+ // uncordon. That durable intent is stronger than a vanished runner-local
1144
+ // receipt; the later commit will resume the same exact candidates.
1145
+ if (action === ReceiptGatewayPreparationAction.ReconcileActivation) {
1146
+ await persistCandidatePhase({
1147
+ candidates: green,
1148
+ phase: ReceiptGatewayOperationPhase.Activating,
1149
+ });
1150
+ }
1151
+ await waitForCandidateFleet({
1152
+ operationId,
1153
+ count: greenMachineIds.length,
1154
+ routing: ReceiptGatewayCandidateRouting.Cordoned,
1155
+ allowedPhases: [ReceiptGatewayOperationPhase.Activating],
1156
+ });
1157
+ return;
1158
+ }
1159
+ assertCandidateSourceGeneration({
1160
+ candidates: green,
1161
+ serving: await waitForServingGeneration({
1162
+ serving: () => serving(),
1163
+ sleep: (milliseconds) =>
1164
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
1165
+ }),
1166
+ });
1167
+ green = await waitForCandidates(operationId, greenMachineIds.length);
1168
+ await proveCordonedReceiptCandidates({
1169
+ greenMachineIds,
1170
+ assertCordoned: (machineIds) =>
1171
+ assertExactCordonedCandidates({
1172
+ operationId,
1173
+ expectedMachineIds: machineIds,
1174
+ }),
1175
+ canary: (machineId) =>
1176
+ canary(stagedUrl, machineId, fixtureApplicationDirectory),
1177
+ });
1178
+ await persistCandidatePhase({
1179
+ candidates: green,
1180
+ phase: ReceiptGatewayOperationPhase.Proven,
1181
+ });
1182
+ await waitForCandidateFleet({
1183
+ operationId,
1184
+ count: greenMachineIds.length,
1185
+ routing: ReceiptGatewayCandidateRouting.Cordoned,
1186
+ allowedPhases: [ReceiptGatewayOperationPhase.Proven],
1187
+ });
1188
+ return;
1189
+ }
1190
+
1191
+ // A public or partially-public green fleet can only be resumed when every
1192
+ // candidate carries the durable activation intent written before its first
1193
+ // uncordon. Never pretend this is a fresh cordoned proof; it is recovery of
1194
+ // the exact provider operation after a runner/network interruption.
1195
+ await waitForCandidateFleet({
1196
+ operationId,
1197
+ count: greenMachineIds.length,
1198
+ routing,
1199
+ allowedPhases: [ReceiptGatewayOperationPhase.Activating],
1200
+ });
1201
+ console.info(
1202
+ `Resuming interrupted receipt gateway activation for ${operationId} from ${routing}.`,
1203
+ );
1204
+ }
1205
+
1206
+ async function activate(operationId: string): Promise<void> {
1207
+ let green = candidatesFor(operationId);
1208
+ if (green.length === 0)
1209
+ throw new Error('Receipt rollout has no Fly candidates.');
1210
+ let candidateOperation = classifyReceiptGatewayCandidateOperation(green);
1211
+ let routing = candidateOperation.routing;
1212
+ // Once green is public, retries complete this same Fly topology. They never
1213
+ // invent a replacement fleet or silently roll back an ambiguous cutover.
1214
+ let sourceIds: string[];
1215
+ if (routing === ReceiptGatewayCandidateRouting.Cordoned) {
1216
+ const phase = candidateOperation.phase;
1217
+ if (
1218
+ ![
1219
+ ReceiptGatewayOperationPhase.Proven,
1220
+ ReceiptGatewayOperationPhase.Activating,
1221
+ ].includes(phase)
1222
+ ) {
1223
+ throw new Error(
1224
+ `Cordoned receipt candidates are not in an allowed durable operation phase: ${phase}.`,
1225
+ );
1226
+ }
1227
+ sourceIds = assertCandidateSourceGeneration({
1228
+ candidates: green,
1229
+ serving: serving(),
1230
+ });
1231
+ if (phase === ReceiptGatewayOperationPhase.Proven) {
1232
+ // Write the externally durable intent before the first admission
1233
+ // mutation. A new runner can now resume this exact candidate set even
1234
+ // when its temporary proof receipt has disappeared.
1235
+ await persistCandidatePhase({
1236
+ candidates: green,
1237
+ phase: ReceiptGatewayOperationPhase.Activating,
1238
+ });
1239
+ }
1240
+ green = await waitForCandidateFleet({
1241
+ operationId,
1242
+ count: sourceIds.length,
1243
+ routing: ReceiptGatewayCandidateRouting.Cordoned,
1244
+ allowedPhases: [ReceiptGatewayOperationPhase.Activating],
1245
+ });
1246
+ candidateOperation = classifyReceiptGatewayCandidateOperation(green);
1247
+ routing = candidateOperation.routing;
1248
+ } else {
1249
+ green = await waitForCandidateFleet({
1250
+ operationId,
1251
+ count: green.length,
1252
+ routing,
1253
+ allowedPhases: [ReceiptGatewayOperationPhase.Activating],
1254
+ });
1255
+ sourceIds = candidateSourceIds(green);
1256
+ }
1257
+
1258
+ // There is deliberately no release-SHA fence here. The workflow's
1259
+ // release-train concurrency group runs one production release at a time and
1260
+ // debounces queued pushes to the newest main commit, so a cutover that has
1261
+ // started finishes even when main moves; the newer commit releases next.
1262
+ // Aborting here used to strand every release once merges landed faster than
1263
+ // one release could complete.
1264
+
1265
+ if (routing !== ReceiptGatewayCandidateRouting.Public) {
1266
+ await activateCordonedReceiptCandidates({
1267
+ greenMachineIds: green.map((machine) => machine.id!),
1268
+ blueMachineIds: sourceIds,
1269
+ uncordonMachineIds: green
1270
+ .filter((machine) => machine.cordoned === true)
1271
+ .map((machine) => machine.id!),
1272
+ uncordon: (machineId) => {
1273
+ run('flyctl', ['machine', 'uncordon', machineId, '-a', fleet.app]);
1274
+ },
1275
+ cordon: (machineId) => {
1276
+ run('flyctl', ['machine', 'cordon', machineId, '-a', fleet.app]);
1277
+ },
1278
+ assertPublic,
1279
+ sleep: (milliseconds) =>
1280
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
1281
+ });
1282
+ } else {
1283
+ // An interrupted commit resumes its exact public green fleet. Do not run
1284
+ // a hidden fixture here: proof is an explicit, pre-commit operation.
1285
+ await waitForGatewayRouteProbe({
1286
+ description: 'Receipt gateway resumed serving generation',
1287
+ probe: () => assertPublic(green.map((machine) => machine.id!)),
1288
+ onRetry: reportGatewayRouteProbeRetry,
1289
+ });
1290
+ }
1291
+
1292
+ const blue = machines().filter(
1293
+ (machine) => active(machine) && sourceIds.includes(machine.id ?? ''),
1294
+ );
1295
+ for (const machine of blue) {
1296
+ if (machine.cordoned === false) {
1297
+ run('flyctl', ['machine', 'cordon', machine.id!, '-a', fleet.app]);
1298
+ }
1299
+ }
1300
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
1301
+ await waitForGatewayRouteProbe({
1302
+ description: 'Receipt gateway serving generation after blue cordon',
1303
+ probe: () => assertPublic(green.map((machine) => machine.id!)),
1304
+ onRetry: reportGatewayRouteProbeRetry,
1305
+ });
1306
+ // Blue remains cordoned as the rollback fleet. Its separately authorized
1307
+ // retirement must use exact source IDs and provider-confirmed deletion; an
1308
+ // activation retry must never destroy the only rollback path.
1309
+ }
1310
+
1311
+ async function rollback(operationId: string): Promise<void> {
1312
+ const green = candidatesFor(operationId);
1313
+ const greenMachineIds = green
1314
+ .map((machine) => machine.id)
1315
+ .filter((machineId): machineId is string => Boolean(machineId));
1316
+ if (greenMachineIds.length !== green.length || green.length === 0) {
1317
+ throw new Error(
1318
+ 'Receipt gateway rollback requires a complete candidate generation.',
1319
+ );
1320
+ }
1321
+ const sourceIds = candidateSourceIds(green);
1322
+ const inventory = machines();
1323
+ const blue = inventory.filter((machine) =>
1324
+ sourceIds.includes(machine.id ?? ''),
1325
+ );
1326
+ if (
1327
+ blue.length !== sourceIds.length ||
1328
+ blue.some((machine) => !machine.id || !active(machine) || !healthy(machine))
1329
+ ) {
1330
+ throw new Error(
1331
+ 'Receipt gateway rollback source generation is not a complete healthy retained fleet.',
1332
+ );
1333
+ }
1334
+ await restoreReceiptGatewaySourceGeneration({
1335
+ greenMachineIds,
1336
+ blueMachineIds: sourceIds,
1337
+ uncordon: (machineId) => {
1338
+ run('flyctl', ['machine', 'uncordon', machineId, '-a', fleet.app]);
1339
+ },
1340
+ cordon: (machineId) => {
1341
+ run('flyctl', ['machine', 'cordon', machineId, '-a', fleet.app]);
1342
+ },
1343
+ assertPublic,
1344
+ sleep: (milliseconds) =>
1345
+ new Promise((resolve) => setTimeout(resolve, milliseconds)),
1346
+ });
1347
+ }
1348
+
1349
+ export async function main(): Promise<void> {
1350
+ const command = process.argv[2];
1351
+ const input = flags(process.argv.slice(3));
1352
+ requireApply(input);
1353
+ const operationId = sha(input);
1354
+ if (command === 'prepare') return await prepare(operationId);
1355
+ if (command === 'prove') {
1356
+ const stagedUrl = input.get('staged-url');
1357
+ if (typeof stagedUrl !== 'string' || !stagedUrl.startsWith('https://')) {
1358
+ throw new Error('prove requires --staged-url https://…');
1359
+ }
1360
+ const fixtureApplicationDirectory = input.get('fixture-application-dir');
1361
+ if (
1362
+ fixtureApplicationDirectory !== undefined &&
1363
+ typeof fixtureApplicationDirectory !== 'string'
1364
+ ) {
1365
+ throw new Error('prove --fixture-application-dir requires a directory.');
1366
+ }
1367
+ return await prove(operationId, stagedUrl, fixtureApplicationDirectory);
1368
+ }
1369
+ if (command === 'activate') {
1370
+ return await activate(operationId);
1371
+ }
1372
+ if (command === 'rollback') return await rollback(operationId);
1373
+ if (command === 'drain') return await drain(operationId);
1374
+ if (command === 'cleanup') return await removeCordoned(operationId);
1375
+ throw new Error(
1376
+ 'Usage: roll-receipt-gateway.ts prepare|prove|activate|rollback|drain|cleanup --environment production --sha <sha> [--staged-url <url>] [--fixture-application-dir <dir>] --apply',
1377
+ );
1378
+ }