lucihub-common 0.1.0__tar.gz

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 (30) hide show
  1. lucihub_common-0.1.0/PKG-INFO +69 -0
  2. lucihub_common-0.1.0/README.md +50 -0
  3. lucihub_common-0.1.0/pyproject.toml +86 -0
  4. lucihub_common-0.1.0/src/anabrid/lucihub/deploy/__init__.py +0 -0
  5. lucihub_common-0.1.0/src/anabrid/lucihub/deploy/generate.py +363 -0
  6. lucihub_common-0.1.0/src/anabrid/lucihub/devices.py +571 -0
  7. lucihub_common-0.1.0/src/anabrid/lucihub/models/__init__.py +0 -0
  8. lucihub_common-0.1.0/src/anabrid/lucihub/models/api/__init__.py +0 -0
  9. lucihub_common-0.1.0/src/anabrid/lucihub/models/api/v1.py +141 -0
  10. lucihub_common-0.1.0/src/anabrid/lucihub/models/auth.py +33 -0
  11. lucihub_common-0.1.0/src/anabrid/lucihub/models/celery.py +6 -0
  12. lucihub_common-0.1.0/src/anabrid/lucihub/models/config.py +67 -0
  13. lucihub_common-0.1.0/src/anabrid/lucihub/models/db.py +477 -0
  14. lucihub_common-0.1.0/src/anabrid/lucihub/models/enums.py +21 -0
  15. lucihub_common-0.1.0/src/anabrid/lucihub/storage/__init__.py +1 -0
  16. lucihub_common-0.1.0/src/anabrid/lucihub/storage/blob.py +288 -0
  17. lucihub_common-0.1.0/src/anabrid/lucihub/storage/session.py +43 -0
  18. lucihub_common-0.1.0/src/anabrid/lucihub/util/__init__.py +0 -0
  19. lucihub_common-0.1.0/src/anabrid/lucihub/util/config.py +72 -0
  20. lucihub_common-0.1.0/tests/__init__.py +1 -0
  21. lucihub_common-0.1.0/tests/conftest.py +83 -0
  22. lucihub_common-0.1.0/tests/test_blob_storage.py +279 -0
  23. lucihub_common-0.1.0/tests/test_deploy_generate.py +735 -0
  24. lucihub_common-0.1.0/tests/test_device_manager.py +868 -0
  25. lucihub_common-0.1.0/tests/test_models.py +685 -0
  26. lucihub_common-0.1.0/tests/test_run_task_queue_length.py +200 -0
  27. lucihub_common-0.1.0/tests/test_task_create.py +340 -0
  28. lucihub_common-0.1.0/tests/test_task_delete.py +214 -0
  29. lucihub_common-0.1.0/tests/test_task_list.py +258 -0
  30. lucihub_common-0.1.0/tests/test_task_mark_cancelled.py +136 -0
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.1
2
+ Name: lucihub-common
3
+ Version: 0.1.0
4
+ Summary: Common data models and storage components for LuciHub
5
+ Author-Email: Anabrid <info@anabrid.com>
6
+ Requires-Python: <4.0,>=3.11
7
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
8
+ Requires-Dist: celery<6.0.0,>=5.3.0
9
+ Requires-Dist: numpy>=2.4
10
+ Requires-Dist: pybrid-computing
11
+ Requires-Dist: pybrid-computing-native
12
+ Requires-Dist: pyredacc
13
+ Requires-Dist: sympy>=1.13.3
14
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
15
+ Requires-Dist: asyncpg>=0.29
16
+ Requires-Dist: alembic>=1.13
17
+ Requires-Dist: pyyaml>=6.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Models
21
+
22
+ This component contains data models that are used to communicate between
23
+ at least two different components of the project.
24
+
25
+ This includes the following models:
26
+ - `database`: models/classes for interfacing with hot and cold storage
27
+ - `hot`: use plain Python classes that abstract away the path variables and include static types
28
+ - `cold`: data models in SQL alchemy
29
+
30
+ Note that the `hot` storage essentially caches parts of the data structures
31
+ of the `cold` storage. This should be reflected in the SQL alchemy model:
32
+
33
+ ## Principles
34
+
35
+ - make sure to use object-oriented expressons and techniques, for example
36
+ abstract models and classes which have different implementations to account for versioning.
37
+ - consider the fact that the API itself is mostly consisting of CRUD-type of endpoints
38
+ - for hot storage, favor small field with primitive data types
39
+ - for cold storage, prefer large objects that enable fast data read without too many joins
40
+ when reading/writing through the API endpoints
41
+
42
+ ## Modelled data
43
+
44
+ This is a very rough first draft which does _not_ correspond to an 1:1 ORM:
45
+
46
+ **Cold storage**
47
+ - run
48
+ - id
49
+ - type of job (compile, run, verify...)
50
+ - state changes (array of timestamp/status)
51
+ - logs
52
+ - number of channels
53
+ - measurements (per channel) for run/verify jobs
54
+ - device id
55
+ - input type
56
+ - input (JSON data)
57
+ - output (JSON data)
58
+ - device
59
+ - id
60
+ - type
61
+ - input type
62
+ - parameter schema (as a data type that will simplify the model checking later)
63
+
64
+ **Hot storage**
65
+ - run (by UUID)
66
+ - state changes (array of pairs timestamp/status)
67
+ - logs (array of lines, lines are appended)
68
+ - measurements (on different channels, each channel is a time series of floating point values)
69
+ - list of active devices (device IDs), regularly updated from scanner
@@ -0,0 +1,50 @@
1
+ # Models
2
+
3
+ This component contains data models that are used to communicate between
4
+ at least two different components of the project.
5
+
6
+ This includes the following models:
7
+ - `database`: models/classes for interfacing with hot and cold storage
8
+ - `hot`: use plain Python classes that abstract away the path variables and include static types
9
+ - `cold`: data models in SQL alchemy
10
+
11
+ Note that the `hot` storage essentially caches parts of the data structures
12
+ of the `cold` storage. This should be reflected in the SQL alchemy model:
13
+
14
+ ## Principles
15
+
16
+ - make sure to use object-oriented expressons and techniques, for example
17
+ abstract models and classes which have different implementations to account for versioning.
18
+ - consider the fact that the API itself is mostly consisting of CRUD-type of endpoints
19
+ - for hot storage, favor small field with primitive data types
20
+ - for cold storage, prefer large objects that enable fast data read without too many joins
21
+ when reading/writing through the API endpoints
22
+
23
+ ## Modelled data
24
+
25
+ This is a very rough first draft which does _not_ correspond to an 1:1 ORM:
26
+
27
+ **Cold storage**
28
+ - run
29
+ - id
30
+ - type of job (compile, run, verify...)
31
+ - state changes (array of timestamp/status)
32
+ - logs
33
+ - number of channels
34
+ - measurements (per channel) for run/verify jobs
35
+ - device id
36
+ - input type
37
+ - input (JSON data)
38
+ - output (JSON data)
39
+ - device
40
+ - id
41
+ - type
42
+ - input type
43
+ - parameter schema (as a data type that will simplify the model checking later)
44
+
45
+ **Hot storage**
46
+ - run (by UUID)
47
+ - state changes (array of pairs timestamp/status)
48
+ - logs (array of lines, lines are appended)
49
+ - measurements (on different channels, each channel is a time series of floating point values)
50
+ - list of active devices (device IDs), regularly updated from scanner
@@ -0,0 +1,86 @@
1
+ [build-system]
2
+ requires = [
3
+ "pdm-backend",
4
+ ]
5
+ build-backend = "pdm.backend"
6
+
7
+ [tool.pytest.ini_options]
8
+ asyncio_mode = "auto"
9
+ testpaths = [
10
+ "tests",
11
+ ]
12
+ pythonpath = [
13
+ "src",
14
+ ]
15
+ python_classes = [
16
+ "Test*",
17
+ "!_*",
18
+ ]
19
+ python_functions = [
20
+ "test_*",
21
+ ]
22
+
23
+ [tool.black]
24
+ line-length = 88
25
+ target-version = [
26
+ "py313",
27
+ ]
28
+
29
+ [tool.ruff]
30
+ line-length = 88
31
+ target-version = "py313"
32
+
33
+ [tool.pdm.build]
34
+ includes = [
35
+ "src/anabrid",
36
+ ]
37
+
38
+ [tool.uv]
39
+ prerelease = "allow"
40
+
41
+ [tool.uv.sources.pybrid-computing]
42
+ path = "/home/dthuerck/dev/software/pybrid-computing/packages/pybrid-computing"
43
+ editable = true
44
+
45
+ [tool.uv.sources.pybrid-computing-native]
46
+ path = "/home/dthuerck/dev/software/pybrid-computing/packages/pybrid-computing-native"
47
+ editable = false
48
+
49
+ [tool.uv.sources.pyredacc]
50
+ path = "/home/dthuerck/dev/software/redacc/src/frontends/pyredacc"
51
+ editable = true
52
+
53
+ [project]
54
+ authors = [
55
+ { name = "Anabrid", email = "info@anabrid.com" },
56
+ ]
57
+ requires-python = "<4.0,>=3.11"
58
+ dependencies = [
59
+ "pydantic<3.0.0,>=2.0.0",
60
+ "celery<6.0.0,>=5.3.0",
61
+ "numpy>=2.4",
62
+ "pybrid-computing",
63
+ "pybrid-computing-native",
64
+ "pyredacc",
65
+ "sympy>=1.13.3",
66
+ "sqlalchemy[asyncio]>=2.0",
67
+ "asyncpg>=0.29",
68
+ "alembic>=1.13",
69
+ "pyyaml>=6.0",
70
+ ]
71
+ name = "lucihub-common"
72
+ version = "0.1.0"
73
+ description = "Common data models and storage components for LuciHub"
74
+ readme = "README.md"
75
+
76
+ [project.scripts]
77
+ lucihub-deploy = "anabrid.lucihub.deploy.generate:main"
78
+
79
+ [dependency-groups]
80
+ dev = [
81
+ "pytest<8.0.0,>=7.0.0",
82
+ "pytest-asyncio<1.0.0,>=0.21.0",
83
+ "black>=24.0.0",
84
+ "ruff<1.0.0,>=0.0.280",
85
+ "psycopg[binary]>=3.1",
86
+ ]
@@ -0,0 +1,363 @@
1
+ """Deploy configuration generator for LuciHub worker services.
2
+
3
+ Converts a ``LUCIHUBConfig`` instance into two deployment artefacts written
4
+ to the same output directory:
5
+
6
+ * A Docker Compose YAML fragment (``docker-compose.workers.yml``) — one
7
+ service per worker process (compile and run). SIMULATOR run-workers
8
+ receive an additional bind-mount of the simulator binary.
9
+
10
+ * A supervisord configuration file (``supervisord.conf``) — one program
11
+ per worker process plus the API server. Programs use ``watchfiles`` (for
12
+ workers) and uvicorn ``--reload`` (for the API) so source edits hot-reload
13
+ without restarting supervisord.
14
+
15
+ Both artefacts are generated from the same ``LUCIHUBConfig``; a
16
+ duplicate-port guard fires before either is written.
17
+
18
+ The database DSN is never baked into the generated files. The compose
19
+ fragment emits a compose-level ``${POSTGRES_PASSWORD}`` interpolation so
20
+ docker resolves the secret from ``.env`` at container start; the
21
+ supervisord fragment emits ``%(ENV_POSTGRES_PASSWORD)s`` (and the
22
+ matching user/host/port/db placeholders) so supervisord resolves the
23
+ secret from its own environment at program-start. No plaintext password
24
+ appears in either artefact.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ import yaml
34
+
35
+ from anabrid.lucihub.models.config import LUCIHUBConfig
36
+ from anabrid.lucihub.models.enums import DeviceTypeEnum
37
+
38
+ DEFAULT_WORKER_IMAGE = "lucihub-worker:latest"
39
+ _RESTART = "unless-stopped"
40
+ _NETWORKS: tuple[str, ...] = ("internal",)
41
+ _VOLUMES: tuple[str, ...] = (
42
+ "${LUCIHUB_BASE_DIR}/config:/config:ro",
43
+ "${LUCIHUB_BASE_DIR}/data:/data",
44
+ "${LUCIHUB_BASE_DIR}/logs:/logs",
45
+ )
46
+ _BROKER_URL = "amqp://rabbitmq:5672//"
47
+ _REDACC_EXECUTABLE = "/usr/local/bin/redacc"
48
+ _SIMULATOR_EXECUTABLE = "/usr/local/bin/compiler_sim"
49
+
50
+ # Compose-level interpolation literal: docker resolves ${...} at container
51
+ # start, pulling the DB secret from the compose process's environment
52
+ # (which is loaded from the project's .env file).
53
+ _COMPOSE_DATABASE_URL = (
54
+ "postgresql+asyncpg://"
55
+ "${POSTGRES_USER:-lucihub}:${POSTGRES_PASSWORD}"
56
+ "@postgres:${POSTGRES_PORT:-5432}"
57
+ "/${POSTGRES_DB:-lucihub}"
58
+ )
59
+
60
+ # Supervisord runs in the dev-host-api topology only; the API and workers
61
+ # reach postgres via the published port on the loopback. Supervisord's
62
+ # %(ENV_X)s syntax has no default-value form, so non-secret fields are
63
+ # inlined here and only the password remains a placeholder resolved from
64
+ # the shell (which sources .env before launching supervisord).
65
+ _SUPERVISORD_DATABASE_URL = (
66
+ "postgresql+asyncpg://lucihub:%(ENV_POSTGRES_PASSWORD)s"
67
+ "@localhost:5432/lucihub"
68
+ )
69
+
70
+ # Supervisord-specific constants
71
+ _SUPERVISORD_BROKER_URL = "amqp://localhost:5672//"
72
+ _SUPERVISORD_SOCK = "/tmp/supervisor.sock"
73
+
74
+ _API_COMMAND = (
75
+ "uv run uvicorn anabrid.lucihub.server.main:app"
76
+ " --reload"
77
+ " --reload-dir %(ENV_LUCIHUB_BASE_DIR)s/components/server/src"
78
+ " --reload-dir %(ENV_LUCIHUB_BASE_DIR)s/components/common/src"
79
+ " --port 8000"
80
+ )
81
+ _WORKER_COMMAND = (
82
+ "uv run watchfiles --filter python"
83
+ ' "python -m anabrid.lucihub.worker.main"'
84
+ " %(ENV_LUCIHUB_BASE_DIR)s/components/worker/src"
85
+ " %(ENV_LUCIHUB_BASE_DIR)s/components/common/src"
86
+ )
87
+
88
+
89
+ def _check_duplicate_simulator_ports(config: LUCIHUBConfig) -> None:
90
+ """Raise ``ValueError`` if two SIMULATOR devices share the same (host, port).
91
+
92
+ This guard fires before any output is produced, so a misconfigured file
93
+ fails fast with a clear message naming both devices and the conflicting
94
+ address.
95
+ """
96
+ port_map: dict[tuple[str, int], list[str]] = {}
97
+ for entry in config.devices.available:
98
+ if entry.type == DeviceTypeEnum.SIMULATOR:
99
+ key = (entry.host, entry.port)
100
+ port_map.setdefault(key, []).append(entry.name)
101
+
102
+ for (host, port), names in port_map.items():
103
+ if len(names) > 1:
104
+ first, second = names[0], names[1]
105
+ suffix = f" and {len(names) - 2} others" if len(names) > 2 else ""
106
+ raise ValueError(
107
+ f"SIMULATOR devices {first!r} and {second!r}{suffix} share"
108
+ f" host:port {host}:{port}"
109
+ )
110
+
111
+
112
+ def _base_service(
113
+ worker_type: str, worker_image: str
114
+ ) -> dict[str, object]:
115
+ """Return a service dict with common fields populated for the given worker type.
116
+
117
+ The service's ``LUCIHUB_DATABASE_URL`` is a compose-interpolation
118
+ literal; docker resolves it from the compose-process environment
119
+ (loaded from ``.env``) at container start.
120
+ """
121
+ return {
122
+ "image": worker_image,
123
+ "restart": _RESTART,
124
+ "user": "${LUCIHUB_UID:-1000}:${LUCIHUB_GID:-1000}",
125
+ "networks": list(_NETWORKS),
126
+ "volumes": list(_VOLUMES),
127
+ "environment": {
128
+ "WORKER_TYPE": worker_type,
129
+ "BROKER_URL": _BROKER_URL,
130
+ "LUCIHUB_DATABASE_URL": _COMPOSE_DATABASE_URL,
131
+ "LUCIHUB_BASE_DIR": "/",
132
+ },
133
+ }
134
+
135
+
136
+ def generate_compose(
137
+ config: LUCIHUBConfig,
138
+ worker_image: str = DEFAULT_WORKER_IMAGE,
139
+ ) -> str:
140
+ """Generate a Docker Compose YAML string for all worker services.
141
+
142
+ Emits one service per compile instance and one per device-worker slot.
143
+ SIMULATOR run-workers receive ``LUCIHUB_SIMULATOR_BINARY`` pointing at
144
+ the ``compiler_sim`` binary baked into the worker image. Raises
145
+ ``ValueError`` if two SIMULATOR devices share the same ``(host, port)``
146
+ pair, as that would cause a runtime port conflict.
147
+
148
+ ``worker_image`` controls the image tag on every generated service.
149
+ Defaults to the local ``lucihub-worker:latest`` tag built by
150
+ ``init_workers.sh``; production bundles pass a registry-qualified
151
+ reference so the prod host can pull directly from the registry.
152
+
153
+ The returned string is valid Compose YAML that can be combined with the
154
+ main ``docker-compose.yml``.
155
+
156
+ Security:
157
+ The generated file carries the DB DSN as a compose-level
158
+ interpolation literal (``${POSTGRES_PASSWORD}`` etc). Docker
159
+ resolves the secret from the ``.env`` file at container start,
160
+ so no plaintext password is ever written to disk by this
161
+ generator.
162
+ """
163
+ _check_duplicate_simulator_ports(config)
164
+
165
+ services: dict = {}
166
+
167
+ for i in range(config.workers.compile_instances):
168
+ name = f"worker-compile-{i}"
169
+ svc = _base_service("compile", worker_image)
170
+ svc["environment"]["LUCIHUB_LOG_NAME"] = name
171
+ svc["environment"]["REDACC_EXECUTABLE"] = _REDACC_EXECUTABLE
172
+ services[name] = svc
173
+
174
+ for entry in config.devices.available:
175
+ for i in range(entry.workers):
176
+ name = f"worker-run-{entry.name}-{i}"
177
+ svc = _base_service("run", worker_image)
178
+ svc["environment"]["LUCIHUB_LOG_NAME"] = name
179
+ svc["environment"]["DEVICE_NAME"] = entry.name
180
+ if entry.type == DeviceTypeEnum.SIMULATOR:
181
+ svc["environment"]["LUCIHUB_SIMULATOR_BINARY"] = (
182
+ _SIMULATOR_EXECUTABLE
183
+ )
184
+ services[name] = svc
185
+
186
+ compose = {
187
+ "services": services,
188
+ "networks": {
189
+ "internal": {
190
+ "external": True,
191
+ "name": "lucihub_internal",
192
+ }
193
+ },
194
+ }
195
+
196
+ return yaml.safe_dump(compose, sort_keys=False, default_flow_style=False)
197
+
198
+
199
+ def generate_supervisord(config: LUCIHUBConfig) -> str:
200
+ """Generate a supervisord.conf string for the dev-host-API topology.
201
+
202
+ Emits four harness stanzas (supervisord, unix_http_server,
203
+ rpcinterface:supervisor, supervisorctl), one ``[program:api]`` stanza
204
+ running uvicorn with --reload, one ``[program:worker-compile-N]`` per
205
+ compile instance running the worker entrypoint wrapped by watchfiles,
206
+ and one ``[program:worker-run-<device>-N]`` per device-worker slot.
207
+
208
+ All program paths reference ``%(ENV_LUCIHUB_BASE_DIR)s`` so the
209
+ generated file is independent of where the project is checked out.
210
+ Log files are written to
211
+ ``%(ENV_LUCIHUB_BASE_DIR)s/logs/<program-name>.{out,err}``.
212
+
213
+ Raises ``ValueError`` if two SIMULATOR devices share the same
214
+ ``(host, port)`` pair.
215
+ """
216
+ _check_duplicate_simulator_ports(config)
217
+
218
+ common_env_pairs = [
219
+ f'BROKER_URL="{_SUPERVISORD_BROKER_URL}"',
220
+ f'LUCIHUB_DATABASE_URL="{_SUPERVISORD_DATABASE_URL}"',
221
+ 'LUCIHUB_BASE_DIR="%(ENV_LUCIHUB_BASE_DIR)s"',
222
+ ]
223
+
224
+ def _log_base(name: str) -> str:
225
+ return "%(ENV_LUCIHUB_BASE_DIR)s/logs/" + name
226
+
227
+ def _program_block(
228
+ name: str,
229
+ command: str,
230
+ directory: str,
231
+ extra_env_pairs: list[str] | None = None,
232
+ ) -> str:
233
+ env_pairs = list(extra_env_pairs or []) + common_env_pairs
234
+ env_line = ",".join(env_pairs)
235
+ lines = [
236
+ f"[program:{name}]",
237
+ f"command={command}",
238
+ f"directory={directory}",
239
+ "autostart=true",
240
+ "autorestart=true",
241
+ f"stdout_logfile={_log_base(name)}.out",
242
+ f"stderr_logfile={_log_base(name)}.err",
243
+ f"environment={env_line}",
244
+ "",
245
+ ]
246
+ return "\n".join(lines)
247
+
248
+ base_dir = "%(ENV_LUCIHUB_BASE_DIR)s"
249
+ server_dir = f"{base_dir}/components/server"
250
+ worker_dir = f"{base_dir}/components/worker"
251
+
252
+ sections: list[str] = []
253
+
254
+ # Harness stanzas
255
+ sections.append(
256
+ "[supervisord]\n"
257
+ "nodaemon=true\n"
258
+ "\n"
259
+ )
260
+ sections.append(
261
+ "[unix_http_server]\n"
262
+ f"file={_SUPERVISORD_SOCK}\n"
263
+ "\n"
264
+ )
265
+ sections.append(
266
+ "[rpcinterface:supervisor]\n"
267
+ "supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface\n"
268
+ "\n"
269
+ )
270
+ sections.append(
271
+ "[supervisorctl]\n"
272
+ f"serverurl=unix://{_SUPERVISORD_SOCK}\n"
273
+ "\n"
274
+ )
275
+
276
+ # API program
277
+ sections.append(
278
+ _program_block(
279
+ name="api",
280
+ command=_API_COMMAND,
281
+ directory=server_dir,
282
+ )
283
+ )
284
+
285
+ # Compile workers
286
+ for i in range(config.workers.compile_instances):
287
+ name = f"worker-compile-{i}"
288
+ sections.append(
289
+ _program_block(
290
+ name=name,
291
+ command=_WORKER_COMMAND,
292
+ directory=worker_dir,
293
+ extra_env_pairs=[
294
+ 'WORKER_TYPE="compile"',
295
+ 'REDACC_EXECUTABLE="%(ENV_LUCIHUB_BASE_DIR)s/bin/redacc"',
296
+ ],
297
+ )
298
+ )
299
+
300
+ # Run workers
301
+ simulator_binary = config.workers.simulator_binary_path
302
+ for entry in config.devices.available:
303
+ for i in range(entry.workers):
304
+ name = f"worker-run-{entry.name}-{i}"
305
+ env_pairs = [
306
+ 'WORKER_TYPE="run"',
307
+ f'DEVICE_NAME="{entry.name}"',
308
+ ]
309
+ if entry.type == DeviceTypeEnum.SIMULATOR:
310
+ env_pairs.append(f'LUCIHUB_SIMULATOR_BINARY="{simulator_binary}"')
311
+ sections.append(
312
+ _program_block(
313
+ name=name,
314
+ command=_WORKER_COMMAND,
315
+ directory=worker_dir,
316
+ extra_env_pairs=env_pairs,
317
+ )
318
+ )
319
+
320
+ return "\n".join(sections)
321
+
322
+
323
+ def main() -> None:
324
+ """Entry point for the ``lucihub-deploy`` CLI tool."""
325
+ parser = argparse.ArgumentParser(
326
+ description="Generate Docker Compose worker configuration from a LuciHub config file."
327
+ )
328
+ subparsers = parser.add_subparsers(dest="command", required=True)
329
+
330
+ gen = subparsers.add_parser("generate", help="Generate a worker compose file.")
331
+ gen.add_argument("-c", "--config", required=True, help="Path to lucihub.yaml.")
332
+ gen.add_argument(
333
+ "-o", "--output", required=True, help="Path for the output compose file."
334
+ )
335
+ gen.add_argument(
336
+ "--worker-image",
337
+ default=DEFAULT_WORKER_IMAGE,
338
+ help=(
339
+ "Worker image reference to emit on every generated service. "
340
+ f"Defaults to {DEFAULT_WORKER_IMAGE!r}. Pass a registry-qualified "
341
+ "ref (e.g. lab.analogparadigm.com:5050/.../worker:latest) when "
342
+ "preparing a production bundle."
343
+ ),
344
+ )
345
+
346
+ args = parser.parse_args()
347
+
348
+ config_path = Path(args.config)
349
+ config = LUCIHUBConfig.model_validate(
350
+ yaml.safe_load(config_path.read_text(encoding="utf-8"))
351
+ )
352
+ try:
353
+ yaml_text = generate_compose(config, worker_image=args.worker_image)
354
+ supervisord_text = generate_supervisord(config)
355
+ except ValueError as exc:
356
+ print(f"error: {exc}", file=sys.stderr)
357
+ sys.exit(2)
358
+
359
+ output_path = Path(args.output)
360
+ output_path.write_text(yaml_text, encoding="utf-8")
361
+
362
+ supervisord_path = output_path.parent / "supervisord.conf"
363
+ supervisord_path.write_text(supervisord_text, encoding="utf-8")