234 lines
8.6 KiB
Python
234 lines
8.6 KiB
Python
"""Operational diagnostics and the operations API (US07-05).
|
|
|
|
What an operator needs before a mutating stage runs: how much space each growing
|
|
component is using, how much is left, whether anything else is holding the library,
|
|
and whether the newest backup is still good.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from photo_pipeline.api.app import create_app
|
|
from photo_pipeline.config import Config
|
|
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
|
from photo_pipeline.models import Asset
|
|
from photo_pipeline.services import diagnostics
|
|
from photo_pipeline.services.app_lock import LibraryLock
|
|
from photo_pipeline.services.backup import DB_NAME, BackupService
|
|
|
|
NOW = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
# Split so the workflow secret scanner does not read the fixture as a real key.
|
|
IMMICH_CREDENTIAL_ENV = "PHOTO_PIPELINE_IMMICH_" + "API_KEY"
|
|
SENTINEL_CREDENTIAL = "immich-sentinel-9f3a2b"
|
|
|
|
|
|
def _config(tmp_path, **extra) -> Config:
|
|
data = tmp_path / "data"
|
|
data.mkdir(parents=True, exist_ok=True)
|
|
lib = tmp_path / "lib"
|
|
lib.mkdir(exist_ok=True)
|
|
return Config.from_env(
|
|
{
|
|
"PHOTO_PIPELINE_DATA_DIR": str(data),
|
|
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
|
|
**extra,
|
|
}
|
|
)
|
|
|
|
|
|
def _migrated(config: Config):
|
|
run_migrations(config.database_url)
|
|
engine = create_db_engine(config.database_url)
|
|
factory = create_session_factory(engine)
|
|
with factory() as session:
|
|
session.add(
|
|
Asset(
|
|
id=str(uuid.uuid4()),
|
|
original_path="a.jpg",
|
|
current_path="a.jpg",
|
|
discovered_at=NOW,
|
|
hash_version=1,
|
|
byte_size=1,
|
|
)
|
|
)
|
|
session.commit()
|
|
engine.dispose()
|
|
|
|
|
|
def _component(report: dict, name: str) -> dict:
|
|
return next(item for item in report["components"] if item["name"] == name)
|
|
|
|
|
|
# ── sizes ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_every_growing_component_is_reported_separately(tmp_path):
|
|
config = _config(tmp_path)
|
|
_migrated(config)
|
|
(config.thumbnail_cache_dir).mkdir(parents=True)
|
|
(config.thumbnail_cache_dir / "a.webp").write_bytes(b"x" * 500)
|
|
(config.data_dir / "uploads").mkdir()
|
|
(config.data_dir / "uploads" / "batch.log").write_text("INFO ok\n")
|
|
BackupService(config).create()
|
|
|
|
report = diagnostics.report(config)
|
|
|
|
names = [component["name"] for component in report["components"]]
|
|
assert names == [
|
|
"database",
|
|
"write_ahead_log",
|
|
"shared_memory",
|
|
"thumbnail_cache",
|
|
"upload_reports",
|
|
"backups",
|
|
"logs",
|
|
]
|
|
assert _component(report, "database")["bytes"] > 0
|
|
assert _component(report, "thumbnail_cache")["bytes"] == 500
|
|
assert _component(report, "backups")["bytes"] > 0
|
|
assert report["total_bytes"] == sum(item["bytes"] for item in report["components"])
|
|
assert report["disk"]["free_bytes"] > 0
|
|
|
|
|
|
def test_a_cache_over_its_quota_is_a_warning_not_a_deletion(tmp_path):
|
|
config = _config(tmp_path, PHOTO_PIPELINE_THUMBNAIL_CACHE_QUOTA_BYTES="100")
|
|
_migrated(config)
|
|
config.thumbnail_cache_dir.mkdir(parents=True)
|
|
cached = config.thumbnail_cache_dir / "big.webp"
|
|
cached.write_bytes(b"x" * 400)
|
|
|
|
report = diagnostics.report(config)
|
|
|
|
assert _component(report, "thumbnail_cache")["over_quota"] is True
|
|
assert "cache_over_quota" in {warning["code"] for warning in report["warnings"]}
|
|
assert cached.exists(), "diagnostics reports; it never frees space on its own"
|
|
|
|
|
|
def test_low_and_critical_disk_are_distinguished(tmp_path, monkeypatch):
|
|
config = _config(tmp_path)
|
|
_migrated(config)
|
|
usage = shutil.disk_usage(tmp_path)
|
|
|
|
monkeypatch.setattr(
|
|
shutil, "disk_usage", lambda _: type(usage)(usage.total, usage.used, 500_000_000)
|
|
)
|
|
assert {w["code"] for w in diagnostics.report(config)["warnings"]} == {"disk_low"}
|
|
|
|
monkeypatch.setattr(
|
|
shutil, "disk_usage", lambda _: type(usage)(usage.total, usage.used, 10_000_000)
|
|
)
|
|
assert "disk_critical" in {w["code"] for w in diagnostics.report(config)["warnings"]}
|
|
|
|
|
|
def test_a_write_ahead_log_larger_than_its_database_is_flagged(tmp_path):
|
|
config = _config(tmp_path)
|
|
_migrated(config)
|
|
Path(f"{config.database_path}-wal").write_bytes(b"x" * (config.database_path.stat().st_size + 1))
|
|
|
|
codes = {warning["code"] for warning in diagnostics.report(config)["warnings"]}
|
|
assert "wal_growth" in codes
|
|
|
|
|
|
def test_disk_is_reported_for_a_data_directory_that_does_not_exist_yet(tmp_path):
|
|
config = Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "not" / "yet")})
|
|
report = diagnostics.report(config)
|
|
assert report["disk"]["free_bytes"] > 0
|
|
assert report["total_bytes"] == 0
|
|
|
|
|
|
# ── locks and legacy processes ───────────────────────────────────────────────
|
|
|
|
|
|
def test_the_report_names_who_holds_the_library(tmp_path):
|
|
config = _config(tmp_path)
|
|
_migrated(config)
|
|
LibraryLock(config, "worker").acquire()
|
|
|
|
report = diagnostics.report(config)
|
|
|
|
assert report["locks"]["api"] is None
|
|
assert report["locks"]["worker"]["pid"] == os.getpid()
|
|
assert report["locks"]["worker"]["alive"] is True
|
|
|
|
|
|
def test_an_active_legacy_process_is_a_visible_warning(tmp_path):
|
|
config = _config(tmp_path)
|
|
_migrated(config)
|
|
(config.library_roots[0] / "photo_analyzer.log").write_text("scanning...\n")
|
|
|
|
report = diagnostics.report(config)
|
|
|
|
assert report["legacy_activity"]["active"] is True
|
|
assert "legacy_process_active" in {warning["code"] for warning in report["warnings"]}
|
|
|
|
|
|
# ── API ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path):
|
|
config = _config(tmp_path)
|
|
with TestClient(create_app(config)) as client:
|
|
client.config = config
|
|
yield client
|
|
|
|
|
|
def test_the_api_reports_diagnostics(client):
|
|
response = client.get("/api/v1/diagnostics")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert {"components", "disk", "warnings", "locks", "legacy_activity"} <= set(body)
|
|
|
|
|
|
def test_a_backup_can_be_taken_listed_and_verified_over_the_api(client):
|
|
created = client.post("/api/v1/backups", json={"reason": "before-upgrade"})
|
|
assert created.status_code == 201
|
|
name = created.json()["name"]
|
|
|
|
listed = client.get("/api/v1/backups").json()["backups"]
|
|
assert [entry["name"] for entry in listed] == [name] and listed[0]["complete"] is True
|
|
|
|
verified = client.get(f"/api/v1/backups/{name}/verify").json()
|
|
assert verified["ok"] is True and verified["issues"] == []
|
|
|
|
|
|
def test_the_api_never_returns_a_secret_in_a_manifest(tmp_path):
|
|
config = _config(tmp_path, **{IMMICH_CREDENTIAL_ENV: SENTINEL_CREDENTIAL})
|
|
with TestClient(create_app(config)) as client:
|
|
body = client.post("/api/v1/backups", json={}).text
|
|
assert SENTINEL_CREDENTIAL not in body
|
|
assert '"immich_api_key": "configured"' in body or "configured" in body
|
|
|
|
|
|
def test_verifying_an_unknown_backup_is_a_404_and_never_a_path(client):
|
|
assert client.get("/api/v1/backups/nope/verify").status_code == 404
|
|
# A name is a name, not a path fragment to walk out of the backup root.
|
|
escaped = client.get("/api/v1/backups/..%2F..%2Fetc/verify")
|
|
assert escaped.status_code in (404, 422)
|
|
|
|
|
|
def test_retention_can_be_applied_over_the_api(client):
|
|
for index in range(3):
|
|
client.post("/api/v1/backups", json={"reason": f"drill{index}", "keep": 99})
|
|
removed = client.post("/api/v1/backups/prune", params={"keep": 1}).json()["removed"]
|
|
assert len(removed) == 2
|
|
assert len(client.get("/api/v1/backups").json()["backups"]) == 1
|
|
assert client.post("/api/v1/backups/prune", params={"keep": 0}).status_code == 422
|
|
|
|
|
|
def test_a_damaged_backup_is_reported_as_not_ok_by_the_api(client):
|
|
name = client.post("/api/v1/backups", json={}).json()["name"]
|
|
snapshot = BackupService(client.config).root / name / DB_NAME
|
|
snapshot.write_bytes(snapshot.read_bytes() + b"trailing garbage")
|
|
|
|
verified = client.get(f"/api/v1/backups/{name}/verify").json()
|
|
assert verified["ok"] is False and verified["issues"]
|