Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,18 @@ CORS_ORIGINS=http://localhost:8400
# SUBDIRECTORY of the bind mount, not the mount root, so the share root stays
# clean and Plex path translation keeps working.
MUSIC_LIBRARY_PATH=/music/Database

# --- Auto-update --------------------------------------------------------------
# The waxflow-updater service applies updates by PULLING prebuilt images (fast,
# no compiler on your box) and rolling back if the API is unhealthy afterwards.
# It mounts the Docker socket, so it deliberately runs with NO network: the
# worker decides what to update to, and the Docker daemon fetches the layers.
#
# Turn the whole thing off by removing the waxflow-updater service, or set the
# `auto_update_enabled` setting to 0 in the web UI.
# WAXFLOW_UPDATE_POLL_SECONDS=60
# WAXFLOW_HEALTH_TIMEOUT=180
#
# Run your own registry/fork? Point these at it:
# WAXFLOW_REGISTRY=ghcr.io/youruser
# WAXFLOW_IMAGE_TAG=2.12.0
88 changes: 88 additions & 0 deletions .github/workflows/release-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Publish release images

# Auto-update pulls prebuilt images instead of rebuilding from source. That is
# what makes an unattended 3am update safe: a from-source rebuild of the worker
# took ~25 minutes on a Synology NAS and wedged the Docker daemon once. Pulling a
# published layer takes seconds and needs no compiler on the user's box.
#
# Publishes:
# ghcr.io/<owner>/waxflow-api:<version> + :latest
# ghcr.io/<owner>/waxflow-worker:<version> + :latest
# ghcr.io/<owner>/waxflow-web:<version> + :latest

on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version to build and push (e.g. 2.11.0)"
required: true

permissions:
contents: read
packages: write

jobs:
publish:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- service: api
context: .
dockerfile: sync-api/Dockerfile
- service: worker
context: ./sync-worker
dockerfile: ./sync-worker/Dockerfile
- service: web
context: ./sync-web
dockerfile: ./sync-web/Dockerfile

steps:
- uses: actions/checkout@v4

- name: Resolve version
id: v
run: |
if [ -n "${{ github.event.inputs.version }}" ]; then
V="${{ github.event.inputs.version }}"
else
V="${GITHUB_REF_NAME#v}"
fi
# VERSION in the repo is the source of truth; refuse a mismatched tag
# rather than publishing an image whose /app/VERSION disagrees with it.
FILE_V="$(cat VERSION | tr -d '[:space:]')"
if [ "$V" != "$FILE_V" ]; then
echo "::error::tag/input version '$V' != VERSION file '$FILE_V'"
exit 1
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
# linux/amd64 covers Synology/Intel NAS boxes; arm64 covers Apple
# silicon and Raspberry Pi hosts.
platforms: linux/amd64,linux/arm64
push: true
build-args: |
GIT_SHA=${{ github.sha }}
tags: |
ghcr.io/${{ steps.v.outputs.owner }}/waxflow-${{ matrix.service }}:${{ steps.v.outputs.version }}
ghcr.io/${{ steps.v.outputs.owner }}/waxflow-${{ matrix.service }}:latest
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,59 @@
# Changelog

## 2.12.0 — Auto-update actually works

`auto_update_enabled` has existed for a while. It could never have worked. Three
independent reasons, each sufficient on its own:

1. **The version check used a string compare.** `"2.11.0" > "2.9.0"` is `False`
(at index 2, `"1" < "9"`), so every x.9 -> x.10+ upgrade was invisible. The
reverse was `True`, meaning the check could have offered a **downgrade** as an
update. Present in both `routes/admin.py` and `tasks/auto_update.py`.
2. **Nothing applied the update.** `scripts/auto-update.sh` had to be installed in
the host's crontab by hand; nothing shipped it, so the signal file the worker
wrote was never read by anything.
3. **Even when triggered, it did not update.** The script ran
`docker compose up -d --build` against the source already on disk — a rebuild
of the same version.

### Added — `waxflow-updater`
A container cannot restart itself, so applying an update needs host-side Docker
access. This is the only place WaxFlow asks for it, and it is deliberately
constrained:

- **`network_mode: none`.** It never downloads anything. The worker (network, no
socket) decides the target version; the Docker *daemon* fetches image layers
when asked over the socket. Nothing with host-root access talks to the internet.
- **Rollback.** After applying, it health-checks the API and restores the previous
tag if the new version does not come up. This runs unattended at 3am by default;
an update that half-applies and is never noticed is worse than one that never ran.
- **Input is treated as untrusted.** The target tag comes from the GitHub API and
is refused unless it matches semver, so nothing unexpected reaches `docker pull`.

Delete the service from `docker-compose.yml` if you would rather not grant socket
access; everything else keeps working.

### Added — published images
`.github/workflows/release-images.yml` builds and pushes
`ghcr.io/<owner>/waxflow-{api,worker,web}` (linux/amd64 + linux/arm64) on every
published release, and refuses to publish if the tag disagrees with `VERSION`.

Updating is now a pull, not a rebuild. The rebuild path took ~25 minutes for the
worker image on a Synology NAS and wedged the Docker daemon once — unacceptable
for an unattended 3am job. `build:` blocks remain, so `docker compose up -d
--build` still works offline and for forks (`WAXFLOW_REGISTRY`).

