testsAndMisc/python_pkg/endurain_import/__main__.py
Krzysztof kuhy Rudnicki 558883f281 feat(endurain): self-hosted run viewer fed by RunnerUp WebDAV exports
Stands up Endurain behind the existing Caddy edge and imports RunnerUp
activities into it. Endurain is a VIEWER: screen-locker's workout gate still
reads the phone over adb and is untouched here, because a WebDAV drop folder is
spoofable by anything that can write to ~/cloud and must not become a
screen-unlock authority.

Primary path is phone -> WebDAV (existing dufs) -> ~/cloud/RunnerUp/ ->
importer -> Endurain, with an adb pull as fallback when a run has not been
uploaded yet. A systemd user timer runs the importer every 15 min.

Findings that shaped the implementation, each verified against the running
system rather than assumed:

- Endurain moved to Codeberg; the GitHub repo is a read-only mirror stale at
  v0.17.7 against v0.19.0. The image is pulled from Codeberg.
- postgres:18 moved PGDATA to /var/lib/postgresql/18/docker. Upstream's compose
  example still mounts the pre-18 path, which fails as an unexplained
  "container is unhealthy"; the real cause is only visible in docker logs.
- Endurain seeds a hardcoded admin/admin via an Alembic migration with no env
  override, and endurain.kuhy.duckdns.org already resolved publicly. Publishing
  is therefore a separate script that refuses to install the Caddy site while
  those credentials still work.
- Endurain performs NO duplicate detection, so the importer owns dedupe on two
  axes: a content hash, and an activity key that collapses the .gpx/.tcx pair
  RunnerUp writes for a single run (different bytes, same run).
- Ambiguous uploads (5xx/timeout) are never re-queued, since the activity may
  already have been committed; only definite 4xx rejections are staged for
  retry. The importer exits non-zero so the timer surfaces the problem.
- RunnerUp has no background sync at all, so every upload is a manual tap.
- Its WebDAV synchronizer needs HTTPS with a real cert (Android blocks
  cleartext at targetSdk 36; self-signed is broken upstream) and a URL with no
  trailing slash, and cannot MKCOL against dufs - so the inbox must pre-exist.

The phone gets a dufs credential scoped to /RunnerUp, verified unable to read
Keepass or gdrive_backups.

Verified: 52 tests at 100% branch coverage; the timer imports 4 real runs and
re-runs create zero duplicates; https://endurain.kuhy.duckdns.org serves 200
with a valid cert and the other edge sites are unaffected; screen-locker's
suite still passes 1090 tests at 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot
2026-08-23 13:09:18 +02:00

171 lines
6.1 KiB
Python

