@tsplc/agent 0.0.0-stage → 0.2.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/README.md +12 -2
- package/dist/cli.js +574 -0
- package/dist/cli.js.map +15 -0
- package/dist/client.js +140 -0
- package/dist/client.js.map +11 -0
- package/dist/index.js +525 -0
- package/dist/index.js.map +14 -0
- package/package.json +48 -4
- package/src/agent.ts +361 -0
- package/src/cli.ts +60 -0
- package/src/client.ts +119 -0
- package/src/discovery.ts +84 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +86 -0
- package/src/releases.ts +77 -0
- package/src/server-process.ts +96 -0
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tsplc/agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of [tsPLC](https://github.com/livingforjesus/tsplc).
|
|
4
|
+
|
|
5
|
+
The plant agent behind `tsplc deploy`. It runs on a PC in the controller's network, reached over Tailscale: it receives a release, starts its server, downloads or online-changes the controller through CODESYS in Docker, and holds an engineering login for `tsplc read`, `write`, `force` and `unforce`.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add --global @tsplc/agent
|
|
9
|
+
tsplc-agent setup
|
|
10
|
+
TSPLC_AGENT_TOKEN=… tsplc-agent serve --listen 100.101.102.103:4850
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
See the [plant agent guide](https://github.com/livingforjesus/tsplc/tree/main/docs/guides/plant.md). MIT licensed.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// packages/agent/src/cli.ts
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { join as join3 } from "path";
|
|
7
|
+
import { parseArgs } from "util";
|
|
8
|
+
import { configureDocker, configureGateway } from "@tsplc/build";
|
|
9
|
+
import { z as z4 } from "zod";
|
|
10
|
+
|
|
11
|
+
// packages/agent/src/agent.ts
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
15
|
+
import { mkdir as mkdir2, readdir, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
16
|
+
import { join as join2 } from "node:path";
|
|
17
|
+
import { setTimeout as delay2 } from "node:timers/promises";
|
|
18
|
+
import { promisify } from "node:util";
|
|
19
|
+
import {
|
|
20
|
+
explicitDownload,
|
|
21
|
+
findControllerLogin,
|
|
22
|
+
gatewayImage,
|
|
23
|
+
loginCommandSchema,
|
|
24
|
+
openControllerLogin,
|
|
25
|
+
resolveConfig,
|
|
26
|
+
updateController
|
|
27
|
+
} from "@tsplc/build";
|
|
28
|
+
import { z as z3 } from "zod";
|
|
29
|
+
|
|
30
|
+
// packages/agent/src/discovery.ts
|
|
31
|
+
import { Resolver } from "node:dns/promises";
|
|
32
|
+
import { createConnection } from "node:net";
|
|
33
|
+
import { networkInterfaces as osInterfaces } from "node:os";
|
|
34
|
+
var runtimePort = 11740;
|
|
35
|
+
var largestPrefix = 22;
|
|
36
|
+
var probeTimeoutMs = 500;
|
|
37
|
+
var probesAtOnce = 128;
|
|
38
|
+
var networkInterfaces = () => Object.entries(osInterfaces()).flatMap(([name, addresses]) => (addresses ?? []).filter((address) => address.family === "IPv4" && !address.internal).map((address) => ({
|
|
39
|
+
name,
|
|
40
|
+
address: address.address,
|
|
41
|
+
prefixLength: address.netmask.split(".").reduce((bits, octet) => bits + Number(octet).toString(2).replaceAll("0", "").length, 0)
|
|
42
|
+
})));
|
|
43
|
+
var toNumber = (address) => address.split(".").reduce((value, octet) => value * 256 + Number(octet), 0);
|
|
44
|
+
var toAddress = (value) => [24, 16, 8, 0].map((shift) => Math.floor(value / 2 ** shift) % 256).join(".");
|
|
45
|
+
var subnetHosts = ({ address, prefixLength }) => {
|
|
46
|
+
if (prefixLength < largestPrefix)
|
|
47
|
+
throw new Error(`The /${prefixLength} network is too large to scan; the limit is /${largestPrefix}.`);
|
|
48
|
+
const size = 2 ** (32 - prefixLength);
|
|
49
|
+
const network = toNumber(address) - toNumber(address) % size;
|
|
50
|
+
const hosts = size <= 2 ? Array.from({ length: size }, (_, index) => network + index) : Array.from({ length: size - 2 }, (_, index) => network + index + 1);
|
|
51
|
+
return hosts.map(toAddress).filter((host) => host !== address);
|
|
52
|
+
};
|
|
53
|
+
var accepts = (host, port) => new Promise((resolve) => {
|
|
54
|
+
const socket = createConnection({ host, port });
|
|
55
|
+
const timer = setTimeout(() => finish(false), probeTimeoutMs);
|
|
56
|
+
const finish = (open) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
socket.destroy();
|
|
59
|
+
resolve(open);
|
|
60
|
+
};
|
|
61
|
+
socket.once("connect", () => finish(true));
|
|
62
|
+
socket.once("error", () => finish(false));
|
|
63
|
+
});
|
|
64
|
+
var resolver = new Resolver({ timeout: 1000, tries: 1 });
|
|
65
|
+
var hostname = (address) => resolver.reverse(address).then((names) => names[0] ?? null, () => null);
|
|
66
|
+
var discoverControllers = async (network, port = runtimePort) => {
|
|
67
|
+
const hosts = subnetHosts(network);
|
|
68
|
+
const found = [];
|
|
69
|
+
for (let start = 0;start < hosts.length; start += probesAtOnce) {
|
|
70
|
+
const batch = hosts.slice(start, start + probesAtOnce);
|
|
71
|
+
const open = await Promise.all(batch.map((host) => accepts(host, port)));
|
|
72
|
+
for (const [index, host] of batch.entries())
|
|
73
|
+
if (open[index])
|
|
74
|
+
found.push({ address: host, port, hostname: await hostname(host) });
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// packages/agent/src/protocol.ts
|
|
80
|
+
import { z } from "zod";
|
|
81
|
+
var targetSchema = z.strictObject({
|
|
82
|
+
host: z.string().nonempty(),
|
|
83
|
+
port: z.number().int().min(1).max(65535)
|
|
84
|
+
});
|
|
85
|
+
var networkInterfaceSchema = z.strictObject({
|
|
86
|
+
name: z.string(),
|
|
87
|
+
address: z.ipv4(),
|
|
88
|
+
prefixLength: z.number().int().min(0).max(32)
|
|
89
|
+
});
|
|
90
|
+
var controllerSchema = z.strictObject({
|
|
91
|
+
address: z.ipv4(),
|
|
92
|
+
port: z.number().int(),
|
|
93
|
+
hostname: z.string().nullable()
|
|
94
|
+
});
|
|
95
|
+
var credentialsSchema = z.strictObject({
|
|
96
|
+
username: z.string().nonempty(),
|
|
97
|
+
password: z.string().nonempty()
|
|
98
|
+
});
|
|
99
|
+
var variableSchema = z.strictObject({ path: z.string(), type: z.string() });
|
|
100
|
+
var releaseManifestSchema = z.strictObject({
|
|
101
|
+
config: z.record(z.string(), z.unknown()),
|
|
102
|
+
variables: z.array(variableSchema),
|
|
103
|
+
server: z.string().nonempty().optional()
|
|
104
|
+
});
|
|
105
|
+
var deployOptionsSchema = z.strictObject({
|
|
106
|
+
credentials: credentialsSchema,
|
|
107
|
+
download: z.boolean(),
|
|
108
|
+
initializeUser: z.boolean()
|
|
109
|
+
});
|
|
110
|
+
var eventSchema = z.discriminatedUnion("kind", [
|
|
111
|
+
z.strictObject({ kind: z.literal("progress"), message: z.string() }),
|
|
112
|
+
z.strictObject({ kind: z.literal("done"), result: z.unknown() }),
|
|
113
|
+
z.strictObject({
|
|
114
|
+
kind: z.literal("error"),
|
|
115
|
+
message: z.string(),
|
|
116
|
+
downloadRequired: z.boolean()
|
|
117
|
+
})
|
|
118
|
+
]);
|
|
119
|
+
var releaseSchema = z.strictObject({
|
|
120
|
+
id: z.string(),
|
|
121
|
+
deployedAt: z.iso.datetime(),
|
|
122
|
+
operation: z.enum(["download", "online-change"]),
|
|
123
|
+
controllerIds: z.strictObject({ codeId: z.string(), dataId: z.string() }),
|
|
124
|
+
server: z.string().optional()
|
|
125
|
+
});
|
|
126
|
+
var statusSchema = z.strictObject({
|
|
127
|
+
target: targetSchema.nullable(),
|
|
128
|
+
release: releaseSchema.nullable(),
|
|
129
|
+
server: z.strictObject({ executable: z.string(), running: z.boolean() }).nullable(),
|
|
130
|
+
login: z.strictObject({ state: z.string(), forced: z.array(z.string()) }).nullable()
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// packages/agent/src/releases.ts
|
|
134
|
+
import { createHash } from "node:crypto";
|
|
135
|
+
import { existsSync } from "node:fs";
|
|
136
|
+
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
137
|
+
import { dirname, join, posix } from "node:path";
|
|
138
|
+
import { z as z2 } from "zod";
|
|
139
|
+
var filePrefix = "files/";
|
|
140
|
+
var releasePath = (name) => {
|
|
141
|
+
const path = posix.normalize(name.slice(filePrefix.length));
|
|
142
|
+
if (posix.isAbsolute(path) || path.startsWith("..") || path.includes("\\"))
|
|
143
|
+
throw new Error(`Invalid release file name: ${name}`);
|
|
144
|
+
return path;
|
|
145
|
+
};
|
|
146
|
+
var readUpload = async (form) => {
|
|
147
|
+
const manifest = releaseManifestSchema.parse(JSON.parse(z2.string().parse(form.get("manifest"))));
|
|
148
|
+
const files = await Promise.all([...form.entries()].filter(([name]) => name.startsWith(filePrefix)).map(async ([name, value]) => ({
|
|
149
|
+
path: releasePath(name),
|
|
150
|
+
content: new Uint8Array(await z2.instanceof(File).parse(value).arrayBuffer())
|
|
151
|
+
})));
|
|
152
|
+
if (manifest.server && !files.some((file) => file.path === manifest.server))
|
|
153
|
+
throw new Error(`The release names a server ${manifest.server} it doesn't contain`);
|
|
154
|
+
return { manifest, files };
|
|
155
|
+
};
|
|
156
|
+
var saveRelease = async (releases, { manifest, files }) => {
|
|
157
|
+
const hash = createHash("sha256").update(JSON.stringify(manifest));
|
|
158
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path)))
|
|
159
|
+
hash.update(file.path).update(createHash("sha256").update(file.content).digest("hex"));
|
|
160
|
+
const id = hash.digest("hex").slice(0, 16);
|
|
161
|
+
const directory = join(releases, id);
|
|
162
|
+
if (!existsSync(directory)) {
|
|
163
|
+
await mkdir(releases, { recursive: true });
|
|
164
|
+
const staging = await mkdtemp(join(releases, ".upload-"));
|
|
165
|
+
try {
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
await mkdir(dirname(join(staging, file.path)), { recursive: true });
|
|
168
|
+
await writeFile(join(staging, file.path), file.content, {
|
|
169
|
+
mode: file.path === manifest.server ? 493 : 420
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
await writeFile(join(staging, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
173
|
+
await rename(staging, directory);
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
await rm(staging, { recursive: true, force: true });
|
|
176
|
+
throw cause;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { id, directory, manifest };
|
|
180
|
+
};
|
|
181
|
+
var loadRelease = async (releases, id) => {
|
|
182
|
+
const directory = join(releases, id);
|
|
183
|
+
const manifest = releaseManifestSchema.parse(JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")));
|
|
184
|
+
return { id, directory, manifest };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// packages/agent/src/server-process.ts
|
|
188
|
+
import { dirname as dirname2 } from "node:path";
|
|
189
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
190
|
+
var healthyWithinMs = 60000;
|
|
191
|
+
var settleMs = 5000;
|
|
192
|
+
var restartDelayMs = 2000;
|
|
193
|
+
var createServerSupervisor = (options) => {
|
|
194
|
+
let current;
|
|
195
|
+
const spawn = (executable) => {
|
|
196
|
+
const child = Bun.spawn([executable], {
|
|
197
|
+
cwd: dirname2(executable),
|
|
198
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
199
|
+
onExit: (_, code) => {
|
|
200
|
+
if (current?.process !== child)
|
|
201
|
+
return;
|
|
202
|
+
options.log(`Server exited (${code}); restarting it in ${restartDelayMs / 1000} s`);
|
|
203
|
+
delay(restartDelayMs).then(() => {
|
|
204
|
+
if (current?.process === child)
|
|
205
|
+
current = { executable, process: spawn(executable) };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
return child;
|
|
210
|
+
};
|
|
211
|
+
const start = (executable) => {
|
|
212
|
+
current = { executable, process: spawn(executable) };
|
|
213
|
+
return current.process;
|
|
214
|
+
};
|
|
215
|
+
const stop = async () => {
|
|
216
|
+
const running = current;
|
|
217
|
+
current = undefined;
|
|
218
|
+
if (!running)
|
|
219
|
+
return;
|
|
220
|
+
running.process.kill();
|
|
221
|
+
await running.process.exited;
|
|
222
|
+
};
|
|
223
|
+
const healthy = async (child) => {
|
|
224
|
+
if (!options.healthUrl) {
|
|
225
|
+
await delay(settleMs);
|
|
226
|
+
return child.exitCode === null;
|
|
227
|
+
}
|
|
228
|
+
const deadline = Date.now() + healthyWithinMs;
|
|
229
|
+
while (Date.now() < deadline && child.exitCode === null) {
|
|
230
|
+
if (await fetch(options.healthUrl).then((response) => response.ok, () => false))
|
|
231
|
+
return true;
|
|
232
|
+
await delay(1000);
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
start,
|
|
238
|
+
stop,
|
|
239
|
+
replace: async (executable) => {
|
|
240
|
+
const previous = current?.executable;
|
|
241
|
+
await stop();
|
|
242
|
+
let failure;
|
|
243
|
+
const started = await (async () => healthy(start(executable)))().catch((error) => {
|
|
244
|
+
failure = error;
|
|
245
|
+
return false;
|
|
246
|
+
});
|
|
247
|
+
if (started)
|
|
248
|
+
return;
|
|
249
|
+
await stop();
|
|
250
|
+
if (previous)
|
|
251
|
+
start(previous);
|
|
252
|
+
const reason = failure instanceof Error ? failure.message : `it did not start${options.healthUrl ? ` answering ${options.healthUrl}` : ""}`;
|
|
253
|
+
throw new Error(`The new server failed: ${reason}. ${previous ? "The previous one is running again." : ""}`.trim(), { cause: failure });
|
|
254
|
+
},
|
|
255
|
+
status: () => current && { executable: current.executable, running: current.process.exitCode === null }
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// packages/agent/src/agent.ts
|
|
260
|
+
var execute = promisify(execFile);
|
|
261
|
+
var network = "tsplc-agent";
|
|
262
|
+
var gateway = { host: "tsplc-agent-gateway", port: 1217 };
|
|
263
|
+
var loginContainer = "tsplc-agent-login";
|
|
264
|
+
var credentialNames = {
|
|
265
|
+
usernameEnv: "TSPLC_DEVICE_USERNAME",
|
|
266
|
+
passwordEnv: "TSPLC_DEVICE_PASSWORD"
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
class AgentError extends Error {
|
|
270
|
+
status;
|
|
271
|
+
downloadRequired;
|
|
272
|
+
constructor(message, status, downloadRequired = false) {
|
|
273
|
+
super(message);
|
|
274
|
+
this.status = status;
|
|
275
|
+
this.downloadRequired = downloadRequired;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
var json = (body, status = 200) => Response.json(body, { status });
|
|
279
|
+
var readJson = async (path, schema) => existsSync2(path) ? schema.parse(JSON.parse(await readFile2(path, "utf8"))) : null;
|
|
280
|
+
var docker = (...args) => execute("docker", args).then(({ stdout }) => stdout.trim());
|
|
281
|
+
var ensureGateway = async () => {
|
|
282
|
+
if (!await docker("network", "ls", "--quiet", "--filter", `name=^${network}$`))
|
|
283
|
+
await docker("network", "create", network);
|
|
284
|
+
if (await docker("container", "ls", "--quiet", "--filter", `name=^${gateway.host}$`))
|
|
285
|
+
return;
|
|
286
|
+
await docker("rm", "--force", gateway.host);
|
|
287
|
+
await docker("run", "--detach", "--restart", "unless-stopped", "--platform", "linux/amd64", "--network", network, "--name", gateway.host, gatewayImage);
|
|
288
|
+
};
|
|
289
|
+
var withCredentials = async (credentials, run) => {
|
|
290
|
+
process.env[credentialNames.usernameEnv] = credentials.username;
|
|
291
|
+
process.env[credentialNames.passwordEnv] = credentials.password;
|
|
292
|
+
try {
|
|
293
|
+
return await run();
|
|
294
|
+
} finally {
|
|
295
|
+
delete process.env[credentialNames.usernameEnv];
|
|
296
|
+
delete process.env[credentialNames.passwordEnv];
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
var followProgress = (controller, emit) => {
|
|
300
|
+
const jobs = join2(controller, ".codesys/controller/jobs");
|
|
301
|
+
const since = Date.now();
|
|
302
|
+
let following = true;
|
|
303
|
+
const done = (async () => {
|
|
304
|
+
let log;
|
|
305
|
+
let offset = 0;
|
|
306
|
+
while (following) {
|
|
307
|
+
await delay2(2000);
|
|
308
|
+
if (!log && existsSync2(jobs)) {
|
|
309
|
+
for (const job of await readdir(jobs)) {
|
|
310
|
+
const path = join2(jobs, job, ".codesys/controller/progress.log");
|
|
311
|
+
if (existsSync2(path) && (await stat(path)).mtimeMs >= since)
|
|
312
|
+
log = path;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!log)
|
|
316
|
+
continue;
|
|
317
|
+
const text = await readFile2(log, "utf8");
|
|
318
|
+
for (const line of text.slice(offset).split(`
|
|
319
|
+
`).filter(Boolean))
|
|
320
|
+
emit({ kind: "progress", message: line.replace(/^\S+Z /, "") });
|
|
321
|
+
offset = text.length;
|
|
322
|
+
}
|
|
323
|
+
})();
|
|
324
|
+
return async () => {
|
|
325
|
+
following = false;
|
|
326
|
+
await done;
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
var createAgent = async (options) => {
|
|
330
|
+
const paths = {
|
|
331
|
+
releases: join2(options.state, "releases"),
|
|
332
|
+
controller: join2(options.state, "controller"),
|
|
333
|
+
release: join2(options.state, "release.json"),
|
|
334
|
+
target: join2(options.state, "target.json")
|
|
335
|
+
};
|
|
336
|
+
await mkdir2(options.state, { recursive: true });
|
|
337
|
+
const server = createServerSupervisor({ healthUrl: options.serverHealthUrl, log: options.log });
|
|
338
|
+
const deployed = await readJson(paths.release, releaseSchema);
|
|
339
|
+
if (deployed?.server)
|
|
340
|
+
server.start(join2(paths.releases, deployed.server));
|
|
341
|
+
let busy;
|
|
342
|
+
const requireTarget = async () => {
|
|
343
|
+
const target = await readJson(paths.target, targetSchema);
|
|
344
|
+
if (!target)
|
|
345
|
+
throw new AgentError("Select the controller first: tsplc target", 409);
|
|
346
|
+
return target;
|
|
347
|
+
};
|
|
348
|
+
const controllerConfig = (release, target) => ({
|
|
349
|
+
...resolveConfig({
|
|
350
|
+
...release.manifest.config,
|
|
351
|
+
online: { network, gateway, device: target, credentials: credentialNames }
|
|
352
|
+
}, join2(release.directory, "manifest.json")),
|
|
353
|
+
outDir: paths.controller
|
|
354
|
+
});
|
|
355
|
+
const currentRelease = async () => {
|
|
356
|
+
const release = await readJson(paths.release, releaseSchema);
|
|
357
|
+
if (!release)
|
|
358
|
+
throw new AgentError("Nothing has been deployed yet: tsplc deploy", 409);
|
|
359
|
+
return loadRelease(paths.releases, release.id);
|
|
360
|
+
};
|
|
361
|
+
const operation = (name, run) => {
|
|
362
|
+
if (busy)
|
|
363
|
+
throw new AgentError(`The agent is busy with ${busy}`, 409);
|
|
364
|
+
busy = name;
|
|
365
|
+
const encoder = new TextEncoder;
|
|
366
|
+
let listening = true;
|
|
367
|
+
return new Response(new ReadableStream({
|
|
368
|
+
start: async (controller) => {
|
|
369
|
+
const write = (text) => {
|
|
370
|
+
if (listening)
|
|
371
|
+
controller.enqueue(encoder.encode(text));
|
|
372
|
+
};
|
|
373
|
+
const emit = (event) => write(`${JSON.stringify(event)}
|
|
374
|
+
`);
|
|
375
|
+
const heartbeat = setInterval(() => write(`
|
|
376
|
+
`), 15000);
|
|
377
|
+
try {
|
|
378
|
+
emit({ kind: "done", result: await run(emit) });
|
|
379
|
+
} catch (error) {
|
|
380
|
+
options.log(`${name} failed: ${error instanceof Error ? error.stack : String(error)}`);
|
|
381
|
+
emit({
|
|
382
|
+
kind: "error",
|
|
383
|
+
message: error instanceof Error ? error.message : String(error),
|
|
384
|
+
downloadRequired: error instanceof AgentError && error.downloadRequired || error instanceof Error && error.message.includes(explicitDownload)
|
|
385
|
+
});
|
|
386
|
+
} finally {
|
|
387
|
+
clearInterval(heartbeat);
|
|
388
|
+
busy = undefined;
|
|
389
|
+
if (listening)
|
|
390
|
+
controller.close();
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
cancel: () => {
|
|
394
|
+
listening = false;
|
|
395
|
+
}
|
|
396
|
+
}), { headers: { "Content-Type": "application/x-ndjson" } });
|
|
397
|
+
};
|
|
398
|
+
const deploy = async (form) => {
|
|
399
|
+
const settings = deployOptionsSchema.parse(JSON.parse(z3.string().parse(form.get("options"))));
|
|
400
|
+
const upload = await readUpload(form);
|
|
401
|
+
const target = await requireTarget();
|
|
402
|
+
if (await findControllerLogin(loginContainer))
|
|
403
|
+
throw new AgentError("A login is open; log out before deploying: tsplc logout", 409);
|
|
404
|
+
return operation("a deployment", async (emit) => {
|
|
405
|
+
const release = await saveRelease(paths.releases, upload);
|
|
406
|
+
const config = controllerConfig(release, target);
|
|
407
|
+
if (!settings.download && !existsSync2(join2(paths.controller, ".codesys/controller/current.json")))
|
|
408
|
+
throw new AgentError("The controller has no application downloaded by this agent; only a full download can deploy", 409, true);
|
|
409
|
+
const previous = await readJson(paths.release, releaseSchema);
|
|
410
|
+
const serverPath = release.manifest.server && `${release.id}/${release.manifest.server}`;
|
|
411
|
+
if (serverPath) {
|
|
412
|
+
emit({ kind: "progress", message: "Starting the new server" });
|
|
413
|
+
await server.replace(join2(paths.releases, serverPath));
|
|
414
|
+
}
|
|
415
|
+
const kind = settings.download ? "download" : "online-change";
|
|
416
|
+
emit({ kind: "progress", message: `Starting the ${kind}; CODESYS takes minutes to start` });
|
|
417
|
+
await ensureGateway();
|
|
418
|
+
const stopFollowing = followProgress(paths.controller, emit);
|
|
419
|
+
const receipt = await withCredentials(settings.credentials, () => updateController(config, kind, { initializeUser: settings.initializeUser })).catch(async (failure) => {
|
|
420
|
+
if (serverPath) {
|
|
421
|
+
emit({ kind: "progress", message: "Restoring the previous server" });
|
|
422
|
+
await (previous?.server ? server.replace(join2(paths.releases, previous.server)) : server.stop());
|
|
423
|
+
}
|
|
424
|
+
throw failure;
|
|
425
|
+
}).finally(stopFollowing);
|
|
426
|
+
const deployedRelease = {
|
|
427
|
+
id: release.id,
|
|
428
|
+
deployedAt: new Date().toISOString(),
|
|
429
|
+
operation: kind,
|
|
430
|
+
controllerIds: receipt.controllerIds,
|
|
431
|
+
server: serverPath ?? previous?.server
|
|
432
|
+
};
|
|
433
|
+
await writeFile2(paths.release, JSON.stringify(deployedRelease, null, 2));
|
|
434
|
+
return deployedRelease;
|
|
435
|
+
});
|
|
436
|
+
};
|
|
437
|
+
const openLogin = async (credentials) => {
|
|
438
|
+
const release = await currentRelease();
|
|
439
|
+
const config = controllerConfig(release, await requireTarget());
|
|
440
|
+
return operation("a login", async (emit) => {
|
|
441
|
+
if (await findControllerLogin(loginContainer))
|
|
442
|
+
throw new AgentError("A login is already open", 409);
|
|
443
|
+
emit({ kind: "progress", message: "Logging in; CODESYS takes minutes to start" });
|
|
444
|
+
await ensureGateway();
|
|
445
|
+
const login = await withCredentials(credentials, () => openControllerLogin(config, loginContainer));
|
|
446
|
+
return login.send({ command: "state" });
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
const requireLogin = async () => {
|
|
450
|
+
const login = await findControllerLogin(loginContainer);
|
|
451
|
+
if (!login)
|
|
452
|
+
throw new AgentError("No login is open: tsplc login", 409);
|
|
453
|
+
return login;
|
|
454
|
+
};
|
|
455
|
+
const status = async () => {
|
|
456
|
+
const login = await findControllerLogin(loginContainer);
|
|
457
|
+
const state = login && await login.ready() && await login.send({ command: "state" });
|
|
458
|
+
return {
|
|
459
|
+
target: await readJson(paths.target, targetSchema),
|
|
460
|
+
release: await readJson(paths.release, releaseSchema),
|
|
461
|
+
server: server.status() ?? null,
|
|
462
|
+
login: state && "state" in state ? state : null
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
const route = async (request) => {
|
|
466
|
+
const { pathname } = new URL(request.url);
|
|
467
|
+
const key = `${request.method} ${pathname}`;
|
|
468
|
+
switch (key) {
|
|
469
|
+
case "GET /status":
|
|
470
|
+
return json(await status());
|
|
471
|
+
case "GET /interfaces":
|
|
472
|
+
return json(networkInterfaces());
|
|
473
|
+
case "POST /discover": {
|
|
474
|
+
const { name } = z3.strictObject({ name: z3.string() }).parse(await request.json());
|
|
475
|
+
const selected = networkInterfaces().find((network) => network.name === name);
|
|
476
|
+
if (!selected)
|
|
477
|
+
throw new AgentError(`No IPv4 interface named ${name}`, 404);
|
|
478
|
+
return json(await discoverControllers(selected));
|
|
479
|
+
}
|
|
480
|
+
case "PUT /target": {
|
|
481
|
+
const target = targetSchema.parse(await request.json());
|
|
482
|
+
await writeFile2(paths.target, JSON.stringify(target, null, 2));
|
|
483
|
+
return json(target);
|
|
484
|
+
}
|
|
485
|
+
case "GET /variables":
|
|
486
|
+
return json((await currentRelease()).manifest.variables);
|
|
487
|
+
case "POST /deploy":
|
|
488
|
+
return deploy(await request.formData());
|
|
489
|
+
case "POST /login":
|
|
490
|
+
return openLogin(credentialsSchema.parse(await request.json()));
|
|
491
|
+
case "POST /login/command":
|
|
492
|
+
return json(await (await requireLogin()).send(loginCommandSchema.parse(await request.json())));
|
|
493
|
+
case "DELETE /login":
|
|
494
|
+
await (await requireLogin()).close();
|
|
495
|
+
return json({});
|
|
496
|
+
default:
|
|
497
|
+
return json({ error: `No route ${key}` }, 404);
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const expected = Buffer.from(`Bearer ${options.token}`);
|
|
501
|
+
return {
|
|
502
|
+
fetch: async (request) => {
|
|
503
|
+
const actual = Buffer.from(request.headers.get("authorization") ?? "");
|
|
504
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
505
|
+
return json({ error: "Unauthorized" }, 401);
|
|
506
|
+
try {
|
|
507
|
+
return await route(request);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (error instanceof AgentError)
|
|
510
|
+
return json({ error: error.message }, error.status);
|
|
511
|
+
if (error instanceof z3.ZodError)
|
|
512
|
+
return json({ error: z3.prettifyError(error) }, 400);
|
|
513
|
+
options.log(`${request.method} ${request.url} failed: ${error instanceof Error ? error.stack : String(error)}`);
|
|
514
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
stop: () => server.stop()
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// packages/agent/src/cli.ts
|
|
522
|
+
var usage = `tsplc-agent setup|serve [--listen host:port] [--state directory] [--server-health url]
|
|
523
|
+
|
|
524
|
+
setup Builds the CODESYS builder and gateway images. Run it once, and after updating tsPLC.
|
|
525
|
+
serve Serves deployments and logins. --listen is required: use the machine's Tailscale
|
|
526
|
+
address, such as 100.101.102.103:4850. TSPLC_AGENT_TOKEN must hold a shared secret of
|
|
527
|
+
at least 32 characters, such as one from \`openssl rand -hex 32\`.`;
|
|
528
|
+
var { values, positionals } = parseArgs({
|
|
529
|
+
allowPositionals: true,
|
|
530
|
+
options: {
|
|
531
|
+
help: { type: "boolean", short: "h" },
|
|
532
|
+
listen: { type: "string" },
|
|
533
|
+
state: { type: "string" },
|
|
534
|
+
"server-health": { type: "string" }
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
var command = positionals[0];
|
|
538
|
+
if (!command || values.help) {
|
|
539
|
+
console.log(usage);
|
|
540
|
+
process.exit(0);
|
|
541
|
+
}
|
|
542
|
+
if (positionals.length > 1)
|
|
543
|
+
throw new Error("Specify one command.");
|
|
544
|
+
if (command === "setup") {
|
|
545
|
+
await configureDocker();
|
|
546
|
+
await configureGateway();
|
|
547
|
+
} else if (command === "serve") {
|
|
548
|
+
const [, hostname, port] = /^(.+):(\d+)$/.exec(values.listen ?? "") ?? [];
|
|
549
|
+
if (!hostname || !port)
|
|
550
|
+
throw new Error("--listen host:port is required.");
|
|
551
|
+
const token = z4.string({ error: "Set TSPLC_AGENT_TOKEN" }).min(32, "TSPLC_AGENT_TOKEN must be at least 32 characters").parse(process.env["TSPLC_AGENT_TOKEN"]);
|
|
552
|
+
const agent = await createAgent({
|
|
553
|
+
state: values.state ?? join3(homedir(), ".tsplc-agent"),
|
|
554
|
+
token,
|
|
555
|
+
serverHealthUrl: values["server-health"],
|
|
556
|
+
log: (message) => console.log(`${new Date().toISOString()} ${message}`)
|
|
557
|
+
});
|
|
558
|
+
const server = Bun.serve({
|
|
559
|
+
hostname,
|
|
560
|
+
port: Number(port),
|
|
561
|
+
fetch: agent.fetch,
|
|
562
|
+
idleTimeout: 0,
|
|
563
|
+
maxRequestBodySize: 2 ** 30
|
|
564
|
+
});
|
|
565
|
+
console.log(`tsPLC agent listening on ${server.url}`);
|
|
566
|
+
for (const signal of ["SIGINT", "SIGTERM"])
|
|
567
|
+
process.on(signal, () => {
|
|
568
|
+
agent.stop().then(() => process.exit(0));
|
|
569
|
+
});
|
|
570
|
+
} else
|
|
571
|
+
throw new Error(`Unknown command ${command}.`);
|
|
572
|
+
|
|
573
|
+
//# debugId=CCF1F2B2CF5AEA4964756E2164756E21
|
|
574
|
+
//# sourceMappingURL=cli.js.map
|