diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml index 2accece..bdf4a46 100644 --- a/.gitea/workflows/container.yml +++ b/.gitea/workflows/container.yml @@ -1,4 +1,4 @@ -name: Publish and promote container image +name: Publish image, chart, and GitOps promotion on: push: @@ -9,69 +9,57 @@ env: jobs: publish: - name: Build and push + name: Test and publish immutable artifacts runs-on: ubuntu-latest - steps: - - name: Check out source + - name: Check out source and tags uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve and validate chart version + id: version + run: | + version=$(./scripts/chart-version.sh) + echo "version=${version}" >> "${GITEA_OUTPUT}" + helm lint chart + helm template immich-sync chart --set-string image.tag="${GITEA_SHA}" >/dev/null - name: Log in to Quay + if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v') uses: docker/login-action@v3 with: registry: registry.brunner.ninja username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_TOKEN }} - - name: Build image - env: - COMMIT_SHA: ${{ gitea.sha }} + - name: Build and push image + if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v') run: | - docker build \ - --load \ - --label "org.opencontainers.image.revision=${COMMIT_SHA}" \ + docker build --load \ + --label "org.opencontainers.image.revision=${GITEA_SHA}" \ --label "org.opencontainers.image.source=https://code.brunner.ninja/feedc0de/immich-sync" \ - --tag "${IMAGE}:${COMMIT_SHA}" \ - . - - - name: Push commit tag - env: - COMMIT_SHA: ${{ gitea.sha }} - run: docker push "${IMAGE}:${COMMIT_SHA}" - - - name: Push latest tag - if: gitea.ref == 'refs/heads/main' - env: - COMMIT_SHA: ${{ gitea.sha }} - run: | - docker tag "${IMAGE}:${COMMIT_SHA}" "${IMAGE}:latest" - docker push "${IMAGE}:latest" - - promote: - name: Promote image in GitOps - if: gitea.ref == 'refs/heads/main' - needs: publish - runs-on: ubuntu-latest - - steps: - - name: Check out source - uses: actions/checkout@v4 - - - name: Check that this is still the current main commit - id: current-main - env: - COMMIT_SHA: ${{ gitea.sha }} - run: | - 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}" + --tag "${IMAGE}:${GITEA_SHA}" . + docker push "${IMAGE}:${GITEA_SHA}" + if [ "${GITEA_REF}" = refs/heads/main ]; then + docker tag "${IMAGE}:${GITEA_SHA}" "${IMAGE}:latest" + docker push "${IMAGE}:latest" fi + - name: Package and publish Helm chart + if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/tags/v') + env: + CHART_VERSION: ${{ steps.version.outputs.version }} + run: | + mkdir -p dist + helm package chart --destination dist \ + --version "${CHART_VERSION}" --app-version "${GITEA_SHA}" + curl --fail-with-body --user "${{ secrets.PACKAGE_USERNAME }}:${{ secrets.PACKAGE_TOKEN }}" \ + --upload-file "dist/immich-sync-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Check out GitOps repository - if: steps.current-main.outputs.promote == 'true' + if: gitea.ref == 'refs/heads/main' uses: actions/checkout@v4 with: repository: ${{ env.GITOPS_REPOSITORY }} @@ -79,19 +67,16 @@ jobs: token: ${{ secrets.GITOPS_TOKEN }} path: homelab-gitops - - name: Commit image promotion - if: steps.current-main.outputs.promote == 'true' + - name: Promote exact chart version + if: gitea.ref == 'refs/heads/main' working-directory: homelab-gitops env: - COMMIT_SHA: ${{ gitea.sha }} + CHART_VERSION: ${{ steps.version.outputs.version }} 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}" + ./scripts/update-chart-version.sh immich-sync "${CHART_VERSION}" + git config user.name "feedc0de" + git config user.email "feedc0de@users.noreply.code.brunner.ninja" + git add bootstrap/immich-sync.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy immich-sync chart ${CHART_VERSION}" git push origin HEAD:main diff --git a/README.md b/README.md index fc906e5..551148a 100644 --- a/README.md +++ b/README.md @@ -73,14 +73,23 @@ kubectl apply -f deploy/example.yaml kubectl port-forward service/immich-sync 8090:80 ``` +The maintained Helm chart is published with the exact image revision in its +`appVersion`: + +```sh +helm repo add brunner https://brunner.ninja/charts +helm repo update brunner +helm upgrade --install immich-sync brunner/immich-sync +``` + `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 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, +[`apps/immich-sync` GitOps values](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/immich-sync) +are public and show one Argo CD installation of the chart. They include 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. @@ -107,8 +116,9 @@ Per-process operational counters and active gauges should be summed across repli ## 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. +`registry.brunner.ninja/feedc0de/immich-sync`. Every release receives an immutable image tag containing its full Git +SHA. The same run packages the chart with that SHA as `appVersion`. Chart versions come from `git describe`: tag +`v1.0.0` produces chart `1.0.0`; a later commit produces a unique version such as `1.0.0-1-g41627ab`. 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 @@ -118,15 +128,16 @@ 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. +- `PACKAGE_USERNAME` and `PACKAGE_TOKEN`: credentials that can publish to the public `feedc0de` Helm repository. - `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. -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. +After a successful `main` build, the promotion job updates the Argo Application's exact chart `targetRevision` 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. ## Current scope diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..6ab5c00 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: immich-sync +description: Compare and synchronize assets between two Immich public shares +type: application +version: 0.1.0 +appVersion: latest diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..6e9d2cd --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{- define "immich-sync.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "immich-sync.fullname" -}} +{{- default (include "immich-sync.name" .) .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "immich-sync.labels" -}} +app.kubernetes.io/name: {{ include "immich-sync.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "immich-sync.selectorLabels" -}} +app: immich-sync +{{- end }} diff --git a/chart/templates/optional.yaml b/chart/templates/optional.yaml new file mode 100644 index 0000000..dc907ae --- /dev/null +++ b/chart/templates/optional.yaml @@ -0,0 +1,58 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "immich-sync.fullname" . }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "immich-sync.fullname" . }} + port: + name: http +{{- end }} +{{- if .Values.podDisruptionBudget.enabled }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "immich-sync.fullname" . }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "immich-sync.selectorLabels" . | nindent 6 }} +{{- end }} +{{- if .Values.metrics.serviceMonitor.enabled }} +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "immich-sync.fullname" . }} + labels: + {{- include "immich-sync.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "immich-sync.selectorLabels" . | nindent 6 }} + endpoints: + - port: http + path: /metrics + interval: 30s + scrapeTimeout: 10s +{{- end }} diff --git a/chart/templates/pvc.yaml b/chart/templates/pvc.yaml new file mode 100644 index 0000000..f0efd02 --- /dev/null +++ b/chart/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "immich-sync.fullname" . }}-stats + labels: + {{- include "immich-sync.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- with .Values.persistence.storageClassName }} + storageClassName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/chart/templates/workload.yaml b/chart/templates/workload.yaml new file mode 100644 index 0000000..2756b65 --- /dev/null +++ b/chart/templates/workload.yaml @@ -0,0 +1,98 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "immich-sync.fullname" . }} + labels: + {{- include "immich-sync.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + {{- include "immich-sync.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "immich-sync.selectorLabels" . | nindent 8 }} + {{- include "immich-sync.labels" . | nindent 8 }} + spec: + automountServiceAccountToken: false + securityContext: + fsGroup: 65532 + fsGroupChangePolicy: OnRootMismatch + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: immich-sync + image: {{ printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + 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: + {{- toYaml .Values.resources | nindent 10 }} + 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 + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ default (printf "%s-stats" (include "immich-sync.fullname" .)) .Values.persistence.existingClaim }} + {{- else }} + emptyDir: {} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "immich-sync.fullname" . }} + labels: + {{- include "immich-sync.labels" . | nindent 4 }} +spec: + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + selector: + {{- include "immich-sync.selectorLabels" . | nindent 4 }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..680f808 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,44 @@ +replicaCount: 1 + +image: + repository: registry.brunner.ninja/feedc0de/immich-sync + tag: "" + pullPolicy: IfNotPresent +imagePullSecrets: [] + +service: + port: 80 + +persistence: + enabled: false + existingClaim: "" + storageClassName: "" + accessModes: [ReadWriteMany] + size: 64Mi + +ingress: + enabled: false + className: "" + annotations: {} + host: immich-sync.example.com + +metrics: + serviceMonitor: + enabled: false + labels: {} + +podDisruptionBudget: + enabled: false + minAvailable: 1 + +resources: + requests: + cpu: 10m + memory: 32Mi + ephemeral-storage: 64Mi + limits: + cpu: "1" + memory: 128Mi + ephemeral-storage: 20Gi + +affinity: {} diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..a20feb6 --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +description=$(git describe --tags --long --match 'v[0-9]*' --dirty) +case "$description" in + *-dirty) echo "refusing to package a dirty tree" >&2; exit 1 ;; +esac +version=${description#v} +case "$version" in + *-0-g*) version=${version%%-0-g*} ;; +esac +printf '%s\n' "$version"