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.
- package/dist/bundling-sources/sdk/src/client.ts +101 -6
- package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
- package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
- package/dist/bundling-sources/sdk/src/release.ts +7 -12
- package/dist/bundling-sources/sdk/src/types.ts +6 -1
- package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
- package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +5 -1
- package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
- package/dist/bundling-sources/shared_libs/monitors/package.json +107 -5
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +12 -7
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +48 -0
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
- package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +46 -3
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
- package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
- package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
- package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
- package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
- package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
- package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +79 -6
- package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
- package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
- package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
- package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
- package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
- package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
- package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
- package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
- package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
- package/dist/cli/index.js +369 -162
- package/dist/cli/index.mjs +369 -162
- package/dist/{compiler-manifest-BaJfE-5H.d.mts → compiler-manifest-x14KL83E.d.mts} +1 -1
- package/dist/{compiler-manifest-BaJfE-5H.d.ts → compiler-manifest-x14KL83E.d.ts} +1 -1
- package/dist/index.d.mts +45 -8
- package/dist/index.d.ts +45 -8
- package/dist/index.js +61 -14
- package/dist/index.mjs +61 -14
- package/dist/install-integrity.json +54 -2
- package/dist/monitor-fleet-contract.d.mts +3 -10
- package/dist/monitor-fleet-contract.d.ts +3 -10
- package/dist/monitor-fleet-contract.js +2 -0
- package/dist/monitor-fleet-contract.mjs +1 -1
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/dist/release.d.mts +3 -3
- package/dist/release.d.ts +3 -3
- package/dist/release.js +7 -12
- package/dist/release.mjs +7 -12
- package/package.json +2 -2
package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts
ADDED
|
@@ -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
|
+
}
|