diff --git a/clusters/base/apps/immich/configmap-rsync.yaml b/clusters/base/apps/immich/configmap-rsync.yaml
new file mode 100644
index 0000000..d8ceba7
--- /dev/null
+++ b/clusters/base/apps/immich/configmap-rsync.yaml
@@ -0,0 +1,168 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: immich-rsync-script
+ namespace: photo
+data:
+ rsync.py: |
+ import os
+ import re
+ import subprocess
+ import threading
+ import time
+ import json
+ from datetime import datetime, timezone
+
+ SRC = "/source/"
+ DST = "/data/"
+ OUT_DIR = "/www"
+
+ state_lock = threading.Lock()
+ state = {
+ "status": "starting",
+ "percent": 0.0,
+ "processed": 0,
+ "total": 0,
+ "rate": "",
+ "eta": "",
+ "bytes_transferred": "",
+ "started_at": None,
+ "finished_at": None,
+ "error": None,
+ "returncode": None,
+ }
+
+ # matches lines from `rsync --info=progress2`, e.g.:
+ # 1,234,567 43% 12.34MB/s 0:00:12 (xfr#12, to-chk=345/400)
+ # the "(xfr#N, to-chk=X/Y)" suffix only appears on some lines (once a
+ # file completes), so processed/total only update when it's present.
+ LINE_RE = re.compile(
+ r"^\s*([\d,]+)\s+(\d+)%\s+(\S+/s)\s+([\d:]+)"
+ r"(?:\s+\(xfr#(\d+),\s+(?:to-chk|ir-chk)=(\d+)/(\d+)\))?\s*$"
+ )
+
+
+ def render_html(s):
+ progress_html = ""
+ if s["status"] == "scanning":
+ progress_html = "
Building file list...
"
+ elif s["status"] in ("syncing", "done", "error"):
+ progress_html = f"""
+ Progress: {s['processed']} / {s['total']} files ({s['percent']:.1f}%)
+
+ Transferred: {s['bytes_transferred']} bytes · {s['rate']} · ETA {s['eta']}
+ """
+ error_html = f"Error: {s['error']}
" if s["error"] else ""
+ return f"""
+ Immich NAS rsync
+
+
+ Immich NAS rsync
+ {SRC} → {DST}
+ Status: {s['status']}
+ {progress_html}
+ {error_html}
+
+ | Started | {s['started_at'] or '-'} |
+ | Finished | {s['finished_at'] or '-'} |
+ | Exit code | {s['returncode'] if s['returncode'] is not None else '-'} |
+
+ """
+
+
+ def write_output():
+ with state_lock:
+ s = dict(state)
+ html = render_html(s)
+
+ tmp = os.path.join(OUT_DIR, "index.html.tmp")
+ with open(tmp, "w") as f:
+ f.write(html)
+ os.replace(tmp, os.path.join(OUT_DIR, "index.html"))
+
+ tmp = os.path.join(OUT_DIR, "stats.json.tmp")
+ with open(tmp, "w") as f:
+ json.dump(s, f, indent=2)
+ os.replace(tmp, os.path.join(OUT_DIR, "stats.json"))
+
+
+ def run_rsync():
+ with state_lock:
+ state.update(status="scanning", started_at=datetime.now(timezone.utc).isoformat())
+ write_output()
+
+ proc = subprocess.Popen(
+ [
+ "rsync",
+ "-a",
+ "--info=progress2",
+ "--no-inc-recursive",
+ "--outbuf=L",
+ SRC,
+ DST,
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ )
+
+ last_write = 0.0
+ tail = []
+ for line in proc.stdout:
+ tail.append(line)
+ tail[:] = tail[-20:]
+
+ m = LINE_RE.match(line)
+ if m:
+ bytes_transferred, percent, rate, eta, _xfr, to_chk_left, to_chk_total = m.groups()
+ with state_lock:
+ state["status"] = "syncing"
+ state["bytes_transferred"] = bytes_transferred
+ state["percent"] = float(percent)
+ state["rate"] = rate
+ state["eta"] = eta
+ if to_chk_total is not None:
+ total = int(to_chk_total)
+ left = int(to_chk_left)
+ state["total"] = total
+ state["processed"] = total - left
+
+ now = time.time()
+ if now - last_write > 2:
+ write_output()
+ last_write = now
+
+ returncode = proc.wait()
+
+ with state_lock:
+ state["returncode"] = returncode
+ state["finished_at"] = datetime.now(timezone.utc).isoformat()
+ if returncode == 0:
+ state["status"] = "done"
+ state["percent"] = 100.0
+ else:
+ state["status"] = "error"
+ state["error"] = "".join(tail[-5:]).strip() or f"rsync exited {returncode}"
+ write_output()
+
+
+ def main():
+ os.makedirs(OUT_DIR, exist_ok=True)
+ write_output()
+ run_rsync()
+ # stay alive so the pod doesn't restart-loop after a one-shot sync;
+ # the status page keeps showing the final result.
+ while True:
+ time.sleep(3600)
+
+
+ if __name__ == "__main__":
+ main()
diff --git a/clusters/base/apps/immich/helmrelease-server.yaml b/clusters/base/apps/immich/helmrelease-server.yaml
index 8f3a0e6..551255d 100644
--- a/clusters/base/apps/immich/helmrelease-server.yaml
+++ b/clusters/base/apps/immich/helmrelease-server.yaml
@@ -53,6 +53,46 @@ spec:
memory: 512Mi
limits:
memory: 4Gi
+ # one-shot migration copy from the old NAS-hosted docker install
+ # into this install's own library volume (see "library" below).
+ # rsync is idempotent, so a pod restart mid-copy just resumes
+ # cleanly; once done it sleeps forever instead of exiting, so the
+ # container doesn't restart-loop and the status page stays up.
+ rsync:
+ image:
+ repository: python
+ tag: 3.13-alpine
+ command:
+ - sh
+ - -c
+ - apk add --no-cache rsync >/dev/null && python3 /app/rsync.py
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ memory: 256Mi
+ rsync-web:
+ image:
+ repository: nginx
+ tag: 1.27-alpine
+ probes:
+ # explicit port: this controller has two services (app:2283,
+ # rsync-web:80) and probe auto-detection picks the
+ # controller's primary service port rather than this
+ # container's own - without this it checks the wrong port.
+ liveness:
+ enabled: true
+ port: 80
+ readiness:
+ enabled: true
+ port: 80
+ resources:
+ requests:
+ cpu: 10m
+ memory: 16Mi
+ limits:
+ memory: 64Mi
service:
app:
@@ -60,6 +100,11 @@ spec:
ports:
http:
port: 2283
+ rsync-web:
+ controller: server
+ ports:
+ http:
+ port: 80
ingress:
app:
@@ -78,6 +123,22 @@ spec:
- secretName: immich-tls
hosts:
- photo.aggtaa.com
+ rsync-web:
+ className: traefik
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+ hosts:
+ - host: rsync.photo.aggtaa.com
+ paths:
+ - path: /
+ pathType: Prefix
+ service:
+ identifier: rsync-web
+ port: http
+ tls:
+ - secretName: immich-rsync-tls
+ hosts:
+ - rsync.photo.aggtaa.com
persistence:
# brand new, dedicated library - deliberately not reusing the old
@@ -90,3 +151,28 @@ spec:
retain: true
globalMounts:
- path: /data
+ nas-source:
+ type: persistentVolumeClaim
+ existingClaim: immich-nas-source
+ advancedMounts:
+ server:
+ rsync:
+ - path: /source
+ readOnly: true
+ rsync-script:
+ type: configMap
+ name: immich-rsync-script
+ advancedMounts:
+ server:
+ rsync:
+ - path: /app/rsync.py
+ subPath: rsync.py
+ readOnly: true
+ rsync-www:
+ type: emptyDir
+ advancedMounts:
+ server:
+ rsync:
+ - path: /www
+ rsync-web:
+ - path: /usr/share/nginx/html
diff --git a/clusters/base/apps/immich/kustomization.yaml b/clusters/base/apps/immich/kustomization.yaml
index d205c70..aad1359 100644
--- a/clusters/base/apps/immich/kustomization.yaml
+++ b/clusters/base/apps/immich/kustomization.yaml
@@ -3,6 +3,8 @@ kind: Kustomization
resources:
- helmrepository.yaml
- secret.enc.yaml
+ - configmap-rsync.yaml
+ - pv-pvc-nas-source.yaml
- helmrelease-postgres.yaml
- helmrelease-redis.yaml
- helmrelease-ml.yaml
diff --git a/clusters/base/apps/immich/pv-pvc-nas-source.yaml b/clusters/base/apps/immich/pv-pvc-nas-source.yaml
new file mode 100644
index 0000000..8a26b96
--- /dev/null
+++ b/clusters/base/apps/immich/pv-pvc-nas-source.yaml
@@ -0,0 +1,37 @@
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: immich-nas-source-nfs
+spec:
+ capacity:
+ storage: 1Ti
+ accessModes:
+ - ReadOnlyMany
+ persistentVolumeReclaimPolicy: Retain
+ storageClassName: ""
+ mountOptions:
+ - nfsvers=3
+ - nolock
+ nfs:
+ server: 10.0.64.80
+ path: /mnt/sdb2/immich
+ readOnly: true
+ claimRef:
+ namespace: photo
+ name: immich-nas-source
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: immich-nas-source
+ namespace: photo
+ annotations:
+ helm.sh/resource-policy: keep
+spec:
+ accessModes:
+ - ReadOnlyMany
+ storageClassName: ""
+ volumeName: immich-nas-source-nfs
+ resources:
+ requests:
+ storage: 1Ti