From c93ecd5dd6c39bea41e67c3fcc57eceb211270aa Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Fri, 23 Oct 2026 09:53:36 +0200 Subject: [PATCH] Retire Jellyfin direct deploy bootstrap and guard cold backups --- README.md | 31 ++++------- backup.sh | 6 ++ ci-deployer.yaml | 60 -------------------- create-ci-kubeconfig.sh | 48 ---------------- migrate.sh | 47 ---------------- migration-job.yaml | 119 ---------------------------------------- namespace.yaml | 6 -- test.sh | 21 ------- 8 files changed, 16 insertions(+), 322 deletions(-) delete mode 100644 ci-deployer.yaml delete mode 100755 create-ci-kubeconfig.sh delete mode 100755 migrate.sh delete mode 100644 migration-job.yaml delete mode 100644 namespace.yaml delete mode 100755 test.sh diff --git a/README.md b/README.md index f587c5d..19897e0 100644 --- a/README.md +++ b/README.md @@ -21,30 +21,19 @@ image is pinned to version 12.2. Before this major-version migration, the complete PVC was backed up cold to the bcachefs-backed `/komposthaufen/backups/jellyfin` directory on `arschrock`. -## One-time migration +## Historical migration -`migrate.sh` stops the Arch service, confirms no Jellyfin process remains, -checks every source SQLite database, creates the PVC, and starts a one-shot -copy Job: - -```sh -./migrate.sh -kubectl -n jellyfin logs -f job/jellyfin-archlinux-migration -kubectl -n jellyfin wait --for=condition=complete \ - job/jellyfin-archlinux-migration --timeout=12h -``` - -The Job resumes an interrupted copy using rsync, checks all copied files with -checksums, and then compares regular-file counts and byte totals before writing -its completion marker. Copy I/O is deliberately duty-cycle throttled so the -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. - -The historical migration scripts remain for recovery; routine deployment is -owned by Argo CD. Jellyfin remains available at +The one-time Arch-to-Kubernetes copy tooling is preserved in Git history, not +as an active deployment path. The existing `jellyfin-config` PVC must not be +deleted or recreated during upgrades. The cold pre-12.2 backup remains under +`/komposthaufen/backups/jellyfin` on arschrock. Routine deployment is owned by +Argo CD. Jellyfin remains available at `https://jellyfin.brunner.ninja` and directly on `192.168.0.2:8096`. +The historical `backup.sh` refuses to scale Jellyfin while Argo automatic +sync is enabled; suspend automatic sync deliberately before a cold backup, +then restore normal reconciliation afterward. + ## Monitoring The init container enables Jellyfin's native Prometheus endpoint. The diff --git a/backup.sh b/backup.sh index 8539f63..3eaf302 100755 --- a/backup.sh +++ b/backup.sh @@ -11,6 +11,12 @@ if [ "${context}" != "${expected_context}" ]; then exit 1 fi +if [ "$(kubectl --namespace argocd get application/jellyfin \ + --output=jsonpath='{.spec.syncPolicy.automated.enabled}')" = true ]; then + echo 'Refusing cold backup while Argo self-heal can restart Jellyfin. Suspend automatic sync first.' >&2 + exit 1 +fi + kubectl --namespace jellyfin scale deployment/jellyfin --replicas=0 kubectl --namespace jellyfin wait --for=delete pod \ --selector=app.kubernetes.io/name=jellyfin,app.kubernetes.io/component=server \ diff --git a/ci-deployer.yaml b/ci-deployer.yaml deleted file mode 100644 index bd399f9..0000000 --- a/ci-deployer.yaml +++ /dev/null @@ -1,60 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: jellyfin-deployer - namespace: jellyfin -automountServiceAccountToken: false ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: jellyfin-deployer - namespace: jellyfin -rules: - - apiGroups: [""] - resources: ["configmaps"] - resourceNames: ["jellyfin-scripts"] - verbs: ["get", "patch", "update"] - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - resourceNames: ["jellyfin-config"] - verbs: ["get", "patch", "update"] - - apiGroups: [""] - resources: ["services"] - resourceNames: ["jellyfin"] - verbs: ["get", "patch", "update"] - - apiGroups: ["apps"] - resources: ["deployments"] - resourceNames: ["jellyfin"] - verbs: ["get", "patch", "update"] - - apiGroups: ["traefik.io"] - resources: ["ingressroutes", "middlewares"] - resourceNames: ["jellyfin-brunner-ninja", "jellyfin-metrics-internal"] - verbs: ["get", "patch", "update"] - - apiGroups: ["monitoring.coreos.com"] - resources: ["servicemonitors", "prometheusrules"] - resourceNames: ["jellyfin"] - verbs: ["get", "patch", "update"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: jellyfin-deployer - namespace: jellyfin -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: jellyfin-deployer -subjects: - - kind: ServiceAccount - name: jellyfin-deployer - namespace: jellyfin ---- -apiVersion: v1 -kind: Secret -metadata: - name: jellyfin-deployer-token - namespace: jellyfin - annotations: - kubernetes.io/service-account.name: jellyfin-deployer -type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh deleted file mode 100755 index 86d61b5..0000000 --- a/create-ci-kubeconfig.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -namespace=jellyfin -service_account=jellyfin-deployer -secret=jellyfin-deployer-token - -if [[ "$(kubectl config current-context)" != kubernetes-admin@kubernetes ]]; then - echo 'Run this only with the kubernetes-admin@kubernetes homelab context.' >&2 - exit 1 -fi -kubectl apply --filename "${project_dir}/namespace.yaml" >&2 -kubectl apply --filename "${project_dir}/ci-deployer.yaml" >&2 - -for attempt in {1..30}; do - if kubectl --namespace "${namespace}" get secret "${secret}" \ - --output=jsonpath='{.data.token}' 2>/dev/null | grep -q .; then - break - fi - if [[ "${attempt}" == 30 ]]; then - echo 'Timed out waiting for the service-account token.' >&2 - exit 1 - fi - sleep 1 -done - -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 "${service_account}" --token="${token}" >/dev/null -kubectl config set-context jellyfin --cluster=cluster --user="${service_account}" \ - --namespace="${namespace}" >/dev/null -kubectl config use-context jellyfin >/dev/null - -echo 'Store this single line as the Gitea Actions secret KUBE_CONFIG_BASE64.' >&2 -echo 'Treat it as a password; do not commit it.' >&2 -base64 --wrap=0 "${KUBECONFIG}" -printf '\n' diff --git a/migrate.sh b/migrate.sh deleted file mode 100755 index fac7343..0000000 --- a/migrate.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[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 - -sudo systemctl stop jellyfin.service -if sudo systemctl is-active --quiet jellyfin.service; then - echo 'jellyfin.service is still active; refusing to copy its databases.' >&2 - exit 1 -fi -if pgrep -u jellyfin >/dev/null; then - echo 'A process owned by jellyfin is still running; refusing to copy its databases.' >&2 - exit 1 -fi - -while IFS= read -r -d '' database; do - result=$(sudo -u jellyfin sqlite3 "${database}" 'PRAGMA quick_check;') - if [[ "${result}" != ok ]]; then - echo "SQLite quick_check failed for ${database}: ${result}" >&2 - exit 1 - fi -done < <(sudo -u jellyfin find /var/lib/jellyfin -type f -name '*.db' -print0) - -kubectl apply --filename "${project_dir}/namespace.yaml" -kubectl apply --filename "${project_dir}/jellyfin.yaml" \ - --selector=app.kubernetes.io/component=state -kubectl --namespace jellyfin wait \ - --for=jsonpath='{.status.phase}'=Bound \ - persistentvolumeclaim/jellyfin-config \ - --timeout=10m - -if kubectl --namespace jellyfin get job jellyfin-archlinux-migration >/dev/null 2>&1; then - echo 'Job jellyfin-archlinux-migration already exists; inspect it instead of overwriting it.' >&2 - exit 1 -fi -kubectl apply --filename "${project_dir}/migration-job.yaml" -echo 'Migration started. Follow it with:' -echo ' kubectl -n jellyfin logs -f job/jellyfin-archlinux-migration' -echo ' kubectl -n jellyfin wait --for=condition=complete job/jellyfin-archlinux-migration --timeout=12h' diff --git a/migration-job.yaml b/migration-job.yaml deleted file mode 100644 index ae7a126..0000000 --- a/migration-job.yaml +++ /dev/null @@ -1,119 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: jellyfin-archlinux-migration - namespace: jellyfin - labels: - app.kubernetes.io/name: jellyfin - app.kubernetes.io/component: migration -spec: - backoffLimit: 0 - template: - metadata: - labels: - app.kubernetes.io/name: jellyfin - app.kubernetes.io/component: migration - spec: - restartPolicy: Never - nodeSelector: - kubernetes.io/hostname: arschrock - tolerations: - - key: node-role.kubernetes.io/control-plane - operator: Exists - effect: NoSchedule - containers: - - name: copy - image: docker.io/library/alpine:3.24.2 - imagePullPolicy: IfNotPresent - command: - - /bin/sh - - -ec - - | - marker=/target/.archlinux-migration-complete - if [ -e "${marker}" ]; then - echo 'Migration marker already exists; refusing to overwrite the PVC.' >&2 - exit 1 - fi - apk add --no-cache rsync - mkdir -p /target/data /target/config /target/cache /target/log - chown 930:930 /target /target/data /target/config /target/cache /target/log - copy_throttled() { - setsid "$@" & - copy_pid=$! - sleep 0.1 - while kill -0 "${copy_pid}" 2>/dev/null; do - kill -CONT "-${copy_pid}" 2>/dev/null || true - sleep 0.2 - kill -STOP "-${copy_pid}" 2>/dev/null || true - sleep 1.8 - done - kill -CONT "-${copy_pid}" 2>/dev/null || true - wait "${copy_pid}" - } - for directory in data config cache log; do - copy_throttled rsync -a --partial --inplace --bwlimit=8m \ - "/source/${directory}/" "/target/${directory}/" - done - sync -f /target - for directory in data config cache log; do - copy_throttled rsync -a --checksum --dry-run \ - --out-format='%n' "/source/${directory}/" "/target/${directory}/" \ - > "/target/.${directory}-verification" - if [ -s "/target/.${directory}-verification" ]; then - echo "Verification found differences in ${directory}:" >&2 - head -20 "/target/.${directory}-verification" >&2 - exit 1 - fi - done - source_files=$(find /source/data /source/config /source/cache /source/log -type f | wc -l) - destination_files=$(find /target/data /target/config /target/cache /target/log -type f | wc -l) - source_bytes=$(find /source/data /source/config /source/cache /source/log -type f -exec stat -c '%s' {} + | awk '{ total += $1 } END { print total + 0 }') - destination_bytes=$(find /target/data /target/config /target/cache /target/log -type f -exec stat -c '%s' {} + | awk '{ total += $1 } END { print total + 0 }') - test "${source_files}" = "${destination_files}" - test "${source_bytes}" = "${destination_bytes}" - printf 'source_files=%s\nsource_bytes=%s\n' "${source_files}" "${source_bytes}" > "${marker}" - chown 930:930 /target "${marker}" - echo "Copied ${source_files} files (${source_bytes} bytes)." - resources: - requests: - cpu: 250m - memory: 256Mi - securityContext: - allowPrivilegeEscalation: false - runAsUser: 0 - runAsGroup: 0 - volumeMounts: - - name: data - mountPath: /source/data - readOnly: true - - name: config - mountPath: /source/config - readOnly: true - - name: cache - mountPath: /source/cache - readOnly: true - - name: log - mountPath: /source/log - readOnly: true - - name: target - mountPath: /target - volumes: - - name: data - hostPath: - path: /var/lib/jellyfin - type: Directory - - name: config - hostPath: - path: /etc/jellyfin - type: Directory - - name: cache - hostPath: - path: /var/cache/jellyfin - type: Directory - - name: log - hostPath: - path: /var/log/jellyfin - type: Directory - - name: target - persistentVolumeClaim: - claimName: jellyfin-config diff --git a/namespace.yaml b/namespace.yaml deleted file mode 100644 index 2dcf216..0000000 --- a/namespace.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: jellyfin - labels: - app.kubernetes.io/name: jellyfin diff --git a/test.sh b/test.sh deleted file mode 100755 index 63d085a..0000000 --- a/test.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) - -sh -n "${project_dir}/install.sh" -sh -n "${project_dir}/backup.sh" -bash -n "${project_dir}/migrate.sh" -bash -n "${project_dir}/create-ci-kubeconfig.sh" - -if command -v podman >/dev/null 2>&1; then - for manifest in namespace.yaml jellyfin.yaml migration-job.yaml backup-job.yaml plugin-upgrade-job.yaml; do - podman run --rm --interactive \ - ghcr.io/yannh/kubeconform:v0.7.0 \ - -strict -ignore-missing-schemas -summary < "${project_dir}/${manifest}" - done - podman run --rm --interactive \ - ghcr.io/yannh/kubeconform:v0.7.0 \ - -strict -summary < "${project_dir}/ci-deployer.yaml" -fi