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/health.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""The health contract, and a small HTTP surface that serves it.
|
|
2
|
+
|
|
3
|
+
[INV-6](../../../docs/architecture/invariants.md) is the reason this module exists: a
|
|
4
|
+
component reported as `HEALTHY` must genuinely be able to do its job. Two rules make that
|
|
5
|
+
structural rather than aspirational, and both are here rather than in each service:
|
|
6
|
+
1. **`state` is derived, never assigned.** [`derive_state`][cctv.health.derive_state] is the
|
|
7
|
+
only thing that produces an overall state, and it takes the checks as its only input. A
|
|
8
|
+
service cannot return a literal `HEALTHY`; it can only supply checks that add up to one.
|
|
9
|
+
2. **A check that has not been evaluated is `UNKNOWN`, not healthy.** The fallback is
|
|
10
|
+
`UNKNOWN`, which never renders as `200` in strict mode, so an absence of information
|
|
11
|
+
cannot be mistaken for good news.
|
|
12
|
+
|
|
13
|
+
The HTTP layer is a thin shell: it does no health logic of its own, so every rule above is
|
|
14
|
+
testable without a socket, and the socket can be tested separately with one request.
|
|
15
|
+
|
|
16
|
+
Endpoints, per
|
|
17
|
+
[`contracts/service-contracts.md`](../../../docs/architecture/contracts/service-contracts.md):
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
GET /health JSON report; ?strict=1 makes DEGRADED/UNKNOWN return 503
|
|
21
|
+
GET /metrics Prometheus text exposition
|
|
22
|
+
GET /admin/queue backlog summary (requires INTERNAL_API_TOKEN)
|
|
23
|
+
POST /admin/retry requeue failed clips (requires INTERNAL_API_TOKEN and a reason)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Admin routes are only mounted when a token is configured, and they are never published on a
|
|
27
|
+
host port — the compose network is the boundary.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import threading
|
|
35
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from http import HTTPStatus
|
|
38
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
39
|
+
from typing import Any
|
|
40
|
+
from urllib.parse import parse_qs, urlsplit
|
|
41
|
+
|
|
42
|
+
from . import __version__
|
|
43
|
+
from .timeutil import Instant
|
|
44
|
+
from .timeutil import now as _now
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"CODES",
|
|
48
|
+
"DEGRADED",
|
|
49
|
+
"DISABLED",
|
|
50
|
+
"HEALTHY",
|
|
51
|
+
"UNHEALTHY",
|
|
52
|
+
"UNKNOWN",
|
|
53
|
+
"AdminRoutes",
|
|
54
|
+
"Check",
|
|
55
|
+
"HealthReport",
|
|
56
|
+
"HealthServer",
|
|
57
|
+
"derive_state",
|
|
58
|
+
"render_metrics",
|
|
59
|
+
"start_health_server",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
HEALTHY = "HEALTHY"
|
|
65
|
+
DEGRADED = "DEGRADED"
|
|
66
|
+
UNHEALTHY = "UNHEALTHY"
|
|
67
|
+
UNKNOWN = "UNKNOWN"
|
|
68
|
+
DISABLED = "DISABLED"
|
|
69
|
+
|
|
70
|
+
ALL_STATES = frozenset({HEALTHY, DEGRADED, UNHEALTHY, UNKNOWN, DISABLED})
|
|
71
|
+
|
|
72
|
+
#: The documented machine codes (service-contracts §1.6). Codes are a stable API because
|
|
73
|
+
#: alerting rules reference them, so this set is deliberately closed: a check names one of
|
|
74
|
+
#: these or it names nothing.
|
|
75
|
+
CODES = frozenset(
|
|
76
|
+
{
|
|
77
|
+
"BACKLOG_AGING",
|
|
78
|
+
"CAMERA_OFFLINE",
|
|
79
|
+
"CLEANUP_STALE",
|
|
80
|
+
"CLIPS_OVERDUE_RETENTION",
|
|
81
|
+
"CLIP_COVERAGE_LOW",
|
|
82
|
+
"CLIP_FLUSH_PRESSURE",
|
|
83
|
+
"CLIP_HAS_GAPS",
|
|
84
|
+
"DB_INTEGRITY_FAILED",
|
|
85
|
+
"DB_UNAVAILABLE",
|
|
86
|
+
"DETECTION_FALLING_BEHIND",
|
|
87
|
+
"DISK_CRITICAL",
|
|
88
|
+
"DISK_PRESSURE",
|
|
89
|
+
"FRIGATE_AUTH_FAILED",
|
|
90
|
+
"FRIGATE_UNREACHABLE",
|
|
91
|
+
"FRIGATE_VERSION_MISMATCH",
|
|
92
|
+
"MQTT_DISCONNECTED",
|
|
93
|
+
"RETENTION_HEADROOM_LOW",
|
|
94
|
+
"STORAGE_AUTH_FAILED",
|
|
95
|
+
"STORAGE_STALE",
|
|
96
|
+
"STORAGE_UNREACHABLE",
|
|
97
|
+
"SWEEP_LAG_HIGH",
|
|
98
|
+
"UPLOAD_FAILED_PERMANENT",
|
|
99
|
+
"UPLOAD_LOCAL_FILE_MISSING",
|
|
100
|
+
"UPLOAD_UNVERIFIED",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
#: Overall state → HTTP status, by mode. The table in service-contracts §1.4 omits `UNKNOWN`,
|
|
105
|
+
#: which is an omission rather than a licence: "we cannot tell" must never be a `200`, or a
|
|
106
|
+
#: component that has never evaluated a single check looks fine to every monitor.
|
|
107
|
+
_STATUS_CODES: Mapping[str, tuple[int, int]] = {
|
|
108
|
+
HEALTHY: (200, 200),
|
|
109
|
+
DISABLED: (200, 200),
|
|
110
|
+
DEGRADED: (200, 503),
|
|
111
|
+
UNHEALTHY: (503, 503),
|
|
112
|
+
UNKNOWN: (503, 503),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class Check:
|
|
118
|
+
"""One dependency or condition, evaluated now.
|
|
119
|
+
|
|
120
|
+
`code` is required whenever the state is not `HEALTHY`: an unhealthy check with no
|
|
121
|
+
machine code cannot be alerted on, and a human reading the dashboard has to guess what
|
|
122
|
+
to do about it.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
state: str
|
|
127
|
+
detail: str | None = None
|
|
128
|
+
code: str | None = None
|
|
129
|
+
latency_ms: int | None = None
|
|
130
|
+
observed_at: Instant | None = None
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
if self.state not in ALL_STATES:
|
|
134
|
+
raise ValueError(f"{self.name}: unknown health state {self.state!r}.")
|
|
135
|
+
# `DISABLED` is exempt: a component that is deliberately off is not a condition any
|
|
136
|
+
# alerting rule can act on, so demanding a code would only invite a meaningless one.
|
|
137
|
+
if self.state not in {HEALTHY, DISABLED} and not self.code:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"{self.name}: a {self.state} check must carry a machine code, because "
|
|
140
|
+
f"alerting rules reference codes rather than prose."
|
|
141
|
+
)
|
|
142
|
+
if self.code is not None and self.code not in CODES:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"{self.name}: {self.code!r} is not a documented code. Codes are a stable "
|
|
145
|
+
f"API (service-contracts §1.6); adding one is a deliberate act."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def to_dict(self) -> dict[str, Any]:
|
|
149
|
+
return {
|
|
150
|
+
"name": self.name,
|
|
151
|
+
"state": self.state,
|
|
152
|
+
"code": self.code,
|
|
153
|
+
"detail": self.detail,
|
|
154
|
+
"latency_ms": self.latency_ms,
|
|
155
|
+
"observed_at": self.observed_at,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def derive_state(checks: Sequence[Check | str]) -> str:
|
|
160
|
+
"""The overall state, derived from the checks and nothing else.
|
|
161
|
+
|
|
162
|
+
The order is the contract's (§1.3), evaluated top to bottom, with one refinement the
|
|
163
|
+
contract's list does not cover: **`DISABLED` checks are neutral**. A component with a
|
|
164
|
+
deliberately-off dependency (cloud backup not configured, for instance) is otherwise
|
|
165
|
+
neither all-`HEALTHY` nor all-`DISABLED`, and would fall through to `UNKNOWN` — which in
|
|
166
|
+
strict mode is a `503`, so Docker would mark a perfectly intentional deployment
|
|
167
|
+
permanently unhealthy.
|
|
168
|
+
|
|
169
|
+
The final `UNKNOWN` is the important one: anything the rules do not positively cover —
|
|
170
|
+
including an empty check list — is *unknown*, never healthy.
|
|
171
|
+
"""
|
|
172
|
+
states = [check.state if isinstance(check, Check) else check for check in checks]
|
|
173
|
+
|
|
174
|
+
if not states or all(state == DISABLED for state in states):
|
|
175
|
+
return DISABLED if states else UNKNOWN
|
|
176
|
+
if UNHEALTHY in states:
|
|
177
|
+
return UNHEALTHY
|
|
178
|
+
if DEGRADED in states:
|
|
179
|
+
return DEGRADED
|
|
180
|
+
if all(state in {HEALTHY, DISABLED} for state in states):
|
|
181
|
+
return HEALTHY
|
|
182
|
+
return UNKNOWN
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class HealthReport:
|
|
187
|
+
"""One component's answer to "are you actually working?"."""
|
|
188
|
+
|
|
189
|
+
component: str
|
|
190
|
+
checks: tuple[Check, ...]
|
|
191
|
+
summary: str = ""
|
|
192
|
+
version: str = __version__
|
|
193
|
+
since: Instant | None = None
|
|
194
|
+
observed_at: Instant = field(default_factory=_now)
|
|
195
|
+
uptime_s: float = 0.0
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def state(self) -> str:
|
|
199
|
+
return derive_state(self.checks)
|
|
200
|
+
|
|
201
|
+
def to_dict(self) -> dict[str, Any]:
|
|
202
|
+
return {
|
|
203
|
+
"component": self.component,
|
|
204
|
+
"version": self.version,
|
|
205
|
+
"state": self.state,
|
|
206
|
+
"since": self.since,
|
|
207
|
+
"observed_at": self.observed_at,
|
|
208
|
+
"uptime_s": self.uptime_s,
|
|
209
|
+
"checks": [check.to_dict() for check in self.checks],
|
|
210
|
+
"summary": self.summary,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def http_status(self, *, strict: bool = False) -> int:
|
|
214
|
+
return _STATUS_CODES[self.state][1 if strict else 0]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def render_metrics(report: HealthReport, *, gauges: Mapping[str, float | int] | None = None) -> str:
|
|
218
|
+
"""Prometheus text exposition (service-contracts §3).
|
|
219
|
+
|
|
220
|
+
Only bounded-cardinality labels are emitted — a component name and a state, never a clip
|
|
221
|
+
path or an event id, which would make the series count grow with the number of clips.
|
|
222
|
+
"""
|
|
223
|
+
lines = [
|
|
224
|
+
"# HELP cctv_component_state The component's derived health state, 1 for the current one.",
|
|
225
|
+
"# TYPE cctv_component_state gauge",
|
|
226
|
+
]
|
|
227
|
+
for state in sorted(ALL_STATES):
|
|
228
|
+
value = 1 if state == report.state else 0
|
|
229
|
+
lines.append(
|
|
230
|
+
f'cctv_component_state{{component="{report.component}",state="{state}"}} {value}'
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
for name, value in sorted((gauges or {}).items()):
|
|
234
|
+
lines.append(f"{name} {value}")
|
|
235
|
+
|
|
236
|
+
return "\n".join(lines) + "\n"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True, slots=True)
|
|
240
|
+
class AdminRoutes:
|
|
241
|
+
"""Callables the HTTP layer exposes under `/admin`.
|
|
242
|
+
|
|
243
|
+
Injected rather than imported so the health server has no idea what an uploader is, and
|
|
244
|
+
so a test can exercise the routing and the token check without a database.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
queue: Callable[[], Mapping[str, Any]]
|
|
248
|
+
retry: Callable[[Mapping[str, Any]], Mapping[str, Any]]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
252
|
+
server_version = "cctv-health"
|
|
253
|
+
protocol_version = "HTTP/1.1"
|
|
254
|
+
|
|
255
|
+
# A request line per scrape is noise; only failures are worth a log line.
|
|
256
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
257
|
+
logger.debug("http request", extra={"topic": self.requestline})
|
|
258
|
+
|
|
259
|
+
# -- helpers -----------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def _config(self) -> HealthServer:
|
|
263
|
+
server = self.server
|
|
264
|
+
assert isinstance(server, HealthServer)
|
|
265
|
+
return server
|
|
266
|
+
|
|
267
|
+
def _write(self, status: int, body: bytes, content_type: str) -> None:
|
|
268
|
+
self.send_response(status)
|
|
269
|
+
self.send_header("Content-Type", content_type)
|
|
270
|
+
self.send_header("Content-Length", str(len(body)))
|
|
271
|
+
self.end_headers()
|
|
272
|
+
if self.command != "HEAD":
|
|
273
|
+
self.wfile.write(body)
|
|
274
|
+
|
|
275
|
+
def _json(self, status: int, payload: Any) -> None:
|
|
276
|
+
self._write(
|
|
277
|
+
status,
|
|
278
|
+
json.dumps(payload, separators=(",", ":")).encode("utf-8"),
|
|
279
|
+
"application/json",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _authorised(self) -> bool:
|
|
283
|
+
token = self._config.token
|
|
284
|
+
if not token:
|
|
285
|
+
return False
|
|
286
|
+
header = self.headers.get("Authorization") or ""
|
|
287
|
+
presented = (
|
|
288
|
+
header.removeprefix("Bearer ").strip()
|
|
289
|
+
or (self.headers.get("X-Internal-Token") or "").strip()
|
|
290
|
+
)
|
|
291
|
+
# Compared as strings; the token never appears in a log line (INV-2).
|
|
292
|
+
return presented == token
|
|
293
|
+
|
|
294
|
+
# -- routes ------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def do_HEAD(self) -> None:
|
|
297
|
+
self.do_GET()
|
|
298
|
+
|
|
299
|
+
def do_GET(self) -> None:
|
|
300
|
+
path = urlsplit(self.path)
|
|
301
|
+
if path.path == "/health":
|
|
302
|
+
self._health(path.query)
|
|
303
|
+
return
|
|
304
|
+
if path.path == "/metrics":
|
|
305
|
+
self._metrics()
|
|
306
|
+
return
|
|
307
|
+
if path.path == "/admin/queue":
|
|
308
|
+
self._admin_queue()
|
|
309
|
+
return
|
|
310
|
+
self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
|
311
|
+
|
|
312
|
+
def do_POST(self) -> None:
|
|
313
|
+
path = urlsplit(self.path)
|
|
314
|
+
if path.path == "/admin/retry":
|
|
315
|
+
self._admin_retry()
|
|
316
|
+
return
|
|
317
|
+
self._json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
|
318
|
+
|
|
319
|
+
def _health(self, query: str) -> None:
|
|
320
|
+
strict = parse_qs(query).get("strict", ["0"])[0] in {"1", "true", "yes"}
|
|
321
|
+
report = self._config.snapshot()
|
|
322
|
+
self._json(report.http_status(strict=strict), report.to_dict())
|
|
323
|
+
|
|
324
|
+
def _metrics(self) -> None:
|
|
325
|
+
report = self._config.snapshot()
|
|
326
|
+
gauges = self._config.gauges() if self._config.gauges else None
|
|
327
|
+
self._write(200, render_metrics(report, gauges=gauges).encode("utf-8"), "text/plain")
|
|
328
|
+
|
|
329
|
+
def _admin_guard(self) -> bool:
|
|
330
|
+
if self._config.admin is None or not self._config.token:
|
|
331
|
+
self._json(
|
|
332
|
+
HTTPStatus.NOT_FOUND,
|
|
333
|
+
{"error": "admin endpoints are not enabled (INTERNAL_API_TOKEN is unset)"},
|
|
334
|
+
)
|
|
335
|
+
return False
|
|
336
|
+
if not self._authorised():
|
|
337
|
+
self._json(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid token"})
|
|
338
|
+
return False
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
def _admin_queue(self) -> None:
|
|
342
|
+
if not self._admin_guard():
|
|
343
|
+
return
|
|
344
|
+
assert self._config.admin is not None
|
|
345
|
+
self._json(200, dict(self._config.admin.queue()))
|
|
346
|
+
|
|
347
|
+
def _admin_retry(self) -> None:
|
|
348
|
+
if not self._admin_guard():
|
|
349
|
+
return
|
|
350
|
+
assert self._config.admin is not None
|
|
351
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
352
|
+
raw = self.rfile.read(length) if length else b"{}"
|
|
353
|
+
try:
|
|
354
|
+
body = json.loads(raw or b"{}")
|
|
355
|
+
except json.JSONDecodeError as exc:
|
|
356
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": f"body is not JSON: {exc}"})
|
|
357
|
+
return
|
|
358
|
+
if not isinstance(body, dict):
|
|
359
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": "body must be a JSON object"})
|
|
360
|
+
return
|
|
361
|
+
try:
|
|
362
|
+
result = self._config.admin.retry(body)
|
|
363
|
+
except ValueError as exc:
|
|
364
|
+
# A missing `reason`, or no filter. Both are operator mistakes, not crashes.
|
|
365
|
+
self._json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
|
366
|
+
return
|
|
367
|
+
self._json(200, dict(result))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class HealthServer(ThreadingHTTPServer):
|
|
371
|
+
"""A threaded HTTP server carrying the snapshot callables.
|
|
372
|
+
|
|
373
|
+
`daemon_threads` matters: a slow scrape must not hold up shutdown, and a service that
|
|
374
|
+
will not exit on `SIGTERM` is a service Docker eventually `SIGKILL`s mid-upload.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
daemon_threads = True
|
|
378
|
+
allow_reuse_address = True
|
|
379
|
+
|
|
380
|
+
def __init__(
|
|
381
|
+
self,
|
|
382
|
+
address: tuple[str, int],
|
|
383
|
+
*,
|
|
384
|
+
snapshot: Callable[[], HealthReport],
|
|
385
|
+
admin: AdminRoutes | None = None,
|
|
386
|
+
token: str | None = None,
|
|
387
|
+
gauges: Callable[[], Mapping[str, float | int]] | None = None,
|
|
388
|
+
) -> None:
|
|
389
|
+
super().__init__(address, _Handler)
|
|
390
|
+
self.snapshot = snapshot
|
|
391
|
+
self.admin = admin
|
|
392
|
+
self.token = token
|
|
393
|
+
self.gauges = gauges
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def start_health_server(
|
|
397
|
+
*,
|
|
398
|
+
port: int,
|
|
399
|
+
host: str = "0.0.0.0",
|
|
400
|
+
snapshot: Callable[[], HealthReport],
|
|
401
|
+
admin: AdminRoutes | None = None,
|
|
402
|
+
token: str | None = None,
|
|
403
|
+
gauges: Callable[[], Mapping[str, float | int]] | None = None,
|
|
404
|
+
) -> tuple[HealthServer, threading.Thread]:
|
|
405
|
+
"""Start serving in a background thread. Returns the server and its thread.
|
|
406
|
+
|
|
407
|
+
Port `0` means "disabled" — a deployment that does not want the surface pays nothing for
|
|
408
|
+
it, and the caller logs that choice rather than leaving it implicit.
|
|
409
|
+
"""
|
|
410
|
+
server = HealthServer((host, port), snapshot=snapshot, admin=admin, token=token, gauges=gauges)
|
|
411
|
+
thread = threading.Thread(target=server.serve_forever, name="health", daemon=True)
|
|
412
|
+
thread.start()
|
|
413
|
+
return server, thread
|
cctv/logging.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""JSON logging for every service.
|
|
2
|
+
|
|
3
|
+
Structured rather than formatted, for two reasons that are both about INV-2:
|
|
4
|
+
|
|
5
|
+
1. **A camera's RTSP URL is a credential.** `rtsp://user:pass@host` is the normal form, so
|
|
6
|
+
a URL printed "just for debugging" leaks a password into every log sink that touches it
|
|
7
|
+
— including the ones that ship logs off the machine. [`redact_url`][cctv.logging.redact_url]
|
|
8
|
+
strips the userinfo and keeps the host, because the host is the useful part.
|
|
9
|
+
|
|
10
|
+
2. **Fields stay fields.** A message interpolated together cannot be filtered, counted or
|
|
11
|
+
redacted afterwards. `extra={"camera": name}` produces a queryable key; the same value
|
|
12
|
+
pasted into an f-string produces a sentence.
|
|
13
|
+
|
|
14
|
+
The redaction is a `logging.Filter` rather than a helper the call sites must remember,
|
|
15
|
+
because a rule that depends on every caller remembering it is a rule that will be broken
|
|
16
|
+
once, silently, in the one code path nobody reviewed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
__all__ = ["LOG_LEVEL_ENV", "configure_logging", "redact_url"]
|
|
30
|
+
|
|
31
|
+
LOG_LEVEL_ENV = "CCTV_LOG_LEVEL"
|
|
32
|
+
DEFAULT_LEVEL = "INFO"
|
|
33
|
+
|
|
34
|
+
#: Extra fields promoted into the JSON object. An allowlist, not "everything on the
|
|
35
|
+
#: record minus the internals": the deny-list version silently starts emitting whatever a
|
|
36
|
+
#: library decides to attach, which is how a session token ends up in a log line.
|
|
37
|
+
_FIELDS = (
|
|
38
|
+
"camera",
|
|
39
|
+
"review_id",
|
|
40
|
+
"clip_id",
|
|
41
|
+
"path",
|
|
42
|
+
"status",
|
|
43
|
+
"reason",
|
|
44
|
+
"coverage",
|
|
45
|
+
"duration_s",
|
|
46
|
+
"attempts",
|
|
47
|
+
"count",
|
|
48
|
+
"state",
|
|
49
|
+
"topic",
|
|
50
|
+
"host",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: `scheme://user:password@host` → `scheme://***@host`.
|
|
54
|
+
#:
|
|
55
|
+
#: The userinfo runs to the **last** `@` before the path, which is why this is `[^/\s]*@`
|
|
56
|
+
#: rather than `[^/@\s]+@`. A camera password is chosen by a person, so one containing an `@`
|
|
57
|
+
#: (`p@ssw0rd`) is a password that occurs — and stopping at the first `@` would publish its
|
|
58
|
+
#: tail, on a line that *looks* redacted, which is worse than not redacting at all.
|
|
59
|
+
#: Excluding `/` and whitespace is what keeps the match inside one URL.
|
|
60
|
+
_USERINFO_RE = re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*://)[^/\s]*@")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def redact_url(value: str) -> str:
|
|
64
|
+
"""Remove credentials from anything that looks like a URL, leaving the host.
|
|
65
|
+
|
|
66
|
+
Applies to every URL in the string, because a log message is a sentence and a sentence
|
|
67
|
+
can carry more than one camera.
|
|
68
|
+
"""
|
|
69
|
+
return _USERINFO_RE.sub(r"\g<scheme>***@", value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _RedactingFilter(logging.Filter):
|
|
73
|
+
"""Scrub credentials from the message *and* from the arguments.
|
|
74
|
+
|
|
75
|
+
Both, deliberately. `logger.info("connecting to %s", url)` is the idiomatic way to log
|
|
76
|
+
a value, and a message-only filter would miss it entirely — the credential is in
|
|
77
|
+
`record.args` until the formatter joins them.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
81
|
+
if isinstance(record.msg, str):
|
|
82
|
+
record.msg = redact_url(record.msg)
|
|
83
|
+
if record.args:
|
|
84
|
+
if isinstance(record.args, dict):
|
|
85
|
+
record.args = {
|
|
86
|
+
k: redact_url(v) if isinstance(v, str) else v for k, v in record.args.items()
|
|
87
|
+
}
|
|
88
|
+
else:
|
|
89
|
+
record.args = tuple(
|
|
90
|
+
redact_url(arg) if isinstance(arg, str) else arg for arg in record.args
|
|
91
|
+
)
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _JsonFormatter(logging.Formatter):
|
|
96
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
97
|
+
payload: dict[str, Any] = {
|
|
98
|
+
# UTC always. INV-12 makes UTC the storage timezone, and a log line is read
|
|
99
|
+
# next to database rows, so a local-time log is a log that has to be converted
|
|
100
|
+
# in the reader's head before it can be compared to anything.
|
|
101
|
+
"ts": datetime.fromtimestamp(record.created, tz=UTC)
|
|
102
|
+
.isoformat(timespec="milliseconds")
|
|
103
|
+
.replace("+00:00", "Z"),
|
|
104
|
+
"level": record.levelname,
|
|
105
|
+
"logger": record.name,
|
|
106
|
+
"msg": record.getMessage(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for field in _FIELDS:
|
|
110
|
+
value = getattr(record, field, None)
|
|
111
|
+
if value is not None:
|
|
112
|
+
# Redacted here as well as in the filter, because an `extra` value never
|
|
113
|
+
# travels through `record.msg` or `record.args` — it is set as an attribute.
|
|
114
|
+
# `logger.info("fetching", extra={"path": url})` is the idiomatic way to log
|
|
115
|
+
# a clip, and the two places that can put a string into a log line have to
|
|
116
|
+
# apply the same rule or the rule has a hole in it.
|
|
117
|
+
payload[field] = redact_url(value) if isinstance(value, str) else value
|
|
118
|
+
|
|
119
|
+
if record.exc_info:
|
|
120
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
121
|
+
|
|
122
|
+
# `default=str` so an unexpected type degrades to its repr instead of raising
|
|
123
|
+
# inside the logging call — a log line that crashes the worker is worse than a
|
|
124
|
+
# slightly ugly one.
|
|
125
|
+
return json.dumps(payload, separators=(",", ":"), default=str)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def configure_logging(level: str | None = None, *, stream: Any = None) -> None:
|
|
129
|
+
"""Install the JSON handler as the root configuration.
|
|
130
|
+
|
|
131
|
+
Replaces existing handlers, so importing a library that called `basicConfig` cannot
|
|
132
|
+
leave a second, differently-formatted stream attached.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: the level is not a name `logging` recognises. Raised rather than
|
|
136
|
+
ignored because a typo'd level that silently becomes INFO is a log level the
|
|
137
|
+
operator believes they set.
|
|
138
|
+
"""
|
|
139
|
+
requested = level if level is not None else os.environ.get(LOG_LEVEL_ENV, "")
|
|
140
|
+
resolved = (requested or DEFAULT_LEVEL).upper()
|
|
141
|
+
|
|
142
|
+
known = logging.getLevelNamesMapping()
|
|
143
|
+
if resolved not in known:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"{LOG_LEVEL_ENV}={requested!r} is not a log level. "
|
|
146
|
+
f"Expected one of: {', '.join(sorted(known))}."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
handler = logging.StreamHandler(stream if stream is not None else sys.stdout)
|
|
150
|
+
handler.setFormatter(_JsonFormatter())
|
|
151
|
+
handler.addFilter(_RedactingFilter())
|
|
152
|
+
|
|
153
|
+
root = logging.getLogger()
|
|
154
|
+
for existing in list(root.handlers):
|
|
155
|
+
root.removeHandler(existing)
|
|
156
|
+
root.addHandler(handler)
|
|
157
|
+
root.setLevel(known[resolved])
|