From 0b9b548d202a891aa94adde3c35c23d8de8fc626 Mon Sep 17 00:00:00 2001 From: Pigway Date: Thu, 8 Oct 2026 19:25:37 +0800 Subject: [PATCH] Preserve Photos deployment configuration and verify source dependencies --- .github/workflows/verify.yml | 2 ++ docs/deployment/GITEA_CD.md | 20 ++++++++++++ scripts/production-deploy.py | 3 ++ scripts/smoke-test.py | 19 +++++++++++ tests/test_smoke_dependencies.py | 56 ++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+) create mode 100644 tests/test_smoke_dependencies.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index d99ebbd..979610d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -56,6 +56,8 @@ jobs: PROD_DATA_DIR: ${{ vars.PROD_DATA_DIR }} PROD_CONFIG_DIR: ${{ vars.PROD_CONFIG_DIR }} PROD_PHOTOS_DIR: ${{ vars.PROD_PHOTOS_DIR }} + SYNOLOGY_PHOTOS_URL: ${{ vars.SYNOLOGY_PHOTOS_URL }} + SYNOLOGY_PHOTOS_CERT_SHA256: ${{ vars.SYNOLOGY_PHOTOS_CERT_SHA256 }} PROD_WEB_BIND: ${{ vars.PROD_WEB_BIND }} PROD_WEB_PORT: ${{ vars.PROD_WEB_PORT }} PROD_STATE_VOLUME: ${{ vars.PROD_STATE_VOLUME }} diff --git a/docs/deployment/GITEA_CD.md b/docs/deployment/GITEA_CD.md index 9e712a6..f1cd953 100644 --- a/docs/deployment/GITEA_CD.md +++ b/docs/deployment/GITEA_CD.md @@ -301,3 +301,23 @@ and change the workflow pin. All validation steps remain unchanged. Python toolcache misses may still require setup-python to download Python from its upstream release source. This removes Action source download dependence, not every possible external download. No Runner or NAS network configuration is changed. + +### Synology Photos dependencies + +For a production photo source using Synology Photos person filtering, configure +`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Variables. The +certificate fingerprint must match the configured HTTPS endpoint. Keep the Photos +session in the persistent `/config` directory; never put session credentials in +Variables or repository files. + +`PROD_PHOTOS_DIR` must identify the actual NAS photo directory mounted read-only +at `/photos`. Adoption requires that mount to exist on the current container; +changing this Variable alone does not create a new mount. Existing supplementary +permission groups are preserved by deployment. + +Restoring SQLite and session files does not restore these runtime dependencies. +Production smoke checks now reject an enabled people-filtered source if its +Photos configuration or stored connection is missing. These checks do not prove +session validity or scan the complete catalog: after restoring a source, also +verify its read-only `/api/v1/photo-sources/{id}/images?limit=1` endpoint before +running an automation task. diff --git a/scripts/production-deploy.py b/scripts/production-deploy.py index d896add..4705e1a 100644 --- a/scripts/production-deploy.py +++ b/scripts/production-deploy.py @@ -376,6 +376,9 @@ class Deploy: service["image"] = image proposed = self.secret_config() service["environment"].update(proposed) + for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"): + if self.env.get(key): + service["environment"][key] = self.env[key] service["environment"].update( KOKONNA_MOCK=self.env["PROD_MOCK"], KOKONNA_WRITE_ENABLED=self.env["PROD_WRITE_ENABLED"], diff --git a/scripts/smoke-test.py b/scripts/smoke-test.py index 4c74159..f52b44e 100644 --- a/scripts/smoke-test.py +++ b/scripts/smoke-test.py @@ -1,5 +1,6 @@ """Read-only HTTP smoke checks. No frame writes; token read from env/private .env.""" +import json import os from pathlib import Path from urllib.request import Request, urlopen @@ -26,4 +27,22 @@ for path in [ body = response.read() print(path, "HTTP", response.status, "bytes", len(body)) assert response.status == 200 +# A restored job is not usable merely because the scheduler is ready. +request = Request( + base + "/api/v1/photo-sources", headers={"Authorization": "Bearer " + token} +) +with urlopen(request, timeout=15) as response: + sources = json.load(response) +if any(source.get("enabled") and source.get("people_filter") for source in sources): + request = Request( + base + "/api/v1/integrations/synology-photos", + headers={"Authorization": "Bearer " + token}, + ) + with urlopen(request, timeout=15) as response: + photos = json.load(response) + if not photos.get("configured") or not photos.get("connected"): + raise RuntimeError( + "Enabled people-filtered photo source requires Photos connection" + ) + print("Synology Photos dependency configured and connected") print("Read-only smoke checks passed") diff --git a/tests/test_smoke_dependencies.py b/tests/test_smoke_dependencies.py new file mode 100644 index 0000000..621c0ca --- /dev/null +++ b/tests/test_smoke_dependencies.py @@ -0,0 +1,56 @@ +"""Production smoke must reject incomplete restored photo dependencies.""" + +import io +import json +import runpy +from pathlib import Path +from urllib.request import Request + +import pytest + + +@pytest.mark.parametrize( + "sources,connection,passes", + [ + ([], {}, True), + ([{"enabled": False, "people_filter": True}], {}, True), + ( + [{"enabled": True, "people_filter": True}], + {"configured": False, "connected": False}, + False, + ), + ( + [{"enabled": True, "people_filter": True}], + {"configured": True, "connected": False}, + False, + ), + ( + [{"enabled": True, "people_filter": True}], + {"configured": True, "connected": True}, + True, + ), + ], +) +def test_smoke_validates_people_filter_dependency( + monkeypatch, sources, connection, passes +): + calls = [] + + class Response(io.BytesIO): + status = 200 + + def read_only(request: Request, timeout): + assert request.get_method() == "GET" + calls.append(request.full_url) + value = sources if request.full_url.endswith("/photo-sources") else connection + return Response(json.dumps(value).encode()) + + monkeypatch.setenv("FRAME_FLOW_TOKEN", "fixture") + monkeypatch.setattr("urllib.request.urlopen", read_only) + script = Path(__file__).parents[1] / "scripts/smoke-test.py" + if passes: + runpy.run_path(str(script)) + else: + with pytest.raises(RuntimeError, match="requires Photos connection"): + runpy.run_path(str(script)) + assert len(calls) >= 10