@tsplc/agent 0.0.0-stage → 0.2.0
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/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsplc/agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "The tsPLC plant agent: deploys a program and its server to a controller on the plant network, and holds an engineering login for reads, writes and forces.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"plc",
|
|
9
|
+
"codesys",
|
|
10
|
+
"deployment"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/livingforjesus/tsplc#readme",
|
|
13
|
+
"bugs": "https://github.com/livingforjesus/tsplc/issues",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/livingforjesus/tsplc.git",
|
|
17
|
+
"directory": "packages/agent"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/index.ts",
|
|
22
|
+
"bun": "./src/index.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"types": "./src/client.ts",
|
|
27
|
+
"bun": "./src/client.ts",
|
|
28
|
+
"default": "./dist/client.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!**/*.test.ts"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.4.3"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@tsplc/build": "0.2.0",
|
|
41
|
+
"@tsplc/config": "0.2.0",
|
|
42
|
+
"zod": "4.3.6"
|
|
43
|
+
},
|
|
44
|
+
"bin": {
|
|
45
|
+
"tsplc-agent": "./dist/cli.js"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import {
|
|
9
|
+
explicitDownload,
|
|
10
|
+
findControllerLogin,
|
|
11
|
+
gatewayImage,
|
|
12
|
+
loginCommandSchema,
|
|
13
|
+
openControllerLogin,
|
|
14
|
+
resolveConfig,
|
|
15
|
+
updateController,
|
|
16
|
+
} from "@tsplc/build";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { discoverControllers, networkInterfaces } from "./discovery";
|
|
19
|
+
import {
|
|
20
|
+
type AgentEvent,
|
|
21
|
+
type AgentStatus,
|
|
22
|
+
type Credentials,
|
|
23
|
+
credentialsSchema,
|
|
24
|
+
deployOptionsSchema,
|
|
25
|
+
type Release,
|
|
26
|
+
releaseSchema,
|
|
27
|
+
type Target,
|
|
28
|
+
targetSchema,
|
|
29
|
+
} from "./protocol";
|
|
30
|
+
import { loadRelease, readUpload, saveRelease } from "./releases";
|
|
31
|
+
import { createServerSupervisor } from "./server-process";
|
|
32
|
+
|
|
33
|
+
const execute = promisify(execFile);
|
|
34
|
+
|
|
35
|
+
/** Builder containers reach the gateway by name on this Docker network. */
|
|
36
|
+
const network = "tsplc-agent";
|
|
37
|
+
const gateway = { host: "tsplc-agent-gateway", port: 1217 };
|
|
38
|
+
const loginContainer = "tsplc-agent-login";
|
|
39
|
+
/** CODESYS reads the controller user from these for one operation; the agent never stores it. */
|
|
40
|
+
const credentialNames = {
|
|
41
|
+
usernameEnv: "TSPLC_DEVICE_USERNAME",
|
|
42
|
+
passwordEnv: "TSPLC_DEVICE_PASSWORD",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
class AgentError extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
message: string,
|
|
48
|
+
readonly status: number,
|
|
49
|
+
readonly downloadRequired = false,
|
|
50
|
+
) {
|
|
51
|
+
super(message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
|
56
|
+
|
|
57
|
+
const readJson = async <T>(path: string, schema: z.ZodType<T>) =>
|
|
58
|
+
existsSync(path) ? schema.parse(JSON.parse(await readFile(path, "utf8"))) : null;
|
|
59
|
+
|
|
60
|
+
const docker = (...args: string[]) => execute("docker", args).then(({ stdout }) => stdout.trim());
|
|
61
|
+
|
|
62
|
+
/** The gateway container stays up between operations, as a CODESYS gateway service would. */
|
|
63
|
+
const ensureGateway = async () => {
|
|
64
|
+
if (!(await docker("network", "ls", "--quiet", "--filter", `name=^${network}$`)))
|
|
65
|
+
await docker("network", "create", network);
|
|
66
|
+
if (await docker("container", "ls", "--quiet", "--filter", `name=^${gateway.host}$`)) return;
|
|
67
|
+
await docker("rm", "--force", gateway.host);
|
|
68
|
+
await docker(
|
|
69
|
+
"run",
|
|
70
|
+
"--detach",
|
|
71
|
+
"--restart",
|
|
72
|
+
"unless-stopped",
|
|
73
|
+
"--platform",
|
|
74
|
+
"linux/amd64",
|
|
75
|
+
"--network",
|
|
76
|
+
network,
|
|
77
|
+
"--name",
|
|
78
|
+
gateway.host,
|
|
79
|
+
gatewayImage,
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Hands the controller user to CODESYS through the environment for the duration of `run`. */
|
|
84
|
+
const withCredentials = async <T>(credentials: Credentials, run: () => Promise<T>) => {
|
|
85
|
+
process.env[credentialNames.usernameEnv] = credentials.username;
|
|
86
|
+
process.env[credentialNames.passwordEnv] = credentials.password;
|
|
87
|
+
try {
|
|
88
|
+
return await run();
|
|
89
|
+
} finally {
|
|
90
|
+
delete process.env[credentialNames.usernameEnv];
|
|
91
|
+
delete process.env[credentialNames.passwordEnv];
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Relays the newest controller job's progress log, which CODESYS appends to as it works. */
|
|
96
|
+
const followProgress = (controller: string, emit: (event: AgentEvent) => void) => {
|
|
97
|
+
const jobs = join(controller, ".codesys/controller/jobs");
|
|
98
|
+
const since = Date.now();
|
|
99
|
+
let following = true;
|
|
100
|
+
const done = (async () => {
|
|
101
|
+
let log: string | undefined;
|
|
102
|
+
let offset = 0;
|
|
103
|
+
while (following) {
|
|
104
|
+
await delay(2000);
|
|
105
|
+
if (!log && existsSync(jobs)) {
|
|
106
|
+
for (const job of await readdir(jobs)) {
|
|
107
|
+
const path = join(jobs, job, ".codesys/controller/progress.log");
|
|
108
|
+
if (existsSync(path) && (await stat(path)).mtimeMs >= since) log = path;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!log) continue;
|
|
112
|
+
const text = await readFile(log, "utf8");
|
|
113
|
+
for (const line of text.slice(offset).split("\n").filter(Boolean))
|
|
114
|
+
emit({ kind: "progress", message: line.replace(/^\S+Z /, "") });
|
|
115
|
+
offset = text.length;
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
118
|
+
return async () => {
|
|
119
|
+
following = false;
|
|
120
|
+
await done;
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export interface AgentOptions {
|
|
125
|
+
/** Releases, the controller's download baseline and the selected target live here. */
|
|
126
|
+
state: string;
|
|
127
|
+
token: string;
|
|
128
|
+
/** The deployed server's health route; without it a server that stays up 5 s counts as started. */
|
|
129
|
+
serverHealthUrl?: string;
|
|
130
|
+
log: (message: string) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The plant agent's routes. It runs one deployment or login operation at a time. */
|
|
134
|
+
export const createAgent = async (options: AgentOptions) => {
|
|
135
|
+
const paths = {
|
|
136
|
+
releases: join(options.state, "releases"),
|
|
137
|
+
controller: join(options.state, "controller"),
|
|
138
|
+
release: join(options.state, "release.json"),
|
|
139
|
+
target: join(options.state, "target.json"),
|
|
140
|
+
};
|
|
141
|
+
await mkdir(options.state, { recursive: true });
|
|
142
|
+
const server = createServerSupervisor({ healthUrl: options.serverHealthUrl, log: options.log });
|
|
143
|
+
const deployed = await readJson(paths.release, releaseSchema);
|
|
144
|
+
if (deployed?.server) server.start(join(paths.releases, deployed.server));
|
|
145
|
+
let busy: string | undefined;
|
|
146
|
+
|
|
147
|
+
const requireTarget = async () => {
|
|
148
|
+
const target = await readJson(paths.target, targetSchema);
|
|
149
|
+
if (!target) throw new AgentError("Select the controller first: tsplc target", 409);
|
|
150
|
+
return target;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The release's build config, aimed at `target` through the agent's gateway. */
|
|
154
|
+
const controllerConfig = (release: Awaited<ReturnType<typeof saveRelease>>, target: Target) => ({
|
|
155
|
+
...resolveConfig(
|
|
156
|
+
{
|
|
157
|
+
...release.manifest.config,
|
|
158
|
+
online: { network, gateway, device: target, credentials: credentialNames },
|
|
159
|
+
},
|
|
160
|
+
join(release.directory, "manifest.json"),
|
|
161
|
+
),
|
|
162
|
+
// The baseline outlives releases: an online change compares against the last download.
|
|
163
|
+
outDir: paths.controller,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const currentRelease = async () => {
|
|
167
|
+
const release = await readJson(paths.release, releaseSchema);
|
|
168
|
+
if (!release) throw new AgentError("Nothing has been deployed yet: tsplc deploy", 409);
|
|
169
|
+
return loadRelease(paths.releases, release.id);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/** Streams one exclusive operation's events, then its result, as newline-delimited JSON. */
|
|
173
|
+
const operation = (
|
|
174
|
+
name: string,
|
|
175
|
+
run: (emit: (event: AgentEvent) => void) => Promise<unknown>,
|
|
176
|
+
) => {
|
|
177
|
+
if (busy) throw new AgentError(`The agent is busy with ${busy}`, 409);
|
|
178
|
+
busy = name;
|
|
179
|
+
const encoder = new TextEncoder();
|
|
180
|
+
// A client that disconnects doesn't stop the operation; it just stops hearing about it.
|
|
181
|
+
let listening = true;
|
|
182
|
+
return new Response(
|
|
183
|
+
new ReadableStream({
|
|
184
|
+
start: async (controller) => {
|
|
185
|
+
const write = (text: string) => {
|
|
186
|
+
if (listening) controller.enqueue(encoder.encode(text));
|
|
187
|
+
};
|
|
188
|
+
const emit = (event: AgentEvent) => write(`${JSON.stringify(event)}\n`);
|
|
189
|
+
// CODESYS can stay silent for minutes; blank lines keep proxies from closing the stream.
|
|
190
|
+
const heartbeat = setInterval(() => write("\n"), 15_000);
|
|
191
|
+
try {
|
|
192
|
+
emit({ kind: "done", result: await run(emit) });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
options.log(`${name} failed: ${error instanceof Error ? error.stack : String(error)}`);
|
|
195
|
+
emit({
|
|
196
|
+
kind: "error",
|
|
197
|
+
message: error instanceof Error ? error.message : String(error),
|
|
198
|
+
downloadRequired:
|
|
199
|
+
(error instanceof AgentError && error.downloadRequired) ||
|
|
200
|
+
(error instanceof Error && error.message.includes(explicitDownload)),
|
|
201
|
+
});
|
|
202
|
+
} finally {
|
|
203
|
+
clearInterval(heartbeat);
|
|
204
|
+
busy = undefined;
|
|
205
|
+
if (listening) controller.close();
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
cancel: () => {
|
|
209
|
+
listening = false;
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
{ headers: { "Content-Type": "application/x-ndjson" } },
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const deploy = async (form: FormData) => {
|
|
217
|
+
const settings = deployOptionsSchema.parse(JSON.parse(z.string().parse(form.get("options"))));
|
|
218
|
+
const upload = await readUpload(form);
|
|
219
|
+
const target = await requireTarget();
|
|
220
|
+
if (await findControllerLogin(loginContainer))
|
|
221
|
+
throw new AgentError("A login is open; log out before deploying: tsplc logout", 409);
|
|
222
|
+
return operation("a deployment", async (emit) => {
|
|
223
|
+
const release = await saveRelease(paths.releases, upload);
|
|
224
|
+
const config = controllerConfig(release, target);
|
|
225
|
+
if (
|
|
226
|
+
!settings.download &&
|
|
227
|
+
!existsSync(join(paths.controller, ".codesys/controller/current.json"))
|
|
228
|
+
)
|
|
229
|
+
throw new AgentError(
|
|
230
|
+
"The controller has no application downloaded by this agent; only a full download can deploy",
|
|
231
|
+
409,
|
|
232
|
+
true,
|
|
233
|
+
);
|
|
234
|
+
const previous = await readJson(paths.release, releaseSchema);
|
|
235
|
+
// Server paths are relative to the releases directory.
|
|
236
|
+
const serverPath = release.manifest.server && `${release.id}/${release.manifest.server}`;
|
|
237
|
+
if (serverPath) {
|
|
238
|
+
emit({ kind: "progress", message: "Starting the new server" });
|
|
239
|
+
await server.replace(join(paths.releases, serverPath));
|
|
240
|
+
}
|
|
241
|
+
const kind = settings.download ? "download" : "online-change";
|
|
242
|
+
emit({ kind: "progress", message: `Starting the ${kind}; CODESYS takes minutes to start` });
|
|
243
|
+
await ensureGateway();
|
|
244
|
+
const stopFollowing = followProgress(paths.controller, emit);
|
|
245
|
+
const receipt = await withCredentials(settings.credentials, () =>
|
|
246
|
+
updateController(config, kind, { initializeUser: settings.initializeUser }),
|
|
247
|
+
)
|
|
248
|
+
.catch(async (failure: unknown) => {
|
|
249
|
+
if (serverPath) {
|
|
250
|
+
emit({ kind: "progress", message: "Restoring the previous server" });
|
|
251
|
+
await (previous?.server
|
|
252
|
+
? server.replace(join(paths.releases, previous.server))
|
|
253
|
+
: server.stop());
|
|
254
|
+
}
|
|
255
|
+
throw failure;
|
|
256
|
+
})
|
|
257
|
+
.finally(stopFollowing);
|
|
258
|
+
const deployedRelease: Release = {
|
|
259
|
+
id: release.id,
|
|
260
|
+
deployedAt: new Date().toISOString(),
|
|
261
|
+
operation: kind,
|
|
262
|
+
controllerIds: receipt.controllerIds,
|
|
263
|
+
// A release without a server leaves the previous one running.
|
|
264
|
+
server: serverPath ?? previous?.server,
|
|
265
|
+
};
|
|
266
|
+
await writeFile(paths.release, JSON.stringify(deployedRelease, null, 2));
|
|
267
|
+
return deployedRelease;
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const openLogin = async (credentials: Credentials) => {
|
|
272
|
+
const release = await currentRelease();
|
|
273
|
+
const config = controllerConfig(release, await requireTarget());
|
|
274
|
+
return operation("a login", async (emit) => {
|
|
275
|
+
if (await findControllerLogin(loginContainer))
|
|
276
|
+
throw new AgentError("A login is already open", 409);
|
|
277
|
+
emit({ kind: "progress", message: "Logging in; CODESYS takes minutes to start" });
|
|
278
|
+
await ensureGateway();
|
|
279
|
+
const login = await withCredentials(credentials, () =>
|
|
280
|
+
openControllerLogin(config, loginContainer),
|
|
281
|
+
);
|
|
282
|
+
return login.send({ command: "state" });
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const requireLogin = async () => {
|
|
287
|
+
const login = await findControllerLogin(loginContainer);
|
|
288
|
+
if (!login) throw new AgentError("No login is open: tsplc login", 409);
|
|
289
|
+
return login;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const status = async (): Promise<AgentStatus> => {
|
|
293
|
+
const login = await findControllerLogin(loginContainer);
|
|
294
|
+
// A login still opening has its container but doesn't answer yet.
|
|
295
|
+
const state = login && (await login.ready()) && (await login.send({ command: "state" }));
|
|
296
|
+
return {
|
|
297
|
+
target: await readJson(paths.target, targetSchema),
|
|
298
|
+
release: await readJson(paths.release, releaseSchema),
|
|
299
|
+
server: server.status() ?? null,
|
|
300
|
+
login: state && "state" in state ? state : null,
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const route = async (request: Request): Promise<Response> => {
|
|
305
|
+
const { pathname } = new URL(request.url);
|
|
306
|
+
const key = `${request.method} ${pathname}`;
|
|
307
|
+
switch (key) {
|
|
308
|
+
case "GET /status":
|
|
309
|
+
return json(await status());
|
|
310
|
+
case "GET /interfaces":
|
|
311
|
+
return json(networkInterfaces());
|
|
312
|
+
case "POST /discover": {
|
|
313
|
+
const { name } = z.strictObject({ name: z.string() }).parse(await request.json());
|
|
314
|
+
const selected = networkInterfaces().find((network) => network.name === name);
|
|
315
|
+
if (!selected) throw new AgentError(`No IPv4 interface named ${name}`, 404);
|
|
316
|
+
return json(await discoverControllers(selected));
|
|
317
|
+
}
|
|
318
|
+
case "PUT /target": {
|
|
319
|
+
const target = targetSchema.parse(await request.json());
|
|
320
|
+
await writeFile(paths.target, JSON.stringify(target, null, 2));
|
|
321
|
+
return json(target);
|
|
322
|
+
}
|
|
323
|
+
case "GET /variables":
|
|
324
|
+
return json((await currentRelease()).manifest.variables);
|
|
325
|
+
case "POST /deploy":
|
|
326
|
+
return deploy(await request.formData());
|
|
327
|
+
case "POST /login":
|
|
328
|
+
return openLogin(credentialsSchema.parse(await request.json()));
|
|
329
|
+
case "POST /login/command":
|
|
330
|
+
return json(
|
|
331
|
+
await (await requireLogin()).send(loginCommandSchema.parse(await request.json())),
|
|
332
|
+
);
|
|
333
|
+
case "DELETE /login":
|
|
334
|
+
await (await requireLogin()).close();
|
|
335
|
+
return json({});
|
|
336
|
+
default:
|
|
337
|
+
return json({ error: `No route ${key}` }, 404);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const expected = Buffer.from(`Bearer ${options.token}`);
|
|
342
|
+
return {
|
|
343
|
+
fetch: async (request: Request) => {
|
|
344
|
+
const actual = Buffer.from(request.headers.get("authorization") ?? "");
|
|
345
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
346
|
+
return json({ error: "Unauthorized" }, 401);
|
|
347
|
+
try {
|
|
348
|
+
return await route(request);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error instanceof AgentError) return json({ error: error.message }, error.status);
|
|
351
|
+
if (error instanceof z.ZodError) return json({ error: z.prettifyError(error) }, 400);
|
|
352
|
+
options.log(
|
|
353
|
+
`${request.method} ${request.url} failed: ${error instanceof Error ? error.stack : String(error)}`,
|
|
354
|
+
);
|
|
355
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
/** Stops the supervised server; a login container keeps running for the next agent. */
|
|
359
|
+
stop: () => server.stop(),
|
|
360
|
+
};
|
|
361
|
+
};
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { configureDocker, configureGateway } from "@tsplc/build";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createAgent } from "./agent.ts";
|
|
8
|
+
|
|
9
|
+
const usage = `tsplc-agent setup|serve [--listen host:port] [--state directory] [--server-health url]
|
|
10
|
+
|
|
11
|
+
setup Builds the CODESYS builder and gateway images. Run it once, and after updating tsPLC.
|
|
12
|
+
serve Serves deployments and logins. --listen is required: use the machine's Tailscale
|
|
13
|
+
address, such as 100.101.102.103:4850. TSPLC_AGENT_TOKEN must hold a shared secret of
|
|
14
|
+
at least 32 characters, such as one from \`openssl rand -hex 32\`.`;
|
|
15
|
+
|
|
16
|
+
const { values, positionals } = parseArgs({
|
|
17
|
+
allowPositionals: true,
|
|
18
|
+
options: {
|
|
19
|
+
help: { type: "boolean", short: "h" },
|
|
20
|
+
listen: { type: "string" },
|
|
21
|
+
state: { type: "string" },
|
|
22
|
+
"server-health": { type: "string" },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
const command = positionals[0];
|
|
26
|
+
if (!command || values.help) {
|
|
27
|
+
console.log(usage);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
if (positionals.length > 1) throw new Error("Specify one command.");
|
|
31
|
+
if (command === "setup") {
|
|
32
|
+
await configureDocker();
|
|
33
|
+
await configureGateway();
|
|
34
|
+
} else if (command === "serve") {
|
|
35
|
+
const [, hostname, port] = /^(.+):(\d+)$/.exec(values.listen ?? "") ?? [];
|
|
36
|
+
if (!hostname || !port) throw new Error("--listen host:port is required.");
|
|
37
|
+
const token = z
|
|
38
|
+
.string({ error: "Set TSPLC_AGENT_TOKEN" })
|
|
39
|
+
.min(32, "TSPLC_AGENT_TOKEN must be at least 32 characters")
|
|
40
|
+
.parse(process.env["TSPLC_AGENT_TOKEN"]);
|
|
41
|
+
const agent = await createAgent({
|
|
42
|
+
state: values.state ?? join(homedir(), ".tsplc-agent"),
|
|
43
|
+
token,
|
|
44
|
+
serverHealthUrl: values["server-health"],
|
|
45
|
+
log: (message) => console.log(`${new Date().toISOString()} ${message}`),
|
|
46
|
+
});
|
|
47
|
+
const server = Bun.serve({
|
|
48
|
+
hostname,
|
|
49
|
+
port: Number(port),
|
|
50
|
+
fetch: agent.fetch,
|
|
51
|
+
// Deployments stream for minutes and upload a server executable.
|
|
52
|
+
idleTimeout: 0,
|
|
53
|
+
maxRequestBodySize: 2 ** 30,
|
|
54
|
+
});
|
|
55
|
+
console.log(`tsPLC agent listening on ${server.url}`);
|
|
56
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const)
|
|
57
|
+
process.on(signal, () => {
|
|
58
|
+
void agent.stop().then(() => process.exit(0));
|
|
59
|
+
});
|
|
60
|
+
} else throw new Error(`Unknown command ${command}.`);
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type LoginCommand, loginResultSchema } from "@tsplc/build";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
type AgentEvent,
|
|
5
|
+
controllerSchema,
|
|
6
|
+
type Credentials,
|
|
7
|
+
type DeployOptions,
|
|
8
|
+
eventSchema,
|
|
9
|
+
networkInterfaceSchema,
|
|
10
|
+
type ReleaseManifest,
|
|
11
|
+
releaseSchema,
|
|
12
|
+
statusSchema,
|
|
13
|
+
type Target,
|
|
14
|
+
targetSchema,
|
|
15
|
+
variableSchema,
|
|
16
|
+
} from "./protocol";
|
|
17
|
+
|
|
18
|
+
/** A failed agent operation; `downloadRequired` means only a full download can deploy. */
|
|
19
|
+
export class AgentOperationError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
readonly downloadRequired = false,
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A file to upload, at its path in the release. */
|
|
29
|
+
export interface ReleaseFile {
|
|
30
|
+
path: string;
|
|
31
|
+
content: Blob;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Talks to a plant agent at `url`, such as its Tailscale name and port. */
|
|
35
|
+
export const createAgentClient = (options: { url: string; token: string }) => {
|
|
36
|
+
const call = async (path: string, init: RequestInit = {}) => {
|
|
37
|
+
const response = await fetch(new URL(path, options.url), {
|
|
38
|
+
...init,
|
|
39
|
+
headers: { ...init.headers, Authorization: `Bearer ${options.token}` },
|
|
40
|
+
}).catch((cause: unknown) => {
|
|
41
|
+
throw new Error(`Cannot reach the plant agent at ${options.url}`, { cause });
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const body = await response.json().catch(() => ({}));
|
|
45
|
+
throw new AgentOperationError(
|
|
46
|
+
z.object({ error: z.string() }).safeParse(body).data?.error ??
|
|
47
|
+
`The agent answered ${response.status}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return response;
|
|
51
|
+
};
|
|
52
|
+
const get = async <T>(path: string, schema: z.ZodType<T>) =>
|
|
53
|
+
schema.parse(await (await call(path)).json());
|
|
54
|
+
const send = async <T>(method: string, path: string, body: unknown, schema: z.ZodType<T>) =>
|
|
55
|
+
schema.parse(
|
|
56
|
+
await (
|
|
57
|
+
await call(path, {
|
|
58
|
+
method,
|
|
59
|
+
headers: { "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
})
|
|
62
|
+
).json(),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** Reads a streamed operation, reporting progress, until its result or failure. */
|
|
66
|
+
const operation = async (response: Response, onProgress: (message: string) => void) => {
|
|
67
|
+
const reader = response.body?.pipeThrough(new TextDecoderStream()).getReader();
|
|
68
|
+
if (!reader) throw new Error("The agent returned no operation stream");
|
|
69
|
+
let buffered = "";
|
|
70
|
+
while (true) {
|
|
71
|
+
const { done, value } = await reader.read();
|
|
72
|
+
if (done) throw new Error("The agent closed the operation without a result");
|
|
73
|
+
buffered += value;
|
|
74
|
+
const lines = buffered.split("\n");
|
|
75
|
+
buffered = lines.pop() ?? "";
|
|
76
|
+
for (const line of lines.filter(Boolean)) {
|
|
77
|
+
const event: AgentEvent = eventSchema.parse(JSON.parse(line));
|
|
78
|
+
if (event.kind === "progress") onProgress(event.message);
|
|
79
|
+
else if (event.kind === "done") return event.result;
|
|
80
|
+
else throw new AgentOperationError(event.message, event.downloadRequired);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
status: () => get("/status", statusSchema),
|
|
87
|
+
interfaces: () => get("/interfaces", z.array(networkInterfaceSchema)),
|
|
88
|
+
discover: (name: string) => send("POST", "/discover", { name }, z.array(controllerSchema)),
|
|
89
|
+
setTarget: (target: Target) => send("PUT", "/target", target, targetSchema),
|
|
90
|
+
variables: () => get("/variables", z.array(variableSchema)),
|
|
91
|
+
deploy: async (
|
|
92
|
+
release: { manifest: ReleaseManifest; files: ReleaseFile[] },
|
|
93
|
+
settings: DeployOptions,
|
|
94
|
+
onProgress: (message: string) => void,
|
|
95
|
+
) => {
|
|
96
|
+
const form = new FormData();
|
|
97
|
+
form.set("manifest", JSON.stringify(release.manifest));
|
|
98
|
+
form.set("options", JSON.stringify(settings));
|
|
99
|
+
for (const file of release.files) form.set(`files/${file.path}`, file.content, file.path);
|
|
100
|
+
return releaseSchema.parse(
|
|
101
|
+
await operation(await call("/deploy", { method: "POST", body: form }), onProgress),
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
login: async (credentials: Credentials, onProgress: (message: string) => void) =>
|
|
105
|
+
loginResultSchema.parse(
|
|
106
|
+
await operation(
|
|
107
|
+
await call("/login", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify(credentials),
|
|
111
|
+
}),
|
|
112
|
+
onProgress,
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
command: (command: LoginCommand) => send("POST", "/login/command", command, loginResultSchema),
|
|
116
|
+
logout: () => send("DELETE", "/login", undefined, z.object({})),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
export type AgentClient = ReturnType<typeof createAgentClient>;
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Resolver } from "node:dns/promises";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { networkInterfaces as osInterfaces } from "node:os";
|
|
4
|
+
import type { DiscoveredController, NetworkInterface } from "./protocol";
|
|
5
|
+
|
|
6
|
+
/** CODESYS runtimes accept engineering connections on their TCP block driver port. */
|
|
7
|
+
export const runtimePort = 11740;
|
|
8
|
+
/** A /22 is 1022 hosts: a sweep at 128 probes a time takes a few seconds. */
|
|
9
|
+
const largestPrefix = 22;
|
|
10
|
+
const probeTimeoutMs = 500;
|
|
11
|
+
const probesAtOnce = 128;
|
|
12
|
+
|
|
13
|
+
/** The machine's IPv4 networks, which `discoverControllers` scans one at a time. */
|
|
14
|
+
export const networkInterfaces = (): NetworkInterface[] =>
|
|
15
|
+
Object.entries(osInterfaces()).flatMap(([name, addresses]) =>
|
|
16
|
+
(addresses ?? [])
|
|
17
|
+
.filter((address) => address.family === "IPv4" && !address.internal)
|
|
18
|
+
.map((address) => ({
|
|
19
|
+
name,
|
|
20
|
+
address: address.address,
|
|
21
|
+
prefixLength: address.netmask
|
|
22
|
+
.split(".")
|
|
23
|
+
.reduce((bits, octet) => bits + Number(octet).toString(2).replaceAll("0", "").length, 0),
|
|
24
|
+
})),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const toNumber = (address: string) =>
|
|
28
|
+
address.split(".").reduce((value, octet) => value * 256 + Number(octet), 0);
|
|
29
|
+
const toAddress = (value: number) =>
|
|
30
|
+
[24, 16, 8, 0].map((shift) => Math.floor(value / 2 ** shift) % 256).join(".");
|
|
31
|
+
|
|
32
|
+
/** Every host address on the interface's subnet, except the interface's own. */
|
|
33
|
+
export const subnetHosts = ({ address, prefixLength }: NetworkInterface) => {
|
|
34
|
+
if (prefixLength < largestPrefix)
|
|
35
|
+
throw new Error(
|
|
36
|
+
`The /${prefixLength} network is too large to scan; the limit is /${largestPrefix}.`,
|
|
37
|
+
);
|
|
38
|
+
const size = 2 ** (32 - prefixLength);
|
|
39
|
+
const network = toNumber(address) - (toNumber(address) % size);
|
|
40
|
+
// /31 and /32 networks have no network or broadcast address to skip.
|
|
41
|
+
const hosts =
|
|
42
|
+
size <= 2
|
|
43
|
+
? Array.from({ length: size }, (_, index) => network + index)
|
|
44
|
+
: Array.from({ length: size - 2 }, (_, index) => network + index + 1);
|
|
45
|
+
return hosts.map(toAddress).filter((host) => host !== address);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const accepts = (host: string, port: number) =>
|
|
49
|
+
new Promise<boolean>((resolve) => {
|
|
50
|
+
const socket = createConnection({ host, port });
|
|
51
|
+
// A silent host leaves the connection pending for the OS's own timeout, which is minutes.
|
|
52
|
+
const timer = setTimeout(() => finish(false), probeTimeoutMs);
|
|
53
|
+
const finish = (open: boolean) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
socket.destroy();
|
|
56
|
+
resolve(open);
|
|
57
|
+
};
|
|
58
|
+
socket.once("connect", () => finish(true));
|
|
59
|
+
socket.once("error", () => finish(false));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const resolver = new Resolver({ timeout: 1000, tries: 1 });
|
|
63
|
+
const hostname = (address: string) =>
|
|
64
|
+
resolver.reverse(address).then(
|
|
65
|
+
(names) => names[0] ?? null,
|
|
66
|
+
// Plant networks rarely have reverse DNS; the address alone identifies the controller.
|
|
67
|
+
() => null,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
/** Finds the hosts on `network` that accept CODESYS runtime connections. */
|
|
71
|
+
export const discoverControllers = async (
|
|
72
|
+
network: NetworkInterface,
|
|
73
|
+
port = runtimePort,
|
|
74
|
+
): Promise<DiscoveredController[]> => {
|
|
75
|
+
const hosts = subnetHosts(network);
|
|
76
|
+
const found: DiscoveredController[] = [];
|
|
77
|
+
for (let start = 0; start < hosts.length; start += probesAtOnce) {
|
|
78
|
+
const batch = hosts.slice(start, start + probesAtOnce);
|
|
79
|
+
const open = await Promise.all(batch.map((host) => accepts(host, port)));
|
|
80
|
+
for (const [index, host] of batch.entries())
|
|
81
|
+
if (open[index]) found.push({ address: host, port, hostname: await hostname(host) });
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
};
|
package/src/index.ts
ADDED