Add demo export cleanup tooling
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 01:42:00 +02:00
parent 6b5c04456a
commit baf830fb5b
10 changed files with 303 additions and 1 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog # Changelog
## Sprint 24 demo/export artifact cleanup tooling (2026-06-17)
- Added `scripts/cleanup_demo_artifacts.py`, a dry-run-first maintenance script for old offline demo export artifacts.
- The cleanup keeps the newest exports per matching demo project, deletes only explicit `exports` rows/files when `--apply` is set and refuses file deletion outside `STORAGE_ROOT`.
- Added tests for cleanup candidate selection, storage-root path safety and readiness gate coverage.
- Added the cleanup script to the main readiness gate via Python compile validation.
- No API contracts, migrations, product features, provider fetching, AI inference or source dataset cleanup behavior were changed.
## Sprint 23 V1 report handoff summary (2026-06-17) ## Sprint 23 V1 report handoff summary (2026-06-17)
- Added V1 readiness summary data to project metadata exports. - Added V1 readiness summary data to project metadata exports.
+3
View File
@@ -185,6 +185,9 @@ bash scripts/live_migration_smoke.sh
- `GET /api/v1/exports/{export_id}/content` - `GET /api/v1/exports/{export_id}/content`
- Exported detection and segmentation GeoJSON is generated from persisted first-class geometry rows. - Exported detection and segmentation GeoJSON is generated from persisted first-class geometry rows.
- No new migrations, product lines, live providers or AI dependencies are introduced by this export pass. - No new migrations, product lines, live providers or AI dependencies are introduced by this export pass.
- Old offline demo export artifacts can be inspected with `python scripts/cleanup_demo_artifacts.py`
and removed only with an explicit `--apply`. The script keeps the newest exports
per demo project and refuses to delete files outside `STORAGE_ROOT`.
## Run locally ## Run locally
+7
View File
@@ -22,6 +22,13 @@ def test_readiness_gate_checks_demo_export_workflow_script_syntax() -> None:
assert "bash -n scripts/verify_demo_export_workflow.sh" in content assert "bash -n scripts/verify_demo_export_workflow.sh" in content
def test_readiness_gate_compiles_demo_cleanup_script() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "run_readiness_check.sh"
content = script.read_text(encoding="utf-8")
assert "-m py_compile scripts/cleanup_demo_artifacts.py" in content
def test_demo_export_workflow_script_verifies_export_endpoints() -> None: def test_demo_export_workflow_script_verifies_export_endpoints() -> None:
script = Path(__file__).resolve().parents[2] / "scripts" / "verify_demo_export_workflow.sh" script = Path(__file__).resolve().parents[2] / "scripts" / "verify_demo_export_workflow.sh"
content = script.read_text(encoding="utf-8") content = script.read_text(encoding="utf-8")
@@ -0,0 +1,75 @@
from __future__ import annotations
import importlib.util
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from types import ModuleType
def load_cleanup_module() -> ModuleType:
script = Path(__file__).resolve().parents[2] / "scripts" / "cleanup_demo_artifacts.py"
spec = importlib.util.spec_from_file_location("cleanup_demo_artifacts", script)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@dataclass
class ExportRow:
id: str
created_at: datetime | None
storage_path: str
def test_cleanup_candidate_selection_keeps_newest_exports() -> None:
cleanup = load_cleanup_module()
base = datetime(2026, 1, 1, 12, 0, 0)
exports = [
ExportRow("old", base, "/tmp/old.json"),
ExportRow("new", base + timedelta(days=2), "/tmp/new.json"),
ExportRow("middle", base + timedelta(days=1), "/tmp/middle.json"),
ExportRow("unknown", None, "/tmp/unknown.json"),
]
kept, candidates = cleanup.select_cleanup_candidates(exports, keep_latest=2)
assert [export.id for export in kept] == ["new", "middle"]
assert [export.id for export in candidates] == ["old", "unknown"]
def test_cleanup_candidate_selection_rejects_negative_keep_latest() -> None:
cleanup = load_cleanup_module()
try:
cleanup.select_cleanup_candidates([], keep_latest=-1)
except ValueError as exc:
assert "keep_latest" in str(exc)
else:
raise AssertionError("negative keep_latest should fail")
def test_cleanup_path_safety_requires_storage_root_containment(tmp_path: Path) -> None:
cleanup = load_cleanup_module()
storage_root = tmp_path / "storage"
safe_export = storage_root / "exports" / "project" / "report.html"
unsafe_export = tmp_path / "outside" / "report.html"
safe_export.parent.mkdir(parents=True)
unsafe_export.parent.mkdir(parents=True)
assert cleanup.is_within_storage_root(safe_export, storage_root) is True
assert cleanup.is_within_storage_root(unsafe_export, storage_root) is False
def test_cleanup_script_defaults_to_explicit_demo_project() -> None:
cleanup = load_cleanup_module()
parser = cleanup.build_parser()
args = parser.parse_args([])
assert args.project_name == cleanup.DEMO_PROJECT_NAME
assert args.keep_latest == 3
assert args.apply is False
+21 -1
View File
@@ -1,3 +1,23 @@
## Sprint 24 demo/export artifact cleanup tooling (2026-06-17)
Changed:
- Added `scripts/cleanup_demo_artifacts.py` for dry-run-first cleanup of old offline demo export artifacts.
- Cleanup is constrained to an exact demo project name by default, keeps the newest exports per project and refuses file deletion outside `STORAGE_ROOT`.
- Added regression tests for cleanup selection, path safety and readiness gate coverage.
- Added Python compile validation for the cleanup script to `scripts/run_readiness_check.sh`.
- Documented cleanup usage in `scripts/README.md`, `docs/STORAGE_ARCHITECTURE.md`, `backend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
Validation:
- `python -m py_compile scripts/cleanup_demo_artifacts.py` passed.
- `python -m pytest backend/tests/test_sprint24_cleanup_demo_artifacts.py backend/tests/test_readiness_gate.py` passed: 10 tests.
- `bash scripts/run_readiness_check.sh` passed: 151 backend tests, frontend typecheck/build, Alembic head check and script syntax checks.
- `python -m compileall backend/app` passed.
- `cd backend && python -m alembic upgrade head --sql` passed.
- `bash -n scripts/live_migration_smoke.sh` and `bash -n scripts/verify_demo_export_workflow.sh` passed.
- Local `docker compose config` could not run because the Windows Docker CLI is not installed in this Codex environment.
Notes:
- No API contracts, migrations, product features, provider fetching, AI inference or source dataset cleanup behavior changed.
## Sprint 23 V1 report handoff summary (2026-06-17) ## Sprint 23 V1 report handoff summary (2026-06-17)
Changed: Changed:
@@ -385,7 +405,7 @@ Date: 2026-06-11
### Next recommended pass ### Next recommended pass
- Run full raster end-to-end tests with real GeoTIFF fixtures and validate output dataset metadata persistence. - Run full raster end-to-end tests with real GeoTIFF fixtures and validate output dataset metadata persistence.
## Pass 16 Sprint 4 raster operations foundation hardening ## Pass 16 Sprint 4 raster operations foundation hardening
Date: 2026-06-11 Date: 2026-06-11
### Completed ### Completed
+14
View File
@@ -99,6 +99,20 @@ Mask files are provenance/debug artifacts. QA, map display and GeoJSON output mu
Do not delete originals automatically. Derived outputs may be cleaned through explicit cache management. Do not delete originals automatically. Derived outputs may be cleaned through explicit cache management.
Offline demo export artifacts can be inspected and cleaned with:
```bash
python scripts/cleanup_demo_artifacts.py
python scripts/cleanup_demo_artifacts.py --keep-latest 3 --apply
```
The script is dry-run by default, targets only the explicit
`GeoIntel Demo - Building QA` project unless an exact `--project-name` is
provided, keeps the newest export artifacts per matching project and refuses to
delete files outside `STORAGE_ROOT`. It cleans `exports` records/files only; it
does not remove original uploads, vector features, QA/QC rows, projects, areas,
tiles, rasters or masks.
## Model storage ## Model storage
Model artifacts live under: Model artifacts live under:
+1
View File
@@ -33,6 +33,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] V1 readiness handoff summary in project metadata/report exports. - [x] V1 readiness handoff summary in project metadata/report exports.
- [x] Browser-facing demo/export workflow smoke script with connected V1 state checks. - [x] Browser-facing demo/export workflow smoke script with connected V1 state checks.
- [x] Compact V1 workbench status strip for project, AOI, datasets, map, QA/QC and exports. - [x] Compact V1 workbench status strip for project, AOI, datasets, map, QA/QC and exports.
- [x] Dry-run-first demo export artifact cleanup tooling.
- [x] Live Docker/PostGIS validation on Tower/Unraid. - [x] Live Docker/PostGIS validation on Tower/Unraid.
- [ ] Real YOLO compatibility smoke with optional AI extras and local model file. - [ ] Real YOLO compatibility smoke with optional AI extras and local model file.
- [ ] Further frontend state/module decomposition beyond Sprint 10 extraction. - [ ] Further frontend state/module decomposition beyond Sprint 10 extraction.
+13
View File
@@ -23,6 +23,19 @@ datasets, vector FeatureCollection content, vector feature summary, persisted
QA/QC metrics, creates metadata/report/vector GeoJSON exports, lists exports QA/QC metrics, creates metadata/report/vector GeoJSON exports, lists exports
and downloads the JSON/GeoJSON/HTML artifacts through the frontend proxy. and downloads the JSON/GeoJSON/HTML artifacts through the frontend proxy.
Clean old offline demo export artifacts without touching uploaded source data:
```bash
python scripts/cleanup_demo_artifacts.py
python scripts/cleanup_demo_artifacts.py --keep-latest 3 --apply
```
The cleanup script is dry-run by default. It only targets the explicit
`GeoIntel Demo - Building QA` project unless `--project-name` is provided, keeps
the newest exports per matching project, deletes only `exports` rows/files when
`--apply` is set, and refuses to remove files outside the configured
`STORAGE_ROOT`.
## Tower deployment ## Tower deployment
Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime: Push the local branch to Gitea, then rebuild the Unraid/Tower Docker runtime:
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
from app.core.config import get_settings
from app.db.session import SessionLocal
from app.models import Export, Project
DEMO_PROJECT_NAME = "GeoIntel Demo - Building QA"
def is_within_storage_root(path: Path, storage_root: Path) -> bool:
try:
path.resolve().relative_to(storage_root.resolve())
except ValueError:
return False
return True
def export_created_at(export: Any) -> datetime:
created_at = getattr(export, "created_at", None)
if isinstance(created_at, datetime):
return created_at
return datetime.min
def select_cleanup_candidates(exports: list[Any], keep_latest: int) -> tuple[list[Any], list[Any]]:
if keep_latest < 0:
raise ValueError("keep_latest must be greater than or equal to zero")
ordered = sorted(exports, key=export_created_at, reverse=True)
return ordered[:keep_latest], ordered[keep_latest:]
def export_path(export: Any) -> Path:
return Path(str(getattr(export, "storage_path")))
def prune_empty_parents(start_path: Path, storage_root: Path) -> list[str]:
pruned: list[str] = []
parent = start_path.resolve().parent
stop_at = storage_root.resolve()
while parent != stop_at and is_within_storage_root(parent, stop_at):
try:
parent.rmdir()
except OSError:
break
pruned.append(str(parent))
parent = parent.parent
return pruned
def cleanup_demo_exports(project_name: str, keep_latest: int, apply: bool) -> dict[str, Any]:
settings = get_settings()
storage_root = Path(settings.storage_root).resolve()
summary: dict[str, Any] = {
"dry_run": not apply,
"project_name": project_name,
"storage_root": str(storage_root),
"projects": [],
"matched_export_count": 0,
"selected_export_count": 0,
"deleted_export_count": 0,
"deleted_files": [],
"missing_files": [],
"skipped_outside_storage": [],
"pruned_dirs": [],
"kept_export_ids": [],
}
with SessionLocal() as db:
projects = (
db.query(Project)
.filter(Project.name == project_name)
.filter(Project.status != "deleted")
.order_by(Project.created_at.desc())
.all()
)
for project in projects:
exports = (
db.query(Export)
.filter(Export.project_id == project.id)
.order_by(Export.created_at.desc())
.all()
)
kept, candidates = select_cleanup_candidates(exports, keep_latest)
summary["projects"].append(str(project.id))
summary["matched_export_count"] += len(exports)
summary["selected_export_count"] += len(candidates)
summary["kept_export_ids"].extend(str(export.id) for export in kept)
for export in candidates:
path = export_path(export)
if not is_within_storage_root(path, storage_root):
summary["skipped_outside_storage"].append(
{"export_id": str(export.id), "storage_path": str(path)}
)
continue
if path.exists():
if apply:
path.unlink()
summary["pruned_dirs"].extend(prune_empty_parents(path, storage_root))
summary["deleted_files"].append(str(path))
else:
summary["missing_files"].append({"export_id": str(export.id), "storage_path": str(path)})
if apply:
db.delete(export)
summary["deleted_export_count"] += 1
if apply:
db.commit()
return summary
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Clean old offline demo export artifacts. The script is dry-run by default "
"and only targets the explicit GeoIntel demo project unless overridden."
)
)
parser.add_argument("--project-name", default=DEMO_PROJECT_NAME, help="Exact project name to clean.")
parser.add_argument(
"--keep-latest",
type=int,
default=3,
help="Number of newest export records/files to keep per matching project.",
)
parser.add_argument("--apply", action="store_true", help="Delete selected export rows and files.")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.keep_latest < 0:
parser.error("--keep-latest must be greater than or equal to zero")
summary = cleanup_demo_exports(
project_name=args.project_name,
keep_latest=args.keep_latest,
apply=args.apply,
)
print(json.dumps(summary, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -37,6 +37,7 @@ ${PYTHON_BIN} scripts/validate_m13_codex_assets.py
${PYTHON_BIN} scripts/validate_m14_launch_assets.py ${PYTHON_BIN} scripts/validate_m14_launch_assets.py
${PYTHON_BIN} -m py_compile scripts/gis_import_smoke.py ${PYTHON_BIN} -m py_compile scripts/gis_import_smoke.py
${PYTHON_BIN} -m py_compile scripts/seed_demo_workflow.py ${PYTHON_BIN} -m py_compile scripts/seed_demo_workflow.py
${PYTHON_BIN} -m py_compile scripts/cleanup_demo_artifacts.py
${PYTHON_BIN} -m compileall backend/app ${PYTHON_BIN} -m compileall backend/app
(cd backend && ${PYTHON_BIN} -m pytest -W error::DeprecationWarning) (cd backend && ${PYTHON_BIN} -m pytest -W error::DeprecationWarning)
(cd backend && ${PYTHON_BIN} -m alembic heads) (cd backend && ${PYTHON_BIN} -m alembic heads)