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.
@@ -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)