@tsplc/build 0.2.7 → 0.2.9
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/dist/cli.js +51 -33
- package/dist/cli.js.map +5 -5
- package/dist/index.js +187 -63
- package/dist/index.js.map +5 -5
- package/docker/runner.ts +29 -9
- package/package.json +3 -3
- package/python/build.py +4 -1
- package/python/codesys_online.py +38 -12
- package/python/test_codesys_online.py +31 -1
- package/python/worker.py +16 -7
- package/src/controller-online.ts +248 -65
- package/src/controller-session.ts +157 -28
- package/src/docker-run.ts +60 -17
- package/src/host/codesys-worker.ts +1 -0
- package/src/index.ts +2 -0
|
@@ -4,10 +4,13 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import type { ResolvedConfig } from "./config";
|
|
6
6
|
import {
|
|
7
|
+
type ControllerCredentials,
|
|
7
8
|
controllerResultSchema,
|
|
8
9
|
controllerTarget,
|
|
9
10
|
explicitDownload,
|
|
11
|
+
failureReason,
|
|
10
12
|
loginCommandSchema,
|
|
13
|
+
warmOperationSchema,
|
|
11
14
|
} from "./controller-online";
|
|
12
15
|
import {
|
|
13
16
|
createCodesysWorker,
|
|
@@ -90,7 +93,33 @@ const readBaseline = async (
|
|
|
90
93
|
return { baseline, project };
|
|
91
94
|
};
|
|
92
95
|
|
|
93
|
-
/**
|
|
96
|
+
/** A CODESYS worker that handles the `online` request written to `requestDirectory` when `run`. */
|
|
97
|
+
interface OnlineWorker {
|
|
98
|
+
requestDirectory: string;
|
|
99
|
+
run: () => Promise<unknown>;
|
|
100
|
+
/** A candidate generation copied from baseline generation `baseline`, which the worker has open. */
|
|
101
|
+
prepared?: { baseline: string; generation: string };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A worker started for one operation and stopped after it. */
|
|
105
|
+
const oneShotWorker = (config: ResolvedConfig): OnlineWorker => {
|
|
106
|
+
const worker = createCodesysWorker(config);
|
|
107
|
+
return {
|
|
108
|
+
requestDirectory: resolve(config.outDir, ".codesys/controller"),
|
|
109
|
+
run: () =>
|
|
110
|
+
withWorkerShutdown(config, worker, () =>
|
|
111
|
+
withWorkerLock(config, async () =>
|
|
112
|
+
worker.request(await worker.launch(), { action: "online" }, config.timeouts.build),
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Called inside the builder container, while its output-directory flock is held or, for a warm
|
|
120
|
+
* `worker`, while the agent runs nothing else. Without `credentials`, CODESYS reads the controller
|
|
121
|
+
* user from the environment variables `config.online` names.
|
|
122
|
+
*/
|
|
94
123
|
export const runControllerSession = async (
|
|
95
124
|
config: ResolvedConfig,
|
|
96
125
|
operation: "download" | "online-change" | "inspect",
|
|
@@ -99,7 +128,9 @@ export const runControllerSession = async (
|
|
|
99
128
|
operationId: string;
|
|
100
129
|
initializeUser?: boolean;
|
|
101
130
|
read?: string[];
|
|
131
|
+
credentials?: ControllerCredentials;
|
|
102
132
|
},
|
|
133
|
+
worker?: OnlineWorker,
|
|
103
134
|
) => {
|
|
104
135
|
if (!config.online) {
|
|
105
136
|
throw new Error("Controller operations require online settings");
|
|
@@ -119,12 +150,15 @@ export const runControllerSession = async (
|
|
|
119
150
|
if (options.initializeUser && operation !== "download") {
|
|
120
151
|
throw new Error("Initial user creation requires an explicit download");
|
|
121
152
|
}
|
|
122
|
-
const
|
|
153
|
+
const opened = worker?.prepared;
|
|
154
|
+
const prepared =
|
|
155
|
+
operation === "online-change" && opened?.baseline === baseline?.generation ? opened : undefined;
|
|
156
|
+
const generation = prepared?.generation ?? randomUUID();
|
|
123
157
|
const candidate = resolve(root, "generations", generation);
|
|
124
158
|
const project = resolve(candidate, "project", "Application.project");
|
|
125
159
|
await mkdir(resolve(candidate, "project"), { recursive: true });
|
|
126
160
|
// A download saves the template here, with its controller and device tree.
|
|
127
|
-
if (previous) {
|
|
161
|
+
if (previous && !prepared) {
|
|
128
162
|
await cp(previous.project, resolve(candidate, "project"), {
|
|
129
163
|
recursive: true,
|
|
130
164
|
});
|
|
@@ -133,8 +167,19 @@ export const runControllerSession = async (
|
|
|
133
167
|
...config,
|
|
134
168
|
outDir: resolve(root, "jobs", options.operationId),
|
|
135
169
|
};
|
|
136
|
-
|
|
170
|
+
// The job directory keeps this operation's progress and descriptions; the worker its mailbox.
|
|
171
|
+
const jobDirectory = resolve(jobConfig.outDir, ".codesys/controller");
|
|
172
|
+
const online = worker ?? oneShotWorker(jobConfig);
|
|
173
|
+
const { requestDirectory } = online;
|
|
174
|
+
await mkdir(jobDirectory, { recursive: true });
|
|
137
175
|
await mkdir(requestDirectory, { recursive: true });
|
|
176
|
+
await rm(resolve(requestDirectory, "result.json"), { force: true });
|
|
177
|
+
// Only this container reads it; the worker deletes it once CODESYS has the user.
|
|
178
|
+
const credentialsFile =
|
|
179
|
+
options.credentials && `/tmp/tsplc-credentials-${options.operationId}.json`;
|
|
180
|
+
if (credentialsFile) {
|
|
181
|
+
await writeFile(credentialsFile, JSON.stringify(options.credentials), { mode: 0o600 });
|
|
182
|
+
}
|
|
138
183
|
const sources = operation === "inspect" ? undefined : await loadSources(config);
|
|
139
184
|
const request = {
|
|
140
185
|
libraryReferences: config.libraryReferences,
|
|
@@ -145,8 +190,8 @@ export const runControllerSession = async (
|
|
|
145
190
|
// Kept across jobs: generating a device tree takes minutes, and the same inputs make the same one.
|
|
146
191
|
caches: await toWindowsPath(root, config.codesys.wine),
|
|
147
192
|
...(config.deviceTree && {
|
|
148
|
-
deviceTree: await writeDeviceTree(config.deviceTree,
|
|
149
|
-
devices: await toWindowsPath(
|
|
193
|
+
deviceTree: await writeDeviceTree(config.deviceTree, jobDirectory),
|
|
194
|
+
devices: await toWindowsPath(jobDirectory, config.codesys.wine),
|
|
150
195
|
}),
|
|
151
196
|
}),
|
|
152
197
|
libraries: await Promise.all(
|
|
@@ -165,26 +210,25 @@ export const runControllerSession = async (
|
|
|
165
210
|
online: config.online,
|
|
166
211
|
controller: config.controller,
|
|
167
212
|
project: await toWindowsPath(project, config.codesys.wine),
|
|
168
|
-
progress: await toWindowsPath(resolve(
|
|
213
|
+
progress: await toWindowsPath(resolve(jobDirectory, "progress.log"), config.codesys.wine),
|
|
214
|
+
...(credentialsFile && {
|
|
215
|
+
credentialsFile: await toWindowsPath(credentialsFile, config.codesys.wine),
|
|
216
|
+
}),
|
|
169
217
|
baseline,
|
|
170
218
|
read: options.read ?? [],
|
|
171
219
|
};
|
|
172
220
|
await writeFile(resolve(requestDirectory, "request.json"), JSON.stringify(request, null, 2));
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const result =
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
values: z.record(z.string(), z.string()),
|
|
185
|
-
})
|
|
186
|
-
.parse(JSON.parse(await readFile(resolve(requestDirectory, "result.json"), "utf8")));
|
|
187
|
-
});
|
|
221
|
+
try {
|
|
222
|
+
await online.run();
|
|
223
|
+
const result = z
|
|
224
|
+
.object({
|
|
225
|
+
applicationGuid: guid,
|
|
226
|
+
application: z.string().optional(),
|
|
227
|
+
controllerIds: z.strictObject({ codeId: guid, dataId: guid }),
|
|
228
|
+
state: z.string(),
|
|
229
|
+
values: z.record(z.string(), z.string()),
|
|
230
|
+
})
|
|
231
|
+
.parse(JSON.parse(await readFile(resolve(requestDirectory, "result.json"), "utf8")));
|
|
188
232
|
const receipt = controllerResultSchema.parse({
|
|
189
233
|
...result,
|
|
190
234
|
application: result.application ?? baseline?.application,
|
|
@@ -237,11 +281,16 @@ export const runControllerSession = async (
|
|
|
237
281
|
}
|
|
238
282
|
await writeJson(resultPath, receipt);
|
|
239
283
|
return receipt;
|
|
240
|
-
}
|
|
284
|
+
} finally {
|
|
285
|
+
if (credentialsFile) await rm(credentialsFile, { force: true });
|
|
286
|
+
// The worker closed an opened project this operation didn't use.
|
|
287
|
+
if (opened && opened !== prepared) {
|
|
288
|
+
await rm(resolve(root, "generations", opened.generation), { recursive: true, force: true });
|
|
289
|
+
}
|
|
241
290
|
if (operation === "inspect") {
|
|
242
291
|
await rm(candidate, { recursive: true, force: true });
|
|
243
292
|
}
|
|
244
|
-
}
|
|
293
|
+
}
|
|
245
294
|
};
|
|
246
295
|
|
|
247
296
|
/**
|
|
@@ -284,6 +333,8 @@ export const serveControllerLogin = async (
|
|
|
284
333
|
// The worker has one mailbox, so commands run one at a time.
|
|
285
334
|
let previous: Promise<unknown> = Promise.resolve();
|
|
286
335
|
const server = Bun.serve({
|
|
336
|
+
// The agent calls through `docker exec`, so nothing else on the network can.
|
|
337
|
+
hostname: "127.0.0.1",
|
|
287
338
|
port: options.port,
|
|
288
339
|
idleTimeout: 0,
|
|
289
340
|
fetch: async (request) => {
|
|
@@ -297,10 +348,7 @@ export const serveControllerLogin = async (
|
|
|
297
348
|
try {
|
|
298
349
|
return Response.json({ ok: true, result: await result });
|
|
299
350
|
} catch (error) {
|
|
300
|
-
|
|
301
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
302
|
-
const reason = message.trim().split("\n").at(-1) ?? message;
|
|
303
|
-
return Response.json({ ok: false, error: reason.replace(/^\w+: /, "") });
|
|
351
|
+
return Response.json({ ok: false, error: failureReason(error) });
|
|
304
352
|
} finally {
|
|
305
353
|
if (close) closed.resolve();
|
|
306
354
|
}
|
|
@@ -311,3 +359,84 @@ export const serveControllerLogin = async (
|
|
|
311
359
|
}),
|
|
312
360
|
);
|
|
313
361
|
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Called inside the builder container: keeps one CODESYS worker running and serves downloads and
|
|
365
|
+
* online changes on `port`, so each skips starting the container and CODESYS. Each
|
|
366
|
+
* POST names its own config, written for that release. The agent runs one operation at a time, and
|
|
367
|
+
* none while a login is open, so this container doesn't hold the output directory's flock.
|
|
368
|
+
*/
|
|
369
|
+
export const serveWarmController = async (
|
|
370
|
+
config: ResolvedConfig,
|
|
371
|
+
options: {
|
|
372
|
+
imageId: string;
|
|
373
|
+
port: number;
|
|
374
|
+
loadConfig: (path: string) => Promise<ResolvedConfig>;
|
|
375
|
+
},
|
|
376
|
+
) => {
|
|
377
|
+
const warmConfig = { ...config, outDir: resolve(config.outDir, ".codesys/controller/warm") };
|
|
378
|
+
// One container owns this directory at a time; a stopped one can leave its worker session behind.
|
|
379
|
+
await rm(warmConfig.outDir, { recursive: true, force: true });
|
|
380
|
+
const worker = createCodesysWorker(warmConfig);
|
|
381
|
+
const session = await worker.launch();
|
|
382
|
+
const root = resolve(config.outDir, ".codesys/controller");
|
|
383
|
+
const online: OnlineWorker = {
|
|
384
|
+
requestDirectory: resolve(warmConfig.outDir, ".codesys/controller"),
|
|
385
|
+
run: () => worker.request(session, { action: "online" }, config.timeouts.build),
|
|
386
|
+
};
|
|
387
|
+
/** Opens a copy of the committed baseline, so the next online change needn't open one. */
|
|
388
|
+
const prepare = async () => {
|
|
389
|
+
const pointer = await readFile(resolve(root, "current.json"), "utf8").catch(() => undefined);
|
|
390
|
+
if (!pointer) return;
|
|
391
|
+
const baseline = pointerSchema.parse(JSON.parse(pointer)).generation;
|
|
392
|
+
const generation = randomUUID();
|
|
393
|
+
const project = resolve(root, "generations", generation, "project");
|
|
394
|
+
await cp(resolve(root, "generations", baseline, "project"), project, { recursive: true });
|
|
395
|
+
await worker.request(
|
|
396
|
+
session,
|
|
397
|
+
{
|
|
398
|
+
action: "prepare",
|
|
399
|
+
project: await toWindowsPath(resolve(project, "Application.project"), config.codesys.wine),
|
|
400
|
+
},
|
|
401
|
+
config.timeouts.build,
|
|
402
|
+
);
|
|
403
|
+
online.prepared = { baseline, generation };
|
|
404
|
+
};
|
|
405
|
+
// A failed prepare only costs the next operation the time to open the project itself.
|
|
406
|
+
const prepareNext = () =>
|
|
407
|
+
prepare().catch((error: unknown) => {
|
|
408
|
+
console.error(`Could not open the next project ahead: ${failureReason(error)}`);
|
|
409
|
+
});
|
|
410
|
+
let preparing = prepareNext();
|
|
411
|
+
Bun.serve({
|
|
412
|
+
hostname: "127.0.0.1",
|
|
413
|
+
port: options.port,
|
|
414
|
+
idleTimeout: 0,
|
|
415
|
+
fetch: async (request) => {
|
|
416
|
+
if (request.method === "GET") return Response.json({ ok: true });
|
|
417
|
+
await preparing;
|
|
418
|
+
try {
|
|
419
|
+
const body = warmOperationSchema.parse(await request.json());
|
|
420
|
+
const result = await runControllerSession(
|
|
421
|
+
await options.loadConfig(body.request),
|
|
422
|
+
body.operation,
|
|
423
|
+
{
|
|
424
|
+
imageId: options.imageId,
|
|
425
|
+
operationId: body.operationId,
|
|
426
|
+
initializeUser: body.initializeUser,
|
|
427
|
+
credentials: body.credentials,
|
|
428
|
+
},
|
|
429
|
+
online,
|
|
430
|
+
);
|
|
431
|
+
return Response.json({ ok: true, result });
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return Response.json({ ok: false, error: failureReason(error) });
|
|
434
|
+
} finally {
|
|
435
|
+
online.prepared = undefined;
|
|
436
|
+
preparing = prepareNext();
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
// Serves until the container stops; the next one clears what this one leaves behind.
|
|
441
|
+
await new Promise(() => {});
|
|
442
|
+
};
|
package/src/docker-run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { copyFile, cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, posix, relative, resolve, sep } from "node:path";
|
|
5
5
|
import type { ResolvedConfig } from "./config.ts";
|
|
6
6
|
|
|
@@ -39,7 +39,12 @@ export const runDocker = (args: string[], failure: (status: string | number | nu
|
|
|
39
39
|
const bindMount = (source: string, target: string, readonly = false) =>
|
|
40
40
|
`type=bind,"source=${source.replaceAll('"', '""')}",target=${target}${readonly ? ",readonly" : ""}`;
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/** Copies the libraries to `directory`, which the container sees as `root`. */
|
|
43
|
+
async function mountLibraries(
|
|
44
|
+
libraries: ResolvedConfig["libraries"],
|
|
45
|
+
directory: string,
|
|
46
|
+
root = "/input",
|
|
47
|
+
) {
|
|
43
48
|
if (!libraries?.length) return undefined;
|
|
44
49
|
await mkdir(resolve(directory, "libraries"), { recursive: true });
|
|
45
50
|
return Promise.all(
|
|
@@ -49,11 +54,57 @@ async function mountLibraries(libraries: ResolvedConfig["libraries"], directory:
|
|
|
49
54
|
throw new Error(`Library changed after installation: ${library.title}`);
|
|
50
55
|
const filename = `${library.sha256}-${basename(library.path)}`;
|
|
51
56
|
await writeFile(resolve(directory, "libraries", filename), data);
|
|
52
|
-
return { ...library, path:
|
|
57
|
+
return { ...library, path: `${root}/libraries/${filename}` };
|
|
53
58
|
}),
|
|
54
59
|
);
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/** The config a builder container reads: host-only settings removed, paths inside the container. */
|
|
63
|
+
const containerConfig = (
|
|
64
|
+
config: ResolvedConfig,
|
|
65
|
+
paths: { sourceDir: string; template: string },
|
|
66
|
+
libraries: ResolvedConfig["libraries"] | [],
|
|
67
|
+
) => ({
|
|
68
|
+
...config,
|
|
69
|
+
libraries,
|
|
70
|
+
configFile: undefined,
|
|
71
|
+
docker: undefined,
|
|
72
|
+
codesys: undefined,
|
|
73
|
+
sourceDir: paths.sourceDir,
|
|
74
|
+
entry: posix.resolve(
|
|
75
|
+
paths.sourceDir,
|
|
76
|
+
relative(config.sourceDir, config.entry).split(sep).join("/"),
|
|
77
|
+
),
|
|
78
|
+
template: paths.template,
|
|
79
|
+
outDir: "/output",
|
|
80
|
+
runtime: undefined,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Copies one operation's sources, template and libraries to `directory` in the output directory,
|
|
85
|
+
* which a warm container sees as `root`, and writes their config there. Returns its container path.
|
|
86
|
+
*/
|
|
87
|
+
export const writeOperationRequest = async (
|
|
88
|
+
config: ResolvedConfig,
|
|
89
|
+
directory: string,
|
|
90
|
+
root: string,
|
|
91
|
+
) => {
|
|
92
|
+
await mkdir(directory, { recursive: true });
|
|
93
|
+
await cp(config.sourceDir, resolve(directory, "sources"), { recursive: true });
|
|
94
|
+
await copyFile(config.template, resolve(directory, "template.project"));
|
|
95
|
+
await writeFile(
|
|
96
|
+
resolve(directory, "config.json"),
|
|
97
|
+
JSON.stringify(
|
|
98
|
+
containerConfig(
|
|
99
|
+
config,
|
|
100
|
+
{ sourceDir: `${root}/sources`, template: `${root}/template.project` },
|
|
101
|
+
await mountLibraries(config.libraries, directory, root),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
return `${root}/config.json`;
|
|
106
|
+
};
|
|
107
|
+
|
|
57
108
|
/**
|
|
58
109
|
* Write the builder container's request and return the `docker run` options that precede
|
|
59
110
|
* the image; the caller chooses `--rm` or `--detach`. The request names only container paths;
|
|
@@ -67,21 +118,13 @@ export const prepareContainerRun = async (
|
|
|
67
118
|
) => {
|
|
68
119
|
await writeFile(
|
|
69
120
|
resolve(requestDirectory, "config.json"),
|
|
70
|
-
JSON.stringify(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
codesys: undefined,
|
|
76
|
-
sourceDir: "/sources",
|
|
77
|
-
entry: posix.resolve(
|
|
78
|
-
"/sources",
|
|
79
|
-
relative(config.sourceDir, config.entry).split(sep).join("/"),
|
|
121
|
+
JSON.stringify(
|
|
122
|
+
containerConfig(
|
|
123
|
+
config,
|
|
124
|
+
{ sourceDir: "/sources", template: "/template.project" },
|
|
125
|
+
options.sources ? await mountLibraries(config.libraries, requestDirectory) : [],
|
|
80
126
|
),
|
|
81
|
-
|
|
82
|
-
outDir: "/output",
|
|
83
|
-
runtime: undefined,
|
|
84
|
-
}),
|
|
127
|
+
),
|
|
85
128
|
);
|
|
86
129
|
return [
|
|
87
130
|
"--init",
|
|
@@ -32,6 +32,7 @@ type Session = z.infer<typeof sessionSchema>;
|
|
|
32
32
|
|
|
33
33
|
type WorkerCommand =
|
|
34
34
|
| { action: "build" | "online" | "ping" | "stop" }
|
|
35
|
+
| { action: "prepare"; project: string }
|
|
35
36
|
| { action: "login"; arguments: Record<string, unknown> };
|
|
36
37
|
|
|
37
38
|
const hasCode = (error: unknown, code: string) =>
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,9 @@ export {
|
|
|
19
19
|
type LoginResult,
|
|
20
20
|
loginResultSchema,
|
|
21
21
|
openControllerLogin,
|
|
22
|
+
startWarmController,
|
|
22
23
|
updateController,
|
|
24
|
+
updateWarmController,
|
|
23
25
|
} from "./controller-online";
|
|
24
26
|
export { type CodesysCompilerDiagnostic, CodesysCompilerError } from "./diagnostics";
|
|
25
27
|
export { runProject } from "./run";
|