Retire Jellyfin direct deploy bootstrap and guard cold backups
Validate, publish and promote Jellyfin chart / validate (push) Successful in 1s
Validate, publish and promote Jellyfin chart / publish (push) Successful in 3s

This commit is contained in:
2026-10-23 09:53:36 +02:00
parent e6fe3ed57e
commit c93ecd5dd6
8 changed files with 16 additions and 322 deletions
+10 -21
View File
@@ -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
+6
View File
@@ -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 \
-60
View File
@@ -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
-48
View File
@@ -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'
-47
View File
@@ -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'
-119
View File
@@ -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
-6
View File
@@ -1,6 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: jellyfin
labels:
app.kubernetes.io/name: jellyfin
-21
View File
@@ -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