@forgezero/agent 0.1.159 → 0.1.161
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/agent-heartbeat.js +1 -1
- package/dist/bootstrap.js +76 -9
- package/dist/fz-agent.js +143 -36
- package/dist/fz.js +76 -9
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.js +76 -9
- package/dist/platform-bootstrap-runtime.d.ts +13 -2
- package/dist/platform-bootstrap-runtime.js +98 -35
- package/dist/platform-fleet-verification.js +143 -36
- package/dist/provision.js +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1460,7 +1460,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
1460
1460
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
1461
1461
|
|
|
1462
1462
|
// src/version.ts
|
|
1463
|
-
var VERSION = "0.1.
|
|
1463
|
+
var VERSION = "0.1.161";
|
|
1464
1464
|
|
|
1465
1465
|
// src/software.ts
|
|
1466
1466
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
@@ -3551,12 +3551,23 @@ function renderPlatformActivationFiles(input) {
|
|
|
3551
3551
|
throw new Error("Activation root must be absolute and single-line.");
|
|
3552
3552
|
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
|
|
3553
3553
|
throw new Error("Invalid activation service user.");
|
|
3554
|
+
boundedInteger("publicPort", input.publicPort, 1024, 65535);
|
|
3554
3555
|
boundedInteger("bluePort", input.bluePort, 1024, 65535);
|
|
3555
3556
|
boundedInteger("greenPort", input.greenPort, 1024, 65535);
|
|
3556
|
-
if (input.bluePort
|
|
3557
|
-
throw new Error("Activation slot ports must differ.");
|
|
3558
|
-
|
|
3559
|
-
|
|
3557
|
+
if (new Set([input.publicPort, input.bluePort, input.greenPort]).size !== 3) {
|
|
3558
|
+
throw new Error("Activation public and slot ports must differ.");
|
|
3559
|
+
}
|
|
3560
|
+
const runtimeHandoff = input.runtimeHandoff;
|
|
3561
|
+
if (!/^[A-Za-z0-9.-]{1,253}$/.test(runtimeHandoff.address) || runtimeHandoff.address.startsWith(".") || runtimeHandoff.address.endsWith(".")) {
|
|
3562
|
+
throw new Error("Runtime handoff address is malformed.");
|
|
3563
|
+
}
|
|
3564
|
+
for (const [name, path] of Object.entries({
|
|
3565
|
+
residentPath: runtimeHandoff.residentPath,
|
|
3566
|
+
readyPath: runtimeHandoff.readyPath
|
|
3567
|
+
})) {
|
|
3568
|
+
if (!/^\/[A-Za-z0-9/_.-]{1,192}$/.test(path) || path.includes("..")) {
|
|
3569
|
+
throw new Error(`Runtime handoff ${name} is malformed.`);
|
|
3570
|
+
}
|
|
3560
3571
|
}
|
|
3561
3572
|
boundedInteger("keepReleases", input.keepReleases, 2, 100);
|
|
3562
3573
|
const drainDeadlineMs = boundedInteger("drainDeadlineMs", input.drainDeadlineMs ?? 35000, 1000, 300000);
|
|
@@ -3566,16 +3577,17 @@ function renderPlatformActivationFiles(input) {
|
|
|
3566
3577
|
const environment = [
|
|
3567
3578
|
`FZ_DIR=${input.root}`,
|
|
3568
3579
|
`FZ_USER=${input.serviceUser}`,
|
|
3580
|
+
`FZ_PUBLIC_PORT=${input.publicPort}`,
|
|
3569
3581
|
`FZ_BLUE_PORT=${input.bluePort}`,
|
|
3570
3582
|
`FZ_GREEN_PORT=${input.greenPort}`,
|
|
3571
|
-
`
|
|
3583
|
+
`FZ_RUNTIME_HANDOFF_ADDRESS=${runtimeHandoff.address}`,
|
|
3572
3584
|
`FZ_HEALTH_PATH=${input.healthPath}`,
|
|
3573
3585
|
`FZ_KEEP_RELEASES=${input.keepReleases}`,
|
|
3574
3586
|
`FZ_DRAIN_DEADLINE_MS=${drainDeadlineMs}`
|
|
3575
3587
|
].join(`
|
|
3576
3588
|
`) + `
|
|
3577
3589
|
`;
|
|
3578
|
-
const helper = `${JSON.stringify({ ...input, drainDeadlineMs }, null, 2)}
|
|
3590
|
+
const helper = `${JSON.stringify({ ...input, runtimeHandoff, drainDeadlineMs }, null, 2)}
|
|
3579
3591
|
`;
|
|
3580
3592
|
return {
|
|
3581
3593
|
environment,
|
|
@@ -4456,7 +4468,7 @@ function interruptedPlatformReleaseIdentityDigests(config, releaseEvidence, boot
|
|
|
4456
4468
|
return [bootstrapIdentityDigest(previous), legacyBootstrapIdentityDigest(previous)];
|
|
4457
4469
|
}
|
|
4458
4470
|
function storedPlatformCoordinatesMatch(state, config) {
|
|
4459
|
-
return state.kind === "platform" && state.profile === config.profile && state.nodeHostname === config.nodeHostname && state.apiUrl === config.apiUrl && state.environment === config.environment && state.databaseRole === config.database.role && state.databaseAgency === config.database.agency && state.databaseReadPreferred === config.database.readPreferred && state.databaseServerMode === config.database.serverMode && (state.databaseAddress ?? null) === (config.database.address ?? null) && JSON.stringify(state.databaseCoordinators ?? []) === JSON.stringify(config.database.coordinators) && state.collectorUnit === config.runtime.environment.otlpCollectorUnit && state.publicApiPort === config.runtime.environment.publicApiPort && state.healthPath === config.runtime.healthPath;
|
|
4471
|
+
return state.kind === "platform" && state.profile === config.profile && state.nodeHostname === config.nodeHostname && state.apiUrl === config.apiUrl && state.environment === config.environment && state.databaseRole === config.database.role && state.databaseAgency === config.database.agency && state.databaseReadPreferred === config.database.readPreferred && state.databaseServerMode === config.database.serverMode && (state.databaseAddress ?? null) === (config.database.address ?? null) && JSON.stringify(state.databaseCoordinators ?? []) === JSON.stringify(config.database.coordinators) && state.collectorUnit === config.runtime.environment.otlpCollectorUnit && state.publicApiPort === config.runtime.environment.publicApiPort && (state.bluePort === undefined || state.bluePort === config.runtime.bluePort) && (state.greenPort === undefined || state.greenPort === config.runtime.greenPort) && (state.slotsDirectory === undefined || state.slotsDirectory === config.runtime.slotsDirectory) && (state.seedMeshAddress === undefined || state.seedMeshAddress === (config.runtime.environment.databaseAddress ?? config.database.address)) && state.healthPath === config.runtime.healthPath;
|
|
4460
4472
|
}
|
|
4461
4473
|
function parseStoredState(raw) {
|
|
4462
4474
|
let value;
|
|
@@ -4535,6 +4547,12 @@ function stateFor(config, cloudflare, previousCloudflareTunnelId) {
|
|
|
4535
4547
|
databaseModeEvidence: config.database.role === "none" ? undefined : DB_MODE_EVIDENCE,
|
|
4536
4548
|
collectorUnit: config.runtime.environment.otlpCollectorUnit,
|
|
4537
4549
|
publicApiPort: config.runtime.environment.publicApiPort,
|
|
4550
|
+
bluePort: config.runtime.bluePort,
|
|
4551
|
+
greenPort: config.runtime.greenPort,
|
|
4552
|
+
slotsDirectory: config.runtime.slotsDirectory,
|
|
4553
|
+
seedMeshAddress: config.runtime.environment.databaseAddress ?? config.database.address,
|
|
4554
|
+
runtimeHandoffResidentPath: "/_internal/forgezero/seed-mesh/resident",
|
|
4555
|
+
runtimeHandoffReadyPath: "/_internal/forgezero/seed-mesh/ready",
|
|
4538
4556
|
healthPath: config.runtime.healthPath,
|
|
4539
4557
|
cloudflare: config.runtime.environment.cloudflare,
|
|
4540
4558
|
realtime: config.runtime.environment.realtime,
|
|
@@ -4616,6 +4634,50 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
4616
4634
|
services["forgezero@active.service"] = blue.exitCode === 0 || green.exitCode === 0;
|
|
4617
4635
|
if (!services["forgezero@active.service"])
|
|
4618
4636
|
problems.push("neither API slot is active");
|
|
4637
|
+
const activeSlots = [
|
|
4638
|
+
...blue.exitCode === 0 ? ["blue"] : [],
|
|
4639
|
+
...green.exitCode === 0 ? ["green"] : []
|
|
4640
|
+
];
|
|
4641
|
+
const activeSlotFile = state.slotsDirectory ? `${state.slotsDirectory}/.active` : "";
|
|
4642
|
+
const recordedSlot = activeSlotFile && host.exists(activeSlotFile) ? host.read(activeSlotFile).trim() : "";
|
|
4643
|
+
const coordinatesValid = Number.isSafeInteger(state.bluePort) && Number.isSafeInteger(state.greenPort) && Boolean(state.slotsDirectory) && Boolean(state.seedMeshAddress) && Boolean(state.healthPath) && Boolean(state.runtimeHandoffResidentPath) && Boolean(state.runtimeHandoffReadyPath);
|
|
4644
|
+
services["platform-active-slot-consistent"] = coordinatesValid && activeSlots.length === 1 && (recordedSlot === "blue" || recordedSlot === "green") && activeSlots[0] === recordedSlot;
|
|
4645
|
+
if (!services["platform-active-slot-consistent"]) {
|
|
4646
|
+
problems.push("active slot marker, service state, or runtime coordinates disagree");
|
|
4647
|
+
}
|
|
4648
|
+
if (services["platform-active-slot-consistent"]) {
|
|
4649
|
+
const slotPort = recordedSlot === "blue" ? state.bluePort : state.greenPort;
|
|
4650
|
+
const directHealth = await host.exec([
|
|
4651
|
+
"curl",
|
|
4652
|
+
"--fail",
|
|
4653
|
+
"--silent",
|
|
4654
|
+
"--show-error",
|
|
4655
|
+
"--max-time",
|
|
4656
|
+
"5",
|
|
4657
|
+
`http://127.0.0.1:${slotPort}${state.healthPath}`
|
|
4658
|
+
]);
|
|
4659
|
+
services["platform-active-slot-health"] = directHealth.exitCode === 0;
|
|
4660
|
+
if (directHealth.exitCode !== 0)
|
|
4661
|
+
problems.push("active API slot health check failed");
|
|
4662
|
+
const expectedUpstream = `upstream forgezero { server 127.0.0.1:${slotPort}; }
|
|
4663
|
+
`;
|
|
4664
|
+
const upstream = "/etc/nginx/conf.d/forgezero-upstream.conf";
|
|
4665
|
+
services["platform-nginx-active-slot"] = host.exists(upstream) && host.read(upstream) === expectedUpstream;
|
|
4666
|
+
if (!services["platform-nginx-active-slot"])
|
|
4667
|
+
problems.push("nginx upstream does not match the active API slot");
|
|
4668
|
+
const resident = await host.exec([
|
|
4669
|
+
"curl",
|
|
4670
|
+
"--fail",
|
|
4671
|
+
"--silent",
|
|
4672
|
+
"--show-error",
|
|
4673
|
+
"--max-time",
|
|
4674
|
+
"5",
|
|
4675
|
+
`http://${state.seedMeshAddress}:${slotPort}${state.runtimeHandoffResidentPath}`
|
|
4676
|
+
]);
|
|
4677
|
+
services["platform-vault-resident"] = resident.exitCode === 0;
|
|
4678
|
+
if (resident.exitCode !== 0)
|
|
4679
|
+
problems.push("active API slot has no resident Vault seed");
|
|
4680
|
+
}
|
|
4619
4681
|
if (!Number.isSafeInteger(state.publicApiPort) || !state.healthPath) {
|
|
4620
4682
|
problems.push("platform health coordinates are missing from bootstrap state");
|
|
4621
4683
|
} else {
|
|
@@ -4947,9 +5009,14 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
|
|
|
4947
5009
|
const activation = renderPlatformActivationFiles({
|
|
4948
5010
|
root: config.deployRoot ?? "/opt/forgezero",
|
|
4949
5011
|
serviceUser: runtime.serviceUser,
|
|
5012
|
+
publicPort: runtime.environment.publicApiPort,
|
|
4950
5013
|
bluePort: runtime.bluePort,
|
|
4951
5014
|
greenPort: runtime.greenPort,
|
|
4952
|
-
|
|
5015
|
+
runtimeHandoff: {
|
|
5016
|
+
address: seedMeshAddress,
|
|
5017
|
+
residentPath: "/_internal/forgezero/seed-mesh/resident",
|
|
5018
|
+
readyPath: "/_internal/forgezero/seed-mesh/ready"
|
|
5019
|
+
},
|
|
4953
5020
|
healthPath: runtime.healthPath,
|
|
4954
5021
|
keepReleases: runtime.keepReleases,
|
|
4955
5022
|
drainDeadlineMs: runtime.environment.drainDeadlineMs
|
|
@@ -174,10 +174,19 @@ export declare function planPlatformActivation(releasesDirectory: string, releas
|
|
|
174
174
|
export interface PlatformActivationConfig {
|
|
175
175
|
root: string;
|
|
176
176
|
serviceUser: string;
|
|
177
|
+
/** Stable loopback listener served by nginx/cloudflared. */
|
|
178
|
+
publicPort: number;
|
|
177
179
|
bluePort: number;
|
|
178
180
|
greenPort: number;
|
|
179
|
-
/**
|
|
180
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Runtime-owned handoff acknowledgement endpoints. The Agent never reads or
|
|
183
|
+
* transfers runtime state; it only gates traffic switching on these probes.
|
|
184
|
+
*/
|
|
185
|
+
runtimeHandoff: {
|
|
186
|
+
address: string;
|
|
187
|
+
residentPath: string;
|
|
188
|
+
readyPath: string;
|
|
189
|
+
};
|
|
181
190
|
healthPath: string;
|
|
182
191
|
keepReleases: number;
|
|
183
192
|
drainDeadlineMs?: number;
|
|
@@ -202,6 +211,8 @@ export declare function activatePlatformRelease(config: PlatformActivationConfig
|
|
|
202
211
|
exec?: PlatformActivationExec;
|
|
203
212
|
fetch?: typeof fetch;
|
|
204
213
|
sleep?: (ms: number) => Promise<void>;
|
|
214
|
+
/** Test seam; production always uses the root-owned nginx include. */
|
|
215
|
+
upstreamPath?: string;
|
|
205
216
|
}): Promise<{
|
|
206
217
|
release: string;
|
|
207
218
|
slot: ApiSlot;
|
|
@@ -1164,12 +1164,23 @@ function renderPlatformActivationFiles(input) {
|
|
|
1164
1164
|
throw new Error("Activation root must be absolute and single-line.");
|
|
1165
1165
|
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
|
|
1166
1166
|
throw new Error("Invalid activation service user.");
|
|
1167
|
+
boundedInteger("publicPort", input.publicPort, 1024, 65535);
|
|
1167
1168
|
boundedInteger("bluePort", input.bluePort, 1024, 65535);
|
|
1168
1169
|
boundedInteger("greenPort", input.greenPort, 1024, 65535);
|
|
1169
|
-
if (input.bluePort
|
|
1170
|
-
throw new Error("Activation slot ports must differ.");
|
|
1171
|
-
|
|
1172
|
-
|
|
1170
|
+
if (new Set([input.publicPort, input.bluePort, input.greenPort]).size !== 3) {
|
|
1171
|
+
throw new Error("Activation public and slot ports must differ.");
|
|
1172
|
+
}
|
|
1173
|
+
const runtimeHandoff = input.runtimeHandoff;
|
|
1174
|
+
if (!/^[A-Za-z0-9.-]{1,253}$/.test(runtimeHandoff.address) || runtimeHandoff.address.startsWith(".") || runtimeHandoff.address.endsWith(".")) {
|
|
1175
|
+
throw new Error("Runtime handoff address is malformed.");
|
|
1176
|
+
}
|
|
1177
|
+
for (const [name, path2] of Object.entries({
|
|
1178
|
+
residentPath: runtimeHandoff.residentPath,
|
|
1179
|
+
readyPath: runtimeHandoff.readyPath
|
|
1180
|
+
})) {
|
|
1181
|
+
if (!/^\/[A-Za-z0-9/_.-]{1,192}$/.test(path2) || path2.includes("..")) {
|
|
1182
|
+
throw new Error(`Runtime handoff ${name} is malformed.`);
|
|
1183
|
+
}
|
|
1173
1184
|
}
|
|
1174
1185
|
boundedInteger("keepReleases", input.keepReleases, 2, 100);
|
|
1175
1186
|
const drainDeadlineMs = boundedInteger("drainDeadlineMs", input.drainDeadlineMs ?? 35000, 1000, 300000);
|
|
@@ -1179,16 +1190,17 @@ function renderPlatformActivationFiles(input) {
|
|
|
1179
1190
|
const environment = [
|
|
1180
1191
|
`FZ_DIR=${input.root}`,
|
|
1181
1192
|
`FZ_USER=${input.serviceUser}`,
|
|
1193
|
+
`FZ_PUBLIC_PORT=${input.publicPort}`,
|
|
1182
1194
|
`FZ_BLUE_PORT=${input.bluePort}`,
|
|
1183
1195
|
`FZ_GREEN_PORT=${input.greenPort}`,
|
|
1184
|
-
`
|
|
1196
|
+
`FZ_RUNTIME_HANDOFF_ADDRESS=${runtimeHandoff.address}`,
|
|
1185
1197
|
`FZ_HEALTH_PATH=${input.healthPath}`,
|
|
1186
1198
|
`FZ_KEEP_RELEASES=${input.keepReleases}`,
|
|
1187
1199
|
`FZ_DRAIN_DEADLINE_MS=${drainDeadlineMs}`
|
|
1188
1200
|
].join(`
|
|
1189
1201
|
`) + `
|
|
1190
1202
|
`;
|
|
1191
|
-
const helper = `${JSON.stringify({ ...input, drainDeadlineMs }, null, 2)}
|
|
1203
|
+
const helper = `${JSON.stringify({ ...input, runtimeHandoff, drainDeadlineMs }, null, 2)}
|
|
1192
1204
|
`;
|
|
1193
1205
|
return {
|
|
1194
1206
|
environment,
|
|
@@ -1266,27 +1278,66 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
|
|
|
1266
1278
|
mkdirSync2(slots, { recursive: true, mode: 493 });
|
|
1267
1279
|
const slotFile = join2(slots, ".active");
|
|
1268
1280
|
let previousSlot = existsSync2(slotFile) ? readFileSync2(slotFile, "utf8").trim() : undefined;
|
|
1269
|
-
if (previousSlot !== "blue" && previousSlot !== "green")
|
|
1281
|
+
if (previousSlot !== "blue" && previousSlot !== "green")
|
|
1270
1282
|
previousSlot = undefined;
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
}
|
|
1283
|
+
const slotPort = (slot) => slot === "blue" ? normalized.bluePort : normalized.greenPort;
|
|
1284
|
+
const healthy = async (port2) => {
|
|
1285
|
+
try {
|
|
1286
|
+
const response = await request(`http://127.0.0.1:${port2}${normalized.healthPath}`, {
|
|
1287
|
+
signal: AbortSignal.timeout(2000)
|
|
1288
|
+
});
|
|
1289
|
+
return response.ok;
|
|
1290
|
+
} catch {
|
|
1291
|
+
return false;
|
|
1277
1292
|
}
|
|
1293
|
+
};
|
|
1294
|
+
const runningHealthySlots = [];
|
|
1295
|
+
for (const candidate of ["blue", "green"]) {
|
|
1296
|
+
const active = await exec(["/usr/bin/systemctl", "is-active", "--quiet", `forgezero@${candidate}.service`]);
|
|
1297
|
+
if (active.exitCode === 0 && await healthy(slotPort(candidate)))
|
|
1298
|
+
runningHealthySlots.push(candidate);
|
|
1299
|
+
}
|
|
1300
|
+
if (previousSlot && !runningHealthySlots.includes(previousSlot)) {
|
|
1301
|
+
throw new Error("active slot evidence disagrees with the running healthy API slot");
|
|
1302
|
+
}
|
|
1303
|
+
if (!previousSlot) {
|
|
1304
|
+
if (runningHealthySlots.length !== 1) {
|
|
1305
|
+
throw new Error("exactly one running healthy API slot is required before promotion");
|
|
1306
|
+
}
|
|
1307
|
+
previousSlot = runningHealthySlots[0];
|
|
1278
1308
|
}
|
|
1279
1309
|
const target = previousSlot === "blue" ? "green" : "blue";
|
|
1280
|
-
const
|
|
1281
|
-
const
|
|
1310
|
+
const previousPort = slotPort(previousSlot);
|
|
1311
|
+
const port = slotPort(target);
|
|
1312
|
+
const handoffProbe = async (slotPort2, path2) => {
|
|
1282
1313
|
try {
|
|
1283
|
-
const response = await request(`http://${normalized.
|
|
1314
|
+
const response = await request(`http://${normalized.runtimeHandoff.address}:${slotPort2}${path2}`, { signal: AbortSignal.timeout(2000) });
|
|
1284
1315
|
return response.ok;
|
|
1285
1316
|
} catch {
|
|
1286
1317
|
return false;
|
|
1287
1318
|
}
|
|
1288
1319
|
};
|
|
1289
|
-
const
|
|
1320
|
+
const upstream = options.upstreamPath ?? "/etc/nginx/conf.d/forgezero-upstream.conf";
|
|
1321
|
+
const renderUpstream = (upstreamPort) => `upstream forgezero { server 127.0.0.1:${upstreamPort}; }
|
|
1322
|
+
`;
|
|
1323
|
+
const reloadNginx = async (upstreamPort) => {
|
|
1324
|
+
const pending = `${upstream}.next`;
|
|
1325
|
+
writeFileSync2(pending, renderUpstream(upstreamPort), { mode: 420, flush: true });
|
|
1326
|
+
renameSync2(pending, upstream);
|
|
1327
|
+
const test = await exec(["/usr/sbin/nginx", "-t"]);
|
|
1328
|
+
const reload = test.exitCode === 0 ? await exec(["/usr/sbin/nginx", "-s", "reload"]) : test;
|
|
1329
|
+
if (reload.exitCode !== 0) {
|
|
1330
|
+
const refusal = (reload.output || test.output).trim().slice(0, 2000);
|
|
1331
|
+
throw new Error(`nginx refused the active-slot upstream${refusal ? `: ${refusal}` : ""}`);
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
if (!existsSync2(upstream) || readFileSync2(upstream, "utf8") !== renderUpstream(previousPort)) {
|
|
1335
|
+
await reloadNginx(previousPort);
|
|
1336
|
+
}
|
|
1337
|
+
if (!await healthy(normalized.publicPort)) {
|
|
1338
|
+
throw new Error("stable public listener does not reach the active API slot");
|
|
1339
|
+
}
|
|
1340
|
+
const previousHeldSeed = previousSlot ? await handoffProbe(previousSlot === "blue" ? normalized.bluePort : normalized.greenPort, normalized.runtimeHandoff.residentPath) : false;
|
|
1290
1341
|
if (previousSlot && !previousHeldSeed) {
|
|
1291
1342
|
throw new Error("active slot has no resident Vault seed; unlock custody before promotion");
|
|
1292
1343
|
}
|
|
@@ -1307,13 +1358,10 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
|
|
|
1307
1358
|
replaceLink(targetLink, previousTarget);
|
|
1308
1359
|
throw new Error("target slot failed to start");
|
|
1309
1360
|
}
|
|
1310
|
-
let
|
|
1361
|
+
let targetHealthy = false;
|
|
1311
1362
|
for (let attempt = 0;attempt < 30; attempt += 1) {
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
healthy = response.ok;
|
|
1315
|
-
} catch {}
|
|
1316
|
-
if (healthy)
|
|
1363
|
+
targetHealthy = await healthy(port);
|
|
1364
|
+
if (targetHealthy)
|
|
1317
1365
|
break;
|
|
1318
1366
|
await sleep(1000);
|
|
1319
1367
|
}
|
|
@@ -1323,14 +1371,18 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
|
|
|
1323
1371
|
if (previousSlot)
|
|
1324
1372
|
await exec(["/usr/bin/systemctl", "start", `forgezero@${previousSlot}.service`]);
|
|
1325
1373
|
};
|
|
1326
|
-
if (!
|
|
1374
|
+
if (!targetHealthy) {
|
|
1327
1375
|
await stopTarget();
|
|
1328
1376
|
throw new Error("target slot failed its health deadline");
|
|
1329
1377
|
}
|
|
1330
1378
|
if (previousHeldSeed) {
|
|
1331
1379
|
let seedReady = false;
|
|
1332
1380
|
for (let attempt = 0;attempt < 30; attempt += 1) {
|
|
1333
|
-
|
|
1381
|
+
const [resident, converged] = await Promise.all([
|
|
1382
|
+
handoffProbe(port, normalized.runtimeHandoff.residentPath),
|
|
1383
|
+
handoffProbe(port, normalized.runtimeHandoff.readyPath)
|
|
1384
|
+
]);
|
|
1385
|
+
seedReady = resident && converged;
|
|
1334
1386
|
if (seedReady)
|
|
1335
1387
|
break;
|
|
1336
1388
|
await sleep(1000);
|
|
@@ -1340,18 +1392,24 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
|
|
|
1340
1392
|
throw new Error("target slot failed RAM-seed handover deadline");
|
|
1341
1393
|
}
|
|
1342
1394
|
}
|
|
1343
|
-
const upstream = "/etc/nginx/conf.d/forgezero-upstream.conf";
|
|
1344
1395
|
const backup = `${upstream}.forgezero-backup`;
|
|
1345
1396
|
if (existsSync2(upstream))
|
|
1346
1397
|
copyFileSync2(upstream, backup);
|
|
1347
1398
|
else
|
|
1348
1399
|
rmSync2(backup, { force: true });
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1400
|
+
try {
|
|
1401
|
+
await reloadNginx(port);
|
|
1402
|
+
} catch (cause) {
|
|
1403
|
+
if (existsSync2(backup))
|
|
1404
|
+
renameSync2(backup, upstream);
|
|
1405
|
+
else
|
|
1406
|
+
rmSync2(upstream, { force: true });
|
|
1407
|
+
await exec(["/usr/sbin/nginx", "-t"]);
|
|
1408
|
+
await exec(["/usr/sbin/nginx", "-s", "reload"]);
|
|
1409
|
+
await stopTarget();
|
|
1410
|
+
throw cause;
|
|
1411
|
+
}
|
|
1412
|
+
if (!await healthy(normalized.publicPort)) {
|
|
1355
1413
|
if (existsSync2(backup))
|
|
1356
1414
|
renameSync2(backup, upstream);
|
|
1357
1415
|
else
|
|
@@ -1359,14 +1417,19 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
|
|
|
1359
1417
|
await exec(["/usr/sbin/nginx", "-t"]);
|
|
1360
1418
|
await exec(["/usr/sbin/nginx", "-s", "reload"]);
|
|
1361
1419
|
await stopTarget();
|
|
1362
|
-
throw new Error(
|
|
1420
|
+
throw new Error("stable public listener failed after the slot switch");
|
|
1363
1421
|
}
|
|
1364
1422
|
rmSync2(backup, { force: true });
|
|
1365
1423
|
writeFileSync2(slotFile, `${target}
|
|
1366
1424
|
`, { mode: 420 });
|
|
1367
1425
|
if (previousSlot && previousSlot !== target) {
|
|
1368
|
-
|
|
1369
|
-
await exec(["/usr/bin/systemctl", "stop",
|
|
1426
|
+
const previousService = `forgezero@${previousSlot}.service`;
|
|
1427
|
+
const stopped = await exec(["/usr/bin/systemctl", "stop", previousService]);
|
|
1428
|
+
const inactive = await exec(["/usr/bin/systemctl", "is-active", "--quiet", previousService]);
|
|
1429
|
+
const status = await exec(["/usr/bin/systemctl", "show", "--property=ExecMainStatus", "--value", previousService]);
|
|
1430
|
+
if (stopped.exitCode !== 0 || inactive.exitCode === 0 || status.exitCode !== 0 || status.output.trim() !== "0") {
|
|
1431
|
+
throw new Error("old API slot failed its clean request and job drain; target remains active");
|
|
1432
|
+
}
|
|
1370
1433
|
}
|
|
1371
1434
|
const old = readdirSync(releases, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join2(releases, entry.name)).sort((left, right) => statSync2(right).mtimeMs - statSync2(left).mtimeMs).slice(normalized.keepReleases);
|
|
1372
1435
|
for (const path2 of old)
|