@tsplc/build 0.1.0 → 0.2.1

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/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "url": "git+https://github.com/livingforjesus/tsplc.git",
16
16
  "directory": "packages/build"
17
17
  },
18
- "version": "0.1.0",
18
+ "version": "0.2.1",
19
19
  "type": "module",
20
20
  "exports": {
21
21
  ".": {
@@ -43,8 +43,8 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "4.3.6",
46
- "@tsplc/config": "0.1.0",
47
- "@tsplc/typegen": "0.1.0"
46
+ "@tsplc/config": "0.2.1",
47
+ "@tsplc/typegen": "0.2.1"
48
48
  },
49
49
  "bin": {
50
50
  "tsplc-build": "./dist/cli.js"
package/python/build.py CHANGED
@@ -77,12 +77,17 @@ def diagnostic(message, category):
77
77
  return result
78
78
 
79
79
 
80
- def fail_on_errors(engine):
80
+ def reported_errors(engine):
81
81
  errors = []
82
82
  for category in engine.system.get_message_categories():
83
83
  errors.extend(diagnostic(message, category) for message in engine.system.get_message_objects(
84
84
  category, engine.Severity.Error | engine.Severity.FatalError,
85
85
  ))
86
+ return errors
87
+
88
+
89
+ def fail_on_errors(engine):
90
+ errors = reported_errors(engine)
86
91
  if errors:
87
92
  raise CompilerError(errors)
88
93
 
@@ -0,0 +1,100 @@
1
+ """A long-lived login to the downloaded application; executed by CODESYS's IronPython engine."""
2
+ from __future__ import print_function
3
+
4
+ from codesys_online import (connect_controller, controller_ids, controller_state, core_application,
5
+ load_downloaded_reference, session_login)
6
+
7
+
8
+ def open_login(engine, request):
9
+ """Log in to the controller's running application without changing it.
10
+
11
+ The project is a copy of the downloaded baseline, so the login never offers
12
+ a download: the controller and the compiler reference must both match it.
13
+ """
14
+ baseline = request['baseline']
15
+ project = engine.projects.open(request['project'], primary=True)
16
+ online_app = None
17
+ try:
18
+ application = project.active_application
19
+ if application is None or str(application.guid) != baseline['applicationGuid']:
20
+ raise RuntimeError('The saved project does not contain the downloaded application')
21
+ connect_controller(engine, application, request['online'])
22
+ online_app = engine.online.create_online_application(application)
23
+ core = core_application(online_app)
24
+ session_login(core)
25
+ try:
26
+ if controller_ids(core) != baseline['controllerIds']:
27
+ raise RuntimeError('The controller runs a different application than the last deployment')
28
+ if load_downloaded_reference(application) != baseline['controllerIds']:
29
+ raise RuntimeError('Downloaded compiler reference differs from the controller')
30
+ finally:
31
+ core.Logout()
32
+ # The core login, unlike the scripting wrapper's, never creates or downloads an application.
33
+ if not core.Login(True):
34
+ raise RuntimeError('Controller application disappeared before login')
35
+ except Exception:
36
+ if online_app is not None:
37
+ online_app.Dispose()
38
+ project.close()
39
+ raise
40
+ return {'project': project, 'online': online_app, 'core': core}
41
+
42
+
43
+ def apply_prepared(online_app, prepared, operation):
44
+ try:
45
+ operation()
46
+ except Exception as error:
47
+ failed = getattr(error, 'failed_expressions', None)
48
+ if failed is None:
49
+ raise
50
+ raise RuntimeError('The controller rejected ' + ', '.join(str(expression) for expression in failed))
51
+ finally:
52
+ # An unapplied preparation would otherwise ride along with the next write or force.
53
+ for expression in prepared:
54
+ online_app.set_prepared_value(expression, None)
55
+
56
+
57
+ def run_command(engine, login, command):
58
+ online_app = login['online']
59
+ name = command['command']
60
+ if name == 'read':
61
+ return {'values': {expression: online_app.read_value(expression) for expression in command['expressions']}}
62
+ if name in ('write', 'force'):
63
+ values = command['values']
64
+ if name == 'write':
65
+ # The controller rejects writing a forced variable with only a generic error.
66
+ forced = set(str(expression).lower() for expression in online_app.get_forced_expressions())
67
+ blocked = [expression for expression in values if expression.lower() in forced]
68
+ if blocked:
69
+ raise RuntimeError(', '.join(blocked) + ' is forced; unforce it before writing')
70
+ for expression, value in values.items():
71
+ online_app.set_prepared_value(expression, value)
72
+ apply_prepared(online_app, values.keys(),
73
+ online_app.write_prepared_values if name == 'write' else online_app.force_prepared_values)
74
+ elif name == 'unforce':
75
+ expressions = command['expressions']
76
+ if expressions:
77
+ for expression in expressions:
78
+ online_app.set_unforce_value(expression, command['restore'])
79
+ # CODESYS applies prepared unforces with its force command.
80
+ apply_prepared(online_app, expressions, online_app.force_prepared_values)
81
+ else:
82
+ apply_prepared(online_app, [], online_app.unforce_all_values)
83
+ elif name == 'start':
84
+ online_app.start()
85
+ elif name == 'stop':
86
+ online_app.stop()
87
+ elif name == 'reset':
88
+ online_app.reset(getattr(engine.ResetOption, command['kind']))
89
+ elif name != 'state':
90
+ raise ValueError('Unknown login command: ' + name)
91
+ return {'state': controller_state(login['core']),
92
+ 'forced': [str(expression) for expression in online_app.get_forced_expressions()]}
93
+
94
+
95
+ def close_login(login):
96
+ try:
97
+ login['online'].logout()
98
+ login['online'].Dispose()
99
+ finally:
100
+ login['project'].close()
@@ -4,8 +4,9 @@ from __future__ import print_function
4
4
  import io
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import time
8
- from build import apply_controller, fail_on_errors, import_objects, unique_child
9
+ from build import CompilerError, apply_controller, fail_on_errors, import_objects, reported_errors, unique_child
9
10
  from System import Activator, AppDomain, Array, Enum, Environment, Object
10
11
  from System.Net import Dns, IPAddress
11
12
  from System.Reflection import BindingFlags, TargetInvocationException
@@ -145,6 +146,47 @@ def reconcile(application, previous, current):
145
146
  remove_deleted(parent, [old], [fresh] if fresh else [])
146
147
 
147
148
 
149
+ def fail_on_errors_after_transfer(engine, progress):
150
+ """Like fail_on_errors, once the controller runs the verified application.
151
+
152
+ Logging in to a Linux SL controller loads its device's visualization placeholders, which
153
+ nothing in a tsPLC project resolves. The application compiled without them, so the
154
+ engineering session reports them rather than failing a completed transfer.
155
+ """
156
+ errors, unresolved = [], []
157
+ for error in reported_errors(engine):
158
+ placeholder = re.match(r"Could not open library '(#[^']+)'.*could not be resolved", error['text'])
159
+ if placeholder:
160
+ unresolved.append(placeholder.group(1))
161
+ else:
162
+ errors.append(error)
163
+ if unresolved:
164
+ progress('Ignoring library placeholders the login loaded but nothing resolves: ' + ', '.join(unresolved))
165
+ if errors:
166
+ raise CompilerError(errors)
167
+
168
+
169
+ def connect_controller(engine, application, settings):
170
+ """Route the application's controller through the configured gateway; returns it and its credentials."""
171
+ device = application.parent
172
+ while device is not None and not getattr(device, 'is_device', False):
173
+ device = device.parent
174
+ if device is None:
175
+ raise RuntimeError('Application has no controller device')
176
+ gateway_ip = str(Dns.GetHostAddresses(settings['gateway']['host'])[0])
177
+ device_ip = str(Dns.GetHostAddresses(settings['device']['host'])[0])
178
+ gateway = engine.online.gateways.add_new_gateway('codesys-build', {0: gateway_ip, 1: settings['gateway']['port']})
179
+ device.set_gateway_and_ip_address(gateway.guid, IPAddress.Parse(device_ip), settings['device']['port'])
180
+ credentials = settings.get('credentials')
181
+ if not credentials:
182
+ return device, None
183
+ username, password = (os.environ.get(credentials[key]) for key in ('usernameEnv', 'passwordEnv'))
184
+ if not username or not password:
185
+ raise RuntimeError('CODESYS online credential environment variables are required')
186
+ engine.online.set_specific_credentials(device, username, password)
187
+ return device, (username, password)
188
+
189
+
148
190
  def run_online(engine, request):
149
191
  progress_path = request.get('progress')
150
192
  def progress(stage):
@@ -163,27 +205,12 @@ def run_online(engine, request):
163
205
  progress('Project opened; configuring controller connection')
164
206
  if application is None:
165
207
  raise RuntimeError('Project has no active Application')
166
- device = application.parent
167
- while device is not None and not getattr(device, 'is_device', False):
168
- device = device.parent
169
- if device is None:
170
- raise RuntimeError('Application has no controller device')
171
- settings = request['online']
172
- gateway_ip = str(Dns.GetHostAddresses(settings['gateway']['host'])[0])
173
- device_ip = str(Dns.GetHostAddresses(settings['device']['host'])[0])
174
- gateway = engine.online.gateways.add_new_gateway('codesys-build', {0: gateway_ip, 1: settings['gateway']['port']})
175
- device.set_gateway_and_ip_address(gateway.guid, IPAddress.Parse(device_ip), settings['device']['port'])
176
- credentials = settings.get('credentials')
177
- if credentials:
178
- username, password = (os.environ.get(credentials[key]) for key in ('usernameEnv', 'passwordEnv'))
179
- if not username or not password:
180
- raise RuntimeError('CODESYS online credential environment variables are required')
181
- engine.online.set_specific_credentials(device, username, password)
208
+ device, credentials = connect_controller(engine, application, request['online'])
182
209
  online_device = engine.online.create_online_device(device)
183
210
  if request.get('initializeUser'):
184
211
  if request['operation'] != 'download' or not credentials:
185
212
  raise RuntimeError('Initial controller user requires an explicit download and credentials')
186
- online_device.set_credentials_for_initial_user(username, password)
213
+ online_device.set_credentials_for_initial_user(*credentials)
187
214
  progress('Controller connection configured')
188
215
  previous = request.get('baseline')
189
216
  if request['operation'] != 'download':
@@ -255,7 +282,7 @@ def run_online(engine, request):
255
282
  expected = controller_ids(core)
256
283
  if not application.is_uptodate:
257
284
  if not application.is_online_change_possible:
258
- raise RuntimeError('CODESYS requires a full download for this source; online change was refused')
285
+ raise RuntimeError('CODESYS cannot apply this source as an online change; use an explicit download')
259
286
  progress('Generating online-change code')
260
287
  generate_online_code(application)
261
288
  fail_on_errors(engine)
@@ -275,7 +302,7 @@ def run_online(engine, request):
275
302
  session_login(core)
276
303
  if controller_ids(core) != expected:
277
304
  raise RuntimeError('Controller did not retain the downloaded code/data identity')
278
- fail_on_errors(engine)
305
+ fail_on_errors_after_transfer(engine, progress)
279
306
  ids = controller_ids(core)
280
307
  state = controller_state(core)
281
308
  if state != 'run':
@@ -0,0 +1,120 @@
1
+ import sys
2
+ import unittest
3
+ from types import SimpleNamespace
4
+ from unittest.mock import Mock, patch
5
+
6
+ # Only .NET imports are unavailable in CPython.
7
+ with patch.dict(sys.modules, {
8
+ 'System': SimpleNamespace(Activator=None, AppDomain=None, Array=None, Object=None, Enum=None,
9
+ Environment=None),
10
+ 'System.Net': SimpleNamespace(Dns=None, IPAddress=None),
11
+ 'System.Reflection': SimpleNamespace(BindingFlags=None, TargetInvocationException=Exception),
12
+ }):
13
+ import codesys_login
14
+
15
+
16
+ class ValuesFailed(Exception):
17
+ def __init__(self, failed_expressions):
18
+ super().__init__('values failed')
19
+ self.failed_expressions = failed_expressions
20
+
21
+
22
+ class LoginTests(unittest.TestCase):
23
+ def setUp(self):
24
+ self.ids = {'codeId': 'code', 'dataId': 'data'}
25
+ self.application = SimpleNamespace(guid='application-guid')
26
+ self.project = SimpleNamespace(active_application=self.application, close=Mock())
27
+ self.core = SimpleNamespace(Login=Mock(return_value=True), Logout=Mock())
28
+ self.prepared = {}
29
+ self.online = SimpleNamespace(
30
+ Dispose=Mock(), logout=Mock(), start=Mock(), stop=Mock(), reset=Mock(),
31
+ read_value=Mock(side_effect=lambda expression: 'TRUE'),
32
+ set_prepared_value=Mock(side_effect=self.prepare),
33
+ set_unforce_value=Mock(side_effect=lambda expression, restore: self.prepare(expression, '<unforce>')),
34
+ write_prepared_values=Mock(), force_prepared_values=Mock(), unforce_all_values=Mock(),
35
+ get_forced_expressions=Mock(return_value=['Line.enabled']),
36
+ )
37
+ self.engine = SimpleNamespace(
38
+ projects=SimpleNamespace(open=Mock(return_value=self.project)),
39
+ online=SimpleNamespace(create_online_application=Mock(return_value=self.online)),
40
+ ResetOption=SimpleNamespace(Warm='warm', Cold='cold'),
41
+ )
42
+ patcher = patch.multiple(
43
+ codesys_login,
44
+ connect_controller=Mock(),
45
+ core_application=Mock(return_value=self.core),
46
+ session_login=Mock(),
47
+ controller_ids=Mock(return_value=self.ids),
48
+ controller_state=Mock(return_value='run'),
49
+ load_downloaded_reference=Mock(return_value=self.ids),
50
+ )
51
+ patcher.start()
52
+ self.addCleanup(patcher.stop)
53
+ self.request = {
54
+ 'project': 'Application.project',
55
+ 'online': {},
56
+ 'baseline': {'applicationGuid': 'application-guid', 'controllerIds': self.ids},
57
+ }
58
+
59
+ def prepare(self, expression, value):
60
+ if value is None:
61
+ self.prepared.pop(expression, None)
62
+ else:
63
+ self.prepared[expression] = value
64
+
65
+ def test_open_logs_in_to_the_running_application_after_checking_its_identity(self):
66
+ login = codesys_login.open_login(self.engine, self.request)
67
+ self.core.Logout.assert_called_once()
68
+ self.core.Login.assert_called_once_with(True)
69
+ self.assertIs(login['online'], self.online)
70
+
71
+ def test_open_refuses_a_controller_running_another_application(self):
72
+ codesys_login.controller_ids.return_value = {'codeId': 'other', 'dataId': 'data'}
73
+ with self.assertRaisesRegex(RuntimeError, 'different application'):
74
+ codesys_login.open_login(self.engine, self.request)
75
+ self.core.Login.assert_not_called()
76
+ self.online.Dispose.assert_called_once()
77
+ self.project.close.assert_called_once()
78
+
79
+ def test_force_applies_prepared_values_and_reports_forced_expressions(self):
80
+ login = codesys_login.open_login(self.engine, self.request)
81
+ seen = {}
82
+ self.online.force_prepared_values.side_effect = lambda: seen.update(self.prepared)
83
+ result = codesys_login.run_command(
84
+ self.engine, login, {'command': 'force', 'values': {'Line.enabled': 'TRUE'}})
85
+ self.assertEqual(seen, {'Line.enabled': 'TRUE'})
86
+ self.assertEqual(self.prepared, {})
87
+ self.assertEqual(result, {'state': 'run', 'forced': ['Line.enabled']})
88
+
89
+ def test_rejected_write_names_the_expressions_and_clears_preparation(self):
90
+ login = codesys_login.open_login(self.engine, self.request)
91
+ self.online.write_prepared_values.side_effect = ValuesFailed(['Line.speed'])
92
+ with self.assertRaisesRegex(RuntimeError, 'rejected Line.speed'):
93
+ codesys_login.run_command(
94
+ self.engine, login, {'command': 'write', 'values': {'Line.speed': '200'}})
95
+ self.assertEqual(self.prepared, {})
96
+
97
+ def test_writing_a_forced_expression_names_the_force(self):
98
+ login = codesys_login.open_login(self.engine, self.request)
99
+ with self.assertRaisesRegex(RuntimeError, 'line.ENABLED is forced'):
100
+ codesys_login.run_command(
101
+ self.engine, login, {'command': 'write', 'values': {'line.ENABLED': 'TRUE'}})
102
+ self.online.write_prepared_values.assert_not_called()
103
+
104
+ def test_unforce_releases_named_expressions_or_everything(self):
105
+ login = codesys_login.open_login(self.engine, self.request)
106
+ codesys_login.run_command(
107
+ self.engine, login, {'command': 'unforce', 'expressions': ['Line.enabled'], 'restore': True})
108
+ self.online.set_unforce_value.assert_called_once_with('Line.enabled', True)
109
+ self.online.force_prepared_values.assert_called_once()
110
+ codesys_login.run_command(self.engine, login, {'command': 'unforce', 'expressions': [], 'restore': False})
111
+ self.online.unforce_all_values.assert_called_once()
112
+
113
+ def test_reset_uses_the_named_reset_kind(self):
114
+ login = codesys_login.open_login(self.engine, self.request)
115
+ codesys_login.run_command(self.engine, login, {'command': 'reset', 'kind': 'Cold'})
116
+ self.online.reset.assert_called_once_with('cold')
117
+
118
+
119
+ if __name__ == '__main__':
120
+ unittest.main()
@@ -1,4 +1,6 @@
1
+ import os
1
2
  import sys
3
+ import tempfile
2
4
  import unittest
3
5
  from types import SimpleNamespace
4
6
  from unittest.mock import Mock, patch
@@ -151,6 +153,18 @@ class OnlineLifecycleTests(unittest.TestCase):
151
153
  self.core.CreateBootProject.assert_called_once()
152
154
  self.project.close.assert_called_once()
153
155
 
156
+ def test_full_download_reports_device_placeholders_a_login_cannot_resolve(self):
157
+ def report_unresolved_placeholder(core):
158
+ self.messages.append(SimpleNamespace(text="Could not open library '#System_VisuInputs'. "
159
+ "(Reason: The placeholder library 'System_VisuInputs' could not be resolved.)"))
160
+ codesys_online.session_login.side_effect = report_unresolved_placeholder
161
+ with tempfile.TemporaryDirectory() as directory:
162
+ self.request['progress'] = os.path.join(directory, 'progress.log')
163
+ result = codesys_online.run_online(self.engine, self.request)
164
+ with open(self.request['progress']) as log:
165
+ self.assertIn('nothing resolves: #System_VisuInputs', log.read())
166
+ self.assertEqual(result['state'], 'run')
167
+
154
168
  def test_boot_failure_preserves_vendor_clr_stack_and_never_confirms_completion(self):
155
169
  stack = ('System.ArgumentException: The path is not of a legal form.\n'
156
170
  ' at System.IO.Path.GetFullPath(String path)\n'
package/python/worker.py CHANGED
@@ -58,6 +58,19 @@ def serve(engine, directory):
58
58
  with io.open(os.path.join(root, "request.json"), encoding="utf-8") as source:
59
59
  request = json.load(source)
60
60
  write_json(os.path.join(root, "result.json"), run_online(engine, request))
61
+ elif command["action"] == "login":
62
+ from codesys_login import close_login, open_login, run_command
63
+ arguments = command["arguments"]
64
+ if arguments["command"] == "open":
65
+ if loaded.get("project") is not None:
66
+ loaded["project"].close()
67
+ loaded.clear()
68
+ loaded["login"] = open_login(engine, arguments)
69
+ response["result"] = run_command(engine, loaded["login"], {"command": "state"})
70
+ elif arguments["command"] == "close":
71
+ close_login(loaded.pop("login"))
72
+ else:
73
+ response["result"] = run_command(engine, loaded["login"], arguments)
61
74
  elif command["action"] not in ("ping", "stop"):
62
75
  raise ValueError("Unknown worker action")
63
76
  except Exception as error:
@@ -1,11 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import { createHash, randomUUID } from "node:crypto";
3
4
  import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { resolve } from "node:path";
7
+ import { setTimeout as delay } from "node:timers/promises";
6
8
  import { promisify } from "node:util";
7
9
  import { z } from "zod";
8
10
  import { controllerSchema, dockerImage, type ResolvedConfig } from "./config";
11
+ import { controllerFailureFile } from "./diagnostics.ts";
9
12
  import { prepareContainerRun, runDocker } from "./docker-run.ts";
10
13
 
11
14
  const execute = promisify(execFile);
@@ -42,11 +45,8 @@ export type ControllerUpdateOptions = {
42
45
  read?: string[];
43
46
  };
44
47
 
45
- const controllerOperation = async (
46
- config: ResolvedConfig,
47
- operation: ControllerResult["operation"],
48
- options: ControllerUpdateOptions,
49
- ): Promise<ControllerResult> => {
48
+ /** Checks the settings every engineering connection needs and names its builder image and target. */
49
+ const controllerConnection = async (config: ResolvedConfig) => {
50
50
  if (!config.docker || !config.online) {
51
51
  throw new Error(
52
52
  "Docker image and online gateway/device configuration are required for controller operations",
@@ -70,23 +70,38 @@ const controllerOperation = async (
70
70
  { cause },
71
71
  );
72
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,
73
+ return {
74
+ environmentNames,
75
+ imageId: imageIdSchema.parse(stdout.trim()),
76
+ templateHash: createHash("sha256")
77
+ .update(await readFile(config.template))
78
+ .digest("hex"),
79
+ target: {
80
+ network: config.online.network,
81
+ gateway: config.online.gateway,
82
+ device: config.online.device,
83
+ controller: config.controller,
84
+ },
83
85
  };
86
+ };
87
+
88
+ const controllerOperation = async (
89
+ config: ResolvedConfig,
90
+ operation: ControllerResult["operation"],
91
+ options: ControllerUpdateOptions,
92
+ ): Promise<ControllerResult> => {
93
+ const { environmentNames, imageId, templateHash, target } = await controllerConnection(config);
94
+ const operationId = randomUUID();
84
95
  const requestDirectory = await mkdtemp(resolve(tmpdir(), "codesys-controller-"));
85
96
  const diagnostics = resolve(config.outDir, ".codesys/controller");
97
+ const failure = resolve(config.outDir, controllerFailureFile);
86
98
  try {
87
99
  await mkdir(config.outDir, { recursive: true });
100
+ await rm(failure, { force: true });
88
101
  await runDocker(
89
102
  [
103
+ "run",
104
+ "--rm",
90
105
  ...(await prepareContainerRun(config, requestDirectory, {
91
106
  network: target.network,
92
107
  environment: environmentNames,
@@ -105,7 +120,12 @@ const controllerOperation = async (
105
120
  ],
106
121
  (status) =>
107
122
  `Docker controller operation failed (${status}). Inspect ${diagnostics} for diagnostics`,
108
- );
123
+ ).catch(async (cause: unknown) => {
124
+ if (!existsSync(failure)) throw cause;
125
+ // A CODESYS failure arrives as a Python traceback, whose last line names it.
126
+ const reason = (await readFile(failure, "utf8")).trim().split("\n").at(-1);
127
+ throw new Error(`${reason}. Inspect ${diagnostics} for diagnostics`, { cause });
128
+ });
109
129
  const result = await readFile(resolve(diagnostics, "result.json"), "utf8")
110
130
  .then((content) => controllerResultSchema.parse(JSON.parse(content)))
111
131
  .catch((cause: unknown) => {
@@ -141,3 +161,137 @@ export const updateController = (
141
161
  /** Read the controller using the runner's saved compilation baseline. */
142
162
  export const inspectController = (config: ResolvedConfig, read?: string[]) =>
143
163
  controllerOperation(config, "inspect", { read });
164
+
165
+ /**
166
+ * Ends every refusal that only a full download resolves, so callers can offer one. The Python
167
+ * online operations end theirs with the same words.
168
+ */
169
+ export const explicitDownload = "use an explicit download";
170
+
171
+ export const loginCommandSchema = z.discriminatedUnion("command", [
172
+ z.strictObject({ command: z.literal("state") }),
173
+ z.strictObject({
174
+ command: z.literal("read"),
175
+ expressions: z.array(z.string().nonempty()).nonempty(),
176
+ }),
177
+ z.strictObject({
178
+ command: z.enum(["write", "force"]),
179
+ values: z.record(z.string().nonempty(), z.string().nonempty()),
180
+ }),
181
+ // No expressions unforces everything; `restore` puts back the value from before forcing.
182
+ z.strictObject({
183
+ command: z.literal("unforce"),
184
+ expressions: z.array(z.string().nonempty()),
185
+ restore: z.boolean(),
186
+ }),
187
+ z.strictObject({ command: z.enum(["start", "stop"]) }),
188
+ z.strictObject({ command: z.literal("reset"), kind: z.enum(["Warm", "Cold"]) }),
189
+ ]);
190
+ export type LoginCommand = z.infer<typeof loginCommandSchema>;
191
+ export const loginResultSchema = z.union([
192
+ z.strictObject({ values: z.record(z.string(), z.string()) }),
193
+ z.strictObject({ state: z.string(), forced: z.array(z.string()) }),
194
+ ]);
195
+ export type LoginResult = z.infer<typeof loginResultSchema>;
196
+ const loginReplySchema = z.discriminatedUnion("ok", [
197
+ z.strictObject({ ok: z.literal(true), result: z.unknown() }),
198
+ z.strictObject({ ok: z.literal(false), error: z.string() }),
199
+ ]);
200
+
201
+ /** The port a login container serves its commands on, published to the host's loopback only. */
202
+ const loginPort = 4851;
203
+
204
+ const docker = (args: string[], timeout: number) =>
205
+ execute("docker", args, { timeout }).then(({ stdout }) => stdout.trim());
206
+
207
+ const isRunning = async (name: string, timeout: number) =>
208
+ (await docker(["container", "ls", "--quiet", "--filter", `name=^${name}$`], timeout)) !== "";
209
+
210
+ /** A login held open in builder container `name`; it ends with `close` or when the container stops. */
211
+ const attachLogin = async (name: string, timeout: number) => {
212
+ const published = await docker(["port", name, String(loginPort)], timeout);
213
+ const url = `http://${published.split("\n")[0]}`;
214
+ const call = async (init: RequestInit) => {
215
+ const reply = loginReplySchema.parse(await (await fetch(url, init)).json());
216
+ if (!reply.ok) throw new Error(reply.error);
217
+ return reply.result;
218
+ };
219
+ return {
220
+ name,
221
+ /** The relay answers only once CODESYS has logged in. */
222
+ ready: () =>
223
+ fetch(url)
224
+ .then((response) => response.ok)
225
+ .catch(() => false),
226
+ send: async (command: LoginCommand) =>
227
+ loginResultSchema.parse(
228
+ await call({
229
+ method: "POST",
230
+ headers: { "Content-Type": "application/json" },
231
+ body: JSON.stringify(command),
232
+ }),
233
+ ),
234
+ /** Logs out and waits for the container to stop. */
235
+ close: async () => {
236
+ await call({ method: "DELETE" });
237
+ await docker(["wait", name], timeout);
238
+ await docker(["rm", name], timeout);
239
+ },
240
+ };
241
+ };
242
+ export type ControllerLogin = Awaited<ReturnType<typeof attachLogin>>;
243
+
244
+ /** The login in container `name`, if one is running; it outlives the process that opened it. */
245
+ export const findControllerLogin = async (name: string) =>
246
+ (await isRunning(name, 15_000)) ? attachLogin(name, 15_000) : undefined;
247
+
248
+ /**
249
+ * Logs in to the running application from the saved download baseline, in a detached builder
250
+ * container named `name`. Commands then take seconds instead of a CODESYS start each.
251
+ */
252
+ export const openControllerLogin = async (config: ResolvedConfig, name: string) => {
253
+ const { environmentNames, imageId, target } = await controllerConnection(config);
254
+ const timeout = config.timeouts.hostCommand;
255
+ // A login that failed to open leaves its stopped container for diagnostics until the next one.
256
+ await docker(["rm", "--force", name], timeout);
257
+ // The request is overwritten in place: a bind mount of a recreated directory can still show
258
+ // Docker Desktop's cached, empty view of the deleted one.
259
+ const requestDirectory = resolve(config.outDir, ".codesys/login-request");
260
+ await mkdir(requestDirectory, { recursive: true });
261
+ await docker(
262
+ [
263
+ "run",
264
+ "--detach",
265
+ "--name",
266
+ name,
267
+ "--publish",
268
+ `127.0.0.1::${loginPort}`,
269
+ ...(await prepareContainerRun(config, requestDirectory, {
270
+ network: target.network,
271
+ environment: environmentNames,
272
+ sources: false,
273
+ })),
274
+ imageId,
275
+ "login",
276
+ "--request",
277
+ "/input/config.json",
278
+ "--image-id",
279
+ imageId,
280
+ "--port",
281
+ String(loginPort),
282
+ ],
283
+ timeout,
284
+ );
285
+ const deadline = Date.now() + config.timeouts.startup + config.timeouts.build;
286
+ while (Date.now() < deadline) {
287
+ if (!(await isRunning(name, timeout))) {
288
+ const logs = await execute("docker", ["logs", "--tail", "40", name], { timeout });
289
+ throw new Error(`The controller login failed:\n${logs.stdout}${logs.stderr}`);
290
+ }
291
+ const login = await attachLogin(name, timeout);
292
+ if (await login.ready()) return login;
293
+ await delay(2000);
294
+ }
295
+ await docker(["rm", "--force", name], timeout);
296
+ throw new Error("The controller login did not open in time");
297
+ };