diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml index f66fc15..2accece 100644 --- a/.gitea/workflows/container.yml +++ b/.gitea/workflows/container.yml @@ -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 diff --git a/README.md b/README.md index f9c6e0f..fc906e5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ci-deployer.yaml b/ci-deployer.yaml deleted file mode 100644 index 726c209..0000000 --- a/ci-deployer.yaml +++ /dev/null @@ -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 diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh deleted file mode 100755 index 09c3593..0000000 --- a/create-ci-kubeconfig.sh +++ /dev/null @@ -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' diff --git a/deploy/example.yaml b/deploy/example.yaml new file mode 100644 index 0000000..bdca121 --- /dev/null +++ b/deploy/example.yaml @@ -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 diff --git a/immich-sync.yaml b/immich-sync.yaml deleted file mode 100644 index cd2a1f6..0000000 --- a/immich-sync.yaml +++ /dev/null @@ -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 diff --git a/install.sh b/install.sh deleted file mode 100755 index bf3f51f..0000000 --- a/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -kubectl apply -f immich-sync.yaml -