Manage immich-sync with Argo CD

This commit is contained in:
2026-10-18 13:02:21 +02:00
commit cf768e1c93
9 changed files with 373 additions and 0 deletions
+90
View File
@@ -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 <https://cd.brunner.ninja>.
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:<source SHA>`.
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.
+14
View File
@@ -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
+7
View File
@@ -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
+162
View File
@@ -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
+19
View File
@@ -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
+19
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- project.yaml
- immich-sync.yaml
+25
View File
@@ -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
+32
View File
@@ -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 <full-lowercase-git-sha>" >&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}"