From cf768e1c93eba4b8d3937378688bc44b769d1e79 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Sun, 18 Oct 2026 13:02:21 +0200 Subject: [PATCH] Manage immich-sync with Argo CD --- README.md | 90 ++++++++++++++++ apps/immich-sync/install.sh | 14 +++ apps/immich-sync/kustomization.yaml | 7 ++ apps/immich-sync/resources.yaml | 162 ++++++++++++++++++++++++++++ bootstrap/immich-sync.yaml | 19 ++++ bootstrap/install.sh | 19 ++++ bootstrap/kustomization.yaml | 5 + bootstrap/project.yaml | 25 +++++ scripts/update-immich-sync-image.sh | 32 ++++++ 9 files changed, 373 insertions(+) create mode 100644 README.md create mode 100755 apps/immich-sync/install.sh create mode 100644 apps/immich-sync/kustomization.yaml create mode 100644 apps/immich-sync/resources.yaml create mode 100644 bootstrap/immich-sync.yaml create mode 100755 bootstrap/install.sh create mode 100644 bootstrap/kustomization.yaml create mode 100644 bootstrap/project.yaml create mode 100755 scripts/update-immich-sync-image.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..f752b50 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Homelab GitOps + +Argo CD is the only component that deploys the resources in this repository. Application CI publishes immutable +container images and then commits the selected image tag here; it never receives Kubernetes credentials. + +## Repository layout + +- `apps/immich-sync/` is the desired Kubernetes state for Immich Share Sync. +- `bootstrap/` contains the Argo CD project and Application that point back to this repository. +- `scripts/update-immich-sync-image.sh` is the single image-promotion interface used by CI. + +The only canonical Immich Share Sync image version is `images[0].newTag` in +`apps/immich-sync/kustomization.yaml`. The resource manifest deliberately omits a tag, and Kustomize supplies the +immutable full Git SHA while rendering. + +## First-time setup + +The cluster already runs Argo CD in the `argocd` namespace and exposes its UI at . + +1. Push this repository so Argo CD can read its public HTTPS URL. +2. Configure the `immich-sync` repository's cross-repository token as described below. +3. Review the rendered workload: + + ```sh + kubectl kustomize apps/immich-sync + ``` + +4. Record the existing persistent volume before the first synchronization: + + ```sh + kubectl --namespace default get pvc immich-sync-stats \ + -o jsonpath='{.metadata.uid}{"|"}{.spec.volumeName}{"\n"}' + ``` + +5. Bootstrap the Argo CD resources: + + ```sh + ./bootstrap/install.sh + ``` + +6. Inspect the result in the UI or CLI: + + ```sh + argocd app get immich-sync + argocd app diff immich-sync + kubectl --namespace default rollout status deployment/immich-sync + kubectl --namespace default get deployment immich-sync \ + -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}' + ``` + +7. Confirm that the PVC UID and volume name are unchanged. Only after Argo CD reports the application `Synced` and + `Healthy`, remove the obsolete CI deployer and delete the old `KUBE_CONFIG_BASE64` secret from Gitea: + + ```sh + kubectl --namespace default delete rolebinding/immich-sync-deployer \ + role/immich-sync-deployer serviceaccount/immich-sync-deployer \ + secret/immich-sync-deployer-token + ``` + +The bootstrap Application enables automatic synchronization, pruning, and self-healing. Consequently, direct edits +to its managed Kubernetes resources are reverted to Git. Change Git when experimenting, observe `OutOfSync`, and then +watch Argo CD return the application to `Synced` and `Healthy`. + +## Give Immich Share Sync CI permission to promote images + +The built-in Gitea Actions job token cannot write to another repository. Create a dedicated bot account, for example +`gitops-promoter`, add it to `feedc0de/homelab-gitops` with repository **Write** permission, and create a personal +access token for that account with only the `write:repository` scope. + +In `feedc0de/immich-sync`, open **Settings → Actions → Secrets** and create `GITOPS_TOKEN` containing that token. The +workflow passes it directly to `actions/checkout` while checking out this repository; the checkout action configures +authenticated HTTPS for the final `git push` without placing the token in a committed URL. + +The main-branch workflow now performs this sequence: + +1. Build and push `registry.brunner.ninja/feedc0de/immich-sync:`. +2. Verify that the completed build still belongs to the current `main` commit. +3. Update `apps/immich-sync/kustomization.yaml` with that SHA. +4. Commit and push the promotion to this repository. +5. Let Argo CD detect and synchronize the Git commit. + +For a personal token owned by `feedc0de`, `write:repository` also applies to other repositories that account can +write. A dedicated bot restricted to this one repository therefore gives the token a meaningfully smaller blast +radius. + +## Manual recovery path + +Argo CD is the normal deployment mechanism. If it is unavailable, `apps/immich-sync/install.sh` renders, performs a +server-side dry-run, applies the same Kustomize source, and waits for the Deployment rollout. A later Argo CD refresh +will see the same desired resources. diff --git a/apps/immich-sync/install.sh b/apps/immich-sync/install.sh new file mode 100755 index 0000000..88c1359 --- /dev/null +++ b/apps/immich-sync/install.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +expected_context=kubernetes-admin@kubernetes +current_context=$(kubectl config current-context) +if [ "${current_context}" != "${expected_context}" ]; then + echo "Refusing to deploy through unexpected context: ${current_context}" >&2 + exit 1 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +kubectl apply --server-side --dry-run=server -k "${script_dir}" +kubectl apply --server-side -k "${script_dir}" +kubectl --namespace default rollout status deployment/immich-sync --timeout=5m diff --git a/apps/immich-sync/kustomization.yaml b/apps/immich-sync/kustomization.yaml new file mode 100644 index 0000000..99bb1a8 --- /dev/null +++ b/apps/immich-sync/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- resources.yaml +images: +- name: registry.brunner.ninja/feedc0de/immich-sync + newTag: a6cb14a675cd12f61719213900df8d9487b079ca diff --git a/apps/immich-sync/resources.yaml b/apps/immich-sync/resources.yaml new file mode 100644 index 0000000..8c135df --- /dev/null +++ b/apps/immich-sync/resources.yaml @@ -0,0 +1,162 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: immich-sync-stats +spec: + accessModes: + - ReadWriteMany + storageClassName: rook-cephfs + resources: + requests: + storage: 64Mi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-sync +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: immich-sync + template: + metadata: + labels: + app: immich-sync + spec: + automountServiceAccountToken: false + securityContext: + fsGroup: 65532 + fsGroupChangePolicy: OnRootMismatch + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: immich-sync + topologyKey: kubernetes.io/hostname + containers: + - name: immich-sync + image: registry.brunner.ninja/feedc0de/immich-sync + imagePullPolicy: Always + env: + - name: PORT + value: "8090" + - name: STATS_FILE + value: /data/stats.json + ports: + - name: http + containerPort: 8090 + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 1 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + resources: + requests: + cpu: 10m + memory: 32Mi + ephemeral-storage: 64Mi + limits: + cpu: "1" + memory: 128Mi + ephemeral-storage: 20Gi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + volumeMounts: + - name: tmp + mountPath: /tmp + - name: stats + mountPath: /data + volumes: + - name: tmp + emptyDir: + sizeLimit: 20Gi + - name: stats + persistentVolumeClaim: + claimName: immich-sync-stats +--- +apiVersion: v1 +kind: Service +metadata: + name: immich-sync + labels: + app: immich-sync +spec: + ports: + - name: http + port: 80 + protocol: TCP + targetPort: http + selector: + app: immich-sync +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: immich-sync +spec: + minAvailable: 1 + selector: + matchLabels: + app: immich-sync +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: immich-sync + labels: + release: prometheus +spec: + selector: + matchLabels: + app: immich-sync + namespaceSelector: + matchNames: + - default + endpoints: + - port: http + path: /metrics + interval: 30s + scrapeTimeout: 10s +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: immich-sync + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + rules: + - host: immich-sync.brunner.ninja + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: immich-sync + port: + name: http diff --git a/bootstrap/immich-sync.yaml b/bootstrap/immich-sync.yaml new file mode 100644 index 0000000..84aeef1 --- /dev/null +++ b/bootstrap/immich-sync.yaml @@ -0,0 +1,19 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: immich-sync + namespace: argocd +spec: + project: homelab + source: + repoURL: https://code.brunner.ninja/feedc0de/homelab-gitops.git + targetRevision: main + path: apps/immich-sync + destination: + server: https://kubernetes.default.svc + namespace: default + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true diff --git a/bootstrap/install.sh b/bootstrap/install.sh new file mode 100755 index 0000000..068f300 --- /dev/null +++ b/bootstrap/install.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +expected_context=kubernetes-admin@kubernetes +current_context=$(kubectl config current-context) +if [ "${current_context}" != "${expected_context}" ]; then + echo "Refusing to bootstrap through unexpected context: ${current_context}" >&2 + exit 1 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +kubectl apply --server-side --dry-run=server -k "${script_dir}" +kubectl apply --server-side -k "${script_dir}" +kubectl --namespace argocd wait application/immich-sync \ + --for=jsonpath='{.status.sync.status}'=Synced \ + --timeout=5m +kubectl --namespace argocd wait application/immich-sync \ + --for=jsonpath='{.status.health.status}'=Healthy \ + --timeout=5m diff --git a/bootstrap/kustomization.yaml b/bootstrap/kustomization.yaml new file mode 100644 index 0000000..e21bed5 --- /dev/null +++ b/bootstrap/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- project.yaml +- immich-sync.yaml diff --git a/bootstrap/project.yaml b/bootstrap/project.yaml new file mode 100644 index 0000000..329c63f --- /dev/null +++ b/bootstrap/project.yaml @@ -0,0 +1,25 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: homelab + namespace: argocd +spec: + description: Applications managed from the public homelab GitOps repository + sourceRepos: + - https://code.brunner.ninja/feedc0de/homelab-gitops.git + destinations: + - namespace: default + server: https://kubernetes.default.svc + namespaceResourceWhitelist: + - group: "" + kind: PersistentVolumeClaim + - group: "" + kind: Service + - group: apps + kind: Deployment + - group: networking.k8s.io + kind: Ingress + - group: policy + kind: PodDisruptionBudget + - group: monitoring.coreos.com + kind: ServiceMonitor diff --git a/scripts/update-immich-sync-image.sh b/scripts/update-immich-sync-image.sh new file mode 100755 index 0000000..a03c3cc --- /dev/null +++ b/scripts/update-immich-sync-image.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ] || ! printf '%s\n' "$1" | grep -Eq '^[0-9a-f]{40}$'; then + echo "usage: $0 " >&2 + exit 2 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repository_dir=$(dirname -- "${script_dir}") +kustomization="${repository_dir}/apps/immich-sync/kustomization.yaml" +temporary="${kustomization}.tmp" + +awk -v tag="$1" ' + /^ newTag: / { + if (updated) { + print "multiple newTag fields found" > "/dev/stderr" + exit 1 + } + print " newTag: " tag + updated = 1 + next + } + { print } + END { + if (!updated) { + print "newTag field not found" > "/dev/stderr" + exit 1 + } + } +' "${kustomization}" > "${temporary}" +mv "${temporary}" "${kustomization}"