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.
- lucihub_common-0.1.0/PKG-INFO +69 -0
- lucihub_common-0.1.0/README.md +50 -0
- lucihub_common-0.1.0/pyproject.toml +86 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/deploy/__init__.py +0 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/deploy/generate.py +363 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/devices.py +571 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/__init__.py +0 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/api/__init__.py +0 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/api/v1.py +141 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/auth.py +33 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/celery.py +6 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/config.py +67 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/db.py +477 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/models/enums.py +21 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/storage/__init__.py +1 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/storage/blob.py +288 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/storage/session.py +43 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/util/__init__.py +0 -0
- lucihub_common-0.1.0/src/anabrid/lucihub/util/config.py +72 -0
- lucihub_common-0.1.0/tests/__init__.py +1 -0
- lucihub_common-0.1.0/tests/conftest.py +83 -0
- lucihub_common-0.1.0/tests/test_blob_storage.py +279 -0
- lucihub_common-0.1.0/tests/test_deploy_generate.py +735 -0
- lucihub_common-0.1.0/tests/test_device_manager.py +868 -0
- lucihub_common-0.1.0/tests/test_models.py +685 -0
- lucihub_common-0.1.0/tests/test_run_task_queue_length.py +200 -0
- lucihub_common-0.1.0/tests/test_task_create.py +340 -0
- lucihub_common-0.1.0/tests/test_task_delete.py +214 -0
- lucihub_common-0.1.0/tests/test_task_list.py +258 -0
- 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
|
+
]
|
|
File without changes
|
|
@@ -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")
|