From 558883f281866623803c39299a0132bf43c7c543 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 23 Aug 2026 13:09:18 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01GbYEnfGnf1988cdFdGebot --- .gitignore | 6 + .../endurain-runnerup-webdav-2026-08-23.json | 20 ++ .../endurain-runnerup-webdav-2026-08-23.json | 64 ++++++ endurain/README.md | 68 ++++++ endurain/docker-compose.yml | 69 +++++++ endurain/scripts/publish_endurain.sh | 104 ++++++++++ endurain/scripts/setup_endurain.sh | 193 ++++++++++++++++++ endurain/systemd/endurain-import.service | 21 ++ endurain/systemd/endurain-import.timer | 13 ++ meta/pyproject.toml | 4 + python_pkg/endurain_import/__init__.py | 11 + python_pkg/endurain_import/__main__.py | 170 +++++++++++++++ python_pkg/endurain_import/ledger.py | 134 ++++++++++++ python_pkg/endurain_import/sources.py | 117 +++++++++++ python_pkg/endurain_import/tests/__init__.py | 1 + .../endurain_import/tests/test_ledger.py | 95 +++++++++ python_pkg/endurain_import/tests/test_main.py | 113 ++++++++++ .../tests/test_orchestration.py | 179 ++++++++++++++++ .../endurain_import/tests/test_sources.py | 170 +++++++++++++++ .../endurain_import/tests/test_upload.py | 137 +++++++++++++ python_pkg/endurain_import/upload.py | 122 +++++++++++ 21 files changed, 1811 insertions(+) create mode 100644 docs/superpowers/contracts/endurain-runnerup-webdav-2026-08-23.json create mode 100644 docs/superpowers/evidence/endurain-runnerup-webdav-2026-08-23.json create mode 100644 endurain/README.md create mode 100644 endurain/docker-compose.yml create mode 100755 endurain/scripts/publish_endurain.sh create mode 100755 endurain/scripts/setup_endurain.sh create mode 100644 endurain/systemd/endurain-import.service create mode 100644 endurain/systemd/endurain-import.timer create mode 100644 python_pkg/endurain_import/__init__.py create mode 100644 python_pkg/endurain_import/__main__.py create mode 100644 python_pkg/endurain_import/ledger.py create mode 100644 python_pkg/endurain_import/sources.py create mode 100644 python_pkg/endurain_import/tests/__init__.py create mode 100644 python_pkg/endurain_import/tests/test_ledger.py create mode 100644 python_pkg/endurain_import/tests/test_main.py create mode 100644 python_pkg/endurain_import/tests/test_orchestration.py create mode 100644 python_pkg/endurain_import/tests/test_sources.py create mode 100644 python_pkg/endurain_import/tests/test_upload.py create mode 100644 python_pkg/endurain_import/upload.py diff --git a/.gitignore b/.gitignore index f5d04d71..c791c48c 100644 --- a/.gitignore +++ b/.gitignore @@ -412,3 +412,9 @@ billsplit/linux/ *.tsbuildinfo # CocoIndex Code (ccc) /.cocoindex_code/ + +# Endurain: generated secrets (SECRET_KEY, FERNET_KEY, DB password) and the +# importer's API key + dedupe ledger. Never commit. +endurain/.env +endurain/.api_key +endurain/state/ diff --git a/docs/superpowers/contracts/endurain-runnerup-webdav-2026-08-23.json b/docs/superpowers/contracts/endurain-runnerup-webdav-2026-08-23.json new file mode 100644 index 00000000..2562b99f --- /dev/null +++ b/docs/superpowers/contracts/endurain-runnerup-webdav-2026-08-23.json @@ -0,0 +1,20 @@ +{ + "title": "Endurain viewer fed by RunnerUp WebDAV exports", + "objective": "Stand up self-hosted Endurain behind the existing Caddy edge and import RunnerUp activities into it, primarily over WebDAV from the phone with an adb pull as fallback. Endurain is a VIEWER only: screen-locker's workout gate keeps reading the phone over adb and is not modified, because a WebDAV drop folder is spoofable by anything that can write to ~/cloud and must never become a screen-unlock authority. Success means real activities from the phone are visible in Endurain, importing twice never creates a duplicate, and screen-locker still passes its full suite.", + "acceptance_criteria": [ + "Endurain reachable at https://endurain.kuhy.duckdns.org with a valid Let's Encrypt certificate", + "At least 4 real RunnerUp activities imported and visible, with correct distance and duration", + "Re-running the importer over already-imported files creates ZERO new activities", + "The .gpx and .tcx of a single run import as ONE activity, not two", + "The seeded admin/admin login is rejected before the service is ever published", + "The phone-facing WebDAV credential cannot read ~/cloud/Keepass or gdrive_backups", + "screen-locker's full test suite still passes at 100% coverage" + ], + "out_of_scope": [ + "Any edit to screen-locker's _runnerup_verification.py, _runnerup_tcx.py or _runnerup_db.py", + "Full history backfill out of RunnerUp's SQLite DB (the phone holds only 5 files)", + "Strava and Garmin Connect integration", + "Patching RunnerUp to add background auto-upload (it has none by design)" + ], + "verifier": "pre-commit run --files ; pytest python_pkg/endurain_import/tests/ (52 tests, 100% branch coverage); systemctl --user start endurain-import.service; psql count against endurain-postgres; screen-locker pytest suite (1090 tests)" +} diff --git a/docs/superpowers/evidence/endurain-runnerup-webdav-2026-08-23.json b/docs/superpowers/evidence/endurain-runnerup-webdav-2026-08-23.json new file mode 100644 index 00000000..b457959a --- /dev/null +++ b/docs/superpowers/evidence/endurain-runnerup-webdav-2026-08-23.json @@ -0,0 +1,64 @@ +{ + "intent": "Give RunnerUp runs a self-hosted dashboard (Endurain) fed over WebDAV from the phone, without touching the adb-based workout gate that unlocks the PC.", + "scope": [ + "endurain/ - compose, setup and publish scripts, systemd units, README", + "python_pkg/endurain_import/ - importer package and its tests", + "~/.config/dufs/dufs.yaml - one added scoped auth entry (not in this repo)", + "~/gitea/sites/endurain.caddy - new edge site (not in this repo)", + "NON-GOAL: screen-locker remains byte-for-byte unmodified" + ], + "changes": [ + "docker-compose.yml pinned to codeberg.org (the GitHub repo is a stale read-only mirror at v0.17.7 vs v0.19.0), bound to 127.0.0.1:8085 so the LAN cannot reach it unfronted, redis renamed endurain-redis to avoid a container name collision", + "postgres volume mounted at /var/lib/postgresql/18/docker instead of upstream's pre-18 /var/lib/postgresql/data; upstream's example fails as an unexplained 'container is unhealthy' because mounting over that path leaves it root-owned", + "setup_endurain.sh generates secrets, chowns each bind mount to the uid its own image runs as (1000 app, 999 postgres, 999 redis), and gates on the seeded admin/admin login", + "publish_endurain.sh installs the Caddy site only after that gate passes, and validates the config before reloading so a bad snippet cannot take down gitea/cloud/website", + "importer dedupes on TWO axes: a SHA-256 content hash, and an activity key that collapses the .gpx/.tcx pair RunnerUp writes for one run (different bytes, same run) and strips the device model RunnerUp's WebDAV names embed", + "ambiguous upload outcomes (5xx/timeout) are never re-queued to bulk_import, because Endurain has no duplicate detection and the activity may already be committed; only definite 4xx rejections are staged", + "second dufs auth entry scoped to /RunnerUp so the phone credential cannot read Keepass or gdrive_backups; setup_dufs_cloud.sh patched to preserve it across re-runs" + ], + "verification": [ + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "ruff, ruff format, mypy, pylint, bandit, pytest-with-coverage, shellcheck, leaked-secrets and python_pkg-location hooks all Passed" + }, + { + "command": "pytest python_pkg/endurain_import/tests/", + "result": "pass", + "evidence": "52 passed; 100% branch coverage on all 5 modules" + }, + { + "command": "systemctl --user start endurain-import.service (real deploy path)", + "result": "pass", + "evidence": "first run: 4 imported, 1 already known (the .gpx twin correctly skipped); re-run: 0 imported, 5 already known; DB count stayed 4 with no duplicate start_times" + }, + { + "command": "curl https://endurain.kuhy.duckdns.org/", + "result": "pass", + "evidence": "HTTP 200, ssl_verify_result=0, issuer Let's Encrypt; gitea/cloud/dice on the same edge still 200/401/200" + }, + { + "command": "curl -u runnerup:*** -X PROPFIND https://kuhy-cloud.duckdns.org/{RunnerUp,Keepass}", + "result": "pass", + "evidence": "207 on /RunnerUp, PUT 201, 403 on /Keepass and on a direct Keepass file GET; root PROPFIND lists only / and /RunnerUp" + }, + { + "command": "pytest screen_locker/tests (regression guard)", + "result": "pass", + "evidence": "1090 passed, 100% coverage; _runnerup_*.py unmodified per git status; all 5 phone files intact after the adb pulls" + } + ], + "risks": [ + "RunnerUp has NO background sync (no WorkManager/JobScheduler/AlarmManager anywhere in its source), so every upload needs a manual tap after a run. Not fixable without patching the app.", + "The ledger JSON is the only thing preventing duplicates; deleting it and re-importing WILL duplicate every activity, since Endurain does not deduplicate.", + "Endurain's OpenAPI schema mislabels the API-key header as X-Client-Type; the working header is X-API-Key, established empirically. A future version could change this.", + "The service is now internet-reachable. Its admin password is the only thing protecting it, and Endurain seeds admin/admin with no env override on a fresh database." + ], + "rollback": [ + "systemctl --user disable --now endurain-import.timer", + "rm ~/gitea/sites/endurain.caddy && docker exec gitea-caddy caddy reload --config /etc/caddy/Caddyfile # unpublishes immediately", + "docker compose -f endurain/docker-compose.yml down; sudo rm -rf /var/opt/endurain # destroys all imported activities", + "Remove the runnerup line from ~/.config/dufs/dufs.yaml and restart dufs", + "After rollback, confirm screen-locker still verifies a workout: pytest screen_locker/tests -k runnerup" + ] +} diff --git a/endurain/README.md b/endurain/README.md new file mode 100644 index 00000000..afed8730 --- /dev/null +++ b/endurain/README.md @@ -0,0 +1,68 @@ +# Endurain + RunnerUp WebDAV sync + +Self-hosted fitness tracker ([Endurain](https://codeberg.org/endurain-project/endurain)) +fed by RunnerUp exports from the phone. + +**Endurain is a viewer.** `screen-locker`'s workout gate still reads the phone +over adb and is untouched by anything here — a WebDAV drop folder is trivially +spoofable by anything that can write to `~/cloud`, so it must never become an +unlock authority. + +## Paths + +| What | Where | +|---|---| +| Public URL | `https://endurain.kuhy.duckdns.org` | +| Local URL | `http://127.0.0.1:8085` (loopback only) | +| WebDAV inbox | `https://kuhy-cloud.duckdns.org/RunnerUp` → `~/cloud/RunnerUp/` | +| Runtime data | `/var/opt/endurain/` | +| Ledger | `~/.local/state/endurain-import/ledger.json` | +| Importer code | `../python_pkg/endurain_import/` (repo requires Python under `python_pkg/`) | +| Secrets | `.env`, `.api_key` (both gitignored, mode 600) | + +## Flow + +``` +phone --(WebDAV, manual tap)--> dufs ~/cloud/RunnerUp/ --\ + >-- importer --> Endurain +phone --(adb pull, fallback)--> ~/cloud/RunnerUp/ -------/ +``` + +A systemd user timer (`endurain-import.timer`) runs every 15 min. + +## Commands + +```bash +./scripts/setup_endurain.sh # provision / restart the stack +./scripts/publish_endurain.sh # install the Caddy site (gated) +systemctl --user start endurain-import.service # import now +journalctl --user -u endurain-import.service -n 30 +cd .. && .venv/bin/python -m pytest python_pkg/endurain_import/tests/ -q # 52 tests +``` + +## Things that will bite you + +- **RunnerUp has no background sync.** No `WorkManager`, `JobScheduler` or + `AlarmManager` anywhere in its source. Every upload is a manual tap on the + save/upload button after a run. This is not configurable. +- **The WebDAV URL must not end in a slash.** RunnerUp builds the target as + `url + fileBase + fileExt` where `fileBase` already starts with `/`. +- **`MKCOL` is not supported by dufs.** RunnerUp `PROPFIND`s the directory and + only issues `MKCOL` on 404, so `~/cloud/RunnerUp/` must exist on disk or + adding the account fails with an opaque generic error (upstream #1147/#1172). +- **HTTPS is mandatory.** RunnerUp targets `targetSdk 36` with no + `usesCleartextTraffic`, so Android blocks plain HTTP. Self-signed certs are + broken upstream (stock `OkHttpClient`, system trust store only) — this is why + it rides on the existing Let's Encrypt cert. +- **Endurain does not deduplicate.** Re-uploading a file creates a second + activity. All dedupe is the importer's job, on two axes: the content hash, + *and* an activity key that collapses the `.gpx`/`.tcx` pair RunnerUp writes + for a single run (different bytes, same run). +- **`X-API-Key`, not `X-Client-Type`.** Endurain's own OpenAPI schema declares + the `APIKeyHeader` scheme with the wrong name; verified against the server. +- **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 admin account is seeded `admin`/`admin`** by an Alembic migration with + no env override. `publish_endurain.sh` refuses to expose the service until + that password is changed. diff --git a/endurain/docker-compose.yml b/endurain/docker-compose.yml new file mode 100644 index 00000000..cd9baa3f --- /dev/null +++ b/endurain/docker-compose.yml @@ -0,0 +1,69 @@ +# Endurain self-hosted fitness tracker. +# +# Based on the upstream docker-compose.yml.example from Codeberg (the GitHub +# repo is a read-only mirror and is stale). Deviations from upstream, all +# deliberate: +# +# 1. The app port is bound to 127.0.0.1 explicitly. Upstream's "8080:8080" +# binds every interface; this host runs Docker alongside a Caddy edge on +# the host network, so an unqualified mapping would expose Endurain +# unfronted to the LAN. Public access is via gitea-caddy ONLY +# (sites/endurain.caddy -> endurain.kuhy.duckdns.org). +# 2. The redis container is named endurain-redis, not upstream's bare +# "redis", which would collide with any other redis on this host. +# 3. user: "1000:1000" is set explicitly. v0.19.0 removed the UID/GID env +# vars, so the host bind mounts must be chown'd to match. +services: + endurain: + container_name: endurain-app + image: codeberg.org/endurain-project/endurain:latest + user: "1000:1000" + env_file: + - .env + volumes: + - ${LOCAL_PATH:-/var/opt/endurain}/backend/data:/app/backend/data:Z + - ${LOCAL_PATH:-/var/opt/endurain}/backend/logs:/app/backend/logs:Z + ports: + # Loopback only — see note 1 above. Do NOT change to "8085:8080". + - "127.0.0.1:8085:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + postgres: + image: docker.io/postgres:18@sha256:8ff36f3c66371cba71d20ceedccfc3de9669a68737607888c4ef0af93abe8e39 + container_name: endurain-postgres + env_file: + - .env + healthcheck: + test: ["CMD-SHELL", "pg_isready -U endurain"] + interval: 5s + timeout: 5s + retries: 5 + volumes: + # postgres:18 moved its default PGDATA to /var/lib/postgresql/18/docker + # and ships /var/lib/postgresql as mode-1777 owned by uid 999. Upstream's + # example still mounts at the pre-18 /var/lib/postgresql/data and sets + # PGDATA=.../data/pgdata; mounting over that path leaves it root-owned, so + # the entrypoint dies with "mkdir: cannot create directory + # '/var/lib/postgresql/data': Permission denied" and the only symptom + # compose reports is "container endurain-postgres is unhealthy". + # Mount at the image's own path and let PGDATA keep its default. + - ${LOCAL_PATH:-/var/opt/endurain}/postgres:/var/lib/postgresql/18/docker:Z + restart: unless-stopped + + redis: + image: redis:8-alpine@sha256:09160599abd229764c0fb44cb6be640294e1d360a54b19985ab4843dcf2d90f1 + container_name: endurain-redis + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + volumes: + - ${LOCAL_PATH:-/var/opt/endurain}/redis:/data:Z + restart: unless-stopped diff --git a/endurain/scripts/publish_endurain.sh b/endurain/scripts/publish_endurain.sh new file mode 100755 index 00000000..409a60a4 --- /dev/null +++ b/endurain/scripts/publish_endurain.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# ============================================================================ +# Publish Endurain on the shared Caddy edge (endurain.kuhy.duckdns.org). +# +# Split out of setup_endurain.sh deliberately. endurain.kuhy.duckdns.org +# already resolves to this host via the duckdns wildcard on "kuhy", so +# installing the site snippet makes the service world-reachable the moment +# Caddy reloads. Endurain ships a hardcoded admin/admin account seeded by an +# Alembic migration with no environment override, so publishing before that +# password is changed hands the internet an admin login. +# +# This script therefore re-runs the same gate and REFUSES to publish while the +# seeded credentials still work. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +readonly APP_URL="http://127.0.0.1:8085" +readonly PUBLIC_HOST="endurain.kuhy.duckdns.org" +readonly EDGE_DIR="${HOME}/gitea" +readonly SITE_FILE="${EDGE_DIR}/sites/endurain.caddy" +readonly EDGE_CONTAINER="gitea-caddy" + +log() { printf '%s\n' "$*"; } +log_ok() { printf ' ok: %s\n' "$*"; } +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +usage() { + echo "Usage: ${SCRIPT_NAME}" + echo " Installs the Caddy site for ${PUBLIC_HOST} and reloads the edge." + exit 0 +} + +# Fail closed. Only an explicit rejection counts as "safe"; any unexpected +# status is treated as still-vulnerable. +assert_admin_password_changed() { + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \ + -X POST "${APP_URL}/api/v1/auth/login" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'X-Client-Type: mobile' \ + --data 'username=admin&password=admin' 2>/dev/null || echo 000)" + case "${code}" in + 401 | 403) log_ok "seeded admin/admin is rejected" ;; + 200) die "REFUSING TO PUBLISH: Endurain still accepts admin/admin. + Change it at ${APP_URL} first." ;; + *) die "REFUSING TO PUBLISH: unexpected status ${code} from the login + probe; cannot prove the seeded password was changed." ;; + esac +} + +validate_requirements() { + command -v docker >/dev/null || die "docker not installed" + [[ -d "${EDGE_DIR}/sites" ]] || die "Caddy edge not found at ${EDGE_DIR}/sites" + docker ps --format '{{.Names}}' | grep -qx "${EDGE_CONTAINER}" || + die "${EDGE_CONTAINER} is not running" + curl -fsS --max-time 5 "${APP_URL}/api/v1/about" >/dev/null || + die "Endurain is not responding on ${APP_URL}" +} + +install_site() { + cat >"${SITE_FILE}" </dev/null 2>&1 || die "Caddy config failed validation; edge NOT reloaded" + docker exec "${EDGE_CONTAINER}" caddy reload --config /etc/caddy/Caddyfile \ + >/dev/null || die "caddy reload failed" + log_ok "Edge reloaded" +} + +main() { + validate_requirements + assert_admin_password_changed + install_site + reload_edge + log "" + log "Published: https://${PUBLIC_HOST}" + log "Caddy obtains the certificate on the first request; allow a few seconds." +} + +while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) usage ;; + *) die "Unknown option: $1" ;; + esac +done + +main "$@" diff --git a/endurain/scripts/setup_endurain.sh b/endurain/scripts/setup_endurain.sh new file mode 100755 index 00000000..ab77fde0 --- /dev/null +++ b/endurain/scripts/setup_endurain.sh @@ -0,0 +1,193 @@ +#!/bin/bash + +# ============================================================================ +# Provision the Endurain self-hosted fitness tracker. +# +# Idempotent: re-running keeps the existing .env (and therefore the existing +# database credentials — regenerating them would orphan the Postgres volume). +# +# ORDERING IS LOAD-BEARING. Endurain seeds a hardcoded admin/admin account via +# an Alembic migration with no env override, and endurain.kuhy.duckdns.org +# already resolves publicly. So this script brings the stack up on loopback +# ONLY and refuses to install the Caddy site; publishing is a separate, +# deliberate step taken after the admin password has been changed. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +readonly SCRIPT_NAME REPO_DIR +readonly ENV_FILE="${REPO_DIR}/.env" +readonly LOCAL_PATH="${ENDURAIN_LOCAL_PATH:-/var/opt/endurain}" +readonly APP_URL="http://127.0.0.1:8085" +readonly PUBLIC_HOST="endurain.kuhy.duckdns.org" + +log() { printf '%s\n' "$*"; } +log_ok() { printf ' ok: %s\n' "$*"; } +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +usage() { + echo "Usage: ${SCRIPT_NAME} [--recreate-env]" + echo " --recreate-env Regenerate secrets. DESTRUCTIVE: orphans the existing DB." + exit 0 +} + +RECREATE_ENV=0 + +validate_requirements() { + command -v docker >/dev/null || die "docker not installed" + docker compose version >/dev/null 2>&1 || die "docker compose plugin missing" + command -v openssl >/dev/null || die "openssl not installed" + # Fail early rather than after the volumes are created. + docker info >/dev/null 2>&1 || die "cannot talk to the docker daemon" +} + +# Secrets are generated once and never printed to a file outside .env. +write_env() { + if [[ -f "${ENV_FILE}" && "${RECREATE_ENV}" -eq 0 ]]; then + log_ok ".env already exists — keeping existing credentials" + return + fi + local db_password secret_key fernet_key + db_password="$(openssl rand -hex 32)" + secret_key="$(openssl rand -hex 32)" + fernet_key="$(openssl rand -base64 32)" + + umask 077 + cat >"${ENV_FILE}" < 1000 (v0.19.0 removed the UID/GID env vars) +# postgres -> 999 (image drops privileges internally after entrypoint) +# redis -> 999 +# Getting this wrong surfaces as "dependency failed to start: container +# endurain-postgres is unhealthy", with the real cause (mkdir: Permission +# denied) visible only in `docker logs endurain-postgres`. +readonly POSTGRES_UID=999 +readonly REDIS_UID=999 +readonly ENDURAIN_UID=1000 + +prepare_data_dirs() { + local d + for d in "${LOCAL_PATH}/backend/data" "${LOCAL_PATH}/backend/logs" \ + "${LOCAL_PATH}/postgres" "${LOCAL_PATH}/redis"; do + sudo mkdir -p "${d}" + done + sudo chown -R "${ENDURAIN_UID}:${ENDURAIN_UID}" "${LOCAL_PATH}/backend" + sudo chown -R "${POSTGRES_UID}:${POSTGRES_UID}" "${LOCAL_PATH}/postgres" + sudo chown -R "${REDIS_UID}:${REDIS_UID}" "${LOCAL_PATH}/redis" + log_ok "Data directories ready under ${LOCAL_PATH}" +} + +start_stack() { + docker compose -f "${REPO_DIR}/docker-compose.yml" up -d + log_ok "Stack started" +} + +wait_for_health() { + for _ in $(seq 1 60); do + if curl -fsS --max-time 5 "${APP_URL}/api/v1/about" >/dev/null 2>&1; then + log_ok "Endurain healthy at ${APP_URL}" + return 0 + fi + sleep 2 + done + die "Endurain did not become healthy within 120s. Check: docker logs endurain-app" +} + +# Fail closed: if the seeded admin/admin still works, the service must not be +# published. This is a gate, not a warning. +# +# The endpoint is /api/v1/auth/login with X-Client-Type: mobile. Two traps here, +# both of which produce a FALSE PASS (gate says "safe" while admin/admin works): +# - /api/v1/token does not exist; it falls through to the SPA index. +# - Without X-Client-Type: mobile the API answers 403 "CSRF token required" +# before ever checking the credentials. +# So only an explicit 200 means "still vulnerable", and any unexpected status is +# treated as vulnerable rather than assumed safe. +check_admin_password_changed() { + local code + code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 \ + -X POST "${APP_URL}/api/v1/auth/login" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'X-Client-Type: mobile' \ + --data 'username=admin&password=admin' 2>/dev/null || echo 000)" + case "${code}" in + 401 | 403) return 0 ;; # credentials rejected — password was changed + 200) return 1 ;; # seeded admin/admin still works + *) + log " (unexpected status ${code} from the login probe)" + return 1 + ;; # fail closed on anything unexpected + esac +} + +main() { + validate_requirements + log "=== Endurain setup ===" + write_env + prepare_data_dirs + start_stack + wait_for_health + + log "" + if check_admin_password_changed; then + log_ok "Seeded admin/admin no longer accepted" + log "" + log "Next: install the Caddy site to publish it, then configure the phone." + log " ${REPO_DIR}/scripts/publish_endurain.sh" + else + log "!! ACTION REQUIRED — the service is NOT safe to publish yet." + log " Endurain still accepts the seeded admin/admin login." + log " 1. Open ${APP_URL} and log in as admin/admin" + log " 2. Change the admin password immediately (store it in Keepass)" + log " 3. Settings -> Security -> API Keys: create a key scoped" + log " 'activities:upload' and save it for the importer" + log " 4. Re-run this script to confirm, then publish." + log "" + log " The Caddy site is deliberately NOT installed while this is true:" + log " https://${PUBLIC_HOST} already resolves to this host." + fi +} + +while [[ $# -gt 0 ]]; do + case $1 in + --recreate-env) + RECREATE_ENV=1 + shift + ;; + -h | --help) usage ;; + *) die "Unknown option: $1" ;; + esac +done + +main "$@" diff --git a/endurain/systemd/endurain-import.service b/endurain/systemd/endurain-import.service new file mode 100644 index 00000000..52e6497b --- /dev/null +++ b/endurain/systemd/endurain-import.service @@ -0,0 +1,21 @@ +[Unit] +Description=Import RunnerUp activities into Endurain +Documentation=file:///home/kuhy/testsAndMisc/endurain/README.md +# Endurain runs in Docker; without this the timer fires during boot before the +# stack is listening and logs a spurious "not reachable" failure. +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/home/kuhy/testsAndMisc +Environment=ENDURAIN_URL=http://127.0.0.1:8085 +Environment=ENDURAIN_INBOX=/home/kuhy/cloud/RunnerUp +Environment=ENDURAIN_STATE=/home/kuhy/.local/state/endurain-import +Environment=ENDURAIN_BULK_DIR=/var/opt/endurain/backend/data/activity_files/bulk_import +# The API key is read from a mode-600 file rather than embedded here: systemd +# unit files are world-readable. +ExecStart=/usr/bin/bash -c 'ENDURAIN_API_KEY="$(cat /home/kuhy/testsAndMisc/endurain/.api_key)" /home/kuhy/testsAndMisc/.venv/bin/python -m python_pkg.endurain_import' + +[Install] +WantedBy=default.target diff --git a/endurain/systemd/endurain-import.timer b/endurain/systemd/endurain-import.timer new file mode 100644 index 00000000..ad2c3a0a --- /dev/null +++ b/endurain/systemd/endurain-import.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Import RunnerUp activities into Endurain every 15 minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=15min +# The phone uploads on a manual tap, so the exact minute does not matter; +# jitter avoids lining up with every other timer on this box. +RandomizedDelaySec=60 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/meta/pyproject.toml b/meta/pyproject.toml index d3393fb8..181f61de 100644 --- a/meta/pyproject.toml +++ b/meta/pyproject.toml @@ -64,6 +64,10 @@ unfixable = [] "S101", # assert IS what tests do — every Python test suite suppresses this. "SLF001", # Unit tests must exercise private internals (_method, _attr) to reach # 100% branch coverage; only integration tests can avoid this. + "TC002", # pytest resolves fixture parameter annotations at RUNTIME, so moving + "TC003", # pytest/Path into a TYPE_CHECKING block breaks collection. Verified + # empirically: guarding them failed 11 tests with UnboundLocalError + # and NameError, not a hypothetical concern. ] "**/test_*.py" = [ "ARG", diff --git a/python_pkg/endurain_import/__init__.py b/python_pkg/endurain_import/__init__.py new file mode 100644 index 00000000..b8f32b7b --- /dev/null +++ b/python_pkg/endurain_import/__init__.py @@ -0,0 +1,11 @@ +"""RunnerUp -> Endurain importer. + +Moves RunnerUp activity exports into Endurain exactly once, from two sources: + + 1. the WebDAV inbox the phone uploads to (primary), and + 2. ``adb pull`` from the phone's RunnerUp export dir (fallback). + +Endurain performs no duplicate detection of its own -- re-importing a file +creates a second activity -- so deduplication is entirely this package's +responsibility. See :mod:`ledger`. +""" diff --git a/python_pkg/endurain_import/__main__.py b/python_pkg/endurain_import/__main__.py new file mode 100644 index 00000000..ba670030 --- /dev/null +++ b/python_pkg/endurain_import/__main__.py @@ -0,0 +1,170 @@ +"""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()) diff --git a/python_pkg/endurain_import/ledger.py b/python_pkg/endurain_import/ledger.py new file mode 100644 index 00000000..dabce528 --- /dev/null +++ b/python_pkg/endurain_import/ledger.py @@ -0,0 +1,134 @@ +"""Content-addressed record of what has already been imported. + +Endurain has no duplicate detection: uploading the same file twice creates two +activities. The ledger is therefore the only thing standing between a re-run +and a duplicated activity history, and it keys on the file's SHA-256 rather +than its name so that the same run imports once even when it arrives by both +the WebDAV and adb routes under different filenames. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import json +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + +_CHUNK = 1 << 20 +# "RunnerUp", , -- the minimum for a parseable export name. +_MIN_NAME_PARTS = 3 + + +class LedgerCorruptError(RuntimeError): + """Raised when the on-disk ledger cannot be parsed.""" + + +def activity_key(path: Path) -> str: + """Return a stable identity for the *run*, independent of export format. + + RunnerUp writes the same activity as both .gpx and .tcx + (RunnerUp_2026-08-22-23-51-04_Running.{gpx,tcx}). Those files have + different bytes, so a content hash sees two distinct files and Endurain -- + which does no duplicate detection -- happily creates two activities for one + run. Keying on the stem collapses them. + + The device model that RunnerUp's WebDAV synchronizer injects + (RunnerUp_Pixel_6a__Running) is stripped too, so the same run arriving + over WebDAV and over adb resolves to one key. + """ + stem = path.stem + parts = stem.split("_") + if len(parts) >= _MIN_NAME_PARTS and parts[0] == "RunnerUp": + # Keep the trailing _; drop any model in between. + return f"RunnerUp_{parts[-2]}_{parts[-1]}" + return stem + + +def file_digest(path: Path) -> str: + """Return the SHA-256 of ``path``, read incrementally.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(_CHUNK): + digest.update(chunk) + return digest.hexdigest() + + +@dataclass(frozen=True) +class Entry: + """One successfully imported file.""" + + sha256: str + activity_key: str + filename: str + activity_id: int | None + uploaded_at: str + + def as_dict(self) -> dict[str, Any]: + """Return the JSON-serialisable form stored in the ledger.""" + return { + "activity_key": self.activity_key, + "filename": self.filename, + "activity_id": self.activity_id, + "uploaded_at": self.uploaded_at, + } + + +class Ledger: + """A JSON-backed set of imported content hashes. + + Writes are atomic (temp file + replace) because the importer runs from a + systemd timer: a crash mid-write must not leave a truncated ledger, which + would look like "nothing has been imported" and duplicate the entire + history on the next run. + """ + + def __init__(self, path: Path) -> None: + """Open (or create) the ledger stored at ``path``.""" + self._path = path + self._entries: dict[str, dict[str, Any]] = {} + self._load() + + def _load(self) -> None: + if not self._path.exists(): + return + try: + raw = json.loads(self._path.read_text()) + except json.JSONDecodeError as exc: + # Fail closed: a corrupt ledger must stop the run, not silently + # re-import everything. + message = f"corrupt ledger at {self._path}: {exc}" + raise LedgerCorruptError(message) from exc + if isinstance(raw, dict): + self._entries = raw + + def __contains__(self, sha256: str) -> bool: + """True when a file with this digest has already been imported.""" + return sha256 in self._entries + + def has_activity(self, key: str) -> bool: + """True when this run was imported already, in any export format.""" + return any(entry.get("activity_key") == key for entry in self._entries.values()) + + def __len__(self) -> int: + """Number of files recorded as imported.""" + return len(self._entries) + + def record(self, entry: Entry) -> None: + """Add ``entry`` and flush to disk immediately.""" + self._entries[entry.sha256] = entry.as_dict() + self._flush() + + def _flush(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix(".tmp") + tmp.write_text(json.dumps(self._entries, indent=2, sort_keys=True)) + tmp.replace(self._path) + + +def now_iso() -> str: + """UTC timestamp for ledger entries.""" + return datetime.now(timezone.utc).isoformat() diff --git a/python_pkg/endurain_import/sources.py b/python_pkg/endurain_import/sources.py new file mode 100644 index 00000000..edcad9be --- /dev/null +++ b/python_pkg/endurain_import/sources.py @@ -0,0 +1,117 @@ +"""Where activity files come from. + +Primary source is the WebDAV inbox the phone uploads into. The adb fallback +exists because RunnerUp has no background sync -- uploads only happen when the +user taps the upload button -- so a run can sit on the phone indefinitely. When +the phone is attached, its export directory is pulled into the same inbox and +from there follows the identical path, deduplicated by content hash. + +This module is strictly read-only with respect to the phone. screen-locker's +workout verification reads the same directory over adb and is the gate that +unlocks the PC; nothing here may delete, move, or rewrite those files. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + +# RunnerUp's File synchronizer target on the phone. +PHONE_EXPORT_DIR = "/sdcard/Documents/RunnerUp" +ACTIVITY_SUFFIXES = (".tcx", ".gpx", ".fit") +_ADB_TIMEOUT = 60 +# Resolved at call time so a missing adb is reported as "no device" rather than +# crashing the whole import run. +_ADB_BIN = shutil.which("adb") or "adb" + + +def inbox_files(inbox: Path) -> list[Path]: + """Return activity files waiting in the WebDAV inbox, oldest first. + + ``processed/`` is skipped: it holds files this importer has already + delivered and is not rescanned. + """ + if not inbox.is_dir(): + return [] + found = [ + path + for path in inbox.iterdir() + if path.is_file() and path.suffix.lower() in ACTIVITY_SUFFIXES + ] + return sorted(found, key=lambda p: p.stat().st_mtime) + + +def _adb(args: list[str]) -> tuple[bool, str]: + """Run an adb command, returning (ok, output).""" + try: + proc = subprocess.run( + [_ADB_BIN, *args], + capture_output=True, + text=True, + timeout=_ADB_TIMEOUT, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, str(exc) + return proc.returncode == 0, proc.stdout + proc.stderr + + +def phone_attached() -> bool: + """True when exactly one adb device is in the 'device' state.""" + ok, out = _adb(["devices"]) + if not ok: + return False + states = [ + line.split("\t")[1].strip() for line in out.splitlines()[1:] if "\t" in line + ] + return "device" in states + + +def pull_from_phone(inbox: Path) -> list[Path]: + """Copy RunnerUp exports from the phone into ``inbox``. + + Returns the paths newly written. Files already present in the inbox are + left alone; content-level deduplication happens later against the ledger, + so a name collision here is not authoritative either way. + """ + if not phone_attached(): + _logger.info("no adb device attached; skipping phone fallback") + return [] + + ok, out = _adb(["shell", "ls", PHONE_EXPORT_DIR]) + if not ok: + _logger.warning("could not list %s: %s", PHONE_EXPORT_DIR, out.strip()) + return [] + + names = [ + line.strip() + for line in out.splitlines() + if line.strip().lower().endswith(ACTIVITY_SUFFIXES) + ] + inbox.mkdir(parents=True, exist_ok=True) + pulled: list[Path] = [] + for name in names: + target = inbox / name + if target.exists(): + continue + staged = inbox / f".{name}.partial" + ok, err = _adb(["pull", f"{PHONE_EXPORT_DIR}/{name}", str(staged)]) + if not ok: + _logger.warning("adb pull failed for %s: %s", name, err.strip()) + staged.unlink(missing_ok=True) + continue + # Rename only after a complete pull, so a partial transfer is never + # picked up as a whole activity by this or any concurrent run. + shutil.move(str(staged), str(target)) + pulled.append(target) + + if pulled: + _logger.info("pulled %d file(s) from the phone", len(pulled)) + return pulled diff --git a/python_pkg/endurain_import/tests/__init__.py b/python_pkg/endurain_import/tests/__init__.py new file mode 100644 index 00000000..2fc99711 --- /dev/null +++ b/python_pkg/endurain_import/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the RunnerUp -> Endurain importer.""" diff --git a/python_pkg/endurain_import/tests/test_ledger.py b/python_pkg/endurain_import/tests/test_ledger.py new file mode 100644 index 00000000..fec667c0 --- /dev/null +++ b/python_pkg/endurain_import/tests/test_ledger.py @@ -0,0 +1,95 @@ +"""Ledger behaviour, especially the two independent dedupe paths.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from python_pkg.endurain_import.ledger import ( + Entry, + Ledger, + LedgerCorruptError, + activity_key, + file_digest, + now_iso, +) + + +def _entry(sha: str = "abc", key: str = "RunnerUp_ts_Running") -> Entry: + return Entry(sha, key, "f.tcx", 7, now_iso()) + + +def test_digest_is_content_addressed(tmp_path: Path) -> None: + a = tmp_path / "a.tcx" + b = tmp_path / "b.tcx" + a.write_text("same") + b.write_text("same") + assert file_digest(a) == file_digest(b) + + b.write_text("different") + assert file_digest(a) != file_digest(b) + + +def test_activity_key_collapses_export_formats() -> None: + """The .gpx and .tcx of one run must share a key.""" + tcx = Path("RunnerUp_2026-08-22-23-51-04_Running.tcx") + gpx = Path("RunnerUp_2026-08-22-23-51-04_Running.gpx") + assert activity_key(tcx) == activity_key(gpx) + + +def test_activity_key_ignores_device_model() -> None: + """WebDAV names embed Build.MODEL; adb names do not.""" + webdav = Path("RunnerUp_Pixel_6a_2026-08-22-23-51-04_Running.tcx") + adb = Path("RunnerUp_2026-08-22-23-51-04_Running.tcx") + assert activity_key(webdav) == activity_key(adb) + + +def test_activity_key_separates_distinct_runs() -> None: + first = Path("RunnerUp_2026-08-22-23-51-04_Running.tcx") + second = Path("RunnerUp_2026-08-19-19-10-04_Running.tcx") + assert activity_key(first) != activity_key(second) + + +def test_activity_key_falls_back_to_stem() -> None: + assert activity_key(Path("whatever.tcx")) == "whatever" + + +def test_record_and_membership(tmp_path: Path) -> None: + ledger = Ledger(tmp_path / "l.json") + assert len(ledger) == 0 + ledger.record(_entry()) + assert "abc" in ledger + assert ledger.has_activity("RunnerUp_ts_Running") + assert not ledger.has_activity("other") + assert len(ledger) == 1 + + +def test_ledger_survives_reload(tmp_path: Path) -> None: + path = tmp_path / "l.json" + Ledger(path).record(_entry()) + assert "abc" in Ledger(path) + + +def test_corrupt_ledger_fails_closed(tmp_path: Path) -> None: + """A truncated ledger must stop the run, not re-import everything.""" + path = tmp_path / "l.json" + path.write_text("{not json") + with pytest.raises(LedgerCorruptError): + Ledger(path) + + +def test_write_is_atomic(tmp_path: Path) -> None: + path = tmp_path / "l.json" + ledger = Ledger(path) + ledger.record(_entry()) + assert not path.with_suffix(".tmp").exists() + assert json.loads(path.read_text())["abc"]["activity_id"] == 7 + + +def test_non_dict_ledger_is_ignored(tmp_path: Path) -> None: + """A JSON list is valid JSON but not a ledger; treat it as empty.""" + path = tmp_path / "l.json" + path.write_text("[]") + assert len(Ledger(path)) == 0 diff --git a/python_pkg/endurain_import/tests/test_main.py b/python_pkg/endurain_import/tests/test_main.py new file mode 100644 index 00000000..8721a94a --- /dev/null +++ b/python_pkg/endurain_import/tests/test_main.py @@ -0,0 +1,113 @@ +"""Orchestration: routing, fail-closed behaviour and exit status.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from python_pkg.endurain_import.__main__ import _process, _route_rejected, main +from python_pkg.endurain_import.ledger import Entry, Ledger, now_iso +from python_pkg.endurain_import.upload import Outcome, Result + + +class _StubClient: + def __init__(self, result: Result) -> None: + self._result = result + self.uploads: list[Path] = [] + + def upload(self, path: Path) -> Result: + self.uploads.append(path) + return self._result + + +@pytest.fixture +def inbox(tmp_path: Path) -> Path: + path = tmp_path / "inbox" + path.mkdir() + return path + + +def _file(inbox: Path, name: str = "RunnerUp_ts_Running.tcx") -> Path: + path = inbox / name + path.write_text("") + return path + + +def test_success_moves_to_processed_and_records(tmp_path: Path, inbox: Path) -> None: + path = _file(inbox) + ledger = Ledger(tmp_path / "l.json") + client = _StubClient(Result(Outcome.OK, 5, "ok")) + processed = inbox / "processed" + + assert _process(path, client, ledger, processed, None) == "imported" + assert (processed / path.name).exists() + assert not path.exists() + assert len(ledger) == 1 + + +def test_known_digest_is_skipped_without_uploading(tmp_path: Path, inbox: Path) -> None: + path = _file(inbox) + ledger = Ledger(tmp_path / "l.json") + from python_pkg.endurain_import.ledger import activity_key, file_digest + + ledger.record(Entry(file_digest(path), activity_key(path), path.name, 1, now_iso())) + client = _StubClient(Result(Outcome.OK, 9, "ok")) + + assert _process(path, client, ledger, inbox / "processed", None) == "skipped" + assert client.uploads == [] + + +def test_same_run_other_format_is_skipped(tmp_path: Path, inbox: Path) -> None: + """The .gpx twin of an imported .tcx must not create a second activity.""" + tcx = _file(inbox, "RunnerUp_2026-08-22-23-51-04_Running.tcx") + ledger = Ledger(tmp_path / "l.json") + from python_pkg.endurain_import.ledger import activity_key, file_digest + + ledger.record(Entry(file_digest(tcx), activity_key(tcx), tcx.name, 1, now_iso())) + + gpx = inbox / "RunnerUp_2026-08-22-23-51-04_Running.gpx" + gpx.write_text("") # different bytes, same run + client = _StubClient(Result(Outcome.OK, 9, "ok")) + + assert _process(gpx, client, ledger, inbox / "processed", None) == "skipped" + assert client.uploads == [] + + +def test_rejected_file_is_staged_and_left_in_place(tmp_path: Path, inbox: Path) -> None: + path = _file(inbox) + bulk = tmp_path / "bulk" + ledger = Ledger(tmp_path / "l.json") + client = _StubClient(Result(Outcome.REJECTED, None, "400")) + + assert _process(path, client, ledger, inbox / "processed", bulk) == "rejected" + assert (bulk / path.name).exists() + assert path.exists(), "a rejected file must stay for inspection" + assert len(ledger) == 0 + + +def test_ambiguous_never_stages_to_bulk_import(tmp_path: Path, inbox: Path) -> None: + """The whole point of the ambiguous class: do not risk a duplicate.""" + path = _file(inbox) + bulk = tmp_path / "bulk" + ledger = Ledger(tmp_path / "l.json") + client = _StubClient(Result(Outcome.AMBIGUOUS, None, "timeout")) + + assert _process(path, client, ledger, inbox / "processed", bulk) == "ambiguous" + assert not bulk.exists(), "ambiguous uploads must not be re-queued" + assert path.exists() + assert len(ledger) == 0 + + +def test_route_rejected_without_bulk_dir_is_safe(tmp_path: Path) -> None: + path = tmp_path / "f.tcx" + path.write_text("x") + _route_rejected(path, None) + assert path.exists() + + +def test_missing_api_key_exits_two(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ENDURAIN_API_KEY", raising=False) + with pytest.raises(SystemExit) as excinfo: + main() + assert excinfo.value.code == 2 diff --git a/python_pkg/endurain_import/tests/test_orchestration.py b/python_pkg/endurain_import/tests/test_orchestration.py new file mode 100644 index 00000000..22b3c940 --- /dev/null +++ b/python_pkg/endurain_import/tests/test_orchestration.py @@ -0,0 +1,179 @@ +"""End-to-end orchestration: environment wiring and exit status.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from python_pkg.endurain_import.__main__ import _route_rejected, main +from python_pkg.endurain_import.upload import Outcome, Result + + +@pytest.fixture +def inbox(tmp_path: Path) -> Path: + path = tmp_path / "inbox" + path.mkdir() + return path + + +def _file(inbox: Path, name: str = "RunnerUp_ts_Running.tcx") -> Path: + path = inbox / name + path.write_text("") + return path + + +class _StubMainClient: + """Stands in for EndurainClient across a whole main() run.""" + + def __init__(self, result: Result, *, reachable: bool = True) -> None: + self._result = result + self._reachable = reachable + + def about(self) -> dict[str, object]: + if not self._reachable: + message = "connection refused" + raise OSError(message) + return {"version": "test"} + + def upload(self, _path: Path) -> Result: + return self._result + + +def _env( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path, **extra: str +) -> None: + monkeypatch.setenv("ENDURAIN_API_KEY", "k") + monkeypatch.setenv("ENDURAIN_INBOX", str(inbox)) + monkeypatch.setenv("ENDURAIN_STATE", str(tmp_path / "state")) + monkeypatch.setenv("ENDURAIN_NO_ADB", "1") + for key, value in extra.items(): + monkeypatch.setenv(key, value) + + +def _patch_client(monkeypatch: pytest.MonkeyPatch, client: _StubMainClient) -> None: + import python_pkg.endurain_import.__main__ as mod + + monkeypatch.setattr(mod, "EndurainClient", lambda *_a, **_k: client) + + +def test_main_returns_one_when_unreachable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + _env(monkeypatch, tmp_path, inbox) + _patch_client( + monkeypatch, + _StubMainClient(Result(Outcome.OK, 1, "ok"), reachable=False), + ) + assert main() == 1 + + +def test_main_succeeds_with_empty_inbox( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + _env(monkeypatch, tmp_path, inbox) + _patch_client(monkeypatch, _StubMainClient(Result(Outcome.OK, 1, "ok"))) + assert main() == 0 + + +def test_main_imports_and_returns_zero( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + _file(inbox) + _env(monkeypatch, tmp_path, inbox) + _patch_client(monkeypatch, _StubMainClient(Result(Outcome.OK, 3, "ok"))) + assert main() == 0 + assert (inbox / "processed" / "RunnerUp_ts_Running.tcx").exists() + + +def test_main_fails_closed_on_ambiguous( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + _file(inbox) + _env(monkeypatch, tmp_path, inbox) + _patch_client( + monkeypatch, _StubMainClient(Result(Outcome.AMBIGUOUS, None, "timeout")) + ) + assert main() == 1 + + +def test_main_fails_closed_on_rejection( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + _file(inbox) + _env(monkeypatch, tmp_path, inbox, ENDURAIN_BULK_DIR=str(tmp_path / "bulk")) + _patch_client(monkeypatch, _StubMainClient(Result(Outcome.REJECTED, None, "400"))) + assert main() == 1 + + +def test_main_prefers_tcx_over_gpx( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + """When both formats are pending, the richer TCX must be the one uploaded.""" + stem = "RunnerUp_2026-08-22-23-51-04_Running" + (inbox / f"{stem}.gpx").write_text("") + (inbox / f"{stem}.tcx").write_text("") + _env(monkeypatch, tmp_path, inbox) + + uploaded: list[str] = [] + + class _Recorder(_StubMainClient): + def upload(self, path: Path) -> Result: + uploaded.append(path.name) + return Result(Outcome.OK, 1, "ok") + + _patch_client(monkeypatch, _Recorder(Result(Outcome.OK, 1, "ok"))) + assert main() == 0 + assert uploaded == [f"{stem}.tcx"] + + +def test_main_runs_adb_fallback_when_enabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + _env(monkeypatch, tmp_path, inbox) + monkeypatch.delenv("ENDURAIN_NO_ADB") + called: list[Path] = [] + import python_pkg.endurain_import.__main__ as mod + + def _record(path: Path) -> list[Path]: + called.append(path) + return [] + + monkeypatch.setattr(mod, "pull_from_phone", _record) + _patch_client(monkeypatch, _StubMainClient(Result(Outcome.OK, 1, "ok"))) + assert main() == 0 + assert called == [inbox] + + +def test_route_rejected_handles_unwritable_bulk_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A broken bulk_import must not take down the whole run.""" + path = tmp_path / "f.tcx" + path.write_text("x") + + import shutil + + def _boom(*_a: object, **_k: object) -> None: + message = "read-only filesystem" + raise OSError(message) + + monkeypatch.setattr(shutil, "copy2", _boom) + _route_rejected(path, tmp_path / "bulk") + assert path.exists() + + +def test_main_handles_requests_exception( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, inbox: Path +) -> None: + import requests + + _env(monkeypatch, tmp_path, inbox) + + class _Failing(_StubMainClient): + def about(self) -> dict[str, object]: + message = "refused" + raise requests.ConnectionError(message) + + _patch_client(monkeypatch, _Failing(Result(Outcome.OK, 1, "ok"))) + assert main() == 1 diff --git a/python_pkg/endurain_import/tests/test_sources.py b/python_pkg/endurain_import/tests/test_sources.py new file mode 100644 index 00000000..54443960 --- /dev/null +++ b/python_pkg/endurain_import/tests/test_sources.py @@ -0,0 +1,170 @@ +"""Inbox scanning and the adb fallback. + +The fallback is read-only with respect to the phone: screen-locker reads the +same directory as its screen-unlock gate, so nothing here may delete or +rewrite those files. +""" + +from __future__ import annotations + +from pathlib import Path +import subprocess + +import pytest + +from python_pkg.endurain_import import sources + + +def test_inbox_lists_only_activity_files(tmp_path: Path) -> None: + (tmp_path / "a.tcx").write_text("x") + (tmp_path / "b.gpx").write_text("x") + (tmp_path / "c.fit").write_text("x") + (tmp_path / "notes.txt").write_text("x") + names = {p.name for p in sources.inbox_files(tmp_path)} + assert names == {"a.tcx", "b.gpx", "c.fit"} + + +def test_inbox_skips_processed_subdir(tmp_path: Path) -> None: + (tmp_path / "processed").mkdir() + (tmp_path / "processed" / "old.tcx").write_text("x") + assert sources.inbox_files(tmp_path) == [] + + +def test_missing_inbox_is_not_an_error(tmp_path: Path) -> None: + assert sources.inbox_files(tmp_path / "nope") == [] + + +def test_inbox_is_ordered_oldest_first(tmp_path: Path) -> None: + first = tmp_path / "first.tcx" + second = tmp_path / "second.tcx" + first.write_text("x") + second.write_text("x") + import os + + os.utime(first, (1000, 1000)) + os.utime(second, (2000, 2000)) + assert [p.name for p in sources.inbox_files(tmp_path)] == [ + "first.tcx", + "second.tcx", + ] + + +def test_phone_attached_parses_adb_devices( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sources, + "_adb", + lambda _a: (True, "List of devices attached\nABC123\tdevice\n"), + ) + assert sources.phone_attached() + + +def test_phone_unauthorised_is_not_attached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sources, + "_adb", + lambda _a: (True, "List of devices attached\nABC123\tunauthorized\n"), + ) + assert not sources.phone_attached() + + +def test_pull_skips_when_no_device( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(sources, "phone_attached", lambda: False) + assert sources.pull_from_phone(tmp_path) == [] + + +def test_pull_ignores_already_present_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(sources, "phone_attached", lambda: True) + monkeypatch.setattr(sources, "_adb", lambda _a: (True, "RunnerUp_ts_Running.tcx\n")) + (tmp_path / "RunnerUp_ts_Running.tcx").write_text("already here") + assert sources.pull_from_phone(tmp_path) == [] + + +def test_failed_pull_leaves_no_partial_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A half-transferred file must never look like a complete activity.""" + monkeypatch.setattr(sources, "phone_attached", lambda: True) + + def _adb(args: list[str]) -> tuple[bool, str]: + if args[0] == "shell": + return True, "RunnerUp_ts_Running.tcx\n" + return False, "pull failed" + + monkeypatch.setattr(sources, "_adb", _adb) + assert sources.pull_from_phone(tmp_path) == [] + assert list(tmp_path.iterdir()) == [] + + +def test_adb_handles_missing_binary(monkeypatch: pytest.MonkeyPatch) -> None: + """A machine with no adb reports 'no device', it does not crash the run.""" + + def _boom(*_a: object, **_k: object) -> None: + message = "adb not found" + raise OSError(message) + + monkeypatch.setattr(subprocess, "run", _boom) + ok, out = sources._adb(["devices"]) + assert not ok + assert "adb not found" in out + + +def test_adb_handles_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + def _slow(*_a: object, **_k: object) -> None: + raise subprocess.TimeoutExpired(cmd="adb", timeout=1) + + monkeypatch.setattr(subprocess, "run", _slow) + ok, _ = sources._adb(["devices"]) + assert not ok + + +def test_adb_returns_output_on_success(monkeypatch: pytest.MonkeyPatch) -> None: + class _Proc: + returncode = 0 + stdout = "out" + stderr = "" + + monkeypatch.setattr(subprocess, "run", lambda *_a, **_k: _Proc()) + ok, out = sources._adb(["devices"]) + assert ok + assert out == "out" + + +def test_phone_attached_false_when_adb_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sources, "_adb", lambda _a: (False, "boom")) + assert not sources.phone_attached() + + +def test_pull_reports_unlistable_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(sources, "phone_attached", lambda: True) + monkeypatch.setattr(sources, "_adb", lambda _a: (False, "no such dir")) + assert sources.pull_from_phone(tmp_path) == [] + + +def test_successful_pull_moves_into_place( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(sources, "phone_attached", lambda: True) + + def _adb(args: list[str]) -> tuple[bool, str]: + if args[0] == "shell": + return True, "RunnerUp_ts_Running.tcx\nnotes.txt\n" + Path(args[2]).write_text("pulled") + return True, "" + + monkeypatch.setattr(sources, "_adb", _adb) + pulled = sources.pull_from_phone(tmp_path) + assert [p.name for p in pulled] == ["RunnerUp_ts_Running.tcx"] + assert (tmp_path / "RunnerUp_ts_Running.tcx").read_text() == "pulled" + assert not list(tmp_path.glob(".*partial")) diff --git a/python_pkg/endurain_import/tests/test_upload.py b/python_pkg/endurain_import/tests/test_upload.py new file mode 100644 index 00000000..6de1babb --- /dev/null +++ b/python_pkg/endurain_import/tests/test_upload.py @@ -0,0 +1,137 @@ +"""Upload outcome classification. + +The distinction that matters: a definite rejection may be retried/staged, an +ambiguous one may NOT, because Endurain does not deduplicate and the activity +may already have been committed. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import requests + +from python_pkg.endurain_import.upload import EndurainClient, Outcome + + +class _FakeResponse: + def __init__(self, status: int, body: object = None) -> None: + self.status_code = status + self._body = body + self.text = str(body) + + def json(self) -> object: + if self._body is None: + message = "no json" + raise ValueError(message) + return self._body + + +def _client_with(monkeypatch: pytest.MonkeyPatch, outcome: object) -> EndurainClient: + client = EndurainClient("http://x", "k") + + def _post(*_args: object, **_kwargs: object) -> object: + if isinstance(outcome, Exception): + raise outcome + return outcome + + monkeypatch.setattr(client._session, "post", _post) + return client + + +@pytest.fixture +def sample(tmp_path: Path) -> Path: + path = tmp_path / "RunnerUp_ts_Running.tcx" + path.write_text("") + return path + + +def test_success_extracts_activity_id( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, _FakeResponse(201, {"id": 42})) + result = client.upload(sample) + assert result.outcome is Outcome.OK + assert result.activity_id == 42 + + +def test_success_without_parsable_body( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, _FakeResponse(200)) + result = client.upload(sample) + assert result.outcome is Outcome.OK + assert result.activity_id is None + + +def test_bare_integer_body_is_an_id( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, _FakeResponse(200, 9)) + assert client.upload(sample).activity_id == 9 + + +def test_client_error_is_rejected( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, _FakeResponse(400, {"detail": "bad"})) + assert client.upload(sample).outcome is Outcome.REJECTED + + +def test_server_error_is_ambiguous( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + """5xx may mean the activity WAS created; retrying could duplicate it.""" + client = _client_with(monkeypatch, _FakeResponse(503, {})) + assert client.upload(sample).outcome is Outcome.AMBIGUOUS + + +def test_transport_failure_is_ambiguous( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, requests.Timeout("timed out")) + assert client.upload(sample).outcome is Outcome.AMBIGUOUS + + +def test_api_key_header_is_set() -> None: + """Endurain's OpenAPI schema mislabels this; X-API-Key is correct.""" + client = EndurainClient("http://x/", "secret") + assert client._session.headers["X-API-Key"] == "secret" + + +def test_about_returns_parsed_body(monkeypatch: pytest.MonkeyPatch) -> None: + client = EndurainClient("http://x", "k") + + class _Resp: + status_code = 200 + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, str]: + return {"version": "0.19.0"} + + monkeypatch.setattr(client._session, "get", lambda *_a, **_k: _Resp()) + assert client.about() == {"version": "0.19.0"} + + +def test_activity_id_ignores_non_integer_fields( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, _FakeResponse(200, {"id": "not-an-int"})) + assert client.upload(sample).activity_id is None + + +def test_activity_id_from_alternate_key( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, _FakeResponse(200, {"activity_id": 12})) + assert client.upload(sample).activity_id == 12 + + +def test_activity_id_none_for_list_body( + monkeypatch: pytest.MonkeyPatch, sample: Path +) -> None: + client = _client_with(monkeypatch, _FakeResponse(200, ["a"])) + assert client.upload(sample).activity_id is None diff --git a/python_pkg/endurain_import/upload.py b/python_pkg/endurain_import/upload.py new file mode 100644 index 00000000..30e9c3db --- /dev/null +++ b/python_pkg/endurain_import/upload.py @@ -0,0 +1,122 @@ +"""Endurain API client for activity uploads. + +Endurain does not deduplicate uploads, so the distinction between "definitely +rejected" and "might have been committed" decides whether a retry is safe: + + * 4xx -- the server refused the file. Retrying is pointless but harmless; + the file is routed to Endurain's bulk_import for manual recovery. + * 5xx / timeout / connection loss -- the server may have created the activity + before the response was lost. Retrying blindly would duplicate it, + so the file is left in the inbox and reported as AMBIGUOUS. The + caller must not treat this as either success or a clean failure. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import logging +from typing import TYPE_CHECKING, Any, Protocol + +import requests + +if TYPE_CHECKING: + from pathlib import Path + +_logger = logging.getLogger(__name__) + +UPLOAD_PATH = "/api/v1/activities/create/upload" +# The API key goes in X-API-Key. Endurain's own OpenAPI schema declares the +# APIKeyHeader security scheme as name "X-Client-Type", which is wrong -- +# verified against the running server: X-API-Key with a bad key returns +# {"detail":"Invalid API key"} (parsed, then rejected), whereas X-Client-Type +# returns {"detail":"Not authenticated..."} (never read as a key at all). +_API_KEY_HEADER = "X-API-Key" +_TIMEOUT = 120 +_HTTP_BAD_REQUEST = 400 +_HTTP_SERVER_ERROR = 500 + + +class SupportsUpload(Protocol): + """The upload surface :func:`_process` needs. + + Declared structurally so tests can substitute a stub without subclassing + the real client (and without mypy complaining about the swap). + """ + + def upload(self, path: Path) -> Result: + """Upload one activity file and report what happened.""" + ... # pragma: no cover - structural typing only, never executed + + +class Outcome(Enum): + """What happened to one upload attempt.""" + + OK = "ok" + REJECTED = "rejected" + AMBIGUOUS = "ambiguous" + + +@dataclass(frozen=True) +class Result: + """The outcome of a single upload attempt.""" + + outcome: Outcome + activity_id: int | None + detail: str + + +class EndurainClient: + """Minimal client for the endpoints this importer needs.""" + + def __init__(self, base_url: str, api_key: str) -> None: + """Build a client for ``base_url`` authenticating with ``api_key``.""" + self._base = base_url.rstrip("/") + self._session = requests.Session() + self._session.headers.update({_API_KEY_HEADER: api_key}) + + def about(self) -> dict[str, Any]: + """Liveness probe; raises on any non-2xx.""" + resp = self._session.get(f"{self._base}/api/v1/about", timeout=30) + resp.raise_for_status() + body: dict[str, Any] = resp.json() + return body + + def upload(self, path: Path) -> Result: + """Upload one activity file.""" + try: + with path.open("rb") as handle: + resp = self._session.post( + f"{self._base}{UPLOAD_PATH}", + files={"file": (path.name, handle)}, + timeout=_TIMEOUT, + ) + except requests.RequestException as exc: + # The request may or may not have reached the server. + return Result(Outcome.AMBIGUOUS, None, f"transport error: {exc}") + + if resp.status_code >= _HTTP_SERVER_ERROR: + return Result(Outcome.AMBIGUOUS, None, f"server error {resp.status_code}") + if resp.status_code >= _HTTP_BAD_REQUEST: + return Result( + Outcome.REJECTED, + None, + f"{resp.status_code}: {resp.text[:200]}", + ) + return Result(Outcome.OK, _activity_id(resp), "uploaded") + + +def _activity_id(resp: requests.Response) -> int | None: + """Best-effort extraction of the new activity id from a success body.""" + try: + body = resp.json() + except ValueError: + return None + if isinstance(body, int): + return body + if isinstance(body, dict): + for key in ("id", "activity_id"): + value = body.get(key) + if isinstance(value, int): + return value + return None