coda-node 0.1.0__py3-none-any.whl
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.
- coda_node/__init__.py +18 -0
- coda_node/errors.py +44 -0
- coda_node/py.typed +0 -0
- coda_node/server/__init__.py +9 -0
- coda_node/server/app.py +191 -0
- coda_node/server/auth.py +192 -0
- coda_node/server/cli.py +360 -0
- coda_node/server/config.py +300 -0
- coda_node/server/consumer.py +625 -0
- coda_node/server/daemon.py +261 -0
- coda_node/server/executor.py +200 -0
- coda_node/server/heartbeat.py +113 -0
- coda_node/server/ir.py +182 -0
- coda_node/server/qasm.py +377 -0
- coda_node/server/webhook.py +161 -0
- coda_node/vpn/__init__.py +49 -0
- coda_node/vpn/guard.py +424 -0
- coda_node/vpn/service.py +593 -0
- coda_node-0.1.0.dist-info/METADATA +348 -0
- coda_node-0.1.0.dist-info/RECORD +23 -0
- coda_node-0.1.0.dist-info/WHEEL +4 -0
- coda_node-0.1.0.dist-info/entry_points.txt +3 -0
- coda_node-0.1.0.dist-info/licenses/LICENSE +21 -0
coda_node/server/cli.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Command-line interface for the Coda node runtime.
|
|
2
|
+
|
|
3
|
+
Provides the ``coda`` (and ``coda-node``) entry points with
|
|
4
|
+
subcommands:
|
|
5
|
+
|
|
6
|
+
* ``start`` -- launch the FastAPI server with optional ``--token`` for
|
|
7
|
+
first-time node provisioning. Use ``--daemon`` to run in the
|
|
8
|
+
background.
|
|
9
|
+
* ``stop`` -- stop the daemon if running in background mode.
|
|
10
|
+
* ``status`` -- show daemon status and basic runtime info.
|
|
11
|
+
* ``doctor`` -- print diagnostic information about the local
|
|
12
|
+
environment (OpenVPN, VPN interface, Redis, executor).
|
|
13
|
+
* ``reset`` -- wipe persisted credentials, VPN profiles, and stop any
|
|
14
|
+
managed OpenVPN daemon.
|
|
15
|
+
* ``stop-vpn`` -- stop the managed OpenVPN process without resetting
|
|
16
|
+
other state.
|
|
17
|
+
* ``logs`` -- show recent daemon log output.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import tempfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import uvicorn
|
|
31
|
+
|
|
32
|
+
from coda_node.server.config import (
|
|
33
|
+
PERSISTED_CONFIG_PATH,
|
|
34
|
+
PERSISTED_PRIVATE_KEY_PATH,
|
|
35
|
+
Settings,
|
|
36
|
+
)
|
|
37
|
+
from coda_node.server.daemon import (
|
|
38
|
+
DAEMON_LOG_PATH,
|
|
39
|
+
DAEMON_PID_PATH,
|
|
40
|
+
daemon_status,
|
|
41
|
+
is_daemon_running,
|
|
42
|
+
start_daemon,
|
|
43
|
+
stop_daemon,
|
|
44
|
+
tail_daemon_log,
|
|
45
|
+
)
|
|
46
|
+
from coda_node.vpn import (
|
|
47
|
+
OPENVPN_LOG_PATH,
|
|
48
|
+
OPENVPN_PID_PATH,
|
|
49
|
+
detect_tun_interface,
|
|
50
|
+
kill_openvpn_daemon,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = ["main"]
|
|
54
|
+
|
|
55
|
+
_BANNER_WIDTH = 48
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_logging_configured = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _configure_logging() -> None:
|
|
62
|
+
"""Set up root logger with a human-readable format at INFO level.
|
|
63
|
+
|
|
64
|
+
Guarded so that repeated CLI entry (e.g. test harnesses calling
|
|
65
|
+
``main()`` multiple times) does not add duplicate handlers.
|
|
66
|
+
"""
|
|
67
|
+
global _logging_configured
|
|
68
|
+
if _logging_configured:
|
|
69
|
+
return
|
|
70
|
+
logging.basicConfig(
|
|
71
|
+
level=logging.INFO,
|
|
72
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
73
|
+
)
|
|
74
|
+
_logging_configured = True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
78
|
+
"""Build the ``coda`` CLI argument parser with all subcommands."""
|
|
79
|
+
start_parent = argparse.ArgumentParser(add_help=False)
|
|
80
|
+
start_parent.add_argument("-H", "--host")
|
|
81
|
+
start_parent.add_argument("-p", "--port", type=int)
|
|
82
|
+
start_parent.add_argument("-t", "--token", dest="node_token")
|
|
83
|
+
start_parent.add_argument(
|
|
84
|
+
"-d",
|
|
85
|
+
"--daemon",
|
|
86
|
+
action="store_true",
|
|
87
|
+
help="Run the server as a background daemon",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
parser = argparse.ArgumentParser(prog="coda")
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--reset",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Clear persisted runtime state and VPN artifacts",
|
|
95
|
+
)
|
|
96
|
+
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
97
|
+
|
|
98
|
+
subparsers.add_parser(
|
|
99
|
+
"start", parents=[start_parent], help="Run the FastAPI server"
|
|
100
|
+
)
|
|
101
|
+
subparsers.add_parser("stop", help="Stop the background daemon")
|
|
102
|
+
subparsers.add_parser("status", help="Show daemon status")
|
|
103
|
+
subparsers.add_parser("doctor", help="Print basic runtime checks")
|
|
104
|
+
subparsers.add_parser(
|
|
105
|
+
"reset", help="Clear persisted runtime state and VPN artifacts"
|
|
106
|
+
)
|
|
107
|
+
subparsers.add_parser("stop-vpn", help="Stop the managed OpenVPN process")
|
|
108
|
+
|
|
109
|
+
logs_parser = subparsers.add_parser("logs", help="Show recent daemon log output")
|
|
110
|
+
logs_parser.add_argument(
|
|
111
|
+
"-n",
|
|
112
|
+
"--lines",
|
|
113
|
+
type=int,
|
|
114
|
+
default=50,
|
|
115
|
+
help="Number of lines to show (default: 50)",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return parser
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _apply_overrides(args: argparse.Namespace) -> None:
|
|
122
|
+
"""Push CLI flags into environment variables before Settings loads."""
|
|
123
|
+
host = getattr(args, "host", None)
|
|
124
|
+
port = getattr(args, "port", None)
|
|
125
|
+
node_token = getattr(args, "node_token", None)
|
|
126
|
+
|
|
127
|
+
if host:
|
|
128
|
+
os.environ["CODA_HOST"] = host
|
|
129
|
+
if port is not None:
|
|
130
|
+
os.environ["CODA_PORT"] = str(port)
|
|
131
|
+
if node_token:
|
|
132
|
+
os.environ["CODA_NODE_TOKEN"] = node_token
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _print_banner(title: str, rows: list[tuple[str, str]]) -> None:
|
|
136
|
+
"""Print a bordered banner with a title and key-value rows."""
|
|
137
|
+
print()
|
|
138
|
+
print(f" ┌{'─' * (_BANNER_WIDTH - 2)}┐")
|
|
139
|
+
print(f" │{title:^{_BANNER_WIDTH - 2}}│")
|
|
140
|
+
print(f" └{'─' * (_BANNER_WIDTH - 2)}┘")
|
|
141
|
+
print()
|
|
142
|
+
for label, value in rows:
|
|
143
|
+
print(f" {label:<14}{value}")
|
|
144
|
+
print()
|
|
145
|
+
print(f" {'─' * _BANNER_WIDTH}")
|
|
146
|
+
print()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _print_status(label: str, value: str) -> None:
|
|
150
|
+
"""Print a single diagnostic status row."""
|
|
151
|
+
print(f" {'→':<2} {label:<14}{value}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _start_mode(token: str) -> str:
|
|
155
|
+
"""Return the display name for the startup mode."""
|
|
156
|
+
return "token" if token else "env"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _read_reset_paths() -> list[Path]:
|
|
160
|
+
"""Collect all file paths that should be removed during a reset."""
|
|
161
|
+
paths = {
|
|
162
|
+
PERSISTED_CONFIG_PATH,
|
|
163
|
+
PERSISTED_PRIVATE_KEY_PATH,
|
|
164
|
+
OPENVPN_PID_PATH,
|
|
165
|
+
OPENVPN_LOG_PATH,
|
|
166
|
+
DAEMON_PID_PATH,
|
|
167
|
+
DAEMON_LOG_PATH,
|
|
168
|
+
Path(f"{tempfile.gettempdir()}/coda-node.ovpn"),
|
|
169
|
+
}
|
|
170
|
+
if PERSISTED_CONFIG_PATH.exists():
|
|
171
|
+
try:
|
|
172
|
+
data = json.loads(PERSISTED_CONFIG_PATH.read_text())
|
|
173
|
+
except (OSError, json.JSONDecodeError):
|
|
174
|
+
data = {}
|
|
175
|
+
if isinstance(data, dict):
|
|
176
|
+
for key in ("jwt_private_key_path", "node_vpn_profile_path"):
|
|
177
|
+
value = data.get(key)
|
|
178
|
+
if isinstance(value, str) and value:
|
|
179
|
+
paths.add(Path(value))
|
|
180
|
+
return sorted(paths)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _reset() -> int:
|
|
184
|
+
"""Stop the daemon and VPN, and remove all persisted runtime files."""
|
|
185
|
+
print("Resetting persisted Coda runtime state...")
|
|
186
|
+
|
|
187
|
+
# Stop the daemon if running
|
|
188
|
+
daemon_stopped = stop_daemon()
|
|
189
|
+
print(" Stopped Coda daemon" if daemon_stopped else " No Coda daemon running")
|
|
190
|
+
|
|
191
|
+
# Stop OpenVPN
|
|
192
|
+
vpn_killed = kill_openvpn_daemon()
|
|
193
|
+
print(
|
|
194
|
+
" Stopped managed OpenVPN daemon"
|
|
195
|
+
if vpn_killed
|
|
196
|
+
else " No managed VPN process found"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
removed_any = False
|
|
200
|
+
for path in _read_reset_paths():
|
|
201
|
+
if path.exists():
|
|
202
|
+
path.unlink()
|
|
203
|
+
removed_any = True
|
|
204
|
+
print(f" ✓ Removed {path}")
|
|
205
|
+
else:
|
|
206
|
+
print(f" - Not found {path}")
|
|
207
|
+
|
|
208
|
+
if not removed_any:
|
|
209
|
+
print(" No persisted runtime files found")
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _doctor() -> int:
|
|
214
|
+
"""Print diagnostic information about the runtime environment."""
|
|
215
|
+
settings = Settings()
|
|
216
|
+
openvpn_bin = shutil.which("openvpn") or shutil.which("openvpn.exe")
|
|
217
|
+
iface = detect_tun_interface(settings.vpn_interface_hint)
|
|
218
|
+
pid_exists = Path(OPENVPN_PID_PATH).exists()
|
|
219
|
+
|
|
220
|
+
_print_banner(
|
|
221
|
+
"C O D A · D O C T O R",
|
|
222
|
+
[
|
|
223
|
+
("WEBAPP", settings.webapp_url),
|
|
224
|
+
("CONNECT", settings.connect_url),
|
|
225
|
+
("REDIS", settings.redis_url or "not set"),
|
|
226
|
+
],
|
|
227
|
+
)
|
|
228
|
+
_print_status("EXECUTOR", settings.executor_factory or "NoopExecutor")
|
|
229
|
+
_print_status("OPENVPN", openvpn_bin or "not found")
|
|
230
|
+
_print_status("VPN IFACE", iface or "not detected")
|
|
231
|
+
_print_status("VPN PID", "present" if pid_exists else "absent")
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main() -> None:
|
|
236
|
+
"""Entry point for the ``coda`` / ``coda-node`` CLI.
|
|
237
|
+
|
|
238
|
+
Parses arguments, dispatches to the appropriate subcommand, and
|
|
239
|
+
raises :class:`SystemExit` with the subcommand's return code.
|
|
240
|
+
"""
|
|
241
|
+
_configure_logging()
|
|
242
|
+
parser = _build_parser()
|
|
243
|
+
args = parser.parse_args()
|
|
244
|
+
|
|
245
|
+
if args.reset or args.command == "reset":
|
|
246
|
+
raise SystemExit(_reset())
|
|
247
|
+
|
|
248
|
+
if args.command == "start":
|
|
249
|
+
_apply_overrides(args)
|
|
250
|
+
settings = Settings()
|
|
251
|
+
daemon_mode = getattr(args, "daemon", False)
|
|
252
|
+
|
|
253
|
+
if daemon_mode:
|
|
254
|
+
if is_daemon_running():
|
|
255
|
+
status = daemon_status()
|
|
256
|
+
print(f"Daemon already running (PID {status['pid']})")
|
|
257
|
+
raise SystemExit(1)
|
|
258
|
+
|
|
259
|
+
_print_banner(
|
|
260
|
+
"C O D A · D A E M O N",
|
|
261
|
+
[
|
|
262
|
+
("WEBAPP", settings.webapp_url),
|
|
263
|
+
("ENDPOINT", f"{settings.host}:{settings.port}"),
|
|
264
|
+
("MODE", _start_mode(settings.node_token)),
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
try:
|
|
268
|
+
pid = start_daemon(
|
|
269
|
+
host=settings.host,
|
|
270
|
+
port=settings.port,
|
|
271
|
+
token=settings.node_token or None,
|
|
272
|
+
)
|
|
273
|
+
print(f" Started daemon (PID {pid})")
|
|
274
|
+
print(f" Log file: {DAEMON_LOG_PATH}")
|
|
275
|
+
print()
|
|
276
|
+
print(" Use 'coda stop' to stop the daemon")
|
|
277
|
+
print(" Use 'coda status' to check daemon status")
|
|
278
|
+
print(" Use 'coda logs' to view log output")
|
|
279
|
+
except RuntimeError as exc:
|
|
280
|
+
print(f" Failed to start daemon: {exc}")
|
|
281
|
+
raise SystemExit(1) from None
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
_print_banner(
|
|
285
|
+
"C O D A · N O D E",
|
|
286
|
+
[
|
|
287
|
+
("WEBAPP", settings.webapp_url),
|
|
288
|
+
("ENDPOINT", f"{settings.host}:{settings.port}"),
|
|
289
|
+
("MODE", _start_mode(settings.node_token)),
|
|
290
|
+
],
|
|
291
|
+
)
|
|
292
|
+
uvicorn.run(
|
|
293
|
+
"coda_node.server.app:app",
|
|
294
|
+
host=settings.host,
|
|
295
|
+
port=settings.port,
|
|
296
|
+
reload=False,
|
|
297
|
+
log_level="warning",
|
|
298
|
+
)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
if args.command == "stop":
|
|
302
|
+
if not is_daemon_running():
|
|
303
|
+
print("No daemon is running")
|
|
304
|
+
raise SystemExit(1)
|
|
305
|
+
status = daemon_status()
|
|
306
|
+
print(f"Stopping daemon (PID {status['pid']})...")
|
|
307
|
+
stopped = stop_daemon()
|
|
308
|
+
if stopped:
|
|
309
|
+
print(" Daemon stopped")
|
|
310
|
+
raise SystemExit(0)
|
|
311
|
+
else:
|
|
312
|
+
print(" Failed to stop daemon")
|
|
313
|
+
raise SystemExit(1)
|
|
314
|
+
|
|
315
|
+
if args.command == "status":
|
|
316
|
+
status = daemon_status()
|
|
317
|
+
_print_banner(
|
|
318
|
+
"C O D A · S T A T U S",
|
|
319
|
+
[
|
|
320
|
+
("DAEMON", "running" if status["running"] else "stopped"),
|
|
321
|
+
("PID", str(status["pid"]) if status["pid"] else "-"),
|
|
322
|
+
("PID FILE", str(DAEMON_PID_PATH)),
|
|
323
|
+
],
|
|
324
|
+
)
|
|
325
|
+
_print_status("LOG FILE", str(DAEMON_LOG_PATH))
|
|
326
|
+
_print_status("LOG EXISTS", "yes" if status["log_exists"] else "no")
|
|
327
|
+
|
|
328
|
+
# Also show VPN status if available
|
|
329
|
+
try:
|
|
330
|
+
settings = Settings()
|
|
331
|
+
iface = detect_tun_interface(settings.vpn_interface_hint)
|
|
332
|
+
_print_status("VPN IFACE", iface or "not detected")
|
|
333
|
+
except Exception:
|
|
334
|
+
_print_status("VPN IFACE", "unknown")
|
|
335
|
+
|
|
336
|
+
raise SystemExit(0 if status["running"] else 1)
|
|
337
|
+
|
|
338
|
+
if args.command == "logs":
|
|
339
|
+
lines = getattr(args, "lines", 50)
|
|
340
|
+
log_output = tail_daemon_log(lines)
|
|
341
|
+
if not log_output:
|
|
342
|
+
print(f"No log file found at {DAEMON_LOG_PATH}")
|
|
343
|
+
raise SystemExit(1)
|
|
344
|
+
print(log_output)
|
|
345
|
+
raise SystemExit(0)
|
|
346
|
+
|
|
347
|
+
if args.command == "doctor":
|
|
348
|
+
raise SystemExit(_doctor())
|
|
349
|
+
|
|
350
|
+
if args.command == "stop-vpn":
|
|
351
|
+
killed = kill_openvpn_daemon()
|
|
352
|
+
print("Stopping Coda VPN service...")
|
|
353
|
+
print(
|
|
354
|
+
" ✓ Stopped managed OpenVPN daemon"
|
|
355
|
+
if killed
|
|
356
|
+
else " No managed VPN process found"
|
|
357
|
+
)
|
|
358
|
+
raise SystemExit(0 if killed else 1)
|
|
359
|
+
|
|
360
|
+
parser.error("a command is required")
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Runtime configuration for the Coda node server.
|
|
2
|
+
|
|
3
|
+
Configuration is resolved in three layers (highest priority first):
|
|
4
|
+
|
|
5
|
+
1. **Environment variables** prefixed with ``CODA_`` (e.g.
|
|
6
|
+
``CODA_REDIS_URL``).
|
|
7
|
+
2. **Persisted runtime config** written to ``/tmp/coda.config`` after a
|
|
8
|
+
successful node provisioning, so later restarts can reconnect
|
|
9
|
+
without a fresh token.
|
|
10
|
+
3. **Hardcoded defaults** defined on :class:`Settings`.
|
|
11
|
+
|
|
12
|
+
The persisted private key is stored separately at
|
|
13
|
+
``/tmp/coda-private-key`` with ``0600`` permissions on POSIX systems.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import model_validator
|
|
28
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
29
|
+
|
|
30
|
+
from coda_node.errors import ConfigError
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
_EXECUTOR_FACTORY_RE = re.compile(r"^executor_factory\s*:\s*(.+?)\s*$")
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"PERSISTED_CONFIG_PATH",
|
|
37
|
+
"PERSISTED_PRIVATE_KEY_PATH",
|
|
38
|
+
"Settings",
|
|
39
|
+
"load_persisted_runtime_config",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_RUNTIME_DIR = Path(tempfile.gettempdir())
|
|
43
|
+
PERSISTED_CONFIG_PATH = _RUNTIME_DIR / "coda.config"
|
|
44
|
+
PERSISTED_PRIVATE_KEY_PATH = _RUNTIME_DIR / "coda-private-key"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_secure_text(path: Path) -> str:
|
|
48
|
+
"""Read a file after verifying it has ``0600`` permissions on POSIX."""
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return ""
|
|
51
|
+
if os.name != "nt":
|
|
52
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
53
|
+
if mode != 0o600:
|
|
54
|
+
raise ConfigError(f"{path} must have permissions 0600")
|
|
55
|
+
return path.read_text()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_persisted_runtime_config() -> dict[str, Any]:
|
|
59
|
+
"""Load previously persisted runtime state from disk.
|
|
60
|
+
|
|
61
|
+
Reads the JSON config at :data:`PERSISTED_CONFIG_PATH` and the
|
|
62
|
+
private key at the path recorded inside it (or the default
|
|
63
|
+
:data:`PERSISTED_PRIVATE_KEY_PATH`). Both files must have ``0600``
|
|
64
|
+
permissions on POSIX systems or a :class:`ValueError` is raised.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A dictionary of setting overrides, or an empty dict if no
|
|
68
|
+
persisted config exists.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ConfigError: If the config file exists but has wrong permissions
|
|
72
|
+
or does not contain a JSON object.
|
|
73
|
+
"""
|
|
74
|
+
if not PERSISTED_CONFIG_PATH.exists():
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
raw_config = _read_secure_text(PERSISTED_CONFIG_PATH)
|
|
78
|
+
if not raw_config.strip():
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
data = json.loads(raw_config)
|
|
82
|
+
if not isinstance(data, dict):
|
|
83
|
+
raise ConfigError(f"{PERSISTED_CONFIG_PATH} must contain a JSON object")
|
|
84
|
+
|
|
85
|
+
private_key_path = Path(
|
|
86
|
+
str(data.get("jwt_private_key_path") or PERSISTED_PRIVATE_KEY_PATH)
|
|
87
|
+
)
|
|
88
|
+
private_key = _read_secure_text(private_key_path)
|
|
89
|
+
|
|
90
|
+
persisted: dict[str, Any] = {}
|
|
91
|
+
for key in (
|
|
92
|
+
"qpu_id",
|
|
93
|
+
"qpu_display_name",
|
|
94
|
+
"native_gate_set",
|
|
95
|
+
"num_qubits",
|
|
96
|
+
"jwt_key_id",
|
|
97
|
+
"redis_url",
|
|
98
|
+
"webapp_url",
|
|
99
|
+
"connect_path",
|
|
100
|
+
"heartbeat_path",
|
|
101
|
+
"webhook_path",
|
|
102
|
+
"vpn_required",
|
|
103
|
+
"vpn_check_interval_sec",
|
|
104
|
+
"vpn_interface_hint",
|
|
105
|
+
"vpn_probe_targets",
|
|
106
|
+
"node_auto_vpn",
|
|
107
|
+
"node_vpn_profile_path",
|
|
108
|
+
"node_machine_fingerprint",
|
|
109
|
+
):
|
|
110
|
+
if key in data:
|
|
111
|
+
persisted[key] = data[key]
|
|
112
|
+
|
|
113
|
+
if private_key:
|
|
114
|
+
persisted["jwt_private_key"] = private_key
|
|
115
|
+
|
|
116
|
+
return persisted
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _strip_inline_yaml_comment(value: str) -> str:
|
|
120
|
+
"""Remove trailing comments from a simple scalar YAML value."""
|
|
121
|
+
in_single = False
|
|
122
|
+
in_double = False
|
|
123
|
+
for idx, char in enumerate(value):
|
|
124
|
+
if char == "'" and not in_double:
|
|
125
|
+
in_single = not in_single
|
|
126
|
+
elif char == '"' and not in_single:
|
|
127
|
+
in_double = not in_double
|
|
128
|
+
elif char == "#" and not in_single and not in_double:
|
|
129
|
+
return value[:idx].rstrip()
|
|
130
|
+
return value.strip()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_executor_factory_from_device_config(path: str) -> str:
|
|
134
|
+
"""Read an optional top-level ``executor_factory`` scalar from device YAML."""
|
|
135
|
+
try:
|
|
136
|
+
for raw_line in Path(path).read_text().splitlines():
|
|
137
|
+
if raw_line.startswith((" ", "\t")):
|
|
138
|
+
continue
|
|
139
|
+
match = _EXECUTOR_FACTORY_RE.match(raw_line.strip())
|
|
140
|
+
if not match:
|
|
141
|
+
continue
|
|
142
|
+
value = _strip_inline_yaml_comment(match.group(1))
|
|
143
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
144
|
+
value = value[1:-1]
|
|
145
|
+
return value.strip()
|
|
146
|
+
except OSError:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"Failed to read device config while resolving executor factory: %s", path
|
|
149
|
+
)
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Settings(BaseSettings):
|
|
154
|
+
"""Coda-connected node configuration.
|
|
155
|
+
|
|
156
|
+
All fields can be set via ``CODA_``-prefixed environment variables
|
|
157
|
+
(e.g. ``CODA_QPU_ID``, ``CODA_REDIS_URL``). When no node
|
|
158
|
+
token is provided, the model validator automatically loads any
|
|
159
|
+
previously persisted runtime config so the node can reconnect with
|
|
160
|
+
its stored JWT credentials.
|
|
161
|
+
|
|
162
|
+
The model uses ``validate_assignment=True`` so that mutations made
|
|
163
|
+
by :func:`~coda_node.vpn.service.apply_node_bundle` are
|
|
164
|
+
validated against field types.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
qpu_id: str = ""
|
|
168
|
+
qpu_display_name: str = ""
|
|
169
|
+
native_gate_set: str = "cz"
|
|
170
|
+
num_qubits: int = 5
|
|
171
|
+
|
|
172
|
+
redis_url: str = ""
|
|
173
|
+
|
|
174
|
+
jwt_private_key: str = ""
|
|
175
|
+
jwt_key_id: str = ""
|
|
176
|
+
|
|
177
|
+
webapp_url: str = "https://coda.conductorquantum.com"
|
|
178
|
+
webhook_path: str = "/api/internal/qpu/webhook"
|
|
179
|
+
connect_path: str = "/api/internal/qpu/connect"
|
|
180
|
+
heartbeat_path: str = "/api/internal/qpu/heartbeat"
|
|
181
|
+
|
|
182
|
+
host: str = "0.0.0.0"
|
|
183
|
+
port: int = 8080
|
|
184
|
+
|
|
185
|
+
vpn_required: bool = True
|
|
186
|
+
vpn_check_interval_sec: int = 10
|
|
187
|
+
vpn_probe_targets: list[str] = []
|
|
188
|
+
vpn_interface_hint: str | None = None
|
|
189
|
+
allow_degraded_startup: bool = False
|
|
190
|
+
|
|
191
|
+
node_token: str = ""
|
|
192
|
+
node_timeout_sec: int = 15
|
|
193
|
+
node_machine_fingerprint: str = ""
|
|
194
|
+
node_auto_vpn: bool = True
|
|
195
|
+
node_vpn_profile_path: str = f"{tempfile.gettempdir()}/coda-node.ovpn"
|
|
196
|
+
|
|
197
|
+
node_connect_headers: dict[str, str] = {}
|
|
198
|
+
node_connect_retries: int = 3
|
|
199
|
+
shutdown_drain_timeout_sec: int = 30
|
|
200
|
+
heartbeat_interval_sec: int = 30
|
|
201
|
+
|
|
202
|
+
executor_factory: str = ""
|
|
203
|
+
device_config: str = ""
|
|
204
|
+
advertised_provider: str = "coda"
|
|
205
|
+
|
|
206
|
+
@model_validator(mode="before")
|
|
207
|
+
@classmethod
|
|
208
|
+
def merge_persisted_runtime_config(cls, data: Any) -> Any:
|
|
209
|
+
"""Apply persisted runtime config before field validation.
|
|
210
|
+
|
|
211
|
+
This preserves the intended precedence order without mutating the model
|
|
212
|
+
during validation, which would otherwise re-enter assignment validation.
|
|
213
|
+
"""
|
|
214
|
+
if not isinstance(data, dict):
|
|
215
|
+
return data
|
|
216
|
+
if data.get("node_token"):
|
|
217
|
+
return data
|
|
218
|
+
|
|
219
|
+
merged = dict(data)
|
|
220
|
+
persisted = load_persisted_runtime_config()
|
|
221
|
+
for key, value in persisted.items():
|
|
222
|
+
current = merged.get(key)
|
|
223
|
+
if key not in merged or current in ("", None, []):
|
|
224
|
+
merged[key] = value
|
|
225
|
+
return merged
|
|
226
|
+
|
|
227
|
+
@model_validator(mode="after")
|
|
228
|
+
def apply_default_device_config(self) -> Settings:
|
|
229
|
+
"""Use ``./site/device.yaml`` when no explicit device config is set."""
|
|
230
|
+
if not self.device_config:
|
|
231
|
+
default_path = Path("site/device.yaml")
|
|
232
|
+
if default_path.exists():
|
|
233
|
+
logger.info("Using default device config: %s", default_path)
|
|
234
|
+
self.device_config = default_path.as_posix()
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
@model_validator(mode="after")
|
|
238
|
+
def apply_device_config_executor_factory(self) -> Settings:
|
|
239
|
+
"""Use ``executor_factory`` from the device config when env/config omit it."""
|
|
240
|
+
if self.executor_factory or not self.device_config:
|
|
241
|
+
return self
|
|
242
|
+
|
|
243
|
+
resolved = _load_executor_factory_from_device_config(self.device_config)
|
|
244
|
+
if resolved:
|
|
245
|
+
logger.info(
|
|
246
|
+
"Using executor factory from device config %s: %s",
|
|
247
|
+
self.device_config,
|
|
248
|
+
resolved,
|
|
249
|
+
)
|
|
250
|
+
self.executor_factory = resolved
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
@model_validator(mode="after")
|
|
254
|
+
def check_jwt_or_node_token(self) -> Settings:
|
|
255
|
+
"""Require JWT credentials unless node will supply them."""
|
|
256
|
+
|
|
257
|
+
if not self.node_token:
|
|
258
|
+
if not self.jwt_private_key:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
"CODA_JWT_PRIVATE_KEY must be set "
|
|
261
|
+
"(or provide CODA_NODE_TOKEN for auto-provisioning)"
|
|
262
|
+
)
|
|
263
|
+
if not self.jwt_key_id:
|
|
264
|
+
raise ValueError(
|
|
265
|
+
"CODA_JWT_KEY_ID must be set "
|
|
266
|
+
"(or provide CODA_NODE_TOKEN for auto-provisioning)"
|
|
267
|
+
)
|
|
268
|
+
return self
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def callback_url(self) -> str:
|
|
272
|
+
"""Full URL for webhook delivery."""
|
|
273
|
+
return f"{self.webapp_url}{self.webhook_path}"
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def connect_url(self) -> str:
|
|
277
|
+
"""Full URL for the node connect endpoint."""
|
|
278
|
+
return f"{self.webapp_url}{self.connect_path}"
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def heartbeat_url(self) -> str:
|
|
282
|
+
"""Full URL for heartbeat reporting."""
|
|
283
|
+
return f"{self.webapp_url}{self.heartbeat_path}"
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def vpn_probe_urls(self) -> list[str]:
|
|
287
|
+
"""URLs to probe for VPN connectivity.
|
|
288
|
+
|
|
289
|
+
Returns explicit targets when configured, the health endpoint as
|
|
290
|
+
a fallback when VPN is required, or an empty list when VPN is
|
|
291
|
+
not required (HTTPS mode) to avoid probing endpoints that only
|
|
292
|
+
accept POST.
|
|
293
|
+
"""
|
|
294
|
+
if self.vpn_probe_targets:
|
|
295
|
+
return list(self.vpn_probe_targets)
|
|
296
|
+
if not self.vpn_required:
|
|
297
|
+
return []
|
|
298
|
+
return [f"{self.webapp_url}/api/internal/qpu/health"]
|
|
299
|
+
|
|
300
|
+
model_config = SettingsConfigDict(env_prefix="CODA_", validate_assignment=True)
|