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/cleanup.py ADDED
@@ -0,0 +1,1014 @@
1
+ """Local clip retention — cleanup job J1 ([LLD 05](../../../docs/architecture/lld/05-cleanup.md)).
2
+
3
+ This is the only module in the system that deletes a clip, and therefore the one that has to
4
+ be the most conservative. [INV-1](../../../docs/architecture/invariants.md) is the reason: a
5
+ clip is never deleted locally until its upload state is terminal-success, or the operator
6
+ explicitly forces it.
7
+
8
+ The implementation is shaped so that the unsafe thing is **impossible** rather than unlikely
9
+ (LLD 05 §2):
10
+
11
+ 1. `db.local_expiry_batch` enumerates the states that permit deletion, so a state added later
12
+ is safe by default rather than deletable by default.
13
+ 2. Each clip's row is re-read and re-checked immediately before its file is unlinked, which
14
+ catches a state change between the page query and the unlink.
15
+ 3. An `UPLOADED` clip must also carry a `cloud_key` **and** an `uploaded_at`, so a row marked
16
+ uploaded without a recorded upload is never treated as backed up.
17
+ 4. The path is resolved and must stay inside `clips/`, so a corrupted or tampered `path`
18
+ cannot reach anything else.
19
+ 5. `DRY_RUN` logs every intended deletion and performs none.
20
+
21
+ What this module deliberately does **not** do (LLD 05 §2.3): walk the filesystem to decide
22
+ what to delete; delete differently for a retired camera; touch `data/media/` or
23
+ `data/frigate/`; list the bucket to decide anything; re-upload; or auto-resolve a finding.
24
+
25
+ ## Scope
26
+
27
+ **J1 only.** Cloud retention (J2), reconciliation (J3), housekeeping (J4) and the full
28
+ retention report (J5) are not implemented yet; the deferred work is recorded in
29
+ [`docs/plans/milestones.md`](../../../docs/plans/milestones.md). The service wrapper, health
30
+ surface and compose entry point arrive with the rest of M4.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import os
37
+ import shutil
38
+ import signal
39
+ import socket
40
+ import sqlite3
41
+ import sys
42
+ import threading
43
+ from collections.abc import Mapping, Sequence
44
+ from dataclasses import dataclass
45
+ from pathlib import Path, PurePosixPath
46
+ from typing import Any
47
+
48
+ from . import db, health, paths, timeutil
49
+ from .config import CamerasConfig, ConfigError, load_cameras_config
50
+ from .logging import configure_logging
51
+ from .timeutil import Instant
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ __all__ = [
56
+ "COMPONENT",
57
+ "DEFAULT_BATCH_SIZE",
58
+ "DEFAULT_HEALTH_PORT",
59
+ "DEFAULT_LOCAL_DAYS",
60
+ "DEFAULT_RETIRE_GRACE_DAYS",
61
+ "DEFAULT_RUN_INTERVAL_S",
62
+ "CleanupHealth",
63
+ "CleanupPathError",
64
+ "CleanupService",
65
+ "CleanupSettings",
66
+ "LocalRetention",
67
+ "RetentionRun",
68
+ "main",
69
+ "resolve_within_clips_dir",
70
+ ]
71
+
72
+ #: The component name used in logs, health reports and `system_events`.
73
+ COMPONENT = "cleanup"
74
+
75
+ #: The directory, under the data directory, that local retention owns.
76
+ CLIPS_DIR_NAME = "clips"
77
+
78
+ SECONDS_PER_DAY = 86400.0
79
+
80
+ DEFAULT_RUN_INTERVAL_S = 3600.0
81
+ DEFAULT_BATCH_SIZE = 200
82
+ DEFAULT_SCHEDULER_TICK_S = 1.0
83
+
84
+ #: The cleanup health surface (hld.md §11 — internal only, loopback in compose).
85
+ DEFAULT_HEALTH_PORT = 8083
86
+
87
+ #: Overdue-retention thresholds. `lld/05` §9: 0 is HEALTHY, 1-10 is DEGRADED, more than 10 -
88
+ #: or anything held longer than 30 days - is UNHEALTHY. The `warn` default is 1 rather than 10
89
+ #: because even one held clip means the invariant is costing disk, and the point of J5 is to
90
+ #: make that visible while it is still small.
91
+ DEFAULT_CLIPS_OVERDUE_WARN = 1
92
+ DEFAULT_CLIPS_OVERDUE_CRITICAL = 10
93
+ DEFAULT_OVERDUE_AGE_CRITICAL_DAYS = 30
94
+
95
+ #: `health.disk_warn_pct` / `disk_critical_pct` (lld/00 §4), shared by every service.
96
+ DEFAULT_DISK_WARN_PCT = 15.0
97
+ DEFAULT_DISK_CRITICAL_PCT = 5.0
98
+
99
+ EXIT_OK = 0
100
+ EXIT_CONFIG = 78
101
+ EXIT_UNAVAILABLE = 69
102
+
103
+ #: The `sweep_state` row recording when J1 last ran. That table is reused rather than a new one
104
+ #: created: it already carries `last_run_at`, `last_success_at` and `consecutive_failures`,
105
+ #: which is exactly what a staleness check needs.
106
+ LAST_RUN_NAME = "cleanup_local"
107
+
108
+ #: The retention applied when a camera is not in `cameras.yml` — a retired camera. Mirrors
109
+ #: `RetentionConfig`'s default, and is deliberately a copy rather than an import so that
110
+ #: changing the config default cannot silently change what happens to retired footage.
111
+ DEFAULT_LOCAL_DAYS = 90
112
+ DEFAULT_RETIRE_GRACE_DAYS = 0
113
+
114
+
115
+ class CleanupPathError(ValueError):
116
+ """A clip path that will not be acted on.
117
+
118
+ Raised rather than worked around: a `clips.path` that escapes `clips/` means either a bug
119
+ or tampering, and the only safe response is to refuse and report. Nothing is deleted.
120
+ """
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class CleanupSettings:
125
+ """Every input is an environment variable, so there are no CLI arguments to get wrong."""
126
+
127
+ data_dir: Path
128
+ cameras_path: Path
129
+ db_path: Path
130
+ run_interval_s: float
131
+ batch_size: int
132
+ dry_run: bool
133
+ retire_grace_days: int
134
+ health_host: str
135
+ health_port: int
136
+ scheduler_tick_s: float
137
+ clips_overdue_warn: int
138
+ clips_overdue_critical: int
139
+ overdue_age_critical_days: int
140
+ disk_warn_pct: float
141
+ disk_critical_pct: float
142
+ log_level: str | None
143
+
144
+ @property
145
+ def clips_dir(self) -> Path:
146
+ return self.data_dir / CLIPS_DIR_NAME
147
+
148
+ def configuration_problem(self) -> str | None:
149
+ if self.run_interval_s <= 0:
150
+ return "CCTV_CLEANUP_RUN_INTERVAL_S must be positive."
151
+ if self.batch_size < 1:
152
+ return "CCTV_CLEANUP_BATCH_SIZE must be at least 1."
153
+ if self.retire_grace_days < 0:
154
+ return "CCTV_CLEANUP_RETIRE_GRACE_DAYS cannot be negative."
155
+ if self.health_port < 0:
156
+ return "CCTV_CLEANUP_HEALTH_PORT cannot be negative."
157
+ if self.clips_overdue_critical < self.clips_overdue_warn:
158
+ return "CCTV_CLEANUP_OVERDUE_CRITICAL cannot be below CCTV_CLEANUP_OVERDUE_WARN."
159
+ if self.disk_critical_pct >= self.disk_warn_pct:
160
+ return "CCTV_HEALTH_DISK_CRITICAL_PCT must be below CCTV_HEALTH_DISK_WARN_PCT."
161
+ return None
162
+
163
+ @classmethod
164
+ def from_environ(cls, environ: Mapping[str, str] | None = None) -> CleanupSettings:
165
+ env = os.environ if environ is None else environ
166
+
167
+ def number(name: str, default: float) -> float:
168
+ raw = (env.get(name) or "").strip()
169
+ if not raw:
170
+ return default
171
+ try:
172
+ return float(raw)
173
+ except ValueError as exc:
174
+ raise ConfigError(f"{name}={raw!r} is not a number.") from exc
175
+
176
+ config_dir = Path(env.get("CCTV_CONFIG_DIR", "/config"))
177
+ data_dir = Path(env.get("CCTV_DATA_DIR", "/data"))
178
+
179
+ return cls(
180
+ data_dir=data_dir,
181
+ cameras_path=Path(env.get("CCTV_CAMERAS", str(config_dir / "cameras.yml"))),
182
+ db_path=Path(env.get("CCTV_DB", str(data_dir / "cctv.db"))),
183
+ run_interval_s=number("CCTV_CLEANUP_RUN_INTERVAL_S", DEFAULT_RUN_INTERVAL_S),
184
+ batch_size=int(number("CCTV_CLEANUP_BATCH_SIZE", DEFAULT_BATCH_SIZE)),
185
+ dry_run=(env.get("DRY_RUN") or "").strip().lower() in {"1", "true", "yes"},
186
+ retire_grace_days=int(
187
+ number("CCTV_CLEANUP_RETIRE_GRACE_DAYS", DEFAULT_RETIRE_GRACE_DAYS)
188
+ ),
189
+ health_host=(env.get("CCTV_HEALTH_HOST") or "0.0.0.0").strip(),
190
+ health_port=int(number("CCTV_CLEANUP_HEALTH_PORT", DEFAULT_HEALTH_PORT)),
191
+ scheduler_tick_s=number("CCTV_CLEANUP_TICK_S", DEFAULT_SCHEDULER_TICK_S),
192
+ clips_overdue_warn=int(number("CCTV_CLEANUP_OVERDUE_WARN", DEFAULT_CLIPS_OVERDUE_WARN)),
193
+ clips_overdue_critical=int(
194
+ number("CCTV_CLEANUP_OVERDUE_CRITICAL", DEFAULT_CLIPS_OVERDUE_CRITICAL)
195
+ ),
196
+ overdue_age_critical_days=int(
197
+ number(
198
+ "CCTV_CLEANUP_OVERDUE_AGE_CRITICAL_DAYS",
199
+ DEFAULT_OVERDUE_AGE_CRITICAL_DAYS,
200
+ )
201
+ ),
202
+ disk_warn_pct=number("CCTV_HEALTH_DISK_WARN_PCT", DEFAULT_DISK_WARN_PCT),
203
+ disk_critical_pct=number("CCTV_HEALTH_DISK_CRITICAL_PCT", DEFAULT_DISK_CRITICAL_PCT),
204
+ log_level=env.get("CCTV_LOG_LEVEL") or None,
205
+ )
206
+
207
+
208
+ def resolve_within_clips_dir(clips_dir: Path, relpath: str) -> Path:
209
+ """Resolve a clip's path and refuse anything outside `clips/` (guard 4).
210
+
211
+ The path stored in the database is relative to the data directory — `clips/<camera>/…` —
212
+ so it is joined to the clips directory's parent, then resolved. Resolution follows
213
+ symlinks, so a symlink pointing outside the tree is refused too, which a purely lexical
214
+ check would miss.
215
+
216
+ Raises:
217
+ CleanupPathError: the path is empty, absolute, or resolves outside `clips/`.
218
+ """
219
+ if not relpath or not str(relpath).strip():
220
+ raise CleanupPathError("A clip row has an empty path.")
221
+
222
+ candidate = Path(relpath)
223
+ if candidate.is_absolute():
224
+ raise CleanupPathError(
225
+ f"Clip path {relpath!r} is absolute. Paths are stored relative to the data "
226
+ f"directory so a restore to a different mount point still works."
227
+ )
228
+
229
+ root = clips_dir.resolve()
230
+ resolved = (clips_dir.parent / candidate).resolve()
231
+ if resolved != root and root not in resolved.parents:
232
+ raise CleanupPathError(
233
+ f"Clip path {relpath!r} resolves to {resolved}, which is outside {root}. "
234
+ f"Nothing was deleted."
235
+ )
236
+ return resolved
237
+
238
+
239
+ @dataclass(frozen=True, slots=True)
240
+ class RetentionRun:
241
+ """What one J1 pass did, or would have done under `DRY_RUN`."""
242
+
243
+ cameras: int = 0
244
+ considered: int = 0
245
+ deleted: int = 0
246
+ missing: int = 0
247
+ refused: int = 0
248
+ would_delete: int = 0
249
+ bytes_freed: int = 0
250
+ dry_run: bool = False
251
+ started_at: Instant = 0.0
252
+ finished_at: Instant = 0.0
253
+
254
+ @property
255
+ def duration_s(self) -> float:
256
+ return max(0.0, self.finished_at - self.started_at)
257
+
258
+
259
+ class LocalRetention:
260
+ """J1: delete local clip files past `local_days`, and only when it is safe to.
261
+
262
+ Instantiated once and called from one place, because cleanup is single-instance by
263
+ construction (LLD 05 §8).
264
+ """
265
+
266
+ def __init__(
267
+ self,
268
+ *,
269
+ settings: CleanupSettings,
270
+ logger: Any | None = None,
271
+ clock: Any = timeutil.now,
272
+ ) -> None:
273
+ self._settings = settings
274
+ self._clock = clock
275
+ self._logger = logger
276
+
277
+ # -- retention windows ---------------------------------------------------------------
278
+
279
+ def cutoff_for(
280
+ self, camera: str, *, config: CamerasConfig, retired: bool, now: Instant
281
+ ) -> Instant:
282
+ """The oldest `start_time` that survives, for one camera.
283
+
284
+ A camera that is still in `cameras.yml` uses its configured `local_days`. A **retired**
285
+ camera — removed from the config — has no window of its own, so it falls back to the
286
+ default plus `retire_grace_days`. That is what stops a camera's footage from becoming
287
+ immortal by virtue of the camera no longer being configured ([INV-13]).
288
+
289
+ Note that this depends only on the camera's *configuration*, never on whether it is
290
+ currently `enabled`: a camera switched off for the season keeps its own retention.
291
+ """
292
+ configured = config.cameras.get(camera)
293
+ if configured is not None:
294
+ days = float(configured.retention.local_days)
295
+ else:
296
+ grace = self._settings.retire_grace_days if retired else 0
297
+ days = float(DEFAULT_LOCAL_DAYS + grace)
298
+ return now - days * SECONDS_PER_DAY
299
+
300
+ # -- the run -------------------------------------------------------------------------
301
+
302
+ def run_once(self, conn: Any, config: CamerasConfig) -> RetentionRun:
303
+ """One J1 pass over every known camera, in batches, committing per batch."""
304
+ started = self._clock()
305
+ totals = _Counters()
306
+ cameras = db.known_cameras(conn)
307
+
308
+ for row in cameras:
309
+ camera = str(row["name"])
310
+ retired = row["retired_at"] is not None
311
+ cutoff = self.cutoff_for(camera, config=config, retired=retired, now=started)
312
+ self._expire_camera(conn, camera=camera, cutoff=cutoff, totals=totals)
313
+
314
+ finished = self._clock()
315
+ run = RetentionRun(
316
+ cameras=len(cameras),
317
+ considered=totals.considered,
318
+ deleted=totals.deleted,
319
+ missing=totals.missing,
320
+ refused=totals.refused,
321
+ would_delete=totals.dry_run_deleted,
322
+ bytes_freed=totals.bytes_freed,
323
+ dry_run=self._settings.dry_run,
324
+ started_at=started,
325
+ finished_at=finished,
326
+ )
327
+ # No summary line here: `CleanupService` logs one per pass whether or not anything
328
+ # happened, which is a heartbeat as well as a report. Logging here too would double
329
+ # every line on the runs that matter.
330
+ return run
331
+
332
+ def _expire_camera(self, conn: Any, *, camera: str, cutoff: Instant, totals: _Counters) -> None:
333
+ cursor: tuple[float, int] | None = None
334
+ while True:
335
+ batch = db.local_expiry_batch(
336
+ conn,
337
+ camera=camera,
338
+ cutoff=cutoff,
339
+ limit=self._settings.batch_size,
340
+ after=cursor,
341
+ )
342
+ if not batch:
343
+ return
344
+ totals.considered += len(batch)
345
+ # One transaction per batch, not per clip and not for the whole run: bounded
346
+ # rollback, bounded lock, observable progress. The asymmetry is deliberate — it is
347
+ # always safer for the database to under-report a deletion (the file is gone, the
348
+ # row still says it exists) than to over-report one. J3 finds the former.
349
+ with db.write_txn(conn):
350
+ for clip in batch:
351
+ self._expire_one(conn, clip, totals=totals)
352
+ # Advance the cursor past this page. Deliberately **not** "the set shrank": a
353
+ # refused clip, or every clip at all under DRY_RUN, keeps its row eligible, so a
354
+ # shrink-based loop would return this page forever. See `db.local_expiry_batch`.
355
+ last = batch[-1]
356
+ cursor = (float(last["start_time"]), int(last["id"]))
357
+
358
+ def _expire_one(self, conn: Any, clip: Any, *, totals: _Counters) -> None:
359
+ clip_id = int(clip["id"])
360
+ now = self._clock()
361
+
362
+ # Guard 2: re-read and re-check inside the write transaction. The page query ran
363
+ # outside it, so an uploader could have moved the clip since.
364
+ row = conn.execute(
365
+ "SELECT id, upload_state, cloud_key, uploaded_at, path, sidecar_path, size_bytes "
366
+ "FROM clips WHERE id = ?",
367
+ (clip_id,),
368
+ ).fetchone()
369
+ if row is None:
370
+ return
371
+ state = str(row["upload_state"])
372
+ if state not in db.LOCAL_EXPIRY_STATES:
373
+ self._log(
374
+ "CLEANUP_CLIP_NO_LONGER_DELETABLE",
375
+ clip_id=clip_id,
376
+ state=state,
377
+ )
378
+ return
379
+
380
+ # Guard 3: `UPLOADED` means "an object exists at the deterministic key". Without both
381
+ # of these the row is claiming a backup we cannot point at, so the file stays.
382
+ if state == "UPLOADED" and (not row["cloud_key"] or row["uploaded_at"] is None):
383
+ totals.refused += 1
384
+ self._log(
385
+ "CLEANUP_REFUSED_UNPROVABLE_BACKUP",
386
+ clip_id=clip_id,
387
+ state=state,
388
+ reason="UPLOADED without a cloud_key and uploaded_at",
389
+ )
390
+ return
391
+
392
+ try:
393
+ target = resolve_within_clips_dir(self._settings.clips_dir, str(row["path"]))
394
+ except CleanupPathError as exc:
395
+ # Guard 4. A poisoned path is a serious finding, not a crash: refuse this clip,
396
+ # report it, and keep the rest of the run moving.
397
+ totals.refused += 1
398
+ self._log(
399
+ "CLEANUP_REFUSED_PATH_ESCAPE",
400
+ clip_id=clip_id,
401
+ path=str(row["path"]),
402
+ reason=str(exc),
403
+ )
404
+ return
405
+
406
+ if not target.exists():
407
+ # Already gone — a crash between unlink and commit, or a manual removal. Record it
408
+ # and move on; this is not an error, it is the desired state being reached.
409
+ totals.missing += 1
410
+ db.mark_local_deleted(conn, clip_id=clip_id, now=now)
411
+ db.record_finding(
412
+ conn,
413
+ kind="missing_local_file",
414
+ subject=str(row["path"]),
415
+ detail={"clip_id": clip_id, "camera": str(clip["camera"])},
416
+ now=now,
417
+ )
418
+ return
419
+
420
+ size = int(row["size_bytes"] or 0)
421
+
422
+ if self._settings.dry_run:
423
+ # Guard 5. Log the intention with everything an operator needs to judge it, and
424
+ # change nothing — not even the row.
425
+ totals.dry_run_deleted += 1
426
+ self._log(
427
+ "CLEANUP_WOULD_DELETE",
428
+ clip_id=clip_id,
429
+ path=str(row["path"]),
430
+ state=state,
431
+ count=size,
432
+ )
433
+ return
434
+
435
+ # Order matters (§3.2): clip, then sidecar, then the row. A crash after the clip
436
+ # leaves an orphan sidecar that J4 reaps; a crash after the sidecar leaves a row
437
+ # pointing at nothing, which J3 reports. The reverse order would leave a clip with no
438
+ # metadata, and the sidecar is what makes a future recovery possible.
439
+ _unlink(target)
440
+ sidecar = self._sidecar_path(row, target=target)
441
+ if sidecar.exists():
442
+ _unlink(sidecar)
443
+
444
+ db.mark_local_deleted(conn, clip_id=clip_id, now=now)
445
+ totals.deleted += 1
446
+ totals.bytes_freed += size
447
+ self._log(
448
+ "CLIP_LOCAL_EXPIRED",
449
+ clip_id=clip_id,
450
+ camera=str(clip["camera"]),
451
+ path=str(row["path"]),
452
+ state=state,
453
+ count=size,
454
+ )
455
+
456
+ def _sidecar_path(self, row: Any, *, target: Path) -> Path:
457
+ """The sidecar to remove alongside the clip.
458
+
459
+ The recorded `sidecar_path` is preferred; otherwise it is derived with the one path
460
+ function ([INV-5](../../../docs/architecture/invariants.md)) rather than by string
461
+ surgery, so the two can never disagree about where a sidecar lives. Deriving it from
462
+ the already-resolved target means it cannot escape `clips/` either.
463
+ """
464
+ recorded = row["sidecar_path"]
465
+ if recorded:
466
+ try:
467
+ return resolve_within_clips_dir(self._settings.clips_dir, str(recorded))
468
+ except CleanupPathError:
469
+ # A poisoned sidecar path is not worth failing the clip over: the clip is the
470
+ # only irreplaceable artefact, and an orphan sidecar is reaped by J4.
471
+ pass
472
+ sibling = paths.sidecar_relpath(PurePosixPath(target.name))
473
+ return target.with_name(sibling.name)
474
+
475
+ def _log(self, message: str, **extra: Any) -> None:
476
+ if self._logger is None:
477
+ return
478
+ self._logger.info(message, extra=extra)
479
+
480
+
481
+ class _Counters:
482
+ """Mutable tallies for one run. A dataclass would only add ceremony."""
483
+
484
+ __slots__ = (
485
+ "bytes_freed",
486
+ "considered",
487
+ "deleted",
488
+ "dry_run_deleted",
489
+ "missing",
490
+ "refused",
491
+ )
492
+
493
+ def __init__(self) -> None:
494
+ self.considered = 0
495
+ self.deleted = 0
496
+ self.missing = 0
497
+ self.refused = 0
498
+ self.bytes_freed = 0
499
+ self.dry_run_deleted = 0
500
+
501
+
502
+ def _unlink(path: Path) -> None:
503
+ """Remove a file, tolerating a race with another deleter.
504
+
505
+ Two cleanups should never run at once (LLD 05 §8), and J1 already checked `exists()` —
506
+ but a missing file at this point still means the desired state holds, so it is not an
507
+ error worth failing a batch over.
508
+ """
509
+ try:
510
+ path.unlink()
511
+ except FileNotFoundError:
512
+ return
513
+
514
+
515
+ # ---------------------------------------------------------------------------------------
516
+ # Health (INV-6, lld/05 §9)
517
+ # ---------------------------------------------------------------------------------------
518
+
519
+
520
+ class CleanupHealth:
521
+ """The component's answer to "are you actually working?".
522
+
523
+ [INV-6](../../../docs/architecture/invariants.md) requires every component to expose a
524
+ health state, and this is the surface that makes J1's trade honest: the clips being
525
+ *held* are reported with their count, their size and their age, so "retention is running"
526
+ can never quietly mean "retention is running and the disk is filling" (LLD 05 §7).
527
+
528
+ Every non-healthy check names a code from `health.CODES`. A check that would need a new
529
+ code is a check worth discussing rather than inventing a string for.
530
+ """
531
+
532
+ def __init__(
533
+ self,
534
+ *,
535
+ settings: CleanupSettings,
536
+ config: CamerasConfig,
537
+ started_at: Instant | None = None,
538
+ clock: Any = timeutil.now,
539
+ ) -> None:
540
+ self._settings = settings
541
+ self._config = config
542
+ self._clock = clock
543
+ self._started_at = self._clock() if started_at is None else started_at
544
+ # Built here rather than injected, so the retention windows this reports on cannot
545
+ # come from a different set of settings than the ones it was configured with. Two
546
+ # objects that *should* agree but are passed separately eventually do not.
547
+ self._retention = LocalRetention(settings=settings, clock=clock)
548
+
549
+ # -- the report ---------------------------------------------------------------------
550
+
551
+ def report(self) -> health.HealthReport:
552
+ checks = (
553
+ self._state_db_check(),
554
+ self._clips_dir_check(),
555
+ self._last_run_check(),
556
+ self._overdue_check(),
557
+ self._disk_check(),
558
+ self._storage_check(),
559
+ )
560
+ return health.HealthReport(
561
+ component=COMPONENT,
562
+ checks=checks,
563
+ summary=self._summary(checks),
564
+ since=self._started_at,
565
+ uptime_s=max(0.0, self._clock() - self._started_at),
566
+ )
567
+
568
+ def _readonly(self) -> Any:
569
+ return db.open_db(self._settings.db_path, readonly=True)
570
+
571
+ def _state_db_check(self) -> health.Check:
572
+ try:
573
+ conn = self._readonly()
574
+ try:
575
+ conn.execute("SELECT COUNT(*) FROM clips").fetchone()
576
+ finally:
577
+ conn.close()
578
+ except (sqlite3.Error, db.DbError, OSError) as exc:
579
+ return health.Check(
580
+ name="state_db",
581
+ state=health.UNHEALTHY,
582
+ code="DB_UNAVAILABLE",
583
+ detail=f"cannot read the state store: {exc}",
584
+ )
585
+ return health.Check(name="state_db", state=health.HEALTHY)
586
+
587
+ def _clips_dir_check(self) -> health.Check:
588
+ clips = self._settings.clips_dir
589
+ if not clips.is_dir():
590
+ return health.Check(
591
+ name="clips_dir",
592
+ state=health.UNHEALTHY,
593
+ code="DISK_CRITICAL",
594
+ detail=f"{clips} does not exist, so nothing can be expired",
595
+ )
596
+ if not os.access(clips, os.W_OK):
597
+ return health.Check(
598
+ name="clips_dir",
599
+ state=health.UNHEALTHY,
600
+ code="DISK_CRITICAL",
601
+ detail=f"{clips} is not writable, so retention cannot delete anything",
602
+ )
603
+ return health.Check(name="clips_dir", state=health.HEALTHY)
604
+
605
+ def _last_run_check(self) -> health.Check:
606
+ interval = self._settings.run_interval_s
607
+ try:
608
+ conn = self._readonly()
609
+ try:
610
+ row = db.get_watermark(conn, LAST_RUN_NAME)
611
+ finally:
612
+ conn.close()
613
+ except (sqlite3.Error, db.DbError, OSError) as exc:
614
+ return health.Check(
615
+ name="last_run",
616
+ state=health.UNKNOWN,
617
+ code="CLEANUP_STALE",
618
+ detail=f"cannot read when cleanup last ran: {exc}",
619
+ )
620
+
621
+ if row is None or row["last_success_at"] is None:
622
+ # A service that has never completed a run is not healthy, however recently it
623
+ # started: INV-6 forbids "no information" rendering as good news.
624
+ return health.Check(
625
+ name="last_run",
626
+ state=health.DEGRADED,
627
+ code="CLEANUP_STALE",
628
+ detail="cleanup has not completed a run yet",
629
+ )
630
+
631
+ age = max(0.0, self._clock() - float(row["last_success_at"]))
632
+ detail = f"last successful run {_human(age)} ago"
633
+ if age < 2 * interval:
634
+ return health.Check(name="last_run", state=health.HEALTHY, detail=detail)
635
+ if age < 6 * interval:
636
+ return health.Check(
637
+ name="last_run",
638
+ state=health.DEGRADED,
639
+ code="CLEANUP_STALE",
640
+ detail=detail,
641
+ )
642
+ failures = int(row["consecutive_failures"] or 0)
643
+ return health.Check(
644
+ name="last_run",
645
+ state=health.UNHEALTHY,
646
+ code="CLEANUP_STALE",
647
+ detail=f"{detail}; {failures} consecutive failure(s)",
648
+ )
649
+
650
+ def overdue(self) -> db.OverdueSummary:
651
+ """Every camera's held clips, summed. Reported per scrape rather than stored, because
652
+ a count is cheap and an indexed query cannot go stale."""
653
+ count = 0
654
+ held = 0
655
+ oldest_start: Instant | None = None
656
+ oldest_overdue: float | None = None
657
+ try:
658
+ conn = self._readonly()
659
+ try:
660
+ now = self._clock()
661
+ for camera in db.known_cameras(conn):
662
+ name = str(camera["name"])
663
+ cutoff = self._retention.cutoff_for(
664
+ name,
665
+ config=self._config,
666
+ retired=camera["retired_at"] is not None,
667
+ now=now,
668
+ )
669
+ summary = db.overdue_local(conn, camera=name, cutoff=cutoff)
670
+ count += summary.count
671
+ held += summary.bytes_held
672
+ if summary.oldest_start_time is not None and (
673
+ oldest_start is None or summary.oldest_start_time < oldest_start
674
+ ):
675
+ oldest_start = summary.oldest_start_time
676
+ if summary.oldest_overdue_s is not None and (
677
+ oldest_overdue is None or summary.oldest_overdue_s > oldest_overdue
678
+ ):
679
+ oldest_overdue = summary.oldest_overdue_s
680
+ finally:
681
+ conn.close()
682
+ except (sqlite3.Error, db.DbError, OSError):
683
+ return db.OverdueSummary()
684
+ return db.OverdueSummary(
685
+ count=count,
686
+ bytes_held=held,
687
+ oldest_start_time=oldest_start,
688
+ oldest_overdue_s=oldest_overdue,
689
+ )
690
+
691
+ def _overdue_check(self) -> health.Check:
692
+ summary = self.overdue()
693
+ detail = f"{summary.count} clip(s) held, {_human_bytes(summary.bytes_held)}"
694
+ if summary.oldest_overdue_s is not None:
695
+ detail = f"{detail}, oldest overdue by {_human(summary.oldest_overdue_s)}"
696
+
697
+ if summary.count == 0:
698
+ return health.Check(name="clips_overdue_retention", state=health.HEALTHY, detail=detail)
699
+
700
+ too_old = summary.oldest_overdue_s is not None and summary.oldest_overdue_s > (
701
+ self._settings.overdue_age_critical_days * SECONDS_PER_DAY
702
+ )
703
+ return health.Check(
704
+ name="clips_overdue_retention",
705
+ state=(
706
+ health.UNHEALTHY
707
+ if summary.count > self._settings.clips_overdue_critical or too_old
708
+ else health.DEGRADED
709
+ ),
710
+ code="CLIPS_OVERDUE_RETENTION",
711
+ detail=detail,
712
+ )
713
+
714
+ def _disk_check(self) -> health.Check:
715
+ try:
716
+ usage = shutil.disk_usage(self._settings.data_dir)
717
+ except OSError as exc:
718
+ return health.Check(
719
+ name="disk_free",
720
+ state=health.UNKNOWN,
721
+ code="DISK_PRESSURE",
722
+ detail=f"cannot measure free space: {exc}",
723
+ )
724
+ if usage.total <= 0:
725
+ return health.Check(
726
+ name="disk_free",
727
+ state=health.UNKNOWN,
728
+ code="DISK_PRESSURE",
729
+ detail="the filesystem reports no size",
730
+ )
731
+
732
+ free_pct = usage.free / usage.total * 100.0
733
+ detail = f"{free_pct:.1f}% free ({_human_bytes(usage.free)} of {_human_bytes(usage.total)})"
734
+ if free_pct < self._settings.disk_critical_pct:
735
+ return health.Check(
736
+ name="disk_free",
737
+ state=health.UNHEALTHY,
738
+ code="DISK_CRITICAL",
739
+ detail=detail,
740
+ )
741
+ if free_pct < self._settings.disk_warn_pct:
742
+ return health.Check(
743
+ name="disk_free",
744
+ state=health.DEGRADED,
745
+ code="DISK_PRESSURE",
746
+ detail=detail,
747
+ )
748
+ return health.Check(name="disk_free", state=health.HEALTHY, detail=detail)
749
+
750
+ def _storage_check(self) -> health.Check:
751
+ """Cloud retention (J2) does not exist yet, and says so.
752
+
753
+ `DISABLED` rather than `HEALTHY`: an unimplemented check must never look like a check
754
+ that passed. `DISABLED` is neutral in `derive_state`, so it is honest without dragging
755
+ the component's state down over a capability that has not been claimed.
756
+ """
757
+ return health.Check(
758
+ name="storage_reachable",
759
+ state=health.DISABLED,
760
+ detail="cloud retention (J2) is not implemented yet",
761
+ )
762
+
763
+ def _summary(self, checks: Sequence[health.Check]) -> str:
764
+ by_name = {check.name: check for check in checks}
765
+ return (
766
+ f"{by_name['clips_overdue_retention'].detail}; "
767
+ f"{by_name['last_run'].detail}; {by_name['disk_free'].detail}"
768
+ )
769
+
770
+ def gauges(self) -> dict[str, float | int]:
771
+ summary = self.overdue()
772
+ free_pct = 0.0
773
+ try:
774
+ usage = shutil.disk_usage(self._settings.data_dir)
775
+ if usage.total > 0:
776
+ free_pct = round(usage.free / usage.total * 100.0, 2)
777
+ except OSError:
778
+ free_pct = 0.0
779
+ return {
780
+ "cctv_clips_overdue_retention": summary.count,
781
+ "cctv_clips_overdue_bytes": summary.bytes_held,
782
+ "cctv_disk_free_pct": free_pct,
783
+ }
784
+
785
+
786
+ # ---------------------------------------------------------------------------------------
787
+ # The service
788
+ # ---------------------------------------------------------------------------------------
789
+
790
+
791
+ class CleanupService:
792
+ """Runs J1 on its cadence, and records that it did."""
793
+
794
+ def __init__(
795
+ self,
796
+ *,
797
+ settings: CleanupSettings,
798
+ config: CamerasConfig,
799
+ clock: Any = timeutil.now,
800
+ ) -> None:
801
+ self._settings = settings
802
+ self._config = config
803
+ self._clock = clock
804
+ # Constructed from the same settings the service runs with. Injecting a ready-made
805
+ # `LocalRetention` would let a caller hand in a dry-run setting and a live runner —
806
+ # a combination that deletes files while claiming to be a rehearsal.
807
+ self._retention = LocalRetention(settings=settings, logger=logger, clock=clock)
808
+
809
+ def run_once(self) -> RetentionRun:
810
+ """One pass, on its own connection, with the outcome recorded either way."""
811
+ conn = db.open_db(self._settings.db_path)
812
+ try:
813
+ run = self._retention.run_once(conn, self._config)
814
+ except Exception as exc:
815
+ # Recorded, then re-raised: the caller decides whether to keep going, and the
816
+ # staleness check must see that a pass *failed* rather than never happened.
817
+ db.set_watermark(
818
+ conn,
819
+ name=LAST_RUN_NAME,
820
+ watermark=self._clock(),
821
+ succeeded=False,
822
+ note=f"{type(exc).__name__}: {exc}"[:200],
823
+ now=self._clock(),
824
+ )
825
+ raise
826
+ db.set_watermark(
827
+ conn,
828
+ name=LAST_RUN_NAME,
829
+ watermark=run.finished_at,
830
+ succeeded=True,
831
+ note=(
832
+ f"deleted={run.deleted} missing={run.missing} refused={run.refused} "
833
+ f"would_delete={run.would_delete}"
834
+ ),
835
+ now=self._clock(),
836
+ )
837
+ return run
838
+
839
+ def run(self, stop: threading.Event) -> None:
840
+ """Pass immediately, then on the interval, until asked to stop.
841
+
842
+ Immediately, because a service that waited a full interval before its first pass would
843
+ leave a freshly started install holding expired clips for an hour for no reason.
844
+ """
845
+ while not stop.is_set():
846
+ started = self._clock()
847
+ try:
848
+ run = self.run_once()
849
+ except Exception:
850
+ # The outermost loop of a process meant to run for years: log and keep going.
851
+ # A failed pass leaves the watermark untouched, so nothing is skipped.
852
+ logger.exception("cleanup pass failed; the next one will retry")
853
+ else:
854
+ self._log_run(run)
855
+ self._wait(stop, spent=max(0.0, self._clock() - started))
856
+
857
+ def _wait(self, stop: threading.Event, *, spent: float) -> None:
858
+ """Sleep out the rest of the interval, waking promptly on shutdown.
859
+
860
+ In small ticks rather than one long wait, so a `SIGTERM` during a quiet hour is acted
861
+ on within a second: a service that will not exit is one Docker eventually `SIGKILL`s.
862
+ """
863
+ remaining = max(0.0, self._settings.run_interval_s - spent)
864
+ tick = max(0.01, self._settings.scheduler_tick_s)
865
+ while remaining > 0 and not stop.is_set():
866
+ nap = min(tick, remaining)
867
+ stop.wait(nap)
868
+ remaining -= nap
869
+
870
+ def _log_run(self, run: RetentionRun) -> None:
871
+ logger.info(
872
+ "cleanup pass",
873
+ extra={
874
+ "count": run.deleted + run.missing,
875
+ "duration_s": round(run.duration_s, 3),
876
+ "state": (
877
+ f"cameras={run.cameras} considered={run.considered} "
878
+ f"deleted={run.deleted} missing={run.missing} "
879
+ f"refused={run.refused} would_delete={run.would_delete} "
880
+ f"freed={run.bytes_freed}"
881
+ ),
882
+ },
883
+ )
884
+
885
+
886
+ def _human(seconds: float) -> str:
887
+ if seconds < 90:
888
+ return f"{seconds:.0f}s"
889
+ if seconds < 5400:
890
+ return f"{seconds / 60:.0f}m"
891
+ if seconds < 172800:
892
+ return f"{seconds / 3600:.1f}h"
893
+ return f"{seconds / SECONDS_PER_DAY:.1f}d"
894
+
895
+
896
+ def _human_bytes(count: float) -> str:
897
+ value = float(count)
898
+ for unit in ("B", "KB", "MB", "GB"):
899
+ if value < 1024:
900
+ return f"{value:.1f} {unit}"
901
+ value /= 1024
902
+ return f"{value:.1f} TB"
903
+
904
+
905
+ def _install_signal_handlers(stop: threading.Event) -> None:
906
+ """Turn SIGTERM/SIGINT into a cooperative shutdown.
907
+
908
+ Deliberately duplicated from the uploader rather than extracted: a shared module for
909
+ fifteen lines would be indirection without insight, and the two services want different
910
+ things from their loops. A third service needing it is the moment to extract it.
911
+ """
912
+
913
+ def request_stop(signum: int, _frame: object) -> None:
914
+ if not stop.is_set():
915
+ logger.info("shutdown requested", extra={"reason": signal.Signals(signum).name})
916
+ stop.set()
917
+
918
+ for signum in (signal.SIGTERM, signal.SIGINT):
919
+ signal.signal(signum, request_stop)
920
+
921
+
922
+ def main(argv: Sequence[str] | None = None) -> int:
923
+ """LLD 00 §3's service skeleton: configure, validate, serve health, then work."""
924
+ del argv # every input is an environment variable; see CleanupSettings
925
+
926
+ try:
927
+ settings = CleanupSettings.from_environ()
928
+ except ConfigError as exc:
929
+ print(f"cctv-cleanup: {exc}", file=sys.stderr)
930
+ return EXIT_CONFIG
931
+
932
+ try:
933
+ configure_logging(settings.log_level)
934
+ except ValueError as exc:
935
+ print(f"cctv-cleanup: {exc}", file=sys.stderr)
936
+ return EXIT_CONFIG
937
+
938
+ problem = settings.configuration_problem()
939
+ if problem is not None:
940
+ logger.error("configuration is unusable", extra={"reason": problem})
941
+ return EXIT_CONFIG
942
+
943
+ if settings.dry_run:
944
+ logger.warning(
945
+ "DRY_RUN is set: every pass reports what it would delete and deletes nothing"
946
+ )
947
+
948
+ logger.info(
949
+ "starting",
950
+ extra={
951
+ "host": socket.gethostname(),
952
+ "state": "dry-run" if settings.dry_run else "live",
953
+ },
954
+ )
955
+
956
+ try:
957
+ config = load_cameras_config(settings.cameras_path)
958
+ except (ConfigError, OSError) as exc:
959
+ logger.error(
960
+ "configuration is unusable",
961
+ extra={"path": str(settings.cameras_path), "reason": str(exc)},
962
+ )
963
+ return EXIT_CONFIG
964
+
965
+ for warning in config.warnings:
966
+ logger.warning(warning)
967
+
968
+ conn = None
969
+ try:
970
+ conn = db.open_db(settings.db_path)
971
+ version = db.migrate(conn)
972
+ db.integrity_check(conn)
973
+ except (db.DbError, sqlite3.Error) as exc:
974
+ logger.error(
975
+ "state store is unusable",
976
+ extra={"path": str(settings.db_path), "reason": str(exc)},
977
+ )
978
+ return EXIT_UNAVAILABLE
979
+ finally:
980
+ if conn is not None:
981
+ conn.close()
982
+
983
+ logger.info("state store ready", extra={"state": f"schema-{version}"})
984
+
985
+ reporter = CleanupHealth(settings=settings, config=config)
986
+
987
+ stop = threading.Event()
988
+ _install_signal_handlers(stop)
989
+
990
+ server = None
991
+ if settings.health_port > 0:
992
+ server, _thread = health.start_health_server(
993
+ port=settings.health_port,
994
+ host=settings.health_host,
995
+ snapshot=reporter.report,
996
+ gauges=reporter.gauges,
997
+ )
998
+ logger.info(
999
+ "health endpoint listening",
1000
+ extra={"host": settings.health_host, "count": settings.health_port},
1001
+ )
1002
+ else:
1003
+ logger.warning("no health endpoint: CCTV_CLEANUP_HEALTH_PORT is 0")
1004
+
1005
+ service = CleanupService(settings=settings, config=config)
1006
+ try:
1007
+ service.run(stop)
1008
+ finally:
1009
+ if server is not None:
1010
+ server.shutdown()
1011
+ server.server_close()
1012
+ logger.info("stopped")
1013
+
1014
+ return EXIT_OK