@tsplc/build 0.0.0-stage → 0.1.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 +1652 -0
- package/dist/cli.js.map +34 -0
- package/dist/index.js +1552 -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 +17 -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/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 +131 -0
- package/package.json +53 -4
- package/python/build.py +655 -0
- package/python/codesys_online.py +300 -0
- package/python/test_build.py +444 -0
- package/python/test_codesys_online.py +170 -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 +89 -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 +143 -0
- package/src/controller-session.ts +229 -0
- package/src/diagnostics.ts +66 -0
- package/src/docker-build.ts +148 -0
- package/src/docker-context.ts +35 -0
- package/src/docker-run.ts +104 -0
- package/src/host/codesys-command.ts +45 -0
- package/src/host/codesys-host.ts +145 -0
- package/src/host/codesys-worker.ts +345 -0
- package/src/host/windows-path.ts +73 -0
- package/src/index.ts +26 -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,143 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { controllerSchema, dockerImage, type ResolvedConfig } from "./config";
|
|
9
|
+
import { prepareContainerRun, runDocker } from "./docker-run.ts";
|
|
10
|
+
|
|
11
|
+
const execute = promisify(execFile);
|
|
12
|
+
const imageIdSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
13
|
+
const endpointSchema = z.strictObject({
|
|
14
|
+
host: z.string().nonempty(),
|
|
15
|
+
port: z.number().int().min(1).max(65535),
|
|
16
|
+
});
|
|
17
|
+
export const controllerResultSchema = z.strictObject({
|
|
18
|
+
schemaVersion: z.literal(1),
|
|
19
|
+
operationId: z.uuid(),
|
|
20
|
+
operation: z.enum(["download", "online-change", "inspect"]),
|
|
21
|
+
imageId: imageIdSchema,
|
|
22
|
+
templateHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
23
|
+
target: z.strictObject({
|
|
24
|
+
network: z.string().nonempty(),
|
|
25
|
+
gateway: endpointSchema,
|
|
26
|
+
device: endpointSchema,
|
|
27
|
+
controller: controllerSchema.optional(),
|
|
28
|
+
}),
|
|
29
|
+
generation: z.string().nonempty(),
|
|
30
|
+
application: z.string().nonempty(),
|
|
31
|
+
applicationGuid: z.string().nonempty(),
|
|
32
|
+
controllerIds: z.strictObject({
|
|
33
|
+
codeId: z.string().nonempty(),
|
|
34
|
+
dataId: z.string().nonempty(),
|
|
35
|
+
}),
|
|
36
|
+
state: z.string().nonempty(),
|
|
37
|
+
values: z.record(z.string(), z.string()),
|
|
38
|
+
});
|
|
39
|
+
export type ControllerResult = z.infer<typeof controllerResultSchema>;
|
|
40
|
+
export type ControllerUpdateOptions = {
|
|
41
|
+
initializeUser?: boolean;
|
|
42
|
+
read?: string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const controllerOperation = async (
|
|
46
|
+
config: ResolvedConfig,
|
|
47
|
+
operation: ControllerResult["operation"],
|
|
48
|
+
options: ControllerUpdateOptions,
|
|
49
|
+
): Promise<ControllerResult> => {
|
|
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
|
+
const imageId = imageIdSchema.parse(stdout.trim());
|
|
74
|
+
const templateHash = createHash("sha256")
|
|
75
|
+
.update(await readFile(config.template))
|
|
76
|
+
.digest("hex");
|
|
77
|
+
const operationId = randomUUID();
|
|
78
|
+
const target = {
|
|
79
|
+
network: config.online.network,
|
|
80
|
+
gateway: config.online.gateway,
|
|
81
|
+
device: config.online.device,
|
|
82
|
+
controller: config.controller,
|
|
83
|
+
};
|
|
84
|
+
const requestDirectory = await mkdtemp(resolve(tmpdir(), "codesys-controller-"));
|
|
85
|
+
const diagnostics = resolve(config.outDir, ".codesys/controller");
|
|
86
|
+
try {
|
|
87
|
+
await mkdir(config.outDir, { recursive: true });
|
|
88
|
+
await runDocker(
|
|
89
|
+
[
|
|
90
|
+
...(await prepareContainerRun(config, requestDirectory, {
|
|
91
|
+
network: target.network,
|
|
92
|
+
environment: environmentNames,
|
|
93
|
+
sources: operation !== "inspect",
|
|
94
|
+
})),
|
|
95
|
+
imageId,
|
|
96
|
+
operation,
|
|
97
|
+
"--request",
|
|
98
|
+
"/input/config.json",
|
|
99
|
+
"--operation-id",
|
|
100
|
+
operationId,
|
|
101
|
+
"--image-id",
|
|
102
|
+
imageId,
|
|
103
|
+
...(options.initializeUser ? ["--initialize-user"] : []),
|
|
104
|
+
...(options.read ? ["--read", JSON.stringify(options.read)] : []),
|
|
105
|
+
],
|
|
106
|
+
(status) =>
|
|
107
|
+
`Docker controller operation failed (${status}). Inspect ${diagnostics} for diagnostics`,
|
|
108
|
+
);
|
|
109
|
+
const result = await readFile(resolve(diagnostics, "result.json"), "utf8")
|
|
110
|
+
.then((content) => controllerResultSchema.parse(JSON.parse(content)))
|
|
111
|
+
.catch((cause: unknown) => {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Docker did not produce a valid controller receipt. Inspect ${diagnostics} for diagnostics`,
|
|
114
|
+
{ cause },
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
if (
|
|
118
|
+
result.operationId !== operationId ||
|
|
119
|
+
result.operation !== operation ||
|
|
120
|
+
result.imageId !== imageId ||
|
|
121
|
+
result.templateHash !== templateHash ||
|
|
122
|
+
JSON.stringify(result.target) !== JSON.stringify(target)
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Controller receipt does not match this operation's image, template or target. Inspect ${diagnostics} for diagnostics`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
} finally {
|
|
130
|
+
await rm(requestDirectory, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Download or apply an online change using the original CODESYS Docker compiler. */
|
|
135
|
+
export const updateController = (
|
|
136
|
+
config: ResolvedConfig,
|
|
137
|
+
operation: "download" | "online-change",
|
|
138
|
+
options: ControllerUpdateOptions = {},
|
|
139
|
+
) => controllerOperation(config, operation, options);
|
|
140
|
+
|
|
141
|
+
/** Read the controller using the runner's saved compilation baseline. */
|
|
142
|
+
export const inspectController = (config: ResolvedConfig, read?: string[]) =>
|
|
143
|
+
controllerOperation(config, "inspect", { read });
|
|
@@ -0,0 +1,229 @@
|
|
|
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 } 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; use an explicit download",
|
|
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; use an explicit download");
|
|
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
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
export const buildFailureSchema = z.strictObject({
|
|
45
|
+
operationId: z.uuid(),
|
|
46
|
+
message: z.string(),
|
|
47
|
+
diagnostics: z.array(compilerDiagnosticSchema),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Keep the primary compiler location and the shutdown context across the Docker boundary. */
|
|
51
|
+
export function describeBuildFailure(cause: unknown) {
|
|
52
|
+
const messages: string[] = [];
|
|
53
|
+
let diagnostics: readonly CodesysCompilerDiagnostic[] = [];
|
|
54
|
+
let current = cause;
|
|
55
|
+
while (current instanceof Error) {
|
|
56
|
+
messages.push(current.message);
|
|
57
|
+
if (current instanceof AggregateError) {
|
|
58
|
+
// withWorkerShutdown puts the primary failure in cause and secondary failures in errors.
|
|
59
|
+
for (const secondary of current.errors.slice(1))
|
|
60
|
+
messages.push(secondary instanceof Error ? secondary.message : String(secondary));
|
|
61
|
+
}
|
|
62
|
+
if (current instanceof CodesysCompilerError) diagnostics = current.diagnostics;
|
|
63
|
+
current = current.cause;
|
|
64
|
+
}
|
|
65
|
+
return { message: messages.length ? messages.join("\n") : String(cause), diagnostics };
|
|
66
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { readBuild } from "./artifacts";
|
|
8
|
+
import { dockerImage, type ResolvedConfig } from "./config";
|
|
9
|
+
import { prepareDockerContext } from "./docker-context.ts";
|
|
10
|
+
import { buildFailureFile, buildFailureSchema, CodesysCompilerError } from "./diagnostics.ts";
|
|
11
|
+
import { prepareContainerRun, runDocker } from "./docker-run.ts";
|
|
12
|
+
import { packageDirectory } from "./package-path.ts";
|
|
13
|
+
|
|
14
|
+
const execute = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
/** Install the IDE and its prerequisites in the Docker builder image. */
|
|
17
|
+
export const configureDocker = async () => {
|
|
18
|
+
const image = dockerImage;
|
|
19
|
+
const { stdout } = await execute(
|
|
20
|
+
"docker",
|
|
21
|
+
["info", "--format", "{{.OSType}}/{{.Architecture}}"],
|
|
22
|
+
{ timeout: 30_000 },
|
|
23
|
+
).catch((cause: unknown) => {
|
|
24
|
+
throw new Error("Cannot connect to Docker. Install and start Docker Desktop or Docker Engine", {
|
|
25
|
+
cause,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
const architecture = stdout.trim();
|
|
29
|
+
const arm = architecture === "linux/aarch64" || architecture === "linux/arm64";
|
|
30
|
+
if (!arm && architecture !== "linux/x86_64" && architecture !== "linux/amd64") {
|
|
31
|
+
throw new Error("The builder requires a Linux Docker server on ARM64 or AMD64");
|
|
32
|
+
}
|
|
33
|
+
const context = await mkdtemp(resolve(tmpdir(), "codesys-installer-"));
|
|
34
|
+
let buildContext: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
buildContext = await prepareDockerContext();
|
|
37
|
+
const cache = resolve(context, "winetricks");
|
|
38
|
+
await mkdir(cache);
|
|
39
|
+
if (process.env["CODESYS_WINETRICKS_CACHE"]) {
|
|
40
|
+
await cp(process.env["CODESYS_WINETRICKS_CACHE"], cache, {
|
|
41
|
+
recursive: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
console.log(
|
|
45
|
+
`Preparing Docker builder (${image}). Downloading and installing CODESYS 3.5.22.30 and prerequisites.`,
|
|
46
|
+
);
|
|
47
|
+
await runDocker(
|
|
48
|
+
[
|
|
49
|
+
"build",
|
|
50
|
+
"--platform",
|
|
51
|
+
"linux/amd64",
|
|
52
|
+
"--build-arg",
|
|
53
|
+
`SETUP_BASE=${arm ? "arm-setup" : "base"}`,
|
|
54
|
+
"--build-arg",
|
|
55
|
+
`SETUP_PLATFORM=${arm ? "linux/arm64" : "linux/amd64"}`,
|
|
56
|
+
"--build-arg",
|
|
57
|
+
"INSTALLER_SOURCE=installer-download",
|
|
58
|
+
"--progress",
|
|
59
|
+
"plain",
|
|
60
|
+
"--build-context",
|
|
61
|
+
`installer=${context}`,
|
|
62
|
+
"-f",
|
|
63
|
+
resolve(buildContext, "docker/Dockerfile"),
|
|
64
|
+
"-t",
|
|
65
|
+
image,
|
|
66
|
+
buildContext,
|
|
67
|
+
],
|
|
68
|
+
(status) =>
|
|
69
|
+
`Docker configuration failed (${status}). Review the image-build output and run tsplc configure again`,
|
|
70
|
+
);
|
|
71
|
+
return image;
|
|
72
|
+
} finally {
|
|
73
|
+
await Promise.all([
|
|
74
|
+
rm(context, { recursive: true, force: true }),
|
|
75
|
+
...(buildContext ? [rm(buildContext, { recursive: true, force: true })] : []),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const runtimeImage = "tsplc-control-linux-sl:4.22.0.0";
|
|
81
|
+
|
|
82
|
+
/** Build the local CODESYS Control for Linux SL runtime, the same device as a production build. */
|
|
83
|
+
export const configureRuntime = async () => {
|
|
84
|
+
const context = resolve(packageDirectory, "docker/control-linux-sl");
|
|
85
|
+
console.log(`Preparing the local PLC runtime (${runtimeImage}).`);
|
|
86
|
+
await runDocker(
|
|
87
|
+
["build", "--platform", "linux/amd64", "--progress", "plain", "-t", runtimeImage, context],
|
|
88
|
+
(status) => `Runtime image build failed (${status}). Review the image-build output`,
|
|
89
|
+
);
|
|
90
|
+
return runtimeImage;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const buildInDocker = async (config: ResolvedConfig, image: string) => {
|
|
94
|
+
const requestDirectory = await mkdtemp(resolve(tmpdir(), "codesys-docker-"));
|
|
95
|
+
try {
|
|
96
|
+
const operationId = randomUUID();
|
|
97
|
+
await mkdir(config.outDir, { recursive: true });
|
|
98
|
+
const failurePath = resolve(config.outDir, buildFailureFile);
|
|
99
|
+
await rm(failurePath, { force: true });
|
|
100
|
+
const args = [
|
|
101
|
+
...(await prepareContainerRun(config, requestDirectory, { network: "none", sources: true })),
|
|
102
|
+
image,
|
|
103
|
+
"--request",
|
|
104
|
+
"/input/config.json",
|
|
105
|
+
"--operation-id",
|
|
106
|
+
operationId,
|
|
107
|
+
];
|
|
108
|
+
console.log(`Building with Docker (${image})`);
|
|
109
|
+
try {
|
|
110
|
+
await runDocker(
|
|
111
|
+
args,
|
|
112
|
+
(status) =>
|
|
113
|
+
`Docker build failed (${status}). Run tsplc configure if the image is missing; inspect ${config.outDir}/.codesys/docker for compiler logs`,
|
|
114
|
+
);
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
117
|
+
const failure = await readFile(failurePath, "utf8")
|
|
118
|
+
.then((text) => buildFailureSchema.parse(JSON.parse(text)))
|
|
119
|
+
.catch((error: unknown) => {
|
|
120
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
|
121
|
+
throw new AggregateError(
|
|
122
|
+
[cause, error],
|
|
123
|
+
`${message}. Invalid CODESYS failure metadata (${failurePath})`,
|
|
124
|
+
{ cause: error },
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
if (!failure) throw cause;
|
|
128
|
+
if (failure.operationId !== operationId)
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${message}. CODESYS failure metadata belongs to a different Docker build`,
|
|
131
|
+
{
|
|
132
|
+
cause,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
if (cause instanceof Error) {
|
|
136
|
+
// The mapper keeps ordinary error messages as notes, while vendor
|
|
137
|
+
// diagnostics supply their own text. Preserve shutdown context there.
|
|
138
|
+
cause.message = `${message}\n${failure.message}`;
|
|
139
|
+
if (failure.diagnostics.length)
|
|
140
|
+
cause.cause = new CodesysCompilerError(failure.message, failure.diagnostics);
|
|
141
|
+
}
|
|
142
|
+
throw cause;
|
|
143
|
+
}
|
|
144
|
+
return await readBuild(config.outDir);
|
|
145
|
+
} finally {
|
|
146
|
+
await rm(requestDirectory, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { packageDirectory } from "./package-path.ts";
|
|
6
|
+
|
|
7
|
+
export async function prepareDockerContext() {
|
|
8
|
+
const context = await mkdtemp(resolve(tmpdir(), "tsplc-docker-"));
|
|
9
|
+
try {
|
|
10
|
+
for (const directory of ["src", "python", "docker", "runtime"])
|
|
11
|
+
await cp(resolve(packageDirectory, directory), resolve(context, directory), {
|
|
12
|
+
recursive: true,
|
|
13
|
+
});
|
|
14
|
+
const configRoot = resolve(
|
|
15
|
+
dirname(createRequire(import.meta.url).resolve("@tsplc/config/build")),
|
|
16
|
+
"..",
|
|
17
|
+
);
|
|
18
|
+
await mkdir(resolve(context, "shared-config"));
|
|
19
|
+
await cp(resolve(configRoot, "src"), resolve(context, "shared-config/src"), {
|
|
20
|
+
recursive: true,
|
|
21
|
+
});
|
|
22
|
+
await cp(resolve(configRoot, "templates"), resolve(context, "shared-config/templates"), {
|
|
23
|
+
recursive: true,
|
|
24
|
+
});
|
|
25
|
+
await cp(resolve(configRoot, "package.json"), resolve(context, "shared-config/package.json"));
|
|
26
|
+
await writeFile(
|
|
27
|
+
resolve(context, "package.json"),
|
|
28
|
+
JSON.stringify({ type: "module", dependencies: { zod: "4.3.6", jiti: "2.7.0" } }),
|
|
29
|
+
);
|
|
30
|
+
return context;
|
|
31
|
+
} catch (cause) {
|
|
32
|
+
await rm(context, { recursive: true, force: true });
|
|
33
|
+
throw cause;
|
|
34
|
+
}
|
|
35
|
+
}
|