(feature) Require review before incomplete cleanup
Require incomplete snapshots to be marked reviewed before the cleanup action can delete them, and show review state in the retention plan UI. Keep cleanup confirmation counts scoped to reviewed incomplete snapshots and add coverage for blocked, reviewed, and deletion flows.
This commit is contained in:
@@ -23,7 +23,7 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
|
||||
host_config = _enabled_host_config(host)
|
||||
retention = _retention_for_host(host_config)
|
||||
snapshots = _snapshots_for_retention(host_config=host_config, kind=kind)
|
||||
incomplete_snapshots = _incomplete_snapshots_for_host(host_config)
|
||||
incomplete_items = _incomplete_snapshot_items_for_host(host_config)
|
||||
|
||||
plan = build_retention_plan(
|
||||
snapshots=snapshots,
|
||||
@@ -49,10 +49,9 @@ def run_sql_retention_plan(*, host: str, kind: str, protect_bases: bool) -> dict
|
||||
"keep": sorted(keep),
|
||||
"keep_items": [_snapshot_to_item(snapshot, reasons=reasons.get(snapshot.dirname, [])) for snapshot in keep_items],
|
||||
"delete": [_snapshot_to_item(snapshot, reasons=["outside retention policy"]) for snapshot in delete],
|
||||
"incomplete": [
|
||||
_snapshot_to_item(snapshot, reasons=["incomplete snapshot; excluded from retention cleanup"])
|
||||
for snapshot in incomplete_snapshots
|
||||
],
|
||||
"incomplete": incomplete_items,
|
||||
"incomplete_reviewed_count": sum(1 for item in incomplete_items if item["reviewed"]),
|
||||
"incomplete_unreviewed_count": sum(1 for item in incomplete_items if not item["reviewed"]),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
@@ -164,9 +163,15 @@ def run_incomplete_cleanup(
|
||||
|
||||
def _do_cleanup() -> dict[str, Any]:
|
||||
host_config = _enabled_host_config(host)
|
||||
unreviewed_count = _unreviewed_incomplete_count(host_config)
|
||||
if unreviewed_count:
|
||||
raise ConfigError(
|
||||
f"Refusing to delete {unreviewed_count} incomplete snapshot(s) that have not been reviewed."
|
||||
)
|
||||
|
||||
incomplete_list = [
|
||||
_snapshot_to_item(snapshot, reasons=["manual incomplete cleanup"])
|
||||
for snapshot in _incomplete_snapshots_for_host(host_config)
|
||||
for snapshot in _reviewed_incomplete_snapshots_for_host(host_config)
|
||||
]
|
||||
if max_delete == 0 and len(incomplete_list) > 0:
|
||||
raise ConfigError("Incomplete cleanup blocked by --max-delete=0")
|
||||
@@ -253,15 +258,39 @@ def _snapshots_for_retention(*, host_config: HostConfig, kind: str) -> list[Snap
|
||||
return [_snapshot_from_record(record) for record in records]
|
||||
|
||||
|
||||
def _incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
|
||||
def _incomplete_snapshot_items_for_host(host_config: HostConfig) -> list[dict[str, Any]]:
|
||||
records = (
|
||||
SnapshotRecord.objects.filter(host=host_config, kind=SnapshotRecord.Kind.INCOMPLETE)
|
||||
.select_related("base")
|
||||
.order_by("-started_at", "dirname")
|
||||
)
|
||||
return [
|
||||
_snapshot_record_to_item(record, reasons=["incomplete snapshot; excluded from retention cleanup"])
|
||||
for record in records
|
||||
]
|
||||
|
||||
|
||||
def _reviewed_incomplete_snapshots_for_host(host_config: HostConfig) -> list[Snapshot]:
|
||||
records = (
|
||||
SnapshotRecord.objects.filter(
|
||||
host=host_config,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
reviewed_at__isnull=False,
|
||||
)
|
||||
.select_related("base")
|
||||
.order_by("-started_at", "dirname")
|
||||
)
|
||||
return [_snapshot_from_record(record) for record in records]
|
||||
|
||||
|
||||
def _unreviewed_incomplete_count(host_config: HostConfig) -> int:
|
||||
return SnapshotRecord.objects.filter(
|
||||
host=host_config,
|
||||
kind=SnapshotRecord.Kind.INCOMPLETE,
|
||||
reviewed_at__isnull=True,
|
||||
).count()
|
||||
|
||||
|
||||
def _snapshot_from_record(record: SnapshotRecord) -> Snapshot:
|
||||
return Snapshot(
|
||||
kind=record.kind,
|
||||
@@ -301,6 +330,14 @@ def _snapshot_to_item(snapshot: Snapshot, *, reasons: list[str]) -> dict[str, An
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_record_to_item(record: SnapshotRecord, *, reasons: list[str]) -> dict[str, Any]:
|
||||
item = _snapshot_to_item(_snapshot_from_record(record), reasons=reasons)
|
||||
item["reviewed"] = record.reviewed_at is not None
|
||||
item["reviewed_at"] = record.reviewed_at.isoformat() if record.reviewed_at else ""
|
||||
item["reviewed_by"] = record.reviewed_by
|
||||
return item
|
||||
|
||||
|
||||
def _snapshot_delete_path(*, path: Path, dirname: str) -> Path:
|
||||
if path.name == "data" and path.parent.name == dirname:
|
||||
return path.parent
|
||||
|
||||
Reference in New Issue
Block a user