"""Import RunnerUp activity files into Endurain, exactly once each.
Run from a systemd user timer. Configuration comes from the environment:
ENDURAIN_URL base URL (default http://127.0.0.1:8085)
ENDURAIN_API_KEY API key with the activities:upload scope (required)
ENDURAIN_INBOX WebDAV inbox (default ~/cloud/RunnerUp)
ENDURAIN_STATE ledger directory (default ~/.local/state/endurain-import)
ENDURAIN_BULK_DIR bulk_import dir for files the server rejected
ENDURAIN_NO_ADB set to 1 to disable the phone fallback
Exit status is 0 only when every file was accounted for; anything left in an
ambiguous state exits non-zero so the timer surfaces it rather than logging
quietly and moving on.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import shutil
import sys
import requests
from python_pkg.endurain_import.ledger import (
Entry,
Ledger,
activity_key,
file_digest,
now_iso,
)
from python_pkg.endurain_import.sources import inbox_files, pull_from_phone
from python_pkg.endurain_import.upload import EndurainClient, Outcome, SupportsUpload
_logger = logging.getLogger("endurain_import")
_DEFAULT_URL = "http://127.0.0.1:8085"
_DEFAULT_INBOX = Path.home() / "cloud" / "RunnerUp"
_DEFAULT_STATE = Path.home() / ".local" / "state" / "endurain-import"
def _configure_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
def _require_api_key() -> str:
key = os.environ.get("ENDURAIN_API_KEY", "").strip()
if not key:
_logger.error(
"ENDURAIN_API_KEY is not set. Create one in Endurain under "
"Settings -> Security -> API Keys with the activities:upload scope."
)
raise SystemExit(2)
return key
def _route_rejected(path: Path, bulk_dir: Path | None) -> None:
"""Copy a server-rejected file into bulk_import for manual recovery.
Only ever called for a definite 4xx. Ambiguous failures must not land here:
Endurain would import them a second time if the first attempt had in fact
been committed.
"""
if bulk_dir is None:
_logger.warning("no bulk_import dir configured; leaving %s", path.name)
return
try:
bulk_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, bulk_dir / path.name)
_logger.warning("copied %s to bulk_import for manual retry", path.name)
except OSError:
_logger.exception("could not stage %s for bulk import", path.name)
def _process(
path: Path,
client: SupportsUpload,
ledger: Ledger,
processed_dir: Path,
bulk_dir: Path | None,
) -> str:
"""Import one file. Returns one of: skipped, imported, rejected, ambiguous."""
digest = file_digest(path)
key = activity_key(path)
# Two independent dedupe checks. The digest catches the identical file
# arriving twice; the activity key catches the SAME RUN arriving as both
# .gpx and .tcx, whose bytes differ and which Endurain would otherwise
# import as two separate activities.
if digest in ledger or ledger.has_activity(key):
processed_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(path), str(processed_dir / path.name))
_logger.info("%s already imported; moved to processed/", path.name)
return "skipped"
result = client.upload(path)
if result.outcome is Outcome.OK:
ledger.record(Entry(digest, key, path.name, result.activity_id, now_iso()))
processed_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(path), str(processed_dir / path.name))
_logger.info("imported %s (activity_id=%s)", path.name, result.activity_id)
return "imported"
if result.outcome is Outcome.REJECTED:
_logger.error("rejected %s -- %s", path.name, result.detail)
_route_rejected(path, bulk_dir)
return "rejected"
# Ambiguous: leave the file exactly where it is and do NOT record it.
_logger.error(
"ambiguous outcome for %s -- %s. Left in the inbox; verify in Endurain "
"before the next run to avoid a duplicate.",
path.name,
result.detail,
)
return "ambiguous"
def main() -> int:
"""Run one import pass. Returns the process exit status."""
_configure_logging()
api_key = _require_api_key()
base_url = os.environ.get("ENDURAIN_URL", _DEFAULT_URL)
inbox = Path(os.environ.get("ENDURAIN_INBOX", _DEFAULT_INBOX))
state = Path(os.environ.get("ENDURAIN_STATE", _DEFAULT_STATE))
bulk = os.environ.get("ENDURAIN_BULK_DIR")
bulk_dir = Path(bulk) if bulk else None
client = EndurainClient(base_url, api_key)
try:
client.about()
# requests.RequestException covers the transport failures; OSError/ValueError
# cover a malformed response body. ConnectionError subclasses OSError, so a
# separate requests handler would be unreachable.
except (OSError, ValueError, RuntimeError, requests.RequestException):
_logger.exception("Endurain is not reachable at %s", base_url)
return 1
ledger = Ledger(state / "ledger.json")
processed_dir = inbox / "processed"
if os.environ.get("ENDURAIN_NO_ADB") != "1":
pull_from_phone(inbox)
# TCX carries more than GPX (laps, cadence, HR), so when a run is present
# in both formats the TCX must be the one that wins the dedupe race.
pending = sorted(inbox_files(inbox), key=lambda p: p.suffix.lower() != ".tcx")
if not pending:
_logger.info("nothing to import (ledger holds %d file(s))", len(ledger))
return 0
counts = {"skipped": 0, "imported": 0, "rejected": 0, "ambiguous": 0}
for path in pending:
counts[_process(path, client, ledger, processed_dir, bulk_dir)] += 1
_logger.info(
"done: %(imported)d imported, %(skipped)d already known, "
"%(rejected)d rejected, %(ambiguous)d ambiguous",
counts,
)
# Fail closed so the timer reports a problem instead of hiding it.
return 1 if counts["ambiguous"] or counts["rejected"] else 0
if __name__ == "__main__":
sys.exit(main())