Prune MAS evidence local runs after 30 days, as the owners accepted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 4541@bnt-lap001 Assistant-Session: 2099b3aa-d7d9-49a9-ab4e-fa8b7d0e39e8
This commit is contained in:
parent
4277088e1c
commit
efb17b4984
2 changed files with 57 additions and 1 deletions
|
|
@ -13,6 +13,10 @@ freshness
|
|||
Fails when the latest verified upload is older than STALE_HOURS, its
|
||||
receipt is not verified, or the local ciphertext no longer matches it.
|
||||
|
||||
After each verified upload, local run directories older than
|
||||
LOCAL_RETENTION_DAYS are pruned. The current run is never pruned, and
|
||||
nothing at the provider is ever deleted.
|
||||
|
||||
Credentials come only from /etc/mas-backup/primary.env through the systemd
|
||||
EnvironmentFile. No TEM or SMTP credential and no age identity is read here.
|
||||
"""
|
||||
|
|
@ -22,6 +26,8 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
|
@ -36,6 +42,8 @@ STATE = Path("/var/lib/mas-evidence-backup")
|
|||
RECIPIENT_FILE = Path("/etc/mas-backup/age-recipient")
|
||||
LATEST = "latest-offsite.json"
|
||||
STALE_HOURS = 26
|
||||
LOCAL_RETENTION_DAYS = 30 # owner-accepted 2026-11-03
|
||||
_RUN_NAME = re.compile(r"\A\d{8}T\d{6}Z\Z")
|
||||
APPLICATION = "mas"
|
||||
|
||||
|
||||
|
|
@ -120,7 +128,25 @@ def backup(state=STATE, capture=None, client_factory=None, now=None):
|
|||
archive.checkpoint(state / LATEST, {**receipt, "run": stamp, "capture": public})
|
||||
|
||||
receipt = publish_run(run_dir, captured["captured_at"], client, save_latest)
|
||||
return {**receipt, "run": stamp, "capture": public}
|
||||
pruned = prune_local(state, stamp, now or datetime.now(UTC))
|
||||
return {**receipt, "run": stamp, "capture": public,
|
||||
"local_retention_days": LOCAL_RETENTION_DAYS, "local_runs_pruned": pruned}
|
||||
|
||||
|
||||
def prune_local(state, keep, now, days=LOCAL_RETENTION_DAYS):
|
||||
"""Remove dated run directories older than `days`, never `keep`. Local only."""
|
||||
cutoff = now - timedelta(days=days)
|
||||
removed = []
|
||||
for entry in sorted(state.iterdir()):
|
||||
if entry.name == keep or not _RUN_NAME.fullmatch(entry.name):
|
||||
continue
|
||||
if entry.is_symlink() or not entry.is_dir() or entry.parent != state:
|
||||
continue
|
||||
stamp = datetime.strptime(entry.name, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
|
||||
if stamp < cutoff:
|
||||
shutil.rmtree(entry)
|
||||
removed.append(entry.name)
|
||||
return removed
|
||||
|
||||
|
||||
def freshness(state=STATE, now=None):
|
||||
|
|
|
|||
|
|
@ -136,3 +136,33 @@ def test_recipient_must_be_a_public_age_key(tmp_path):
|
|||
bad.write_text("AGE-SECRET-KEY-1XYZ")
|
||||
with pytest.raises(worker.archive.Refused):
|
||||
worker.recipient(bad)
|
||||
|
||||
|
||||
def test_prune_removes_only_old_dated_runs_and_never_the_current(env, tmp_path):
|
||||
env.mkdir(mode=0o700)
|
||||
for name in ("20261001T041500Z", "20261010T041500Z", "20261101T041500Z", "notes", "20261001T041500Z-x"):
|
||||
(env / name).mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(env / "20260901T041500Z").symlink_to(outside)
|
||||
removed = worker.prune_local(env, "20261103T041500Z", NOW)
|
||||
assert removed == ["20261001T041500Z"]
|
||||
remaining = sorted(p.name for p in env.iterdir())
|
||||
assert remaining == ["20260901T041500Z", "20261001T041500Z-x", "20261010T041500Z", "20261101T041500Z", "notes"]
|
||||
assert outside.exists()
|
||||
assert worker.prune_local(env, "20261010T041500Z", NOW + timedelta(days=40)) == ["20261101T041500Z"]
|
||||
|
||||
|
||||
def test_backup_reports_retention_and_prunes_after_verified_upload(env):
|
||||
env.mkdir(mode=0o700)
|
||||
(env / "20260901T041500Z").mkdir()
|
||||
result = worker.backup(env, FakeCapture(), lambda: Provider(), now=NOW)
|
||||
assert result["local_retention_days"] == 30 and result["local_runs_pruned"] == ["20260901T041500Z"]
|
||||
|
||||
|
||||
def test_failed_upload_prunes_nothing(env):
|
||||
env.mkdir(mode=0o700)
|
||||
(env / "20260901T041500Z").mkdir()
|
||||
with pytest.raises(TimeoutError):
|
||||
worker.backup(env, FakeCapture(), lambda: Provider(fail_complete=True), now=NOW)
|
||||
assert (env / "20260901T041500Z").exists()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue