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/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
|