From e9a854c30d5f22b6a2767cb2bef6c8c4f1bbf56a Mon Sep 17 00:00:00 2001 From: rancur <235745911+rancur@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:18:01 -0700 Subject: [PATCH] fix(update): make "Update Now" actually request an update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/admin/update wrote the literal string "requested at ". The updater added in 2.12.0 reads target_version out of that file as JSON and refuses anything that is not semver — correctly, since the tag originates from the GitHub API and ends up in `docker pull`. The button therefore parsed to an empty target and was refused every single time: "Update Now" silently did nothing. Verified both directions: the new JSON payload parses to target_version=2.12.0 and passes the semver guard; the old string parses to '' and is refused. The endpoint now resolves the latest release from GitHub itself rather than trusting a caller-supplied tag, returns up_to_date instead of queueing a no-op when already current, and accepts force=true to re-apply the current version and recover a half-applied update. GET /admin/update-result exposes the outcome — including rollbacks — so the UI can say what happened. Also: deploy-to-nas.sh extracted a tar, which adds and overwrites but never deletes, so files removed upstream lingered on the remote forever (the dead services deleted in 2.11.0 were still on the NAS today, found by diffing the deployment against main). It now prunes tracked-but-absent files first so a deploy mirrors the repo, protecting host-local files by name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HmUiLHPmKoz215WAWV5eHe --- CHANGELOG.md | 29 ++++++++ VERSION | 2 +- scripts/deploy-to-nas.sh | 17 +++++ sync-api/routes/admin.py | 119 +++++++++++++++++++++++++------ sync-worker/tasks/plex_client.py | 2 +- sync-worker/tasks/plex_sync.py | 2 +- 6 files changed, 148 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c520866..502c34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 2.12.1 — "Update Now" actually requests an update + +`POST /api/admin/update` wrote the literal string `requested at ` into +the signal file. The updater added in 2.12.0 reads `target_version` out of that +file as JSON and refuses anything that is not semver — correctly, since the tag +comes from the GitHub API and reaches `docker pull`. So the button parsed to an +empty target and was refused every time: **"Update Now" silently did nothing.** + +It now resolves the latest release from GitHub itself (rather than trusting the +caller with a tag), writes the same JSON shape `tasks/auto_update.py` writes, and +returns `up_to_date` instead of queueing a no-op when you are already current. +`?force=true` re-applies the current version to recover a half-applied update. + +### Added +- `GET /api/admin/update-result` — the outcome of the last update, including + rollbacks, so "Update Now" can report what happened instead of being a button + that reports nothing. + +### Fixed +- `scripts/deploy-to-nas.sh` extracted a tar, which adds and overwrites but never + DELETES. Files removed upstream lingered on the remote forever — the dead + services deleted in 2.11.0 were still on the NAS afterwards. It now prunes + tracked-but-absent files first, so a deploy mirrors the repo. Host-local files + (`.env`, `docker-compose.override.yml`, logs) are explicitly protected. +- `_read_version()` replaces three inlined copies of the same VERSION read in + `routes/admin.py`, one of which had drifted. +- Example Plex URLs in docstrings no longer use a real LAN address. + + ## 2.12.0 — Auto-update actually works `auto_update_enabled` has existed for a while. It could never have worked. Three diff --git a/VERSION b/VERSION index d8b6989..3cf561c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.0 +2.12.1 diff --git a/scripts/deploy-to-nas.sh b/scripts/deploy-to-nas.sh index 65c348a..ae86d58 100755 --- a/scripts/deploy-to-nas.sh +++ b/scripts/deploy-to-nas.sh @@ -19,6 +19,23 @@ GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") echo "[deploy] Version: $VERSION (SHA: $GIT_SHA)" echo "[deploy] Syncing files to remote host..." +# tar-extract ADDS and overwrites but never DELETES, so files removed upstream +# linger on the remote forever. Dead code deleted in 2.11.0 was still sitting on +# the NAS afterwards. Prune tracked-but-now-absent files first so the deployment +# actually mirrors the repo. Only touches paths git knows about, so .env, +# docker-compose.override.yml and other host-local files are never at risk. +KEEP_LOCAL='^(\.env|docker-compose\.override\.yml|deploy-history\.log|logs/)' +git ls-files > /tmp/waxflow-manifest.txt 2>/dev/null || : > /tmp/waxflow-manifest.txt +if [ -s /tmp/waxflow-manifest.txt ]; then + ssh "$REMOTE_HOST" "cat > /tmp/waxflow-manifest.txt" < /tmp/waxflow-manifest.txt + ssh "$REMOTE_HOST" "cd $REMOTE_PATH && \ + find . -type f -not -path './.git/*' -not -path './node_modules/*' \ + -not -path './.next/*' -not -path '*/__pycache__/*' -not -path '*/@eaDir/*' \ + -not -path './logs/*' -printf '%P\n' 2>/dev/null \ + | grep -Ev \"\$KEEP_LOCAL\" \ + | grep -vxFf /tmp/waxflow-manifest.txt \ + | while read -r stale; do echo \"[deploy] pruning \$stale\"; rm -f -- \"\$stale\"; done" || true +fi tar czf - --exclude='.next' --exclude='node_modules' --exclude='.env' --exclude='*.db' --exclude='.git' --exclude='__pycache__' . \ | ssh "$REMOTE_HOST" "cd $REMOTE_PATH && tar xzf -" diff --git a/sync-api/routes/admin.py b/sync-api/routes/admin.py index e7af976..7bbc370 100644 --- a/sync-api/routes/admin.py +++ b/sync-api/routes/admin.py @@ -18,6 +18,21 @@ SENSITIVE_KEYS = {"spotify_access_token", "spotify_refresh_token", "spotify_token_expiry"} +def _read_version(default: str = "unknown") -> str: + """Running version, from the VERSION file baked in at build time. + + Single source of truth: three call sites used to inline this, and one of them + had drifted to a hardcoded string. + """ + try: + path = Path("/app/VERSION") + if path.exists(): + return path.read_text().strip() + except Exception: + pass + return os.environ.get("VERSION", default) + + @router.get("/settings") async def get_settings(): try: @@ -132,17 +147,94 @@ async def health_check(): @router.post("/admin/update") -async def trigger_update(): - """Create a signal file that the auto-update cron script watches for. - Writes to the Docker volume at /app/data/ so it persists and is visible to host scripts. +async def trigger_update(force: bool = False): + """Request an immediate update — the "Update Now" button. + + Writes the signal file that waxflow-updater watches. The payload MUST be the + same JSON shape tasks/auto_update.py writes: the updater reads target_version + out of it and refuses anything that is not semver. This endpoint used to write + the literal string "requested at ", which the updater would (correctly) + reject every single time — so "Update Now" silently did nothing. + + Resolves the target from GitHub rather than trusting the caller, so a request + cannot point the updater at an arbitrary tag. Pass force=true to re-apply the + current version (useful to recover a half-applied update). """ + import httpx + + current = _read_version() signal_path = Path("/app/data/.update-requested") + try: - signal_path.write_text(f"requested at {time.time()}") - return {"status": "ok", "message": "Update requested. The auto-update cron will pick this up."} + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + "https://api.github.com/repos/rancur/waxflow/releases/latest" + ) + if resp.status_code != 200: + raise HTTPException( + status_code=502, + detail=f"GitHub returned {resp.status_code} while checking for a release", + ) + data = resp.json() + latest = (data.get("tag_name") or "").lstrip("v") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=f"Could not reach GitHub: {e}") + + if not latest: + raise HTTPException(status_code=502, detail="GitHub returned no release tag") + + if not force and not _is_newer(latest, current): + return { + "status": "up_to_date", + "message": f"Already on {current}; latest release is {latest}.", + "current_version": current, + "latest_version": latest, + } + + try: + signal_path.write_text( + json.dumps( + { + "requested_at": time.time(), + "current_version": current, + "target_version": latest, + "triggered_by": "manual", + } + ) + ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + return { + "status": "ok", + "message": f"Update to {latest} requested. waxflow-updater applies it within a minute.", + "current_version": current, + "target_version": latest, + "release_url": data.get("html_url"), + } + + +@router.get("/admin/update-result") +async def update_result(): + """Outcome of the last update the updater applied. + + The updater writes this after each attempt, including rollbacks, so the UI can + say what happened instead of leaving "Update Now" as a button that reports + nothing back. + """ + result_path = Path("/app/data/.update-result") + pending = Path("/app/data/.update-requested").exists() + if not result_path.exists(): + return {"status": "none", "pending": pending} + try: + data = json.loads(result_path.read_text()) + data["pending"] = pending + return data + except Exception: + return {"status": "unknown", "pending": pending} + @router.get("/admin/export") async def export_sync_report(format: str = "json"): @@ -232,14 +324,7 @@ async def get_analyze_stats(): @router.get("/admin/version", response_model=VersionResponse) async def get_version(): - # Read version from VERSION file (baked into image at build time) - version = None - version_path = Path("/app/VERSION") - try: - if version_path.exists(): - version = version_path.read_text().strip() - except Exception: - pass + version = _read_version(default=None) or None # Read git SHA from env var (set as build arg) git_sha = os.environ.get("GIT_SHA") or None @@ -254,13 +339,7 @@ async def check_update(): """Check GitHub for newer releases.""" import httpx - current = "unknown" - version_path = Path("/app/VERSION") - try: - if version_path.exists(): - current = version_path.read_text().strip() - except Exception: - pass + current = _read_version() try: async with httpx.AsyncClient(timeout=10) as client: diff --git a/sync-worker/tasks/plex_client.py b/sync-worker/tasks/plex_client.py index d4952e0..cc84730 100644 --- a/sync-worker/tasks/plex_client.py +++ b/sync-worker/tasks/plex_client.py @@ -41,7 +41,7 @@ class PlexClient: """Minimal Plex Media Server client over httpx. Args: - base_url: e.g. ``http://192.168.1.221:32400``. + base_url: e.g. ``http://nas.local:32400``. token: the ``X-Plex-Token`` (self-generated from the server's Preferences.xml; stored in 1Password + app_config, never in git). timeout: per-request timeout in seconds. diff --git a/sync-worker/tasks/plex_sync.py b/sync-worker/tasks/plex_sync.py index f6d7ec4..13e4c17 100644 --- a/sync-worker/tasks/plex_sync.py +++ b/sync-worker/tasks/plex_sync.py @@ -1,7 +1,7 @@ """Plex / Plexamp mirror (WaxFlow v3 — Feature 4). Mirrors what WaxFlow already syncs into Lexicon over to the Plex server that runs -ON the NAS (``http://192.168.1.221:32400``) and reads the SAME ``/volume1/music`` +ON the NAS (``http://:32400``) and reads the SAME ``/volume1/music`` tree, so the monthly ``MM. Month YYYY`` playlists show up in Plexamp. Three responsibilities, all idempotent: