@tsplc/build 0.2.8 → 0.2.10

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/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.
@@ -139,7 +139,25 @@ def fail_on_errors_after_transfer(engine, progress):
139
139
  raise CompilerError(errors)
140
140
 
141
141
 
142
- def connect_controller(engine, application, settings):
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
- gateway = engine.online.gateways.add_new_gateway('codesys-build', {0: gateway_ip, 1: settings['gateway']['port']})
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.get('credentials')
154
- if not credentials:
155
- return device, None
156
- username, password = (os.environ.get(credentials[key]) for key in ('usernameEnv', 'passwordEnv'))
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
- if loaded.get("project") is not None:
55
- loaded["project"].close()
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
- if loaded.get("project") is not None:
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":
@@ -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 { environmentNames, imageId, templateHash, target } = await controllerConnection(config);
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
- if (
154
- result.operationId !== operationId ||
155
- result.operation !== operation ||
156
- result.imageId !== imageId ||
157
- result.templateHash !== templateHash ||
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 port a login container serves its commands on, published to the host's loopback only. */
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
- /** A login held open in builder container `name`; it ends with `close` or when the container stops. */
227
- const attachLogin = async (name: string, timeout: number) => {
228
- const published = await docker(["port", name, String(loginPort)], timeout);
229
- const url = `http://${published.split("\n")[0]}`;
230
- const call = async (init: RequestInit) => {
231
- const reply = loginReplySchema.parse(await (await fetch(url, init)).json());
232
- if (!reply.ok) throw new Error(reply.error);
233
- return reply.result;
234
- };
235
- return {
236
- name,
237
- /** The relay answers only once CODESYS has logged in. */
238
- ready: () =>
239
- fetch(url)
240
- .then((response) => response.ok)
241
- .catch(() => false),
242
- send: async (command: LoginCommand) =>
243
- loginResultSchema.parse(
244
- await call({
245
- method: "POST",
246
- headers: { "Content-Type": "application/json" },
247
- body: JSON.stringify(command),
248
- }),
249
- ),
250
- /** Logs out and waits for the container to stop. */
251
- close: async () => {
252
- await call({ method: "DELETE" });
253
- await docker(["wait", name], timeout);
254
- await docker(["rm", name], timeout);
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
- export type ControllerLogin = Awaited<ReturnType<typeof attachLogin>>;
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 { environmentNames, imageId, target } = await controllerConnection(config);
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
- // A container that stops right after starting never publishes its port, so check it first.
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 (login && (await login.ready())) return login;
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
+ };