@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
package/python/build.py
CHANGED
|
@@ -258,12 +258,15 @@ def reconcile(application, previous, current):
|
|
|
258
258
|
names = set(item["name"].lower() for item in current)
|
|
259
259
|
gone = []
|
|
260
260
|
for old in previous:
|
|
261
|
+
fresh = new.get((tuple(old["folder"]), old["name"].lower()))
|
|
262
|
+
# Most objects keep their kind, folder and children; each CODESYS lookup costs a call.
|
|
263
|
+
if fresh is not None and object_key(old) == object_key(fresh):
|
|
264
|
+
continue
|
|
261
265
|
parent = application
|
|
262
266
|
for name in old["folder"]:
|
|
263
267
|
parent = unique_child(parent, name)
|
|
264
268
|
if parent is None:
|
|
265
269
|
raise RuntimeError("Saved source folder is missing: " + name)
|
|
266
|
-
fresh = new.get((tuple(old["folder"]), old["name"].lower()))
|
|
267
270
|
if fresh is None and old["name"].lower() in names:
|
|
268
271
|
# A folder move changes placement rather than text. Remove/recreate just
|
|
269
272
|
# that moved object, letting the vendor decide whether the update is valid.
|
package/python/codesys_online.py
CHANGED
|
@@ -139,7 +139,25 @@ def fail_on_errors_after_transfer(engine, progress):
|
|
|
139
139
|
raise CompilerError(errors)
|
|
140
140
|
|
|
141
141
|
|
|
142
|
-
def
|
|
142
|
+
def controller_credentials(settings, credentials_file):
|
|
143
|
+
"""The controller user: from a file a warm worker writes for one operation, else the environment."""
|
|
144
|
+
if credentials_file:
|
|
145
|
+
try:
|
|
146
|
+
with io.open(credentials_file, encoding='utf-8') as source:
|
|
147
|
+
credentials = json.load(source)
|
|
148
|
+
finally:
|
|
149
|
+
os.remove(credentials_file)
|
|
150
|
+
return credentials['username'], credentials['password']
|
|
151
|
+
names = settings.get('credentials')
|
|
152
|
+
if not names:
|
|
153
|
+
return None
|
|
154
|
+
username, password = (os.environ.get(names[key]) for key in ('usernameEnv', 'passwordEnv'))
|
|
155
|
+
if not username or not password:
|
|
156
|
+
raise RuntimeError('CODESYS online credential environment variables are required')
|
|
157
|
+
return username, password
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def connect_controller(engine, application, settings, credentials_file=None):
|
|
143
161
|
"""Route the application's controller through the configured gateway; returns it and its credentials."""
|
|
144
162
|
device = application.parent
|
|
145
163
|
while device is not None and not getattr(device, 'is_device', False):
|
|
@@ -148,19 +166,20 @@ def connect_controller(engine, application, settings):
|
|
|
148
166
|
raise RuntimeError('Application has no controller device')
|
|
149
167
|
gateway_ip = str(Dns.GetHostAddresses(settings['gateway']['host'])[0])
|
|
150
168
|
device_ip = str(Dns.GetHostAddresses(settings['device']['host'])[0])
|
|
151
|
-
|
|
169
|
+
gateways = engine.online.gateways
|
|
170
|
+
# A warm worker connects for every operation, and gateway names needn't be unique.
|
|
171
|
+
for stale in list(gateways.find_with_name('codesys-build')):
|
|
172
|
+
gateways.remove_gateway(stale)
|
|
173
|
+
gateway = gateways.add_new_gateway('codesys-build', {0: gateway_ip, 1: settings['gateway']['port']})
|
|
152
174
|
device.set_gateway_and_ip_address(gateway.guid, IPAddress.Parse(device_ip), settings['device']['port'])
|
|
153
|
-
credentials = settings
|
|
154
|
-
if
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if not username or not password:
|
|
158
|
-
raise RuntimeError('CODESYS online credential environment variables are required')
|
|
159
|
-
engine.online.set_specific_credentials(device, username, password)
|
|
160
|
-
return device, (username, password)
|
|
175
|
+
credentials = controller_credentials(settings, credentials_file)
|
|
176
|
+
if credentials:
|
|
177
|
+
engine.online.set_specific_credentials(device, *credentials)
|
|
178
|
+
return device, credentials
|
|
161
179
|
|
|
162
180
|
|
|
163
|
-
def run_online(engine, request):
|
|
181
|
+
def run_online(engine, request, prepared=None):
|
|
182
|
+
"""`prepared` is a project a warm worker opened ahead; an online change of that project uses it."""
|
|
164
183
|
progress_path = request.get('progress')
|
|
165
184
|
def progress(stage):
|
|
166
185
|
if progress_path:
|
|
@@ -168,8 +187,15 @@ def run_online(engine, request):
|
|
|
168
187
|
output.write(u'{0}Z {1}\n'.format(time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime()), stage))
|
|
169
188
|
progress('Starting; processor count {0}'.format(Environment.ProcessorCount))
|
|
170
189
|
# A download starts from the template; online changes reuse the downloaded project, which has its controller and tree.
|
|
190
|
+
if prepared is not None and (request['operation'] == 'download' or
|
|
191
|
+
os.path.normcase(prepared.path) != os.path.normcase(request['project'])):
|
|
192
|
+
prepared.close()
|
|
193
|
+
prepared = None
|
|
171
194
|
if request['operation'] == 'download':
|
|
172
195
|
project, pinned, generated = open_template(engine, request, request['project'], request.get('devices'), request['caches'], progress)
|
|
196
|
+
elif prepared is not None:
|
|
197
|
+
progress('Using the project opened ahead')
|
|
198
|
+
project, pinned, generated = prepared, {}, None
|
|
173
199
|
else:
|
|
174
200
|
progress('Opening project')
|
|
175
201
|
project = engine.projects.open(request['project'], primary=True)
|
|
@@ -181,7 +207,7 @@ def run_online(engine, request):
|
|
|
181
207
|
progress('Project opened; configuring controller connection')
|
|
182
208
|
if application is None:
|
|
183
209
|
raise RuntimeError('Project has no active Application')
|
|
184
|
-
device, credentials = connect_controller(engine, application, request['online'])
|
|
210
|
+
device, credentials = connect_controller(engine, application, request['online'], request.get('credentialsFile'))
|
|
185
211
|
online_device = engine.online.create_online_device(device)
|
|
186
212
|
if request.get('initializeUser'):
|
|
187
213
|
if request['operation'] != 'download' or not credentials:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
import sys
|
|
3
4
|
import tempfile
|
|
@@ -65,7 +66,8 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
65
66
|
self.engine = SimpleNamespace(
|
|
66
67
|
projects=SimpleNamespace(open=Mock(return_value=self.project)),
|
|
67
68
|
online=SimpleNamespace(
|
|
68
|
-
gateways=SimpleNamespace(add_new_gateway=Mock(return_value=SimpleNamespace(guid='gateway'))
|
|
69
|
+
gateways=SimpleNamespace(add_new_gateway=Mock(return_value=SimpleNamespace(guid='gateway')),
|
|
70
|
+
find_with_name=Mock(return_value=[]), remove_gateway=Mock()),
|
|
69
71
|
create_online_device=Mock(return_value=SimpleNamespace(Dispose=Mock())),
|
|
70
72
|
create_online_application=Mock(side_effect=wrapper),
|
|
71
73
|
),
|
|
@@ -172,6 +174,34 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
172
174
|
codesys_online.run_online(self.engine, self.request)
|
|
173
175
|
self.assertEqual(self.events, ['online-wrapper', 'import', 'remove', 'compile'])
|
|
174
176
|
|
|
177
|
+
def test_a_warm_worker_replaces_its_gateway_and_reads_credentials_once_from_a_file(self):
|
|
178
|
+
stale = SimpleNamespace(guid='earlier')
|
|
179
|
+
gateways = self.engine.online.gateways
|
|
180
|
+
gateways.find_with_name.return_value = [stale]
|
|
181
|
+
self.engine.online.set_specific_credentials = Mock()
|
|
182
|
+
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as source:
|
|
183
|
+
json.dump({'username': 'tsplc', 'password': 'pw'}, source)
|
|
184
|
+
_, credentials = codesys_online.connect_controller(self.engine, self.app, self.request['online'], source.name)
|
|
185
|
+
gateways.remove_gateway.assert_called_once_with(stale)
|
|
186
|
+
self.engine.online.set_specific_credentials.assert_called_once_with(self.app.parent, 'tsplc', 'pw')
|
|
187
|
+
self.assertEqual(credentials, ('tsplc', 'pw'))
|
|
188
|
+
self.assertFalse(os.path.exists(source.name))
|
|
189
|
+
|
|
190
|
+
def test_online_change_uses_the_project_a_warm_worker_opened_ahead(self):
|
|
191
|
+
self.request.update(operation='online-change', baseline={
|
|
192
|
+
'applicationGuid': self.app.guid, 'controllerIds': self.ids, 'objects': [],
|
|
193
|
+
})
|
|
194
|
+
self.project.path = 'Application.project'
|
|
195
|
+
codesys_online.run_online(self.engine, self.request, self.project)
|
|
196
|
+
self.engine.projects.open.assert_not_called()
|
|
197
|
+
self.project.close.assert_called_once()
|
|
198
|
+
|
|
199
|
+
def test_a_download_or_another_project_closes_the_one_opened_ahead(self):
|
|
200
|
+
stale = SimpleNamespace(path='Other.project', close=Mock())
|
|
201
|
+
codesys_online.run_online(self.engine, self.request, stale)
|
|
202
|
+
stale.close.assert_called_once()
|
|
203
|
+
codesys_online.open_template.assert_called_once()
|
|
204
|
+
|
|
175
205
|
def test_full_download_rejects_library_errors_reported_after_transfer(self):
|
|
176
206
|
def report_library_error(core):
|
|
177
207
|
self.messages.append(SimpleNamespace(text="Could not open library '#VisuShared'."))
|
package/python/worker.py
CHANGED
|
@@ -34,6 +34,14 @@ def run_build(engine, directory, loaded):
|
|
|
34
34
|
raise
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def close_loaded(loaded):
|
|
38
|
+
"""Closes the project an earlier build or prepare left open."""
|
|
39
|
+
for key in ("project", "prepared"):
|
|
40
|
+
if loaded.get(key) is not None:
|
|
41
|
+
loaded[key].close()
|
|
42
|
+
loaded.clear()
|
|
43
|
+
|
|
44
|
+
|
|
37
45
|
def serve(engine, directory):
|
|
38
46
|
request_path = os.path.join(directory, "request.json")
|
|
39
47
|
loaded = {}
|
|
@@ -49,22 +57,23 @@ def serve(engine, directory):
|
|
|
49
57
|
try:
|
|
50
58
|
if command["action"] == "build":
|
|
51
59
|
run_build(engine, directory, loaded)
|
|
60
|
+
elif command["action"] == "prepare":
|
|
61
|
+
# A warm worker opens the next online change's project before it is asked for.
|
|
62
|
+
close_loaded(loaded)
|
|
63
|
+
loaded["prepared"] = engine.projects.open(command["project"], primary=True)
|
|
52
64
|
elif command["action"] == "online":
|
|
53
65
|
from codesys_online import run_online
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
loaded.clear()
|
|
66
|
+
prepared = loaded.pop("prepared", None)
|
|
67
|
+
close_loaded(loaded)
|
|
57
68
|
root = os.path.join(os.path.dirname(directory), "controller")
|
|
58
69
|
with io.open(os.path.join(root, "request.json"), encoding="utf-8") as source:
|
|
59
70
|
request = json.load(source)
|
|
60
|
-
write_json(os.path.join(root, "result.json"), run_online(engine, request))
|
|
71
|
+
write_json(os.path.join(root, "result.json"), run_online(engine, request, prepared))
|
|
61
72
|
elif command["action"] == "login":
|
|
62
73
|
from codesys_login import close_login, open_login, run_command
|
|
63
74
|
arguments = command["arguments"]
|
|
64
75
|
if arguments["command"] == "open":
|
|
65
|
-
|
|
66
|
-
loaded["project"].close()
|
|
67
|
-
loaded.clear()
|
|
76
|
+
close_loaded(loaded)
|
|
68
77
|
loaded["login"] = open_login(engine, arguments)
|
|
69
78
|
response["result"] = run_command(engine, loaded["login"], {"command": "state"})
|
|
70
79
|
elif arguments["command"] == "close":
|
package/src/controller-online.ts
CHANGED
|
@@ -3,13 +3,13 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { resolve } from "node:path";
|
|
6
|
+
import { posix, resolve } from "node:path";
|
|
7
7
|
import { setTimeout as delay } from "node:timers/promises";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { controllerSchema, dockerImage, type ResolvedConfig } from "./config";
|
|
11
11
|
import { controllerFailureFile } from "./diagnostics.ts";
|
|
12
|
-
import { prepareContainerRun, runDocker } from "./docker-run.ts";
|
|
12
|
+
import { prepareContainerRun, runDocker, writeOperationRequest } from "./docker-run.ts";
|
|
13
13
|
|
|
14
14
|
const execute = promisify(execFile);
|
|
15
15
|
const imageIdSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
@@ -44,6 +44,27 @@ export const controllerResultSchema = z.strictObject({
|
|
|
44
44
|
values: z.record(z.string(), z.string()),
|
|
45
45
|
});
|
|
46
46
|
export type ControllerResult = z.infer<typeof controllerResultSchema>;
|
|
47
|
+
export const controllerCredentialsSchema = z.strictObject({
|
|
48
|
+
username: z.string().nonempty(),
|
|
49
|
+
password: z.string().nonempty(),
|
|
50
|
+
});
|
|
51
|
+
export type ControllerCredentials = z.infer<typeof controllerCredentialsSchema>;
|
|
52
|
+
|
|
53
|
+
/** One operation for the warm builder container; `request` is the container path of its config. */
|
|
54
|
+
export const warmOperationSchema = z.strictObject({
|
|
55
|
+
operation: z.enum(["download", "online-change"]),
|
|
56
|
+
operationId: z.uuid(),
|
|
57
|
+
request: z.string().nonempty(),
|
|
58
|
+
initializeUser: z.boolean(),
|
|
59
|
+
credentials: controllerCredentialsSchema.optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The worker reports a Python traceback; its last line names the failure. */
|
|
63
|
+
export const failureReason = (error: unknown) => {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
return (message.trim().split("\n").at(-1) ?? message).replace(/^\w+: /, "");
|
|
66
|
+
};
|
|
67
|
+
|
|
47
68
|
export type ControllerUpdateOptions = {
|
|
48
69
|
initializeUser?: boolean;
|
|
49
70
|
read?: string[];
|
|
@@ -66,6 +87,19 @@ export const controllerTarget = (
|
|
|
66
87
|
}),
|
|
67
88
|
});
|
|
68
89
|
|
|
90
|
+
/** The controller user's environment variables, which a container started now passes to CODESYS. */
|
|
91
|
+
const credentialEnvironment = (config: ResolvedConfig) => {
|
|
92
|
+
const names = config.online?.credentials
|
|
93
|
+
? [config.online.credentials.usernameEnv, config.online.credentials.passwordEnv]
|
|
94
|
+
: [];
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (process.env[name] === undefined) {
|
|
97
|
+
throw new Error(`Controller credential environment variable ${name} is not set`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return names;
|
|
101
|
+
};
|
|
102
|
+
|
|
69
103
|
/** Checks the settings every engineering connection needs and names its builder image and target. */
|
|
70
104
|
const controllerConnection = async (config: ResolvedConfig) => {
|
|
71
105
|
if (!config.docker || !config.online) {
|
|
@@ -73,14 +107,6 @@ const controllerConnection = async (config: ResolvedConfig) => {
|
|
|
73
107
|
"Docker image and online gateway/device configuration are required for controller operations",
|
|
74
108
|
);
|
|
75
109
|
}
|
|
76
|
-
const environmentNames = config.online.credentials
|
|
77
|
-
? [config.online.credentials.usernameEnv, config.online.credentials.passwordEnv]
|
|
78
|
-
: [];
|
|
79
|
-
for (const name of environmentNames) {
|
|
80
|
-
if (process.env[name] === undefined) {
|
|
81
|
-
throw new Error(`Controller credential environment variable ${name} is not set`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
110
|
const { stdout } = await execute(
|
|
85
111
|
"docker",
|
|
86
112
|
["image", "inspect", "--format", "{{.Id}}", dockerImage],
|
|
@@ -92,7 +118,6 @@ const controllerConnection = async (config: ResolvedConfig) => {
|
|
|
92
118
|
);
|
|
93
119
|
});
|
|
94
120
|
return {
|
|
95
|
-
environmentNames,
|
|
96
121
|
imageId: imageIdSchema.parse(stdout.trim()),
|
|
97
122
|
templateHash: createHash("sha256")
|
|
98
123
|
.update(await readFile(config.template))
|
|
@@ -101,12 +126,35 @@ const controllerConnection = async (config: ResolvedConfig) => {
|
|
|
101
126
|
};
|
|
102
127
|
};
|
|
103
128
|
|
|
129
|
+
const checkReceipt = (
|
|
130
|
+
result: ControllerResult,
|
|
131
|
+
expected: Pick<
|
|
132
|
+
ControllerResult,
|
|
133
|
+
"operationId" | "operation" | "imageId" | "templateHash" | "target"
|
|
134
|
+
>,
|
|
135
|
+
diagnostics: string,
|
|
136
|
+
) => {
|
|
137
|
+
if (
|
|
138
|
+
result.operationId !== expected.operationId ||
|
|
139
|
+
result.operation !== expected.operation ||
|
|
140
|
+
result.imageId !== expected.imageId ||
|
|
141
|
+
result.templateHash !== expected.templateHash ||
|
|
142
|
+
JSON.stringify(result.target) !== JSON.stringify(expected.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
|
+
};
|
|
150
|
+
|
|
104
151
|
const controllerOperation = async (
|
|
105
152
|
config: ResolvedConfig,
|
|
106
153
|
operation: ControllerResult["operation"],
|
|
107
154
|
options: ControllerUpdateOptions,
|
|
108
155
|
): Promise<ControllerResult> => {
|
|
109
|
-
const
|
|
156
|
+
const environmentNames = credentialEnvironment(config);
|
|
157
|
+
const { imageId, templateHash, target } = await controllerConnection(config);
|
|
110
158
|
const operationId = randomUUID();
|
|
111
159
|
const requestDirectory = await mkdtemp(resolve(tmpdir(), "codesys-controller-"));
|
|
112
160
|
const diagnostics = resolve(config.outDir, ".codesys/controller");
|
|
@@ -150,18 +198,11 @@ const controllerOperation = async (
|
|
|
150
198
|
{ cause },
|
|
151
199
|
);
|
|
152
200
|
});
|
|
153
|
-
|
|
154
|
-
result
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
JSON.stringify(result.target) !== JSON.stringify(target)
|
|
159
|
-
) {
|
|
160
|
-
throw new Error(
|
|
161
|
-
`Controller receipt does not match this operation's image, template or target. Inspect ${diagnostics} for diagnostics`,
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
return result;
|
|
201
|
+
return checkReceipt(
|
|
202
|
+
result,
|
|
203
|
+
{ operationId, operation, imageId, templateHash, target },
|
|
204
|
+
diagnostics,
|
|
205
|
+
);
|
|
165
206
|
} finally {
|
|
166
207
|
await rm(requestDirectory, { recursive: true, force: true });
|
|
167
208
|
}
|
|
@@ -214,8 +255,9 @@ const loginReplySchema = z.discriminatedUnion("ok", [
|
|
|
214
255
|
z.strictObject({ ok: z.literal(false), error: z.string() }),
|
|
215
256
|
]);
|
|
216
257
|
|
|
217
|
-
/** The
|
|
258
|
+
/** The ports login and warm containers serve on, inside the container. */
|
|
218
259
|
const loginPort = 4851;
|
|
260
|
+
const warmPort = 4852;
|
|
219
261
|
|
|
220
262
|
const docker = (args: string[], timeout: number) =>
|
|
221
263
|
execute("docker", args, { timeout }).then(({ stdout }) => stdout.trim());
|
|
@@ -223,39 +265,71 @@ const docker = (args: string[], timeout: number) =>
|
|
|
223
265
|
const isRunning = async (name: string, timeout: number) =>
|
|
224
266
|
(await docker(["container", "ls", "--quiet", "--filter", `name=^${name}$`], timeout)) !== "";
|
|
225
267
|
|
|
226
|
-
/**
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
268
|
+
/**
|
|
269
|
+
* Calls the server builder container `name` runs on `port`. The container's internal network
|
|
270
|
+
* publishes no ports, so the request goes through `docker exec`; the body, which can hold
|
|
271
|
+
* credentials, through its standard input.
|
|
272
|
+
*/
|
|
273
|
+
const callContainer = async (
|
|
274
|
+
name: string,
|
|
275
|
+
port: number,
|
|
276
|
+
method: "GET" | "POST" | "DELETE",
|
|
277
|
+
body?: unknown,
|
|
278
|
+
) => {
|
|
279
|
+
const child = Bun.spawn(
|
|
280
|
+
[
|
|
281
|
+
"docker",
|
|
282
|
+
"exec",
|
|
283
|
+
...(body === undefined ? [] : ["--interactive"]),
|
|
284
|
+
name,
|
|
285
|
+
"curl",
|
|
286
|
+
"--silent",
|
|
287
|
+
"--show-error",
|
|
288
|
+
"--request",
|
|
289
|
+
method,
|
|
290
|
+
...(body === undefined
|
|
291
|
+
? []
|
|
292
|
+
: ["--header", "Content-Type: application/json", "--data-binary", "@-"]),
|
|
293
|
+
`http://127.0.0.1:${port}/`,
|
|
294
|
+
],
|
|
295
|
+
{
|
|
296
|
+
stdin: body === undefined ? "ignore" : new Blob([JSON.stringify(body)]),
|
|
297
|
+
stdout: "pipe",
|
|
298
|
+
stderr: "pipe",
|
|
255
299
|
},
|
|
256
|
-
|
|
300
|
+
);
|
|
301
|
+
const [output, errors, code] = await Promise.all([
|
|
302
|
+
new Response(child.stdout).text(),
|
|
303
|
+
new Response(child.stderr).text(),
|
|
304
|
+
child.exited,
|
|
305
|
+
]);
|
|
306
|
+
if (code !== 0) throw new Error(errors.trim() || `docker exec failed (${code})`);
|
|
307
|
+
const reply = loginReplySchema.parse(JSON.parse(output));
|
|
308
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
309
|
+
return reply.result;
|
|
257
310
|
};
|
|
258
|
-
|
|
311
|
+
|
|
312
|
+
/** The container answers once its server listens, which for a login is once CODESYS has logged in. */
|
|
313
|
+
const answers = (name: string, port: number) =>
|
|
314
|
+
callContainer(name, port, "GET").then(
|
|
315
|
+
() => true,
|
|
316
|
+
() => false,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
/** A login held open in builder container `name`; it ends with `close` or when the container stops. */
|
|
320
|
+
const attachLogin = (name: string, timeout: number) => ({
|
|
321
|
+
name,
|
|
322
|
+
ready: () => answers(name, loginPort),
|
|
323
|
+
send: async (command: LoginCommand) =>
|
|
324
|
+
loginResultSchema.parse(await callContainer(name, loginPort, "POST", command)),
|
|
325
|
+
/** Logs out and waits for the container to stop. */
|
|
326
|
+
close: async () => {
|
|
327
|
+
await callContainer(name, loginPort, "DELETE");
|
|
328
|
+
await docker(["wait", name], timeout);
|
|
329
|
+
await docker(["rm", name], timeout);
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
export type ControllerLogin = ReturnType<typeof attachLogin>;
|
|
259
333
|
|
|
260
334
|
/** The login in container `name`, if one is running; it outlives the process that opened it. */
|
|
261
335
|
export const findControllerLogin = async (name: string) =>
|
|
@@ -266,7 +340,8 @@ export const findControllerLogin = async (name: string) =>
|
|
|
266
340
|
* container named `name`. Commands then take seconds instead of a CODESYS start each.
|
|
267
341
|
*/
|
|
268
342
|
export const openControllerLogin = async (config: ResolvedConfig, name: string) => {
|
|
269
|
-
const
|
|
343
|
+
const environmentNames = credentialEnvironment(config);
|
|
344
|
+
const { imageId, target } = await controllerConnection(config);
|
|
270
345
|
const timeout = config.timeouts.hostCommand;
|
|
271
346
|
// A login that failed to open leaves its stopped container for diagnostics until the next one.
|
|
272
347
|
await docker(["rm", "--force", name], timeout);
|
|
@@ -280,8 +355,6 @@ export const openControllerLogin = async (config: ResolvedConfig, name: string)
|
|
|
280
355
|
"--detach",
|
|
281
356
|
"--name",
|
|
282
357
|
name,
|
|
283
|
-
"--publish",
|
|
284
|
-
`127.0.0.1::${loginPort}`,
|
|
285
358
|
...(await prepareContainerRun(config, requestDirectory, {
|
|
286
359
|
network: target.network,
|
|
287
360
|
environment: environmentNames,
|
|
@@ -298,19 +371,129 @@ export const openControllerLogin = async (config: ResolvedConfig, name: string)
|
|
|
298
371
|
],
|
|
299
372
|
timeout,
|
|
300
373
|
);
|
|
374
|
+
const login = attachLogin(name, timeout);
|
|
301
375
|
const deadline = Date.now() + config.timeouts.startup + config.timeouts.build;
|
|
302
376
|
while (Date.now() < deadline) {
|
|
303
|
-
|
|
304
|
-
const login = (await isRunning(name, timeout))
|
|
305
|
-
? await attachLogin(name, timeout).catch(() => undefined)
|
|
306
|
-
: undefined;
|
|
307
|
-
if (!login && !(await isRunning(name, timeout))) {
|
|
377
|
+
if (!(await isRunning(name, timeout))) {
|
|
308
378
|
const logs = await execute("docker", ["logs", "--tail", "40", name], { timeout });
|
|
309
379
|
throw new Error(`The controller login failed:\n${logs.stdout}${logs.stderr}`);
|
|
310
380
|
}
|
|
311
|
-
if (
|
|
381
|
+
if (await login.ready()) return login;
|
|
312
382
|
await delay(2000);
|
|
313
383
|
}
|
|
314
384
|
await docker(["rm", "--force", name], timeout);
|
|
315
385
|
throw new Error("The controller login did not open in time");
|
|
316
386
|
};
|
|
387
|
+
|
|
388
|
+
/** Builder container `name`, running warm on this image, target and settings, started if needed. */
|
|
389
|
+
const warmController = async (
|
|
390
|
+
config: ResolvedConfig,
|
|
391
|
+
name: string,
|
|
392
|
+
connection: Awaited<ReturnType<typeof controllerConnection>>,
|
|
393
|
+
) => {
|
|
394
|
+
const timeout = config.timeouts.hostCommand;
|
|
395
|
+
const { imageId, target } = connection;
|
|
396
|
+
const settings = createHash("sha256")
|
|
397
|
+
.update(JSON.stringify({ imageId, target, timeouts: config.timeouts, docker: config.docker }))
|
|
398
|
+
.digest("hex");
|
|
399
|
+
const label = `tsplc.warm=${settings}`;
|
|
400
|
+
const running = async () =>
|
|
401
|
+
(await docker(
|
|
402
|
+
["container", "ls", "--quiet", "--filter", `name=^${name}$`, "--filter", `label=${label}`],
|
|
403
|
+
timeout,
|
|
404
|
+
)) !== "";
|
|
405
|
+
if (!(await running())) {
|
|
406
|
+
await docker(["rm", "--force", name], timeout);
|
|
407
|
+
// Its own request: the operations' configs come later, one per release.
|
|
408
|
+
const requestDirectory = resolve(config.outDir, ".codesys/warm-request");
|
|
409
|
+
await mkdir(requestDirectory, { recursive: true });
|
|
410
|
+
await docker(
|
|
411
|
+
[
|
|
412
|
+
"run",
|
|
413
|
+
"--detach",
|
|
414
|
+
"--name",
|
|
415
|
+
name,
|
|
416
|
+
"--label",
|
|
417
|
+
label,
|
|
418
|
+
...(await prepareContainerRun(config, requestDirectory, {
|
|
419
|
+
network: target.network,
|
|
420
|
+
sources: false,
|
|
421
|
+
})),
|
|
422
|
+
imageId,
|
|
423
|
+
"warm",
|
|
424
|
+
"--request",
|
|
425
|
+
"/input/config.json",
|
|
426
|
+
"--image-id",
|
|
427
|
+
imageId,
|
|
428
|
+
"--port",
|
|
429
|
+
String(warmPort),
|
|
430
|
+
],
|
|
431
|
+
timeout,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const deadline = Date.now() + config.timeouts.startup;
|
|
435
|
+
while (Date.now() < deadline) {
|
|
436
|
+
if (!(await running())) {
|
|
437
|
+
const logs = await execute("docker", ["logs", "--tail", "40", name], { timeout });
|
|
438
|
+
throw new Error(`CODESYS failed to start:\n${logs.stdout}${logs.stderr}`);
|
|
439
|
+
}
|
|
440
|
+
if (await answers(name, warmPort)) return;
|
|
441
|
+
await delay(2000);
|
|
442
|
+
}
|
|
443
|
+
await docker(["rm", "--force", name], timeout);
|
|
444
|
+
throw new Error("CODESYS did not start in time");
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
/** Starts warm builder container `name` ahead of the first operation, unless it already runs. */
|
|
448
|
+
export const startWarmController = async (config: ResolvedConfig, name: string) => {
|
|
449
|
+
await warmController(config, name, await controllerConnection(config));
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* A download or online change in builder container `name`, which keeps CODESYS running between
|
|
454
|
+
* operations so each skips starting the container, Wine and CODESYS. The first operation, or one
|
|
455
|
+
* with a new image, target or settings, starts it. Credentials go to CODESYS for this operation only.
|
|
456
|
+
*/
|
|
457
|
+
export const updateWarmController = async (
|
|
458
|
+
config: ResolvedConfig,
|
|
459
|
+
name: string,
|
|
460
|
+
operation: "download" | "online-change",
|
|
461
|
+
options: { initializeUser?: boolean; credentials?: ControllerCredentials } = {},
|
|
462
|
+
): Promise<ControllerResult> => {
|
|
463
|
+
const connection = await controllerConnection(config);
|
|
464
|
+
await warmController(config, name, connection);
|
|
465
|
+
const operationId = randomUUID();
|
|
466
|
+
const diagnostics = resolve(config.outDir, ".codesys/controller");
|
|
467
|
+
const directory = resolve(config.outDir, ".codesys/requests", operationId);
|
|
468
|
+
try {
|
|
469
|
+
const request = await writeOperationRequest(
|
|
470
|
+
config,
|
|
471
|
+
directory,
|
|
472
|
+
posix.join("/output/.codesys/requests", operationId),
|
|
473
|
+
);
|
|
474
|
+
const result = await callContainer(name, warmPort, "POST", {
|
|
475
|
+
operation,
|
|
476
|
+
operationId,
|
|
477
|
+
request,
|
|
478
|
+
initializeUser: options.initializeUser ?? false,
|
|
479
|
+
credentials: options.credentials,
|
|
480
|
+
} satisfies z.input<typeof warmOperationSchema>).catch((error: unknown) => {
|
|
481
|
+
throw new Error(`${failureReason(error)}. Inspect ${diagnostics} for diagnostics`, {
|
|
482
|
+
cause: error,
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
return checkReceipt(
|
|
486
|
+
controllerResultSchema.parse(result),
|
|
487
|
+
{
|
|
488
|
+
operationId,
|
|
489
|
+
operation,
|
|
490
|
+
imageId: connection.imageId,
|
|
491
|
+
templateHash: connection.templateHash,
|
|
492
|
+
target: connection.target,
|
|
493
|
+
},
|
|
494
|
+
diagnostics,
|
|
495
|
+
);
|
|
496
|
+
} finally {
|
|
497
|
+
await rm(directory, { recursive: true, force: true });
|
|
498
|
+
}
|
|
499
|
+
};
|