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/database.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database management for lab controller.
|
|
3
|
+
|
|
4
|
+
Provides SQLite connection management, schema initialization, and migrations.
|
|
5
|
+
|
|
6
|
+
Concurrency notes
|
|
7
|
+
=================
|
|
8
|
+
labctl is multi-process by design (CLI, monitor daemon, MCP server, web app,
|
|
9
|
+
batch jobs all share one SQLite file). To keep concurrent readers and
|
|
10
|
+
writers from blocking each other:
|
|
11
|
+
|
|
12
|
+
* **WAL journal mode** is applied on first ``initialize()``. WAL is
|
|
13
|
+
persisted in the database file header, so any process opening the
|
|
14
|
+
file inherits it. With WAL, readers never block writers and vice
|
|
15
|
+
versa — only writer-vs-writer is serialized, and labctl writes are
|
|
16
|
+
all single-statement (sub-ms) or short transactions.
|
|
17
|
+
* **Per-connection busy_timeout** (default 10s, config-driven via
|
|
18
|
+
``database.timeout_seconds``) backstops the narrow windows where
|
|
19
|
+
WAL still serializes — schema migrations and WAL checkpoints.
|
|
20
|
+
* **``synchronous=NORMAL``** trades a few-second worst-case write loss
|
|
21
|
+
on power failure for substantially faster commits. Safe with WAL.
|
|
22
|
+
* **``initialize()`` is cached per-process** so short-lived CLI
|
|
23
|
+
invocations don't repeatedly re-probe ``sqlite_master``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import sqlite3
|
|
28
|
+
import threading
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Generator, Optional
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Current schema version
|
|
36
|
+
SCHEMA_VERSION = 12
|
|
37
|
+
|
|
38
|
+
# Default SQLite busy-timeout when the Database is constructed without an
|
|
39
|
+
# explicit value (e.g. tests, direct callers). Matches Config.database
|
|
40
|
+
# default; raise only if a migration on a contended DB is timing out.
|
|
41
|
+
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
42
|
+
|
|
43
|
+
# SQL statements for schema creation
|
|
44
|
+
SCHEMA_SQL = """
|
|
45
|
+
-- Schema version tracking
|
|
46
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
47
|
+
version INTEGER PRIMARY KEY,
|
|
48
|
+
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
-- SBC (Single Board Computer) records
|
|
52
|
+
CREATE TABLE IF NOT EXISTS sbcs (
|
|
53
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
54
|
+
name TEXT UNIQUE NOT NULL,
|
|
55
|
+
project TEXT,
|
|
56
|
+
description TEXT,
|
|
57
|
+
ssh_user TEXT DEFAULT 'root',
|
|
58
|
+
status TEXT DEFAULT 'unknown',
|
|
59
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
60
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
61
|
+
-- Cached power observation written by the monitor daemon every cycle.
|
|
62
|
+
-- Read by `labctl status --fast` to avoid live network probes.
|
|
63
|
+
last_power_state TEXT, -- on | off | unknown
|
|
64
|
+
last_power_at TIMESTAMP, -- when the daemon last observed power
|
|
65
|
+
-- Per-SBC power-cycle off→on delay. Used as both the default when
|
|
66
|
+
-- --delay is omitted and the floor when --delay is passed (the CLI
|
|
67
|
+
-- raises smaller values to this with a warning).
|
|
68
|
+
power_cycle_delay_seconds REAL
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
-- Registered USB-serial adapters
|
|
72
|
+
CREATE TABLE IF NOT EXISTS serial_devices (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
name TEXT UNIQUE NOT NULL,
|
|
75
|
+
usb_path TEXT UNIQUE NOT NULL,
|
|
76
|
+
vendor TEXT,
|
|
77
|
+
model TEXT,
|
|
78
|
+
serial_number TEXT,
|
|
79
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
-- Serial port assignments
|
|
83
|
+
CREATE TABLE IF NOT EXISTS serial_ports (
|
|
84
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
85
|
+
sbc_id INTEGER NOT NULL,
|
|
86
|
+
port_type TEXT NOT NULL, -- console, jtag, debug
|
|
87
|
+
device_path TEXT NOT NULL, -- /dev/lab/sbc1-console
|
|
88
|
+
tcp_port INTEGER,
|
|
89
|
+
baud_rate INTEGER DEFAULT 115200,
|
|
90
|
+
alias TEXT, -- human-friendly name for this assignment
|
|
91
|
+
serial_device_id INTEGER, -- FK to serial_devices
|
|
92
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
93
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
|
|
94
|
+
FOREIGN KEY (serial_device_id) REFERENCES serial_devices(id) ON DELETE SET NULL,
|
|
95
|
+
UNIQUE (sbc_id, port_type)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
-- Network addresses
|
|
99
|
+
CREATE TABLE IF NOT EXISTS network_addresses (
|
|
100
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
101
|
+
sbc_id INTEGER NOT NULL,
|
|
102
|
+
address_type TEXT NOT NULL, -- ethernet, wifi
|
|
103
|
+
ip_address TEXT NOT NULL,
|
|
104
|
+
mac_address TEXT,
|
|
105
|
+
hostname TEXT,
|
|
106
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
107
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
|
|
108
|
+
UNIQUE (sbc_id, address_type)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
-- Power plug assignments
|
|
112
|
+
CREATE TABLE IF NOT EXISTS power_plugs (
|
|
113
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
114
|
+
sbc_id INTEGER UNIQUE NOT NULL,
|
|
115
|
+
plug_type TEXT NOT NULL, -- tasmota, kasa, shelly
|
|
116
|
+
address TEXT NOT NULL, -- IP or hostname
|
|
117
|
+
plug_index INTEGER DEFAULT 1, -- For multi-outlet strips
|
|
118
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
119
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
-- SDWire (SD card multiplexer) devices
|
|
123
|
+
CREATE TABLE IF NOT EXISTS sdwire_devices (
|
|
124
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
|
+
name TEXT UNIQUE NOT NULL,
|
|
126
|
+
serial_number TEXT UNIQUE NOT NULL,
|
|
127
|
+
device_type TEXT NOT NULL DEFAULT 'sdwirec', -- sdwire, sdwirec, sdwire3
|
|
128
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
-- SDWire assignments to SBCs
|
|
132
|
+
CREATE TABLE IF NOT EXISTS sdwire_assignments (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
sbc_id INTEGER UNIQUE NOT NULL,
|
|
135
|
+
sdwire_device_id INTEGER NOT NULL,
|
|
136
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
137
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
|
|
138
|
+
FOREIGN KEY (sdwire_device_id) REFERENCES sdwire_devices(id) ON DELETE CASCADE
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
-- Status history log
|
|
142
|
+
CREATE TABLE IF NOT EXISTS status_log (
|
|
143
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
144
|
+
sbc_id INTEGER NOT NULL,
|
|
145
|
+
status TEXT NOT NULL,
|
|
146
|
+
details TEXT,
|
|
147
|
+
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
148
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
-- Audit log / activity stream — records every state-changing action
|
|
152
|
+
-- across CLI, MCP, web API, and the monitor daemon.
|
|
153
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
154
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
155
|
+
action TEXT NOT NULL, -- create, update, delete, power_on, power_off, etc.
|
|
156
|
+
entity_type TEXT NOT NULL, -- sbc, serial_port, power_plug, etc.
|
|
157
|
+
entity_id INTEGER,
|
|
158
|
+
entity_name TEXT,
|
|
159
|
+
details TEXT,
|
|
160
|
+
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
161
|
+
actor TEXT NOT NULL DEFAULT 'internal', -- e.g. "cli:john", "mcp-stdio:12345-..."
|
|
162
|
+
source TEXT NOT NULL DEFAULT 'internal', -- cli | mcp | api | daemon | internal
|
|
163
|
+
result TEXT NOT NULL DEFAULT 'ok', -- ok | error | forbidden
|
|
164
|
+
claim_id INTEGER REFERENCES claims(id)
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
-- Hardware claims (exclusive access coordination)
|
|
168
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
169
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
170
|
+
sbc_id INTEGER NOT NULL,
|
|
171
|
+
agent_name TEXT NOT NULL,
|
|
172
|
+
session_id TEXT NOT NULL,
|
|
173
|
+
session_kind TEXT NOT NULL, -- mcp-stdio, mcp-http, cli, web
|
|
174
|
+
owner TEXT, -- authenticated labctl username (MCP HTTP, REST, web), else NULL
|
|
175
|
+
reason TEXT NOT NULL,
|
|
176
|
+
context_json TEXT,
|
|
177
|
+
acquired_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
178
|
+
duration_seconds INTEGER NOT NULL,
|
|
179
|
+
last_activity TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
180
|
+
expires_at TIMESTAMP NOT NULL, -- materialized = last_activity + duration_seconds
|
|
181
|
+
renewal_count INTEGER NOT NULL DEFAULT 0,
|
|
182
|
+
released_at TIMESTAMP,
|
|
183
|
+
release_reason TEXT, -- released, expired, force-released, session-lost
|
|
184
|
+
released_by TEXT,
|
|
185
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
-- Actuators (USB relays and similar single-bit hardware resources)
|
|
189
|
+
CREATE TABLE IF NOT EXISTS actuators (
|
|
190
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
191
|
+
name TEXT UNIQUE NOT NULL,
|
|
192
|
+
kind TEXT NOT NULL DEFAULT 'relay', -- relay | gpio | …
|
|
193
|
+
driver TEXT NOT NULL, -- lcus1_serial | numato_acm | …
|
|
194
|
+
device_path TEXT, -- /dev/lab/relay-rack1-a
|
|
195
|
+
vid TEXT, -- USB vendor id (hex string)
|
|
196
|
+
pid TEXT, -- USB product id (hex string)
|
|
197
|
+
serial_no TEXT, -- USB serial number (when present)
|
|
198
|
+
last_probe_at TIMESTAMP, -- last health-probe timestamp
|
|
199
|
+
last_probe_result TEXT, -- ok | unreachable | …
|
|
200
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
-- Channels (outlets) on an actuator
|
|
204
|
+
CREATE TABLE IF NOT EXISTS actuator_channels (
|
|
205
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
206
|
+
actuator_id INTEGER NOT NULL,
|
|
207
|
+
channel_index INTEGER NOT NULL, -- 1-based
|
|
208
|
+
label TEXT,
|
|
209
|
+
default_state TEXT NOT NULL DEFAULT 'open'
|
|
210
|
+
CHECK(default_state IN ('open','closed')),
|
|
211
|
+
last_state TEXT
|
|
212
|
+
CHECK(last_state IN ('open','closed') OR last_state IS NULL),
|
|
213
|
+
last_changed_at TIMESTAMP,
|
|
214
|
+
cycle_count INTEGER NOT NULL DEFAULT 0,
|
|
215
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
216
|
+
FOREIGN KEY (actuator_id) REFERENCES actuators(id) ON DELETE CASCADE,
|
|
217
|
+
UNIQUE (actuator_id, channel_index)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
-- Bindings: (SBC, purpose) → actuator_channel
|
|
221
|
+
CREATE TABLE IF NOT EXISTS bindings (
|
|
222
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
223
|
+
sbc_id INTEGER NOT NULL,
|
|
224
|
+
purpose TEXT NOT NULL, -- recovery_mode | boot_select | …
|
|
225
|
+
actuator_channel_id INTEGER NOT NULL,
|
|
226
|
+
shape_mode TEXT NOT NULL
|
|
227
|
+
CHECK(shape_mode IN ('latch','momentary')),
|
|
228
|
+
shape_active TEXT NOT NULL
|
|
229
|
+
CHECK(shape_active IN ('closed','open')),
|
|
230
|
+
momentary_pulse_ms INTEGER,
|
|
231
|
+
sample_phase TEXT NOT NULL DEFAULT 'none'
|
|
232
|
+
CHECK(sample_phase IN ('pre_power','post_power','none')),
|
|
233
|
+
desired_state TEXT NOT NULL DEFAULT 'released'
|
|
234
|
+
CHECK(desired_state IN ('asserted','released','following_power')),
|
|
235
|
+
notes TEXT,
|
|
236
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
237
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
|
|
238
|
+
FOREIGN KEY (actuator_channel_id)
|
|
239
|
+
REFERENCES actuator_channels(id) ON DELETE CASCADE,
|
|
240
|
+
UNIQUE (sbc_id, purpose),
|
|
241
|
+
UNIQUE (actuator_channel_id)
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
-- Release requests (polite nudges without forced eviction)
|
|
245
|
+
CREATE TABLE IF NOT EXISTS claim_requests (
|
|
246
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
247
|
+
claim_id INTEGER NOT NULL,
|
|
248
|
+
requested_by TEXT NOT NULL,
|
|
249
|
+
reason TEXT NOT NULL,
|
|
250
|
+
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
251
|
+
acknowledged INTEGER NOT NULL DEFAULT 0, -- SQLite boolean
|
|
252
|
+
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
-- Images uploaded over MCP HTTP (mcp.upload_dir): who uploaded each one,
|
|
256
|
+
-- so agents can replace or delete only their own (D014). file_id
|
|
257
|
+
-- (device:inode:mtime_ns:size) ties a row to the file it describes.
|
|
258
|
+
CREATE TABLE IF NOT EXISTS images (
|
|
259
|
+
name TEXT PRIMARY KEY,
|
|
260
|
+
uploaded_by TEXT,
|
|
261
|
+
size INTEGER NOT NULL,
|
|
262
|
+
sha256 TEXT NOT NULL,
|
|
263
|
+
file_id TEXT,
|
|
264
|
+
uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
-- Indexes for common queries
|
|
268
|
+
CREATE INDEX IF NOT EXISTS idx_sbcs_project ON sbcs(project);
|
|
269
|
+
CREATE INDEX IF NOT EXISTS idx_sbcs_status ON sbcs(status);
|
|
270
|
+
CREATE INDEX IF NOT EXISTS idx_serial_devices_usb_path ON serial_devices(usb_path);
|
|
271
|
+
CREATE INDEX IF NOT EXISTS idx_serial_ports_sbc ON serial_ports(sbc_id);
|
|
272
|
+
CREATE INDEX IF NOT EXISTS idx_serial_ports_device ON serial_ports(device_path);
|
|
273
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_serial_ports_alias ON serial_ports(alias) WHERE alias IS NOT NULL;
|
|
274
|
+
CREATE INDEX IF NOT EXISTS idx_sdwire_assignments_sbc ON sdwire_assignments(sbc_id);
|
|
275
|
+
CREATE INDEX IF NOT EXISTS idx_status_log_sbc ON status_log(sbc_id);
|
|
276
|
+
CREATE INDEX IF NOT EXISTS idx_status_log_logged_at ON status_log(logged_at);
|
|
277
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
|
278
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_logged_at ON audit_log(logged_at DESC);
|
|
279
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor, logged_at DESC);
|
|
280
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_source ON audit_log(source, logged_at DESC);
|
|
281
|
+
|
|
282
|
+
-- At most one active claim per SBC (partial unique index)
|
|
283
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_active_sbc
|
|
284
|
+
ON claims(sbc_id) WHERE released_at IS NULL;
|
|
285
|
+
CREATE INDEX IF NOT EXISTS idx_claims_session
|
|
286
|
+
ON claims(session_id) WHERE released_at IS NULL;
|
|
287
|
+
CREATE INDEX IF NOT EXISTS idx_claims_agent
|
|
288
|
+
ON claims(agent_name) WHERE released_at IS NULL;
|
|
289
|
+
CREATE INDEX IF NOT EXISTS idx_claims_expiry
|
|
290
|
+
ON claims(expires_at) WHERE released_at IS NULL;
|
|
291
|
+
CREATE INDEX IF NOT EXISTS idx_claim_requests_claim
|
|
292
|
+
ON claim_requests(claim_id);
|
|
293
|
+
CREATE INDEX IF NOT EXISTS idx_actuator_channels_actuator
|
|
294
|
+
ON actuator_channels(actuator_id);
|
|
295
|
+
CREATE INDEX IF NOT EXISTS idx_bindings_sbc ON bindings(sbc_id);
|
|
296
|
+
CREATE INDEX IF NOT EXISTS idx_bindings_actuator_channel
|
|
297
|
+
ON bindings(actuator_channel_id);
|
|
298
|
+
""" # noqa: E501
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class Database:
|
|
302
|
+
"""SQLite database manager for lab controller."""
|
|
303
|
+
|
|
304
|
+
def __init__(
|
|
305
|
+
self,
|
|
306
|
+
db_path: Path,
|
|
307
|
+
*,
|
|
308
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
309
|
+
):
|
|
310
|
+
"""
|
|
311
|
+
Initialize database manager.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
db_path: Path to SQLite database file.
|
|
315
|
+
timeout_seconds: SQLite busy-wait used when another process
|
|
316
|
+
holds an incompatible lock. Defaults to 10s; usually
|
|
317
|
+
wired from ``Config.database.timeout_seconds``.
|
|
318
|
+
"""
|
|
319
|
+
self.db_path = db_path
|
|
320
|
+
self.timeout_seconds = timeout_seconds
|
|
321
|
+
# Cache: only run the migration-probe block once per process per
|
|
322
|
+
# Database instance. Short-lived CLI invocations still pay one
|
|
323
|
+
# initialize(); long-lived processes (daemon, MCP, web) pay it
|
|
324
|
+
# exactly once total. Reset by tests via _reset_initialized().
|
|
325
|
+
self._initialized = False
|
|
326
|
+
# RLock (not Lock) so a future migration or test helper that
|
|
327
|
+
# happens to call back into initialize() while holding the lock
|
|
328
|
+
# doesn't self-deadlock. Nothing in the current codebase does
|
|
329
|
+
# this, but Lock made it a sharp edge.
|
|
330
|
+
self._init_lock = threading.RLock()
|
|
331
|
+
|
|
332
|
+
def initialize(self) -> None:
|
|
333
|
+
"""Initialize database schema if needed.
|
|
334
|
+
|
|
335
|
+
Idempotent across calls within a single process: the first call
|
|
336
|
+
runs the WAL/synchronous pragmas and the migration probe; every
|
|
337
|
+
subsequent call is a near-no-op (only the cache flag is read
|
|
338
|
+
under a lock). Multiple Python processes still each call this
|
|
339
|
+
once on first DB access — which is fine because the pragmas are
|
|
340
|
+
idempotent and the migration probe early-exits when current.
|
|
341
|
+
"""
|
|
342
|
+
with self._init_lock:
|
|
343
|
+
if self._initialized:
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
# Ensure directory exists
|
|
347
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
|
|
349
|
+
with self.connect() as conn:
|
|
350
|
+
self._apply_persistent_pragmas(conn)
|
|
351
|
+
# Check current schema version
|
|
352
|
+
query = (
|
|
353
|
+
"SELECT name FROM sqlite_master "
|
|
354
|
+
"WHERE type='table' AND name='schema_version'"
|
|
355
|
+
)
|
|
356
|
+
cursor = conn.execute(query)
|
|
357
|
+
if cursor.fetchone() is None:
|
|
358
|
+
# Fresh database - apply full schema.
|
|
359
|
+
# OR IGNORE on the version insert defends against the
|
|
360
|
+
# rare cross-process race where two fresh-DB
|
|
361
|
+
# initialize() calls interleave between the
|
|
362
|
+
# sqlite_master probe and the INSERT — both see no
|
|
363
|
+
# schema_version table, both run SCHEMA_SQL (idempotent
|
|
364
|
+
# via IF NOT EXISTS), then the second INSERT would
|
|
365
|
+
# otherwise fail with a PRIMARY KEY conflict.
|
|
366
|
+
_executescript_atomic(conn, SCHEMA_SQL)
|
|
367
|
+
conn.execute(
|
|
368
|
+
"INSERT OR IGNORE INTO schema_version (version) " "VALUES (?)",
|
|
369
|
+
(SCHEMA_VERSION,),
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
# Check for migrations
|
|
373
|
+
cursor = conn.execute("SELECT MAX(version) FROM schema_version")
|
|
374
|
+
current_version = cursor.fetchone()[0] or 0
|
|
375
|
+
if current_version < SCHEMA_VERSION:
|
|
376
|
+
self._apply_migrations(conn, current_version)
|
|
377
|
+
|
|
378
|
+
self._initialized = True
|
|
379
|
+
|
|
380
|
+
def _apply_persistent_pragmas(self, conn: sqlite3.Connection) -> None:
|
|
381
|
+
"""Switch the database to WAL journal mode.
|
|
382
|
+
|
|
383
|
+
``journal_mode=WAL`` is persisted in the database file header,
|
|
384
|
+
so any subsequent process that opens the file inherits it.
|
|
385
|
+
Running it every ``initialize()`` is cheap and ensures a
|
|
386
|
+
freshly-restored rollback-mode backup gets upgraded back to WAL
|
|
387
|
+
on next start. ``synchronous`` is per-connection and set in
|
|
388
|
+
``connect()`` instead.
|
|
389
|
+
"""
|
|
390
|
+
try:
|
|
391
|
+
mode = conn.execute("PRAGMA journal_mode=WAL").fetchone()[0]
|
|
392
|
+
if mode.lower() != "wal":
|
|
393
|
+
# Read-only filesystem or otherwise unable to switch —
|
|
394
|
+
# warn but keep going. Lock contention will fall back to
|
|
395
|
+
# the connect()-level busy_timeout backstop.
|
|
396
|
+
logger.warning(
|
|
397
|
+
"DB %s: requested journal_mode=WAL but got %r — "
|
|
398
|
+
"concurrent readers/writers may block each other.",
|
|
399
|
+
self.db_path,
|
|
400
|
+
mode,
|
|
401
|
+
)
|
|
402
|
+
except sqlite3.OperationalError as e:
|
|
403
|
+
logger.warning("DB %s: pragma setup failed: %s", self.db_path, e)
|
|
404
|
+
|
|
405
|
+
def _reset_initialized(self) -> None:
|
|
406
|
+
"""Test-only: force the next initialize() to re-run."""
|
|
407
|
+
with self._init_lock:
|
|
408
|
+
self._initialized = False
|
|
409
|
+
|
|
410
|
+
def _apply_migrations(self, conn: sqlite3.Connection, from_version: int) -> None:
|
|
411
|
+
"""Apply database migrations."""
|
|
412
|
+
if from_version < 2:
|
|
413
|
+
# v2: Add serial_devices table, add alias/serial_device_id to serial_ports
|
|
414
|
+
_executescript_atomic(
|
|
415
|
+
conn,
|
|
416
|
+
"""
|
|
417
|
+
CREATE TABLE IF NOT EXISTS serial_devices (
|
|
418
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
419
|
+
name TEXT UNIQUE NOT NULL,
|
|
420
|
+
usb_path TEXT UNIQUE NOT NULL,
|
|
421
|
+
vendor TEXT,
|
|
422
|
+
model TEXT,
|
|
423
|
+
serial_number TEXT,
|
|
424
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
425
|
+
);
|
|
426
|
+
CREATE INDEX IF NOT EXISTS idx_serial_devices_usb_path
|
|
427
|
+
ON serial_devices(usb_path);
|
|
428
|
+
""",
|
|
429
|
+
)
|
|
430
|
+
# ALTER TABLE cannot add FK constraints in SQLite, but the column works fine
|
|
431
|
+
try:
|
|
432
|
+
conn.execute("ALTER TABLE serial_ports ADD COLUMN alias TEXT")
|
|
433
|
+
except sqlite3.OperationalError:
|
|
434
|
+
pass # Column already exists
|
|
435
|
+
try:
|
|
436
|
+
conn.execute(
|
|
437
|
+
"ALTER TABLE serial_ports ADD COLUMN serial_device_id INTEGER"
|
|
438
|
+
)
|
|
439
|
+
except sqlite3.OperationalError:
|
|
440
|
+
pass # Column already exists
|
|
441
|
+
try:
|
|
442
|
+
conn.execute(
|
|
443
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_serial_ports_alias "
|
|
444
|
+
"ON serial_ports(alias) WHERE alias IS NOT NULL"
|
|
445
|
+
)
|
|
446
|
+
except sqlite3.OperationalError:
|
|
447
|
+
pass # Index already exists
|
|
448
|
+
|
|
449
|
+
if from_version < 3:
|
|
450
|
+
# v3: Add SDWire device and assignment tables
|
|
451
|
+
_executescript_atomic(
|
|
452
|
+
conn,
|
|
453
|
+
"""
|
|
454
|
+
CREATE TABLE IF NOT EXISTS sdwire_devices (
|
|
455
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
456
|
+
name TEXT UNIQUE NOT NULL,
|
|
457
|
+
serial_number TEXT UNIQUE NOT NULL,
|
|
458
|
+
device_type TEXT NOT NULL DEFAULT 'sdwirec',
|
|
459
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
460
|
+
);
|
|
461
|
+
CREATE TABLE IF NOT EXISTS sdwire_assignments (
|
|
462
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
463
|
+
sbc_id INTEGER UNIQUE NOT NULL,
|
|
464
|
+
sdwire_device_id INTEGER NOT NULL,
|
|
465
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
466
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
|
|
467
|
+
FOREIGN KEY (sdwire_device_id) REFERENCES sdwire_devices(id)
|
|
468
|
+
ON DELETE CASCADE
|
|
469
|
+
);
|
|
470
|
+
CREATE INDEX IF NOT EXISTS idx_sdwire_assignments_sbc
|
|
471
|
+
ON sdwire_assignments(sbc_id);
|
|
472
|
+
""",
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if from_version < 4:
|
|
476
|
+
# v4: Hardware claims (exclusive access coordination)
|
|
477
|
+
_executescript_atomic(
|
|
478
|
+
conn,
|
|
479
|
+
"""
|
|
480
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
481
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
482
|
+
sbc_id INTEGER NOT NULL,
|
|
483
|
+
agent_name TEXT NOT NULL,
|
|
484
|
+
session_id TEXT NOT NULL,
|
|
485
|
+
session_kind TEXT NOT NULL,
|
|
486
|
+
reason TEXT NOT NULL,
|
|
487
|
+
context_json TEXT,
|
|
488
|
+
acquired_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
489
|
+
duration_seconds INTEGER NOT NULL,
|
|
490
|
+
last_activity TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
491
|
+
expires_at TIMESTAMP NOT NULL,
|
|
492
|
+
renewal_count INTEGER NOT NULL DEFAULT 0,
|
|
493
|
+
released_at TIMESTAMP,
|
|
494
|
+
release_reason TEXT,
|
|
495
|
+
released_by TEXT,
|
|
496
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE
|
|
497
|
+
);
|
|
498
|
+
CREATE TABLE IF NOT EXISTS claim_requests (
|
|
499
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
500
|
+
claim_id INTEGER NOT NULL,
|
|
501
|
+
requested_by TEXT NOT NULL,
|
|
502
|
+
reason TEXT NOT NULL,
|
|
503
|
+
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
504
|
+
acknowledged INTEGER NOT NULL DEFAULT 0,
|
|
505
|
+
FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE
|
|
506
|
+
);
|
|
507
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_active_sbc
|
|
508
|
+
ON claims(sbc_id) WHERE released_at IS NULL;
|
|
509
|
+
CREATE INDEX IF NOT EXISTS idx_claims_session
|
|
510
|
+
ON claims(session_id) WHERE released_at IS NULL;
|
|
511
|
+
CREATE INDEX IF NOT EXISTS idx_claims_agent
|
|
512
|
+
ON claims(agent_name) WHERE released_at IS NULL;
|
|
513
|
+
CREATE INDEX IF NOT EXISTS idx_claims_expiry
|
|
514
|
+
ON claims(expires_at) WHERE released_at IS NULL;
|
|
515
|
+
CREATE INDEX IF NOT EXISTS idx_claim_requests_claim
|
|
516
|
+
ON claim_requests(claim_id);
|
|
517
|
+
""",
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if from_version < 5:
|
|
521
|
+
# v5: Activity stream — extend audit_log with actor/source/result/claim_id
|
|
522
|
+
_executescript_atomic(
|
|
523
|
+
conn,
|
|
524
|
+
"""
|
|
525
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
526
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
527
|
+
action TEXT NOT NULL,
|
|
528
|
+
entity_type TEXT NOT NULL,
|
|
529
|
+
entity_id INTEGER,
|
|
530
|
+
entity_name TEXT,
|
|
531
|
+
details TEXT,
|
|
532
|
+
logged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
533
|
+
actor TEXT NOT NULL DEFAULT 'internal',
|
|
534
|
+
source TEXT NOT NULL DEFAULT 'internal',
|
|
535
|
+
result TEXT NOT NULL DEFAULT 'ok',
|
|
536
|
+
claim_id INTEGER REFERENCES claims(id)
|
|
537
|
+
);
|
|
538
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_entity
|
|
539
|
+
ON audit_log(entity_type, entity_id);
|
|
540
|
+
""",
|
|
541
|
+
)
|
|
542
|
+
for column_sql in (
|
|
543
|
+
"ALTER TABLE audit_log ADD COLUMN actor "
|
|
544
|
+
"TEXT NOT NULL DEFAULT 'internal'",
|
|
545
|
+
"ALTER TABLE audit_log ADD COLUMN source "
|
|
546
|
+
"TEXT NOT NULL DEFAULT 'internal'",
|
|
547
|
+
"ALTER TABLE audit_log ADD COLUMN result TEXT NOT NULL DEFAULT 'ok'",
|
|
548
|
+
"ALTER TABLE audit_log ADD COLUMN claim_id "
|
|
549
|
+
"INTEGER REFERENCES claims(id)",
|
|
550
|
+
):
|
|
551
|
+
try:
|
|
552
|
+
conn.execute(column_sql)
|
|
553
|
+
except sqlite3.OperationalError:
|
|
554
|
+
pass # Column already exists
|
|
555
|
+
_executescript_atomic(
|
|
556
|
+
conn,
|
|
557
|
+
"""
|
|
558
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_logged_at
|
|
559
|
+
ON audit_log(logged_at DESC);
|
|
560
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_actor
|
|
561
|
+
ON audit_log(actor, logged_at DESC);
|
|
562
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_source
|
|
563
|
+
ON audit_log(source, logged_at DESC);
|
|
564
|
+
""",
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
if from_version < 6:
|
|
568
|
+
# v6: cache the daemon's most recent power observation on each
|
|
569
|
+
# SBC so `labctl status --fast` can render without live probes.
|
|
570
|
+
for column_sql in (
|
|
571
|
+
"ALTER TABLE sbcs ADD COLUMN last_power_state TEXT",
|
|
572
|
+
"ALTER TABLE sbcs ADD COLUMN last_power_at TIMESTAMP",
|
|
573
|
+
):
|
|
574
|
+
try:
|
|
575
|
+
conn.execute(column_sql)
|
|
576
|
+
except sqlite3.OperationalError:
|
|
577
|
+
pass # column already exists
|
|
578
|
+
|
|
579
|
+
if from_version < 7:
|
|
580
|
+
# v7: per-SBC power-cycle delay (used as both default and floor).
|
|
581
|
+
try:
|
|
582
|
+
conn.execute(
|
|
583
|
+
"ALTER TABLE sbcs ADD COLUMN power_cycle_delay_seconds REAL"
|
|
584
|
+
)
|
|
585
|
+
except sqlite3.OperationalError:
|
|
586
|
+
pass # column already exists
|
|
587
|
+
|
|
588
|
+
if from_version < 8:
|
|
589
|
+
# v8: actuators, actuator_channels, bindings — first-class
|
|
590
|
+
# support for USB relays and per-target purpose bindings.
|
|
591
|
+
_executescript_atomic(
|
|
592
|
+
conn,
|
|
593
|
+
"""
|
|
594
|
+
CREATE TABLE IF NOT EXISTS actuators (
|
|
595
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
596
|
+
name TEXT UNIQUE NOT NULL,
|
|
597
|
+
kind TEXT NOT NULL DEFAULT 'relay',
|
|
598
|
+
driver TEXT NOT NULL,
|
|
599
|
+
device_path TEXT,
|
|
600
|
+
vid TEXT,
|
|
601
|
+
pid TEXT,
|
|
602
|
+
serial_no TEXT,
|
|
603
|
+
last_probe_at TIMESTAMP,
|
|
604
|
+
last_probe_result TEXT,
|
|
605
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
606
|
+
);
|
|
607
|
+
CREATE TABLE IF NOT EXISTS actuator_channels (
|
|
608
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
609
|
+
actuator_id INTEGER NOT NULL,
|
|
610
|
+
channel_index INTEGER NOT NULL,
|
|
611
|
+
label TEXT,
|
|
612
|
+
default_state TEXT NOT NULL DEFAULT 'open'
|
|
613
|
+
CHECK(default_state IN ('open','closed')),
|
|
614
|
+
last_state TEXT
|
|
615
|
+
CHECK(last_state IN ('open','closed') OR last_state IS NULL),
|
|
616
|
+
last_changed_at TIMESTAMP,
|
|
617
|
+
cycle_count INTEGER NOT NULL DEFAULT 0,
|
|
618
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
619
|
+
FOREIGN KEY (actuator_id) REFERENCES actuators(id)
|
|
620
|
+
ON DELETE CASCADE,
|
|
621
|
+
UNIQUE (actuator_id, channel_index)
|
|
622
|
+
);
|
|
623
|
+
CREATE TABLE IF NOT EXISTS bindings (
|
|
624
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
625
|
+
sbc_id INTEGER NOT NULL,
|
|
626
|
+
purpose TEXT NOT NULL,
|
|
627
|
+
actuator_channel_id INTEGER NOT NULL,
|
|
628
|
+
shape_mode TEXT NOT NULL
|
|
629
|
+
CHECK(shape_mode IN ('latch','momentary')),
|
|
630
|
+
shape_active TEXT NOT NULL
|
|
631
|
+
CHECK(shape_active IN ('closed','open')),
|
|
632
|
+
momentary_pulse_ms INTEGER,
|
|
633
|
+
sample_phase TEXT NOT NULL DEFAULT 'none'
|
|
634
|
+
CHECK(sample_phase IN ('pre_power','post_power','none')),
|
|
635
|
+
desired_state TEXT NOT NULL DEFAULT 'released'
|
|
636
|
+
CHECK(desired_state IN ('asserted','released','following_power')),
|
|
637
|
+
notes TEXT,
|
|
638
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
639
|
+
FOREIGN KEY (sbc_id) REFERENCES sbcs(id) ON DELETE CASCADE,
|
|
640
|
+
FOREIGN KEY (actuator_channel_id)
|
|
641
|
+
REFERENCES actuator_channels(id) ON DELETE CASCADE,
|
|
642
|
+
UNIQUE (sbc_id, purpose),
|
|
643
|
+
UNIQUE (actuator_channel_id)
|
|
644
|
+
);
|
|
645
|
+
CREATE INDEX IF NOT EXISTS idx_actuator_channels_actuator
|
|
646
|
+
ON actuator_channels(actuator_id);
|
|
647
|
+
CREATE INDEX IF NOT EXISTS idx_bindings_sbc ON bindings(sbc_id);
|
|
648
|
+
CREATE INDEX IF NOT EXISTS idx_bindings_actuator_channel
|
|
649
|
+
ON bindings(actuator_channel_id);
|
|
650
|
+
""", # noqa: E501
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if from_version < 9:
|
|
654
|
+
# v9: claims.owner — the authenticated labctl username, so one
|
|
655
|
+
# user is one claimant across MCP, REST and server restarts.
|
|
656
|
+
try:
|
|
657
|
+
conn.execute("ALTER TABLE claims ADD COLUMN owner TEXT")
|
|
658
|
+
except sqlite3.OperationalError:
|
|
659
|
+
pass # column already exists
|
|
660
|
+
# Authenticated HTTP sessions already ended in ":<username>"
|
|
661
|
+
# ("mcp-stdio:<pid>-<epoch>:<user>" before v9): backfill the
|
|
662
|
+
# owner of open claims so their users still hold them after
|
|
663
|
+
# the upgrade restarts the server.
|
|
664
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(claims)")}
|
|
665
|
+
rows = (
|
|
666
|
+
conn.execute(
|
|
667
|
+
"SELECT id, session_id FROM claims"
|
|
668
|
+
" WHERE released_at IS NULL AND owner IS NULL"
|
|
669
|
+
" AND session_id LIKE 'mcp-%:%:%'"
|
|
670
|
+
).fetchall()
|
|
671
|
+
if {"session_id", "released_at"} <= cols
|
|
672
|
+
else []
|
|
673
|
+
)
|
|
674
|
+
for claim_id, session_id in rows:
|
|
675
|
+
user = session_id.split(":", 2)[2]
|
|
676
|
+
if user:
|
|
677
|
+
conn.execute(
|
|
678
|
+
"UPDATE claims SET owner = ? WHERE id = ?", (user, claim_id)
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
if from_version < 11:
|
|
682
|
+
# v10: images uploaded over MCP HTTP and who uploaded them.
|
|
683
|
+
# v11: a pre-release v10, created by test runs that reached a
|
|
684
|
+
# lab host's real database, had an images table without
|
|
685
|
+
# file_id (uploads then failed with 500): add it. Rows recorded
|
|
686
|
+
# without one count as unverified (operator-only), as a
|
|
687
|
+
# mismatch would. Both steps are no-ops on a released v10.
|
|
688
|
+
conn.execute("""
|
|
689
|
+
CREATE TABLE IF NOT EXISTS images (
|
|
690
|
+
name TEXT PRIMARY KEY,
|
|
691
|
+
uploaded_by TEXT,
|
|
692
|
+
size INTEGER NOT NULL,
|
|
693
|
+
sha256 TEXT NOT NULL,
|
|
694
|
+
file_id TEXT,
|
|
695
|
+
uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
696
|
+
)
|
|
697
|
+
""")
|
|
698
|
+
try:
|
|
699
|
+
conn.execute("ALTER TABLE images ADD COLUMN file_id TEXT")
|
|
700
|
+
except sqlite3.OperationalError as e:
|
|
701
|
+
if "duplicate column" not in str(e):
|
|
702
|
+
raise
|
|
703
|
+
|
|
704
|
+
if from_version < 12:
|
|
705
|
+
# v12: the monitor prunes status history by age
|
|
706
|
+
# (health.status_retention_days). A hand-made database may
|
|
707
|
+
# lack the table; there is nothing to index then.
|
|
708
|
+
has_table = conn.execute(
|
|
709
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table'"
|
|
710
|
+
" AND name = 'status_log'"
|
|
711
|
+
).fetchone()
|
|
712
|
+
if has_table:
|
|
713
|
+
conn.execute(
|
|
714
|
+
"CREATE INDEX IF NOT EXISTS idx_status_log_logged_at"
|
|
715
|
+
" ON status_log(logged_at)"
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# OR IGNORE: tolerate a concurrent process that has already
|
|
719
|
+
# bumped schema_version to the current value during a race.
|
|
720
|
+
conn.execute(
|
|
721
|
+
"INSERT OR IGNORE INTO schema_version (version) VALUES (?)",
|
|
722
|
+
(SCHEMA_VERSION,),
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
@contextmanager
|
|
726
|
+
def connect(self) -> Generator[sqlite3.Connection, None, None]:
|
|
727
|
+
"""
|
|
728
|
+
Get a database connection as a context manager.
|
|
729
|
+
|
|
730
|
+
The ``timeout=`` arg makes ``sqlite3`` poll the busy lock for
|
|
731
|
+
up to ``self.timeout_seconds`` before raising
|
|
732
|
+
``OperationalError``; the per-connection ``busy_timeout`` pragma
|
|
733
|
+
is a belt-and-suspenders for sqlite3 stdlib versions where the
|
|
734
|
+
``timeout=`` plumbing has historically been buggy.
|
|
735
|
+
|
|
736
|
+
Yields:
|
|
737
|
+
SQLite connection with row factory set to sqlite3.Row.
|
|
738
|
+
"""
|
|
739
|
+
conn = sqlite3.connect(self.db_path, timeout=self.timeout_seconds)
|
|
740
|
+
conn.row_factory = sqlite3.Row
|
|
741
|
+
# Best-effort per-connection tuning. A caller without OS-level
|
|
742
|
+
# write permission on the DB file opens the connection just
|
|
743
|
+
# fine for reads, but SQLite refuses to execute write-side
|
|
744
|
+
# pragmas on that connection ("attempt to write a readonly
|
|
745
|
+
# database"). We log at debug and continue so unprivileged
|
|
746
|
+
# readers (callers outside the ``labctl`` group, ad-hoc CLI
|
|
747
|
+
# users, etc.) keep working after the WAL switch. Writable
|
|
748
|
+
# connections still get the full tuning.
|
|
749
|
+
for pragma in (
|
|
750
|
+
# Mirrors the connect() timeout; belt-and-suspenders for
|
|
751
|
+
# sqlite3 stdlib versions where `timeout=` plumbing has
|
|
752
|
+
# historically been buggy.
|
|
753
|
+
f"PRAGMA busy_timeout = {int(self.timeout_seconds * 1000)}",
|
|
754
|
+
# synchronous=NORMAL is per-connection (not persisted in
|
|
755
|
+
# the file like journal_mode), so it must be set on every
|
|
756
|
+
# connect for writers. Pairs with WAL for substantially
|
|
757
|
+
# faster commits at the cost of a few seconds of write
|
|
758
|
+
# loss on a hard power failure — acceptable for a lab DB.
|
|
759
|
+
"PRAGMA synchronous = NORMAL",
|
|
760
|
+
# FK enforcement is per-connection. Irrelevant for a
|
|
761
|
+
# read-only connection (no writes to enforce against).
|
|
762
|
+
"PRAGMA foreign_keys = ON",
|
|
763
|
+
):
|
|
764
|
+
try:
|
|
765
|
+
conn.execute(pragma)
|
|
766
|
+
except sqlite3.OperationalError as e:
|
|
767
|
+
logger.debug(
|
|
768
|
+
"connect(%s): pragma %r failed (read-only connection?): %s",
|
|
769
|
+
self.db_path,
|
|
770
|
+
pragma,
|
|
771
|
+
e,
|
|
772
|
+
)
|
|
773
|
+
try:
|
|
774
|
+
yield conn
|
|
775
|
+
conn.commit()
|
|
776
|
+
except Exception:
|
|
777
|
+
conn.rollback()
|
|
778
|
+
raise
|
|
779
|
+
finally:
|
|
780
|
+
conn.close()
|
|
781
|
+
|
|
782
|
+
def execute(
|
|
783
|
+
self,
|
|
784
|
+
sql: str,
|
|
785
|
+
params: tuple = (),
|
|
786
|
+
) -> list[sqlite3.Row]:
|
|
787
|
+
"""
|
|
788
|
+
Execute SQL and return all results.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
sql: SQL statement
|
|
792
|
+
params: Query parameters
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
List of result rows
|
|
796
|
+
"""
|
|
797
|
+
with self.connect() as conn:
|
|
798
|
+
cursor = conn.execute(sql, params)
|
|
799
|
+
return cursor.fetchall()
|
|
800
|
+
|
|
801
|
+
def execute_one(
|
|
802
|
+
self,
|
|
803
|
+
sql: str,
|
|
804
|
+
params: tuple = (),
|
|
805
|
+
) -> Optional[sqlite3.Row]:
|
|
806
|
+
"""
|
|
807
|
+
Execute SQL and return single result.
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
sql: SQL statement
|
|
811
|
+
params: Query parameters
|
|
812
|
+
|
|
813
|
+
Returns:
|
|
814
|
+
Single result row or None
|
|
815
|
+
"""
|
|
816
|
+
with self.connect() as conn:
|
|
817
|
+
cursor = conn.execute(sql, params)
|
|
818
|
+
return cursor.fetchone()
|
|
819
|
+
|
|
820
|
+
def execute_insert(
|
|
821
|
+
self,
|
|
822
|
+
sql: str,
|
|
823
|
+
params: tuple = (),
|
|
824
|
+
) -> int:
|
|
825
|
+
"""
|
|
826
|
+
Execute INSERT and return last row ID.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
sql: INSERT statement
|
|
830
|
+
params: Query parameters
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
ID of inserted row
|
|
834
|
+
"""
|
|
835
|
+
with self.connect() as conn:
|
|
836
|
+
cursor = conn.execute(sql, params)
|
|
837
|
+
return cursor.lastrowid
|
|
838
|
+
|
|
839
|
+
def execute_modify(
|
|
840
|
+
self,
|
|
841
|
+
sql: str,
|
|
842
|
+
params: tuple = (),
|
|
843
|
+
) -> int:
|
|
844
|
+
"""
|
|
845
|
+
Execute UPDATE/DELETE and return affected row count.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
sql: UPDATE or DELETE statement
|
|
849
|
+
params: Query parameters
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
Number of affected rows
|
|
853
|
+
"""
|
|
854
|
+
with self.connect() as conn:
|
|
855
|
+
cursor = conn.execute(sql, params)
|
|
856
|
+
return cursor.rowcount
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def get_database(
|
|
860
|
+
db_path: Path, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
|
861
|
+
) -> Database:
|
|
862
|
+
"""
|
|
863
|
+
Get an initialized database instance.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
db_path: Path to database file.
|
|
867
|
+
timeout_seconds: SQLite busy-wait. Usually wired from
|
|
868
|
+
``Config.database.timeout_seconds``; default 10s.
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
Initialized Database instance.
|
|
872
|
+
"""
|
|
873
|
+
db = Database(db_path, timeout_seconds=timeout_seconds)
|
|
874
|
+
db.initialize()
|
|
875
|
+
return db
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _executescript_atomic(conn: sqlite3.Connection, sql: str) -> None:
|
|
879
|
+
"""Run DDL in one explicit transaction to avoid slow statement commits."""
|
|
880
|
+
conn.executescript(f"BEGIN;\n{sql}\nCOMMIT;")
|