Initial commit with the existing deployment files
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
name: Validate and deploy Plex
|
||||
|
||||
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"
|
||||
curl --fail --silent --show-error --location \
|
||||
--output "${RUNNER_TEMP}/kubectl.sha256" \
|
||||
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
|
||||
printf '%s %s\n' "$(cat "${RUNNER_TEMP}/kubectl.sha256")" "${RUNNER_TEMP}/kubectl" | sha256sum --check
|
||||
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}|" \
|
||||
plex.yaml > "${RUNNER_TEMP}/plex.yaml"
|
||||
"${RUNNER_TEMP}/kubectl" apply --dry-run=server --validate=false \
|
||||
--filename "${RUNNER_TEMP}/plex.yaml"
|
||||
"${RUNNER_TEMP}/kubectl" apply --validate=false \
|
||||
--filename "${RUNNER_TEMP}/plex.yaml"
|
||||
for attempt in {1..180}; do
|
||||
IFS='|' read -r generation observed desired updated ready available image <<< "$(
|
||||
"${RUNNER_TEMP}/kubectl" --namespace plex get deployment/plex \
|
||||
--output=jsonpath='{.metadata.generation}|{.status.observedGeneration}|{.spec.replicas}|{.status.updatedReplicas}|{.status.readyReplicas}|{.status.availableReplicas}|{.spec.template.spec.containers[?(@.name=="plex")].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 plex get deployment/plex --output=yaml
|
||||
exit 1
|
||||
@@ -1,3 +1,65 @@
|
||||
# plex-deployment
|
||||
# Plex on Kubernetes
|
||||
|
||||
My kubernetes configuration files to run plex in my kubernetes cluster
|
||||
This project runs the existing Plex server as a single high-performance Pod on
|
||||
`arschrock` in its dedicated `plex` namespace. It is intentionally not highly
|
||||
available and can never fail over to an ODROID. CPU and memory have scheduling
|
||||
requests but no limits.
|
||||
|
||||
The 500 TB media tree stays on the host and is mounted read-only from
|
||||
`/komposthaufen/multimedia`. The Pod also requires the existing `Videos`
|
||||
directory and an init container verifies that the path is backed by bcachefs.
|
||||
If the encrypted filesystem has not been unlocked and mounted, Plex does not
|
||||
start. The Deployment also requires the explicit
|
||||
`media.brunner.ninja/multimedia-ready=true` node label. The shared
|
||||
`../media-storage-offline.sh` helper removes it and stops the media Pods before
|
||||
unmounting; `../media-storage-online.sh` validates the mount before restoring
|
||||
the label.
|
||||
|
||||
Persistent Plex configuration, metadata, preview images, and SQLite databases
|
||||
live on the retained 160 GiB `plex-config` RBD PVC. Transcodes use node-local
|
||||
ephemeral storage. The official Plex image is pinned to the same version as the
|
||||
migrated Arch installation.
|
||||
|
||||
## One-time migration
|
||||
|
||||
`migrate.sh` stops the Arch service, confirms no Plex process remains, checks
|
||||
both source SQLite databases, creates the PVC, and starts a one-shot copy Job:
|
||||
|
||||
```sh
|
||||
./migrate.sh
|
||||
kubectl -n plex logs -f job/plex-archlinux-migration
|
||||
kubectl -n plex wait --for=condition=complete \
|
||||
job/plex-archlinux-migration --timeout=12h
|
||||
```
|
||||
|
||||
The Job refuses to overwrite a non-empty destination and 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 source `/var/lib/plex` tree until the Kubernetes
|
||||
server has been verified and a separate backup exists.
|
||||
|
||||
Deploy with `./install.sh`. Plex remains available at
|
||||
`https://plex.brunner.ninja` and directly on `192.168.0.2:32400`.
|
||||
|
||||
## Monitoring
|
||||
|
||||
The Pod contains a rootless Plex exporter. Its token is read from the migrated
|
||||
`Preferences.xml` into a memory-backed volume; it is not duplicated in a
|
||||
Kubernetes Secret or environment variable. A `ServiceMonitor` and alerts cover
|
||||
the exporter, Plex API access, restart loops, and PVC capacity. Existing
|
||||
Tautulli, node-exporter, Netdata, SMART, and bcachefs monitoring remain in use.
|
||||
|
||||
## Gitea CI/CD bootstrap
|
||||
|
||||
After initializing this directory as its own repository and creating the Gitea
|
||||
repository, add the remote and perform the first push. Then run:
|
||||
|
||||
```sh
|
||||
./create-ci-kubeconfig.sh
|
||||
```
|
||||
|
||||
Store the single output line as `KUBE_CONFIG_BASE64`. The CI identity can only
|
||||
update the already-created Plex 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 images.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: plex-deployer
|
||||
namespace: plex
|
||||
automountServiceAccountToken: false
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: plex-deployer
|
||||
namespace: plex
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps"]
|
||||
resourceNames: ["plex-scripts"]
|
||||
verbs: ["get", "patch", "update"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
resourceNames: ["plex-config"]
|
||||
verbs: ["get", "patch", "update"]
|
||||
- apiGroups: [""]
|
||||
resources: ["services"]
|
||||
resourceNames: ["plex"]
|
||||
verbs: ["get", "patch", "update"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
resourceNames: ["plex"]
|
||||
verbs: ["get", "patch", "update"]
|
||||
- apiGroups: ["traefik.io"]
|
||||
resources: ["ingressroutes"]
|
||||
resourceNames: ["plex-brunner-ninja"]
|
||||
verbs: ["get", "patch", "update"]
|
||||
- apiGroups: ["monitoring.coreos.com"]
|
||||
resources: ["servicemonitors", "prometheusrules"]
|
||||
resourceNames: ["plex"]
|
||||
verbs: ["get", "patch", "update"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: plex-deployer
|
||||
namespace: plex
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: plex-deployer
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: plex-deployer
|
||||
namespace: plex
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: plex-deployer-token
|
||||
namespace: plex
|
||||
annotations:
|
||||
kubernetes.io/service-account.name: plex-deployer
|
||||
type: kubernetes.io/service-account-token
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
namespace=plex
|
||||
service_account=plex-deployer
|
||||
secret=plex-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 plex --cluster=cluster --user="${service_account}" \
|
||||
--namespace="${namespace}" >/dev/null
|
||||
kubectl config use-context plex >/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'
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/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
|
||||
|
||||
if [ "$(findmnt -T /komposthaufen/multimedia -n -o FSTYPE)" != bcachefs ]; then
|
||||
echo '/komposthaufen/multimedia is not backed by bcachefs.' >&2
|
||||
exit 1
|
||||
fi
|
||||
test -d /komposthaufen/multimedia/Videos
|
||||
|
||||
kubectl apply --filename "${project_dir}/namespace.yaml"
|
||||
kubectl --namespace plex wait --for=condition=complete \
|
||||
job/plex-archlinux-migration --timeout=5s
|
||||
kubectl apply --dry-run=server --filename "${project_dir}/plex.yaml"
|
||||
kubectl apply --filename "${project_dir}/plex.yaml"
|
||||
kubectl --namespace plex rollout status deployment/plex --timeout=30m
|
||||
|
||||
pod=$(kubectl --namespace plex get pod \
|
||||
--selector=app.kubernetes.io/name=plex,app.kubernetes.io/component=server \
|
||||
--output=jsonpath='{.items[0].metadata.name}')
|
||||
node=$(kubectl --namespace plex get pod "${pod}" --output=jsonpath='{.spec.nodeName}')
|
||||
image=$(kubectl --namespace plex get deployment plex --output=jsonpath='{.spec.template.spec.containers[?(@.name=="plex")].image}')
|
||||
test "${node}" = arschrock
|
||||
kubectl --namespace plex get pod "${pod}" --output=wide
|
||||
printf 'Plex is ready on %s with image %s.\n' "${node}" "${image}"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/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 plexmediaserver.service
|
||||
if sudo systemctl is-active --quiet plexmediaserver.service; then
|
||||
echo 'plexmediaserver.service is still active; refusing to copy its database.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if pgrep -u plex >/dev/null; then
|
||||
echo 'A process owned by plex is still running; refusing to copy its database.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
database_root='/var/lib/plex/Plex Media Server/Plug-in Support/Databases'
|
||||
for database in \
|
||||
"${database_root}/com.plexapp.plugins.library.db" \
|
||||
"${database_root}/com.plexapp.plugins.library.blobs.db"; do
|
||||
result=$(sudo -u plex '/usr/lib/plexmediaserver/Plex SQLite' "${database}" 'PRAGMA quick_check;')
|
||||
if [[ "${result}" != ok ]]; then
|
||||
echo "SQLite quick_check failed for ${database}: ${result}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
kubectl apply --filename "${project_dir}/namespace.yaml"
|
||||
kubectl apply --filename "${project_dir}/plex.yaml" \
|
||||
--selector=app.kubernetes.io/component=state
|
||||
kubectl --namespace plex wait \
|
||||
--for=jsonpath='{.status.phase}'=Bound \
|
||||
persistentvolumeclaim/plex-config \
|
||||
--timeout=10m
|
||||
|
||||
if kubectl --namespace plex get job plex-archlinux-migration >/dev/null 2>&1; then
|
||||
echo 'Job plex-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 plex logs -f job/plex-archlinux-migration'
|
||||
echo ' kubectl -n plex wait --for=condition=complete job/plex-archlinux-migration --timeout=12h'
|
||||
@@ -0,0 +1,86 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: plex-archlinux-migration
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: migration
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
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@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
marker=/target/.archlinux-migration-complete
|
||||
source='/source/Plex Media Server'
|
||||
destination='/target/Library/Application Support/Plex Media Server'
|
||||
test -d "${source}"
|
||||
if [ -e "${marker}" ]; then
|
||||
echo 'Migration marker already exists; refusing to overwrite the PVC.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if find /target -mindepth 1 -maxdepth 1 ! -name lost+found -print -quit | grep -q .; then
|
||||
echo 'The destination PVC is not empty; refusing to overwrite it.' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p '/target/Library/Application Support'
|
||||
cp -a "${source}" '/target/Library/Application Support/' &
|
||||
copy_pid=$!
|
||||
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 3.8
|
||||
done
|
||||
kill -CONT "${copy_pid}" 2>/dev/null || true
|
||||
wait "${copy_pid}"
|
||||
sync -f /target
|
||||
source_files=$(find "${source}" -type f | wc -l)
|
||||
destination_files=$(find "${destination}" -type f | wc -l)
|
||||
source_bytes=$(find "${source}" -type f -exec stat -c '%s' {} + | awk '{ total += $1 } END { print total + 0 }')
|
||||
destination_bytes=$(find "${destination}" -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 964:964 /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: source
|
||||
mountPath: /source
|
||||
readOnly: true
|
||||
- name: target
|
||||
mountPath: /target
|
||||
volumes:
|
||||
- name: source
|
||||
hostPath:
|
||||
path: /var/lib/plex
|
||||
type: Directory
|
||||
- name: target
|
||||
persistentVolumeClaim:
|
||||
claimName: plex-config
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
@@ -0,0 +1,385 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: plex-config
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: state
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 160Gi
|
||||
storageClassName: rook-ceph-block-ec
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: plex-scripts
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
data:
|
||||
check-media.sh: |
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
filesystem_magic=$(stat -f -c '%t' /komposthaufen/multimedia)
|
||||
if [ "${filesystem_magic}" != ca451a4e ]; then
|
||||
echo "Expected bcachefs at /komposthaufen/multimedia, found filesystem magic ${filesystem_magic}" >&2
|
||||
exit 1
|
||||
fi
|
||||
test -d /komposthaufen/multimedia/Videos
|
||||
extract-token.sh: |
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
preferences='/config/Library/Application Support/Plex Media Server/Preferences.xml'
|
||||
test -s "${preferences}"
|
||||
token=$(sed -n 's/.*PlexOnlineToken="\([^"]*\)".*/\1/p' "${preferences}")
|
||||
if [ -z "${token}" ]; then
|
||||
echo 'PlexOnlineToken is missing from Preferences.xml' >&2
|
||||
exit 1
|
||||
fi
|
||||
umask 077
|
||||
printf '%s' "${token}" > /token/plex-token
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: plex
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: server
|
||||
annotations:
|
||||
deployment.brunner.ninja/revision: manual
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: server
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
hostNetwork: true
|
||||
dnsPolicy: ClusterFirstWithHostNet
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: arschrock
|
||||
media.brunner.ninja/multimedia-ready: "true"
|
||||
tolerations:
|
||||
- key: node-role.kubernetes.io/control-plane
|
||||
operator: Exists
|
||||
effect: NoSchedule
|
||||
terminationGracePeriodSeconds: 120
|
||||
securityContext:
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: media-ready
|
||||
image: docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /tools/check-media.sh
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
volumeMounts:
|
||||
- name: media
|
||||
mountPath: /komposthaufen/multimedia
|
||||
readOnly: true
|
||||
- name: media-ready
|
||||
mountPath: /media-ready
|
||||
readOnly: true
|
||||
- name: tools
|
||||
mountPath: /tools
|
||||
readOnly: true
|
||||
- name: exporter-token
|
||||
image: docker.io/library/alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /tools/extract-token.sh
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 964
|
||||
runAsGroup: 964
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
- name: exporter-token
|
||||
mountPath: /token
|
||||
- name: tools
|
||||
mountPath: /tools
|
||||
readOnly: true
|
||||
containers:
|
||||
- name: plex
|
||||
image: docker.io/plexinc/pms-docker@sha256:f6748983db1054b571b57b4a40f07f53af6c4bfb9edd1fa455f5ebb6e16449bc
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: TZ
|
||||
value: Europe/Vienna
|
||||
- name: PLEX_UID
|
||||
value: "964"
|
||||
- name: PLEX_GID
|
||||
value: "964"
|
||||
- name: CHANGE_CONFIG_DIR_OWNERSHIP
|
||||
value: "false"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 32400
|
||||
protocol: TCP
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /identity
|
||||
port: http
|
||||
failureThreshold: 180
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /identity
|
||||
port: http
|
||||
failureThreshold: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /identity
|
||||
port: http
|
||||
failureThreshold: 3
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 2Gi
|
||||
ephemeral-storage: 1Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
- name: media
|
||||
mountPath: /komposthaufen/multimedia
|
||||
readOnly: true
|
||||
- name: transcode
|
||||
mountPath: /transcode
|
||||
- name: plex-exporter
|
||||
image: ghcr.io/cplieger/plex-exporter@sha256:07ee6d213698bfb0dc086004f26e1048380caaa229f0429e9aafa2eb487ee826
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: PLEX_URL
|
||||
value: http://127.0.0.1:32400
|
||||
- name: PLEX_TOKEN_FILE
|
||||
value: /token/plex-token
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 9594
|
||||
protocol: TCP
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: metrics
|
||||
failureThreshold: 60
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: metrics
|
||||
failureThreshold: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: metrics
|
||||
failureThreshold: 3
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 964
|
||||
runAsGroup: 964
|
||||
volumeMounts:
|
||||
- name: exporter-token
|
||||
mountPath: /token
|
||||
readOnly: true
|
||||
- name: exporter-tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: plex-config
|
||||
- name: media
|
||||
hostPath:
|
||||
path: /komposthaufen/multimedia
|
||||
type: Directory
|
||||
- name: media-ready
|
||||
hostPath:
|
||||
path: /komposthaufen/multimedia/Videos
|
||||
type: Directory
|
||||
- name: transcode
|
||||
emptyDir: {}
|
||||
- name: exporter-token
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
- name: exporter-tmp
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 8Mi
|
||||
- name: tools
|
||||
configMap:
|
||||
name: plex-scripts
|
||||
defaultMode: 0555
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: plex
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: service
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: server
|
||||
ports:
|
||||
- name: http
|
||||
port: 32400
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: metrics
|
||||
port: 9594
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: plex-brunner-ninja
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- kind: Rule
|
||||
match: Host(`plex.brunner.ninja`)
|
||||
services:
|
||||
- kind: Service
|
||||
name: plex
|
||||
port: http
|
||||
passHostHeader: true
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: plex
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: plex
|
||||
app.kubernetes.io/component: service
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
metricRelabelings:
|
||||
- action: labeldrop
|
||||
regex: (pod|endpoint|container)
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: plex
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
spec:
|
||||
groups:
|
||||
- name: plex.availability
|
||||
rules:
|
||||
- alert: PlexMetricsTargetDown
|
||||
expr: |-
|
||||
max(kube_deployment_status_replicas_available{namespace="plex",deployment="plex"}) >= 1
|
||||
and on()
|
||||
(
|
||||
max(up{namespace="plex",service="plex"}) < 1
|
||||
or absent(up{namespace="plex",service="plex"})
|
||||
)
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
component: exporter
|
||||
annotations:
|
||||
summary: Plex metrics are unavailable
|
||||
description: The Plex Pod is available, but Prometheus cannot scrape its exporter.
|
||||
- alert: PlexAPIUnreachable
|
||||
expr: max(plex_http_reachable{namespace="plex",service="plex"}) < 1
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
component: plex
|
||||
annotations:
|
||||
summary: Plex API is unreachable from its exporter
|
||||
description: The exporter has failed to poll Plex for at least ten minutes.
|
||||
- alert: PlexContainerRestarting
|
||||
expr: |-
|
||||
sum by (container) (
|
||||
increase(kube_pod_container_status_restarts_total{namespace="plex",pod=~"plex-.*"}[15m])
|
||||
) > 2
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
component: kubernetes
|
||||
annotations:
|
||||
summary: Plex container is repeatedly restarting
|
||||
description: Container {{ $labels.container }} restarted more than twice in 15 minutes.
|
||||
- alert: PlexConfigVolumeFilling
|
||||
expr: |-
|
||||
kubelet_volume_stats_available_bytes{namespace="plex",persistentvolumeclaim="plex-config"}
|
||||
/
|
||||
kubelet_volume_stats_capacity_bytes{namespace="plex",persistentvolumeclaim="plex-config"} < 0.15
|
||||
for: 30m
|
||||
labels:
|
||||
severity: warning
|
||||
component: storage
|
||||
annotations:
|
||||
summary: Plex configuration volume is filling
|
||||
description: Less than 15 percent of the Plex configuration PVC remains available.
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
|
||||
sh -n "${project_dir}/install.sh"
|
||||
bash -n "${project_dir}/migrate.sh"
|
||||
bash -n "${project_dir}/create-ci-kubeconfig.sh"
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
for manifest in namespace.yaml plex.yaml migration-job.yaml; do
|
||||
docker run --rm --interactive \
|
||||
ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c \
|
||||
-strict -ignore-missing-schemas -summary < "${project_dir}/${manifest}"
|
||||
done
|
||||
docker run --rm --interactive \
|
||||
ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c \
|
||||
-strict -summary < "${project_dir}/ci-deployer.yaml"
|
||||
fi
|
||||
Reference in New Issue
Block a user