Files
oikos/containers/120-mule-images.md
Claudio 8a17a8dcf5 docs(mule-image): OIDC auto-redirect + deploy.sh caveat
LoginPage now bounces straight through Authentik when OIDC is on,
with skipAutoSso escape hatches for logout / error / explicit
password use. Also flagging that the first deploy.sh --force-recreate
run raced with a manual --no-deps recreate and broke the stack;
recovery via docker compose down && up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:03:47 +02:00

12 KiB
Raw Blame History

120 — mule-images

Hosts mule-image / "mulita" — the photos app at photos.hubris.network. Auto-deploys from dtoro/mule-image on git push origin main.

At a glance

  • Hostname: mule-images
  • IP: 192.168.8.136
  • Privilege: privileged
  • Resources: 4 cores / 8 GiB RAM / 60 GiB rootfs
  • Mounts: /mnt/library/mnt/library
  • Public hostname: photos.hubris.networkcaddy:3000 (frontend)

Stack (/opt/mule-image)

/opt/mule-image IS the working tree of dtoro/mule-image. Compose at /opt/mule-image/docker-compose.yml. Services:

Service Port Notes
frontend 3000 Reverse-proxied by Caddy
backend 8001 FastAPI
worker-vision ML scan worker
worker-light Lightweight worker
worker-watcher FS watcher
db (pg) pgvector
redis (rd) queue

.env is untrackedgit checkout .env will wipe it. Holds:

  • PHOTO_DIRS=/mnt/library/images/
  • NEXTCLOUD_USERS_HOST_PATH=/mnt/library/homecloud
  • NEXTCLOUD_BASE_URL=https://cloud.hubris.network
  • OIDC client secret + scopes
  • SECRET_KEY (generated)

Nextcloud-rooted libraries (since 2026-04-26)

Photo libraries live under each user's Nextcloud files/ tree, NOT in /mnt/library/images/*.

  • /mnt/library/homecloud is bind-mounted into backend, worker-light, worker-watcher, worker-vision as /nextcloud-users. Each NC user is /nextcloud-users/<nc_user>/files/.
  • Reads use that bind directly.
  • Mutations (upload, delete, rename, move) dispatch through services/nextcloud_dav.py (HTTP Basic auth, per-user app password Fernet-encrypted in users.nextcloud_app_password_enc) so Nextcloud's oc_filecache, trashbin, comments, and desktop-sync clients stay coherent.
  • Photo copy + cross-system moves return 501 with a "use Nextcloud's web UI" hint — defer until needed.
  • users.nextcloud_username overrides the default OIDC preferred_username. dtoro (mule-image) maps to admin (Nextcloud) — don't assume username equality.
  • Surviving SourceRoots in DB: Photos/nextcloud-users/admin/files/Photos; Memories/nextcloud-users/admin/files/Memories (both owned by dtoro). User muli has nextcloud_username=muli backfilled but no SourceRoot yet.
  • Pre-migration DB dump: /root/snapshots/mulita-pre-nc-migration-20260426-075132.dump (11 MB) on the host.

Authentication (since 2026-04-22)

Native OIDC via Authentik. Code in backend/app/auth_oidc.py, routes /api/v1/auth/oidc/{login,callback}. Authentik side:

  • OAuth2/OIDC Provider, client ID fCuHew48ONTskDjUKnMTZjFbVXuHwvQqTScQRNQ1
  • App slug mule-image
  • Redirect URI: https://photos.hubris.network/api/v1/auth/oidc/callback

Backend container needs extra_hosts: auth.hubris.network:192.168.8.175 via docker-compose.override.yml (gitignored). Otherwise Authlib's metadata fetch fails with SSL: CERTIFICATE_VERIFY_FAILED: self-signed certificate (it ends up at a random public host because LXC DNS resolves the public IONOS A record).

