cctv-stack 0.3.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.
- cctv/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/uploader.py
ADDED
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
"""The uploader service — a canonical clip becomes a durable cloud object, exactly once.
|
|
2
|
+
|
|
3
|
+
```text
|
|
4
|
+
claim (lease) ──→ pre-flight ──→ PUT ──→ verify ──→ record
|
|
5
|
+
│ │ │ │ │
|
|
6
|
+
└── commit ──────┘ └── no transaction is ever held across the network ──┘
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The service is a small pool of worker loops, each owning its own SQLite connection, each
|
|
10
|
+
independently running the five steps above. There is no scheduler thread and no queue in
|
|
11
|
+
memory: **the database is the queue**, and the atomic claim (contract §3.3) is the only
|
|
12
|
+
coordination. That is what makes any number of workers — in this process or another —
|
|
13
|
+
correct rather than merely concurrent.
|
|
14
|
+
|
|
15
|
+
Four rules from the design carry real weight here:
|
|
16
|
+
|
|
17
|
+
* **Nothing slow happens inside a transaction.** Claim, commit, upload, record. A PUT
|
|
18
|
+
holding the write lock would stall the clip-worker's writes for the duration of a 50 MB
|
|
19
|
+
upload.
|
|
20
|
+
* **Never claim a verification that was not performed.** `verified_by="none"` is reported as
|
|
21
|
+
`DEGRADED` (`UPLOAD_UNVERIFIED`) rather than being rounded up to success, because
|
|
22
|
+
`UPLOADED` is what tells cleanup a local file may be deleted.
|
|
23
|
+
* **Never delete, move or modify a file under `clips/`.** The compose mount makes that
|
|
24
|
+
structural ([INV-1](../../../docs/architecture/invariants.md)); this module does not even
|
|
25
|
+
have a code path that could.
|
|
26
|
+
* **An unconfigured deployment is `DISABLED`, not broken.** With no bucket set the service
|
|
27
|
+
starts, reports `DISABLED`, and does no work, so a local-only install is a supported
|
|
28
|
+
choice rather than a restart loop ([INV-6](../../../docs/architecture/invariants.md)).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import math
|
|
37
|
+
import os
|
|
38
|
+
import random
|
|
39
|
+
import re
|
|
40
|
+
import signal
|
|
41
|
+
import socket
|
|
42
|
+
import sqlite3
|
|
43
|
+
import sys
|
|
44
|
+
import threading
|
|
45
|
+
import time
|
|
46
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
47
|
+
from contextlib import contextmanager
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from . import __version__, db, health, storage, timeutil
|
|
53
|
+
from .config import CamerasConfig, ConfigError, load_cameras_config
|
|
54
|
+
from .logging import configure_logging
|
|
55
|
+
from .storage import StorageAdapter, StorageError
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"COMPONENT",
|
|
59
|
+
"EXIT_CONFIG",
|
|
60
|
+
"EXIT_UNAVAILABLE",
|
|
61
|
+
"PERMANENT",
|
|
62
|
+
"RETRY",
|
|
63
|
+
"UPLOADED",
|
|
64
|
+
"UploadOutcome",
|
|
65
|
+
"UploadService",
|
|
66
|
+
"UploadSettings",
|
|
67
|
+
"Uploader",
|
|
68
|
+
"UploaderHealth",
|
|
69
|
+
"backoff_delay",
|
|
70
|
+
"build_adapter",
|
|
71
|
+
"main",
|
|
72
|
+
"object_metadata",
|
|
73
|
+
"validate_key",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
COMPONENT = "uploader"
|
|
79
|
+
|
|
80
|
+
#: `EX_CONFIG` / `EX_TEMPFAIL` from `sysexits.h`, per LLD 00 §3. A bad config never becomes
|
|
81
|
+
#: good, so restarting cannot help; a briefly unavailable dependency might.
|
|
82
|
+
EXIT_CONFIG = 78
|
|
83
|
+
EXIT_UNAVAILABLE = 75
|
|
84
|
+
EXIT_OK = 0
|
|
85
|
+
|
|
86
|
+
DEFAULT_HEALTH_PORT = 8082
|
|
87
|
+
DEFAULT_SCHEDULER_TICK_S = 5.0
|
|
88
|
+
DEFAULT_LEASE_SECONDS = 900.0
|
|
89
|
+
DEFAULT_BACKOFF_BASE_S = 15.0
|
|
90
|
+
DEFAULT_BACKOFF_MAX_S = 3600.0
|
|
91
|
+
DEFAULT_QUOTA_BACKOFF_MAX_S = 21600.0
|
|
92
|
+
DEFAULT_UPLOAD_CONCURRENCY = 4
|
|
93
|
+
DEFAULT_STALE_UPLOAD_WARN_S = 1800.0
|
|
94
|
+
DEFAULT_BACKLOG_WARN_S = 1800.0
|
|
95
|
+
DEFAULT_BACKLOG_CRITICAL_S = 21600.0
|
|
96
|
+
|
|
97
|
+
#: How often the bulk policy pass runs, and how often the backlog is logged.
|
|
98
|
+
POLICY_INTERVAL_S = 60.0
|
|
99
|
+
SUMMARY_INTERVAL_S = 60.0
|
|
100
|
+
|
|
101
|
+
#: Seconds a storage `healthcheck()` result is reused. A `HEAD` per scrape would turn a
|
|
102
|
+
#: 2-second monitoring interval into a stream of requests against a provider that bills for
|
|
103
|
+
#: nothing but will rate-limit us.
|
|
104
|
+
DEFAULT_HEALTHCHECK_CACHE_S = 30.0
|
|
105
|
+
|
|
106
|
+
SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
107
|
+
|
|
108
|
+
#: Key rules from cloud-object-layout §7. A leading slash, a doubled slash, a backslash, a
|
|
109
|
+
#: parent reference or a control character are all refused rather than normalised: a key we
|
|
110
|
+
#: silently rewrote is a key that no longer matches the local path, and key equality is the
|
|
111
|
+
#: property restore and round-trip verification depend on.
|
|
112
|
+
_BAD_KEY_RE = re.compile(r"^/|//|\\|\.\.|[\x00-\x1f]")
|
|
113
|
+
MAX_KEY_BYTES = 1024
|
|
114
|
+
|
|
115
|
+
UPLOADED = "uploaded"
|
|
116
|
+
RETRY = "retry"
|
|
117
|
+
PERMANENT = "permanent"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_key(key: str) -> str:
|
|
121
|
+
"""Validate an object key, or raise. Returns the key unchanged when it is usable."""
|
|
122
|
+
if not key:
|
|
123
|
+
raise ValueError("A cloud key is required.")
|
|
124
|
+
if len(key.encode("utf-8")) > MAX_KEY_BYTES:
|
|
125
|
+
raise ValueError(f"Cloud key exceeds {MAX_KEY_BYTES} bytes.")
|
|
126
|
+
if _BAD_KEY_RE.search(key):
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"Cloud key {key!r} contains a leading or doubled slash, a backslash, a parent "
|
|
129
|
+
f"reference, or a control character (cloud-object-layout §7)."
|
|
130
|
+
)
|
|
131
|
+
return key
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------------------
|
|
135
|
+
# Settings
|
|
136
|
+
# ---------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class UploadSettings:
|
|
141
|
+
"""Every input is an environment variable, so there are no CLI arguments to get wrong."""
|
|
142
|
+
|
|
143
|
+
cameras_path: Path
|
|
144
|
+
data_dir: Path
|
|
145
|
+
db_path: Path
|
|
146
|
+
bucket: str | None
|
|
147
|
+
endpoint_url: str | None
|
|
148
|
+
region: str
|
|
149
|
+
key_id: str | None
|
|
150
|
+
application_key: str | None
|
|
151
|
+
health_host: str
|
|
152
|
+
health_port: int
|
|
153
|
+
internal_token: str | None
|
|
154
|
+
dry_run: bool
|
|
155
|
+
scheduler_tick_s: float
|
|
156
|
+
lease_seconds: float
|
|
157
|
+
max_attempts: int
|
|
158
|
+
backoff_base_s: float
|
|
159
|
+
backoff_max_s: float
|
|
160
|
+
quota_backoff_max_s: float
|
|
161
|
+
upload_concurrency: int
|
|
162
|
+
multipart_threshold_bytes: int
|
|
163
|
+
stale_upload_warn_s: float
|
|
164
|
+
backlog_warn_s: float
|
|
165
|
+
backlog_critical_s: float
|
|
166
|
+
log_level: str | None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def storage_configured(self) -> bool:
|
|
170
|
+
return bool(self.bucket and self.key_id and self.application_key)
|
|
171
|
+
|
|
172
|
+
def configuration_problem(self) -> str | None:
|
|
173
|
+
"""A single sentence naming what is wrong, or `None`.
|
|
174
|
+
|
|
175
|
+
A half-configured bucket is refused rather than guessed at: defaulting the key id or
|
|
176
|
+
the secret would produce an `UNHEALTHY` service whose error message says "denied"
|
|
177
|
+
when the real problem is a `.env` that was never finished.
|
|
178
|
+
"""
|
|
179
|
+
if not self.bucket:
|
|
180
|
+
return None # deliberately unconfigured; the service reports DISABLED
|
|
181
|
+
missing = [
|
|
182
|
+
name
|
|
183
|
+
for name, value in (
|
|
184
|
+
("CCTV_STORAGE_KEY_ID", self.key_id),
|
|
185
|
+
("CCTV_STORAGE_APPLICATION_KEY", self.application_key),
|
|
186
|
+
)
|
|
187
|
+
if not value
|
|
188
|
+
]
|
|
189
|
+
if missing:
|
|
190
|
+
return (
|
|
191
|
+
f"CCTV_STORAGE_BUCKET is set but {', '.join(missing)} is not. Set the "
|
|
192
|
+
f"credentials, or unset CCTV_STORAGE_BUCKET to run without cloud backup."
|
|
193
|
+
)
|
|
194
|
+
if self.upload_concurrency < 1:
|
|
195
|
+
return "CCTV_UPLOAD_CONCURRENCY must be at least 1."
|
|
196
|
+
if self.scheduler_tick_s <= 0 or self.lease_seconds <= 0:
|
|
197
|
+
return "CCTV_UPLOAD_TICK_S and CCTV_UPLOAD_LEASE_S must be positive."
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def from_environ(cls, environ: Mapping[str, str] | None = None) -> UploadSettings:
|
|
202
|
+
env = os.environ if environ is None else environ
|
|
203
|
+
|
|
204
|
+
def pick(*names: str) -> str | None:
|
|
205
|
+
for name in names:
|
|
206
|
+
value = (env.get(name) or "").strip()
|
|
207
|
+
if value:
|
|
208
|
+
return value
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def number(name: str, default: float) -> float:
|
|
212
|
+
raw = (env.get(name) or "").strip()
|
|
213
|
+
if not raw:
|
|
214
|
+
return default
|
|
215
|
+
try:
|
|
216
|
+
return float(raw)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise ConfigError(f"{name}={raw!r} is not a number.") from exc
|
|
219
|
+
|
|
220
|
+
config_dir = Path(env.get("CCTV_CONFIG_DIR", "/config"))
|
|
221
|
+
data_dir = Path(env.get("CCTV_DATA_DIR", "/data"))
|
|
222
|
+
region = pick("CCTV_STORAGE_REGION", "B2_REGION") or "us-west-004"
|
|
223
|
+
|
|
224
|
+
return cls(
|
|
225
|
+
cameras_path=Path(env.get("CCTV_CAMERAS", str(config_dir / "cameras.yml"))),
|
|
226
|
+
data_dir=data_dir,
|
|
227
|
+
db_path=Path(env.get("CCTV_DB", str(data_dir / "cctv.db"))),
|
|
228
|
+
bucket=pick("CCTV_STORAGE_BUCKET", "B2_BUCKET"),
|
|
229
|
+
endpoint_url=pick("CCTV_STORAGE_ENDPOINT_URL", "B2_ENDPOINT_URL")
|
|
230
|
+
or f"https://s3.{region}.backblazeb2.com",
|
|
231
|
+
region=region,
|
|
232
|
+
key_id=pick("CCTV_STORAGE_KEY_ID", "B2_KEY_ID", "B2_APPLICATION_KEY_ID"),
|
|
233
|
+
application_key=pick("CCTV_STORAGE_APPLICATION_KEY", "B2_APPLICATION_KEY"),
|
|
234
|
+
health_host=(env.get("CCTV_HEALTH_HOST") or "0.0.0.0").strip(),
|
|
235
|
+
health_port=int(number("CCTV_UPLOADER_HEALTH_PORT", DEFAULT_HEALTH_PORT)),
|
|
236
|
+
internal_token=(env.get("INTERNAL_API_TOKEN") or "").strip() or None,
|
|
237
|
+
dry_run=(env.get("DRY_RUN") or "").strip().lower() in {"1", "true", "yes"},
|
|
238
|
+
scheduler_tick_s=number("CCTV_UPLOAD_TICK_S", DEFAULT_SCHEDULER_TICK_S),
|
|
239
|
+
lease_seconds=number("CCTV_UPLOAD_LEASE_S", DEFAULT_LEASE_SECONDS),
|
|
240
|
+
max_attempts=int(number("CCTV_UPLOAD_MAX_ATTEMPTS", db.MAX_ATTEMPTS)),
|
|
241
|
+
backoff_base_s=number("CCTV_UPLOAD_BACKOFF_BASE_S", DEFAULT_BACKOFF_BASE_S),
|
|
242
|
+
backoff_max_s=number("CCTV_UPLOAD_BACKOFF_MAX_S", DEFAULT_BACKOFF_MAX_S),
|
|
243
|
+
quota_backoff_max_s=number(
|
|
244
|
+
"CCTV_UPLOAD_QUOTA_BACKOFF_MAX_S", DEFAULT_QUOTA_BACKOFF_MAX_S
|
|
245
|
+
),
|
|
246
|
+
upload_concurrency=int(number("CCTV_UPLOAD_CONCURRENCY", DEFAULT_UPLOAD_CONCURRENCY)),
|
|
247
|
+
multipart_threshold_bytes=int(
|
|
248
|
+
number(
|
|
249
|
+
"CCTV_UPLOAD_MULTIPART_THRESHOLD_BYTES",
|
|
250
|
+
storage.DEFAULT_MULTIPART_THRESHOLD_BYTES,
|
|
251
|
+
)
|
|
252
|
+
),
|
|
253
|
+
stale_upload_warn_s=number("CCTV_UPLOAD_STALE_WARN_S", DEFAULT_STALE_UPLOAD_WARN_S),
|
|
254
|
+
backlog_warn_s=number("CCTV_UPLOAD_BACKLOG_WARN_S", DEFAULT_BACKLOG_WARN_S),
|
|
255
|
+
backlog_critical_s=number("CCTV_UPLOAD_BACKLOG_CRITICAL_S", DEFAULT_BACKLOG_CRITICAL_S),
|
|
256
|
+
log_level=env.get("CCTV_LOG_LEVEL") or None,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------------------
|
|
261
|
+
# Backoff (lld/04 §5)
|
|
262
|
+
# ---------------------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def backoff_delay(
|
|
266
|
+
attempt: int,
|
|
267
|
+
*,
|
|
268
|
+
base_s: float,
|
|
269
|
+
max_s: float,
|
|
270
|
+
rng: random.Random | None = None,
|
|
271
|
+
) -> float:
|
|
272
|
+
"""`min(base * 2^(attempt-1), max)` with ±20 % jitter.
|
|
273
|
+
|
|
274
|
+
`attempt` is 1-based and is the value the claim already incremented, so the first failure
|
|
275
|
+
waits about `base_s`.
|
|
276
|
+
|
|
277
|
+
**The jitter is not decorative.** Without it, a provider outage that affects 200 clips
|
|
278
|
+
produces 200 identically-scheduled retries — a self-inflicted thundering herd against an
|
|
279
|
+
endpoint that has just told us it is unwell.
|
|
280
|
+
"""
|
|
281
|
+
if attempt < 1:
|
|
282
|
+
raise ValueError("attempt is 1-based; it is the value the claim incremented.")
|
|
283
|
+
generator = rng if rng is not None else random
|
|
284
|
+
ceiling = max(0.0, max_s)
|
|
285
|
+
raw = min(base_s * math.pow(2, attempt - 1), ceiling)
|
|
286
|
+
return raw * generator.uniform(0.8, 1.2)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------------------
|
|
290
|
+
# Object metadata (cloud-object-layout §3.2)
|
|
291
|
+
# ---------------------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def object_metadata(
|
|
295
|
+
row: sqlite3.Row, *, location: str, version: str = __version__
|
|
296
|
+
) -> dict[str, str]:
|
|
297
|
+
"""The self-describing metadata attached to every object.
|
|
298
|
+
|
|
299
|
+
This is the recovery path of last resort: with a bucket listing and these keys alone,
|
|
300
|
+
most of `clips` can be reconstructed after a total loss of `state.db`. It deliberately
|
|
301
|
+
carries no credential and nothing derived from one ([INV-2]).
|
|
302
|
+
|
|
303
|
+
Values are strings and ASCII-only, because S3 user metadata is ASCII and a provider that
|
|
304
|
+
accepts more is not one we should depend on.
|
|
305
|
+
"""
|
|
306
|
+
metadata: dict[str, str] = {
|
|
307
|
+
"camera": str(row["camera"]),
|
|
308
|
+
"location": location,
|
|
309
|
+
"start-time": f"{float(row['start_time']):.6f}",
|
|
310
|
+
"cctv-version": version,
|
|
311
|
+
"review-id": str(row["review_id"]),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
event_ids = _json_list(row["event_ids"])
|
|
315
|
+
metadata["event-id"] = event_ids[0] if event_ids else str(row["review_id"])
|
|
316
|
+
|
|
317
|
+
if row["end_time"] is not None:
|
|
318
|
+
metadata["end-time"] = f"{float(row['end_time']):.6f}"
|
|
319
|
+
if row["duration_s"] is not None:
|
|
320
|
+
metadata["duration-s"] = f"{float(row['duration_s']):.3f}"
|
|
321
|
+
if row["label"]:
|
|
322
|
+
metadata["label"] = str(row["label"])
|
|
323
|
+
|
|
324
|
+
zones = _json_list(row["zones"])
|
|
325
|
+
if zones:
|
|
326
|
+
metadata["zones"] = ",".join(zones)
|
|
327
|
+
|
|
328
|
+
return metadata
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _json_list(raw: object) -> list[str]:
|
|
332
|
+
if raw is None:
|
|
333
|
+
return []
|
|
334
|
+
if isinstance(raw, (list, tuple)):
|
|
335
|
+
return [str(item) for item in raw]
|
|
336
|
+
try:
|
|
337
|
+
parsed = json.loads(str(raw))
|
|
338
|
+
except json.JSONDecodeError:
|
|
339
|
+
return []
|
|
340
|
+
return [str(item) for item in parsed] if isinstance(parsed, list) else []
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------------------------------
|
|
344
|
+
# One clip
|
|
345
|
+
# ---------------------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass(frozen=True, slots=True)
|
|
349
|
+
class UploadOutcome:
|
|
350
|
+
"""What happened to one clip. Returned rather than inferred from the database."""
|
|
351
|
+
|
|
352
|
+
clip_id: int
|
|
353
|
+
status: str
|
|
354
|
+
camera: str
|
|
355
|
+
path: str
|
|
356
|
+
attempts: int = 0
|
|
357
|
+
cloud_key: str | None = None
|
|
358
|
+
bytes_sent: int = 0
|
|
359
|
+
duration_ms: int = 0
|
|
360
|
+
verified_by: str | None = None
|
|
361
|
+
error_class: str | None = None
|
|
362
|
+
reason: str | None = None
|
|
363
|
+
skipped: bool = False
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def ok(self) -> bool:
|
|
367
|
+
return self.status == UPLOADED
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class Uploader:
|
|
371
|
+
"""Claims one clip and does the work. Stateless apart from counters.
|
|
372
|
+
|
|
373
|
+
Instantiated once and called from several worker threads; every method takes the
|
|
374
|
+
connection it should use, which is what keeps the concurrency story trivial.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
def __init__(
|
|
378
|
+
self,
|
|
379
|
+
*,
|
|
380
|
+
data_dir: Path,
|
|
381
|
+
location: str,
|
|
382
|
+
adapter: StorageAdapter | None,
|
|
383
|
+
settings: UploadSettings,
|
|
384
|
+
clock: Callable[[], float] = timeutil.now,
|
|
385
|
+
rng: random.Random | None = None,
|
|
386
|
+
) -> None:
|
|
387
|
+
self._data_dir = Path(data_dir)
|
|
388
|
+
self._location = location
|
|
389
|
+
self._adapter = adapter
|
|
390
|
+
self._settings = settings
|
|
391
|
+
self._clock = clock
|
|
392
|
+
self._rng = rng
|
|
393
|
+
self._lock = threading.Lock()
|
|
394
|
+
self._unverified = 0
|
|
395
|
+
self._uploaded = 0
|
|
396
|
+
self._failed_permanent = 0
|
|
397
|
+
|
|
398
|
+
# -- counters (read by the health checks) ------------------------------------------
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def unverified_uploads(self) -> int:
|
|
402
|
+
with self._lock:
|
|
403
|
+
return self._unverified
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def uploaded_uploads(self) -> int:
|
|
407
|
+
with self._lock:
|
|
408
|
+
return self._uploaded
|
|
409
|
+
|
|
410
|
+
# -- the loop ----------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def upload_one(self, conn: sqlite3.Connection, *, owner: str) -> UploadOutcome | None:
|
|
413
|
+
"""Claim and process the oldest eligible clip, or return `None` when idle."""
|
|
414
|
+
if self._adapter is None:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
row = db.claim_next_upload(
|
|
418
|
+
conn,
|
|
419
|
+
owner=owner,
|
|
420
|
+
lease_seconds=self._settings.lease_seconds,
|
|
421
|
+
cloud_key_prefix=f"{self._location}/",
|
|
422
|
+
now=self._clock(),
|
|
423
|
+
)
|
|
424
|
+
if row is None:
|
|
425
|
+
return None
|
|
426
|
+
return self._perform(conn, row)
|
|
427
|
+
|
|
428
|
+
def _perform(self, conn: sqlite3.Connection, row: sqlite3.Row) -> UploadOutcome:
|
|
429
|
+
"""Do the work, and turn *any* failure into a recorded state transition.
|
|
430
|
+
|
|
431
|
+
The broad `except` is deliberate and is the documented behaviour for an
|
|
432
|
+
unrecognised error (lld/04 §4): treat it as transient and retry, because the costs
|
|
433
|
+
are asymmetric — a bounded number of retries against a lost clip. The attempt
|
|
434
|
+
counter bounds it, so a genuine bug reaches `FAILED_PERMANENT` with an alert rather
|
|
435
|
+
than looping forever.
|
|
436
|
+
"""
|
|
437
|
+
started = time.perf_counter()
|
|
438
|
+
try:
|
|
439
|
+
return self._upload(conn, row, started=started)
|
|
440
|
+
except StorageError as exc:
|
|
441
|
+
return self._record_failure(conn, row, exc, started=started)
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
logger.exception(
|
|
444
|
+
"unexpected upload failure",
|
|
445
|
+
extra={"clip_id": int(row["id"]), "camera": row["camera"]},
|
|
446
|
+
)
|
|
447
|
+
classified = storage.TransientStorageError(
|
|
448
|
+
f"Unexpected {type(exc).__name__} during upload: {exc}"
|
|
449
|
+
)
|
|
450
|
+
return self._record_failure(conn, row, classified, started=started)
|
|
451
|
+
|
|
452
|
+
def _upload(
|
|
453
|
+
self, conn: sqlite3.Connection, row: sqlite3.Row, *, started: float
|
|
454
|
+
) -> UploadOutcome:
|
|
455
|
+
clip_id = int(row["id"])
|
|
456
|
+
attempt_no = int(row["attempts"])
|
|
457
|
+
target = self._data_dir / str(row["path"])
|
|
458
|
+
|
|
459
|
+
size_on_disk = _size_or_none(target)
|
|
460
|
+
if size_on_disk is None:
|
|
461
|
+
# The database says a clip exists and it does not. That is a real inconsistency,
|
|
462
|
+
# and it is loud on purpose — but the clip is not deleted and not retried.
|
|
463
|
+
with db.write_txn(conn):
|
|
464
|
+
db.record_event(
|
|
465
|
+
conn,
|
|
466
|
+
component=COMPONENT,
|
|
467
|
+
code="UPLOAD_LOCAL_FILE_MISSING",
|
|
468
|
+
severity="critical",
|
|
469
|
+
message=f"Clip {clip_id} has no local file at {row['path']}.",
|
|
470
|
+
detail={"clip_id": clip_id, "path": str(row["path"])},
|
|
471
|
+
now=self._clock(),
|
|
472
|
+
)
|
|
473
|
+
raise storage.InvalidRequestError(
|
|
474
|
+
f"The local file for clip {clip_id} is missing ({target}). Nothing was "
|
|
475
|
+
f"uploaded and nothing was deleted; the database and the disk disagree."
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
digest = str(row["sha256"] or "")
|
|
479
|
+
if not SHA256_HEX_RE.match(digest) or row["size_bytes"] != size_on_disk:
|
|
480
|
+
# Recompute rather than trust a digest we cannot use, and record it so the next
|
|
481
|
+
# attempt does not have to. This is the only place the uploader writes anything
|
|
482
|
+
# about a clip other than its upload state.
|
|
483
|
+
digest = _sha256(target)
|
|
484
|
+
db.set_clip_checksum(
|
|
485
|
+
conn,
|
|
486
|
+
clip_id=clip_id,
|
|
487
|
+
sha256=digest,
|
|
488
|
+
size_bytes=size_on_disk,
|
|
489
|
+
now=self._clock(),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
key = validate_key(str(row["cloud_key"] or ""))
|
|
493
|
+
metadata = object_metadata(row, location=self._location)
|
|
494
|
+
|
|
495
|
+
assert self._adapter is not None # guarded by upload_one
|
|
496
|
+
result = self._adapter.put_object(
|
|
497
|
+
key=key, path=target, metadata=metadata, checksum_sha256=digest
|
|
498
|
+
)
|
|
499
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
500
|
+
|
|
501
|
+
db.mark_uploaded(
|
|
502
|
+
conn,
|
|
503
|
+
clip_id=clip_id,
|
|
504
|
+
cloud_etag=result.etag,
|
|
505
|
+
cloud_size_bytes=result.size,
|
|
506
|
+
now=self._clock(),
|
|
507
|
+
)
|
|
508
|
+
db.finish_upload_attempt(
|
|
509
|
+
conn,
|
|
510
|
+
clip_id=clip_id,
|
|
511
|
+
attempt_no=attempt_no,
|
|
512
|
+
outcome="success",
|
|
513
|
+
bytes_sent=result.size,
|
|
514
|
+
duration_ms=duration_ms,
|
|
515
|
+
now=self._clock(),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
with self._lock:
|
|
519
|
+
self._uploaded += 1
|
|
520
|
+
if result.verified_by != "sha256":
|
|
521
|
+
self._unverified += 1
|
|
522
|
+
|
|
523
|
+
logger.info(
|
|
524
|
+
"uploaded clip",
|
|
525
|
+
extra={
|
|
526
|
+
"clip_id": clip_id,
|
|
527
|
+
"camera": row["camera"],
|
|
528
|
+
"path": key,
|
|
529
|
+
"attempts": attempt_no,
|
|
530
|
+
"duration_s": duration_ms / 1000.0,
|
|
531
|
+
"state": f"verified:{result.verified_by}",
|
|
532
|
+
"count": result.size,
|
|
533
|
+
},
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
return UploadOutcome(
|
|
537
|
+
clip_id=clip_id,
|
|
538
|
+
status=UPLOADED,
|
|
539
|
+
camera=str(row["camera"]),
|
|
540
|
+
path=str(row["path"]),
|
|
541
|
+
attempts=attempt_no,
|
|
542
|
+
cloud_key=key,
|
|
543
|
+
bytes_sent=result.size,
|
|
544
|
+
duration_ms=duration_ms,
|
|
545
|
+
verified_by=result.verified_by,
|
|
546
|
+
skipped=result.already_present,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def _record_failure(
|
|
550
|
+
self,
|
|
551
|
+
conn: sqlite3.Connection,
|
|
552
|
+
row: sqlite3.Row,
|
|
553
|
+
exc: StorageError,
|
|
554
|
+
*,
|
|
555
|
+
started: float,
|
|
556
|
+
) -> UploadOutcome:
|
|
557
|
+
clip_id = int(row["id"])
|
|
558
|
+
attempt_no = int(row["attempts"])
|
|
559
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
560
|
+
message = str(exc)
|
|
561
|
+
|
|
562
|
+
if exc.retryable:
|
|
563
|
+
ceiling = (
|
|
564
|
+
self._settings.quota_backoff_max_s
|
|
565
|
+
if exc.error_class == "quota"
|
|
566
|
+
else self._settings.backoff_max_s
|
|
567
|
+
)
|
|
568
|
+
delay = backoff_delay(
|
|
569
|
+
attempt_no,
|
|
570
|
+
base_s=self._settings.backoff_base_s,
|
|
571
|
+
max_s=ceiling,
|
|
572
|
+
rng=self._rng,
|
|
573
|
+
)
|
|
574
|
+
state = db.mark_transient_failure(
|
|
575
|
+
conn,
|
|
576
|
+
clip_id=clip_id,
|
|
577
|
+
error_class=exc.error_class,
|
|
578
|
+
message=message,
|
|
579
|
+
next_attempt_at=self._clock() + delay,
|
|
580
|
+
max_attempts=self._settings.max_attempts,
|
|
581
|
+
now=self._clock(),
|
|
582
|
+
)
|
|
583
|
+
final = state == "FAILED_PERMANENT"
|
|
584
|
+
outcome = "transient_error"
|
|
585
|
+
status = PERMANENT if final else RETRY
|
|
586
|
+
log = logger.error if final else logger.warning
|
|
587
|
+
log(
|
|
588
|
+
"upload failed; %s",
|
|
589
|
+
"attempts exhausted" if final else f"retrying in {delay:.0f}s",
|
|
590
|
+
extra={
|
|
591
|
+
"clip_id": clip_id,
|
|
592
|
+
"camera": row["camera"],
|
|
593
|
+
"attempts": attempt_no,
|
|
594
|
+
"reason": exc.error_class,
|
|
595
|
+
"state": state,
|
|
596
|
+
},
|
|
597
|
+
)
|
|
598
|
+
if final:
|
|
599
|
+
with self._lock:
|
|
600
|
+
self._failed_permanent += 1
|
|
601
|
+
else:
|
|
602
|
+
db.mark_permanent_failure(
|
|
603
|
+
conn,
|
|
604
|
+
clip_id=clip_id,
|
|
605
|
+
error_class=exc.error_class,
|
|
606
|
+
message=message,
|
|
607
|
+
now=self._clock(),
|
|
608
|
+
)
|
|
609
|
+
outcome = "checksum_mismatch" if exc.error_class == "checksum" else "permanent_error"
|
|
610
|
+
status = PERMANENT
|
|
611
|
+
with self._lock:
|
|
612
|
+
self._failed_permanent += 1
|
|
613
|
+
logger.error(
|
|
614
|
+
"upload failed permanently",
|
|
615
|
+
extra={
|
|
616
|
+
"clip_id": clip_id,
|
|
617
|
+
"camera": row["camera"],
|
|
618
|
+
"attempts": attempt_no,
|
|
619
|
+
"reason": exc.error_class,
|
|
620
|
+
"state": "FAILED_PERMANENT",
|
|
621
|
+
},
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
db.finish_upload_attempt(
|
|
625
|
+
conn,
|
|
626
|
+
clip_id=clip_id,
|
|
627
|
+
attempt_no=attempt_no,
|
|
628
|
+
outcome=outcome,
|
|
629
|
+
http_status=exc.http_status,
|
|
630
|
+
error_class=exc.error_class,
|
|
631
|
+
error_message=message,
|
|
632
|
+
duration_ms=duration_ms,
|
|
633
|
+
now=self._clock(),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
return UploadOutcome(
|
|
637
|
+
clip_id=clip_id,
|
|
638
|
+
status=status,
|
|
639
|
+
camera=str(row["camera"]),
|
|
640
|
+
path=str(row["path"]),
|
|
641
|
+
attempts=attempt_no,
|
|
642
|
+
cloud_key=str(row["cloud_key"] or ""),
|
|
643
|
+
duration_ms=duration_ms,
|
|
644
|
+
error_class=exc.error_class,
|
|
645
|
+
reason=message,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------------------
|
|
650
|
+
# Health (service-contracts §1)
|
|
651
|
+
# ---------------------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class UploaderHealth:
|
|
655
|
+
"""Builds the uploader's health report. Read-only by construction."""
|
|
656
|
+
|
|
657
|
+
def __init__(
|
|
658
|
+
self,
|
|
659
|
+
*,
|
|
660
|
+
db_path: Path,
|
|
661
|
+
data_dir: Path,
|
|
662
|
+
adapter: StorageAdapter | None,
|
|
663
|
+
settings: UploadSettings,
|
|
664
|
+
uploader: Uploader,
|
|
665
|
+
started_at: float,
|
|
666
|
+
clock: Callable[[], float] = timeutil.now,
|
|
667
|
+
cache_s: float = DEFAULT_HEALTHCHECK_CACHE_S,
|
|
668
|
+
) -> None:
|
|
669
|
+
self._db_path = Path(db_path)
|
|
670
|
+
self._data_dir = Path(data_dir)
|
|
671
|
+
self._adapter = adapter
|
|
672
|
+
self._settings = settings
|
|
673
|
+
self._uploader = uploader
|
|
674
|
+
self._started_at = started_at
|
|
675
|
+
self._clock = clock
|
|
676
|
+
self._cache_s = cache_s
|
|
677
|
+
self._cached: storage.AdapterHealth | None = None
|
|
678
|
+
self._cached_at: float = 0.0
|
|
679
|
+
self._lock = threading.Lock()
|
|
680
|
+
|
|
681
|
+
# -- helpers -----------------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
@contextmanager
|
|
684
|
+
def _readonly(self) -> Iterator[sqlite3.Connection]:
|
|
685
|
+
"""A short-lived read-only connection.
|
|
686
|
+
|
|
687
|
+
Read-only so that monitoring physically cannot mutate what it monitors, and
|
|
688
|
+
short-lived so that the HTTP thread and the main loop never share a cursor. Opening
|
|
689
|
+
a connection is microseconds; reasoning about a shared one is not.
|
|
690
|
+
"""
|
|
691
|
+
conn = db.open_db(self._db_path, readonly=True)
|
|
692
|
+
try:
|
|
693
|
+
yield conn
|
|
694
|
+
finally:
|
|
695
|
+
conn.close()
|
|
696
|
+
|
|
697
|
+
def _storage_health(self) -> storage.AdapterHealth | None:
|
|
698
|
+
if self._adapter is None:
|
|
699
|
+
return None
|
|
700
|
+
with self._lock:
|
|
701
|
+
now = self._clock()
|
|
702
|
+
if self._cached is not None and (now - self._cached_at) < self._cache_s:
|
|
703
|
+
return self._cached
|
|
704
|
+
self._cached = self._adapter.healthcheck()
|
|
705
|
+
self._cached_at = now
|
|
706
|
+
return self._cached
|
|
707
|
+
|
|
708
|
+
def summary(self) -> db.QueueSummary | None:
|
|
709
|
+
try:
|
|
710
|
+
with self._readonly() as conn:
|
|
711
|
+
return db.queue_summary(conn, now=self._clock())
|
|
712
|
+
except sqlite3.Error:
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
# -- the report --------------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
def report(self) -> health.HealthReport:
|
|
718
|
+
now = self._clock()
|
|
719
|
+
if not self._settings.storage_configured:
|
|
720
|
+
return self._disabled_report(now)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
with self._readonly() as conn:
|
|
724
|
+
queue: db.QueueSummary | None = db.queue_summary(conn, now=now)
|
|
725
|
+
checks: list[health.Check] = [
|
|
726
|
+
self._state_db_check(conn),
|
|
727
|
+
self._local_files_check(conn),
|
|
728
|
+
]
|
|
729
|
+
except sqlite3.Error as exc:
|
|
730
|
+
queue = None
|
|
731
|
+
checks = [
|
|
732
|
+
health.Check(
|
|
733
|
+
name="state_db",
|
|
734
|
+
state=health.UNHEALTHY,
|
|
735
|
+
code="DB_UNAVAILABLE",
|
|
736
|
+
detail=f"cannot open {self._db_path}: {exc}",
|
|
737
|
+
),
|
|
738
|
+
health.Check(
|
|
739
|
+
name="local_files_readable",
|
|
740
|
+
state=health.UNKNOWN,
|
|
741
|
+
code="DB_UNAVAILABLE",
|
|
742
|
+
detail="the state database could not be read",
|
|
743
|
+
),
|
|
744
|
+
]
|
|
745
|
+
|
|
746
|
+
checks.extend(self._storage_checks())
|
|
747
|
+
if queue is None:
|
|
748
|
+
checks.append(
|
|
749
|
+
health.Check(
|
|
750
|
+
name="backlog_age",
|
|
751
|
+
state=health.UNKNOWN,
|
|
752
|
+
code="DB_UNAVAILABLE",
|
|
753
|
+
detail="the state database could not be read",
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
else:
|
|
757
|
+
checks.extend(self._backlog_checks(queue))
|
|
758
|
+
|
|
759
|
+
return health.HealthReport(
|
|
760
|
+
component=COMPONENT,
|
|
761
|
+
checks=tuple(checks),
|
|
762
|
+
summary=self._summary_text(queue),
|
|
763
|
+
since=self._started_at,
|
|
764
|
+
observed_at=now,
|
|
765
|
+
uptime_s=max(0.0, now - self._started_at),
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
def _disabled_report(self, now: float) -> health.HealthReport:
|
|
769
|
+
"""All checks `DISABLED`, so the overall state is `DISABLED` — not healthy, not broken.
|
|
770
|
+
|
|
771
|
+
The distinction is the point: the operator chose not to configure cloud backup, and
|
|
772
|
+
a status page that reports `HEALTHY` for a component doing nothing would be a lie
|
|
773
|
+
([INV-6](../../../docs/architecture/invariants.md)).
|
|
774
|
+
"""
|
|
775
|
+
detail = "no storage bucket is configured, so nothing is backed up"
|
|
776
|
+
names = (
|
|
777
|
+
"state_db",
|
|
778
|
+
"storage_reachable",
|
|
779
|
+
"storage_credentials",
|
|
780
|
+
"local_files_readable",
|
|
781
|
+
"upload_staleness",
|
|
782
|
+
"backlog_age",
|
|
783
|
+
"failed_permanent",
|
|
784
|
+
)
|
|
785
|
+
return health.HealthReport(
|
|
786
|
+
component=COMPONENT,
|
|
787
|
+
checks=tuple(
|
|
788
|
+
health.Check(name=name, state=health.DISABLED, detail=detail) for name in names
|
|
789
|
+
),
|
|
790
|
+
summary=detail,
|
|
791
|
+
since=self._started_at,
|
|
792
|
+
observed_at=now,
|
|
793
|
+
uptime_s=max(0.0, now - self._started_at),
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
def _state_db_check(self, conn: sqlite3.Connection) -> health.Check:
|
|
797
|
+
started = time.perf_counter()
|
|
798
|
+
try:
|
|
799
|
+
count = conn.execute("SELECT COUNT(*) AS n FROM clips").fetchone()["n"]
|
|
800
|
+
except sqlite3.Error as exc:
|
|
801
|
+
return health.Check(
|
|
802
|
+
name="state_db",
|
|
803
|
+
state=health.UNHEALTHY,
|
|
804
|
+
code="DB_UNAVAILABLE",
|
|
805
|
+
detail=f"cannot query {self._db_path}: {exc}",
|
|
806
|
+
)
|
|
807
|
+
return health.Check(
|
|
808
|
+
name="state_db",
|
|
809
|
+
state=health.HEALTHY,
|
|
810
|
+
detail=f"read/write ok, {int(count)} clips",
|
|
811
|
+
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
def _local_files_check(self, conn: sqlite3.Connection) -> health.Check:
|
|
815
|
+
try:
|
|
816
|
+
rows = db.stored_clip_checks(conn)
|
|
817
|
+
except sqlite3.Error as exc:
|
|
818
|
+
return health.Check(
|
|
819
|
+
name="local_files_readable",
|
|
820
|
+
state=health.UNKNOWN,
|
|
821
|
+
code="DB_UNAVAILABLE",
|
|
822
|
+
detail=f"could not list stored clips: {exc}",
|
|
823
|
+
)
|
|
824
|
+
missing = [str(row["path"]) for row in rows if not (self._data_dir / row["path"]).exists()]
|
|
825
|
+
if missing:
|
|
826
|
+
return health.Check(
|
|
827
|
+
name="local_files_readable",
|
|
828
|
+
state=health.UNHEALTHY,
|
|
829
|
+
code="UPLOAD_LOCAL_FILE_MISSING",
|
|
830
|
+
detail=(
|
|
831
|
+
f"{len(missing)} clip(s) in a held state have no file, e.g. {missing[0]}. "
|
|
832
|
+
f"The database and the disk disagree."
|
|
833
|
+
),
|
|
834
|
+
)
|
|
835
|
+
return health.Check(
|
|
836
|
+
name="local_files_readable",
|
|
837
|
+
state=health.HEALTHY,
|
|
838
|
+
detail=f"{len(rows)} held clip(s) readable",
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
def _storage_checks(self) -> list[health.Check]:
|
|
842
|
+
info = self._storage_health()
|
|
843
|
+
assert info is not None # guarded by the caller
|
|
844
|
+
|
|
845
|
+
if not info.reachable:
|
|
846
|
+
reachable = health.Check(
|
|
847
|
+
name="storage_reachable",
|
|
848
|
+
state=health.UNHEALTHY,
|
|
849
|
+
code="STORAGE_UNREACHABLE",
|
|
850
|
+
detail=info.detail,
|
|
851
|
+
latency_ms=info.latency_ms,
|
|
852
|
+
)
|
|
853
|
+
elif not info.bucket_present:
|
|
854
|
+
reachable = health.Check(
|
|
855
|
+
name="storage_reachable",
|
|
856
|
+
state=health.UNHEALTHY,
|
|
857
|
+
code="STORAGE_UNREACHABLE",
|
|
858
|
+
detail=f"{info.detail} (expected bucket {self._settings.bucket!r})",
|
|
859
|
+
latency_ms=info.latency_ms,
|
|
860
|
+
)
|
|
861
|
+
else:
|
|
862
|
+
reachable = health.Check(
|
|
863
|
+
name="storage_reachable",
|
|
864
|
+
state=health.HEALTHY,
|
|
865
|
+
detail=info.detail,
|
|
866
|
+
latency_ms=info.latency_ms,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
credentials = health.Check(
|
|
870
|
+
name="storage_credentials",
|
|
871
|
+
state=health.HEALTHY if info.credentials_ok else health.UNHEALTHY,
|
|
872
|
+
code=None if info.credentials_ok else "STORAGE_AUTH_FAILED",
|
|
873
|
+
detail=(
|
|
874
|
+
"accepted"
|
|
875
|
+
if info.credentials_ok
|
|
876
|
+
else f"{info.detail} Rotate them: runbooks/rotate-credentials.md."
|
|
877
|
+
),
|
|
878
|
+
)
|
|
879
|
+
return [reachable, credentials]
|
|
880
|
+
|
|
881
|
+
def _backlog_checks(self, queue: db.QueueSummary) -> list[health.Check]:
|
|
882
|
+
checks = [self._backlog_age_check(queue), self._staleness_check(queue)]
|
|
883
|
+
|
|
884
|
+
if queue.failed_permanent:
|
|
885
|
+
checks.append(
|
|
886
|
+
health.Check(
|
|
887
|
+
name="failed_permanent",
|
|
888
|
+
state=health.UNHEALTHY,
|
|
889
|
+
code="UPLOAD_FAILED_PERMANENT",
|
|
890
|
+
detail=(
|
|
891
|
+
f"{queue.failed_permanent} clip(s) will not retry without "
|
|
892
|
+
f"intervention. See runbooks/recover-failed-uploads.md."
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
)
|
|
896
|
+
else:
|
|
897
|
+
checks.append(
|
|
898
|
+
health.Check(
|
|
899
|
+
name="failed_permanent",
|
|
900
|
+
state=health.HEALTHY,
|
|
901
|
+
detail="none",
|
|
902
|
+
)
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
if self._uploader.unverified_uploads:
|
|
906
|
+
checks.append(
|
|
907
|
+
health.Check(
|
|
908
|
+
name="upload_verification",
|
|
909
|
+
state=health.DEGRADED,
|
|
910
|
+
code="UPLOAD_UNVERIFIED",
|
|
911
|
+
detail=(
|
|
912
|
+
f"{self._uploader.unverified_uploads} upload(s) since start could not "
|
|
913
|
+
f"be verified against a provider checksum. An unverified backup is not "
|
|
914
|
+
f"a backup"
|
|
915
|
+
),
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
return checks
|
|
919
|
+
|
|
920
|
+
def _backlog_age_check(self, queue: db.QueueSummary) -> health.Check:
|
|
921
|
+
age = queue.oldest_pending_age_s
|
|
922
|
+
if age is None:
|
|
923
|
+
return health.Check(
|
|
924
|
+
name="backlog_age",
|
|
925
|
+
state=health.HEALTHY,
|
|
926
|
+
detail=f"nothing pending; {queue.uploaded_24h} uploaded in the last 24h",
|
|
927
|
+
)
|
|
928
|
+
if age >= self._settings.backlog_critical_s:
|
|
929
|
+
state, code = health.UNHEALTHY, "BACKLOG_AGING"
|
|
930
|
+
elif age >= self._settings.backlog_warn_s:
|
|
931
|
+
state, code = health.DEGRADED, "BACKLOG_AGING"
|
|
932
|
+
else:
|
|
933
|
+
return health.Check(
|
|
934
|
+
name="backlog_age",
|
|
935
|
+
state=health.HEALTHY,
|
|
936
|
+
detail=f"{queue.pending} pending, oldest {_human(age)}",
|
|
937
|
+
)
|
|
938
|
+
return health.Check(
|
|
939
|
+
name="backlog_age",
|
|
940
|
+
state=state,
|
|
941
|
+
code=code,
|
|
942
|
+
detail=f"{queue.pending} pending, oldest {_human(age)}",
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
def _staleness_check(self, queue: db.QueueSummary) -> health.Check:
|
|
946
|
+
"""No successful upload recently — but only meaningful when there is work to do.
|
|
947
|
+
|
|
948
|
+
An uploader with an empty queue and no recent upload is *idle*, which is healthy. This
|
|
949
|
+
is the distinction that stops a quiet site from alarming every night.
|
|
950
|
+
"""
|
|
951
|
+
age = queue.stale_upload_s
|
|
952
|
+
if queue.pending == 0 and queue.uploading == 0 and queue.retry_wait == 0:
|
|
953
|
+
return health.Check(
|
|
954
|
+
name="upload_staleness",
|
|
955
|
+
state=health.HEALTHY,
|
|
956
|
+
detail="nothing to upload",
|
|
957
|
+
)
|
|
958
|
+
if age is None:
|
|
959
|
+
# A backlog with no upload ever recorded: judge it by the backlog itself.
|
|
960
|
+
age = queue.oldest_pending_age_s or 0.0
|
|
961
|
+
if age >= self._settings.backlog_critical_s:
|
|
962
|
+
state, code = health.UNHEALTHY, "STORAGE_STALE"
|
|
963
|
+
elif age >= self._settings.stale_upload_warn_s:
|
|
964
|
+
state, code = health.DEGRADED, "STORAGE_STALE"
|
|
965
|
+
else:
|
|
966
|
+
return health.Check(
|
|
967
|
+
name="upload_staleness",
|
|
968
|
+
state=health.HEALTHY,
|
|
969
|
+
detail=f"last success {_human(age)} ago",
|
|
970
|
+
)
|
|
971
|
+
return health.Check(
|
|
972
|
+
name="upload_staleness",
|
|
973
|
+
state=state,
|
|
974
|
+
code=code,
|
|
975
|
+
detail=f"last successful upload {_human(age)} ago",
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
def _summary_text(self, queue: db.QueueSummary | None) -> str:
|
|
979
|
+
if queue is None:
|
|
980
|
+
return "state database unreadable"
|
|
981
|
+
parts = [f"{queue.pending} pending"]
|
|
982
|
+
if queue.oldest_pending_age_s is not None:
|
|
983
|
+
parts.append(f"oldest {_human(queue.oldest_pending_age_s)}")
|
|
984
|
+
parts.append(f"{queue.uploaded_24h} uploaded in 24h")
|
|
985
|
+
if queue.failed_permanent:
|
|
986
|
+
parts.append(f"{queue.failed_permanent} failed permanently")
|
|
987
|
+
return "; ".join(parts)
|
|
988
|
+
|
|
989
|
+
def gauges(self) -> dict[str, float | int]:
|
|
990
|
+
queue = self.summary()
|
|
991
|
+
if queue is None:
|
|
992
|
+
return {}
|
|
993
|
+
return {
|
|
994
|
+
"cctv_clips_pending": queue.pending,
|
|
995
|
+
"cctv_clips_uploading": queue.uploading,
|
|
996
|
+
"cctv_clips_retry_wait": queue.retry_wait,
|
|
997
|
+
"cctv_clips_failed_permanent": queue.failed_permanent,
|
|
998
|
+
"cctv_clips_uploaded_24h": queue.uploaded_24h,
|
|
999
|
+
"cctv_oldest_pending_age_seconds": round(queue.oldest_pending_age_s or 0.0, 3),
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _human(seconds: float) -> str:
|
|
1004
|
+
if seconds < 90:
|
|
1005
|
+
return f"{seconds:.0f}s"
|
|
1006
|
+
if seconds < 5400:
|
|
1007
|
+
return f"{seconds / 60:.0f}m"
|
|
1008
|
+
return f"{seconds / 3600:.1f}h"
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
# ---------------------------------------------------------------------------------------
|
|
1012
|
+
# Admin routes
|
|
1013
|
+
# ---------------------------------------------------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def admin_routes(
|
|
1017
|
+
*, settings: UploadSettings, clock: Callable[[], float] = timeutil.now
|
|
1018
|
+
) -> health.AdminRoutes:
|
|
1019
|
+
"""The two admin operations the runbook actually uses.
|
|
1020
|
+
|
|
1021
|
+
A fresh connection per request: these are human-driven, so the cost is irrelevant and a
|
|
1022
|
+
connection shared with the workers would be one more thing to reason about.
|
|
1023
|
+
"""
|
|
1024
|
+
|
|
1025
|
+
def _connect() -> sqlite3.Connection:
|
|
1026
|
+
return db.open_db(settings.db_path)
|
|
1027
|
+
|
|
1028
|
+
def queue() -> Mapping[str, Any]:
|
|
1029
|
+
conn = _connect()
|
|
1030
|
+
try:
|
|
1031
|
+
summary = db.queue_summary(conn, now=clock())
|
|
1032
|
+
oldest = conn.execute("""
|
|
1033
|
+
SELECT id, camera, path, start_time, attempts, last_error_class
|
|
1034
|
+
FROM clips
|
|
1035
|
+
WHERE upload_state = 'PENDING'
|
|
1036
|
+
ORDER BY start_time ASC, id ASC
|
|
1037
|
+
LIMIT 20
|
|
1038
|
+
""").fetchall()
|
|
1039
|
+
failed = conn.execute("""
|
|
1040
|
+
SELECT id, camera, path, attempts, last_error_class, last_error
|
|
1041
|
+
FROM clips
|
|
1042
|
+
WHERE upload_state = 'FAILED_PERMANENT'
|
|
1043
|
+
ORDER BY id
|
|
1044
|
+
""").fetchall()
|
|
1045
|
+
return {
|
|
1046
|
+
"counts": dict(summary.counts),
|
|
1047
|
+
"pending": summary.pending,
|
|
1048
|
+
"uploading": summary.uploading,
|
|
1049
|
+
"retry_wait": summary.retry_wait,
|
|
1050
|
+
"failed_permanent": summary.failed_permanent,
|
|
1051
|
+
"uploaded_24h": summary.uploaded_24h,
|
|
1052
|
+
"oldest_pending_age_s": summary.oldest_pending_age_s,
|
|
1053
|
+
"oldest_pending": [dict(row) for row in oldest],
|
|
1054
|
+
"failed": [dict(row) for row in failed],
|
|
1055
|
+
}
|
|
1056
|
+
finally:
|
|
1057
|
+
conn.close()
|
|
1058
|
+
|
|
1059
|
+
def retry(body: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
1060
|
+
reason = str(body.get("reason") or "")
|
|
1061
|
+
clip_id = body.get("clip_id")
|
|
1062
|
+
camera = body.get("camera")
|
|
1063
|
+
since = body.get("since")
|
|
1064
|
+
conn = _connect()
|
|
1065
|
+
try:
|
|
1066
|
+
# `reset_failed_permanent` refuses an unfiltered or unexplained request, which is
|
|
1067
|
+
# an operator mistake rather than a database fault. Translating it to a ValueError
|
|
1068
|
+
# is what lets the HTTP layer answer `400` instead of `500`.
|
|
1069
|
+
try:
|
|
1070
|
+
ids = db.reset_failed_permanent(
|
|
1071
|
+
conn,
|
|
1072
|
+
reason=reason,
|
|
1073
|
+
clip_id=int(clip_id) if clip_id is not None else None,
|
|
1074
|
+
camera=str(camera) if camera is not None else None,
|
|
1075
|
+
since=float(since) if since is not None else None,
|
|
1076
|
+
now=clock(),
|
|
1077
|
+
)
|
|
1078
|
+
except db.DbError as exc:
|
|
1079
|
+
raise ValueError(str(exc)) from exc
|
|
1080
|
+
return {"requeued": len(ids), "clip_ids": ids}
|
|
1081
|
+
finally:
|
|
1082
|
+
conn.close()
|
|
1083
|
+
|
|
1084
|
+
return health.AdminRoutes(queue=queue, retry=retry)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
# ---------------------------------------------------------------------------------------
|
|
1088
|
+
# Process
|
|
1089
|
+
# ---------------------------------------------------------------------------------------
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def build_adapter(settings: UploadSettings) -> StorageAdapter | None:
|
|
1093
|
+
"""Construct the adapter, or `None` when cloud backup is deliberately unconfigured."""
|
|
1094
|
+
if not settings.storage_configured:
|
|
1095
|
+
return None
|
|
1096
|
+
assert settings.bucket and settings.key_id and settings.application_key
|
|
1097
|
+
return storage.S3StorageAdapter(
|
|
1098
|
+
bucket=settings.bucket,
|
|
1099
|
+
access_key_id=settings.key_id,
|
|
1100
|
+
secret_access_key=settings.application_key,
|
|
1101
|
+
endpoint_url=settings.endpoint_url,
|
|
1102
|
+
region=settings.region,
|
|
1103
|
+
multipart_threshold_bytes=settings.multipart_threshold_bytes,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
class UploadService:
|
|
1108
|
+
"""Worker threads, the periodic policy pass, and the health surface."""
|
|
1109
|
+
|
|
1110
|
+
def __init__(
|
|
1111
|
+
self,
|
|
1112
|
+
*,
|
|
1113
|
+
settings: UploadSettings,
|
|
1114
|
+
config: CamerasConfig,
|
|
1115
|
+
uploader: Uploader,
|
|
1116
|
+
health_reporter: UploaderHealth,
|
|
1117
|
+
clock: Callable[[], float] = timeutil.now,
|
|
1118
|
+
rng: random.Random | None = None,
|
|
1119
|
+
) -> None:
|
|
1120
|
+
self._settings = settings
|
|
1121
|
+
self._config = config
|
|
1122
|
+
self._uploader = uploader
|
|
1123
|
+
self._health = health_reporter
|
|
1124
|
+
self._clock = clock
|
|
1125
|
+
self._rng = rng or random.Random()
|
|
1126
|
+
self._last_outcome: UploadOutcome | None = None
|
|
1127
|
+
|
|
1128
|
+
# -- policy ------------------------------------------------------------------------
|
|
1129
|
+
|
|
1130
|
+
def policy_pass(self) -> int:
|
|
1131
|
+
"""Mark clips from `retention.upload: false` cameras as `SKIPPED_BY_POLICY`."""
|
|
1132
|
+
skipped_cameras = [
|
|
1133
|
+
name for name, camera in self._config.cameras.items() if not camera.retention.upload
|
|
1134
|
+
]
|
|
1135
|
+
if not skipped_cameras:
|
|
1136
|
+
return 0
|
|
1137
|
+
conn = db.open_db(self._settings.db_path)
|
|
1138
|
+
try:
|
|
1139
|
+
changed = db.skip_by_policy(conn, cameras=skipped_cameras, now=self._clock())
|
|
1140
|
+
if changed:
|
|
1141
|
+
logger.info(
|
|
1142
|
+
"clips skipped by policy",
|
|
1143
|
+
extra={
|
|
1144
|
+
"count": changed,
|
|
1145
|
+
"reason": ",".join(sorted(skipped_cameras)),
|
|
1146
|
+
},
|
|
1147
|
+
)
|
|
1148
|
+
return changed
|
|
1149
|
+
finally:
|
|
1150
|
+
conn.close()
|
|
1151
|
+
|
|
1152
|
+
# -- workers -----------------------------------------------------------------------
|
|
1153
|
+
|
|
1154
|
+
def _worker_loop(self, index: int, stop: threading.Event) -> None:
|
|
1155
|
+
"""One claim→upload→record loop, owning its own connection.
|
|
1156
|
+
|
|
1157
|
+
Its own connection rather than a shared one: SQLite connections are not safe for
|
|
1158
|
+
concurrent use, and "each thread owns its connection" is a rule that needs no
|
|
1159
|
+
discipline to keep.
|
|
1160
|
+
"""
|
|
1161
|
+
conn = db.open_db(self._settings.db_path)
|
|
1162
|
+
owner = f"{socket.gethostname()}:{os.getpid()}:{index}"
|
|
1163
|
+
try:
|
|
1164
|
+
while not stop.is_set():
|
|
1165
|
+
outcome: UploadOutcome | None = None
|
|
1166
|
+
try:
|
|
1167
|
+
outcome = self._uploader.upload_one(conn, owner=owner)
|
|
1168
|
+
except Exception:
|
|
1169
|
+
# The outermost loop of a process meant to run for years: log and keep
|
|
1170
|
+
# going. The lease on any claimed clip expires, so nothing is wedged.
|
|
1171
|
+
logger.exception("upload worker failed; continuing")
|
|
1172
|
+
if outcome is not None:
|
|
1173
|
+
self._last_outcome = outcome
|
|
1174
|
+
else:
|
|
1175
|
+
self._idle(stop)
|
|
1176
|
+
finally:
|
|
1177
|
+
conn.close()
|
|
1178
|
+
|
|
1179
|
+
def _idle(self, stop: threading.Event) -> None:
|
|
1180
|
+
"""Sleep with jitter, so N idle workers do not wake in lock-step."""
|
|
1181
|
+
stop.wait(self._settings.scheduler_tick_s * self._rng.uniform(0.8, 1.2))
|
|
1182
|
+
|
|
1183
|
+
# -- the run loop ------------------------------------------------------------------
|
|
1184
|
+
|
|
1185
|
+
def run(self, stop: threading.Event) -> None:
|
|
1186
|
+
self.policy_pass()
|
|
1187
|
+
|
|
1188
|
+
threads = [
|
|
1189
|
+
threading.Thread(
|
|
1190
|
+
target=self._worker_loop,
|
|
1191
|
+
args=(index, stop),
|
|
1192
|
+
name=f"upload-{index}",
|
|
1193
|
+
daemon=True,
|
|
1194
|
+
)
|
|
1195
|
+
for index in range(max(1, self._settings.upload_concurrency))
|
|
1196
|
+
]
|
|
1197
|
+
for thread in threads:
|
|
1198
|
+
thread.start()
|
|
1199
|
+
logger.info(
|
|
1200
|
+
"uploading",
|
|
1201
|
+
extra={"count": len(threads), "host": self._settings.bucket},
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
last_policy = self._clock()
|
|
1205
|
+
last_summary = self._clock()
|
|
1206
|
+
while not stop.wait(1.0):
|
|
1207
|
+
now = self._clock()
|
|
1208
|
+
if now - last_policy >= POLICY_INTERVAL_S:
|
|
1209
|
+
self.policy_pass()
|
|
1210
|
+
last_policy = now
|
|
1211
|
+
if now - last_summary >= SUMMARY_INTERVAL_S:
|
|
1212
|
+
self._log_summary()
|
|
1213
|
+
last_summary = now
|
|
1214
|
+
|
|
1215
|
+
for thread in threads:
|
|
1216
|
+
thread.join(timeout=self._settings.scheduler_tick_s + 1.0)
|
|
1217
|
+
|
|
1218
|
+
def _log_summary(self) -> None:
|
|
1219
|
+
queue = self._health.summary()
|
|
1220
|
+
if queue is None:
|
|
1221
|
+
return
|
|
1222
|
+
logger.info(
|
|
1223
|
+
"upload backlog",
|
|
1224
|
+
extra={
|
|
1225
|
+
"count": queue.pending,
|
|
1226
|
+
"state": (
|
|
1227
|
+
f"pending={queue.pending} retry={queue.retry_wait} "
|
|
1228
|
+
f"failed={queue.failed_permanent} uploaded24h={queue.uploaded_24h}"
|
|
1229
|
+
),
|
|
1230
|
+
"attempts": int(queue.oldest_pending_age_s or 0),
|
|
1231
|
+
},
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _install_signal_handlers(stop: threading.Event) -> None:
|
|
1236
|
+
"""Turn SIGTERM/SIGINT into a cooperative shutdown.
|
|
1237
|
+
|
|
1238
|
+
Cooperative so that a `SIGTERM` does not interrupt a PUT half-way: the worker finishes the
|
|
1239
|
+
clip it is uploading, then exits. A killed upload leaves no lease held in practice — the
|
|
1240
|
+
lease simply expires — but finishing means the next retry is immediate rather than
|
|
1241
|
+
fifteen minutes away.
|
|
1242
|
+
"""
|
|
1243
|
+
|
|
1244
|
+
def request_stop(signum: int, _frame: object) -> None:
|
|
1245
|
+
# Logged once. `timeout`, Docker and a shell can each deliver the signal to both the
|
|
1246
|
+
# process and its group, and two "shutdown requested" lines for one shutdown reads
|
|
1247
|
+
# like two shutdowns.
|
|
1248
|
+
if not stop.is_set():
|
|
1249
|
+
logger.info("shutdown requested", extra={"reason": signal.Signals(signum).name})
|
|
1250
|
+
stop.set()
|
|
1251
|
+
|
|
1252
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
1253
|
+
signal.signal(signum, request_stop)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def build_health_reporter(
|
|
1257
|
+
*, settings: UploadSettings, uploader: Uploader, adapter: StorageAdapter | None
|
|
1258
|
+
) -> UploaderHealth:
|
|
1259
|
+
return UploaderHealth(
|
|
1260
|
+
db_path=settings.db_path,
|
|
1261
|
+
data_dir=settings.data_dir,
|
|
1262
|
+
adapter=adapter,
|
|
1263
|
+
settings=settings,
|
|
1264
|
+
uploader=uploader,
|
|
1265
|
+
started_at=timeutil.now(),
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
1270
|
+
"""LLD 00 §3's service skeleton: configure, validate, serve health, then work."""
|
|
1271
|
+
del argv # every input is an environment variable; see UploadSettings
|
|
1272
|
+
|
|
1273
|
+
try:
|
|
1274
|
+
settings = UploadSettings.from_environ()
|
|
1275
|
+
except ConfigError as exc:
|
|
1276
|
+
print(f"cctv-uploader: {exc}", file=sys.stderr)
|
|
1277
|
+
return EXIT_CONFIG
|
|
1278
|
+
|
|
1279
|
+
try:
|
|
1280
|
+
configure_logging(settings.log_level)
|
|
1281
|
+
except ValueError as exc:
|
|
1282
|
+
print(f"cctv-uploader: {exc}", file=sys.stderr)
|
|
1283
|
+
return EXIT_CONFIG
|
|
1284
|
+
|
|
1285
|
+
problem = settings.configuration_problem()
|
|
1286
|
+
if problem is not None:
|
|
1287
|
+
logger.error("configuration is unusable", extra={"reason": problem})
|
|
1288
|
+
return EXIT_CONFIG
|
|
1289
|
+
|
|
1290
|
+
logger.info(
|
|
1291
|
+
"starting",
|
|
1292
|
+
extra={
|
|
1293
|
+
"host": socket.gethostname(),
|
|
1294
|
+
"state": "disabled" if not settings.storage_configured else "configured",
|
|
1295
|
+
},
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
try:
|
|
1299
|
+
config = load_cameras_config(settings.cameras_path)
|
|
1300
|
+
except (ConfigError, OSError) as exc:
|
|
1301
|
+
logger.error(
|
|
1302
|
+
"configuration is unusable",
|
|
1303
|
+
extra={"path": str(settings.cameras_path), "reason": str(exc)},
|
|
1304
|
+
)
|
|
1305
|
+
return EXIT_CONFIG
|
|
1306
|
+
|
|
1307
|
+
for warning in config.warnings:
|
|
1308
|
+
logger.warning(warning)
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
conn = db.open_db(settings.db_path)
|
|
1312
|
+
version = db.migrate(conn)
|
|
1313
|
+
db.integrity_check(conn)
|
|
1314
|
+
except (db.DbError, sqlite3.Error) as exc:
|
|
1315
|
+
logger.error(
|
|
1316
|
+
"state store is unusable",
|
|
1317
|
+
extra={"path": str(settings.db_path), "reason": str(exc)},
|
|
1318
|
+
)
|
|
1319
|
+
return EXIT_UNAVAILABLE
|
|
1320
|
+
|
|
1321
|
+
logger.info("state store ready", extra={"state": f"schema-{version}"})
|
|
1322
|
+
|
|
1323
|
+
adapter = build_adapter(settings)
|
|
1324
|
+
if adapter is None:
|
|
1325
|
+
logger.warning(
|
|
1326
|
+
"CCTV_STORAGE_BUCKET is unset: cloud backup is disabled and clips stay local. "
|
|
1327
|
+
"This is a supported choice, and the health endpoint reports DISABLED."
|
|
1328
|
+
)
|
|
1329
|
+
else:
|
|
1330
|
+
# A missing SDK is a configuration error, not a runtime one: every clip would fail
|
|
1331
|
+
# the same way, and the message is about an install, not about the network.
|
|
1332
|
+
probe = adapter.healthcheck()
|
|
1333
|
+
if isinstance(probe.error, storage.StorageBackendMissingError):
|
|
1334
|
+
logger.error("storage backend is unusable", extra={"reason": probe.detail})
|
|
1335
|
+
return EXIT_CONFIG
|
|
1336
|
+
try:
|
|
1337
|
+
adapter.ensure_bucket()
|
|
1338
|
+
except StorageError as exc:
|
|
1339
|
+
# Not fatal: the health check reports it, and a bucket can appear later.
|
|
1340
|
+
logger.warning("could not confirm the bucket", extra={"reason": str(exc)})
|
|
1341
|
+
|
|
1342
|
+
uploader = Uploader(
|
|
1343
|
+
data_dir=settings.data_dir,
|
|
1344
|
+
location=config.site.location,
|
|
1345
|
+
adapter=adapter,
|
|
1346
|
+
settings=settings,
|
|
1347
|
+
)
|
|
1348
|
+
reporter = build_health_reporter(settings=settings, uploader=uploader, adapter=adapter)
|
|
1349
|
+
|
|
1350
|
+
stop = threading.Event()
|
|
1351
|
+
_install_signal_handlers(stop)
|
|
1352
|
+
|
|
1353
|
+
server = None
|
|
1354
|
+
if settings.health_port > 0:
|
|
1355
|
+
server, _thread = health.start_health_server(
|
|
1356
|
+
port=settings.health_port,
|
|
1357
|
+
host=settings.health_host,
|
|
1358
|
+
snapshot=reporter.report,
|
|
1359
|
+
admin=admin_routes(settings=settings),
|
|
1360
|
+
token=settings.internal_token,
|
|
1361
|
+
gauges=reporter.gauges,
|
|
1362
|
+
)
|
|
1363
|
+
logger.info(
|
|
1364
|
+
"health endpoint listening",
|
|
1365
|
+
extra={"host": settings.health_host, "count": settings.health_port},
|
|
1366
|
+
)
|
|
1367
|
+
if not settings.internal_token:
|
|
1368
|
+
logger.warning("INTERNAL_API_TOKEN is unset, so the admin endpoints are not mounted")
|
|
1369
|
+
else:
|
|
1370
|
+
logger.warning("no health endpoint: CCTV_UPLOADER_HEALTH_PORT is 0")
|
|
1371
|
+
|
|
1372
|
+
service = UploadService(
|
|
1373
|
+
settings=settings, config=config, uploader=uploader, health_reporter=reporter
|
|
1374
|
+
)
|
|
1375
|
+
|
|
1376
|
+
try:
|
|
1377
|
+
if settings.dry_run:
|
|
1378
|
+
logger.warning("DRY_RUN is set: nothing will be uploaded")
|
|
1379
|
+
while not stop.wait(1.0):
|
|
1380
|
+
pass
|
|
1381
|
+
elif adapter is None:
|
|
1382
|
+
# Nothing to do, but the container stays up and reports DISABLED, so a
|
|
1383
|
+
# local-only install is a stable state rather than a crash loop.
|
|
1384
|
+
while not stop.wait(1.0):
|
|
1385
|
+
pass
|
|
1386
|
+
else:
|
|
1387
|
+
service.run(stop)
|
|
1388
|
+
finally:
|
|
1389
|
+
if server is not None:
|
|
1390
|
+
server.shutdown()
|
|
1391
|
+
server.server_close()
|
|
1392
|
+
conn.close()
|
|
1393
|
+
logger.info("stopped")
|
|
1394
|
+
|
|
1395
|
+
return EXIT_OK
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _size_or_none(path: Path) -> int | None:
|
|
1399
|
+
try:
|
|
1400
|
+
return path.stat().st_size
|
|
1401
|
+
except OSError:
|
|
1402
|
+
return None
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _sha256(path: Path, *, chunk: int = 1024 * 1024) -> str:
|
|
1406
|
+
digest = hashlib.sha256()
|
|
1407
|
+
with open(path, "rb") as handle:
|
|
1408
|
+
while True:
|
|
1409
|
+
block = handle.read(chunk)
|
|
1410
|
+
if not block:
|
|
1411
|
+
break
|
|
1412
|
+
digest.update(block)
|
|
1413
|
+
return digest.hexdigest()
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
if __name__ == "__main__": # pragma: no cover
|
|
1417
|
+
raise SystemExit(main())
|