@tsplc/build 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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/cli.js +1696 -0
- package/dist/cli.js.map +34 -0
- package/dist/index.js +1683 -0
- package/dist/index.js.map +31 -0
- package/dist/libraries.js +128 -0
- package/dist/libraries.js.map +10 -0
- package/docker/Dockerfile +173 -0
- package/docker/Dockerfile.dockerignore +18 -0
- package/docker/Dockerfile.source +15 -0
- package/docker/build-image +38 -0
- package/docker/codesys-build +5 -0
- package/docker/config.ts +8 -0
- package/docker/control-linux-sl/Dockerfile +38 -0
- package/docker/edge-gateway/Dockerfile +17 -0
- package/docker/entrypoint.sh +10 -0
- package/docker/install/NOTICE.md +21 -0
- package/docker/install/core.sh +36 -0
- package/docker/install/decode.c +12 -0
- package/docker/install/device/Runtime.ico +0 -0
- package/docker/install/device/Runtime.png +0 -0
- package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
- package/docker/install/extract.py +43 -0
- package/docker/install/helper-launcher.c +46 -0
- package/docker/install/install.sh +94 -0
- package/docker/install/prerequisites.sh +15 -0
- package/docker/install/verify.py +133 -0
- package/docker/install/verify.sh +34 -0
- package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
- package/docker/install/wine-ncrypt/README.md +28 -0
- package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
- package/docker/install/wine-ncrypt/main.c +619 -0
- package/docker/install/wine-ncrypt/oaep.patch +11 -0
- package/docker/install/wine-signal/README.md +22 -0
- package/docker/install/wine-signal/build.sh +10 -0
- package/docker/install/wine-signal/check.sh +34 -0
- package/docker/install/wine-signal/noop-i386.c +2 -0
- package/docker/install/wine-signal/signal-state.c +105 -0
- package/docker/install/wine-signal/tests/avx-registers.c +53 -0
- package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
- package/docker/install/wine-signal/tests/loader-i386.c +8 -0
- package/docker/install/wine-signal/tests/signal-return.c +47 -0
- package/docker/install/wine-signal/wine +4 -0
- package/docker/runner.ts +156 -0
- package/package.json +53 -4
- package/python/build.py +660 -0
- package/python/codesys_login.py +100 -0
- package/python/codesys_online.py +327 -0
- package/python/test_build.py +444 -0
- package/python/test_codesys_login.py +120 -0
- package/python/test_codesys_online.py +184 -0
- package/python/test_installer_packages.py +216 -0
- package/python/test_installer_verification.py +307 -0
- package/python/test_worker.py +142 -0
- package/python/worker.py +102 -0
- package/runtime/install.sh +74 -0
- package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
- package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
- package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
- package/src/artifacts.ts +77 -0
- package/src/build.ts +87 -0
- package/src/cli.ts +116 -0
- package/src/config.ts +12 -0
- package/src/controller-online.ts +297 -0
- package/src/controller-session.ts +298 -0
- package/src/diagnostics.ts +68 -0
- package/src/docker-build.ts +163 -0
- package/src/docker-context.ts +35 -0
- package/src/docker-run.ts +103 -0
- package/src/host/codesys-command.ts +45 -0
- package/src/host/codesys-host.ts +145 -0
- package/src/host/codesys-worker.ts +348 -0
- package/src/host/windows-path.ts +73 -0
- package/src/index.ts +43 -0
- package/src/io.ts +127 -0
- package/src/libraries.ts +166 -0
- package/src/package-path.ts +5 -0
- package/src/run.ts +133 -0
- package/src/runtime-status.ts +21 -0
- package/src/sources.ts +248 -0
- package/src/st/declarations.ts +229 -0
- package/src/st/lexer.ts +125 -0
- package/src/st/parse.ts +202 -0
- package/src/st/reader.ts +50 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { controllerSchema, dockerImage, type ResolvedConfig } from "./config";
|
|
11
|
+
import { controllerFailureFile } from "./diagnostics.ts";
|
|
12
|
+
import { prepareContainerRun, runDocker } from "./docker-run.ts";
|
|
13
|
+
|
|
14
|
+
const execute = promisify(execFile);
|
|
15
|
+
const imageIdSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
16
|
+
const endpointSchema = z.strictObject({
|
|
17
|
+
host: z.string().nonempty(),
|
|
18
|
+
port: z.number().int().min(1).max(65535),
|
|
19
|
+
});
|
|
20
|
+
export const controllerResultSchema = z.strictObject({
|
|
21
|
+
schemaVersion: z.literal(1),
|
|
22
|
+
operationId: z.uuid(),
|
|
23
|
+
operation: z.enum(["download", "online-change", "inspect"]),
|
|
24
|
+
imageId: imageIdSchema,
|
|
25
|
+
templateHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
26
|
+
target: z.strictObject({
|
|
27
|
+
network: z.string().nonempty(),
|
|
28
|
+
gateway: endpointSchema,
|
|
29
|
+
device: endpointSchema,
|
|
30
|
+
controller: controllerSchema.optional(),
|
|
31
|
+
}),
|
|
32
|
+
generation: z.string().nonempty(),
|
|
33
|
+
application: z.string().nonempty(),
|
|
34
|
+
applicationGuid: z.string().nonempty(),
|
|
35
|
+
controllerIds: z.strictObject({
|
|
36
|
+
codeId: z.string().nonempty(),
|
|
37
|
+
dataId: z.string().nonempty(),
|
|
38
|
+
}),
|
|
39
|
+
state: z.string().nonempty(),
|
|
40
|
+
values: z.record(z.string(), z.string()),
|
|
41
|
+
});
|
|
42
|
+
export type ControllerResult = z.infer<typeof controllerResultSchema>;
|
|
43
|
+
export type ControllerUpdateOptions = {
|
|
44
|
+
initializeUser?: boolean;
|
|
45
|
+
read?: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Checks the settings every engineering connection needs and names its builder image and target. */
|
|
49
|
+
const controllerConnection = async (config: ResolvedConfig) => {
|
|
50
|
+
if (!config.docker || !config.online) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"Docker image and online gateway/device configuration are required for controller operations",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const environmentNames = config.online.credentials
|
|
56
|
+
? [config.online.credentials.usernameEnv, config.online.credentials.passwordEnv]
|
|
57
|
+
: [];
|
|
58
|
+
for (const name of environmentNames) {
|
|
59
|
+
if (process.env[name] === undefined) {
|
|
60
|
+
throw new Error(`Controller credential environment variable ${name} is not set`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const { stdout } = await execute(
|
|
64
|
+
"docker",
|
|
65
|
+
["image", "inspect", "--format", "{{.Id}}", dockerImage],
|
|
66
|
+
{ timeout: config.timeouts.hostCommand },
|
|
67
|
+
).catch((cause: unknown) => {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Cannot inspect Docker builder image ${dockerImage}. Install and start Docker, then run tsplc-build configure if the image is missing`,
|
|
70
|
+
{ cause },
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
environmentNames,
|
|
75
|
+
imageId: imageIdSchema.parse(stdout.trim()),
|
|
76
|
+
templateHash: createHash("sha256")
|
|
77
|
+
.update(await readFile(config.template))
|
|
78
|
+
.digest("hex"),
|
|
79
|
+
target: {
|
|
80
|
+
network: config.online.network,
|
|
81
|
+
gateway: config.online.gateway,
|
|
82
|
+
device: config.online.device,
|
|
83
|
+
controller: config.controller,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const controllerOperation = async (
|
|
89
|
+
config: ResolvedConfig,
|
|
90
|
+
operation: ControllerResult["operation"],
|
|
91
|
+
options: ControllerUpdateOptions,
|
|
92
|
+
): Promise<ControllerResult> => {
|
|
93
|
+
const { environmentNames, imageId, templateHash, target } = await controllerConnection(config);
|
|
94
|
+
const operationId = randomUUID();
|
|
95
|
+
const requestDirectory = await mkdtemp(resolve(tmpdir(), "codesys-controller-"));
|
|
96
|
+
const diagnostics = resolve(config.outDir, ".codesys/controller");
|
|
97
|
+
const failure = resolve(config.outDir, controllerFailureFile);
|
|
98
|
+
try {
|
|
99
|
+
await mkdir(config.outDir, { recursive: true });
|
|
100
|
+
await rm(failure, { force: true });
|
|
101
|
+
await runDocker(
|
|
102
|
+
[
|
|
103
|
+
"run",
|
|
104
|
+
"--rm",
|
|
105
|
+
...(await prepareContainerRun(config, requestDirectory, {
|
|
106
|
+
network: target.network,
|
|
107
|
+
environment: environmentNames,
|
|
108
|
+
sources: operation !== "inspect",
|
|
109
|
+
})),
|
|
110
|
+
imageId,
|
|
111
|
+
operation,
|
|
112
|
+
"--request",
|
|
113
|
+
"/input/config.json",
|
|
114
|
+
"--operation-id",
|
|
115
|
+
operationId,
|
|
116
|
+
"--image-id",
|
|
117
|
+
imageId,
|
|
118
|
+
...(options.initializeUser ? ["--initialize-user"] : []),
|
|
119
|
+
...(options.read ? ["--read", JSON.stringify(options.read)] : []),
|
|
120
|
+
],
|
|
121
|
+
(status) =>
|
|
122
|
+
`Docker controller operation failed (${status}). Inspect ${diagnostics} for diagnostics`,
|
|
123
|
+
).catch(async (cause: unknown) => {
|
|
124
|
+
if (!existsSync(failure)) throw cause;
|
|
125
|
+
// A CODESYS failure arrives as a Python traceback, whose last line names it.
|
|
126
|
+
const reason = (await readFile(failure, "utf8")).trim().split("\n").at(-1);
|
|
127
|
+
throw new Error(`${reason}. Inspect ${diagnostics} for diagnostics`, { cause });
|
|
128
|
+
});
|
|
129
|
+
const result = await readFile(resolve(diagnostics, "result.json"), "utf8")
|
|
130
|
+
.then((content) => controllerResultSchema.parse(JSON.parse(content)))
|
|
131
|
+
.catch((cause: unknown) => {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Docker did not produce a valid controller receipt. Inspect ${diagnostics} for diagnostics`,
|
|
134
|
+
{ cause },
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
if (
|
|
138
|
+
result.operationId !== operationId ||
|
|
139
|
+
result.operation !== operation ||
|
|
140
|
+
result.imageId !== imageId ||
|
|
141
|
+
result.templateHash !== templateHash ||
|
|
142
|
+
JSON.stringify(result.target) !== JSON.stringify(target)
|
|
143
|
+
) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Controller receipt does not match this operation's image, template or target. Inspect ${diagnostics} for diagnostics`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
} finally {
|
|
150
|
+
await rm(requestDirectory, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Download or apply an online change using the original CODESYS Docker compiler. */
|
|
155
|
+
export const updateController = (
|
|
156
|
+
config: ResolvedConfig,
|
|
157
|
+
operation: "download" | "online-change",
|
|
158
|
+
options: ControllerUpdateOptions = {},
|
|
159
|
+
) => controllerOperation(config, operation, options);
|
|
160
|
+
|
|
161
|
+
/** Read the controller using the runner's saved compilation baseline. */
|
|
162
|
+
export const inspectController = (config: ResolvedConfig, read?: string[]) =>
|
|
163
|
+
controllerOperation(config, "inspect", { read });
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Ends every refusal that only a full download resolves, so callers can offer one. The Python
|
|
167
|
+
* online operations end theirs with the same words.
|
|
168
|
+
*/
|
|
169
|
+
export const explicitDownload = "use an explicit download";
|
|
170
|
+
|
|
171
|
+
export const loginCommandSchema = z.discriminatedUnion("command", [
|
|
172
|
+
z.strictObject({ command: z.literal("state") }),
|
|
173
|
+
z.strictObject({
|
|
174
|
+
command: z.literal("read"),
|
|
175
|
+
expressions: z.array(z.string().nonempty()).nonempty(),
|
|
176
|
+
}),
|
|
177
|
+
z.strictObject({
|
|
178
|
+
command: z.enum(["write", "force"]),
|
|
179
|
+
values: z.record(z.string().nonempty(), z.string().nonempty()),
|
|
180
|
+
}),
|
|
181
|
+
// No expressions unforces everything; `restore` puts back the value from before forcing.
|
|
182
|
+
z.strictObject({
|
|
183
|
+
command: z.literal("unforce"),
|
|
184
|
+
expressions: z.array(z.string().nonempty()),
|
|
185
|
+
restore: z.boolean(),
|
|
186
|
+
}),
|
|
187
|
+
z.strictObject({ command: z.enum(["start", "stop"]) }),
|
|
188
|
+
z.strictObject({ command: z.literal("reset"), kind: z.enum(["Warm", "Cold"]) }),
|
|
189
|
+
]);
|
|
190
|
+
export type LoginCommand = z.infer<typeof loginCommandSchema>;
|
|
191
|
+
export const loginResultSchema = z.union([
|
|
192
|
+
z.strictObject({ values: z.record(z.string(), z.string()) }),
|
|
193
|
+
z.strictObject({ state: z.string(), forced: z.array(z.string()) }),
|
|
194
|
+
]);
|
|
195
|
+
export type LoginResult = z.infer<typeof loginResultSchema>;
|
|
196
|
+
const loginReplySchema = z.discriminatedUnion("ok", [
|
|
197
|
+
z.strictObject({ ok: z.literal(true), result: z.unknown() }),
|
|
198
|
+
z.strictObject({ ok: z.literal(false), error: z.string() }),
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
/** The port a login container serves its commands on, published to the host's loopback only. */
|
|
202
|
+
const loginPort = 4851;
|
|
203
|
+
|
|
204
|
+
const docker = (args: string[], timeout: number) =>
|
|
205
|
+
execute("docker", args, { timeout }).then(({ stdout }) => stdout.trim());
|
|
206
|
+
|
|
207
|
+
const isRunning = async (name: string, timeout: number) =>
|
|
208
|
+
(await docker(["container", "ls", "--quiet", "--filter", `name=^${name}$`], timeout)) !== "";
|
|
209
|
+
|
|
210
|
+
/** A login held open in builder container `name`; it ends with `close` or when the container stops. */
|
|
211
|
+
const attachLogin = async (name: string, timeout: number) => {
|
|
212
|
+
const published = await docker(["port", name, String(loginPort)], timeout);
|
|
213
|
+
const url = `http://${published.split("\n")[0]}`;
|
|
214
|
+
const call = async (init: RequestInit) => {
|
|
215
|
+
const reply = loginReplySchema.parse(await (await fetch(url, init)).json());
|
|
216
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
217
|
+
return reply.result;
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
name,
|
|
221
|
+
/** The relay answers only once CODESYS has logged in. */
|
|
222
|
+
ready: () =>
|
|
223
|
+
fetch(url)
|
|
224
|
+
.then((response) => response.ok)
|
|
225
|
+
.catch(() => false),
|
|
226
|
+
send: async (command: LoginCommand) =>
|
|
227
|
+
loginResultSchema.parse(
|
|
228
|
+
await call({
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body: JSON.stringify(command),
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
/** Logs out and waits for the container to stop. */
|
|
235
|
+
close: async () => {
|
|
236
|
+
await call({ method: "DELETE" });
|
|
237
|
+
await docker(["wait", name], timeout);
|
|
238
|
+
await docker(["rm", name], timeout);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
export type ControllerLogin = Awaited<ReturnType<typeof attachLogin>>;
|
|
243
|
+
|
|
244
|
+
/** The login in container `name`, if one is running; it outlives the process that opened it. */
|
|
245
|
+
export const findControllerLogin = async (name: string) =>
|
|
246
|
+
(await isRunning(name, 15_000)) ? attachLogin(name, 15_000) : undefined;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Logs in to the running application from the saved download baseline, in a detached builder
|
|
250
|
+
* container named `name`. Commands then take seconds instead of a CODESYS start each.
|
|
251
|
+
*/
|
|
252
|
+
export const openControllerLogin = async (config: ResolvedConfig, name: string) => {
|
|
253
|
+
const { environmentNames, imageId, target } = await controllerConnection(config);
|
|
254
|
+
const timeout = config.timeouts.hostCommand;
|
|
255
|
+
// A login that failed to open leaves its stopped container for diagnostics until the next one.
|
|
256
|
+
await docker(["rm", "--force", name], timeout);
|
|
257
|
+
// The request is overwritten in place: a bind mount of a recreated directory can still show
|
|
258
|
+
// Docker Desktop's cached, empty view of the deleted one.
|
|
259
|
+
const requestDirectory = resolve(config.outDir, ".codesys/login-request");
|
|
260
|
+
await mkdir(requestDirectory, { recursive: true });
|
|
261
|
+
await docker(
|
|
262
|
+
[
|
|
263
|
+
"run",
|
|
264
|
+
"--detach",
|
|
265
|
+
"--name",
|
|
266
|
+
name,
|
|
267
|
+
"--publish",
|
|
268
|
+
`127.0.0.1::${loginPort}`,
|
|
269
|
+
...(await prepareContainerRun(config, requestDirectory, {
|
|
270
|
+
network: target.network,
|
|
271
|
+
environment: environmentNames,
|
|
272
|
+
sources: false,
|
|
273
|
+
})),
|
|
274
|
+
imageId,
|
|
275
|
+
"login",
|
|
276
|
+
"--request",
|
|
277
|
+
"/input/config.json",
|
|
278
|
+
"--image-id",
|
|
279
|
+
imageId,
|
|
280
|
+
"--port",
|
|
281
|
+
String(loginPort),
|
|
282
|
+
],
|
|
283
|
+
timeout,
|
|
284
|
+
);
|
|
285
|
+
const deadline = Date.now() + config.timeouts.startup + config.timeouts.build;
|
|
286
|
+
while (Date.now() < deadline) {
|
|
287
|
+
if (!(await isRunning(name, timeout))) {
|
|
288
|
+
const logs = await execute("docker", ["logs", "--tail", "40", name], { timeout });
|
|
289
|
+
throw new Error(`The controller login failed:\n${logs.stdout}${logs.stderr}`);
|
|
290
|
+
}
|
|
291
|
+
const login = await attachLogin(name, timeout);
|
|
292
|
+
if (await login.ready()) return login;
|
|
293
|
+
await delay(2000);
|
|
294
|
+
}
|
|
295
|
+
await docker(["rm", "--force", name], timeout);
|
|
296
|
+
throw new Error("The controller login did not open in time");
|
|
297
|
+
};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFile, cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { ResolvedConfig } from "./config";
|
|
6
|
+
import { controllerResultSchema, explicitDownload, loginCommandSchema } from "./controller-online";
|
|
7
|
+
import {
|
|
8
|
+
createCodesysWorker,
|
|
9
|
+
withWorkerLock,
|
|
10
|
+
withWorkerShutdown,
|
|
11
|
+
writeJson,
|
|
12
|
+
} from "./host/codesys-worker";
|
|
13
|
+
import { toWindowsPath } from "./host/windows-path";
|
|
14
|
+
import { loadSources } from "./sources";
|
|
15
|
+
|
|
16
|
+
// Vendor code/data IDs are .NET Guid values derived from the compiler's data;
|
|
17
|
+
// they do not carry RFC UUID version/variant bits.
|
|
18
|
+
const guid = z.string().regex(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i);
|
|
19
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
20
|
+
const pointerSchema = z.strictObject({
|
|
21
|
+
schemaVersion: z.literal(1),
|
|
22
|
+
generation: z.uuid(),
|
|
23
|
+
});
|
|
24
|
+
const baselineSchema = controllerResultSchema.extend({
|
|
25
|
+
objects: z.array(z.record(z.string(), z.unknown())),
|
|
26
|
+
files: z.record(z.string(), hash),
|
|
27
|
+
});
|
|
28
|
+
const sha256 = (data: Uint8Array) => createHash("sha256").update(data).digest("hex");
|
|
29
|
+
|
|
30
|
+
const guidBytes = (value: string): Buffer => {
|
|
31
|
+
const bytes = Buffer.from(value.replaceAll("-", ""), "hex");
|
|
32
|
+
bytes.subarray(0, 4).reverse();
|
|
33
|
+
bytes.subarray(4, 6).reverse();
|
|
34
|
+
bytes.subarray(6, 8).reverse();
|
|
35
|
+
return bytes;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Hash the complete project directory, including the vendor's downloaded reference. */
|
|
39
|
+
const projectFiles = async (directory: string): Promise<Record<string, string>> => {
|
|
40
|
+
const files: Record<string, string> = {};
|
|
41
|
+
const visit = async (folder: string, prefix: string) => {
|
|
42
|
+
for (const item of await readdir(folder, { withFileTypes: true })) {
|
|
43
|
+
const name = prefix + item.name;
|
|
44
|
+
if (item.isSymbolicLink()) {
|
|
45
|
+
throw new Error(`Online project state contains a symlink: ${name}`);
|
|
46
|
+
}
|
|
47
|
+
if (item.isDirectory()) {
|
|
48
|
+
await visit(resolve(folder, item.name), `${name}/`);
|
|
49
|
+
} else if (item.isFile()) {
|
|
50
|
+
files[name] = sha256(await readFile(resolve(folder, item.name)));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
await visit(directory, "");
|
|
55
|
+
return Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Load the committed generation and prove it still matches this image, template and target. */
|
|
59
|
+
const readBaseline = async (
|
|
60
|
+
root: string,
|
|
61
|
+
expected: Pick<z.infer<typeof baselineSchema>, "imageId" | "templateHash" | "target">,
|
|
62
|
+
) => {
|
|
63
|
+
const pointer = pointerSchema.parse(
|
|
64
|
+
JSON.parse(await readFile(resolve(root, "current.json"), "utf8")),
|
|
65
|
+
);
|
|
66
|
+
const previous = resolve(root, "generations", pointer.generation);
|
|
67
|
+
const baseline = baselineSchema.parse(
|
|
68
|
+
JSON.parse(await readFile(resolve(previous, "manifest.json"), "utf8")),
|
|
69
|
+
);
|
|
70
|
+
if (
|
|
71
|
+
baseline.generation !== pointer.generation ||
|
|
72
|
+
baseline.imageId !== expected.imageId ||
|
|
73
|
+
baseline.templateHash !== expected.templateHash ||
|
|
74
|
+
JSON.stringify(baseline.target) !== JSON.stringify(expected.target)
|
|
75
|
+
) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Downloaded baseline uses a different image, template or controller; ${explicitDownload}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const project = resolve(previous, "project");
|
|
81
|
+
if (JSON.stringify(await projectFiles(project)) !== JSON.stringify(baseline.files)) {
|
|
82
|
+
throw new Error(`Saved online project/reference files changed; ${explicitDownload}`);
|
|
83
|
+
}
|
|
84
|
+
return { baseline, project };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Called inside the builder container while its output-directory flock is held. */
|
|
88
|
+
export const runControllerSession = async (
|
|
89
|
+
config: ResolvedConfig,
|
|
90
|
+
operation: "download" | "online-change" | "inspect",
|
|
91
|
+
options: {
|
|
92
|
+
imageId: string;
|
|
93
|
+
operationId: string;
|
|
94
|
+
initializeUser?: boolean;
|
|
95
|
+
read?: string[];
|
|
96
|
+
},
|
|
97
|
+
) => {
|
|
98
|
+
if (!config.online) {
|
|
99
|
+
throw new Error("Controller operations require online settings");
|
|
100
|
+
}
|
|
101
|
+
const root = resolve(config.outDir, ".codesys/controller");
|
|
102
|
+
await mkdir(root, { recursive: true });
|
|
103
|
+
const resultPath = resolve(root, "result.json");
|
|
104
|
+
await rm(resultPath, { force: true });
|
|
105
|
+
const templateHash = sha256(await readFile(config.template));
|
|
106
|
+
const { network, gateway, device } = config.online;
|
|
107
|
+
// A different development controller needs a fresh download from the template.
|
|
108
|
+
const target = { network, gateway, device, controller: config.controller };
|
|
109
|
+
const previous =
|
|
110
|
+
operation === "download"
|
|
111
|
+
? undefined
|
|
112
|
+
: await readBaseline(root, { imageId: options.imageId, templateHash, target });
|
|
113
|
+
const baseline = previous?.baseline;
|
|
114
|
+
if (options.initializeUser && operation !== "download") {
|
|
115
|
+
throw new Error("Initial user creation requires an explicit download");
|
|
116
|
+
}
|
|
117
|
+
const generation = randomUUID();
|
|
118
|
+
const candidate = resolve(root, "generations", generation);
|
|
119
|
+
const project = resolve(candidate, "project", "Application.project");
|
|
120
|
+
await mkdir(resolve(candidate, "project"), { recursive: true });
|
|
121
|
+
if (previous) {
|
|
122
|
+
await cp(previous.project, resolve(candidate, "project"), {
|
|
123
|
+
recursive: true,
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
await copyFile(config.template, project);
|
|
127
|
+
}
|
|
128
|
+
const jobConfig = {
|
|
129
|
+
...config,
|
|
130
|
+
outDir: resolve(root, "jobs", options.operationId),
|
|
131
|
+
};
|
|
132
|
+
const requestDirectory = resolve(jobConfig.outDir, ".codesys/controller");
|
|
133
|
+
await mkdir(requestDirectory, { recursive: true });
|
|
134
|
+
const sources = operation === "inspect" ? undefined : await loadSources(config);
|
|
135
|
+
const request = {
|
|
136
|
+
libraries: await Promise.all(
|
|
137
|
+
(config.libraries ?? []).map(async (library) => ({
|
|
138
|
+
...library,
|
|
139
|
+
path: await toWindowsPath(library.path, config.codesys.wine),
|
|
140
|
+
})),
|
|
141
|
+
),
|
|
142
|
+
...sources,
|
|
143
|
+
task: config.task,
|
|
144
|
+
...(config.tasks && { tasks: config.tasks }),
|
|
145
|
+
removeObjects: config.removeObjects,
|
|
146
|
+
symbols: config.symbols,
|
|
147
|
+
operation,
|
|
148
|
+
initializeUser: options.initializeUser ?? false,
|
|
149
|
+
online: config.online,
|
|
150
|
+
controller: config.controller,
|
|
151
|
+
project: await toWindowsPath(project, config.codesys.wine),
|
|
152
|
+
progress: await toWindowsPath(resolve(requestDirectory, "progress.log"), config.codesys.wine),
|
|
153
|
+
baseline,
|
|
154
|
+
read: options.read ?? [],
|
|
155
|
+
};
|
|
156
|
+
await writeFile(resolve(requestDirectory, "request.json"), JSON.stringify(request, null, 2));
|
|
157
|
+
const worker = createCodesysWorker(jobConfig);
|
|
158
|
+
return withWorkerShutdown(jobConfig, worker, async () => {
|
|
159
|
+
const result = await withWorkerLock(jobConfig, async () => {
|
|
160
|
+
const session = await worker.launch();
|
|
161
|
+
await worker.request(session, { action: "online" }, config.timeouts.build);
|
|
162
|
+
return z
|
|
163
|
+
.object({
|
|
164
|
+
applicationGuid: guid,
|
|
165
|
+
application: z.string().optional(),
|
|
166
|
+
controllerIds: z.strictObject({ codeId: guid, dataId: guid }),
|
|
167
|
+
state: z.string(),
|
|
168
|
+
values: z.record(z.string(), z.string()),
|
|
169
|
+
})
|
|
170
|
+
.parse(JSON.parse(await readFile(resolve(requestDirectory, "result.json"), "utf8")));
|
|
171
|
+
});
|
|
172
|
+
const receipt = controllerResultSchema.parse({
|
|
173
|
+
...result,
|
|
174
|
+
application: result.application ?? baseline?.application,
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
operationId: options.operationId,
|
|
177
|
+
operation,
|
|
178
|
+
imageId: options.imageId,
|
|
179
|
+
templateHash,
|
|
180
|
+
target,
|
|
181
|
+
generation: operation === "inspect" ? baseline?.generation : generation,
|
|
182
|
+
});
|
|
183
|
+
if (operation !== "inspect") {
|
|
184
|
+
const files = await projectFiles(resolve(candidate, "project"));
|
|
185
|
+
if (!Object.keys(files).some((name) => name.endsWith(".compileinfo"))) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
"CODESYS acknowledged the operation but no downloaded compiler reference was saved; candidate is retained for recovery",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const bootIds = Object.keys(files).filter((name) =>
|
|
191
|
+
name.endsWith(`.${receipt.applicationGuid}.bootinfo_guids`),
|
|
192
|
+
);
|
|
193
|
+
const expectedBootIds = Buffer.concat([
|
|
194
|
+
guidBytes(receipt.controllerIds.codeId),
|
|
195
|
+
guidBytes(receipt.controllerIds.dataId),
|
|
196
|
+
]);
|
|
197
|
+
const bootReference = bootIds[0]?.replace(/_guids$/, "") ?? "";
|
|
198
|
+
const downloadedReference = bootReference.replace(/\.bootinfo$/, ".compileinfo");
|
|
199
|
+
if (
|
|
200
|
+
bootIds.length !== 1 ||
|
|
201
|
+
!files[bootReference] ||
|
|
202
|
+
files[bootReference] === sha256(new Uint8Array()) ||
|
|
203
|
+
files[bootReference] !== files[downloadedReference] ||
|
|
204
|
+
!(await readFile(resolve(candidate, "project", bootIds[0] ?? ""))).equals(expectedBootIds)
|
|
205
|
+
) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
"CODESYS acknowledged the operation but matching boot reference was not saved; candidate is retained for recovery",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
await writeJson(resolve(candidate, "manifest.json"), {
|
|
211
|
+
...receipt,
|
|
212
|
+
objects: sources?.objects,
|
|
213
|
+
files,
|
|
214
|
+
});
|
|
215
|
+
// Each generation is immutable. A single atomic pointer commits it only
|
|
216
|
+
// after controller acknowledgement and matching downloaded/boot references.
|
|
217
|
+
await writeJson(resolve(root, "current.json"), {
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
generation,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
await writeJson(resultPath, receipt);
|
|
223
|
+
return receipt;
|
|
224
|
+
}).finally(async () => {
|
|
225
|
+
if (operation === "inspect") {
|
|
226
|
+
await rm(candidate, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Called inside the builder container: holds one login to the running application, made
|
|
233
|
+
* from the committed baseline, and serves its commands on `port` until a DELETE closes it.
|
|
234
|
+
*/
|
|
235
|
+
export const serveControllerLogin = async (
|
|
236
|
+
config: ResolvedConfig,
|
|
237
|
+
options: { imageId: string; port: number },
|
|
238
|
+
) => {
|
|
239
|
+
if (!config.online) {
|
|
240
|
+
throw new Error("Controller logins require online settings");
|
|
241
|
+
}
|
|
242
|
+
const root = resolve(config.outDir, ".codesys/controller");
|
|
243
|
+
const { network, gateway, device } = config.online;
|
|
244
|
+
const { baseline, project } = await readBaseline(root, {
|
|
245
|
+
imageId: options.imageId,
|
|
246
|
+
templateHash: sha256(await readFile(config.template)),
|
|
247
|
+
target: { network, gateway, device, controller: config.controller },
|
|
248
|
+
});
|
|
249
|
+
const jobConfig = { ...config, outDir: resolve(root, "login") };
|
|
250
|
+
await rm(jobConfig.outDir, { recursive: true, force: true });
|
|
251
|
+
// CODESYS writes into an open project, and the committed baseline must not change.
|
|
252
|
+
await cp(project, resolve(jobConfig.outDir, "project"), { recursive: true });
|
|
253
|
+
const worker = createCodesysWorker(jobConfig);
|
|
254
|
+
await withWorkerShutdown(jobConfig, worker, () =>
|
|
255
|
+
withWorkerLock(jobConfig, async () => {
|
|
256
|
+
const session = await worker.launch();
|
|
257
|
+
const send = (command: Record<string, unknown>) =>
|
|
258
|
+
worker.request(session, { action: "login", arguments: command }, config.timeouts.build);
|
|
259
|
+
await send({
|
|
260
|
+
command: "open",
|
|
261
|
+
project: await toWindowsPath(
|
|
262
|
+
resolve(jobConfig.outDir, "project", "Application.project"),
|
|
263
|
+
config.codesys.wine,
|
|
264
|
+
),
|
|
265
|
+
online: config.online,
|
|
266
|
+
baseline,
|
|
267
|
+
});
|
|
268
|
+
const closed = Promise.withResolvers<void>();
|
|
269
|
+
// The worker has one mailbox, so commands run one at a time.
|
|
270
|
+
let previous: Promise<unknown> = Promise.resolve();
|
|
271
|
+
const server = Bun.serve({
|
|
272
|
+
port: options.port,
|
|
273
|
+
idleTimeout: 0,
|
|
274
|
+
fetch: async (request) => {
|
|
275
|
+
if (request.method === "GET") return Response.json({ ok: true });
|
|
276
|
+
const close = request.method === "DELETE";
|
|
277
|
+
const command = close
|
|
278
|
+
? { command: "close" }
|
|
279
|
+
: loginCommandSchema.parse(await request.json());
|
|
280
|
+
const result = previous.then(() => send(command));
|
|
281
|
+
previous = result.catch(() => undefined);
|
|
282
|
+
try {
|
|
283
|
+
return Response.json({ ok: true, result: await result });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
// The worker reports a Python traceback; its last line names the failure.
|
|
286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
287
|
+
const reason = message.trim().split("\n").at(-1) ?? message;
|
|
288
|
+
return Response.json({ ok: false, error: reason.replace(/^\w+: /, "") });
|
|
289
|
+
} finally {
|
|
290
|
+
if (close) closed.resolve();
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
await closed.promise;
|
|
295
|
+
await server.stop();
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const compilerDiagnosticSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
category: z.string(),
|
|
6
|
+
text: z.string(),
|
|
7
|
+
severity: z.string().nullable(),
|
|
8
|
+
project: z.string().nullable(),
|
|
9
|
+
object: z
|
|
10
|
+
.object({
|
|
11
|
+
name: z.string(),
|
|
12
|
+
guid: z.string(),
|
|
13
|
+
path: z.array(z.string()),
|
|
14
|
+
/** Verified against this message project's active application GUID. */
|
|
15
|
+
isApplicationChild: z.boolean().nullable(),
|
|
16
|
+
})
|
|
17
|
+
.passthrough()
|
|
18
|
+
.nullable(),
|
|
19
|
+
/** Opaque vendor position. It must not be decoded as a line or column. */
|
|
20
|
+
position: z.string().nullable(),
|
|
21
|
+
positionText: z.string().nullable(),
|
|
22
|
+
positionOffset: z.int().nullable(),
|
|
23
|
+
length: z.int().nullable(),
|
|
24
|
+
prefix: z.string().nullable(),
|
|
25
|
+
number: z.int().nullable(),
|
|
26
|
+
})
|
|
27
|
+
.passthrough();
|
|
28
|
+
|
|
29
|
+
export type CodesysCompilerDiagnostic = z.infer<typeof compilerDiagnosticSchema>;
|
|
30
|
+
|
|
31
|
+
/** Preserves vendor diagnostics as the cause of a buildProject failure. */
|
|
32
|
+
export class CodesysCompilerError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
message: string,
|
|
35
|
+
readonly diagnostics: readonly CodesysCompilerDiagnostic[],
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "CodesysCompilerError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A failed container job publishes diagnostics separately from its successful build receipt.
|
|
43
|
+
export const buildFailureFile = ".codesys/docker/failure.json";
|
|
44
|
+
/** Why the last download, online change or inspection failed, for the host to report. */
|
|
45
|
+
export const controllerFailureFile = ".codesys/controller/failure.txt";
|
|
46
|
+
export const buildFailureSchema = z.strictObject({
|
|
47
|
+
operationId: z.uuid(),
|
|
48
|
+
message: z.string(),
|
|
49
|
+
diagnostics: z.array(compilerDiagnosticSchema),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Keep the primary compiler location and the shutdown context across the Docker boundary. */
|
|
53
|
+
export function describeBuildFailure(cause: unknown) {
|
|
54
|
+
const messages: string[] = [];
|
|
55
|
+
let diagnostics: readonly CodesysCompilerDiagnostic[] = [];
|
|
56
|
+
let current = cause;
|
|
57
|
+
while (current instanceof Error) {
|
|
58
|
+
messages.push(current.message);
|
|
59
|
+
if (current instanceof AggregateError) {
|
|
60
|
+
// withWorkerShutdown puts the primary failure in cause and secondary failures in errors.
|
|
61
|
+
for (const secondary of current.errors.slice(1))
|
|
62
|
+
messages.push(secondary instanceof Error ? secondary.message : String(secondary));
|
|
63
|
+
}
|
|
64
|
+
if (current instanceof CodesysCompilerError) diagnostics = current.diagnostics;
|
|
65
|
+
current = current.cause;
|
|
66
|
+
}
|
|
67
|
+
return { message: messages.length ? messages.join("\n") : String(cause), diagnostics };
|
|
68
|
+
}
|