embedded-lab-control 0.2.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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/core/config.py
ADDED
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for lab controller.
|
|
3
|
+
|
|
4
|
+
Loads configuration from YAML files with environment variable overrides.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextvars
|
|
8
|
+
import errno
|
|
9
|
+
import hmac
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Set during load_config(fresh=True): its per-request re-reads (API keys)
|
|
21
|
+
# must not log on every request; the caller reports state changes instead.
|
|
22
|
+
_quiet = contextvars.ContextVar("labctl_config_quiet", default=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _QuietFilter(logging.Filter):
|
|
26
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
27
|
+
return not _quiet.get()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
logger.addFilter(_QuietFilter())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _default_config_dir() -> Path:
|
|
34
|
+
"""Return the user config dir, honoring XDG_CONFIG_HOME when set."""
|
|
35
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
36
|
+
if base:
|
|
37
|
+
return Path(base).expanduser() / "labctl"
|
|
38
|
+
return Path.home() / ".config" / "labctl"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _default_config_file() -> Path:
|
|
42
|
+
"""Return the default config file path for the current user."""
|
|
43
|
+
return _default_config_dir() / "config.yaml"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_data_dir() -> Path:
|
|
47
|
+
"""Return the user data dir, honoring XDG_DATA_HOME when set."""
|
|
48
|
+
base = os.environ.get("XDG_DATA_HOME")
|
|
49
|
+
if base:
|
|
50
|
+
return Path(base).expanduser() / "labctl"
|
|
51
|
+
return Path.home() / ".local" / "share" / "labctl"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _expand_path(value: str | Path) -> Path:
|
|
55
|
+
"""Expand env vars and ~ in config-provided path values."""
|
|
56
|
+
return Path(os.path.expandvars(str(value))).expanduser()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# LABCTL_SYSTEM_CONFIG replaces the system-wide fallback (read at import,
|
|
60
|
+
# so child processes inherit it; ~ and $VARS expanded, as for
|
|
61
|
+
# LABCTL_CONFIG); the test suite points it at a file that doesn't exist,
|
|
62
|
+
# so tests never load a lab host's real config.
|
|
63
|
+
SYSTEM_CONFIG_FILE = _expand_path(
|
|
64
|
+
os.environ.get("LABCTL_SYSTEM_CONFIG") or "/etc/labctl/config.yaml"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _section(data: dict, key: str) -> dict:
|
|
69
|
+
"""A config section as a dict, tolerating an empty or malformed one.
|
|
70
|
+
|
|
71
|
+
A header with everything under it commented out (``mcp:`` followed only
|
|
72
|
+
by ``#`` lines) parses as None; ``data.get(key, {})`` then returned None
|
|
73
|
+
and every labctl command crashed in from_dict. Treat None as an empty
|
|
74
|
+
section; ignore (with a warning) anything that isn't a mapping.
|
|
75
|
+
"""
|
|
76
|
+
value = data.get(key)
|
|
77
|
+
if value is None:
|
|
78
|
+
return {}
|
|
79
|
+
if not isinstance(value, dict):
|
|
80
|
+
logger.warning("Config: section %r is not a mapping; ignoring", key)
|
|
81
|
+
return {}
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _path_list(section: dict, key: str) -> list[str]:
|
|
86
|
+
"""Read a list of absolute directory paths (an allowlist) from config.
|
|
87
|
+
|
|
88
|
+
Accepts a list or a single string. ``~`` and ``$VARS`` are expanded.
|
|
89
|
+
Relative or non-string entries are skipped with a warning: an allowlist
|
|
90
|
+
root that silently depended on the working directory would be a trap.
|
|
91
|
+
"""
|
|
92
|
+
raw = section.get(key) or []
|
|
93
|
+
if isinstance(raw, str):
|
|
94
|
+
raw = [raw]
|
|
95
|
+
if not isinstance(raw, list):
|
|
96
|
+
logger.warning("Config: %s must be a list of paths; ignoring", key)
|
|
97
|
+
return []
|
|
98
|
+
paths = []
|
|
99
|
+
for entry in raw:
|
|
100
|
+
if not isinstance(entry, str) or not entry:
|
|
101
|
+
logger.warning("Config: %s entry %r is not a path; ignoring", key, entry)
|
|
102
|
+
continue
|
|
103
|
+
path = _expand_path(entry)
|
|
104
|
+
if not path.is_absolute():
|
|
105
|
+
logger.warning("Config: %s entry %r is not absolute; ignoring", key, entry)
|
|
106
|
+
continue
|
|
107
|
+
paths.append(str(path))
|
|
108
|
+
return paths
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _abs_path(section: dict, key: str) -> str:
|
|
112
|
+
"""Read one absolute path from config ("" when unset). ``~`` and
|
|
113
|
+
``$VARS`` are expanded; a relative or non-string value is ignored with
|
|
114
|
+
a warning, as in _path_list."""
|
|
115
|
+
raw = section.get(key)
|
|
116
|
+
if raw is None or raw == "":
|
|
117
|
+
return ""
|
|
118
|
+
if not isinstance(raw, str):
|
|
119
|
+
logger.warning("Config: %s must be a path, got %r; ignoring", key, raw)
|
|
120
|
+
return ""
|
|
121
|
+
path = _expand_path(raw)
|
|
122
|
+
if not path.is_absolute():
|
|
123
|
+
logger.warning("Config: %s %r is not absolute; ignoring", key, raw)
|
|
124
|
+
return ""
|
|
125
|
+
return str(path)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _positive_int(section: dict, key: str, default: int) -> int:
|
|
129
|
+
"""Read a whole number above zero; anything else keeps the default,
|
|
130
|
+
with a warning."""
|
|
131
|
+
value = section.get(key)
|
|
132
|
+
if value is None:
|
|
133
|
+
return default
|
|
134
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
135
|
+
logger.warning(
|
|
136
|
+
"Config: %s must be a whole number above 0, got %r; using %d",
|
|
137
|
+
key,
|
|
138
|
+
value,
|
|
139
|
+
default,
|
|
140
|
+
)
|
|
141
|
+
return default
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _non_negative_int(section: dict, key: str, default: int) -> int:
|
|
146
|
+
"""Read a whole number of 0 or more; anything else keeps the default,
|
|
147
|
+
with a warning."""
|
|
148
|
+
value = section.get(key)
|
|
149
|
+
if value is None:
|
|
150
|
+
return default
|
|
151
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
152
|
+
_warn_once(
|
|
153
|
+
"Config: %s must be a whole number (0 or more), got %r; using %d",
|
|
154
|
+
key,
|
|
155
|
+
value,
|
|
156
|
+
default,
|
|
157
|
+
)
|
|
158
|
+
return default
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _tcp_port(section: dict, key: str, default: int) -> int:
|
|
163
|
+
"""Read a TCP port number (1-65535); anything else keeps the default,
|
|
164
|
+
with a warning."""
|
|
165
|
+
value = section.get(key)
|
|
166
|
+
if value is None:
|
|
167
|
+
return default
|
|
168
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 65535:
|
|
169
|
+
_warn_once(
|
|
170
|
+
"Config: %s must be a TCP port number (1-65535), got %r; using %d",
|
|
171
|
+
key,
|
|
172
|
+
value,
|
|
173
|
+
default,
|
|
174
|
+
)
|
|
175
|
+
return default
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Keys earlier versions documented but never acted on; they were removed.
|
|
180
|
+
# A config that still sets one gets a warning, not an error.
|
|
181
|
+
_REMOVED_KEYS = {
|
|
182
|
+
"proxy": ("enabled", "log_retention_days", "idle_timeout"),
|
|
183
|
+
"health": ("update_status_on_check",),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _warn_removed_keys(data: dict) -> None:
|
|
188
|
+
for section, keys in _REMOVED_KEYS.items():
|
|
189
|
+
values = _section(data, section)
|
|
190
|
+
for key in keys:
|
|
191
|
+
if key in values:
|
|
192
|
+
_warn_once(
|
|
193
|
+
"Config: %s.%s is no longer supported and is ignored",
|
|
194
|
+
section,
|
|
195
|
+
key,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _safety_flag(section: dict, key: str, default: bool = True) -> bool:
|
|
200
|
+
"""Read an on/off safety switch: only an explicit boolean counts.
|
|
201
|
+
|
|
202
|
+
A key left blank (``confirm_destructive:``) parses as None, and
|
|
203
|
+
``bool(None)`` would silently turn the safety off. Blank keeps the safe
|
|
204
|
+
default; any other non-boolean also keeps it, with a warning.
|
|
205
|
+
"""
|
|
206
|
+
value = section.get(key)
|
|
207
|
+
if value is None:
|
|
208
|
+
return default
|
|
209
|
+
if isinstance(value, bool):
|
|
210
|
+
return value
|
|
211
|
+
_warn_once(
|
|
212
|
+
"Config: %s must be true or false, got %r; keeping %s", key, value, default
|
|
213
|
+
)
|
|
214
|
+
return default
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_FALSE_WORDS = {"false", "no", "off", "0"}
|
|
218
|
+
|
|
219
|
+
# The MCP HTTP server re-reads the config for every request (API keys),
|
|
220
|
+
# so warn about a given bad value once per process, not per request.
|
|
221
|
+
_warned: set[str] = set()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _warn_once(msg: str, *args) -> None:
|
|
225
|
+
text = msg % args
|
|
226
|
+
if text not in _warned:
|
|
227
|
+
_warned.add(text)
|
|
228
|
+
logger.warning(text)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _opt_in_flag(section: dict, key: str) -> bool:
|
|
232
|
+
"""Read an opt-in safety switch (off by default). Booleans count as
|
|
233
|
+
given; blank keeps it off; anything else is on, with a warning, unless
|
|
234
|
+
it plainly means off ("false", "no", "off", 0): a quoted ``"true"``
|
|
235
|
+
or ``"yes"`` mustn't silently leave the protection off."""
|
|
236
|
+
value = section.get(key)
|
|
237
|
+
if isinstance(value, str) and not value.strip():
|
|
238
|
+
value = None # blank, like an empty YAML value
|
|
239
|
+
if value is None or isinstance(value, bool):
|
|
240
|
+
return bool(value)
|
|
241
|
+
enabled = str(value).strip().lower() not in _FALSE_WORDS
|
|
242
|
+
_warn_once(
|
|
243
|
+
"Config: %s must be true or false, got %r; using %s", key, value, enabled
|
|
244
|
+
)
|
|
245
|
+
return enabled
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _role(user: dict) -> str:
|
|
249
|
+
"""A user's role: missing or blank means operator (existing configs
|
|
250
|
+
keep working); anything unrecognised means agent, the role with less
|
|
251
|
+
privilege, with a warning."""
|
|
252
|
+
value = user.get("role")
|
|
253
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
254
|
+
return ROLE_OPERATOR
|
|
255
|
+
if isinstance(value, str) and value.strip().lower() in ROLES:
|
|
256
|
+
return value.strip().lower()
|
|
257
|
+
_warn_once(
|
|
258
|
+
"Config: auth user %r has unknown role %r (expected %s); treating as %s",
|
|
259
|
+
user.get("username", ""),
|
|
260
|
+
value,
|
|
261
|
+
" or ".join(ROLES),
|
|
262
|
+
ROLE_AGENT,
|
|
263
|
+
)
|
|
264
|
+
return ROLE_AGENT
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _name_list(section: dict, key: str) -> list[str]:
|
|
268
|
+
"""Read a list of names (strings) from config; skip non-strings."""
|
|
269
|
+
raw = section.get(key) or []
|
|
270
|
+
if isinstance(raw, str):
|
|
271
|
+
raw = [raw]
|
|
272
|
+
if not isinstance(raw, list):
|
|
273
|
+
logger.warning("Config: %s must be a list of names; ignoring", key)
|
|
274
|
+
return []
|
|
275
|
+
names = [n for n in raw if isinstance(n, str) and n]
|
|
276
|
+
if len(names) != len(raw):
|
|
277
|
+
logger.warning("Config: %s has non-string entries; ignoring them", key)
|
|
278
|
+
return names
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _path_exists(path: Path) -> bool:
|
|
282
|
+
"""Best-effort existence check that tolerates unreadable paths."""
|
|
283
|
+
try:
|
|
284
|
+
return path.exists()
|
|
285
|
+
except OSError as e:
|
|
286
|
+
logger.warning("Failed to access config path %s: %s", path, e)
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass
|
|
291
|
+
class SerialConfig:
|
|
292
|
+
"""Serial port configuration."""
|
|
293
|
+
|
|
294
|
+
dev_dir: Path = field(default_factory=lambda: Path("/dev/lab"))
|
|
295
|
+
base_tcp_port: int = 4000
|
|
296
|
+
default_baud: int = 115200
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass
|
|
300
|
+
class Ser2NetConfig:
|
|
301
|
+
"""ser2net configuration."""
|
|
302
|
+
|
|
303
|
+
config_file: Path = field(default_factory=lambda: Path("/etc/ser2net.yaml"))
|
|
304
|
+
enabled: bool = True
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass
|
|
308
|
+
class ProxyConfig:
|
|
309
|
+
"""Multi-client serial proxy configuration."""
|
|
310
|
+
|
|
311
|
+
port_base: int = 5500
|
|
312
|
+
port_range: int = 100 # Ports 5500-5599 available
|
|
313
|
+
write_policy: str = "first" # first, all, queue
|
|
314
|
+
log_dir: Path = field(default_factory=lambda: _default_data_dir() / "logs")
|
|
315
|
+
max_clients: int = 10
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class HealthConfig:
|
|
320
|
+
"""Health check and monitoring configuration.
|
|
321
|
+
|
|
322
|
+
The daemon runs two cadences:
|
|
323
|
+
- Fast track (`check_interval`): ping + serial probe.
|
|
324
|
+
- Slow track (`power_check_interval`): power probe (network-bound,
|
|
325
|
+
sometimes slow). Power runs on every Nth fast tick where
|
|
326
|
+
N = ceil(power_check_interval / check_interval).
|
|
327
|
+
|
|
328
|
+
`min_sleep_seconds` is the floor on between-cycle sleep so a runaway
|
|
329
|
+
cycle can't pin a CPU spinning when elapsed >= check_interval.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
check_interval: int = 10 # seconds between fast (ping+serial) cycles
|
|
333
|
+
power_check_interval: int = 60 # seconds between power probes
|
|
334
|
+
min_sleep_seconds: float = 1.0 # floor on between-cycle sleep
|
|
335
|
+
ping_timeout: float = 2.0 # seconds
|
|
336
|
+
serial_timeout: float = 2.0 # seconds
|
|
337
|
+
# Days the monitor keeps status history; 0 keeps all of it.
|
|
338
|
+
status_retention_days: int = 30
|
|
339
|
+
alert_log_path: Path = field(
|
|
340
|
+
default_factory=lambda: _default_data_dir() / "alerts.log"
|
|
341
|
+
)
|
|
342
|
+
alert_on_offline: bool = True
|
|
343
|
+
alert_on_power_change: bool = True
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class WebConfig:
|
|
348
|
+
"""Web server SSL/TLS configuration."""
|
|
349
|
+
|
|
350
|
+
cert_file: str = ""
|
|
351
|
+
key_file: str = ""
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass
|
|
355
|
+
class KasaConfig:
|
|
356
|
+
"""TP-Link Kasa device credentials.
|
|
357
|
+
|
|
358
|
+
Newer Kasa devices (KLAP protocol) require TP-Link cloud account
|
|
359
|
+
credentials for local control.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
username: str = ""
|
|
363
|
+
password: str = ""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# One image upload over MCP HTTP (mcp.max_upload_mb): room for a full
|
|
367
|
+
# SD-card image of a typical embedded Linux build.
|
|
368
|
+
DEFAULT_MAX_UPLOAD_MB = 8192
|
|
369
|
+
|
|
370
|
+
ROLE_OPERATOR = "operator"
|
|
371
|
+
ROLE_AGENT = "agent"
|
|
372
|
+
ROLES = (ROLE_OPERATOR, ROLE_AGENT)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass
|
|
376
|
+
class UserConfig:
|
|
377
|
+
"""User authentication configuration.
|
|
378
|
+
|
|
379
|
+
``role`` decides who may override another user's claim (D013):
|
|
380
|
+
operators may force-release or override; agents can only ask.
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
username: str = ""
|
|
384
|
+
password_hash: str = ""
|
|
385
|
+
api_key: str = ""
|
|
386
|
+
role: str = ROLE_OPERATOR
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def is_operator(self) -> bool:
|
|
390
|
+
return self.role == ROLE_OPERATOR
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass
|
|
394
|
+
class AuthConfig:
|
|
395
|
+
"""Authentication configuration."""
|
|
396
|
+
|
|
397
|
+
enabled: bool = False
|
|
398
|
+
users: list[UserConfig] = field(default_factory=list)
|
|
399
|
+
secret_key: str = ""
|
|
400
|
+
session_lifetime_minutes: int = 480
|
|
401
|
+
|
|
402
|
+
def key_users(self) -> list[UserConfig]:
|
|
403
|
+
"""Users that can authenticate with an API key.
|
|
404
|
+
|
|
405
|
+
Both the key and the username must be non-empty strings: a YAML
|
|
406
|
+
scalar like ``12345`` loads as int, and a blank username would make
|
|
407
|
+
the user indistinguishable from an unauthenticated caller.
|
|
408
|
+
"""
|
|
409
|
+
return [
|
|
410
|
+
u
|
|
411
|
+
for u in self.users
|
|
412
|
+
if isinstance(u.api_key, str)
|
|
413
|
+
and u.api_key
|
|
414
|
+
and isinstance(u.username, str)
|
|
415
|
+
and u.username
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
def user_for_api_key(self, api_key: str) -> Optional[UserConfig]:
|
|
419
|
+
"""The user whose API key this is, or None.
|
|
420
|
+
|
|
421
|
+
Shared by the web REST API (``X-API-Key``) and the MCP HTTP server
|
|
422
|
+
(``Authorization: Bearer``). Compares as UTF-8 bytes in constant
|
|
423
|
+
time: ``hmac.compare_digest`` raises TypeError on non-ASCII str, and
|
|
424
|
+
a header value is attacker-controlled. An empty key never matches,
|
|
425
|
+
nor does a user without a usable name (see ``key_users``).
|
|
426
|
+
"""
|
|
427
|
+
if not api_key:
|
|
428
|
+
return None
|
|
429
|
+
presented = api_key.encode("utf-8", "surrogateescape")
|
|
430
|
+
match = None
|
|
431
|
+
for user in self.key_users():
|
|
432
|
+
if hmac.compare_digest(
|
|
433
|
+
user.api_key.encode("utf-8", "surrogateescape"), presented
|
|
434
|
+
):
|
|
435
|
+
match = match or user
|
|
436
|
+
return match
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass
|
|
440
|
+
class ClaimsConfig:
|
|
441
|
+
"""Hardware claim (exclusive access) configuration."""
|
|
442
|
+
|
|
443
|
+
enabled: bool = True
|
|
444
|
+
default_duration_minutes: int = 30
|
|
445
|
+
max_duration_minutes: int = 1440 # 24h — covers overnight reliability runs
|
|
446
|
+
min_duration_minutes: int = 1
|
|
447
|
+
grace_period_seconds: int = 60
|
|
448
|
+
auto_prune_released_after_days: int = 30
|
|
449
|
+
require_agent_name: bool = False
|
|
450
|
+
# MCP/REST calls that change a board refuse unless the caller holds
|
|
451
|
+
# its claim (D013). Off by default: single-agent setups never claim.
|
|
452
|
+
require_claim: bool = False
|
|
453
|
+
|
|
454
|
+
def validate(self) -> list[str]:
|
|
455
|
+
"""Check invariants, returning a list of warning messages.
|
|
456
|
+
|
|
457
|
+
Clamps obviously wrong values to sane defaults so the system
|
|
458
|
+
still functions. Call after construction.
|
|
459
|
+
"""
|
|
460
|
+
warnings = []
|
|
461
|
+
if self.require_claim and not self.enabled:
|
|
462
|
+
warnings.append(
|
|
463
|
+
"claims.require_claim has no effect while claims.enabled is false"
|
|
464
|
+
)
|
|
465
|
+
if self.min_duration_minutes < 1:
|
|
466
|
+
warnings.append(
|
|
467
|
+
f"claims.min_duration_minutes={self.min_duration_minutes}"
|
|
468
|
+
" clamped to 1"
|
|
469
|
+
)
|
|
470
|
+
self.min_duration_minutes = 1
|
|
471
|
+
if self.max_duration_minutes < self.min_duration_minutes:
|
|
472
|
+
warnings.append(
|
|
473
|
+
f"claims.max_duration_minutes={self.max_duration_minutes}"
|
|
474
|
+
f" < min ({self.min_duration_minutes}), clamped to min"
|
|
475
|
+
)
|
|
476
|
+
self.max_duration_minutes = self.min_duration_minutes
|
|
477
|
+
if (
|
|
478
|
+
self.default_duration_minutes < self.min_duration_minutes
|
|
479
|
+
or self.default_duration_minutes > self.max_duration_minutes
|
|
480
|
+
):
|
|
481
|
+
clamped = max(
|
|
482
|
+
self.min_duration_minutes,
|
|
483
|
+
min(self.default_duration_minutes, self.max_duration_minutes),
|
|
484
|
+
)
|
|
485
|
+
warnings.append(
|
|
486
|
+
f"claims.default_duration_minutes="
|
|
487
|
+
f"{self.default_duration_minutes}"
|
|
488
|
+
f" outside [{self.min_duration_minutes},"
|
|
489
|
+
f" {self.max_duration_minutes}], clamped to {clamped}"
|
|
490
|
+
)
|
|
491
|
+
self.default_duration_minutes = clamped
|
|
492
|
+
if self.grace_period_seconds < 0:
|
|
493
|
+
warnings.append("claims.grace_period_seconds < 0, clamped to 0")
|
|
494
|
+
self.grace_period_seconds = 0
|
|
495
|
+
if self.auto_prune_released_after_days < 1:
|
|
496
|
+
warnings.append("claims.auto_prune_released_after_days < 1, clamped to 1")
|
|
497
|
+
self.auto_prune_released_after_days = 1
|
|
498
|
+
return warnings
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@dataclass
|
|
502
|
+
class DatabaseConfig:
|
|
503
|
+
"""SQLite database tuning knobs.
|
|
504
|
+
|
|
505
|
+
``timeout_seconds`` is the SQLite busy-wait used when another
|
|
506
|
+
process holds an incompatible lock. With WAL mode (always on for
|
|
507
|
+
labctl), readers never block writers and vice versa — the only
|
|
508
|
+
serialization is writer-vs-writer, which for labctl's
|
|
509
|
+
single-statement writes commits sub-millisecond. 10s is several
|
|
510
|
+
orders of magnitude over the realistic worst case; raise only if
|
|
511
|
+
a migration on a contended DB is timing out.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
timeout_seconds: float = 10.0
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@dataclass
|
|
518
|
+
class McpConfig:
|
|
519
|
+
"""MCP server configuration.
|
|
520
|
+
|
|
521
|
+
``allow_admin_actuator_ops`` gates the privileged actuator
|
|
522
|
+
provisioning tools (``actuator_add``, ``actuator_remove``,
|
|
523
|
+
``actuator_set``) over MCP. Defaults to False because raw
|
|
524
|
+
``actuator_set`` lets an agent bypass the binding layer; operators
|
|
525
|
+
deploying MCP behind a trusted transport can flip this on.
|
|
526
|
+
|
|
527
|
+
``allowed_read_paths`` / ``allowed_write_paths`` list the host
|
|
528
|
+
directories MCP tools may read (images, files to copy onto an SD card)
|
|
529
|
+
or write (boot-test output). Both default to empty, which denies all
|
|
530
|
+
host file access over MCP (D012); the CLI is not affected.
|
|
531
|
+
|
|
532
|
+
``confirm_destructive`` (default True) makes every MCP tool annotated
|
|
533
|
+
destructive a two-step call: the first call returns a plan and a
|
|
534
|
+
single-use ``confirm_token``; only a second call with that token and the
|
|
535
|
+
same arguments acts (D012). ``confirm_exempt`` lists tool names that
|
|
536
|
+
skip this (e.g. ``serial_send`` for heavy interactive console use).
|
|
537
|
+
|
|
538
|
+
``allowed_hosts`` / ``allowed_origins`` add accepted ``Host`` / ``Origin``
|
|
539
|
+
header values for the HTTP transport (DNS-rebinding protection), e.g. the
|
|
540
|
+
public name a reverse proxy forwards. ``name:*`` matches any port. HTTP
|
|
541
|
+
authentication itself follows ``auth.enabled`` and the users' API keys.
|
|
542
|
+
|
|
543
|
+
``upload_dir`` is the directory ``PUT /images/<name>`` on the HTTP
|
|
544
|
+
transport writes to (empty, the default, disables uploads); it should
|
|
545
|
+
be inside ``allowed_read_paths`` so tools can use what's uploaded.
|
|
546
|
+
``max_upload_mb`` caps one upload's size (D014).
|
|
547
|
+
|
|
548
|
+
``cert_file`` / ``key_file`` serve the HTTP transport over TLS (both or
|
|
549
|
+
neither; ``labctl mcp --cert/--key`` take precedence).
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
allow_admin_actuator_ops: bool = False
|
|
553
|
+
allowed_read_paths: list[str] = field(default_factory=list)
|
|
554
|
+
allowed_write_paths: list[str] = field(default_factory=list)
|
|
555
|
+
confirm_destructive: bool = True
|
|
556
|
+
confirm_exempt: list[str] = field(default_factory=list)
|
|
557
|
+
allowed_hosts: list[str] = field(default_factory=list)
|
|
558
|
+
allowed_origins: list[str] = field(default_factory=list)
|
|
559
|
+
upload_dir: str = ""
|
|
560
|
+
max_upload_mb: int = DEFAULT_MAX_UPLOAD_MB
|
|
561
|
+
cert_file: str = ""
|
|
562
|
+
key_file: str = ""
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@dataclass
|
|
566
|
+
class Config:
|
|
567
|
+
"""Main configuration for lab controller."""
|
|
568
|
+
|
|
569
|
+
serial: SerialConfig = field(default_factory=SerialConfig)
|
|
570
|
+
ser2net: Ser2NetConfig = field(default_factory=Ser2NetConfig)
|
|
571
|
+
proxy: ProxyConfig = field(default_factory=ProxyConfig)
|
|
572
|
+
health: HealthConfig = field(default_factory=HealthConfig)
|
|
573
|
+
auth: AuthConfig = field(default_factory=AuthConfig)
|
|
574
|
+
web: WebConfig = field(default_factory=WebConfig)
|
|
575
|
+
kasa: KasaConfig = field(default_factory=KasaConfig)
|
|
576
|
+
claims: ClaimsConfig = field(default_factory=ClaimsConfig)
|
|
577
|
+
mcp: McpConfig = field(default_factory=McpConfig)
|
|
578
|
+
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
|
579
|
+
database_path: Path = field(
|
|
580
|
+
default_factory=lambda: _default_config_dir() / "labctl.db"
|
|
581
|
+
)
|
|
582
|
+
log_level: str = "WARNING"
|
|
583
|
+
# The file this config was loaded from (None: defaults); not serialized.
|
|
584
|
+
source_path: Optional[Path] = field(default=None, compare=False, repr=False)
|
|
585
|
+
# "<path>: <error>" for each config file found but not loadable.
|
|
586
|
+
load_errors: list[str] = field(default_factory=list, compare=False, repr=False)
|
|
587
|
+
# True if one of those was a permission error (EACCES/EPERM).
|
|
588
|
+
load_denied: bool = field(default=False, compare=False, repr=False)
|
|
589
|
+
|
|
590
|
+
@classmethod
|
|
591
|
+
def from_dict(cls, data: dict[str, Any]) -> "Config":
|
|
592
|
+
"""Create Config from dictionary."""
|
|
593
|
+
serial_data = _section(data, "serial")
|
|
594
|
+
ser2net_data = _section(data, "ser2net")
|
|
595
|
+
proxy_data = _section(data, "proxy")
|
|
596
|
+
health_data = _section(data, "health")
|
|
597
|
+
auth_data = _section(data, "auth")
|
|
598
|
+
web_data = _section(data, "web")
|
|
599
|
+
kasa_data = _section(data, "kasa")
|
|
600
|
+
claims_data = _section(data, "claims")
|
|
601
|
+
mcp_data = _section(data, "mcp")
|
|
602
|
+
database_data = _section(data, "database")
|
|
603
|
+
_warn_removed_keys(data)
|
|
604
|
+
|
|
605
|
+
serial = SerialConfig(
|
|
606
|
+
dev_dir=_expand_path(serial_data.get("dev_dir", "/dev/lab")),
|
|
607
|
+
base_tcp_port=_tcp_port(serial_data, "base_tcp_port", 4000),
|
|
608
|
+
default_baud=serial_data.get("default_baud", 115200),
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
ser2net = Ser2NetConfig(
|
|
612
|
+
config_file=_expand_path(
|
|
613
|
+
ser2net_data.get("config_file", "/etc/ser2net.yaml")
|
|
614
|
+
),
|
|
615
|
+
enabled=ser2net_data.get("enabled", True),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
proxy = ProxyConfig(
|
|
619
|
+
port_base=proxy_data.get("port_base", 5500),
|
|
620
|
+
port_range=proxy_data.get("port_range", 100),
|
|
621
|
+
write_policy=proxy_data.get("write_policy", "first"),
|
|
622
|
+
log_dir=_expand_path(
|
|
623
|
+
proxy_data.get("log_dir", str(_default_data_dir() / "logs"))
|
|
624
|
+
),
|
|
625
|
+
max_clients=proxy_data.get("max_clients", 10),
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
health = HealthConfig(
|
|
629
|
+
check_interval=health_data.get("check_interval", 10),
|
|
630
|
+
power_check_interval=health_data.get("power_check_interval", 60),
|
|
631
|
+
min_sleep_seconds=health_data.get("min_sleep_seconds", 1.0),
|
|
632
|
+
ping_timeout=health_data.get("ping_timeout", 2.0),
|
|
633
|
+
serial_timeout=health_data.get("serial_timeout", 2.0),
|
|
634
|
+
status_retention_days=_non_negative_int(
|
|
635
|
+
health_data, "status_retention_days", 30
|
|
636
|
+
),
|
|
637
|
+
alert_log_path=_expand_path(
|
|
638
|
+
health_data.get(
|
|
639
|
+
"alert_log_path",
|
|
640
|
+
str(_default_data_dir() / "alerts.log"),
|
|
641
|
+
)
|
|
642
|
+
),
|
|
643
|
+
alert_on_offline=health_data.get("alert_on_offline", True),
|
|
644
|
+
alert_on_power_change=health_data.get("alert_on_power_change", True),
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
auth_users = []
|
|
648
|
+
for u in auth_data.get("users", []):
|
|
649
|
+
auth_users.append(
|
|
650
|
+
UserConfig(
|
|
651
|
+
username=u.get("username", ""),
|
|
652
|
+
password_hash=u.get("password_hash", ""),
|
|
653
|
+
api_key=u.get("api_key", ""),
|
|
654
|
+
role=_role(u),
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
auth = AuthConfig(
|
|
659
|
+
enabled=auth_data.get("enabled", False),
|
|
660
|
+
users=auth_users,
|
|
661
|
+
secret_key=auth_data.get("secret_key", ""),
|
|
662
|
+
session_lifetime_minutes=auth_data.get("session_lifetime_minutes", 480),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
web = WebConfig(
|
|
666
|
+
cert_file=web_data.get("cert_file", ""),
|
|
667
|
+
key_file=web_data.get("key_file", ""),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
kasa = KasaConfig(
|
|
671
|
+
username=kasa_data.get("username", ""),
|
|
672
|
+
password=kasa_data.get("password", ""),
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
claims = ClaimsConfig(
|
|
676
|
+
enabled=claims_data.get("enabled", True),
|
|
677
|
+
default_duration_minutes=claims_data.get("default_duration_minutes", 30),
|
|
678
|
+
max_duration_minutes=claims_data.get("max_duration_minutes", 1440),
|
|
679
|
+
min_duration_minutes=claims_data.get("min_duration_minutes", 1),
|
|
680
|
+
grace_period_seconds=claims_data.get("grace_period_seconds", 60),
|
|
681
|
+
auto_prune_released_after_days=claims_data.get(
|
|
682
|
+
"auto_prune_released_after_days", 30
|
|
683
|
+
),
|
|
684
|
+
require_agent_name=_opt_in_flag(claims_data, "require_agent_name"),
|
|
685
|
+
require_claim=_opt_in_flag(claims_data, "require_claim"),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Validate and clamp claim config bounds
|
|
689
|
+
claim_warnings = claims.validate()
|
|
690
|
+
for w in claim_warnings:
|
|
691
|
+
logger.warning("Config: %s", w)
|
|
692
|
+
|
|
693
|
+
mcp = McpConfig(
|
|
694
|
+
# A grant, not a protection: only an explicit true turns it on.
|
|
695
|
+
allow_admin_actuator_ops=_safety_flag(
|
|
696
|
+
mcp_data, "allow_admin_actuator_ops", default=False
|
|
697
|
+
),
|
|
698
|
+
allowed_read_paths=_path_list(mcp_data, "allowed_read_paths"),
|
|
699
|
+
allowed_write_paths=_path_list(mcp_data, "allowed_write_paths"),
|
|
700
|
+
confirm_destructive=_safety_flag(mcp_data, "confirm_destructive"),
|
|
701
|
+
confirm_exempt=_name_list(mcp_data, "confirm_exempt"),
|
|
702
|
+
allowed_hosts=_name_list(mcp_data, "allowed_hosts"),
|
|
703
|
+
allowed_origins=_name_list(mcp_data, "allowed_origins"),
|
|
704
|
+
upload_dir=_abs_path(mcp_data, "upload_dir"),
|
|
705
|
+
max_upload_mb=_positive_int(
|
|
706
|
+
mcp_data, "max_upload_mb", DEFAULT_MAX_UPLOAD_MB
|
|
707
|
+
),
|
|
708
|
+
cert_file=_abs_path(mcp_data, "cert_file"),
|
|
709
|
+
key_file=_abs_path(mcp_data, "key_file"),
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
database = DatabaseConfig(
|
|
713
|
+
timeout_seconds=float(database_data.get("timeout_seconds", 10.0)),
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
return cls(
|
|
717
|
+
serial=serial,
|
|
718
|
+
ser2net=ser2net,
|
|
719
|
+
proxy=proxy,
|
|
720
|
+
health=health,
|
|
721
|
+
auth=auth,
|
|
722
|
+
web=web,
|
|
723
|
+
kasa=kasa,
|
|
724
|
+
claims=claims,
|
|
725
|
+
mcp=mcp,
|
|
726
|
+
database=database,
|
|
727
|
+
database_path=_expand_path(
|
|
728
|
+
data.get("database_path", str(_default_config_dir() / "labctl.db"))
|
|
729
|
+
),
|
|
730
|
+
log_level=data.get("log_level", "WARNING"),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
def to_dict(self) -> dict[str, Any]:
|
|
734
|
+
"""Convert Config to dictionary."""
|
|
735
|
+
return {
|
|
736
|
+
"serial": {
|
|
737
|
+
"dev_dir": str(self.serial.dev_dir),
|
|
738
|
+
"base_tcp_port": self.serial.base_tcp_port,
|
|
739
|
+
"default_baud": self.serial.default_baud,
|
|
740
|
+
},
|
|
741
|
+
"ser2net": {
|
|
742
|
+
"config_file": str(self.ser2net.config_file),
|
|
743
|
+
"enabled": self.ser2net.enabled,
|
|
744
|
+
},
|
|
745
|
+
"proxy": {
|
|
746
|
+
"port_base": self.proxy.port_base,
|
|
747
|
+
"port_range": self.proxy.port_range,
|
|
748
|
+
"write_policy": self.proxy.write_policy,
|
|
749
|
+
"log_dir": str(self.proxy.log_dir),
|
|
750
|
+
"max_clients": self.proxy.max_clients,
|
|
751
|
+
},
|
|
752
|
+
"health": {
|
|
753
|
+
"check_interval": self.health.check_interval,
|
|
754
|
+
"power_check_interval": self.health.power_check_interval,
|
|
755
|
+
"min_sleep_seconds": self.health.min_sleep_seconds,
|
|
756
|
+
"ping_timeout": self.health.ping_timeout,
|
|
757
|
+
"serial_timeout": self.health.serial_timeout,
|
|
758
|
+
"status_retention_days": self.health.status_retention_days,
|
|
759
|
+
"alert_log_path": str(self.health.alert_log_path),
|
|
760
|
+
"alert_on_offline": self.health.alert_on_offline,
|
|
761
|
+
"alert_on_power_change": self.health.alert_on_power_change,
|
|
762
|
+
},
|
|
763
|
+
"auth": {
|
|
764
|
+
"enabled": self.auth.enabled,
|
|
765
|
+
"users": [
|
|
766
|
+
{
|
|
767
|
+
"username": u.username,
|
|
768
|
+
"password_hash": u.password_hash,
|
|
769
|
+
"api_key": u.api_key,
|
|
770
|
+
"role": u.role,
|
|
771
|
+
}
|
|
772
|
+
for u in self.auth.users
|
|
773
|
+
],
|
|
774
|
+
"secret_key": self.auth.secret_key,
|
|
775
|
+
"session_lifetime_minutes": self.auth.session_lifetime_minutes,
|
|
776
|
+
},
|
|
777
|
+
"web": {
|
|
778
|
+
"cert_file": self.web.cert_file,
|
|
779
|
+
"key_file": self.web.key_file,
|
|
780
|
+
},
|
|
781
|
+
"kasa": {
|
|
782
|
+
"username": self.kasa.username,
|
|
783
|
+
"password": self.kasa.password,
|
|
784
|
+
},
|
|
785
|
+
"claims": {
|
|
786
|
+
"enabled": self.claims.enabled,
|
|
787
|
+
"default_duration_minutes": self.claims.default_duration_minutes,
|
|
788
|
+
"max_duration_minutes": self.claims.max_duration_minutes,
|
|
789
|
+
"min_duration_minutes": self.claims.min_duration_minutes,
|
|
790
|
+
"grace_period_seconds": self.claims.grace_period_seconds,
|
|
791
|
+
"auto_prune_released_after_days": (
|
|
792
|
+
self.claims.auto_prune_released_after_days
|
|
793
|
+
),
|
|
794
|
+
"require_agent_name": self.claims.require_agent_name,
|
|
795
|
+
"require_claim": self.claims.require_claim,
|
|
796
|
+
},
|
|
797
|
+
"mcp": {
|
|
798
|
+
"allow_admin_actuator_ops": self.mcp.allow_admin_actuator_ops,
|
|
799
|
+
"allowed_read_paths": list(self.mcp.allowed_read_paths),
|
|
800
|
+
"allowed_write_paths": list(self.mcp.allowed_write_paths),
|
|
801
|
+
"confirm_destructive": self.mcp.confirm_destructive,
|
|
802
|
+
"confirm_exempt": list(self.mcp.confirm_exempt),
|
|
803
|
+
"allowed_hosts": list(self.mcp.allowed_hosts),
|
|
804
|
+
"allowed_origins": list(self.mcp.allowed_origins),
|
|
805
|
+
"upload_dir": self.mcp.upload_dir,
|
|
806
|
+
"max_upload_mb": self.mcp.max_upload_mb,
|
|
807
|
+
"cert_file": self.mcp.cert_file,
|
|
808
|
+
"key_file": self.mcp.key_file,
|
|
809
|
+
},
|
|
810
|
+
"database": {
|
|
811
|
+
"timeout_seconds": self.database.timeout_seconds,
|
|
812
|
+
},
|
|
813
|
+
"database_path": str(self.database_path),
|
|
814
|
+
"log_level": self.log_level,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# Set by snapshot_config(); process-local on purpose (not inherited by
|
|
819
|
+
# child processes, unlike an environment variable).
|
|
820
|
+
_snapshot: Optional["Config"] = None
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def snapshot_config(config: "Config") -> None:
|
|
824
|
+
"""Make later load_config() calls in this process (without a path
|
|
825
|
+
argument) return ``config``: a long-running server keeps the config it
|
|
826
|
+
started with (database, allowlists, gates, Kasa, health) until it is
|
|
827
|
+
restarted, whatever happens to the files or the environment.
|
|
828
|
+
|
|
829
|
+
load_config(fresh=True) instead re-reads the file ``config`` came from
|
|
830
|
+
(or none, if it started on built-in defaults), for checks that must see
|
|
831
|
+
changes at once and fail closed, i.e. API keys.
|
|
832
|
+
"""
|
|
833
|
+
global _snapshot
|
|
834
|
+
_snapshot = config
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def load_config(
|
|
838
|
+
config_path: Optional[Path] = None,
|
|
839
|
+
create_if_missing: bool = False,
|
|
840
|
+
*,
|
|
841
|
+
fresh: bool = False,
|
|
842
|
+
) -> Config:
|
|
843
|
+
"""
|
|
844
|
+
Load configuration from YAML file.
|
|
845
|
+
|
|
846
|
+
Once a long-running server has called snapshot_config(), calls without
|
|
847
|
+
a ``config_path`` return that snapshot, whatever the environment or the
|
|
848
|
+
files say; ``fresh=True`` instead re-reads only the snapshot's own file
|
|
849
|
+
(or none), without logging (the caller reports problems). Otherwise:
|
|
850
|
+
|
|
851
|
+
Search order:
|
|
852
|
+
1. Explicit path if provided
|
|
853
|
+
2. LABCTL_CONFIG environment variable
|
|
854
|
+
3. ~/.config/labctl/config.yaml
|
|
855
|
+
4. /etc/labctl/config.yaml
|
|
856
|
+
5. Default values
|
|
857
|
+
|
|
858
|
+
An explicit path is the only file tried: if it's missing or fails to
|
|
859
|
+
load, defaults are used, never another config. LABCTL_CONFIG normally
|
|
860
|
+
falls through to 3 and 4; with LABCTL_CONFIG_EXCLUSIVE=1 it behaves
|
|
861
|
+
like an explicit path. `labctl -c FILE mcp` sets both, so every
|
|
862
|
+
load_config() in the server process (including e.g. Kasa credential
|
|
863
|
+
lookups) uses FILE and only FILE.
|
|
864
|
+
|
|
865
|
+
Environment variable overrides:
|
|
866
|
+
- LABCTL_DEV_DIR: Override serial.dev_dir
|
|
867
|
+
- LABCTL_BASE_TCP_PORT: Override serial.base_tcp_port
|
|
868
|
+
- LABCTL_DATABASE_PATH: Override database_path
|
|
869
|
+
- LABCTL_DATABASE_TIMEOUT: Override database.timeout_seconds (float)
|
|
870
|
+
- LABCTL_LOG_LEVEL: Override log_level
|
|
871
|
+
- LABCTL_SYSTEM_CONFIG: Replace /etc/labctl/config.yaml in the search
|
|
872
|
+
order (read when labctl.core.config is imported)
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
config_path: Optional explicit path to config file
|
|
876
|
+
create_if_missing: Create default config if no config found
|
|
877
|
+
fresh: With a snapshot, re-read its file instead (see above)
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
Loaded configuration
|
|
881
|
+
"""
|
|
882
|
+
token = _quiet.set(True) if fresh else None
|
|
883
|
+
try:
|
|
884
|
+
return _load_config(config_path, create_if_missing, fresh)
|
|
885
|
+
finally:
|
|
886
|
+
if token is not None:
|
|
887
|
+
_quiet.reset(token)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _load_config(
|
|
891
|
+
config_path: Optional[Path], create_if_missing: bool, fresh: bool
|
|
892
|
+
) -> Config:
|
|
893
|
+
# Determine config file path
|
|
894
|
+
named_path = None # a file named explicitly: missing is an error
|
|
895
|
+
if config_path:
|
|
896
|
+
paths_to_try = [config_path]
|
|
897
|
+
only_file = True
|
|
898
|
+
named_path = config_path
|
|
899
|
+
elif _snapshot is not None:
|
|
900
|
+
if not fresh:
|
|
901
|
+
return _snapshot
|
|
902
|
+
# Fresh: only the snapshot's own file (or none), never a fallback.
|
|
903
|
+
paths_to_try = [] if _snapshot.source_path is None else [_snapshot.source_path]
|
|
904
|
+
only_file = _snapshot.source_path is not None
|
|
905
|
+
named_path = _snapshot.source_path
|
|
906
|
+
else:
|
|
907
|
+
env_path = os.environ.get("LABCTL_CONFIG")
|
|
908
|
+
paths_to_try = []
|
|
909
|
+
if env_path:
|
|
910
|
+
named_path = _expand_path(env_path)
|
|
911
|
+
paths_to_try.append(named_path)
|
|
912
|
+
only_file = bool(env_path) and os.environ.get("LABCTL_CONFIG_EXCLUSIVE") == "1"
|
|
913
|
+
if not only_file:
|
|
914
|
+
paths_to_try.extend([_default_config_file(), SYSTEM_CONFIG_FILE])
|
|
915
|
+
|
|
916
|
+
# Try to load from file
|
|
917
|
+
config = None
|
|
918
|
+
loaded_from = None
|
|
919
|
+
load_errors = []
|
|
920
|
+
load_denied = False
|
|
921
|
+
for path in paths_to_try:
|
|
922
|
+
try:
|
|
923
|
+
os.stat(path)
|
|
924
|
+
except OSError as e:
|
|
925
|
+
if e.errno not in (errno.ENOENT, errno.ENOTDIR, errno.ELOOP):
|
|
926
|
+
# e.g. EACCES on a 750 /etc/labctl: a config may well be
|
|
927
|
+
# there. (os.stat, not Path.exists(), which hides this.)
|
|
928
|
+
# A file named explicitly (-c, exclusive LABCTL_CONFIG)
|
|
929
|
+
# warns; search-path entries log at debug only, since users
|
|
930
|
+
# outside the labctl group hit /etc/labctl on every command
|
|
931
|
+
# (MCP startup and --check report load_errors either way).
|
|
932
|
+
logger.log(
|
|
933
|
+
logging.WARNING if path == named_path else logging.DEBUG,
|
|
934
|
+
"Failed to access config path %s: %s",
|
|
935
|
+
path,
|
|
936
|
+
e,
|
|
937
|
+
)
|
|
938
|
+
load_errors.append(f"{path}: {e}")
|
|
939
|
+
load_denied |= e.errno in (errno.EACCES, errno.EPERM)
|
|
940
|
+
continue
|
|
941
|
+
# A file named explicitly (-c, LABCTL_CONFIG) being absent is
|
|
942
|
+
# an error even if the search goes on: warn and record it (the
|
|
943
|
+
# HTTP server refuses to start, --check fails).
|
|
944
|
+
if path == named_path:
|
|
945
|
+
logger.warning("Config file %s not found", path)
|
|
946
|
+
load_errors.append(f"{path}: {e.strerror}")
|
|
947
|
+
continue
|
|
948
|
+
else:
|
|
949
|
+
try:
|
|
950
|
+
with open(path) as f:
|
|
951
|
+
data = yaml.safe_load(f) or {}
|
|
952
|
+
except Exception as e:
|
|
953
|
+
logger.warning("Failed to load config from %s: %s", path, e)
|
|
954
|
+
load_errors.append(f"{path}: {e}")
|
|
955
|
+
load_denied |= isinstance(e, PermissionError)
|
|
956
|
+
continue
|
|
957
|
+
# Valid YAML can still hold invalid values (`timeout_seconds:
|
|
958
|
+
# 10s`, a list at the top): treated like a parse error, i.e.
|
|
959
|
+
# on to the next file, not a crash.
|
|
960
|
+
try:
|
|
961
|
+
config = Config.from_dict(data)
|
|
962
|
+
except Exception as e:
|
|
963
|
+
logger.warning("Invalid config in %s: %s", path, e)
|
|
964
|
+
load_errors.append(f"{path}: invalid value: {e}")
|
|
965
|
+
continue
|
|
966
|
+
loaded_from = path
|
|
967
|
+
break
|
|
968
|
+
|
|
969
|
+
if config is None:
|
|
970
|
+
config = Config.from_dict({})
|
|
971
|
+
config.source_path = loaded_from
|
|
972
|
+
config.load_errors = load_errors
|
|
973
|
+
config.load_denied = load_denied
|
|
974
|
+
|
|
975
|
+
# Apply environment variable overrides
|
|
976
|
+
config = _apply_env_overrides(config)
|
|
977
|
+
|
|
978
|
+
# Create default config file if requested and none exists
|
|
979
|
+
if create_if_missing and not any(_path_exists(p) for p in paths_to_try):
|
|
980
|
+
save_config(config, _default_config_file())
|
|
981
|
+
|
|
982
|
+
return config
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _env_int(text: str) -> int | str:
|
|
986
|
+
"""An environment value as a whole number, or the text unchanged (so
|
|
987
|
+
the caller's check rejects it with the value in the warning)."""
|
|
988
|
+
try:
|
|
989
|
+
return int(text)
|
|
990
|
+
except ValueError:
|
|
991
|
+
return text
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _apply_env_overrides(config: Config) -> Config:
|
|
995
|
+
"""Apply environment variable overrides to config."""
|
|
996
|
+
if "LABCTL_DEV_DIR" in os.environ:
|
|
997
|
+
config.serial.dev_dir = _expand_path(os.environ["LABCTL_DEV_DIR"])
|
|
998
|
+
|
|
999
|
+
if "LABCTL_BASE_TCP_PORT" in os.environ:
|
|
1000
|
+
config.serial.base_tcp_port = _tcp_port(
|
|
1001
|
+
{"LABCTL_BASE_TCP_PORT": _env_int(os.environ["LABCTL_BASE_TCP_PORT"])},
|
|
1002
|
+
"LABCTL_BASE_TCP_PORT",
|
|
1003
|
+
config.serial.base_tcp_port,
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
if "LABCTL_DATABASE_PATH" in os.environ:
|
|
1007
|
+
config.database_path = _expand_path(os.environ["LABCTL_DATABASE_PATH"])
|
|
1008
|
+
|
|
1009
|
+
if "LABCTL_DATABASE_TIMEOUT" in os.environ:
|
|
1010
|
+
try:
|
|
1011
|
+
config.database.timeout_seconds = float(
|
|
1012
|
+
os.environ["LABCTL_DATABASE_TIMEOUT"]
|
|
1013
|
+
)
|
|
1014
|
+
except ValueError:
|
|
1015
|
+
pass
|
|
1016
|
+
|
|
1017
|
+
if "LABCTL_LOG_LEVEL" in os.environ:
|
|
1018
|
+
config.log_level = os.environ["LABCTL_LOG_LEVEL"]
|
|
1019
|
+
|
|
1020
|
+
# Proxy overrides
|
|
1021
|
+
if "LABCTL_PROXY_ENABLED" in os.environ:
|
|
1022
|
+
_warn_once("LABCTL_PROXY_ENABLED is no longer supported and is ignored")
|
|
1023
|
+
|
|
1024
|
+
if "LABCTL_PROXY_PORT_BASE" in os.environ:
|
|
1025
|
+
try:
|
|
1026
|
+
config.proxy.port_base = int(os.environ["LABCTL_PROXY_PORT_BASE"])
|
|
1027
|
+
except ValueError:
|
|
1028
|
+
pass
|
|
1029
|
+
|
|
1030
|
+
if "LABCTL_PROXY_WRITE_POLICY" in os.environ:
|
|
1031
|
+
policy = os.environ["LABCTL_PROXY_WRITE_POLICY"]
|
|
1032
|
+
if policy in ("first", "all", "queue"):
|
|
1033
|
+
config.proxy.write_policy = policy
|
|
1034
|
+
|
|
1035
|
+
if "LABCTL_PROXY_LOG_DIR" in os.environ:
|
|
1036
|
+
config.proxy.log_dir = _expand_path(os.environ["LABCTL_PROXY_LOG_DIR"])
|
|
1037
|
+
|
|
1038
|
+
return config
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def save_config(config: Config, path: Path) -> None:
|
|
1042
|
+
"""
|
|
1043
|
+
Save configuration to YAML file.
|
|
1044
|
+
|
|
1045
|
+
Args:
|
|
1046
|
+
config: Configuration to save
|
|
1047
|
+
path: Path to save to
|
|
1048
|
+
"""
|
|
1049
|
+
# Ensure directory exists
|
|
1050
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1051
|
+
|
|
1052
|
+
# Write config with header comment
|
|
1053
|
+
with open(path, "w") as f:
|
|
1054
|
+
f.write("# Lab Controller Configuration\n")
|
|
1055
|
+
f.write("# See documentation for all options\n\n")
|
|
1056
|
+
yaml.dump(config.to_dict(), f, default_flow_style=False, sort_keys=False)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def get_default_config() -> Config:
|
|
1060
|
+
"""Get default configuration without loading from file."""
|
|
1061
|
+
return Config()
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def ensure_config_dir() -> Path:
|
|
1065
|
+
"""Ensure config directory exists and return its path."""
|
|
1066
|
+
config_dir = _default_config_dir()
|
|
1067
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
1068
|
+
return config_dir
|