### Changed
- `auto_update_enabled` defaults to `1` for **new** installs. `INSERT OR IGNORE`
means existing deployments keep whatever they already had.
- Compose images are `${WAXFLOW_REGISTRY:-ghcr.io/rancur}/waxflow-*:${WAXFLOW_IMAGE_TAG:-${VERSION:-latest}}`.

### Tests
`tests/test_auto_update_version.py` — 7 cases pinning the comparison, including
the exact regression (2.9 -> 2.10/2.11 must be newer) and that a downgrade is
never offered.


## 2.11.0 — Path contract, one-way replication, and a pile of real bugs

Everything here was found by *running* the system during a live incident, not by
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,50 @@ downtime here:
`NAME._smb._tcp.local` resolves only via service discovery; when that
advertisement goes stale, mounts **hang forever** rather than failing.

---

## Updates

WaxFlow checks GitHub for new releases and can apply them itself. The check runs
in the worker; the apply runs in a small `waxflow-updater` container.

**It pulls prebuilt images** from `ghcr.io/rancur/waxflow-{api,worker,web}` rather
than rebuilding from source. That matters: a from-source rebuild of the worker
image took ~25 minutes on a Synology NAS and wedged the Docker daemon once — not
something you want happening unattended at 3am.

Before applying, it takes a database backup. Afterwards it health-checks the API
and **rolls back to the previous tag** if the new version does not come up.

| Setting | Default | |
|---|---|---|
| `auto_update_enabled` | `1` for new installs | existing installs keep their current value |
| `auto_update_schedule` | `daily_3am` | or `weekly_sunday_3am`, `manual` |
| `auto_backup_before_update` | `1` | DB + config snapshot, last 10 kept |

### Security: the Docker socket

Applying an update means restarting containers, which a container cannot do to
itself — so `waxflow-updater` mounts `/var/run/docker.sock`. That is
root-equivalent access to the host, so the service is deliberately split:

- the **worker** has network but no socket — it decides *what* to update to
- the **updater** has the socket but **`network_mode: none`** — it never
downloads anything; the Docker *daemon* fetches image layers when asked over
the socket

Nothing with host-root access talks to the internet. If you would rather not
grant socket access at all, delete the `waxflow-updater` service from
`docker-compose.yml` — everything else keeps working, and the UI's "check for
updates" still tells you when a release is available.

### Building from source instead

The `build:` blocks are still there. `docker compose up -d --build` builds
locally and tags the images with the same names, so the stack runs without ever
touching the registry. Point `WAXFLOW_REGISTRY` at your own namespace if you
publish a fork.

## Tech Stack

| Component | Technology |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.11.0
2.12.0
35 changes: 32 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ services:
dockerfile: sync-api/Dockerfile
args:
- GIT_SHA=${GIT_SHA:-unknown}
image: waxflow-api:${VERSION:-latest}
# Published on release by .github/workflows/release-images.yml. The build:
# block above still works — `docker compose build` overrides this locally.
image: ${WAXFLOW_REGISTRY:-ghcr.io/rancur}/waxflow-api:${WAXFLOW_IMAGE_TAG:-${VERSION:-latest}}
container_name: waxflow-api
ports:
- "8402:8402"
Expand Down Expand Up @@ -40,7 +42,7 @@ services:
context: ./sync-worker
args:
- GIT_SHA=${GIT_SHA:-unknown}
image: waxflow-worker:${VERSION:-latest}
image: ${WAXFLOW_REGISTRY:-ghcr.io/rancur}/waxflow-worker:${WAXFLOW_IMAGE_TAG:-${VERSION:-latest}}
container_name: waxflow-worker
ports:
- "8403:8403"
Expand Down Expand Up @@ -83,7 +85,7 @@ services:
- GIT_SHA=${GIT_SHA:-unknown}
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8402}
- INTERNAL_API_URL=http://sync-api:8402
image: waxflow-web:${VERSION:-latest}
image: ${WAXFLOW_REGISTRY:-ghcr.io/rancur}/waxflow-web:${WAXFLOW_IMAGE_TAG:-${VERSION:-latest}}
container_name: waxflow-web
ports:
- "8400:3000"
Expand All @@ -94,5 +96,32 @@ services:
- sync-api
restart: unless-stopped

# ---------------------------------------------------------------------------
# Auto-updater. A container cannot replace itself, so applying an update needs
# host-side Docker access — this is the only place WaxFlow asks for it.
#
# SECURITY: the Docker socket is root-equivalent on the host, so this service
# runs with NO NETWORK. It never downloads anything; the worker (network, no
# socket) decides what to update to, and the DAEMON fetches the image layers
# when asked over the socket. Nothing with host-root access talks to the
# internet. Set WAXFLOW_AUTOUPDATE=0 in .env to disable, or remove the service.
# ---------------------------------------------------------------------------
waxflow-updater:
image: docker:27-cli
container_name: waxflow-updater
network_mode: none
entrypoint: ["/bin/sh", "/updater/waxflow-updater.sh"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./scripts/waxflow-updater.sh:/updater/waxflow-updater.sh:ro
- .:/project:ro
- sync-data:/data
environment:
- WAXFLOW_UPDATE_POLL_SECONDS=${WAXFLOW_UPDATE_POLL_SECONDS:-60}
- WAXFLOW_HEALTH_TIMEOUT=${WAXFLOW_HEALTH_TIMEOUT:-180}
restart: unless-stopped
depends_on:
- sync-api

volumes:
sync-data:
Loading
Loading