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