@tsplc/build 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist/cli.js +1696 -0
- package/dist/cli.js.map +34 -0
- package/dist/index.js +1683 -0
- package/dist/index.js.map +31 -0
- package/dist/libraries.js +128 -0
- package/dist/libraries.js.map +10 -0
- package/docker/Dockerfile +173 -0
- package/docker/Dockerfile.dockerignore +18 -0
- package/docker/Dockerfile.source +15 -0
- package/docker/build-image +38 -0
- package/docker/codesys-build +5 -0
- package/docker/config.ts +8 -0
- package/docker/control-linux-sl/Dockerfile +38 -0
- package/docker/edge-gateway/Dockerfile +17 -0
- package/docker/entrypoint.sh +10 -0
- package/docker/install/NOTICE.md +21 -0
- package/docker/install/core.sh +36 -0
- package/docker/install/decode.c +12 -0
- package/docker/install/device/Runtime.ico +0 -0
- package/docker/install/device/Runtime.png +0 -0
- package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
- package/docker/install/extract.py +43 -0
- package/docker/install/helper-launcher.c +46 -0
- package/docker/install/install.sh +94 -0
- package/docker/install/prerequisites.sh +15 -0
- package/docker/install/verify.py +133 -0
- package/docker/install/verify.sh +34 -0
- package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
- package/docker/install/wine-ncrypt/README.md +28 -0
- package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
- package/docker/install/wine-ncrypt/main.c +619 -0
- package/docker/install/wine-ncrypt/oaep.patch +11 -0
- package/docker/install/wine-signal/README.md +22 -0
- package/docker/install/wine-signal/build.sh +10 -0
- package/docker/install/wine-signal/check.sh +34 -0
- package/docker/install/wine-signal/noop-i386.c +2 -0
- package/docker/install/wine-signal/signal-state.c +105 -0
- package/docker/install/wine-signal/tests/avx-registers.c +53 -0
- package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
- package/docker/install/wine-signal/tests/loader-i386.c +8 -0
- package/docker/install/wine-signal/tests/signal-return.c +47 -0
- package/docker/install/wine-signal/wine +4 -0
- package/docker/runner.ts +156 -0
- package/package.json +53 -4
- package/python/build.py +660 -0
- package/python/codesys_login.py +100 -0
- package/python/codesys_online.py +327 -0
- package/python/test_build.py +444 -0
- package/python/test_codesys_login.py +120 -0
- package/python/test_codesys_online.py +184 -0
- package/python/test_installer_packages.py +216 -0
- package/python/test_installer_verification.py +307 -0
- package/python/test_worker.py +142 -0
- package/python/worker.py +102 -0
- package/runtime/install.sh +74 -0
- package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
- package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
- package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
- package/src/artifacts.ts +77 -0
- package/src/build.ts +87 -0
- package/src/cli.ts +116 -0
- package/src/config.ts +12 -0
- package/src/controller-online.ts +297 -0
- package/src/controller-session.ts +298 -0
- package/src/diagnostics.ts +68 -0
- package/src/docker-build.ts +163 -0
- package/src/docker-context.ts +35 -0
- package/src/docker-run.ts +103 -0
- package/src/host/codesys-command.ts +45 -0
- package/src/host/codesys-host.ts +145 -0
- package/src/host/codesys-worker.ts +348 -0
- package/src/host/windows-path.ts +73 -0
- package/src/index.ts +43 -0
- package/src/io.ts +127 -0
- package/src/libraries.ts +166 -0
- package/src/package-path.ts +5 -0
- package/src/run.ts +133 -0
- package/src/runtime-status.ts +21 -0
- package/src/sources.ts +248 -0
- package/src/st/declarations.ts +229 -0
- package/src/st/lexer.ts +125 -0
- package/src/st/parse.ts +202 -0
- package/src/st/reader.ts +50 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import runpy
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from unittest.mock import Mock, patch
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
path = Path(__file__).parent.parent / 'docker' / 'install' / 'verify.py'
|
|
17
|
+
spec = importlib.util.spec_from_file_location('installer_verification', path)
|
|
18
|
+
verification = importlib.util.module_from_spec(spec)
|
|
19
|
+
spec.loader.exec_module(verification)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# The bundled ARM64 Virtual Control description, which leaves byte-addressing at its default.
|
|
23
|
+
DEVICE_DESCRIPTION = (
|
|
24
|
+
Path(__file__).parent.parent / 'docker' / 'install' / 'device' / 'codesyscontrol.devdesc.xml'
|
|
25
|
+
).read_text(encoding='utf-8')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def target_setting(name, value):
|
|
29
|
+
return (
|
|
30
|
+
'<ts:section name="memory-layout"><ts:setting name="{0}" type="boolean" access="hide">'
|
|
31
|
+
'<ts:value>{1}</ts:value></ts:setting></ts:section>'
|
|
32
|
+
).format(name, value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InstallerVerificationTests(unittest.TestCase):
|
|
36
|
+
def setUp(self):
|
|
37
|
+
patcher = patch.dict(sys.modules, {
|
|
38
|
+
'System': SimpleNamespace(Guid=lambda value: value),
|
|
39
|
+
})
|
|
40
|
+
patcher.start()
|
|
41
|
+
self.addCleanup(patcher.stop)
|
|
42
|
+
self.description = DEVICE_DESCRIPTION
|
|
43
|
+
opener = patch.object(io, 'open', side_effect=lambda path, encoding: io.StringIO(self.description))
|
|
44
|
+
opener.start()
|
|
45
|
+
self.addCleanup(opener.stop)
|
|
46
|
+
self.events = []
|
|
47
|
+
self.messages = []
|
|
48
|
+
self.program = SimpleNamespace(
|
|
49
|
+
textual_declaration=SimpleNamespace(replace=Mock()),
|
|
50
|
+
textual_implementation=SimpleNamespace(replace=Mock()),
|
|
51
|
+
)
|
|
52
|
+
self.task = SimpleNamespace(pous=SimpleNamespace(add=Mock()))
|
|
53
|
+
self.manager = SimpleNamespace(
|
|
54
|
+
get_libraries=Mock(side_effect=lambda **args: self.events.append('libraries')),
|
|
55
|
+
)
|
|
56
|
+
task_configuration = SimpleNamespace(create_task=Mock(return_value=self.task))
|
|
57
|
+
self.application = SimpleNamespace(
|
|
58
|
+
create_program=Mock(return_value=self.program),
|
|
59
|
+
create_task_configuration=Mock(return_value=task_configuration),
|
|
60
|
+
create_symbol_config=Mock(),
|
|
61
|
+
get_library_manager=Mock(return_value=self.manager),
|
|
62
|
+
clean=Mock(side_effect=lambda: self.events.append('clean')),
|
|
63
|
+
rebuild=Mock(side_effect=lambda: self.events.append('compile')),
|
|
64
|
+
generate_code=Mock(side_effect=lambda: self.events.append('generate')),
|
|
65
|
+
create_boot_application=Mock(side_effect=lambda *args: self.events.append('boot')),
|
|
66
|
+
)
|
|
67
|
+
self.project = SimpleNamespace(
|
|
68
|
+
active_application=self.application,
|
|
69
|
+
add=Mock(),
|
|
70
|
+
save=Mock(),
|
|
71
|
+
close=Mock(side_effect=lambda: self.events.append('close')),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def opened(*args, **kwargs):
|
|
75
|
+
self.events.append('open')
|
|
76
|
+
return self.project
|
|
77
|
+
|
|
78
|
+
self.engine = SimpleNamespace(
|
|
79
|
+
projects=SimpleNamespace(
|
|
80
|
+
create=Mock(return_value=self.project),
|
|
81
|
+
open=Mock(side_effect=opened),
|
|
82
|
+
),
|
|
83
|
+
device_repository=SimpleNamespace(
|
|
84
|
+
import_device=Mock(), sources=['repository'],
|
|
85
|
+
create_device_identification=lambda *identity: identity, get_device=Mock(return_value=object()),
|
|
86
|
+
),
|
|
87
|
+
KindOfTask=SimpleNamespace(Cyclic='cyclic'),
|
|
88
|
+
Severity=SimpleNamespace(Error=1, FatalError=2),
|
|
89
|
+
ScriptPromptHandling=SimpleNamespace(LogPrompts='log'),
|
|
90
|
+
system=SimpleNamespace(
|
|
91
|
+
get_message_categories=lambda: ['compiler', 'libraries'],
|
|
92
|
+
get_message_objects=lambda category, severity: self.messages if category == 'libraries' else [],
|
|
93
|
+
clear_messages=lambda category: self.messages.clear(),
|
|
94
|
+
delay=Mock(side_effect=lambda duration: self.events.append('delay')),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_compiles_published_program_for_each_controller_and_reopens_the_project(self):
|
|
99
|
+
verification.verify(self.engine)
|
|
100
|
+
self.assertEqual(
|
|
101
|
+
[call.kwargs['path'] for call in self.engine.device_repository.import_device.call_args_list],
|
|
102
|
+
[r'C:\codesys-device\codesyscontrol.devdesc.xml', r'C:\codesys-device-linux-sl\codesyscontrol.devdesc.xml'],
|
|
103
|
+
)
|
|
104
|
+
self.assertEqual([call.args for call in self.project.add.call_args_list], [
|
|
105
|
+
('Device', 4096, '0000 1008', '4.22.0.0'), ('Device', 4102, '0000 0005', '4.22.0.0'),
|
|
106
|
+
])
|
|
107
|
+
self.assertIn(
|
|
108
|
+
"{attribute 'symbol' := 'readwrite'}",
|
|
109
|
+
self.program.textual_declaration.replace.call_args.args[0],
|
|
110
|
+
)
|
|
111
|
+
self.assertEqual(self.task.kind_of_task, 'cyclic')
|
|
112
|
+
self.assertEqual(self.task.interval, '20')
|
|
113
|
+
self.task.pous.add.assert_called_with('PLC_PRG')
|
|
114
|
+
self.assertEqual(self.application.create_symbol_config.call_args.args[:2], (True, True))
|
|
115
|
+
self.assertEqual(self.events, [
|
|
116
|
+
'clean', 'compile', 'generate', 'boot', 'close',
|
|
117
|
+
'open', 'libraries', 'delay', 'close',
|
|
118
|
+
'clean', 'compile', 'generate', 'boot', 'close',
|
|
119
|
+
])
|
|
120
|
+
self.manager.get_libraries.assert_called_with(recursive=True)
|
|
121
|
+
|
|
122
|
+
def test_reads_byte_addressing_like_codesys(self):
|
|
123
|
+
self.assertFalse(verification.uses_byte_addressing(DEVICE_DESCRIPTION))
|
|
124
|
+
self.assertTrue(verification.uses_byte_addressing(target_setting('byte-addressing', 'TRUE')))
|
|
125
|
+
self.assertFalse(verification.uses_byte_addressing(target_setting('byte-addressing', 'false')))
|
|
126
|
+
self.assertFalse(verification.uses_byte_addressing(target_setting('bit-byte-addressing', 'true')))
|
|
127
|
+
|
|
128
|
+
def test_rejects_a_controller_that_numbers_words_by_byte_before_importing_it(self):
|
|
129
|
+
self.description = target_setting('byte-addressing', 'true')
|
|
130
|
+
with self.assertRaisesRegex(RuntimeError, 'sets byte-addressing'):
|
|
131
|
+
verification.verify(self.engine)
|
|
132
|
+
self.engine.device_repository.import_device.assert_not_called()
|
|
133
|
+
|
|
134
|
+
def test_rejects_transitive_library_errors_reported_when_reopening_saved_project(self):
|
|
135
|
+
def report_late_errors(duration):
|
|
136
|
+
self.messages.append(SimpleNamespace(
|
|
137
|
+
text="Could not open library '#VisuShared'.",
|
|
138
|
+
project=SimpleNamespace(path=r'C:\Managed Libraries\AlarmManager.compiled-library-v3'),
|
|
139
|
+
))
|
|
140
|
+
self.engine.system.delay.side_effect = report_late_errors
|
|
141
|
+
with self.assertRaisesRegex(
|
|
142
|
+
RuntimeError,
|
|
143
|
+
"AlarmManager.compiled-library-v3: Could not open library '#VisuShared'",
|
|
144
|
+
):
|
|
145
|
+
verification.verify(self.engine)
|
|
146
|
+
self.engine.projects.open.assert_called_once()
|
|
147
|
+
self.assertEqual(self.project.close.call_count, 2)
|
|
148
|
+
|
|
149
|
+
def test_compile_error_stops_before_boot_artifact_and_saved_project_reopen(self):
|
|
150
|
+
self.application.rebuild.side_effect = lambda: self.messages.append(SimpleNamespace(text='Invalid source'))
|
|
151
|
+
with self.assertRaisesRegex(RuntimeError, 'Invalid source'):
|
|
152
|
+
verification.verify(self.engine)
|
|
153
|
+
self.application.generate_code.assert_not_called()
|
|
154
|
+
self.application.create_boot_application.assert_not_called()
|
|
155
|
+
self.engine.projects.open.assert_not_called()
|
|
156
|
+
self.project.close.assert_called_once()
|
|
157
|
+
|
|
158
|
+
def run_verifier_main(self):
|
|
159
|
+
owner = self
|
|
160
|
+
|
|
161
|
+
class Kernel:
|
|
162
|
+
@property
|
|
163
|
+
def ExitProcess(self):
|
|
164
|
+
owner.assertEqual(owner.events, [], 'Bind native exit before compiler work')
|
|
165
|
+
return lambda status: sys.exit(status)
|
|
166
|
+
|
|
167
|
+
output = io.StringIO()
|
|
168
|
+
errors = io.StringIO()
|
|
169
|
+
with patch.dict(sys.modules, {
|
|
170
|
+
'ctypes': SimpleNamespace(windll=SimpleNamespace(kernel32=Kernel())),
|
|
171
|
+
'scriptengine': self.engine,
|
|
172
|
+
}), redirect_stdout(output), redirect_stderr(errors):
|
|
173
|
+
with self.assertRaises(SystemExit) as exit:
|
|
174
|
+
runpy.run_path(str(path), run_name='__main__')
|
|
175
|
+
return exit.exception.code, output.getvalue(), errors.getvalue()
|
|
176
|
+
|
|
177
|
+
def test_main_prebinds_native_exit_and_returns_zero_after_verification(self):
|
|
178
|
+
status, output, errors = self.run_verifier_main()
|
|
179
|
+
self.assertEqual(status, 0)
|
|
180
|
+
self.assertIn('project reopen: PASS', output)
|
|
181
|
+
self.assertEqual(errors, '')
|
|
182
|
+
|
|
183
|
+
def test_main_keeps_compile_failure_and_returns_one_using_prebound_native_exit(self):
|
|
184
|
+
self.application.rebuild.side_effect = lambda: self.messages.append(SimpleNamespace(text='Invalid source'))
|
|
185
|
+
status, output, errors = self.run_verifier_main()
|
|
186
|
+
self.assertEqual(status, 1)
|
|
187
|
+
self.assertNotIn('PASS', output)
|
|
188
|
+
self.assertIn('Invalid source', errors)
|
|
189
|
+
self.application.create_boot_application.assert_not_called()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class InstallerVerificationCommandTests(unittest.TestCase):
|
|
193
|
+
"""Run the maintained shell gate with only Wine's process boundary replaced."""
|
|
194
|
+
|
|
195
|
+
def setUp(self):
|
|
196
|
+
temporary = tempfile.TemporaryDirectory(prefix='tsplc verification ')
|
|
197
|
+
self.addCleanup(temporary.cleanup)
|
|
198
|
+
self.root = Path(temporary.name)
|
|
199
|
+
self.prefix = self.root / 'wine prefix'
|
|
200
|
+
(self.prefix / 'drive_c').mkdir(parents=True)
|
|
201
|
+
self.calls = self.root / 'calls.jsonl'
|
|
202
|
+
timeout = self.root / 'timeout'
|
|
203
|
+
timeout.write_text(
|
|
204
|
+
'#!' + sys.executable + '\n'
|
|
205
|
+
'import json, os, pathlib, sys\n'
|
|
206
|
+
'prefix = pathlib.Path(os.environ["WINEPREFIX"]) / "drive_c"\n'
|
|
207
|
+
'for extension, content in json.loads(os.environ["TSPLC_VERIFICATION_FILES"]).items():\n'
|
|
208
|
+
' (prefix / ("codesys-verification." + extension)).write_text(content)\n'
|
|
209
|
+
'with pathlib.Path(os.environ["TSPLC_VERIFICATION_CALLS"]).open("a") as output:\n'
|
|
210
|
+
' output.write(json.dumps({"command": sys.argv[1:], "debug": os.environ["WINEDEBUG"]}) + "\\n")\n'
|
|
211
|
+
'pathlib.Path(sys.argv[-1]).write_text(os.environ["TSPLC_VERIFICATION_TRANSCRIPT"])\n'
|
|
212
|
+
'print("Fixture vendor verification result")\n'
|
|
213
|
+
'sys.exit(int(os.environ["TSPLC_VERIFICATION_STATUS"]))\n'
|
|
214
|
+
)
|
|
215
|
+
timeout.chmod(0o755)
|
|
216
|
+
wineserver = self.root / 'wineserver'
|
|
217
|
+
wineserver.write_text(
|
|
218
|
+
'#!' + sys.executable + '\n'
|
|
219
|
+
'import json, os, pathlib, sys\n'
|
|
220
|
+
'with pathlib.Path(os.environ["TSPLC_VERIFICATION_CALLS"]).open("a") as output:\n'
|
|
221
|
+
' output.write(json.dumps({"wineserver": sys.argv[1:]}) + "\\n")\n'
|
|
222
|
+
)
|
|
223
|
+
wineserver.chmod(0o755)
|
|
224
|
+
|
|
225
|
+
def run_verifier(self, status=0, files=None, transcript='Fixture vendor verification result\n'):
|
|
226
|
+
if files is None:
|
|
227
|
+
files = {extension: 'Vendor artifact' for extension in ('project', 'app', 'crc')}
|
|
228
|
+
result = subprocess.run(
|
|
229
|
+
['sh', str(path.with_name('verify.sh'))],
|
|
230
|
+
env={**os.environ, 'PATH': str(self.root) + os.pathsep + os.environ['PATH'],
|
|
231
|
+
'WINEPREFIX': str(self.prefix), 'TSPLC_VERIFICATION_STATUS': str(status),
|
|
232
|
+
'TSPLC_VERIFICATION_FILES': json.dumps(files),
|
|
233
|
+
'TSPLC_VERIFICATION_TRANSCRIPT': transcript,
|
|
234
|
+
'TSPLC_VERIFICATION_CALLS': str(self.calls)},
|
|
235
|
+
capture_output=True, text=True,
|
|
236
|
+
)
|
|
237
|
+
calls = [json.loads(line) for line in self.calls.read_text().splitlines()] if self.calls.exists() else []
|
|
238
|
+
return result, calls
|
|
239
|
+
|
|
240
|
+
def test_success_requires_all_boot_files_before_stopping_the_template_wine_server(self):
|
|
241
|
+
result, calls = self.run_verifier()
|
|
242
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
243
|
+
self.assertEqual(calls[0]['debug'], '-all,err+seh')
|
|
244
|
+
self.assertEqual(calls[0]['command'], [
|
|
245
|
+
'--foreground', '15m', 'script', '-q', '-e', '-c',
|
|
246
|
+
"wine cmd /d /c 'C:\\verify.cmd'", str(self.prefix / 'drive_c' / 'codesys-verification.log'),
|
|
247
|
+
])
|
|
248
|
+
self.assertEqual(calls[1:], [{'wineserver': ['-k']}, {'wineserver': ['-w']}])
|
|
249
|
+
command = (self.prefix / 'drive_c' / 'verify.cmd').read_text()
|
|
250
|
+
self.assertIn('--runscript="C:\\verify.py"', command)
|
|
251
|
+
self.assertEqual(command.splitlines()[-1], 'exit /b %errorlevel%')
|
|
252
|
+
|
|
253
|
+
def test_vendor_failure_is_preserved_even_when_old_boot_artifacts_exist(self):
|
|
254
|
+
for extension in ('project', 'app', 'crc'):
|
|
255
|
+
(self.prefix / 'drive_c' / ('codesys-verification.' + extension)).write_text('Old boot artifact')
|
|
256
|
+
result, calls = self.run_verifier(status=5, files={})
|
|
257
|
+
self.assertEqual(result.returncode, 5)
|
|
258
|
+
self.assertEqual(len(calls), 1)
|
|
259
|
+
|
|
260
|
+
def test_old_boot_artifacts_cannot_satisfy_a_process_that_skips_verification(self):
|
|
261
|
+
for extension in ('project', 'app', 'crc'):
|
|
262
|
+
(self.prefix / 'drive_c' / ('codesys-verification.' + extension)).write_text('Old boot artifact')
|
|
263
|
+
result, calls = self.run_verifier(files={})
|
|
264
|
+
self.assertEqual(result.returncode, 1)
|
|
265
|
+
self.assertEqual(len(calls), 1)
|
|
266
|
+
|
|
267
|
+
def test_native_signal_assertion_rejects_zero_exit_and_fresh_boot_artifacts(self):
|
|
268
|
+
for ending in ('\r\n', '\r\r\n'):
|
|
269
|
+
with self.subTest(line_ending=ending):
|
|
270
|
+
self.calls.write_text('')
|
|
271
|
+
transcript = ending.join([
|
|
272
|
+
'assertion failed [x86_avx_state_ptr->xsave_header.xfeatures == kSupportedXFeatureBits]: ',
|
|
273
|
+
'(ThreadContextSignals.cpp:414 rt_sigreturn)',
|
|
274
|
+
'Compile complete -- 0 errors, 0 warnings',
|
|
275
|
+
'',
|
|
276
|
+
])
|
|
277
|
+
result, calls = self.run_verifier(transcript=transcript)
|
|
278
|
+
self.assertEqual(result.returncode, 1)
|
|
279
|
+
self.assertIn('Wine translation aborted', result.stderr)
|
|
280
|
+
self.assertIn('codesys-verification.log', result.stderr)
|
|
281
|
+
self.assertEqual(len(calls), 1)
|
|
282
|
+
self.assertEqual((self.prefix / 'drive_c' / 'codesys-verification.log').read_bytes().decode(), transcript)
|
|
283
|
+
|
|
284
|
+
def test_ordinary_compiler_assertion_text_does_not_match_native_signal_failure(self):
|
|
285
|
+
result, calls = self.run_verifier(transcript='Compiler diagnostic: assertion failed\n')
|
|
286
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
287
|
+
self.assertEqual(len(calls), 3)
|
|
288
|
+
|
|
289
|
+
def test_missing_or_empty_boot_file_rejects_an_apparently_successful_vendor_exit(self):
|
|
290
|
+
for extension in ('project', 'app', 'crc'):
|
|
291
|
+
for content in (None, ''):
|
|
292
|
+
with self.subTest(extension=extension, content=content):
|
|
293
|
+
for artifact in (self.prefix / 'drive_c').glob('codesys-verification.*'):
|
|
294
|
+
artifact.unlink()
|
|
295
|
+
self.calls.write_text('')
|
|
296
|
+
files = {name: 'Vendor artifact' for name in ('project', 'app', 'crc')}
|
|
297
|
+
if content is None:
|
|
298
|
+
del files[extension]
|
|
299
|
+
else:
|
|
300
|
+
files[extension] = content
|
|
301
|
+
result, calls = self.run_verifier(files=files)
|
|
302
|
+
self.assertEqual(result.returncode, 1)
|
|
303
|
+
self.assertEqual(len(calls), 1)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == '__main__':
|
|
307
|
+
unittest.main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import runpy
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
from build import CompilerError
|
|
14
|
+
from worker import serve
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkerTests(unittest.TestCase):
|
|
18
|
+
def run_worker(self, actions, compile):
|
|
19
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
20
|
+
root = Path(temporary)
|
|
21
|
+
session = root / "build" / ".codesys" / "worker"
|
|
22
|
+
session.mkdir(parents=True)
|
|
23
|
+
commands = []
|
|
24
|
+
for index, action in enumerate(actions + ["stop"]):
|
|
25
|
+
command = {"id": str(index), "action": action}
|
|
26
|
+
if action == "build":
|
|
27
|
+
output = session.parent / "build"
|
|
28
|
+
output.mkdir(exist_ok=True)
|
|
29
|
+
(output / "request.json").write_text(json.dumps({
|
|
30
|
+
"mode": "build", "objects": [], "project": "BottlingLine.project",
|
|
31
|
+
|
|
32
|
+
"output": "runtime/Application.app", "receipt": "compiled.json",
|
|
33
|
+
"template": "Template.project", "templateHash": "template-fixture",
|
|
34
|
+
}))
|
|
35
|
+
commands.append(command)
|
|
36
|
+
pending = iter(commands)
|
|
37
|
+
def wait(milliseconds):
|
|
38
|
+
(session / "request.json").write_text(json.dumps(next(pending)))
|
|
39
|
+
engine = SimpleNamespace(system=SimpleNamespace(delay=wait))
|
|
40
|
+
with patch("worker.build", side_effect=compile):
|
|
41
|
+
serve(engine, str(session))
|
|
42
|
+
responses = [json.loads((session / (command["id"] + ".json")).read_text()) for command in commands]
|
|
43
|
+
errors = [path.read_text() for path in (session.parent / "build").glob("error.txt")]
|
|
44
|
+
return responses, errors
|
|
45
|
+
|
|
46
|
+
def test_handles_multiple_builds_and_health_checks_without_exiting(self):
|
|
47
|
+
requests = []
|
|
48
|
+
def compile(engine, request, loaded, workspace):
|
|
49
|
+
requests.append(request)
|
|
50
|
+
responses, errors = self.run_worker(["ping", "build", "ping", "build"], compile)
|
|
51
|
+
self.assertTrue(all(response["ok"] for response in responses))
|
|
52
|
+
self.assertEqual(errors, [])
|
|
53
|
+
self.assertEqual(len(requests), 2)
|
|
54
|
+
self.assertEqual(requests[0]["project"], requests[1]["project"])
|
|
55
|
+
for request in requests:
|
|
56
|
+
project = Path(request["project"])
|
|
57
|
+
self.assertTrue(project.is_absolute())
|
|
58
|
+
self.assertEqual(Path(request["template"]).parent, project.parent)
|
|
59
|
+
self.assertEqual(Path(request["output"]).parent.parent, project.parent)
|
|
60
|
+
|
|
61
|
+
def test_reports_a_failed_build_and_processes_the_next_request(self):
|
|
62
|
+
failure = "Fixture compiler failure after writing a receipt"
|
|
63
|
+
requests = []
|
|
64
|
+
def compile(engine, request, loaded, workspace):
|
|
65
|
+
requests.append(request)
|
|
66
|
+
if len(requests) == 1:
|
|
67
|
+
Path(request["receipt"]).write_text(json.dumps({"mode": "build"}))
|
|
68
|
+
raise RuntimeError(failure)
|
|
69
|
+
responses, errors = self.run_worker(["build", "build"], compile)
|
|
70
|
+
self.assertFalse(responses[0]["ok"])
|
|
71
|
+
self.assertIn(failure, responses[0]["error"])
|
|
72
|
+
self.assertTrue(responses[1]["ok"])
|
|
73
|
+
self.assertTrue(responses[2]["ok"])
|
|
74
|
+
self.assertEqual(len(errors), 1)
|
|
75
|
+
self.assertIn(failure, errors[0])
|
|
76
|
+
|
|
77
|
+
def test_preserves_structured_compiler_diagnostics_in_failure_response(self):
|
|
78
|
+
diagnostics = [{"text": "Invalid type", "position": "18446744073709551615", "positionText": "vendor context", "object": None}]
|
|
79
|
+
def compile(engine, request, loaded, workspace):
|
|
80
|
+
raise CompilerError(diagnostics)
|
|
81
|
+
responses, errors = self.run_worker(["build", "ping"], compile)
|
|
82
|
+
self.assertEqual(responses[0]["diagnostics"], diagnostics)
|
|
83
|
+
self.assertIn("Invalid type", responses[0]["error"])
|
|
84
|
+
self.assertTrue(responses[1]["ok"])
|
|
85
|
+
self.assertIn("Invalid type", errors[0])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class WorkerMainTests(unittest.TestCase):
|
|
89
|
+
def test_native_exit_is_preloaded_before_serving_and_preserves_success_and_failure(self):
|
|
90
|
+
worker_path = Path(__file__).with_name("worker.py").resolve()
|
|
91
|
+
for native in (True, False):
|
|
92
|
+
for invalid in (True, False):
|
|
93
|
+
with self.subTest(native=native, invalid=invalid), tempfile.TemporaryDirectory() as temporary:
|
|
94
|
+
root = Path(temporary)
|
|
95
|
+
(root / "request.json").write_text("{invalid" if invalid else json.dumps({"id": "stop", "action": "stop"}))
|
|
96
|
+
phase = "startup"
|
|
97
|
+
events = []
|
|
98
|
+
def exited(api, code):
|
|
99
|
+
events.append((api, phase, code))
|
|
100
|
+
raise SystemExit(code)
|
|
101
|
+
class NativeLibrary:
|
|
102
|
+
@property
|
|
103
|
+
def ExitProcess(self):
|
|
104
|
+
events.append(("bind", phase))
|
|
105
|
+
return lambda code: exited("native", code)
|
|
106
|
+
ctypes = SimpleNamespace(windll=SimpleNamespace(kernel32=NativeLibrary()))
|
|
107
|
+
engine = SimpleNamespace(
|
|
108
|
+
system=SimpleNamespace(exit=lambda code: exited("scriptengine", code)),
|
|
109
|
+
ScriptPromptHandling=SimpleNamespace(LogPrompts="LogPrompts"))
|
|
110
|
+
actual_import = builtins.__import__
|
|
111
|
+
def imported(name, *args, **kwargs):
|
|
112
|
+
if name == "ctypes":
|
|
113
|
+
events.append(("import", phase))
|
|
114
|
+
return ctypes
|
|
115
|
+
return actual_import(name, *args, **kwargs)
|
|
116
|
+
def profiled(frame, event, _argument):
|
|
117
|
+
nonlocal phase
|
|
118
|
+
if frame.f_code.co_filename == str(worker_path) and frame.f_code.co_name == "serve":
|
|
119
|
+
if event == "call": phase = "serving"
|
|
120
|
+
elif event == "return": phase = "after-serve"
|
|
121
|
+
previous_profile = sys.getprofile()
|
|
122
|
+
previous_path = list(sys.path)
|
|
123
|
+
try:
|
|
124
|
+
with patch.dict(sys.modules, {"scriptengine": engine}), patch.dict(os.environ, {
|
|
125
|
+
"CODESYS_BUILD_SESSION": str(root), "CODESYS_BUILD_NATIVE_EXIT": str(native).lower(),
|
|
126
|
+
}), patch("builtins.__import__", side_effect=imported), contextlib.redirect_stderr(io.StringIO()):
|
|
127
|
+
sys.setprofile(profiled)
|
|
128
|
+
with self.assertRaises(SystemExit) as result:
|
|
129
|
+
runpy.run_path(str(worker_path), run_name="__main__")
|
|
130
|
+
finally:
|
|
131
|
+
sys.setprofile(previous_profile)
|
|
132
|
+
sys.path[:] = previous_path
|
|
133
|
+
self.assertEqual(result.exception.code, int(invalid))
|
|
134
|
+
self.assertEqual(events, ([
|
|
135
|
+
("import", "startup"), ("bind", "startup"), ("native", "after-serve", int(invalid)),
|
|
136
|
+
] if native else [("scriptengine", "after-serve", int(invalid))]))
|
|
137
|
+
if not invalid:
|
|
138
|
+
self.assertEqual(json.loads((root / "stop.json").read_text()), {"id": "stop", "ok": True})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
unittest.main()
|
package/python/worker.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""One long-lived script inside CODESYS; all engine calls stay on its script thread."""
|
|
2
|
+
from __future__ import print_function
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.environ["CODESYS_BUILD_SESSION"])
|
|
13
|
+
from build import CompilerError, build
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_json(path, value):
|
|
17
|
+
# The client reads only complete replies. Each request has a unique filename.
|
|
18
|
+
with io.open(path + ".tmp", "w", encoding="utf-8") as output:
|
|
19
|
+
output.write(u"{0}".format(json.dumps(value, ensure_ascii=False)))
|
|
20
|
+
os.rename(path + ".tmp", path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_build(engine, directory, loaded):
|
|
24
|
+
root = os.path.join(os.path.dirname(directory), "build")
|
|
25
|
+
with io.open(os.path.join(root, "request.json"), "r", encoding="utf-8") as source:
|
|
26
|
+
request = json.load(source)
|
|
27
|
+
for key in ("template", "project", "output", "receipt"):
|
|
28
|
+
request[key] = os.path.join(root, request[key])
|
|
29
|
+
try:
|
|
30
|
+
build(engine, request, loaded, directory)
|
|
31
|
+
except Exception:
|
|
32
|
+
with io.open(os.path.join(root, "error.txt"), "w", encoding="utf-8") as output:
|
|
33
|
+
output.write(u"{0}".format(traceback.format_exc()))
|
|
34
|
+
raise
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serve(engine, directory):
|
|
38
|
+
request_path = os.path.join(directory, "request.json")
|
|
39
|
+
loaded = {}
|
|
40
|
+
while True:
|
|
41
|
+
if not os.path.isfile(request_path):
|
|
42
|
+
# Unlike time.sleep, this keeps CODESYS's message loop responsive.
|
|
43
|
+
engine.system.delay(200)
|
|
44
|
+
continue
|
|
45
|
+
with io.open(request_path, "r", encoding="utf-8") as source:
|
|
46
|
+
command = json.load(source)
|
|
47
|
+
os.remove(request_path)
|
|
48
|
+
response = {"id": command["id"], "ok": True}
|
|
49
|
+
try:
|
|
50
|
+
if command["action"] == "build":
|
|
51
|
+
run_build(engine, directory, loaded)
|
|
52
|
+
elif command["action"] == "online":
|
|
53
|
+
from codesys_online import run_online
|
|
54
|
+
if loaded.get("project") is not None:
|
|
55
|
+
loaded["project"].close()
|
|
56
|
+
loaded.clear()
|
|
57
|
+
root = os.path.join(os.path.dirname(directory), "controller")
|
|
58
|
+
with io.open(os.path.join(root, "request.json"), encoding="utf-8") as source:
|
|
59
|
+
request = json.load(source)
|
|
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)
|
|
74
|
+
elif command["action"] not in ("ping", "stop"):
|
|
75
|
+
raise ValueError("Unknown worker action")
|
|
76
|
+
except Exception as error:
|
|
77
|
+
response = {"id": command["id"], "ok": False, "error": traceback.format_exc()}
|
|
78
|
+
if isinstance(error, CompilerError):
|
|
79
|
+
response["diagnostics"] = error.diagnostics
|
|
80
|
+
write_json(os.path.join(directory, command["id"] + ".json"), response)
|
|
81
|
+
if command["action"] == "stop":
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
import scriptengine
|
|
87
|
+
scriptengine.system.script_prompt_handling = scriptengine.ScriptPromptHandling.LogPrompts
|
|
88
|
+
native_exit = None
|
|
89
|
+
if os.environ.get("CODESYS_BUILD_NATIVE_EXIT") == "true":
|
|
90
|
+
# Import and bind while the engine is starting. A late IronPython import
|
|
91
|
+
# after serving builds can fault in the runtime's debug-info rewriter.
|
|
92
|
+
import ctypes
|
|
93
|
+
native_exit = ctypes.windll.kernel32.ExitProcess
|
|
94
|
+
exit_code = 0
|
|
95
|
+
try:
|
|
96
|
+
serve(scriptengine, os.environ["CODESYS_BUILD_SESSION"])
|
|
97
|
+
except Exception:
|
|
98
|
+
traceback.print_exc()
|
|
99
|
+
exit_code = 1
|
|
100
|
+
if native_exit is not None:
|
|
101
|
+
native_exit(exit_code)
|
|
102
|
+
scriptengine.system.exit(exit_code)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
application_name=$1
|
|
5
|
+
source_application=${2:-Application}
|
|
6
|
+
config_dir=/conf/codesyscontrol
|
|
7
|
+
data_dir=/data/codesyscontrol
|
|
8
|
+
config_file=$config_dir/CODESYSControl_User.cfg
|
|
9
|
+
|
|
10
|
+
test -d "$config_dir"
|
|
11
|
+
test -d "$data_dir"
|
|
12
|
+
test -s "/codesys-build/$source_application.app"
|
|
13
|
+
test -s "/codesys-build/$source_application.crc"
|
|
14
|
+
|
|
15
|
+
# Match the Virtual Control image's first-start initialization before installing
|
|
16
|
+
# the application. Existing configuration, device users, licenses and data survive.
|
|
17
|
+
if [ ! -f "$config_dir/.docker_initialized" ]; then
|
|
18
|
+
for file in CODESYSControl.cfg CODESYSControl_User.cfg; do
|
|
19
|
+
if [ ! -f "$config_dir/$file" ]; then
|
|
20
|
+
cp "/etc/codesyscontrol/$file" "$config_dir/$file"
|
|
21
|
+
fi
|
|
22
|
+
done
|
|
23
|
+
touch "$config_dir/.docker_initialized"
|
|
24
|
+
fi
|
|
25
|
+
if [ ! -f "$data_dir/.docker_initialized" ]; then
|
|
26
|
+
cp -a /var/opt/codesys/. "$data_dir/"
|
|
27
|
+
touch "$data_dir/.docker_initialized"
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Register the boot application once, retaining other applications and settings.
|
|
31
|
+
# Read the full section first so a new application index cannot overwrite one.
|
|
32
|
+
cp -p "$config_file" "$config_file.codesys-build.tmp"
|
|
33
|
+
awk -v application="$application_name" '
|
|
34
|
+
{
|
|
35
|
+
if (FNR == 1) in_app = 0
|
|
36
|
+
if (FILENAME == ARGV[2]) lines[++count] = $0
|
|
37
|
+
line = $0
|
|
38
|
+
sub(/\r$/, "", line)
|
|
39
|
+
gsub(/^[ \t]+|[ \t]+$/, "", line)
|
|
40
|
+
if (line ~ /^\[/) in_app = (line == "[CmpApp]")
|
|
41
|
+
if (in_app && line ~ /^Application\.[0-9]+[ \t]*=/) {
|
|
42
|
+
split(line, parts, "=")
|
|
43
|
+
index_text = parts[1]
|
|
44
|
+
sub(/^Application\./, "", index_text)
|
|
45
|
+
if (index_text + 0 > maximum) maximum = index_text + 0
|
|
46
|
+
value = parts[2]
|
|
47
|
+
gsub(/^[ \t]+|[ \t]+$/, "", value)
|
|
48
|
+
applications[index_text + 0] = value
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
END {
|
|
52
|
+
# User configuration overrides the base file for the same Application.N key.
|
|
53
|
+
for (application_index in applications) {
|
|
54
|
+
if (applications[application_index] == application) registered = 1
|
|
55
|
+
}
|
|
56
|
+
for (i = 1; i <= count; i++) {
|
|
57
|
+
print lines[i]
|
|
58
|
+
if (!registered && lines[i] ~ /^[ \t]*\[CmpApp\][ \t]*\r?$/) {
|
|
59
|
+
print "Application." (maximum + 1) "=" application
|
|
60
|
+
registered = 1
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!registered) print "\n[CmpApp]\nApplication." (maximum + 1) "=" application
|
|
64
|
+
}' "$config_dir/CODESYSControl.cfg" "$config_file" > "$config_file.codesys-build.tmp"
|
|
65
|
+
mv "$config_file.codesys-build.tmp" "$config_file"
|
|
66
|
+
|
|
67
|
+
# The runtime stays stopped until both boot files are installed. Keep retain files
|
|
68
|
+
# and application-owned data; this is a cold deployment, not an online change.
|
|
69
|
+
application_dir=$data_dir/PlcLogic/$application_name
|
|
70
|
+
mkdir -p "$application_dir"
|
|
71
|
+
for extension in app crc; do
|
|
72
|
+
cp "/codesys-build/$source_application.$extension" "$application_dir/$application_name.$extension.tmp"
|
|
73
|
+
mv "$application_dir/$application_name.$extension.tmp" "$application_dir/$application_name.$extension"
|
|
74
|
+
done
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<project xmlns="http://www.plcopen.org/xml/tc6_0200" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
3
|
+
<fileHeader companyName="" productName="CODESYS" productVersion="CODESYS V3.5 SP21" creationDateTime="<NORMALIZED>" />
|
|
4
|
+
<contentHeader name="FB_Simple" modificationDateTime="<NORMALIZED>">
|
|
5
|
+
<coordinateInfo>
|
|
6
|
+
<fbd><scaling x="1" y="1" /></fbd>
|
|
7
|
+
<ld><scaling x="1" y="1" /></ld>
|
|
8
|
+
<sfc><scaling x="1" y="1" /></sfc>
|
|
9
|
+
</coordinateInfo>
|
|
10
|
+
</contentHeader>
|
|
11
|
+
<types>
|
|
12
|
+
<dataTypes />
|
|
13
|
+
<pous>
|
|
14
|
+
<pou name="FB_Simple" pouType="functionBlock">
|
|
15
|
+
<interface>
|
|
16
|
+
<inputVars>
|
|
17
|
+
<variable name="bEnable">
|
|
18
|
+
<type><BOOL /></type>
|
|
19
|
+
<documentation><xhtml xmlns="http://www.w3.org/1999/xhtml">Enable command</xhtml></documentation>
|
|
20
|
+
</variable>
|
|
21
|
+
</inputVars>
|
|
22
|
+
<outputVars>
|
|
23
|
+
<variable name="bDone">
|
|
24
|
+
<type><BOOL /></type>
|
|
25
|
+
<documentation><xhtml xmlns="http://www.w3.org/1999/xhtml">Done flag</xhtml></documentation>
|
|
26
|
+
</variable>
|
|
27
|
+
</outputVars>
|
|
28
|
+
</interface>
|
|
29
|
+
<body>
|
|
30
|
+
<ST>
|
|
31
|
+
<xhtml xmlns="http://www.w3.org/1999/xhtml"><![CDATA[bDone := bEnable;]]></xhtml>
|
|
32
|
+
</ST>
|
|
33
|
+
</body>
|
|
34
|
+
</pou>
|
|
35
|
+
</pous>
|
|
36
|
+
</types>
|
|
37
|
+
<instances>
|
|
38
|
+
<configurations />
|
|
39
|
+
</instances>
|
|
40
|
+
</project>
|