From e6fe3ed57e2fbdb37be937f258441842b7d9ddc5 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Fri, 23 Oct 2026 09:49:00 +0200 Subject: [PATCH] Publish Jellyfin chart and replace direct Kubernetes CD --- .gitea/workflows/chart.yml | 62 ++++++++++++++++ .gitea/workflows/deploy.yml | 72 ------------------- README.md | 28 +++++--- chart/Chart.yaml | 6 ++ jellyfin.yaml => chart/templates/all.yaml | 86 ++++++++++++----------- chart/values.yaml | 23 ++++++ install.sh | 37 +++------- scripts/chart-version.sh | 8 +++ scripts/test-chart.sh | 11 +++ 9 files changed, 180 insertions(+), 153 deletions(-) create mode 100644 .gitea/workflows/chart.yml delete mode 100644 .gitea/workflows/deploy.yml create mode 100644 chart/Chart.yaml rename jellyfin.yaml => chart/templates/all.yaml (75%) create mode 100644 chart/values.yaml create mode 100755 scripts/chart-version.sh create mode 100755 scripts/test-chart.sh diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..8b667f3 --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,62 @@ +name: Validate, publish and promote Jellyfin chart +on: + push: + pull_request: +jobs: + validate: + runs-on: linux_amd64 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Lint and render + run: ./scripts/test-chart.sh + publish: + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + needs: validate + runs-on: linux_amd64 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Derive chart version + run: echo "CHART_VERSION=$(./scripts/chart-version.sh)" >> "$GITHUB_ENV" + - name: Publish public chart + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "$PACKAGE_USERNAME" && test -n "$PACKAGE_TOKEN" + helm package chart --destination "$RUNNER_TEMP" --version "$CHART_VERSION" + curl --fail --silent --show-error --user "$PACKAGE_USERNAME:$PACKAGE_TOKEN" \ + --request POST --upload-file "$RUNNER_TEMP/jellyfin-$CHART_VERSION.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Confirm current main + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + test "$(git rev-parse FETCH_HEAD)" = "$COMMIT_SHA" + - uses: actions/checkout@v4 + with: + repository: feedc0de/homelab-gitops + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote exact chart + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh jellyfin "$CHART_VERSION" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add bootstrap/jellyfin.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy jellyfin chart $CHART_VERSION" + for attempt in 1 2 3; do + git push origin HEAD:main && exit 0 + test "$attempt" = 3 && exit 1 + git fetch origin main + git rebase origin/main + done diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index 93a98bb..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Validate and deploy Jellyfin - -on: - push: - pull_request: - -env: - KUBECTL_VERSION: v1.36.4 - KUBERNETES_API: https://host.containers.internal:6443 - KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 - -jobs: - validate: - name: Validate manifests and scripts - runs-on: ubuntu-latest - steps: - - name: Check out source - uses: actions/checkout@v4 - - name: Validate - run: ./test.sh - - deploy: - name: Deploy to Kubernetes - if: gitea.ref == 'refs/heads/main' - needs: validate - runs-on: ubuntu-latest - steps: - - name: Check out source - uses: actions/checkout@v4 - - name: Install kubectl - 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: 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: Apply and verify - env: - COMMIT_SHA: ${{ gitea.sha }} - run: | - set -euo pipefail - export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" - sed "s|deployment.brunner.ninja/revision: manual|deployment.brunner.ninja/revision: ${COMMIT_SHA}|" \ - jellyfin.yaml > "${RUNNER_TEMP}/jellyfin.yaml" - "${RUNNER_TEMP}/kubectl" apply --dry-run=server --validate=false \ - --filename "${RUNNER_TEMP}/jellyfin.yaml" - "${RUNNER_TEMP}/kubectl" apply --validate=false \ - --filename "${RUNNER_TEMP}/jellyfin.yaml" - for attempt in {1..180}; do - IFS='|' read -r generation observed desired updated ready available image <<< "$( - "${RUNNER_TEMP}/kubectl" --namespace jellyfin get deployment/jellyfin \ - --output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.readyReplicas}|{.status.availableReplicas}|{.spec.template.spec.containers[0].image}' - )" - echo "Rollout ${attempt}/180: generation ${observed}/${generation}, replicas ${updated}/${desired} updated, ${ready}/${desired} ready, ${available}/${desired} available, image ${image}" - if [[ "${observed}" == "${generation}" && "${updated}" == "${desired}" \ - && "${ready}" == "${desired}" && "${available}" == "${desired}" ]]; then - exit 0 - fi - sleep 5 - done - "${RUNNER_TEMP}/kubectl" --namespace jellyfin get deployment/jellyfin --output=yaml - exit 1 diff --git a/README.md b/README.md index d7c5bb3..f587c5d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ migration cannot monopolize bcachefs and Ceph. Do not delete the Arch source trees until the Kubernetes server has been verified and a separate backup exists. -Deploy with `./install.sh`. Jellyfin remains available at +The historical migration scripts remain for recovery; routine deployment is +owned by Argo CD. Jellyfin remains available at `https://jellyfin.brunner.ninja` and directly on `192.168.0.2:8096`. ## Monitoring @@ -52,17 +53,24 @@ restricts public access to `/metrics`. Alerts cover scrape availability, restart loops, and PVC capacity. Existing node-exporter, Netdata, SMART, and bcachefs monitoring remain in use. -## Gitea CI/CD bootstrap +## Helm and GitOps -After initializing this directory as its own repository and creating the Gitea -repository, add the remote and perform the first push. Then run: +This repository owns the Jellyfin chart: Deployment, Service, media guard, +ingress option, metrics middleware, and monitoring. The chart preserves the +existing Deployment selector used by `media-storage-offline.sh` and +`media-storage-online.sh`. The homelab's node pin, guarded hostPath, existing +PVC, public host and resource requests are selected in the +[GitOps values](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/jellyfin). ```sh -./create-ci-kubeconfig.sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm upgrade --install jellyfin brunner.ninja/jellyfin --namespace jellyfin --create-namespace ``` -Store the single output line as `KUBE_CONFIG_BASE64`. The CI identity can only -update the already-created Jellyfin resources named in `ci-deployer.yaml`; it -cannot read Secrets, run migration Jobs, or alter other workloads. Pull -requests validate, while pushes to `main` validate and deploy the pinned -upstream image. +To install this checkout manually, run `./install.sh -f my-values.yaml` with +the target namespace and media mount configured. Main-branch CI validates, +publishes the chart using the upstream application Git tag plus `git describe`, +then promotes its exact version to GitOps. CI needs `PACKAGE_USERNAME`, +`PACKAGE_TOKEN`, and `GITOPS_TOKEN`, but no kubeconfig. The Jellyfin image is +the official upstream image and is selected by `Chart.yaml.appVersion`. diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..0d4ab06 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: jellyfin +description: Jellyfin with guarded host-local media and native metrics +type: application +version: 0.0.0 +appVersion: "12.2" diff --git a/jellyfin.yaml b/chart/templates/all.yaml similarity index 75% rename from jellyfin.yaml rename to chart/templates/all.yaml index 38613fb..5cf3138 100644 --- a/jellyfin.yaml +++ b/chart/templates/all.yaml @@ -1,8 +1,9 @@ +{{- if not .Values.persistence.existingClaim }} apiVersion: v1 kind: PersistentVolumeClaim metadata: name: jellyfin-config - namespace: jellyfin + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin app.kubernetes.io/component: state @@ -11,26 +12,29 @@ spec: - ReadWriteOnce resources: requests: - storage: 80Gi - storageClassName: rook-ceph-block-ec + storage: {{ .Values.persistence.size }} + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} +{{- end }} --- apiVersion: v1 kind: ConfigMap metadata: name: jellyfin-scripts - namespace: jellyfin + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin data: prepare.sh: | #!/bin/sh set -eu - filesystem_magic=$(stat -f -c '%t' /komposthaufen/multimedia) + filesystem_magic=$(stat -f -c '%t' {{ .Values.media.mountPath }}) if [ "${filesystem_magic}" != ca451a4e ]; then - echo "Expected bcachefs at /komposthaufen/multimedia, found filesystem magic ${filesystem_magic}" >&2 + echo "Expected bcachefs at {{ .Values.media.mountPath }}, found filesystem magic ${filesystem_magic}" >&2 exit 1 fi - test -d /komposthaufen/multimedia/Videos + test -d {{ .Values.media.mountPath }}/Videos system_xml=/state/config/system.xml test -s "${system_xml}" if grep -q 'false' "${system_xml}"; then @@ -43,7 +47,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: jellyfin - namespace: jellyfin + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin app.kubernetes.io/component: server @@ -67,12 +71,9 @@ spec: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet nodeSelector: - kubernetes.io/hostname: arschrock - media.brunner.ninja/multimedia-ready: "true" + {{- toYaml .Values.nodeSelector | nindent 8 }} tolerations: - - key: node-role.kubernetes.io/control-plane - operator: Exists - effect: NoSchedule + {{- toYaml .Values.tolerations | nindent 8 }} terminationGracePeriodSeconds: 120 securityContext: seccompProfile: @@ -96,7 +97,7 @@ spec: - name: state mountPath: /state - name: media - mountPath: /komposthaufen/multimedia + mountPath: {{ .Values.media.mountPath }} readOnly: true - name: media-ready mountPath: /media-ready @@ -106,11 +107,11 @@ spec: readOnly: true containers: - name: jellyfin - image: docker.io/jellyfin/jellyfin:12.2 + image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }} imagePullPolicy: IfNotPresent env: - name: TZ - value: Europe/Vienna + value: {{ .Values.timezone | quote }} - name: HOME value: /var/lib/jellyfin - name: JELLYFIN_DATA_DIR @@ -122,7 +123,7 @@ spec: - name: JELLYFIN_CACHE_DIR value: /var/cache/jellyfin - name: JELLYFIN_PublishedServerUrl - value: https://jellyfin.brunner.ninja + value: {{ .Values.publishedServerUrl | quote }} ports: - name: http containerPort: 8096 @@ -152,10 +153,7 @@ spec: periodSeconds: 30 timeoutSeconds: 5 resources: - requests: - cpu: 500m - memory: 2Gi - ephemeral-storage: 1Gi + {{- toYaml .Values.resources | nindent 12 }} securityContext: allowPrivilegeEscalation: false capabilities: @@ -178,23 +176,23 @@ spec: - name: cache mountPath: /var/cache/jellyfin - name: media - mountPath: /komposthaufen/multimedia + mountPath: {{ .Values.media.mountPath }} readOnly: true - name: tmp mountPath: /tmp volumes: - name: state persistentVolumeClaim: - claimName: jellyfin-config + claimName: {{ default "jellyfin-config" .Values.persistence.existingClaim }} - name: cache emptyDir: {} - name: media hostPath: - path: /komposthaufen/multimedia + path: {{ .Values.media.hostPath }} type: Directory - name: media-ready hostPath: - path: /komposthaufen/multimedia/Videos + path: {{ .Values.media.hostPath }}/Videos type: Directory - name: tmp emptyDir: {} @@ -207,7 +205,7 @@ apiVersion: v1 kind: Service metadata: name: jellyfin - namespace: jellyfin + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin app.kubernetes.io/component: service @@ -220,32 +218,33 @@ spec: port: 8096 targetPort: http protocol: TCP +{{- if .Values.ingress.enabled }} --- apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: jellyfin-metrics-internal - namespace: jellyfin + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin spec: ipAllowList: sourceRange: - - 10.0.0.0/8 + - {{ .Values.monitoring.allowedSourceRange }} --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: - name: jellyfin-brunner-ninja - namespace: jellyfin + name: {{ default "jellyfin" .Values.ingress.name }} + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin spec: entryPoints: - - websecure + - {{ .Values.ingress.entryPoint }} routes: - kind: Rule - match: Host(`jellyfin.brunner.ninja`) && PathPrefix(`/metrics`) + match: Host(`{{ required "ingress.host is required" .Values.ingress.host }}`) && PathPrefix(`/metrics`) priority: 100 middlewares: - name: jellyfin-metrics-internal @@ -255,19 +254,21 @@ spec: port: http passHostHeader: true - kind: Rule - match: Host(`jellyfin.brunner.ninja`) + match: Host(`{{ .Values.ingress.host }}`) priority: 10 services: - kind: Service name: jellyfin port: http passHostHeader: true +{{- end }} +{{- if .Values.monitoring.enabled }} --- apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: name: jellyfin - namespace: jellyfin + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin spec: @@ -285,7 +286,7 @@ apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: name: jellyfin - namespace: jellyfin + namespace: {{ .Release.Namespace }} labels: app.kubernetes.io/name: jellyfin spec: @@ -294,11 +295,11 @@ spec: rules: - alert: JellyfinMetricsTargetDown expr: |- - max(kube_deployment_status_replicas_available{namespace="jellyfin",deployment="jellyfin"}) >= 1 + max(kube_deployment_status_replicas_available{namespace="{{ .Release.Namespace }}",deployment="jellyfin"}) >= 1 and on() ( - max(up{namespace="jellyfin",service="jellyfin"}) < 1 - or absent(up{namespace="jellyfin",service="jellyfin"}) + max(up{namespace="{{ .Release.Namespace }}",service="jellyfin"}) < 1 + or absent(up{namespace="{{ .Release.Namespace }}",service="jellyfin"}) ) for: 10m labels: @@ -310,7 +311,7 @@ spec: - alert: JellyfinContainerRestarting expr: |- sum by (container) ( - increase(kube_pod_container_status_restarts_total{namespace="jellyfin",pod=~"jellyfin-.*"}[15m]) + increase(kube_pod_container_status_restarts_total{namespace="{{ .Release.Namespace }}",pod=~"jellyfin-.*"}[15m]) ) > 2 for: 5m labels: @@ -318,12 +319,12 @@ spec: component: kubernetes annotations: summary: Jellyfin container is repeatedly restarting - description: Container {{ $labels.container }} restarted more than twice in 15 minutes. + description: Container {{ "{{ $labels.container }}" }} restarted more than twice in 15 minutes. - alert: JellyfinConfigVolumeFilling expr: |- - kubelet_volume_stats_available_bytes{namespace="jellyfin",persistentvolumeclaim="jellyfin-config"} + kubelet_volume_stats_available_bytes{namespace="{{ .Release.Namespace }}",persistentvolumeclaim="{{ default "jellyfin-config" .Values.persistence.existingClaim }}"} / - kubelet_volume_stats_capacity_bytes{namespace="jellyfin",persistentvolumeclaim="jellyfin-config"} < 0.15 + kubelet_volume_stats_capacity_bytes{namespace="{{ .Release.Namespace }}",persistentvolumeclaim="{{ default "jellyfin-config" .Values.persistence.existingClaim }}"} < 0.15 for: 30m labels: severity: warning @@ -331,3 +332,4 @@ spec: annotations: summary: Jellyfin configuration volume is filling description: Less than 15 percent of the Jellyfin configuration PVC remains available. +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..d130a37 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,23 @@ +image: + repository: docker.io/jellyfin/jellyfin + tag: "" +media: + hostPath: /media + mountPath: /media +nodeSelector: {} +tolerations: [] +persistence: + existingClaim: "" + storageClass: "" + size: 80Gi +timezone: UTC +publishedServerUrl: "" +resources: {} +ingress: + enabled: false + name: "" + host: "" + entryPoint: websecure +monitoring: + enabled: false + allowedSourceRange: 10.0.0.0/8 diff --git a/install.sh b/install.sh index b5f0750..bba6096 100755 --- a/install.sh +++ b/install.sh @@ -1,31 +1,10 @@ #!/bin/sh - set -eu - -project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -expected_context=kubernetes-admin@kubernetes -context=$(kubectl config current-context) - -if [ "${context}" != "${expected_context}" ]; then - echo "Expected Kubernetes context ${expected_context}, found ${context}." >&2 - exit 1 -fi -kubectl apply --filename "${project_dir}/namespace.yaml" -if kubectl --namespace jellyfin get job/jellyfin-archlinux-migration >/dev/null 2>&1; then - kubectl --namespace jellyfin wait --for=condition=complete \ - job/jellyfin-archlinux-migration --timeout=5s -fi -kubectl apply --dry-run=server --filename "${project_dir}/jellyfin.yaml" -kubectl apply --filename "${project_dir}/jellyfin.yaml" -kubectl --namespace jellyfin scale deployment/jellyfin --replicas=1 -kubectl --namespace jellyfin rollout status deployment/jellyfin --timeout=30m - -pod=$(kubectl --namespace jellyfin get pod \ - --selector=app.kubernetes.io/name=jellyfin,app.kubernetes.io/component=server \ - --output=jsonpath='{.items[0].metadata.name}') -node=$(kubectl --namespace jellyfin get pod "${pod}" --output=jsonpath='{.spec.nodeName}') -image=$(kubectl --namespace jellyfin get deployment jellyfin --output=jsonpath='{.spec.template.spec.containers[0].image}') -test "${node}" = arschrock -test "${image}" = docker.io/jellyfin/jellyfin:12.2 -kubectl --namespace jellyfin get pod "${pod}" --output=wide -printf 'Jellyfin is ready on %s with image %s.\n' "${node}" "${image}" +release=${RELEASE_NAME:-jellyfin} +namespace=${NAMESPACE:-jellyfin} +version=$(./scripts/chart-version.sh) +package_dir=$(mktemp -d) +trap 'rm -rf -- "$package_dir"' EXIT +helm package ./chart --version "$version" --destination "$package_dir" >/dev/null +helm upgrade --install "$release" "$package_dir/jellyfin-$version.tgz" \ + --namespace "$namespace" --create-namespace --wait --timeout 30m "$@" diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..d644052 --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +description=$(git describe --tags --long --match 'v[0-9]*' --dirty) +case "$description" in *-dirty) echo 'Refusing dirty tree' >&2; exit 1 ;; esac +version=${description#v} +case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac +version=$(printf '%s\n' "$version" | sed -E 's/^([0-9]+\.[0-9]+)(-[0-9]+-g[0-9a-f]+)?$/\1.0\2/') +printf '%s\n' "$version" diff --git a/scripts/test-chart.sh b/scripts/test-chart.sh new file mode 100755 index 0000000..4d655f1 --- /dev/null +++ b/scripts/test-chart.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +helm lint chart +rendered=$(helm template jellyfin chart --namespace jellyfin --set persistence.existingClaim=retained-data --set media.hostPath=/example/media --set media.mountPath=/media --set ingress.enabled=true --set ingress.host=jellyfin.example.org --set monitoring.enabled=true) +printf '%s\n' "$rendered" | grep -q 'claimName: retained-data' +printf '%s\n' "$rendered" | grep -q 'type: Recreate' +printf '%s\n' "$rendered" | grep -q 'path: /example/media' +printf '%s\n' "$rendered" | grep -q 'ca451a4e' +if printf '%s\n' "$rendered" | grep -q '^kind: PersistentVolumeClaim$'; then exit 1; fi +helm template jellyfin chart --namespace jellyfin | grep -q '^kind: PersistentVolumeClaim$' +sh -n install.sh scripts/chart-version.sh scripts/test-chart.sh