Files
immich-sync/README.md
T
feedc0de 2e8a394857
Publish container image / Build and push (push) Successful in 49s
Publish container image / Deploy to Kubernetes (push) Successful in 31s
Add shared statistics and Prometheus metrics
2026-08-05 18:32:30 +02:00

142 lines
8.6 KiB
Markdown

# Immich Share Sync
An ephemeral Qt 6 web service that compares two Immich public shares by SHA-1 checksum and copies missing originals in either direction. It never deletes assets or keeps recovery state.
## How it works
- A `QHttpServer` serves an embedded vanilla HTML/CSS/JavaScript frontend.
- The same HTTP listener upgrades `/ws` with Qt WebSockets.
- Each short-lived WebSocket owns exactly one job, one `SyncSession`, two `ImmichClient` instances, and their `QNetworkAccessManager` objects.
- Link inspection tries Immich's share key and custom-slug authentication forms. Current password-protected shares use `POST /api/shared-links/login`; Qt's cookie jar retains the short-lived share cookie for that socket session.
- The service reads `QJsonValue`/`QJsonObject` responses and compares the base64-encoded SHA-1 checksums in Immich's shared-link response.
- Each missing original is downloaded into a `QTemporaryFile`, rewound, and used as the seekable `QIODevice` body of the destination multipart request. Only one asset per job is staged, and the file is removed after success, failure, or WebSocket abort.
- The browser sends one complete job request immediately after connecting. The service closes the WebSocket when that inspection or synchronization finishes.
- Closing the WebSocket early destroys the session and aborts every active network reply. No inspection ID, plan, credentials, or recovery state survives the connection.
- Aggregate counters are the only persistent state. Kubernetes replicas update a small locked JSON file on the shared
CephFS volume; it contains only numeric totals and never contains links, passwords, filenames, or job recovery data.
The destination upload deliberately omits Immich's `x-immich-checksum` optimization. That optimization returns duplicates before Immich associates an already-owned asset with the destination shared album. The normal duplicate path both detects the checksum and adds the existing asset to the share.
## Build and run
Requirements: CMake 3.28+, a C++23 compiler, and Qt 6.9+ with Core, Network, HttpServer, and WebSockets.
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure
PORT=8090 ./build/immich-sync
```
Open `http://localhost:8090`.
After both share URLs are valid, starting a job updates the browser fragment in the form
`#left=<encoded-share-url>&right=<encoded-share-url>`. Reloading or bookmarking that URL restores both fields and
automatically starts a new inspection. Passwords are intentionally excluded from the fragment and must be entered
again for protected shares.
## WebSocket protocol
All messages are JSON. Each WebSocket accepts exactly one complete job request and closes after its terminal response. A client that does not send a request within 10 seconds is disconnected. Passwords are accepted only in client messages and are never placed in URLs or echoed in status responses.
Inspect two links:
```json
{
"type": "inspect",
"left": {"url": "https://one.example/share/key", "password": ""},
"right": {"url": "https://two.example/share/key", "password": ""}
}
```
The service emits granular `side-status` messages. Stages include `connecting`, `authenticating`, `password-required`, `ready`, and `error`. Once both sides resolve, an `inspection` response includes public share details, upload/download permissions, missing counts, and the allowed `left-to-right`, `right-to-left`, and `bidirectional` options. The service then closes the inspection socket.
To synchronize, open a new WebSocket and send both shares again along with one of the directions offered by inspection:
```json
{
"type": "sync",
"direction": "bidirectional",
"left": {"url": "https://one.example/share/key", "password": ""},
"right": {"url": "https://two.example/share/key", "password": ""}
}
```
The synchronization job repeats inspection so its checksum plan and permissions reflect current Immich state. Status messages include the inspection events followed by `sync-status`, `asset-status`, and throttled `asset-progress` events. After upload reaches 100%, a `processing` asset stage makes it clear that the destination Immich is still handling the request. The service closes the socket after completion; close it from the client to abort, delete the temporary asset, and discard the whole session immediately.
## Container and Kubernetes
```sh
docker build -t registry.brunner.ninja/feedc0de/immich-sync:latest .
docker run --rm -p 8090:8090 registry.brunner.ninja/feedc0de/immich-sync:latest
./install.sh
```
The Kubernetes manifest follows the neighboring `brunner-ninja` and `visual-studio-code` layout. It pulls the public
`registry.brunner.ninja/feedc0de/immich-sync:latest` image on every pod start, so no Kubernetes image-pull secret is
required. It assumes the `immich-sync.brunner.ninja` hostname shown in the manifest. The Ingress is intentionally
public and has no authentication middleware. Because the
service can make outbound requests to user-supplied URLs, operators should monitor it for SSRF and bandwidth abuse.
The Deployment runs two replicas with preferred cross-node placement, a zero-unavailable rolling-update strategy, and
a PodDisruptionBudget that keeps at least one replica available. A small `ReadWriteMany` `rook-cephfs` claim stores the
shared lifetime counters. WebSockets need no sticky-session setting: after the HTTP upgrade, the selected pod owns that
connection until the short-lived job finishes or disconnects.
## Statistics and Prometheus
The homepage reads `/api/stats` every 30 seconds and displays shared totals for completed synchronization runs, all
successfully synchronized assets, photos, videos, and bytes. Counters begin at zero when statistics are first enabled.
Each replica exposes Prometheus text metrics at `/metrics`. The manifest includes a `ServiceMonitor` that selects the
`immich-sync` Service and is discovered by the existing Prometheus Operator. Useful Grafana queries include:
```promql
sum(immich_sync_active_synchronizations)
sum(rate(immich_sync_assets_total{result="success"}[5m]))
sum by (media_type) (rate(immich_sync_asset_bytes_total[5m]))
max(immich_sync_lifetime_synchronizations_total)
max by (media_type) (immich_sync_lifetime_assets_total)
```
The lifetime series contains the same shared value on both replicas, so use `max`, not `sum`, for those metrics.
Per-process operational counters and active gauges should be summed across replicas.
## Continuous delivery
The Gitea Actions workflow in `.gitea/workflows/container.yml` builds and pushes
`registry.brunner.ninja/feedc0de/immich-sync`. Every pushed commit receives an immutable tag containing its full Git
SHA. A commit pushed to `main` receives both its SHA tag and `latest` from the same image build.
The workflow deliberately loads the completed image into the Docker engine and pushes it with a separate plain
`docker push`. It does not request provenance attestations, SBOM manifests, or OCI referrers, retaining compatibility
with the self-hosted Quay registry and with Gitea runners whose default builder uses BuildKit's container driver.
The workflow expects these Gitea Actions repository secrets:
- `QUAY_USERNAME`: the complete Quay robot account name, including the `feedc0de+` prefix.
- `QUAY_TOKEN`: the robot account token.
- `KUBE_CONFIG_BASE64`: a kubeconfig for the restricted `immich-sync-deployer` service account, base64-encoded on one line.
Give the robot account `Write` permission only on the `feedc0de/immich-sync` repository. Public image pulls do not use
these credentials and do not require a Gitea secret.
On successful `main` builds, the deploy job patches only the `default/immich-sync` Deployment to the immutable commit
image and waits up to five minutes for rollout. Apply `ci-deployer.yaml` once, generate a kubeconfig for its token, and
store it as `KUBE_CONFIG_BASE64`. The role cannot access secrets, pods, or any other Deployment.
```sh
kubectl apply -f ci-deployer.yaml
./create-ci-kubeconfig.sh
```
Copy the single output line from `create-ci-kubeconfig.sh` into the Gitea repository secret. Treat it as a password.
## Current scope
- Assets without a checksum are ignored by planning.
- Metadata processing on the destination is left to Immich after upload.
- Sidecar files and Live Photo pairing are not reconstructed yet; the primary original asset is transferred.
- Immich serves the edited rendition for edited assets through a shared link even when the original endpoint is requested. Such an asset can acquire a different destination checksum and may be proposed again on a later, stateless run. This cannot be fully resolved with current shared-link permissions alone.
- TLS certificates must be valid. The service never ignores SSL errors.