Retire Jellyfin direct deploy bootstrap and guard cold backups
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: jellyfin
|
||||
labels:
|
||||
app.kubernetes.io/name: jellyfin
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user