labpulse 0.3.6__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.
- labpulse/README.md +35 -0
- labpulse/__init__.py +9 -0
- labpulse/backup.py +524 -0
- labpulse/common/README.md +32 -0
- labpulse/common/__init__.py +1 -0
- labpulse/common/config.py +620 -0
- labpulse/common/fake_config.py +11 -0
- labpulse/common/generated_files.py +27 -0
- labpulse/common/identity.py +32 -0
- labpulse/common/logging_config.py +58 -0
- labpulse/common/measurement_config.py +330 -0
- labpulse/common/mqtt_contracts.py +89 -0
- labpulse/common/output_config.py +52 -0
- labpulse/common/service_config.py +199 -0
- labpulse/common/sms_templates.py +76 -0
- labpulse/common/sms_templates.yaml +73 -0
- labpulse/control.py +1007 -0
- labpulse/deployment/README.md +24 -0
- labpulse/deployment/__init__.py +1 -0
- labpulse/deployment/__main__.py +7 -0
- labpulse/deployment/compose.py +197 -0
- labpulse/deployment/generate.py +225 -0
- labpulse/deployment/mosquitto.py +60 -0
- labpulse/doctor.py +635 -0
- labpulse/hardware/README.md +31 -0
- labpulse/hardware/__init__.py +1 -0
- labpulse/hardware/__main__.py +93 -0
- labpulse/hardware/_simulation.py +140 -0
- labpulse/hardware/driver.py +166 -0
- labpulse/hardware/drivers/README.md +38 -0
- labpulse/hardware/drivers/__init__.py +1 -0
- labpulse/hardware/drivers/_gpio.py +44 -0
- labpulse/hardware/drivers/dht11.py +118 -0
- labpulse/hardware/drivers/gpio_input.py +118 -0
- labpulse/hardware/drivers/gpio_output.py +163 -0
- labpulse/hardware/drivers/mqtt_json.py +404 -0
- labpulse/hardware/drivers/serial_pipe.py +145 -0
- labpulse/hardware/drivers/sht40.py +170 -0
- labpulse/hardware/drivers/x1200.py +180 -0
- labpulse/hardware/homeassistant_publisher.py +224 -0
- labpulse/hardware/registry.py +40 -0
- labpulse/hardware/runner.py +338 -0
- labpulse/homeassistant/README.md +30 -0
- labpulse/homeassistant/__init__.py +1 -0
- labpulse/homeassistant/__main__.py +7 -0
- labpulse/homeassistant/alarm.py +653 -0
- labpulse/homeassistant/generator.py +143 -0
- labpulse/homeassistant/templates/README.md +32 -0
- labpulse/homeassistant/templates/alarm/alarm_package.yaml.j2 +15 -0
- labpulse/homeassistant/templates/alarm/automations/installation.yaml.j2 +31 -0
- labpulse/homeassistant/templates/alarm/automations/measurement.yaml.j2 +4 -0
- labpulse/homeassistant/templates/alarm/automations/measurement_missing_reading.yaml.j2 +148 -0
- labpulse/homeassistant/templates/alarm/automations/measurement_state.yaml.j2 +167 -0
- labpulse/homeassistant/templates/alarm/automations/power.yaml.j2 +6 -0
- labpulse/homeassistant/templates/alarm/automations/power_state.yaml.j2 +142 -0
- labpulse/homeassistant/templates/alarm/automations/service_health.yaml.j2 +134 -0
- labpulse/homeassistant/templates/alarm/derived_entities.yaml.j2 +191 -0
- labpulse/homeassistant/templates/alarm/helpers.yaml.j2 +236 -0
- labpulse/homeassistant/templates/alarm/scripts.yaml.j2 +263 -0
- labpulse/homeassistant/templates/configuration.yaml.j2 +54 -0
- labpulse/homeassistant/templates/dashboard/alarm_setup/bulk_editor.yaml.j2 +204 -0
- labpulse/homeassistant/templates/dashboard/alarm_setup/notification_controls.yaml.j2 +30 -0
- labpulse/homeassistant/templates/dashboard/alarm_setup/targets.yaml.j2 +102 -0
- labpulse/homeassistant/templates/dashboard/alarm_setup.yaml.j2 +11 -0
- labpulse/homeassistant/templates/dashboard/custom_dashboards.yaml.j2 +18 -0
- labpulse/homeassistant/templates/dashboard/monitor.yaml.j2 +83 -0
- labpulse/homeassistant/templates/dashboard/monitor_status.yaml.j2 +135 -0
- labpulse/homeassistant/templates/dashboard/power_subviews.yaml.j2 +40 -0
- labpulse/homeassistant/templates/dashboard/setup_measurements.yaml.j2 +38 -0
- labpulse/homeassistant/templates/dashboard/setup_subviews/measurement_cards.yaml.j2 +329 -0
- labpulse/homeassistant/templates/dashboard/setup_subviews/setup_header.yaml.j2 +73 -0
- labpulse/homeassistant/templates/dashboard/setup_subviews.yaml.j2 +14 -0
- labpulse/homeassistant/templates/dashboard/system_status.yaml.j2 +151 -0
- labpulse/homeassistant/templates/dashboard.yaml.j2 +12 -0
- labpulse/installer.py +71 -0
- labpulse/output/README.md +24 -0
- labpulse/output/__init__.py +1 -0
- labpulse/output/__main__.py +80 -0
- labpulse/output/service.py +323 -0
- labpulse/sms/README.md +28 -0
- labpulse/sms/__init__.py +1 -0
- labpulse/sms/__main__.py +76 -0
- labpulse/sms/sender.py +545 -0
- labpulse/sms/subscriber.py +295 -0
- labpulse-0.3.6.data/data/share/labpulse/config.d/triton-01-measurements.yaml +14 -0
- labpulse-0.3.6.data/data/share/labpulse/config.d/triton-02-measurements.yaml +14 -0
- labpulse-0.3.6.data/data/share/labpulse/config.yaml +354 -0
- labpulse-0.3.6.data/data/share/labpulse/deployment/edit_config.sh +277 -0
- labpulse-0.3.6.data/data/share/labpulse/deployment/generate_compose.sh +75 -0
- labpulse-0.3.6.data/data/share/labpulse/deployment/generate_homeassistant_config.sh +106 -0
- labpulse-0.3.6.data/data/share/labpulse/deployment/setup_container_fs.sh +345 -0
- labpulse-0.3.6.data/data/share/labpulse/requirements-host.txt +6 -0
- labpulse-0.3.6.data/data/share/labpulse/setup_usb_devices.py +315 -0
- labpulse-0.3.6.data/data/share/labpulse/testing/real_hardware/hardware_fault_common.sh +121 -0
- labpulse-0.3.6.data/data/share/labpulse/testing/real_hardware/test_dht11_fault.sh +143 -0
- labpulse-0.3.6.data/data/share/labpulse/testing/real_hardware/test_x1200_faults.sh +161 -0
- labpulse-0.3.6.dist-info/METADATA +228 -0
- labpulse-0.3.6.dist-info/RECORD +102 -0
- labpulse-0.3.6.dist-info/WHEEL +5 -0
- labpulse-0.3.6.dist-info/entry_points.txt +10 -0
- labpulse-0.3.6.dist-info/licenses/LICENSE +21 -0
- labpulse-0.3.6.dist-info/top_level.txt +1 -0
labpulse/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# LabPulse Python package
|
|
2
|
+
|
|
3
|
+
This directory is the maintained Python implementation. The installed package
|
|
4
|
+
supplies commands that run on the Raspberry Pi host and processes that run in
|
|
5
|
+
Docker containers.
|
|
6
|
+
|
|
7
|
+
| Path | Responsibility | Runs in |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `control.py` | Public `labpulse` command: setup, lifecycle, configuration, backup, restore and diagnostics | Pi host |
|
|
10
|
+
| `installer.py` | Locate packaged assets and launch the Linux bootstrap | Pi host |
|
|
11
|
+
| `backup.py` | Create, validate and restore checksummed state archives | Pi host |
|
|
12
|
+
| `doctor.py` | Read-only installation, hardware, Docker and endpoint checks | Pi host |
|
|
13
|
+
| `common/` | Configuration, identity, MQTT, logging and file contracts | Host and containers |
|
|
14
|
+
| `deployment/` | Generate Compose and staged Home Assistant output | Pi host |
|
|
15
|
+
| `hardware/` | Acquire and publish one sensor service | One container per service |
|
|
16
|
+
| `homeassistant/` | Generate dashboards, helpers and alarms | Pi host during generation |
|
|
17
|
+
| `output/` | Apply MQTT commands to one configured output | One container per output |
|
|
18
|
+
| `sms/` | Validate requests and deliver or dry-run SMS messages | SMS container |
|
|
19
|
+
|
|
20
|
+
`__init__.py` exposes the installed version. Executable subpackages use
|
|
21
|
+
`__main__.py` so Compose can run them with `python -m`.
|
|
22
|
+
|
|
23
|
+
Each process loads the validated configuration independently and coordinates
|
|
24
|
+
through MQTT rather than shared Python memory. The operator-owned source is
|
|
25
|
+
`~/labpulse-live/config.yaml`; the repository `config.yaml` is only an
|
|
26
|
+
installation template.
|
|
27
|
+
|
|
28
|
+
Configuration is validated at process boundaries. Drivers normalize hardware,
|
|
29
|
+
runners own retry and freshness, Home Assistant owns alarm decisions, and SMS
|
|
30
|
+
and output workers own their side effects. Do not recreate YAML, identity or
|
|
31
|
+
topic rules inside individual services.
|
|
32
|
+
|
|
33
|
+
Tests are mapped in [`testing/`](../../testing/README.md). Read the
|
|
34
|
+
[User Guide](../../docs/USER_GUIDE.md), [Architecture](../../docs/ARCHITECTURE.md)
|
|
35
|
+
and [Development guide](../../docs/DEVELOPMENT.md) for cross-package behaviour.
|
labpulse/__init__.py
ADDED
labpulse/backup.py
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
"""Create and restore guarded LabPulse state archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import hashlib
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
import platform
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import tarfile
|
|
17
|
+
from typing import Any, Iterator
|
|
18
|
+
from uuid import uuid4
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
BACKUP_FORMAT_VERSION = 1
|
|
22
|
+
MANIFEST_NAME = "manifest.json"
|
|
23
|
+
PAYLOAD_DIRECTORY = "payload"
|
|
24
|
+
SNAPSHOT_PATHS = (
|
|
25
|
+
"config.yaml",
|
|
26
|
+
"config.d",
|
|
27
|
+
"homeassistant/config",
|
|
28
|
+
"mosquitto/data",
|
|
29
|
+
"logs/sms_subscriptions.json",
|
|
30
|
+
"logs/sms_processed_requests.json",
|
|
31
|
+
)
|
|
32
|
+
MANDATORY_PATHS = ("config.yaml", "homeassistant/config")
|
|
33
|
+
CONTAINER_SNAPSHOT_PATHS = {
|
|
34
|
+
"homeassistant/config": ("homeassistant", "/config"),
|
|
35
|
+
"mosquitto/data": ("mosquitto", "/mosquitto/data"),
|
|
36
|
+
}
|
|
37
|
+
class BackupError(RuntimeError):
|
|
38
|
+
"""Raised when a backup or restore cannot complete safely."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def _temporary_directory(parent: Path, prefix: str) -> Iterator[Path]:
|
|
43
|
+
"""Create a private disposable directory without tempfile ACL surprises."""
|
|
44
|
+
|
|
45
|
+
path = parent / f".{prefix}{uuid4().hex}"
|
|
46
|
+
path.mkdir(parents=True)
|
|
47
|
+
if os.name != "nt":
|
|
48
|
+
path.chmod(0o700)
|
|
49
|
+
try:
|
|
50
|
+
yield path
|
|
51
|
+
finally:
|
|
52
|
+
shutil.rmtree(path, ignore_errors=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sha256(path: Path) -> str:
|
|
56
|
+
"""Return the SHA-256 digest of one regular file."""
|
|
57
|
+
|
|
58
|
+
digest = hashlib.sha256()
|
|
59
|
+
with path.open("rb") as stream:
|
|
60
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
61
|
+
digest.update(block)
|
|
62
|
+
return digest.hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _capture_host_command(command: Sequence[str]) -> str:
|
|
66
|
+
"""Capture one optional host-setting command without failing backup creation."""
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
list(command),
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
check=False,
|
|
74
|
+
timeout=10,
|
|
75
|
+
)
|
|
76
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
77
|
+
return f"unavailable: {error}"
|
|
78
|
+
output = (result.stdout or result.stderr).strip()
|
|
79
|
+
return output or f"exit {result.returncode}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _compose(
|
|
83
|
+
live_dir: Path,
|
|
84
|
+
docker_prefix: Sequence[str],
|
|
85
|
+
arguments: Sequence[str],
|
|
86
|
+
) -> subprocess.CompletedProcess[str]:
|
|
87
|
+
"""Run one bounded Compose command or raise an actionable error."""
|
|
88
|
+
|
|
89
|
+
command = [*docker_prefix, "compose", *arguments]
|
|
90
|
+
try:
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
command,
|
|
93
|
+
cwd=live_dir,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=False,
|
|
97
|
+
timeout=120,
|
|
98
|
+
)
|
|
99
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
100
|
+
raise BackupError(f"Cannot run {' '.join(command)}: {error}") from error
|
|
101
|
+
if result.returncode != 0:
|
|
102
|
+
detail = (result.stderr or result.stdout).strip()
|
|
103
|
+
raise BackupError(
|
|
104
|
+
f"{' '.join(command)} failed: {detail or f'exit {result.returncode}'}"
|
|
105
|
+
)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def running_services(
|
|
110
|
+
live_dir: Path,
|
|
111
|
+
docker_prefix: Sequence[str],
|
|
112
|
+
) -> tuple[str, ...]:
|
|
113
|
+
"""Return the currently running Compose services in stable order."""
|
|
114
|
+
|
|
115
|
+
result = _compose(
|
|
116
|
+
live_dir,
|
|
117
|
+
docker_prefix,
|
|
118
|
+
("ps", "--status", "running", "--services"),
|
|
119
|
+
)
|
|
120
|
+
return tuple(line.strip() for line in result.stdout.splitlines() if line.strip())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def stop_services(
|
|
124
|
+
live_dir: Path,
|
|
125
|
+
docker_prefix: Sequence[str],
|
|
126
|
+
services: Sequence[str],
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Stop selected services for a consistent state snapshot."""
|
|
129
|
+
|
|
130
|
+
if services:
|
|
131
|
+
_compose(live_dir, docker_prefix, ("stop", *services))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def start_services(
|
|
135
|
+
live_dir: Path,
|
|
136
|
+
docker_prefix: Sequence[str],
|
|
137
|
+
services: Sequence[str],
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Restart exactly the services stopped for a snapshot."""
|
|
140
|
+
|
|
141
|
+
if services:
|
|
142
|
+
_compose(live_dir, docker_prefix, ("start", *services))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _copy_snapshot_path(source: Path, destination: Path) -> None:
|
|
146
|
+
"""Copy one state path while rejecting links and special files."""
|
|
147
|
+
|
|
148
|
+
# A symbolic link could point outside ~/labpulse-live and pull an unrelated
|
|
149
|
+
# file into the archive, so backups only follow ordinary files/directories.
|
|
150
|
+
if source.is_symlink():
|
|
151
|
+
raise BackupError(f"Refusing to follow state symlink: {source}")
|
|
152
|
+
if source.is_file():
|
|
153
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
shutil.copy2(source, destination)
|
|
155
|
+
return
|
|
156
|
+
if not source.is_dir():
|
|
157
|
+
raise BackupError(f"Unsupported state path type: {source}")
|
|
158
|
+
|
|
159
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
for child in sorted(source.iterdir(), key=lambda item: item.name):
|
|
161
|
+
if child.is_symlink():
|
|
162
|
+
raise BackupError(f"Refusing to follow state symlink: {child}")
|
|
163
|
+
target = destination / child.name
|
|
164
|
+
if child.is_dir():
|
|
165
|
+
_copy_snapshot_path(child, target)
|
|
166
|
+
elif child.is_file():
|
|
167
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
shutil.copy2(child, target)
|
|
169
|
+
else:
|
|
170
|
+
raise BackupError(f"Unsupported state path type: {child}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate_snapshot_path(path: Path) -> None:
|
|
174
|
+
"""Reject links and special files in a container-assisted snapshot copy."""
|
|
175
|
+
|
|
176
|
+
if path.is_symlink():
|
|
177
|
+
raise BackupError(f"Refusing to archive state symlink: {path}")
|
|
178
|
+
if path.is_file():
|
|
179
|
+
return
|
|
180
|
+
if not path.is_dir():
|
|
181
|
+
raise BackupError(f"Unsupported state path type: {path}")
|
|
182
|
+
for child in sorted(path.iterdir(), key=lambda item: item.name):
|
|
183
|
+
_validate_snapshot_path(child)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _copy_snapshot_from_container(
|
|
187
|
+
live_dir: Path,
|
|
188
|
+
relative: str,
|
|
189
|
+
destination: Path,
|
|
190
|
+
docker_prefix: Sequence[str],
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Copy container-owned bind-mount state through its stopped service."""
|
|
193
|
+
|
|
194
|
+
service, container_path = CONTAINER_SNAPSHOT_PATHS[relative]
|
|
195
|
+
shutil.rmtree(destination, ignore_errors=False)
|
|
196
|
+
destination.mkdir(parents=True)
|
|
197
|
+
try:
|
|
198
|
+
_compose(
|
|
199
|
+
live_dir,
|
|
200
|
+
docker_prefix,
|
|
201
|
+
("cp", f"{service}:{container_path}/.", str(destination)),
|
|
202
|
+
)
|
|
203
|
+
except BackupError as error:
|
|
204
|
+
raise BackupError(
|
|
205
|
+
f"Cannot read {live_dir / relative} as the current user and "
|
|
206
|
+
f"could not copy it through the {service} container: {error}"
|
|
207
|
+
) from error
|
|
208
|
+
_validate_snapshot_path(destination)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _assemble_snapshot(
|
|
212
|
+
live_dir: Path,
|
|
213
|
+
staging_root: Path,
|
|
214
|
+
docker_prefix: Sequence[str],
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Copy selected state and write its checksum manifest."""
|
|
217
|
+
|
|
218
|
+
payload_root = staging_root / PAYLOAD_DIRECTORY
|
|
219
|
+
included: list[str] = []
|
|
220
|
+
for relative in SNAPSHOT_PATHS:
|
|
221
|
+
source = live_dir / relative
|
|
222
|
+
if not source.exists():
|
|
223
|
+
if relative in MANDATORY_PATHS:
|
|
224
|
+
raise BackupError(f"Required backup state is missing: {source}")
|
|
225
|
+
continue
|
|
226
|
+
destination = payload_root / relative
|
|
227
|
+
try:
|
|
228
|
+
_copy_snapshot_path(source, destination)
|
|
229
|
+
except PermissionError as error:
|
|
230
|
+
# Home Assistant and Mosquitto may create files owned by their
|
|
231
|
+
# container user. A stopped container can still copy that mounted
|
|
232
|
+
# state when the host user is not allowed to read it directly.
|
|
233
|
+
if relative not in CONTAINER_SNAPSHOT_PATHS:
|
|
234
|
+
raise BackupError(f"Cannot read backup state: {error.filename}") from error
|
|
235
|
+
_copy_snapshot_from_container(
|
|
236
|
+
live_dir,
|
|
237
|
+
relative,
|
|
238
|
+
destination,
|
|
239
|
+
docker_prefix,
|
|
240
|
+
)
|
|
241
|
+
included.append(relative)
|
|
242
|
+
|
|
243
|
+
# Record the digest of every file so restore can detect a missing, added,
|
|
244
|
+
# or modified payload before replacing live state.
|
|
245
|
+
checksums = {
|
|
246
|
+
path.relative_to(staging_root).as_posix(): _sha256(path)
|
|
247
|
+
for path in sorted(payload_root.rglob("*"))
|
|
248
|
+
if path.is_file()
|
|
249
|
+
}
|
|
250
|
+
try:
|
|
251
|
+
labpulse_version = version("labpulse")
|
|
252
|
+
except PackageNotFoundError:
|
|
253
|
+
labpulse_version = "source-checkout"
|
|
254
|
+
try:
|
|
255
|
+
compose_text = (live_dir / "compose.yaml").read_text(encoding="utf-8")
|
|
256
|
+
except OSError:
|
|
257
|
+
compose_text = ""
|
|
258
|
+
runtime_mode = "fake_usb" if "config.fake.yaml:/app/config.yaml" in compose_text else "real_hardware"
|
|
259
|
+
manifest: dict[str, Any] = {
|
|
260
|
+
"format_version": BACKUP_FORMAT_VERSION,
|
|
261
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
262
|
+
"labpulse_version": labpulse_version,
|
|
263
|
+
"source_live_directory": str(live_dir),
|
|
264
|
+
"runtime_mode": runtime_mode,
|
|
265
|
+
"included_paths": included,
|
|
266
|
+
"files": checksums,
|
|
267
|
+
"host": {
|
|
268
|
+
"platform": platform.platform(),
|
|
269
|
+
"python": platform.python_version(),
|
|
270
|
+
"docker_command": " ".join(docker_prefix),
|
|
271
|
+
"clock": _capture_host_command(
|
|
272
|
+
("timedatectl", "show", "--property=Timezone", "--property=NTPSynchronized")
|
|
273
|
+
),
|
|
274
|
+
"watchdog": _capture_host_command(
|
|
275
|
+
("systemctl", "show", "--property=RuntimeWatchdogUSec", "--value")
|
|
276
|
+
),
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
(staging_root / MANIFEST_NAME).write_text(
|
|
280
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
|
281
|
+
encoding="utf-8",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def create_backup(
|
|
286
|
+
live_dir: Path,
|
|
287
|
+
archive_path: Path,
|
|
288
|
+
docker_prefix: Sequence[str],
|
|
289
|
+
*,
|
|
290
|
+
force: bool = False,
|
|
291
|
+
quiesce: bool = True,
|
|
292
|
+
) -> Path:
|
|
293
|
+
"""Create one checksummed private archive, restarting quiesced services."""
|
|
294
|
+
|
|
295
|
+
live_dir = live_dir.expanduser().resolve()
|
|
296
|
+
archive_path = archive_path.expanduser().resolve()
|
|
297
|
+
if not live_dir.is_dir() or not (live_dir / "compose.yaml").is_file():
|
|
298
|
+
raise BackupError(
|
|
299
|
+
f"LabPulse is not set up at {live_dir}; run 'labpulse setup' first"
|
|
300
|
+
)
|
|
301
|
+
if archive_path.is_relative_to(live_dir):
|
|
302
|
+
raise BackupError("Backup archive must be stored outside the live directory")
|
|
303
|
+
if archive_path.exists() and not force:
|
|
304
|
+
raise BackupError(f"Backup archive already exists: {archive_path}")
|
|
305
|
+
archive_path.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
|
|
307
|
+
active_services: tuple[str, ...] = ()
|
|
308
|
+
if quiesce:
|
|
309
|
+
active_services = running_services(live_dir, docker_prefix)
|
|
310
|
+
|
|
311
|
+
temporary_archive = archive_path.parent / (
|
|
312
|
+
f".{archive_path.name}.creating-{uuid4().hex}"
|
|
313
|
+
)
|
|
314
|
+
try:
|
|
315
|
+
# Services are stopped only while their mutable state is copied. The
|
|
316
|
+
# finally block restarts exactly those that were running beforehand.
|
|
317
|
+
if quiesce:
|
|
318
|
+
stop_services(live_dir, docker_prefix, active_services)
|
|
319
|
+
with _temporary_directory(
|
|
320
|
+
archive_path.parent,
|
|
321
|
+
"labpulse-backup-",
|
|
322
|
+
) as staging_root:
|
|
323
|
+
_assemble_snapshot(live_dir, staging_root, docker_prefix)
|
|
324
|
+
with tarfile.open(temporary_archive, "w:gz") as archive:
|
|
325
|
+
archive.add(staging_root / MANIFEST_NAME, arcname=MANIFEST_NAME)
|
|
326
|
+
archive.add(staging_root / PAYLOAD_DIRECTORY, arcname=PAYLOAD_DIRECTORY)
|
|
327
|
+
os.chmod(temporary_archive, 0o600)
|
|
328
|
+
os.replace(temporary_archive, archive_path)
|
|
329
|
+
finally:
|
|
330
|
+
if temporary_archive.exists():
|
|
331
|
+
temporary_archive.unlink()
|
|
332
|
+
if quiesce:
|
|
333
|
+
start_services(live_dir, docker_prefix, active_services)
|
|
334
|
+
|
|
335
|
+
return archive_path
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _load_manifest(archive: tarfile.TarFile) -> dict[str, Any]:
|
|
339
|
+
"""Read and minimally validate the archive manifest."""
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
member = archive.getmember(MANIFEST_NAME)
|
|
343
|
+
stream = archive.extractfile(member)
|
|
344
|
+
except (KeyError, tarfile.TarError) as error:
|
|
345
|
+
raise BackupError("Backup manifest is missing") from error
|
|
346
|
+
if stream is None or not member.isreg():
|
|
347
|
+
raise BackupError("Backup manifest is not a regular file")
|
|
348
|
+
try:
|
|
349
|
+
manifest = json.load(stream)
|
|
350
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
351
|
+
raise BackupError(f"Backup manifest is invalid: {error}") from error
|
|
352
|
+
if not isinstance(manifest, dict):
|
|
353
|
+
raise BackupError("Backup manifest must be a JSON object")
|
|
354
|
+
if manifest.get("format_version") != BACKUP_FORMAT_VERSION:
|
|
355
|
+
raise BackupError(
|
|
356
|
+
f"Unsupported backup format: {manifest.get('format_version')!r}"
|
|
357
|
+
)
|
|
358
|
+
if manifest.get("runtime_mode") not in {"real_hardware", "fake_usb"}:
|
|
359
|
+
raise BackupError("Backup manifest has an invalid runtime mode")
|
|
360
|
+
files = manifest.get("files")
|
|
361
|
+
included = manifest.get("included_paths")
|
|
362
|
+
if not isinstance(files, dict) or not all(
|
|
363
|
+
isinstance(key, str) and isinstance(value, str)
|
|
364
|
+
for key, value in files.items()
|
|
365
|
+
):
|
|
366
|
+
raise BackupError("Backup manifest has invalid file checksums")
|
|
367
|
+
if not isinstance(included, list) or not all(
|
|
368
|
+
isinstance(value, str) for value in included
|
|
369
|
+
):
|
|
370
|
+
raise BackupError("Backup manifest has invalid included paths")
|
|
371
|
+
for mandatory in MANDATORY_PATHS:
|
|
372
|
+
if mandatory not in included:
|
|
373
|
+
raise BackupError(f"Backup is incomplete: missing {mandatory}")
|
|
374
|
+
return manifest
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def inspect_backup(archive_path: Path) -> dict[str, Any]:
|
|
378
|
+
"""Validate archive structure and every payload checksum."""
|
|
379
|
+
|
|
380
|
+
archive_path = archive_path.expanduser().resolve()
|
|
381
|
+
if not archive_path.is_file():
|
|
382
|
+
raise BackupError(f"Backup archive does not exist: {archive_path}")
|
|
383
|
+
try:
|
|
384
|
+
archive = tarfile.open(archive_path, "r:*")
|
|
385
|
+
except (OSError, tarfile.TarError) as error:
|
|
386
|
+
raise BackupError(f"Cannot open backup archive: {error}") from error
|
|
387
|
+
|
|
388
|
+
with archive:
|
|
389
|
+
manifest = _load_manifest(archive)
|
|
390
|
+
checksums: dict[str, str] = manifest["files"]
|
|
391
|
+
archive_files: set[str] = set()
|
|
392
|
+
for member in archive.getmembers():
|
|
393
|
+
member_path = PurePosixPath(member.name)
|
|
394
|
+
# Absolute paths or ".." components could make extraction write
|
|
395
|
+
# outside its temporary directory. Only the manifest and payload
|
|
396
|
+
# tree are valid members of a LabPulse archive.
|
|
397
|
+
safe_name = (
|
|
398
|
+
not member_path.is_absolute()
|
|
399
|
+
and ".." not in member_path.parts
|
|
400
|
+
and "." not in member_path.parts
|
|
401
|
+
and (
|
|
402
|
+
member.name == MANIFEST_NAME
|
|
403
|
+
or (bool(member_path.parts) and member_path.parts[0] == PAYLOAD_DIRECTORY)
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
if not safe_name:
|
|
407
|
+
raise BackupError(f"Unsafe archive path: {member.name}")
|
|
408
|
+
if not (member.isdir() or member.isreg()):
|
|
409
|
+
raise BackupError(f"Unsafe archive member type: {member.name}")
|
|
410
|
+
if member.isreg() and member.name.startswith(f"{PAYLOAD_DIRECTORY}/"):
|
|
411
|
+
archive_files.add(member.name)
|
|
412
|
+
if member.name not in checksums:
|
|
413
|
+
raise BackupError(f"Unmanifested backup file: {member.name}")
|
|
414
|
+
stream = archive.extractfile(member)
|
|
415
|
+
if stream is None:
|
|
416
|
+
raise BackupError(f"Cannot read backup file: {member.name}")
|
|
417
|
+
digest = hashlib.sha256()
|
|
418
|
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
419
|
+
digest.update(block)
|
|
420
|
+
if digest.hexdigest() != checksums[member.name]:
|
|
421
|
+
raise BackupError(f"Checksum mismatch: {member.name}")
|
|
422
|
+
if archive_files != set(checksums):
|
|
423
|
+
missing = sorted(set(checksums) - archive_files)
|
|
424
|
+
raise BackupError("Backup payload is missing: " + ", ".join(missing))
|
|
425
|
+
return manifest
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _extract_validated(archive_path: Path, destination: Path) -> dict[str, Any]:
|
|
429
|
+
"""Validate and manually extract regular payload files."""
|
|
430
|
+
|
|
431
|
+
manifest = inspect_backup(archive_path)
|
|
432
|
+
with tarfile.open(archive_path, "r:*") as archive:
|
|
433
|
+
# Extract members ourselves instead of using tarfile.extract(). Their
|
|
434
|
+
# names and file types were checked above, and every destination remains
|
|
435
|
+
# visibly rooted in this temporary directory.
|
|
436
|
+
members = archive.getmembers()
|
|
437
|
+
for member in members:
|
|
438
|
+
if not member.isdir():
|
|
439
|
+
continue
|
|
440
|
+
target = destination.joinpath(*PurePosixPath(member.name).parts)
|
|
441
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
442
|
+
for member in members:
|
|
443
|
+
if not member.isreg():
|
|
444
|
+
continue
|
|
445
|
+
target = destination.joinpath(*PurePosixPath(member.name).parts)
|
|
446
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
stream = archive.extractfile(member)
|
|
448
|
+
if stream is None:
|
|
449
|
+
raise BackupError(f"Cannot extract backup file: {member.name}")
|
|
450
|
+
with target.open("wb") as output:
|
|
451
|
+
shutil.copyfileobj(stream, output)
|
|
452
|
+
try:
|
|
453
|
+
os.chmod(target, member.mode & 0o777)
|
|
454
|
+
except OSError:
|
|
455
|
+
pass
|
|
456
|
+
return manifest
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _remove_path(path: Path) -> None:
|
|
460
|
+
"""Remove one exact file, link, or directory target."""
|
|
461
|
+
|
|
462
|
+
if path.is_symlink() or path.is_file():
|
|
463
|
+
path.unlink()
|
|
464
|
+
elif path.is_dir():
|
|
465
|
+
shutil.rmtree(path)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _copy_local_path(source: Path, destination: Path) -> None:
|
|
469
|
+
"""Copy a staged or rollback path without following stored symlinks."""
|
|
470
|
+
|
|
471
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
472
|
+
if source.is_symlink():
|
|
473
|
+
destination.symlink_to(os.readlink(source))
|
|
474
|
+
elif source.is_dir():
|
|
475
|
+
shutil.copytree(source, destination, symlinks=True)
|
|
476
|
+
else:
|
|
477
|
+
shutil.copy2(source, destination, follow_symlinks=False)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _apply_payload(live_dir: Path, payload_root: Path) -> None:
|
|
481
|
+
"""Replace snapshot-owned paths and roll back the filesystem on failure."""
|
|
482
|
+
|
|
483
|
+
with _temporary_directory(
|
|
484
|
+
live_dir.parent,
|
|
485
|
+
"labpulse-restore-rollback-",
|
|
486
|
+
) as local_rollback:
|
|
487
|
+
# Copy the current state aside before removing anything. If any later
|
|
488
|
+
# copy fails, the except block can put the complete old state back.
|
|
489
|
+
existing: list[str] = []
|
|
490
|
+
for relative in SNAPSHOT_PATHS:
|
|
491
|
+
target = live_dir / relative
|
|
492
|
+
if target.exists() or target.is_symlink():
|
|
493
|
+
_copy_local_path(target, local_rollback / relative)
|
|
494
|
+
existing.append(relative)
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
for relative in SNAPSHOT_PATHS:
|
|
498
|
+
target = live_dir / relative
|
|
499
|
+
_remove_path(target)
|
|
500
|
+
source = payload_root / relative
|
|
501
|
+
if source.exists() or source.is_symlink():
|
|
502
|
+
_copy_local_path(source, target)
|
|
503
|
+
except Exception as error:
|
|
504
|
+
for relative in SNAPSHOT_PATHS:
|
|
505
|
+
_remove_path(live_dir / relative)
|
|
506
|
+
for relative in existing:
|
|
507
|
+
_copy_local_path(local_rollback / relative, live_dir / relative)
|
|
508
|
+
raise BackupError(f"Restore failed and local state was rolled back: {error}") from error
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def restore_backup(live_dir: Path, archive_path: Path) -> dict[str, Any]:
|
|
512
|
+
"""Validate and apply archive state to an existing scaffolded live directory."""
|
|
513
|
+
|
|
514
|
+
live_dir = live_dir.expanduser().resolve()
|
|
515
|
+
archive_path = archive_path.expanduser().resolve()
|
|
516
|
+
if not live_dir.is_dir():
|
|
517
|
+
raise BackupError(f"Restore target does not exist: {live_dir}")
|
|
518
|
+
with _temporary_directory(
|
|
519
|
+
live_dir.parent,
|
|
520
|
+
"labpulse-restore-",
|
|
521
|
+
) as extracted:
|
|
522
|
+
manifest = _extract_validated(archive_path, extracted)
|
|
523
|
+
_apply_payload(live_dir, extracted / PAYLOAD_DIRECTORY)
|
|
524
|
+
return manifest
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Shared contracts
|
|
2
|
+
|
|
3
|
+
`labpulse.common` contains definitions that must remain identical across host
|
|
4
|
+
generation, sensor workers, Home Assistant, SMS and outputs. It runs wherever
|
|
5
|
+
the Python package runs and deliberately has no hardware access.
|
|
6
|
+
|
|
7
|
+
| File | Contract |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `config.py` | Central YAML loader, global models, cross-references and source-aware errors |
|
|
10
|
+
| `service_config.py` | Driver selection, service timing, measurements and power-service rules |
|
|
11
|
+
| `measurement_config.py` | Physical/calculated measurements and restricted formulas |
|
|
12
|
+
| `output_config.py` | Output-driver and safe-state configuration |
|
|
13
|
+
| `identity.py` | Stable slugs, IDs, titles and Home Assistant entity IDs |
|
|
14
|
+
| `mqtt_contracts.py` | Topic constructors and strict SMS requests |
|
|
15
|
+
| `fake_config.py` | Preserve the complete resolved fake-hardware runtime document |
|
|
16
|
+
| `logging_config.py` | Stdout and optional persistent logging |
|
|
17
|
+
| `generated_files.py` | Atomically replace one complete file |
|
|
18
|
+
| `sms_templates.py` / `sms_templates.yaml` | Validate and supply notification text |
|
|
19
|
+
|
|
20
|
+
Unknown fields are rejected. Driver options are converted to the selected
|
|
21
|
+
driver's typed model during the central load; consumers should not parse YAML
|
|
22
|
+
again. Calculated formulas accept only finite arithmetic and physical inputs.
|
|
23
|
+
Single-file replacement is atomic, but replacing several generated files is
|
|
24
|
+
not one filesystem transaction.
|
|
25
|
+
|
|
26
|
+
Keep reusable configuration, identity, topic and small shared utilities here.
|
|
27
|
+
Do not add hardware access, CLI parsing, dashboard policy or delivery effects.
|
|
28
|
+
|
|
29
|
+
Relevant tests include `test_config_pipeline.py`, `test_common_contracts.py`,
|
|
30
|
+
`test_custom_measurements.py` and `test_deployment_generation.py`. See
|
|
31
|
+
[Configuration](../../../docs/CONFIGURATION.md) and
|
|
32
|
+
[Architecture](../../../docs/ARCHITECTURE.md).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared contracts and infrastructure used by LabPulse services."""
|