@tsplc/build 0.1.0 → 0.2.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/dist/cli.js +57 -13
- package/dist/cli.js.map +8 -8
- package/dist/index.js +146 -15
- package/dist/index.js.map +7 -7
- package/docker/Dockerfile.dockerignore +1 -0
- package/docker/edge-gateway/Dockerfile +17 -0
- package/docker/runner.ts +31 -6
- package/package.json +3 -3
- package/python/build.py +6 -1
- package/python/codesys_login.py +100 -0
- package/python/codesys_online.py +47 -20
- package/python/test_codesys_login.py +120 -0
- package/python/test_codesys_online.py +14 -0
- package/python/worker.py +13 -0
- package/src/controller-online.ts +170 -16
- package/src/controller-session.ts +72 -3
- package/src/diagnostics.ts +2 -0
- package/src/docker-build.ts +15 -0
- package/src/docker-run.ts +3 -4
- package/src/host/codesys-worker.ts +9 -6
- package/src/index.ts +19 -2
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.
|
|
18
|
+
"version": "0.2.0",
|
|
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.
|
|
47
|
-
"@tsplc/typegen": "0.
|
|
46
|
+
"@tsplc/config": "0.2.0",
|
|
47
|
+
"@tsplc/typegen": "0.2.0"
|
|
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
|
|
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()
|
package/python/codesys_online.py
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
package/src/controller-online.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
};
|