From 35ae5557b95597015cb3e17028755badddc4b3c2 Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Fri, 9 Oct 2026 09:09:31 +0200 Subject: [PATCH] Initial commit with the existing deployment files --- .gitea/workflows/deploy.yml | 76 +++++++ README.md | 66 ++++++- ci-deployer.yaml | 60 ++++++ create-ci-kubeconfig.sh | 48 +++++ install.sh | 34 ++++ migrate.sh | 50 +++++ migration-job.yaml | 86 ++++++++ namespace.yaml | 6 + plex.yaml | 385 ++++++++++++++++++++++++++++++++++++ test.sh | 20 ++ 10 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 ci-deployer.yaml create mode 100755 create-ci-kubeconfig.sh create mode 100755 install.sh create mode 100755 migrate.sh create mode 100644 migration-job.yaml create mode 100644 namespace.yaml create mode 100644 plex.yaml create mode 100755 test.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..351ce8e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/README.md b/README.md index bd30cf0..922619c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,65 @@ -# plex-deployment +# Plex on Kubernetes -My kubernetes configuration files to run plex in my kubernetes cluster \ No newline at end of file +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. diff --git a/ci-deployer.yaml b/ci-deployer.yaml new file mode 100644 index 0000000..ea57e18 --- /dev/null +++ b/ci-deployer.yaml @@ -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 diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh new file mode 100755 index 0000000..866330d --- /dev/null +++ b/create-ci-kubeconfig.sh @@ -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' diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..18c239b --- /dev/null +++ b/install.sh @@ -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}" diff --git a/migrate.sh b/migrate.sh new file mode 100755 index 0000000..349e48a --- /dev/null +++ b/migrate.sh @@ -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' diff --git a/migration-job.yaml b/migration-job.yaml new file mode 100644 index 0000000..fa9010d --- /dev/null +++ b/migration-job.yaml @@ -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 diff --git a/namespace.yaml b/namespace.yaml new file mode 100644 index 0000000..6db70ee --- /dev/null +++ b/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: plex + labels: + app.kubernetes.io/name: plex diff --git a/plex.yaml b/plex.yaml new file mode 100644 index 0000000..548a444 --- /dev/null +++ b/plex.yaml @@ -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. diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..a761fcc --- /dev/null +++ b/test.sh @@ -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