@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.
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+
3
+ /** The controller the agent deploys to, as its runtime's TCP block driver address. */
4
+ export const targetSchema = z.strictObject({
5
+ host: z.string().nonempty(),
6
+ port: z.number().int().min(1).max(65535),
7
+ });
8
+ export type Target = z.infer<typeof targetSchema>;
9
+
10
+ export const networkInterfaceSchema = z.strictObject({
11
+ name: z.string(),
12
+ address: z.ipv4(),
13
+ prefixLength: z.number().int().min(0).max(32),
14
+ });
15
+ export type NetworkInterface = z.infer<typeof networkInterfaceSchema>;
16
+
17
+ /** A host on the scanned subnet that accepts connections on the CODESYS runtime port. */
18
+ export const controllerSchema = z.strictObject({
19
+ address: z.ipv4(),
20
+ port: z.number().int(),
21
+ hostname: z.string().nullable(),
22
+ });
23
+ export type DiscoveredController = z.infer<typeof controllerSchema>;
24
+
25
+ /** The controller's user; the agent passes it to CODESYS for one operation and never stores it. */
26
+ export const credentialsSchema = z.strictObject({
27
+ username: z.string().nonempty(),
28
+ password: z.string().nonempty(),
29
+ });
30
+ export type Credentials = z.infer<typeof credentialsSchema>;
31
+
32
+ /** A published leaf of the deployed program, as CODESYS expressions name it. */
33
+ export const variableSchema = z.strictObject({ path: z.string(), type: z.string() });
34
+ export type Variable = z.infer<typeof variableSchema>;
35
+
36
+ /**
37
+ * What `tsplc deploy` uploads beside its files. `config` is the build config with paths
38
+ * relative to the release: `st/`, `template.project` and `libraries/`.
39
+ */
40
+ export const releaseManifestSchema = z.strictObject({
41
+ config: z.record(z.string(), z.unknown()),
42
+ variables: z.array(variableSchema),
43
+ /** The server executable's path in the release, when the project builds one. */
44
+ server: z.string().nonempty().optional(),
45
+ });
46
+ export type ReleaseManifest = z.infer<typeof releaseManifestSchema>;
47
+
48
+ export const deployOptionsSchema = z.strictObject({
49
+ credentials: credentialsSchema,
50
+ /** A full download stops the application; without it only an online change is applied. */
51
+ download: z.boolean(),
52
+ initializeUser: z.boolean(),
53
+ });
54
+ export type DeployOptions = z.infer<typeof deployOptionsSchema>;
55
+
56
+ /** Long operations stream one of these per line. */
57
+ export const eventSchema = z.discriminatedUnion("kind", [
58
+ z.strictObject({ kind: z.literal("progress"), message: z.string() }),
59
+ z.strictObject({ kind: z.literal("done"), result: z.unknown() }),
60
+ z.strictObject({
61
+ kind: z.literal("error"),
62
+ message: z.string(),
63
+ /** The controller has no application from this agent yet, so only a full download can deploy. */
64
+ downloadRequired: z.boolean(),
65
+ }),
66
+ ]);
67
+ export type AgentEvent = z.infer<typeof eventSchema>;
68
+
69
+ export const releaseSchema = z.strictObject({
70
+ id: z.string(),
71
+ deployedAt: z.iso.datetime(),
72
+ operation: z.enum(["download", "online-change"]),
73
+ controllerIds: z.strictObject({ codeId: z.string(), dataId: z.string() }),
74
+ /** The running server executable, relative to the releases directory. */
75
+ server: z.string().optional(),
76
+ });
77
+ export type Release = z.infer<typeof releaseSchema>;
78
+
79
+ export const statusSchema = z.strictObject({
80
+ target: targetSchema.nullable(),
81
+ release: releaseSchema.nullable(),
82
+ server: z.strictObject({ executable: z.string(), running: z.boolean() }).nullable(),
83
+ /** The open login's application state and forced expressions. */
84
+ login: z.strictObject({ state: z.string(), forced: z.array(z.string()) }).nullable(),
85
+ });
86
+ export type AgentStatus = z.infer<typeof statusSchema>;
@@ -0,0 +1,77 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join, posix } from "node:path";
5
+ import { z } from "zod";
6
+ import { type ReleaseManifest, releaseManifestSchema } from "./protocol";
7
+
8
+ /** Uploaded files are named `files/<path in the release>`. */
9
+ const filePrefix = "files/";
10
+
11
+ const releasePath = (name: string) => {
12
+ const path = posix.normalize(name.slice(filePrefix.length));
13
+ if (posix.isAbsolute(path) || path.startsWith("..") || path.includes("\\"))
14
+ throw new Error(`Invalid release file name: ${name}`);
15
+ return path;
16
+ };
17
+
18
+ /** An uploaded release, from `tsplc deploy`'s form. */
19
+ export const readUpload = async (form: FormData) => {
20
+ const manifest = releaseManifestSchema.parse(JSON.parse(z.string().parse(form.get("manifest"))));
21
+ const files = await Promise.all(
22
+ [...form.entries()]
23
+ .filter(([name]) => name.startsWith(filePrefix))
24
+ .map(async ([name, value]) => ({
25
+ path: releasePath(name),
26
+ content: new Uint8Array(await z.instanceof(File).parse(value).arrayBuffer()),
27
+ })),
28
+ );
29
+ if (manifest.server && !files.some((file) => file.path === manifest.server))
30
+ throw new Error(`The release names a server ${manifest.server} it doesn't contain`);
31
+ return { manifest, files };
32
+ };
33
+
34
+ /**
35
+ * Stores a release under `releases/<id>`, where the id hashes its contents, so the same
36
+ * upload twice is one release. Each release is complete before it appears.
37
+ */
38
+ export const saveRelease = async (
39
+ releases: string,
40
+ { manifest, files }: Awaited<ReturnType<typeof readUpload>>,
41
+ ) => {
42
+ const hash = createHash("sha256").update(JSON.stringify(manifest));
43
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path)))
44
+ hash.update(file.path).update(createHash("sha256").update(file.content).digest("hex"));
45
+ const id = hash.digest("hex").slice(0, 16);
46
+ const directory = join(releases, id);
47
+ if (!existsSync(directory)) {
48
+ await mkdir(releases, { recursive: true });
49
+ const staging = await mkdtemp(join(releases, ".upload-"));
50
+ try {
51
+ for (const file of files) {
52
+ await mkdir(dirname(join(staging, file.path)), { recursive: true });
53
+ // Uploads lose their mode; the server must stay executable outside Windows.
54
+ await writeFile(join(staging, file.path), file.content, {
55
+ mode: file.path === manifest.server ? 0o755 : 0o644,
56
+ });
57
+ }
58
+ await writeFile(join(staging, "manifest.json"), JSON.stringify(manifest, null, 2));
59
+ await rename(staging, directory);
60
+ } catch (cause) {
61
+ await rm(staging, { recursive: true, force: true });
62
+ throw cause;
63
+ }
64
+ }
65
+ return { id, directory, manifest };
66
+ };
67
+
68
+ export const loadRelease = async (
69
+ releases: string,
70
+ id: string,
71
+ ): Promise<{ id: string; directory: string; manifest: ReleaseManifest }> => {
72
+ const directory = join(releases, id);
73
+ const manifest = releaseManifestSchema.parse(
74
+ JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")),
75
+ );
76
+ return { id, directory, manifest };
77
+ };
@@ -0,0 +1,96 @@
1
+ import type { Subprocess } from "bun";
2
+ import { dirname } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+
5
+ const healthyWithinMs = 60_000;
6
+ /** Without a health URL, a server that stays up this long counts as started. */
7
+ const settleMs = 5000;
8
+ const restartDelayMs = 2000;
9
+
10
+ /**
11
+ * Runs the deployed server executable, restarting it whenever it exits, and swaps it for
12
+ * another release's. The server reads its own environment and secrets, as it would anywhere.
13
+ */
14
+ export const createServerSupervisor = (options: {
15
+ /** The server's `/health` route, which answers once it is listening. */
16
+ healthUrl?: string;
17
+ log: (message: string) => void;
18
+ }) => {
19
+ let current: { executable: string; process: Subprocess } | undefined;
20
+
21
+ const spawn = (executable: string): Subprocess => {
22
+ const child = Bun.spawn([executable], {
23
+ cwd: dirname(executable),
24
+ stdio: ["ignore", "inherit", "inherit"],
25
+ onExit: (_, code) => {
26
+ // `stop` and `replace` clear or change `current` before killing a server.
27
+ if (current?.process !== child) return;
28
+ options.log(`Server exited (${code}); restarting it in ${restartDelayMs / 1000} s`);
29
+ void delay(restartDelayMs).then(() => {
30
+ if (current?.process === child) current = { executable, process: spawn(executable) };
31
+ });
32
+ },
33
+ });
34
+ return child;
35
+ };
36
+
37
+ const start = (executable: string) => {
38
+ current = { executable, process: spawn(executable) };
39
+ return current.process;
40
+ };
41
+
42
+ const stop = async () => {
43
+ const running = current;
44
+ current = undefined;
45
+ if (!running) return;
46
+ running.process.kill();
47
+ await running.process.exited;
48
+ };
49
+
50
+ const healthy = async (child: Subprocess) => {
51
+ if (!options.healthUrl) {
52
+ await delay(settleMs);
53
+ return child.exitCode === null;
54
+ }
55
+ const deadline = Date.now() + healthyWithinMs;
56
+ while (Date.now() < deadline && child.exitCode === null) {
57
+ if (
58
+ await fetch(options.healthUrl).then(
59
+ (response) => response.ok,
60
+ () => false,
61
+ )
62
+ )
63
+ return true;
64
+ await delay(1000);
65
+ }
66
+ return false;
67
+ };
68
+
69
+ return {
70
+ start,
71
+ stop,
72
+ /** Replaces the running server; if the new one doesn't come up, the previous one runs again. */
73
+ replace: async (executable: string) => {
74
+ const previous = current?.executable;
75
+ await stop();
76
+ let failure: unknown;
77
+ const started = await (async () => healthy(start(executable)))().catch((error: unknown) => {
78
+ failure = error;
79
+ return false;
80
+ });
81
+ if (started) return;
82
+ await stop();
83
+ if (previous) start(previous);
84
+ const reason =
85
+ failure instanceof Error
86
+ ? failure.message
87
+ : `it did not start${options.healthUrl ? ` answering ${options.healthUrl}` : ""}`;
88
+ throw new Error(
89
+ `The new server failed: ${reason}. ${previous ? "The previous one is running again." : ""}`.trim(),
90
+ { cause: failure },
91
+ );
92
+ },
93
+ status: () =>
94
+ current && { executable: current.executable, running: current.process.exitCode === null },
95
+ };
96
+ };