@task-handoff/node-agent 0.0.21 → 0.0.22-alpha.1
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/bin/task-handoff-node-update-worker +1 -230
- package/dist/cli.js +1 -1
- package/dist/node-update-worker.js +2 -0
- package/package.json +1 -1
- package/runtime-artifacts/{controlled-instance-runtime-0.0.21-linux-universal.manifest.json → controlled-instance-runtime-0.0.22-alpha.1-linux-universal.manifest.json} +2 -2
- package/runtime-artifacts/controlled-instance-runtime-0.0.22-alpha.1-linux-universal.tar.gz +0 -0
- package/runtime-artifacts/controlled-instance-runtime-0.0.22-alpha.1-linux-universal.tar.gz.sha256 +1 -0
- package/runtime-artifacts/controlled-instance-runtime-0.0.21-linux-universal.tar.gz +0 -0
- package/runtime-artifacts/controlled-instance-runtime-0.0.21-linux-universal.tar.gz.sha256 +0 -1
|
@@ -1,231 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const fs = require("node:fs");
|
|
4
|
-
const path = require("node:path");
|
|
5
|
-
const { spawnSync } = require("node:child_process");
|
|
6
|
-
const { Command, InvalidArgumentError } = require("commander");
|
|
7
|
-
const semver = require("semver");
|
|
8
|
-
const writeFileAtomic = require("write-file-atomic");
|
|
9
|
-
|
|
10
|
-
function parseExactVersion(value) {
|
|
11
|
-
if (value.trim() !== value || /^[v=]/.test(value) || semver.valid(value) === null) throw new InvalidArgumentError("must be an exact semantic version");
|
|
12
|
-
return value;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function parseControlPlaneHealthUrl(value) {
|
|
16
|
-
let parsed;
|
|
17
|
-
try {
|
|
18
|
-
parsed = new URL(value);
|
|
19
|
-
} catch {
|
|
20
|
-
throw new InvalidArgumentError("must be a valid loopback HTTP URL");
|
|
21
|
-
}
|
|
22
|
-
if (!["http:", "https:"].includes(parsed.protocol) || !["127.0.0.1", "localhost", "::1"].includes(parsed.hostname)) {
|
|
23
|
-
throw new InvalidArgumentError("must be a valid loopback HTTP URL");
|
|
24
|
-
}
|
|
25
|
-
return parsed.toString();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const options = new Command()
|
|
29
|
-
.name("task-handoff-node-update-worker")
|
|
30
|
-
.description("Apply a detached TaskHandoff node update.")
|
|
31
|
-
.requiredOption("--job-file <path>", "persisted update job file")
|
|
32
|
-
.requiredOption("--target-version <version>", "exact semantic version", parseExactVersion)
|
|
33
|
-
.option("--service <name>", "systemd service to restart", "task-handoff-node-agent.service")
|
|
34
|
-
.option("--npm-command <path>", "npm executable", process.env.TASK_HANDOFF_NPM_COMMAND || "npm")
|
|
35
|
-
.option("--control-plane-health-url <url>", "local control-plane health endpoint", parseControlPlaneHealthUrl)
|
|
36
|
-
.parse(process.argv)
|
|
37
|
-
.opts();
|
|
38
|
-
|
|
39
|
-
const jobFile = options.jobFile;
|
|
40
|
-
const targetVersion = options.targetVersion;
|
|
41
|
-
const service = options.service;
|
|
42
|
-
const npmCommand = options.npmCommand;
|
|
43
|
-
const controlPlaneHealthUrl = options.controlPlaneHealthUrl;
|
|
44
|
-
const terminalStatuses = new Set(["succeeded", "degraded", "failed"]);
|
|
45
|
-
const supportedPackages = new Set(["@task-handoff/node-agent", "@task-handoff/server"]);
|
|
46
|
-
|
|
47
|
-
function updateJob(expectedStatuses, createPatch) {
|
|
48
|
-
const observed = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
49
|
-
// Test-only synchronization point used to prove that the value is checked
|
|
50
|
-
// again after another process changes it between observation and commit.
|
|
51
|
-
if (process.env.TASK_HANDOFF_UPDATE_WORKER_TEST_CAS_HOOK) {
|
|
52
|
-
require(path.resolve(process.env.TASK_HANDOFF_UPDATE_WORKER_TEST_CAS_HOOK))({ jobFile, observed });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const lockPath = `${jobFile}.worker-lock`;
|
|
56
|
-
fs.mkdirSync(lockPath);
|
|
57
|
-
try {
|
|
58
|
-
const current = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
59
|
-
if (terminalStatuses.has(current.status) || !expectedStatuses.includes(current.status)) {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
const patch = typeof createPatch === "function" ? createPatch(current) : createPatch;
|
|
63
|
-
const next = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
|
64
|
-
writeFileAtomic.sync(jobFile, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8" });
|
|
65
|
-
return true;
|
|
66
|
-
} finally {
|
|
67
|
-
fs.rmdirSync(lockPath);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function run(command, args) {
|
|
72
|
-
const result = spawnSync(command, args, { stdio: "inherit" });
|
|
73
|
-
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function installedVersion(packageName, globalRoot, nestedUnder) {
|
|
77
|
-
const manifest = nestedUnder
|
|
78
|
-
? path.join(globalRoot, ...nestedUnder.split("/"), "node_modules", ...packageName.split("/"), "package.json")
|
|
79
|
-
: path.join(globalRoot, ...packageName.split("/"), "package.json");
|
|
80
|
-
try {
|
|
81
|
-
return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
|
|
82
|
-
} catch (error) {
|
|
83
|
-
if (error?.code === "ENOENT") return undefined;
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function verifyInstalledVersion(packageName, globalRoot) {
|
|
89
|
-
const actualVersion = installedVersion(packageName, globalRoot);
|
|
90
|
-
if (actualVersion !== targetVersion) {
|
|
91
|
-
throw new Error(`Updated ${packageName} verification failed: expected ${targetVersion}, found ${actualVersion || "unknown"}.`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function verifyServerDistributionVersions(globalRoot) {
|
|
96
|
-
for (const packageName of ["@task-handoff/control-plane", "@task-handoff/node-agent", "@task-handoff/controlled-instance"]) {
|
|
97
|
-
const actualVersion = installedVersion(packageName, globalRoot, "@task-handoff/server")
|
|
98
|
-
|| installedVersion(packageName, globalRoot);
|
|
99
|
-
if (actualVersion !== targetVersion) {
|
|
100
|
-
throw new Error(`Updated @task-handoff/server does not provide ${packageName} ${targetVersion}; found ${actualVersion || "unknown"}.`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function npmArtifactIntegrity(packageName) {
|
|
106
|
-
const result = spawnSync(npmCommand, ["view", `${packageName}@${targetVersion}`, "dist.integrity", "--json"], { encoding: "utf8" });
|
|
107
|
-
if (result.status !== 0) throw new Error(`Could not verify the ${packageName} npm artifact integrity.`);
|
|
108
|
-
let integrity;
|
|
109
|
-
try {
|
|
110
|
-
integrity = JSON.parse(result.stdout);
|
|
111
|
-
} catch {
|
|
112
|
-
throw new Error(`npm returned invalid ${packageName} artifact integrity metadata.`);
|
|
113
|
-
}
|
|
114
|
-
if (typeof integrity !== "string" || !/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity)) {
|
|
115
|
-
throw new Error(`npm returned invalid ${packageName} artifact integrity metadata.`);
|
|
116
|
-
}
|
|
117
|
-
return integrity;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function verifyNpmArtifactIntegrity() {
|
|
121
|
-
const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
122
|
-
const suffix = `@${targetVersion}#`;
|
|
123
|
-
if (typeof job.artifactRef !== "string" || !job.artifactRef.startsWith("npm:") || !job.artifactRef.includes(suffix)) {
|
|
124
|
-
throw new Error(`Update job does not pin npm integrity for version ${targetVersion}.`);
|
|
125
|
-
}
|
|
126
|
-
const separator = job.artifactRef.indexOf(suffix);
|
|
127
|
-
const packageName = job.artifactRef.slice("npm:".length, separator);
|
|
128
|
-
const expectedIntegrity = job.artifactRef.slice(separator + suffix.length);
|
|
129
|
-
if (!supportedPackages.has(packageName) || !expectedIntegrity) {
|
|
130
|
-
throw new Error(`Update job does not identify a supported immutable npm artifact.`);
|
|
131
|
-
}
|
|
132
|
-
const actualIntegrity = npmArtifactIntegrity(packageName);
|
|
133
|
-
if (actualIntegrity !== expectedIntegrity) {
|
|
134
|
-
throw new Error(`${packageName} npm artifact integrity mismatch: expected ${expectedIntegrity}, found ${String(actualIntegrity || "unknown")}.`);
|
|
135
|
-
}
|
|
136
|
-
return packageName;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function waitForControlPlaneHealth(healthUrl, timeoutMs = 60_000) {
|
|
140
|
-
const deadline = Date.now() + timeoutMs;
|
|
141
|
-
let lastFailure = "not reachable";
|
|
142
|
-
while (Date.now() < deadline) {
|
|
143
|
-
try {
|
|
144
|
-
if (process.env.TASK_HANDOFF_UPDATE_WORKER_TEST_HEALTH_FILE) {
|
|
145
|
-
const payload = JSON.parse(fs.readFileSync(process.env.TASK_HANDOFF_UPDATE_WORKER_TEST_HEALTH_FILE, "utf8"));
|
|
146
|
-
const version = payload?.data?.version;
|
|
147
|
-
if (version === targetVersion) return;
|
|
148
|
-
lastFailure = `reported version ${String(version || "unknown")}`;
|
|
149
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
const response = await fetch(healthUrl, {
|
|
153
|
-
headers: { "cache-control": "no-cache" },
|
|
154
|
-
signal: AbortSignal.timeout(2_000),
|
|
155
|
-
});
|
|
156
|
-
const payload = await response.json().catch(() => ({}));
|
|
157
|
-
const version = payload?.data?.version;
|
|
158
|
-
if (response.ok && version === targetVersion) return;
|
|
159
|
-
lastFailure = response.ok ? `reported version ${String(version || "unknown")}` : `returned HTTP ${response.status}`;
|
|
160
|
-
} catch (error) {
|
|
161
|
-
lastFailure = error instanceof Error ? error.message : String(error);
|
|
162
|
-
}
|
|
163
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
164
|
-
}
|
|
165
|
-
throw new Error(`Control plane did not become healthy at ${healthUrl} with version ${targetVersion}: ${lastFailure}.`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function main() {
|
|
169
|
-
try {
|
|
170
|
-
const claimed = updateJob(["queued"], (current) => ({
|
|
171
|
-
status: "updating-node",
|
|
172
|
-
rollout: { ...current.rollout, phase: "updating-node" },
|
|
173
|
-
startedAt: new Date().toISOString(),
|
|
174
|
-
error: undefined,
|
|
175
|
-
}));
|
|
176
|
-
if (!claimed) process.exit(0);
|
|
177
|
-
const packageName = verifyNpmArtifactIntegrity();
|
|
178
|
-
const prefixResult = spawnSync(npmCommand, ["prefix", "--global"], { encoding: "utf8" });
|
|
179
|
-
if (prefixResult.status !== 0) throw new Error("Could not determine the npm global prefix.");
|
|
180
|
-
const prefix = prefixResult.stdout.trim();
|
|
181
|
-
const rootResult = spawnSync(npmCommand, ["root", "--global"], { encoding: "utf8" });
|
|
182
|
-
if (rootResult.status !== 0) throw new Error("Could not determine the npm global module root.");
|
|
183
|
-
const globalRoot = rootResult.stdout.trim();
|
|
184
|
-
const standaloneNodeAgentWasInstalled = packageName === "@task-handoff/server"
|
|
185
|
-
&& installedVersion("@task-handoff/node-agent", globalRoot) !== undefined;
|
|
186
|
-
run(npmCommand, [
|
|
187
|
-
"install",
|
|
188
|
-
"--global",
|
|
189
|
-
"--prefix",
|
|
190
|
-
prefix,
|
|
191
|
-
`${packageName}@${targetVersion}`,
|
|
192
|
-
]);
|
|
193
|
-
verifyInstalledVersion(packageName, globalRoot);
|
|
194
|
-
if (packageName === "@task-handoff/server") {
|
|
195
|
-
if (standaloneNodeAgentWasInstalled && installedVersion("@task-handoff/node-agent", globalRoot) !== targetVersion) {
|
|
196
|
-
const expectedNodeAgentIntegrity = npmArtifactIntegrity("@task-handoff/node-agent");
|
|
197
|
-
run(npmCommand, [
|
|
198
|
-
"install",
|
|
199
|
-
"--global",
|
|
200
|
-
"--prefix",
|
|
201
|
-
prefix,
|
|
202
|
-
`@task-handoff/node-agent@${targetVersion}`,
|
|
203
|
-
]);
|
|
204
|
-
if (npmArtifactIntegrity("@task-handoff/node-agent") !== expectedNodeAgentIntegrity) {
|
|
205
|
-
throw new Error("@task-handoff/node-agent npm artifact integrity changed during installation.");
|
|
206
|
-
}
|
|
207
|
-
verifyInstalledVersion("@task-handoff/node-agent", globalRoot);
|
|
208
|
-
}
|
|
209
|
-
verifyServerDistributionVersions(globalRoot);
|
|
210
|
-
}
|
|
211
|
-
if (packageName === "@task-handoff/server") {
|
|
212
|
-
if (!controlPlaneHealthUrl) throw new Error("A control-plane health URL is required for a complete server update.");
|
|
213
|
-
run("systemctl", ["restart", "task-handoff-control-plane.service"]);
|
|
214
|
-
await waitForControlPlaneHealth(controlPlaneHealthUrl);
|
|
215
|
-
}
|
|
216
|
-
const handedOff = updateJob(["updating-node"], (current) => ({ status: "restarting-node", rollout: { ...current.rollout, phase: "restarting-node" } }));
|
|
217
|
-
if (!handedOff) process.exit(0);
|
|
218
|
-
run("systemctl", ["restart", service]);
|
|
219
|
-
} catch (error) {
|
|
220
|
-
updateJob(["queued", "updating-node", "restarting-node"], (current) => ({
|
|
221
|
-
status: "failed",
|
|
222
|
-
rollout: { ...current.rollout, phase: "failed" },
|
|
223
|
-
error: { code: "NODE_UPDATE_FAILED", message: error instanceof Error ? error.message : String(error), retryable: false },
|
|
224
|
-
completedAt: new Date().toISOString(),
|
|
225
|
-
}));
|
|
226
|
-
console.error(error);
|
|
227
|
-
process.exit(1);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
void main();
|
|
2
|
+
require("../dist/node-update-worker.js");
|