@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1696 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1683 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +18 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/edge-gateway/Dockerfile +17 -0
  17. package/docker/entrypoint.sh +10 -0
  18. package/docker/install/NOTICE.md +21 -0
  19. package/docker/install/core.sh +36 -0
  20. package/docker/install/decode.c +12 -0
  21. package/docker/install/device/Runtime.ico +0 -0
  22. package/docker/install/device/Runtime.png +0 -0
  23. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  24. package/docker/install/extract.py +43 -0
  25. package/docker/install/helper-launcher.c +46 -0
  26. package/docker/install/install.sh +94 -0
  27. package/docker/install/prerequisites.sh +15 -0
  28. package/docker/install/verify.py +133 -0
  29. package/docker/install/verify.sh +34 -0
  30. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  31. package/docker/install/wine-ncrypt/README.md +28 -0
  32. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  33. package/docker/install/wine-ncrypt/main.c +619 -0
  34. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  35. package/docker/install/wine-signal/README.md +22 -0
  36. package/docker/install/wine-signal/build.sh +10 -0
  37. package/docker/install/wine-signal/check.sh +34 -0
  38. package/docker/install/wine-signal/noop-i386.c +2 -0
  39. package/docker/install/wine-signal/signal-state.c +105 -0
  40. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  41. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  42. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  43. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  44. package/docker/install/wine-signal/wine +4 -0
  45. package/docker/runner.ts +156 -0
  46. package/package.json +53 -4
  47. package/python/build.py +660 -0
  48. package/python/codesys_login.py +100 -0
  49. package/python/codesys_online.py +327 -0
  50. package/python/test_build.py +444 -0
  51. package/python/test_codesys_login.py +120 -0
  52. package/python/test_codesys_online.py +184 -0
  53. package/python/test_installer_packages.py +216 -0
  54. package/python/test_installer_verification.py +307 -0
  55. package/python/test_worker.py +142 -0
  56. package/python/worker.py +102 -0
  57. package/runtime/install.sh +74 -0
  58. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  59. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  60. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  61. package/src/artifacts.ts +77 -0
  62. package/src/build.ts +87 -0
  63. package/src/cli.ts +116 -0
  64. package/src/config.ts +12 -0
  65. package/src/controller-online.ts +297 -0
  66. package/src/controller-session.ts +298 -0
  67. package/src/diagnostics.ts +68 -0
  68. package/src/docker-build.ts +163 -0
  69. package/src/docker-context.ts +35 -0
  70. package/src/docker-run.ts +103 -0
  71. package/src/host/codesys-command.ts +45 -0
  72. package/src/host/codesys-host.ts +145 -0
  73. package/src/host/codesys-worker.ts +348 -0
  74. package/src/host/windows-path.ts +73 -0
  75. package/src/index.ts +43 -0
  76. package/src/io.ts +127 -0
  77. package/src/libraries.ts +166 -0
  78. package/src/package-path.ts +5 -0
  79. package/src/run.ts +133 -0
  80. package/src/runtime-status.ts +21 -0
  81. package/src/sources.ts +248 -0
  82. package/src/st/declarations.ts +229 -0
  83. package/src/st/lexer.ts +125 -0
  84. package/src/st/parse.ts +202 -0
  85. 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()
@@ -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>