Caddyfile stays plain reverse_proxy 192.168.8.136:3000 — no forward-auth, no /api/* bypass needed.

Auto-deploy

Push to dtoro/mule-image main → gitea webhook → http://192.168.8.136:9797/deploymule-deploy-webhook.service:

  • Validates HMAC against /etc/mule-deploy/secret
  • Filters to refs/heads/main
  • Runs /opt/mule-deploy/deploy.sh in a daemon thread (returns 202 immediately — docker builds exceed gitea's request timeout)
  • git pull --ff-only + docker compose up -d --build + docker image prune -f

Deploy tooling is outside the app repo: /opt/mule-deploy/{deploy.sh,webhook.py}, secret at /etc/mule-deploy/secret, unit at /etc/systemd/system/mule-deploy-webhook.service. Same shape as the Caddy + Artifacto pipelines. Gitea webhook id 6.

app.ini ALLOWED_HOST_LIST on gitea includes 192.168.8.136.

Logs: pct exec 120 -- journalctl -u mule-deploy-webhook -f. Manual deploy: pct exec 120 -- /opt/mule-deploy/deploy.sh.

For pushes from inside the LXC, gitea creds at /etc/mule-deploy/git-credentials (mode 600) — same token as /etc/caddy-deploy/git-credentials on caddy.

Changelog

2026-05-10 — OIDC auto-redirect on LoginPage

OIDC_ENABLED=true was already set in .env, so the LoginPage rendered a "Sign in with Authentik" button next to the password form. With a single trusted IdP and a logged-in Authentik session, that extra click was friction without upside.

LoginPage.tsx now reads /auth/config on mount and, if OIDC is on, immediately navigates to the OIDC login URL. Authentik recognizes the existing session and bounces back through the callback with no user interaction. Two escape hatches: ?password=1 in the URL forces the password form, and a skipAutoSso sessionStorage flag (set by AuthContext.logout and by the OIDC callback's error branch) suppresses the next auto-redirect so logouts actually log out and OIDC failures surface their error instead of looping. While the redirect is in flight the page shows "Signing in with Authentik..." plus a "Use password instead" link.

2026-05-10 — right sidebar restructure (heap pinned, single scroll, collapsible Metadata)

The right sidepanel had three stacked flex regions: <ActiveHeapCard />, <Header />, and <PhotoInfoPanel /> — with PhotoInfoPanel carrying its own internal scroll. That left the editable fields (filename, title, notes, rating, color, flag) stuck above the readonly metadata scroll, effectively two scroll boundaries on one sidebar.

  • Moved the scroll boundary up to RightSidebar.tsx: only ActiveHeapCard and Header stay pinned now. Editable + readonly content scroll together in one flex-1 overflow-y-auto region beneath them.
  • PhotoInfoPanel.tsx dropped its h-full / inner flex-1 overflow-y-auto.
  • The four readonly sections (Tags / Basic Info / Camera / Location) are now wrapped in a single outer "Metadata" Collapsible. Default expanded, one click hides the whole block. Sub-sections stay individually collapsible.
  • Second pass: the editable form (filename / title / notes / rating / color / flag) got the same treatment under an outer "Edit" Collapsible so the panel is now two equal collapsible groups below the title strip. Dropped the X (clear-selection) button from the Header; Esc and grid-empty-area-click still clear.
  • Third pass: split editable vs read-only between the two groups consistently. TagsEditor and TakenAtEditor had been buried inside the readonly Metadata sub-sections — Tags as its own Section, taken-at wedged into Basic Info between size/dims and the filepath. Moved both into the Edit collapsible, ordered identification → description → categorization: Filename · Title · Date Taken · Notes · Tags · Rating · Color · Flag. Metadata now holds only readonly: Basic Info (size, dims, path), Camera, Location.

PreviewView reuses RightSidebar under an <aside class="overflow-hidden">, so the change applies in both the grid sidebar and the preview overlay.

2026-05-10 — photos.hubris.network perf sweep

User reported slow load. Five fixes shipped, in order:

  1. Backend out of dev mode. docker-compose.yml command: was running uvicorn … --reload (single worker, file-watcher). Page loads fan out ~15 parallel API calls; they all serialized on one event loop. → --workers 2 --proxy-headers. Two uvicorn worker procs now.
  2. iPhone Apple ProRAW / Linear DNG decode fixed. rawpy 0.26.1 + LibRaw 0.22 rejects Apple Linear DNG (Photometric Interpretation: Linear Raw, 8-bit) as "Unsupported file format or not RAW file". Those files are TIFF containers with developed RGB inside and have no embedded preview to fall back to. Added PIL Image.open(src_path) as the last fallback in both _generate_proxy_webp (routers/photos.py) and process_raw_image (tasks/thumbs.py). ~1,300 iPhone DNGs were 415-ing on every detail view; now decode in <1s via PIL.
  3. Reconcile DB with disk (renamed-folder case). prune_missing_photos was skipping all 4,154 orphaned photo rows under SourceRoot Taco and Muli - 2024 onward (renamed in Nextcloud to Photo Archive 2004-2024) because the leaf path didn't resolve and the code assumed "drive unmounted → must skip". Added _sr_state() to classify as present / renamed (parent mount fine, leaf gone) / unmounted (parent inaccessible). Only unmounted still skips. Two stale source roots logged with a clearer hint pointing at POST /api/v1/library/maintenance/prune-missing. User has not yet been asked to run that — endpoint is ready when they are.
  4. Frontend page size + idle polling. usePhotosQuery.ts was first-fetching per_page=500 (slow paint + 500 thumb requests at once). Split into PER_PAGE_INITIAL=100 for first paint, PER_PAGE_BACKGROUND=500 for the cursor-chain prefetch. Idle polling for scan-status and worker-status (useScanActivity.ts, ScanProgress.tsx) bumped from 10s/15s to 30s/30s while idle; active cadence (2s/3s) unchanged.
  5. Partial index on photos. Default list query WHERE NOT is_trashed AND NOT is_hidden ORDER BY taken_at DESC NULLS LAST, id DESC LIMIT N was doing a seq-scan + top-N heapsort (~25ms standalone, worse under concurrency). Added migration 0017_photos_list_index creating ix_photos_list_visible (partial index on the sort key, restricted to visible rows). EXPLAIN now shows an Index Only Scan → 24.7ms → 0.097ms, ~250× speedup.

Deploy gotcha — fixed (with caveat). The original docker compose up -d --build in deploy.sh did not reliably recreate containers when only runtime config (command:, env-only) or migration files changed; image hash would change but compose treated the existing container as "current enough". Bit three times this session before /opt/mule-deploy/deploy.sh was updated to docker compose up -d --build --force-recreate. Trade-off accepted: an extra restart cycle on deploys where nothing user-visible changed.

Caveat: the first auto-deploy after the flag flip raced with my own earlier manual docker compose up -d --no-deps --force-recreate frontend and landed the stack in a half-broken state — mulita-frontend got stuck under a renamed temp container, several services dropped off mule-image_mulita-network, frontend nginx restarted in a loop with host not found in upstream "backend". Fixed by docker compose down && docker compose up -d. Don't issue a manual --force-recreate on a single service while the auto-deploy webhook is also expected to fire — let the deploy own the lifecycle.

Data drift still outstanding. 4,154 photo rows + 1 unregistered folder (Photo Archive 2004-2024) on disk that's not a SourceRoot. The reconcile endpoints now work — user decides when to call them. The new folder needs to be added as a SourceRoot via the Settings UI before its files will be indexed.

Proxy cache still empty (mule-image_proxies_data volume is 4 KB). Pre-generating ~5001,500 WebP proxies for non-web-safe formats would make first-open of every RAW/HEIC photo instant. Deferred — needs a one-shot script and the disk-space tradeoff isn't worth it until the data-drift reconcile happens first.

2026-04-28 — wiki entry created

Initial documentation.

2026-04-26 — Nextcloud-rooted libraries shipped

Bind /mnt/library/homecloud into the workers, reads via filesystem, writes via WebDAV. users.nextcloud_username override field added; dtoro → admin mapping. Surviving SourceRoots cleaned up to NC paths.

2026-04-22 — native OIDC via Authentik

Authlib-based code in backend/app/auth_oidc.py. extra_hosts override for auth.hubris.network in compose override (gitignored).

2026-04-21 — auto-deploy pipeline shipped

Webhook receiver at :9797, async deploy returning 202. Mirrors caddy-conf / gitea-customizations.