Deploy immich-sync through Argo CD
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
name: Publish container image
|
||||
name: Publish and promote container image
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
env:
|
||||
IMAGE: registry.brunner.ninja/feedc0de/immich-sync
|
||||
KUBERNETES_API: https://host.containers.internal:6443
|
||||
KUBERNETES_TLS_SERVER_NAME: 192.168.0.2
|
||||
GITOPS_REPOSITORY: feedc0de/homelab-gitops
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -48,60 +47,51 @@ jobs:
|
||||
docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:latest"
|
||||
docker push "${IMAGE}:latest"
|
||||
|
||||
deploy:
|
||||
name: Deploy to Kubernetes
|
||||
promote:
|
||||
name: Promote image in GitOps
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Install kubectl
|
||||
env:
|
||||
KUBECTL_VERSION: v1.36.2
|
||||
run: |
|
||||
curl --fail --silent --show-error --location \
|
||||
--output "${RUNNER_TEMP}/kubectl" \
|
||||
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||
chmod 0700 "${RUNNER_TEMP}/kubectl"
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure Kubernetes access
|
||||
env:
|
||||
KUBE_CONFIG_BASE64: ${{ secrets.KUBE_CONFIG_BASE64 }}
|
||||
run: |
|
||||
test -n "${KUBE_CONFIG_BASE64}"
|
||||
printf '%s' "${KUBE_CONFIG_BASE64}" | base64 --decode > "${RUNNER_TEMP}/kubeconfig"
|
||||
chmod 0600 "${RUNNER_TEMP}/kubeconfig"
|
||||
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
|
||||
"${RUNNER_TEMP}/kubectl" config set-cluster cluster \
|
||||
--server="${KUBERNETES_API}" \
|
||||
--tls-server-name="${KUBERNETES_TLS_SERVER_NAME}"
|
||||
|
||||
- name: Deploy commit image
|
||||
- name: Check that this is still the current main commit
|
||||
id: current-main
|
||||
env:
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
export KUBECONFIG="${RUNNER_TEMP}/kubeconfig"
|
||||
"${RUNNER_TEMP}/kubectl" --namespace default set image \
|
||||
deployment/immich-sync \
|
||||
"immich-sync=${IMAGE}:${COMMIT_SHA}"
|
||||
git fetch --no-tags origin main
|
||||
if [ "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}" ]; then
|
||||
echo "promote=true" >> "${GITEA_OUTPUT}"
|
||||
else
|
||||
echo "This build is no longer the current main commit; skipping promotion"
|
||||
echo "promote=false" >> "${GITEA_OUTPUT}"
|
||||
fi
|
||||
|
||||
for attempt in {1..60}; do
|
||||
IFS='|' read -r generation observed desired updated available unavailable <<< "$(
|
||||
"${RUNNER_TEMP}/kubectl" --namespace default get deployment/immich-sync \
|
||||
--output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.availableReplicas}|{.status.unavailableReplicas}'
|
||||
)"
|
||||
- name: Check out GitOps repository
|
||||
if: steps.current-main.outputs.promote == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.GITOPS_REPOSITORY }}
|
||||
ref: main
|
||||
token: ${{ secrets.GITOPS_TOKEN }}
|
||||
path: homelab-gitops
|
||||
|
||||
echo "Rollout ${attempt}/60: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${available}/${desired} available"
|
||||
if [[ "${observed}" == "${generation}" \
|
||||
&& "${updated}" == "${desired}" \
|
||||
&& "${available}" == "${desired}" \
|
||||
&& ( -z "${unavailable}" || "${unavailable}" == "0" ) ]]; then
|
||||
echo "Deployment successfully rolled out"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
done
|
||||
|
||||
"${RUNNER_TEMP}/kubectl" --namespace default describe deployment/immich-sync
|
||||
exit 1
|
||||
- name: Commit image promotion
|
||||
if: steps.current-main.outputs.promote == 'true'
|
||||
working-directory: homelab-gitops
|
||||
env:
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
./scripts/update-immich-sync-image.sh "${COMMIT_SHA}"
|
||||
git config user.name "immich-sync CI"
|
||||
git config user.email "immich-sync-ci@brunner.ninja"
|
||||
git add apps/immich-sync/kustomization.yaml
|
||||
if git diff --cached --quiet; then
|
||||
echo "GitOps already selects ${COMMIT_SHA}"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Deploy immich-sync ${COMMIT_SHA}"
|
||||
git push origin HEAD:main
|
||||
|
||||
@@ -69,27 +69,29 @@ The synchronization job repeats inspection so its checksum plan and permissions
|
||||
```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
|
||||
kubectl apply -f deploy/example.yaml
|
||||
kubectl port-forward service/immich-sync 8090:80
|
||||
```
|
||||
|
||||
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.
|
||||
`deploy/example.yaml` is a portable starting point for self-hosting. It creates one Deployment and a ClusterIP Service,
|
||||
uses the public image, and does not require an image-pull secret. Add persistent storage, an Ingress, and monitoring to
|
||||
match your cluster. 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.
|
||||
The author's complete
|
||||
[`apps/immich-sync` GitOps application](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/immich-sync)
|
||||
is public and can be used as an Argo CD/Kustomize deployment reference. It includes homelab-specific CephFS storage,
|
||||
multiple replicas, an Ingress hostname, and Prometheus Operator monitoring, so fork it and adjust those assumptions
|
||||
before deploying it to another cluster. 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:
|
||||
Each replica exposes Prometheus text metrics at `/metrics`. A deployment can add a `ServiceMonitor` selecting the
|
||||
`immich-sync` Service. Useful Grafana queries include:
|
||||
|
||||
```promql
|
||||
sum(immich_sync_active_synchronizations)
|
||||
@@ -116,21 +118,15 @@ 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.
|
||||
- `GITOPS_TOKEN`: a `write:repository` token belonging to a dedicated account with write access only to
|
||||
`feedc0de/homelab-gitops`.
|
||||
|
||||
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.
|
||||
After a successful `main` build, the promotion job updates the single image tag in `homelab-gitops` and pushes a
|
||||
deployment commit. Argo CD observes that commit and reconciles Kubernetes. The workflow has no kubeconfig and no
|
||||
direct Kubernetes access. See the GitOps repository README for the token setup, bootstrap procedure, and rollback path.
|
||||
|
||||
## Current scope
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: immich-sync-deployer
|
||||
namespace: default
|
||||
automountServiceAccountToken: false
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: immich-sync-deployer
|
||||
namespace: default
|
||||
rules:
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- deployments
|
||||
resourceNames:
|
||||
- immich-sync
|
||||
verbs:
|
||||
- get
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: immich-sync-deployer
|
||||
namespace: default
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: immich-sync-deployer
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: immich-sync-deployer
|
||||
namespace: default
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: immich-sync-deployer-token
|
||||
namespace: default
|
||||
annotations:
|
||||
kubernetes.io/service-account.name: immich-sync-deployer
|
||||
type: kubernetes.io/service-account-token
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
namespace=default
|
||||
secret=immich-sync-deployer-token
|
||||
workdir=$(mktemp --directory)
|
||||
trap 'rm -rf "${workdir}"' EXIT
|
||||
|
||||
server=$(kubectl config view --minify --output 'jsonpath={.clusters[0].cluster.server}')
|
||||
kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.ca\.crt}' \
|
||||
| base64 --decode > "${workdir}/ca.crt"
|
||||
token=$(kubectl --namespace "${namespace}" get secret "${secret}" --output 'jsonpath={.data.token}' \
|
||||
| base64 --decode)
|
||||
|
||||
export KUBECONFIG="${workdir}/config"
|
||||
kubectl config set-cluster cluster --server "${server}" --certificate-authority "${workdir}/ca.crt" --embed-certs=true \
|
||||
> /dev/null
|
||||
kubectl config set-credentials immich-sync-deployer --token "${token}" > /dev/null
|
||||
kubectl config set-context immich-sync --cluster cluster --user immich-sync-deployer --namespace "${namespace}" \
|
||||
> /dev/null
|
||||
kubectl config use-context immich-sync > /dev/null
|
||||
|
||||
base64 --wrap=0 "${KUBECONFIG}"
|
||||
printf '\n'
|
||||
@@ -0,0 +1,58 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: immich-sync
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: immich-sync
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: immich-sync
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
containers:
|
||||
- name: immich-sync
|
||||
image: registry.brunner.ninja/feedc0de/immich-sync:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8090
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: immich-sync
|
||||
spec:
|
||||
selector:
|
||||
app: immich-sync
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
@@ -1,162 +0,0 @@
|
||||
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:latest
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
kubectl apply -f immich-sync.yaml
|
||||
|
||||
Reference in New Issue
Block a user