Publish Plex chart and replace direct Kubernetes CD
Validate, publish and promote Plex chart / publish (push) Successful in 2s
Validate, publish and promote Plex chart / validate (push) Successful in 1s

This commit is contained in:
2026-10-23 09:48:55 +02:00
parent 4d2bff4fac
commit 1d84fb0bc4
9 changed files with 179 additions and 147 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Validate, publish and promote Plex chart
on:
push:
pull_request:
jobs:
validate:
runs-on: linux_amd64
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Lint and render
run: ./scripts/test-chart.sh
publish:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
needs: validate
runs-on: linux_amd64
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive chart version
run: echo "CHART_VERSION=$(./scripts/chart-version.sh)" >> "$GITHUB_ENV"
- name: Publish public chart
env:
PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME" && test -n "$PACKAGE_TOKEN"
helm package chart --destination "$RUNNER_TEMP" --version "$CHART_VERSION"
curl --fail --silent --show-error --user "$PACKAGE_USERNAME:$PACKAGE_TOKEN" \
--request POST --upload-file "$RUNNER_TEMP/plex-$CHART_VERSION.tgz" \
https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts
- name: Confirm current main
env:
COMMIT_SHA: ${{ gitea.sha }}
run: |
git fetch --no-tags origin main
test "$(git rev-parse FETCH_HEAD)" = "$COMMIT_SHA"
- uses: actions/checkout@v4
with:
repository: feedc0de/homelab-gitops
ref: main
token: ${{ secrets.GITOPS_TOKEN }}
path: homelab-gitops
- name: Promote exact chart
working-directory: homelab-gitops
run: |
set -euo pipefail
./scripts/update-chart-version.sh plex "$CHART_VERSION"
git config user.name feedc0de
git config user.email feedc0de@users.noreply.code.brunner.ninja
git add bootstrap/plex.yaml
if git diff --cached --quiet; then exit 0; fi
git commit -m "Deploy plex chart $CHART_VERSION"
for attempt in 1 2 3; do
git push origin HEAD:main && exit 0
test "$attempt" = 3 && exit 1
git fetch origin main
git rebase origin/main
done
-72
View File
@@ -1,72 +0,0 @@
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"
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..720}; 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}/720: 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
+18 -10
View File
@@ -38,7 +38,8 @@ 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
The historical migration scripts remain for recovery; routine deployment is
owned by Argo CD. Plex remains available at
`https://plex.brunner.ninja` and directly on `192.168.0.2:32400`.
## Monitoring
@@ -49,17 +50,24 @@ 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
## Helm and GitOps
After initializing this directory as its own repository and creating the Gitea
repository, add the remote and perform the first push. Then run:
This repository owns the Plex chart: Deployment, Service, media guard,
exporter, ingress option, and monitoring. The chart preserves the existing
Deployment selector used by `media-storage-offline.sh` and
`media-storage-online.sh`. The homelab's node pin, guarded hostPath, existing
PVC, public host and resource requests are selected in the
[GitOps values](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/plex).
```sh
./create-ci-kubeconfig.sh
helm repo add brunner.ninja https://brunner.ninja/charts
helm repo update brunner.ninja
helm upgrade --install plex brunner.ninja/plex --namespace plex --create-namespace
```
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.
To install this checkout manually, run `./install.sh -f my-values.yaml` with
the target namespace and media mount configured. Main-branch CI validates,
publishes the chart using the upstream application Git tag plus `git describe`,
then promotes its exact version to GitOps. CI needs `PACKAGE_USERNAME`,
`PACKAGE_TOKEN`, and `GITOPS_TOKEN`, but no kubeconfig. The Plex image is the
official upstream image and is selected by `Chart.yaml.appVersion`.
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: plex
description: Plex Media Server with guarded host-local media and optional monitoring
type: application
version: 0.0.0
appVersion: "1.43.4.10903-e5521bd8c"
+43 -41
View File
@@ -1,8 +1,9 @@
{{- if not .Values.persistence.existingClaim }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: plex-config
namespace: plex
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: plex
app.kubernetes.io/component: state
@@ -11,26 +12,29 @@ spec:
- ReadWriteOnce
resources:
requests:
storage: 160Gi
storageClassName: rook-ceph-block-ec
storage: {{ .Values.persistence.size }}
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . }}
{{- end }}
{{- end }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: plex-scripts
namespace: plex
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: plex
data:
check-media.sh: |
#!/bin/sh
set -eu
filesystem_magic=$(stat -f -c '%t' /komposthaufen/multimedia)
filesystem_magic=$(stat -f -c '%t' {{ .Values.media.mountPath }})
if [ "${filesystem_magic}" != ca451a4e ]; then
echo "Expected bcachefs at /komposthaufen/multimedia, found filesystem magic ${filesystem_magic}" >&2
echo "Expected bcachefs at {{ .Values.media.mountPath }}, found filesystem magic ${filesystem_magic}" >&2
exit 1
fi
test -d /komposthaufen/multimedia/Videos
test -d {{ .Values.media.mountPath }}/Videos
extract-token.sh: |
#!/bin/sh
set -eu
@@ -48,7 +52,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: plex
namespace: plex
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: plex
app.kubernetes.io/component: server
@@ -73,12 +77,9 @@ spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: arschrock
media.brunner.ninja/multimedia-ready: "true"
{{- toYaml .Values.nodeSelector | nindent 8 }}
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
{{- toYaml .Values.tolerations | nindent 8 }}
terminationGracePeriodSeconds: 120
securityContext:
seccompProfile:
@@ -100,7 +101,7 @@ spec:
runAsGroup: 65534
volumeMounts:
- name: media
mountPath: /komposthaufen/multimedia
mountPath: {{ .Values.media.mountPath }}
readOnly: true
- name: media-ready
mountPath: /media-ready
@@ -133,15 +134,15 @@ spec:
readOnly: true
containers:
- name: plex
image: docker.io/plexinc/pms-docker:1.43.4.10903-e5521bd8c
image: {{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: Europe/Vienna
value: {{ .Values.timezone | quote }}
- name: PLEX_UID
value: "964"
value: {{ .Values.plexUid | quote }}
- name: PLEX_GID
value: "964"
value: {{ .Values.plexGid | quote }}
- name: CHANGE_CONFIG_DIR_OWNERSHIP
value: "false"
ports:
@@ -170,17 +171,14 @@ spec:
periodSeconds: 30
timeoutSeconds: 5
resources:
requests:
cpu: 500m
memory: 2Gi
ephemeral-storage: 1Gi
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
allowPrivilegeEscalation: false
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /komposthaufen/multimedia
mountPath: {{ .Values.media.mountPath }}
readOnly: true
- name: transcode
mountPath: /transcode
@@ -239,14 +237,14 @@ spec:
volumes:
- name: config
persistentVolumeClaim:
claimName: plex-config
claimName: {{ default "plex-config" .Values.persistence.existingClaim }}
- name: media
hostPath:
path: /komposthaufen/multimedia
path: {{ .Values.media.hostPath }}
type: Directory
- name: media-ready
hostPath:
path: /komposthaufen/multimedia/Videos
path: {{ .Values.media.hostPath }}/Videos
type: Directory
- name: transcode
emptyDir: {}
@@ -267,7 +265,7 @@ apiVersion: v1
kind: Service
metadata:
name: plex
namespace: plex
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: plex
app.kubernetes.io/component: service
@@ -284,31 +282,34 @@ spec:
port: 9594
targetPort: metrics
protocol: TCP
{{- if .Values.ingress.enabled }}
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: plex-brunner-ninja
namespace: plex
name: {{ default "plex" .Values.ingress.name }}
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: plex
spec:
entryPoints:
- websecure
- {{ .Values.ingress.entryPoint }}
routes:
- kind: Rule
match: Host(`plex.brunner.ninja`)
match: Host(`{{ required "ingress.host is required" .Values.ingress.host }}`)
services:
- kind: Service
name: plex
port: http
passHostHeader: true
{{- end }}
{{- if .Values.monitoring.enabled }}
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: plex
namespace: plex
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: plex
spec:
@@ -329,7 +330,7 @@ apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: plex
namespace: plex
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: plex
spec:
@@ -338,11 +339,11 @@ spec:
rules:
- alert: PlexMetricsTargetDown
expr: |-
max(kube_deployment_status_replicas_available{namespace="plex",deployment="plex"}) >= 1
max(kube_deployment_status_replicas_available{namespace="{{ .Release.Namespace }}",deployment="plex"}) >= 1
and on()
(
max(up{namespace="plex",service="plex"}) < 1
or absent(up{namespace="plex",service="plex"})
max(up{namespace="{{ .Release.Namespace }}",service="plex"}) < 1
or absent(up{namespace="{{ .Release.Namespace }}",service="plex"})
)
for: 10m
labels:
@@ -352,7 +353,7 @@ spec:
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
expr: max(plex_http_reachable{namespace="{{ .Release.Namespace }}",service="plex"}) < 1
for: 10m
labels:
severity: warning
@@ -363,7 +364,7 @@ spec:
- alert: PlexContainerRestarting
expr: |-
sum by (container) (
increase(kube_pod_container_status_restarts_total{namespace="plex",pod=~"plex-.*"}[15m])
increase(kube_pod_container_status_restarts_total{namespace="{{ .Release.Namespace }}",pod=~"plex-.*"}[15m])
) > 2
for: 5m
labels:
@@ -371,12 +372,12 @@ spec:
component: kubernetes
annotations:
summary: Plex container is repeatedly restarting
description: Container {{ $labels.container }} restarted more than twice in 15 minutes.
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_available_bytes{namespace="{{ .Release.Namespace }}",persistentvolumeclaim="{{ default "plex-config" .Values.persistence.existingClaim }}"}
/
kubelet_volume_stats_capacity_bytes{namespace="plex",persistentvolumeclaim="plex-config"} < 0.15
kubelet_volume_stats_capacity_bytes{namespace="{{ .Release.Namespace }}",persistentvolumeclaim="{{ default "plex-config" .Values.persistence.existingClaim }}"} < 0.15
for: 30m
labels:
severity: warning
@@ -384,3 +385,4 @@ spec:
annotations:
summary: Plex configuration volume is filling
description: Less than 15 percent of the Plex configuration PVC remains available.
{{- end }}
+23
View File
@@ -0,0 +1,23 @@
image:
repository: docker.io/plexinc/pms-docker
tag: ""
media:
hostPath: /media
mountPath: /media
nodeSelector: {}
tolerations: []
persistence:
existingClaim: ""
storageClass: ""
size: 160Gi
timezone: UTC
plexUid: "964"
plexGid: "964"
resources: {}
ingress:
enabled: false
name: ""
host: ""
entryPoint: websecure
monitoring:
enabled: false
+8 -24
View File
@@ -1,26 +1,10 @@
#!/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
kubectl apply --filename "${project_dir}/namespace.yaml"
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=60m
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}"
release=${RELEASE_NAME:-plex}
namespace=${NAMESPACE:-plex}
version=$(./scripts/chart-version.sh)
package_dir=$(mktemp -d)
trap 'rm -rf -- "$package_dir"' EXIT
helm package ./chart --version "$version" --destination "$package_dir" >/dev/null
helm upgrade --install "$release" "$package_dir/plex-$version.tgz" \
--namespace "$namespace" --create-namespace --wait --timeout 60m "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
description=$(git describe --tags --long --match 'v[0-9]*' --dirty)
case "$description" in *-dirty) echo 'Refusing dirty tree' >&2; exit 1 ;; esac
version=${description#v}
version=$(printf '%s\n' "$version" | sed -E 's/\.([0-9]+-[[:alnum:]]+)(-[0-9]+-g[0-9a-f]+)?$/-\1\2/')
case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac
printf '%s\n' "$version"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
helm lint chart
rendered=$(helm template plex chart --namespace plex --set persistence.existingClaim=retained-data --set media.hostPath=/example/media --set media.mountPath=/media --set ingress.enabled=true --set ingress.host=plex.example.org --set monitoring.enabled=true)
printf '%s\n' "$rendered" | grep -q 'claimName: retained-data'
printf '%s\n' "$rendered" | grep -q 'type: Recreate'
printf '%s\n' "$rendered" | grep -q 'path: /example/media'
printf '%s\n' "$rendered" | grep -q 'ca451a4e'
if printf '%s\n' "$rendered" | grep -q '^kind: PersistentVolumeClaim$'; then exit 1; fi
helm template plex chart --namespace plex | grep -q '^kind: PersistentVolumeClaim$'
sh -n install.sh scripts/chart-version.sh scripts/test-chart.sh