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.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
labctl/core/images.py ADDED
@@ -0,0 +1,503 @@
1
+ """
2
+ Images uploaded over MCP HTTP (D014).
3
+
4
+ An MCP tool's arguments are text the model writes out, so a disk image
5
+ can't travel in one. Agents upload with their own shell instead
6
+ (``PUT /images/<name>`` on the MCP HTTP server, the same API key), into
7
+ ``mcp.upload_dir``; tools then take the bare name. This module is the
8
+ store behind that route and the ``list_images`` / ``delete_image`` tools.
9
+
10
+ - Names are bare file names (no paths, no leading dot), so nothing can be
11
+ written outside the directory, and upload temp files (``.upload-*``)
12
+ never collide with an image.
13
+ - An upload is written to a temp file and renamed into place only when
14
+ complete (and its SHA-256 matches, if the uploader gave one): a broken
15
+ upload never appears as an image, and a tool reading the old file by
16
+ descriptor keeps reading the old file.
17
+ - The ``images`` table records who uploaded each image, and which file
18
+ (device, inode, mtime, size) the upload produced. Replacing or deleting
19
+ an image is allowed to its uploader and to operators. A file put in the
20
+ directory another way, or changed since, matches no record: no
21
+ uploader, so only operators may replace or delete it.
22
+ - Free space is checked against what uploads in progress will still
23
+ write, so concurrent uploads can't jointly eat the reserve.
24
+ - The directory is opened without following symlinks, and files in it are
25
+ created and renamed relative to that descriptor.
26
+ """
27
+
28
+ import errno
29
+ import hashlib
30
+ import os
31
+ import re
32
+ import secrets
33
+ import stat
34
+ import threading
35
+ import time
36
+ from contextlib import contextmanager
37
+ from dataclasses import dataclass
38
+ from typing import Optional
39
+
40
+ from labctl.core.safe_open import open_dir_nofollow
41
+
42
+ # A name a shell, URL and SD-card file system all take without quoting.
43
+ _NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}")
44
+ TEMP_PREFIX = ".upload-"
45
+ # Space left free on the upload file system after an upload.
46
+ FREE_SPACE_RESERVE_BYTES = 1 << 30
47
+ # Temp files of uploads that died (server killed mid-upload) are removed
48
+ # by the next upload once they are this old.
49
+ STALE_TEMP_SECONDS = 24 * 3600
50
+ _CHUNK = 1 << 20
51
+
52
+ # Serializes the replace/delete decision with the rename or unlink it
53
+ # allows, and the record that follows, within this process.
54
+ _commit_lock = threading.Lock()
55
+ # Uploads in progress in this process, for the free-space check.
56
+ _active: set = set()
57
+ _space_lock = threading.Lock()
58
+
59
+
60
+ class ImageError(Exception):
61
+ """A refused image operation: ``code`` and HTTP ``status`` for the
62
+ route, ``message`` for people."""
63
+
64
+ def __init__(self, code: str, status: int, message: str):
65
+ super().__init__(message)
66
+ self.code = code
67
+ self.status = status
68
+ self.message = message
69
+
70
+ def to_dict(self) -> dict:
71
+ return {"error": self.code, "message": self.message}
72
+
73
+
74
+ def name_error(name) -> Optional[str]:
75
+ """Why ``name`` can't be an image name, or None."""
76
+ if not isinstance(name, str) or not _NAME_RE.fullmatch(name):
77
+ return (
78
+ f"invalid image name {name!r}: use 1-128 letters, digits, '.', "
79
+ "'_', '+' or '-', starting with a letter or digit (no paths)"
80
+ )
81
+ return None
82
+
83
+
84
+ @dataclass
85
+ class Caller:
86
+ """Who is acting: ``user`` is None without auth (one shared identity,
87
+ an operator)."""
88
+
89
+ user: Optional[str]
90
+ operator: bool
91
+
92
+
93
+ def file_id(st: os.stat_result) -> str:
94
+ """Identifies one file's contents as uploaded: a rename keeps it, a
95
+ copy over it, or a new file in its place, doesn't."""
96
+ return f"{st.st_dev}:{st.st_ino}:{st.st_mtime_ns}:{st.st_size}"
97
+
98
+
99
+ class ImageStore:
100
+ def __init__(self, directory: str, db):
101
+ self.directory = directory
102
+ self.db = db
103
+
104
+ # -- helpers ---------------------------------------------------------
105
+
106
+ def _open_dir(self) -> int:
107
+ # Resolved first, as tools resolve host paths (a symlinked parent
108
+ # such as /srv -> /data/srv is fine); nothing below it is followed.
109
+ try:
110
+ return open_dir_nofollow(os.path.realpath(self.directory), create=False)
111
+ except OSError as e:
112
+ raise ImageError(
113
+ "upload_dir_unavailable",
114
+ 500,
115
+ f"upload directory {self.directory} can't be opened: "
116
+ f"{e.strerror or e}",
117
+ ) from e
118
+
119
+ def _record(self, name: str, st: os.stat_result):
120
+ """The upload record describing the file ``st``, or None."""
121
+ record = self.db.execute_one(
122
+ "SELECT name, uploaded_by, size, sha256, file_id FROM images"
123
+ " WHERE name = ?",
124
+ (name,),
125
+ )
126
+ if record is None or record["file_id"] != file_id(st):
127
+ return None
128
+ return record
129
+
130
+ def _existing(self, dir_fd: int, name: str) -> Optional[os.stat_result]:
131
+ try:
132
+ st = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
133
+ except FileNotFoundError:
134
+ return None
135
+ return st
136
+
137
+ def _check_may_replace(
138
+ self, name: str, st: os.stat_result, caller: Caller, verb: str
139
+ ) -> None:
140
+ """Refuse ``verb`` ("replace"/"delete") on the existing image ``st``
141
+ the caller didn't upload, unless they are an operator."""
142
+ if caller.operator:
143
+ return
144
+ record = self._record(name, st)
145
+ owner = record["uploaded_by"] if record else None
146
+ if owner is None or owner != caller.user:
147
+ by = (
148
+ f"uploaded by '{owner}'"
149
+ if owner
150
+ else "not uploaded over MCP (or changed since)"
151
+ )
152
+ raise ImageError(
153
+ "not_owner",
154
+ 403,
155
+ f"image '{name}' was {by}; only its uploader or an operator "
156
+ f"may {verb} it",
157
+ )
158
+
159
+ # -- listing ---------------------------------------------------------
160
+
161
+ def list(self) -> list[dict]:
162
+ """The images in the directory (regular files, no dot files), with
163
+ who uploaded them where known."""
164
+ dir_fd = self._open_dir()
165
+ try:
166
+ return self._list(dir_fd)
167
+ except OSError as e:
168
+ raise ImageError(
169
+ "upload_dir_unavailable",
170
+ 500,
171
+ f"upload directory {self.directory} can't be listed: "
172
+ f"{e.strerror or e}",
173
+ ) from e
174
+ finally:
175
+ os.close(dir_fd)
176
+
177
+ def _list(self, dir_fd: int) -> "list[dict]":
178
+ names = sorted(os.listdir(dir_fd))
179
+ records = {
180
+ r["name"]: r
181
+ for r in self.db.execute(
182
+ "SELECT name, uploaded_by, sha256, file_id FROM images"
183
+ )
184
+ }
185
+ images = []
186
+ for name in names:
187
+ if name.startswith("."):
188
+ continue
189
+ st = self._existing(dir_fd, name)
190
+ if st is None or not stat.S_ISREG(st.st_mode):
191
+ continue
192
+ record = records.get(name)
193
+ # A record describes this file only while it is the file
194
+ # uploaded; one replaced or changed another way has no
195
+ # uploader or checksum.
196
+ if record is not None and record["file_id"] != file_id(st):
197
+ record = None
198
+ images.append(
199
+ {
200
+ "name": name,
201
+ "path": os.path.join(self.directory, name),
202
+ "size": st.st_size,
203
+ "modified": time.strftime(
204
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime)
205
+ ),
206
+ "uploaded_by": record["uploaded_by"] if record else None,
207
+ "sha256": record["sha256"] if record else None,
208
+ }
209
+ )
210
+ return images
211
+
212
+ # -- upload ----------------------------------------------------------
213
+
214
+ def begin_upload(
215
+ self,
216
+ name: str,
217
+ size: int,
218
+ caller: Caller,
219
+ *,
220
+ overwrite: bool = False,
221
+ max_bytes: int,
222
+ expected_sha256: Optional[str] = None,
223
+ ) -> "Upload":
224
+ """Check an upload of ``size`` bytes may go ahead, and open its temp
225
+ file. Raises ImageError."""
226
+ err = name_error(name)
227
+ if err:
228
+ raise ImageError("invalid_name", 400, err)
229
+ if expected_sha256 is not None:
230
+ expected_sha256 = expected_sha256.strip().lower()
231
+ if not re.fullmatch(r"[0-9a-f]{64}", expected_sha256):
232
+ raise ImageError(
233
+ "invalid_sha256", 400, "sha256 must be 64 hexadecimal digits"
234
+ )
235
+ if size < 0:
236
+ raise ImageError("invalid_length", 400, "Content-Length is negative")
237
+ if size > max_bytes:
238
+ raise ImageError(
239
+ "too_large",
240
+ 413,
241
+ f"{size} bytes is over the upload limit of {max_bytes} bytes "
242
+ "(mcp.max_upload_mb)",
243
+ )
244
+ dir_fd = self._open_dir()
245
+ try:
246
+ existing = self._existing(dir_fd, name)
247
+ if existing is not None:
248
+ if not stat.S_ISREG(existing.st_mode):
249
+ raise ImageError(
250
+ "not_a_file", 409, f"'{name}' exists and is not a file"
251
+ )
252
+ if not overwrite:
253
+ raise ImageError(
254
+ "exists",
255
+ 409,
256
+ f"image '{name}' already exists; repeat with "
257
+ "overwrite=1 to replace it",
258
+ )
259
+ self._check_may_replace(name, existing, caller, "replace")
260
+ _remove_stale_temps(dir_fd)
261
+ with _space_lock:
262
+ fs = os.statvfs(dir_fd)
263
+ free = fs.f_bavail * fs.f_frsize
264
+ pending = sum(u.size - u.received for u in _active)
265
+ if size + pending + FREE_SPACE_RESERVE_BYTES > free:
266
+ raise ImageError(
267
+ "insufficient_space",
268
+ 507,
269
+ f"{size} bytes would leave less than "
270
+ f"{FREE_SPACE_RESERVE_BYTES >> 20} MiB free on the "
271
+ f"upload file system ({free} bytes free, {pending} "
272
+ "still to come from uploads in progress)",
273
+ )
274
+ temp = f"{TEMP_PREFIX}{secrets.token_hex(8)}"
275
+ fd = os.open(
276
+ temp,
277
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
278
+ # Only this process writes it: the bytes renamed into
279
+ # place are the ones received and hashed.
280
+ 0o600,
281
+ dir_fd=dir_fd,
282
+ )
283
+ upload = Upload(
284
+ self,
285
+ dir_fd,
286
+ fd,
287
+ temp,
288
+ name,
289
+ size,
290
+ caller,
291
+ overwrite,
292
+ expected_sha256,
293
+ )
294
+ _active.add(upload)
295
+ except BaseException:
296
+ os.close(dir_fd)
297
+ raise
298
+ return upload
299
+
300
+ # -- delete ----------------------------------------------------------
301
+
302
+ def delete(self, name: str, caller: Caller) -> None:
303
+ err = name_error(name)
304
+ if err:
305
+ raise ImageError("invalid_name", 400, err)
306
+ dir_fd = self._open_dir()
307
+ try:
308
+ with _commit_lock:
309
+ existing = self._existing(dir_fd, name)
310
+ if existing is None:
311
+ raise ImageError("not_found", 404, f"no image named '{name}'")
312
+ if not stat.S_ISREG(existing.st_mode):
313
+ raise ImageError(
314
+ "not_a_file", 409, f"'{name}' exists and is not a file"
315
+ )
316
+ self._check_may_replace(name, existing, caller, "delete")
317
+ # One step, as for uploads: a failed unlink rolls the
318
+ # record back.
319
+ with self.db.connect() as conn:
320
+ conn.execute("DELETE FROM images WHERE name = ?", (name,))
321
+ try:
322
+ os.unlink(name, dir_fd=dir_fd)
323
+ except OSError as e:
324
+ raise ImageError(
325
+ "delete_failed",
326
+ 500,
327
+ f"could not delete image '{name}': {e.strerror or e}",
328
+ ) from e
329
+ finally:
330
+ os.close(dir_fd)
331
+
332
+
333
+ class Upload:
334
+ """One upload in progress: write() the body, then commit() or abort()."""
335
+
336
+ def __init__(
337
+ self,
338
+ store: ImageStore,
339
+ dir_fd: int,
340
+ fd: int,
341
+ temp: str,
342
+ name: str,
343
+ size: int,
344
+ caller: Caller,
345
+ overwrite: bool,
346
+ expected_sha256: Optional[str],
347
+ ):
348
+ self.store = store
349
+ self.dir_fd = dir_fd
350
+ self.fd = fd
351
+ self.temp = temp
352
+ self.name = name
353
+ self.size = size
354
+ self.caller = caller
355
+ self.overwrite = overwrite
356
+ self.expected_sha256 = expected_sha256
357
+ self.received = 0
358
+ self._hash = hashlib.sha256()
359
+ self._done = False
360
+
361
+ def write(self, chunk: bytes) -> None:
362
+ self.received += len(chunk)
363
+ if self.received > self.size:
364
+ raise ImageError(
365
+ "length_mismatch", 400, "body is longer than Content-Length"
366
+ )
367
+ self._hash.update(chunk)
368
+ view = memoryview(chunk)
369
+ while view:
370
+ with _full_disk_is_507():
371
+ written = os.write(self.fd, view[:_CHUNK])
372
+ view = view[written:]
373
+
374
+ def commit(self) -> dict:
375
+ """Move the finished upload into place and record it."""
376
+ try:
377
+ if self.received != self.size:
378
+ raise ImageError(
379
+ "length_mismatch",
380
+ 400,
381
+ f"received {self.received} of {self.size} bytes",
382
+ )
383
+ digest = self._hash.hexdigest()
384
+ if self.expected_sha256 and digest != self.expected_sha256:
385
+ raise ImageError(
386
+ "sha256_mismatch",
387
+ 422,
388
+ f"uploaded data has SHA-256 {digest}, not "
389
+ f"{self.expected_sha256}; nothing was stored",
390
+ )
391
+ # Readable by all, writable by the service only (not its group).
392
+ os.fchmod(self.fd, 0o644)
393
+ with _full_disk_is_507(): # delayed allocation, quotas, NFS
394
+ os.fsync(self.fd)
395
+ uploaded_id = file_id(os.fstat(self.fd)) # a rename keeps it
396
+ os.close(self.fd)
397
+ self.fd = -1
398
+ store, name = self.store, self.name
399
+ with _commit_lock:
400
+ # Checked again: the image may have appeared, or changed
401
+ # hands, while the body was arriving.
402
+ existing = store._existing(self.dir_fd, name)
403
+ if existing is not None:
404
+ if not stat.S_ISREG(existing.st_mode):
405
+ raise ImageError(
406
+ "not_a_file", 409, f"'{name}' exists and is not a file"
407
+ )
408
+ if not self.overwrite:
409
+ raise ImageError(
410
+ "exists",
411
+ 409,
412
+ f"image '{name}' was created while uploading; "
413
+ "repeat with overwrite=1 to replace it",
414
+ )
415
+ store._check_may_replace(name, existing, self.caller, "replace")
416
+ # Record first, rename inside the same transaction: if the
417
+ # record can't be written, nothing is renamed; if the
418
+ # rename fails, the record rolls back. (Should the commit
419
+ # itself fail after the rename, the old row's file_id no
420
+ # longer matches: the image is operator-only, never
421
+ # someone else's.)
422
+ with store.db.connect() as conn:
423
+ conn.execute(
424
+ "INSERT INTO images (name, uploaded_by, size, sha256,"
425
+ " file_id, uploaded_at)"
426
+ " VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"
427
+ " ON CONFLICT(name) DO UPDATE SET"
428
+ " uploaded_by = excluded.uploaded_by,"
429
+ " size = excluded.size, sha256 = excluded.sha256,"
430
+ " file_id = excluded.file_id,"
431
+ " uploaded_at = excluded.uploaded_at",
432
+ (name, self.caller.user, self.size, digest, uploaded_id),
433
+ )
434
+ os.rename(
435
+ self.temp,
436
+ name,
437
+ src_dir_fd=self.dir_fd,
438
+ dst_dir_fd=self.dir_fd,
439
+ )
440
+ self._done = True
441
+ try:
442
+ os.fsync(self.dir_fd)
443
+ except OSError:
444
+ pass # best effort: the rename is already visible
445
+ return {
446
+ "status": "uploaded",
447
+ "name": name,
448
+ "path": os.path.join(store.directory, name),
449
+ "size": self.size,
450
+ "sha256": digest,
451
+ "replaced": existing is not None,
452
+ "uploaded_by": self.caller.user,
453
+ }
454
+ finally:
455
+ self.abort()
456
+
457
+ def abort(self) -> None:
458
+ """Drop the temp file (no-op after a successful commit)."""
459
+ with _space_lock:
460
+ _active.discard(self)
461
+ if self.fd >= 0:
462
+ os.close(self.fd)
463
+ self.fd = -1
464
+ if not self._done:
465
+ self._done = True
466
+ try:
467
+ os.unlink(self.temp, dir_fd=self.dir_fd)
468
+ except OSError:
469
+ pass
470
+ if self.dir_fd >= 0:
471
+ os.close(self.dir_fd)
472
+ self.dir_fd = -1
473
+
474
+
475
+ @contextmanager
476
+ def _full_disk_is_507():
477
+ try:
478
+ yield
479
+ except OSError as e:
480
+ if e.errno in (errno.ENOSPC, errno.EDQUOT):
481
+ raise ImageError(
482
+ "insufficient_space",
483
+ 507,
484
+ "the upload file system filled up during the upload; "
485
+ "nothing was stored",
486
+ ) from e
487
+ raise
488
+
489
+
490
+ def _remove_stale_temps(dir_fd: int) -> None:
491
+ cutoff = time.time() - STALE_TEMP_SECONDS
492
+ with _space_lock:
493
+ # A stalled upload of this process is still alive, however old.
494
+ live = {u.temp for u in _active}
495
+ for entry in os.listdir(dir_fd):
496
+ if not entry.startswith(TEMP_PREFIX) or entry in live:
497
+ continue
498
+ try:
499
+ st = os.stat(entry, dir_fd=dir_fd, follow_symlinks=False)
500
+ if st.st_mtime < cutoff:
501
+ os.unlink(entry, dir_fd=dir_fd)
502
+ except OSError:
503
+ pass # gone already, or not ours to remove