From cc39c5e9c2cc144246aee6044c99fd5e3897309e Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Mon, 19 Oct 2026 21:44:23 +0200 Subject: [PATCH] Add versioned Mumble chart with optional PostgreSQL --- .gitea/workflows/chart.yml | 71 ++++++ .gitignore | 1 + README.md | 182 +--------------- bootstrap-homelab-secrets.sh | 74 +++++++ chart/mumble/Chart.lock | 6 + chart/mumble/Chart.yaml | 11 + chart/mumble/templates/workload.yaml | 312 +++++++++++++++++++++++++++ chart/mumble/values.schema.json | 12 ++ chart/mumble/values.yaml | 37 ++++ install.sh | 64 +----- mumble.yaml | 6 +- scripts/chart-version.sh | 7 + test.sh | 49 +++-- values.example.yaml | 10 + 14 files changed, 587 insertions(+), 255 deletions(-) create mode 100644 .gitea/workflows/chart.yml create mode 100755 bootstrap-homelab-secrets.sh create mode 100644 chart/mumble/Chart.lock create mode 100644 chart/mumble/Chart.yaml create mode 100644 chart/mumble/templates/workload.yaml create mode 100644 chart/mumble/values.schema.json create mode 100644 chart/mumble/values.yaml create mode 100755 scripts/chart-version.sh create mode 100644 values.example.yaml diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..0126541 --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,71 @@ +name: Validate, publish and promote Mumble chart +on: + push: + pull_request: +jobs: + chart: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: azure/setup-helm@v4 + with: + version: v4.3.0 + - name: Validate chart + run: | + python3 -m venv "${RUNNER_TEMP}/chart-test" + "${RUNNER_TEMP}/chart-test/bin/pip" install PyYAML + PATH="${RUNNER_TEMP}/chart-test/bin:${PATH}" ./test.sh + - name: Select immutable chart version + if: gitea.ref == 'refs/heads/main' + run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}" + - name: Package and publish chart + if: gitea.ref == 'refs/heads/main' + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" && test -n "${PACKAGE_TOKEN}" + mkdir -p .chart-packages + helm package chart/mumble --destination .chart-packages --version "${CHART_VERSION}" + curl --fail --silent --show-error --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST --upload-file ".chart-packages/mumble-${CHART_VERSION}.tgz" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Check current main + id: current-main + if: gitea.ref == 'refs/heads/main' + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if test "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}"; then + echo 'promote=true' >> "${GITEA_OUTPUT}" + else + echo 'promote=false' >> "${GITEA_OUTPUT}" + fi + - uses: actions/checkout@v4 + if: steps.current-main.outputs.promote == 'true' + with: + repository: feedc0de/homelab-gitops + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Promote chart + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh mumble "${CHART_VERSION}" + git config user.name feedc0de + git config user.email feedc0de@users.noreply.code.brunner.ninja + git add bootstrap/mumble.yaml + if git diff --cached --quiet; then exit 0; fi + git commit -m "Deploy Mumble 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 diff --git a/.gitignore b/.gitignore index b41f070..943dff5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ mumble-server.ini kubeconfig *.kubeconfig .env +chart/mumble/charts/ diff --git a/README.md b/README.md index 13ca8c9..adb0ef2 100644 --- a/README.md +++ b/README.md @@ -1,181 +1,19 @@ -# Mumble on Kubernetes +# Mumble Helm chart -Production deployment for the brunner.ninja Mumble server. It runs the -official Mumble server image with trusted TLS, a MetalLB address, and the -shared PostgreSQL instance in the `default` namespace. - -## Architecture - -- `mumblevoip/mumble-server:v1.5.915-1` runs as an unprivileged user. -- MetalLB advertises `192.168.0.14`; TCP and UDP use port `64738` on the same - LoadBalancer Service. -- `brunner-ninja-tls`, maintained by cert-manager, supplies the certificate for - both `brunner.ninja` and `*.brunner.ninja`. -- A small sidecar watches the mounted certificate and asks Mumble to reload TLS - with `SIGUSR1` after renewal. -- Mumble stores channels, ACLs, registered users, bans, and server state in the - `mumble` database on `postgresql.default.svc.cluster.local`. -- `/data` and the generated INI are ephemeral. No Mumble PVC is needed. -- The single replica uses a `Recreate` strategy because two processes must not - serve the same Mumble virtual server database concurrently. - -The runtime INI is rendered into a mode-`0600` in-memory volume. This avoids an -upstream container-entrypoint behavior that can print `registerPassword` when -configuration is supplied through environment variables. - -## Repository contents - -- `mumble.yaml`: LoadBalancer Service and Mumble Deployment. -- `install.sh`: administrator installation, credential generation, idempotent - PostgreSQL bootstrap, and rollout verification. -- `test.sh`: local script and offline Kubernetes schema validation. -- `ci-deployer.yaml`: restricted Gitea Actions ServiceAccount, Role, binding, - and revocable token request. -- `create-ci-kubeconfig.sh`: one-time CI identity bootstrap. -- `.gitea/workflows/deploy.yml`: validation on every push and pull request; - deployment after successful validation of `main`. - -The old host-level `mumble-server.ini` is intentionally ignored because it -contains obsolete plaintext credentials. Do not commit kubeconfigs, database -passwords, TLS keys, or generated Kubernetes Secret values. - -## Prerequisites - -- Kubernetes and kubectl. -- MetalLB pool `default` containing the reserved address `192.168.0.14`. -- `default/postgresql` Service and Secret. The Secret must contain - `postgres-password`. -- `default/brunner-ninja-tls`, maintained by the existing cert-manager - `letsencrypt-dns` setup. -- Router forwarding of TCP and UDP `64738` to `192.168.0.14`. On the MikroTik, - the two rules use `dst-address-list=WANs`, which also permits LAN hairpin - connections through `brunner.ninja`. - -## Install or restore manually - -Run the first installation with an administrator kubeconfig: +This repository maintains a portable Helm chart for the official `mumblevoip/mumble-server` image. It declares Mumble's Deployment, TCP/UDP Service, private runtime config rendering, TLS reload sidecar, probes, and optional Bitnami PostgreSQL dependency. It publishes to the public chart repository: ```sh -./install.sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +helm show values brunner.ninja/mumble ``` -On the first run, the script creates `default/mumble-secrets` with independent -256-bit random database, SuperUser, and public-registry passwords. It then -creates the PostgreSQL role and database if absent. Later runs preserve the -Secret and database, reset the role to the stored password, reapply ownership -and grants, and safely recreate or update the workload. +For a separate cluster, create a TLS Secret, a `mumble-secrets` Secret containing `database-password`, `superuser-password`, and `registration-password`, and a PostgreSQL credential Secret if enabling the subchart. Put Secret *names*, database host, domain/registration details, and networking policy in a values file. See `values.example.yaml`. `./install.sh VALUES_FILE` performs a local Helm upgrade/install with `--wait`; `./test.sh` validates the chart. Do not run `install.sh` against the brunner.ninja cluster after Argo CD adoption. -Rotate only the public Mumble registry password with: +The PostgreSQL subchart is optional. With `postgresql.enabled=true`, set `postgresql.auth.existingSecret` to a stable Secret with separate PostgreSQL administrator and Mumble database passwords; configure `postgresql.auth.secretKeys` to identify their keys. The same Mumble database password must be available to the application via `mumble-secrets/database-password`. This chart does not render plaintext credentials. Set `postgresql.primary.persistence.*` for durable storage and a retention policy. With the subchart disabled, Mumble's init container bootstraps its role and database on an existing PostgreSQL server using the separate administrator Secret. -```sh -ROTATE_REGISTRATION_PASSWORD=true ./install.sh -``` +The homelab deployment is selected in [homelab-gitops](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/mumble). It uses the optional PostgreSQL subchart and keeps the existing `default/mumble` Service and Deployment names, the `mumble-secrets` and TLS Secrets, and TCP/UDP port 64738. The old shared PostgreSQL database is deliberately left intact as rollback data. `bootstrap-homelab-secrets.sh` is a guarded, one-time secret recovery/bootstrap helper; it does not deploy workloads. Back up the dedicated PostgreSQL PVC and both Mumble Secrets together. -Retrieve the Mumble administrator password without placing it in Git: +Chart package versions come from Git tags using `git describe --tags --long`. The tag `v1.5.939` denotes the tested upstream Mumble release; a later chart-only commit publishes a distinct `1.5.939-N-gHASH` chart version. Source CI validates the chart, publishes it to the indexed `brunner.ninja` repository, and promotes only this chart's selected version in GitOps. CI has no Kubernetes credential. -```sh -kubectl get secret mumble-secrets -n default \ - -o jsonpath='{.data.superuser-password}' | base64 -d; echo -``` - -Connect to `brunner.ninja:64738`. - -### Restoring a PostgreSQL backup - -Stop Mumble before replacing its database: - -```sh -kubectl delete deployment mumble -n default -``` - -Restore the `mumble` database and ensure its objects are owned by role -`mumble`, then run `./install.sh`. The bootstrap is idempotent: it does not drop -an existing database or tables. Mumble may migrate an older compatible schema -when it starts. Its channels and registrations remain in PostgreSQL. - -## Validate locally - -```sh -./test.sh -``` - -The test checks both shell scripts and validates the Kubernetes resources with -kubeconform without contacting the cluster. It uses a locally installed -kubeconform binary when available, otherwise the official -`ghcr.io/yannh/kubeconform:v0.7.0` container through Docker. - -## Automatic deployment with Gitea Actions - -The workflow validates every push and pull request. A successful push to -`main` applies `mumble.yaml` and polls the Deployment for up to five minutes. -It does not build a container because this repository deliberately uses the -official, version-pinned Mumble image. - -CI does not invoke `install.sh` and cannot create application credentials or -bootstrap PostgreSQL. Its Role can call `get`, `patch`, and `update` only for -the existing `default/mumble` Service and Deployment. It cannot create or -delete workloads and has no direct Kubernetes Secrets API permission. - -### One-time CI setup - -1. Install Mumble manually with `./install.sh` so the Service, Deployment, - application Secret, role, and database exist. -2. With an administrator kubeconfig active, run: - - ```sh - ./create-ci-kubeconfig.sh - ``` - -3. Copy the single base64 output line into the Gitea repository Actions secret - named `KUBE_CONFIG_BASE64`. -4. Protect `main` and require the validation job before merging. -5. Push a harmless README change and confirm the deploy job reports a healthy - rollout. - -The runner networking in the workflow matches the existing brunner.ninja -runner: API URL `https://host.containers.internal:6443`, with TLS server name -`192.168.0.2`. - -To revoke CI access, delete the token and remove the Gitea secret: - -```sh -kubectl delete secret mumble-deployer-token -n default -``` - -Running `./create-ci-kubeconfig.sh` again recreates the token and prints a new -kubeconfig. - -### Security boundary - -The Role prevents ordinary API reads of Secrets and limits updates to two named -objects. Kubernetes RBAC cannot restrict individual fields inside a Deployment -patch, however. A malicious change merged into `main` could modify the Mumble -pod specification and potentially expose Secrets already referenced by that -workload. Branch protection and review of manifest and workflow changes remain -part of the security boundary. Admission policy would be required to constrain -specific pod fields further. - -## Gitea CI or Argo CD? - -Gitea CI is the recommended deployment mechanism for this repository today. -It is already proven on this cluster, the deployment is only two resources, -and it does not automatically undo deliberate maintenance such as deleting the -Deployment while restoring PostgreSQL. - -| Capability | Gitea Actions used here | Argo CD | -| --- | --- | --- | -| Trigger | Push to `main` | Continuous Git reconciliation/webhook | -| Drift detection | No | Built-in diff and health view | -| Self-healing | No; rerun CI or `install.sh` | Optional automatic reconciliation | -| Deleted Deployment | Stays deleted for maintenance | Recreated when self-heal is enabled | -| Cluster credential | Restricted token per repository | Repository credential plus central Argo controller | -| Rollout view | Action log | Application resource tree, health, sync history, and UI | -| Pruning | Not performed | Optional, with confirmation controls available | -| Secret handling | Existing Secrets remain out of Git | Still needs an external Secret strategy | - -Argo CD becomes attractive when several more workloads should share one -consistent deployment model, when live drift must be visible or automatically -repaired, or when multiple clusters/environments are introduced. It can remove -per-repository Kubernetes deploy tokens because CI only needs to update Git. -Adoption should start with an `AppProject`, explicit namespace/resource limits, -and a maintenance procedure that disables sync before database restores. +The old `mumble-server.ini` file is ignored because it contains obsolete plaintext credentials. Never commit it, kubeconfigs, generated Secret values, or database dumps. diff --git a/bootstrap-homelab-secrets.sh b/bootstrap-homelab-secrets.sh new file mode 100755 index 0000000..eae97f0 --- /dev/null +++ b/bootstrap-homelab-secrets.sh @@ -0,0 +1,74 @@ +#!/bin/sh + +set -eu +umask 077 + +NAMESPACE=default +SECRET_NAME=mumble-secrets + +for command in kubectl openssl; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command not found: $command" >&2 + exit 1 + fi +done + +if [ "$(kubectl config current-context)" != kubernetes-admin@kubernetes ]; then + echo 'Refusing to deploy outside the homelab context' >&2 + exit 1 +fi + +for dependency in secret/postgresql secret/brunner-ninja-tls; do + if ! kubectl get "$dependency" --namespace "$NAMESPACE" >/dev/null 2>&1; then + echo "Required Kubernetes object not found: $NAMESPACE/$dependency" >&2 + exit 1 + fi +done + +if ! kubectl get secret "$SECRET_NAME" --namespace "$NAMESPACE" >/dev/null 2>&1; then + database_password=$(openssl rand -hex 32) + superuser_password=$(openssl rand -hex 32) + registration_password=$(openssl rand -hex 32) + + kubectl create secret generic "$SECRET_NAME" \ + --namespace "$NAMESPACE" \ + --from-literal="database-password=$database_password" \ + --from-literal="superuser-password=$superuser_password" \ + --from-literal="registration-password=$registration_password" + + unset database_password superuser_password registration_password + echo "Created $NAMESPACE/$SECRET_NAME with random database, SuperUser, and public-registry passwords." +else + echo "Keeping existing $NAMESPACE/$SECRET_NAME." + + registration_password_present=$(kubectl get secret "$SECRET_NAME" \ + --namespace "$NAMESPACE" \ + -o go-template='{{if index .data "registration-password"}}yes{{end}}') + + if [ -z "$registration_password_present" ] || [ "${ROTATE_REGISTRATION_PASSWORD:-false}" = true ]; then + registration_password=$(openssl rand -hex 32) + kubectl patch secret "$SECRET_NAME" \ + --namespace "$NAMESPACE" \ + --type=merge \ + --patch "{\"stringData\":{\"registration-password\":\"$registration_password\"}}" >/dev/null + unset registration_password + echo "Set a new random public-registry password in $NAMESPACE/$SECRET_NAME." + fi + + unset registration_password_present +fi + +if ! kubectl get secret mumble-postgres --namespace "$NAMESPACE" >/dev/null 2>&1; then + admin_password=$(openssl rand -hex 32) + database_password=$(kubectl get secret "$SECRET_NAME" --namespace "$NAMESPACE" \ + -o go-template='{{index .data "database-password"}}' | base64 -d) + kubectl create secret generic mumble-postgres --namespace "$NAMESPACE" \ + --from-literal="postgres-password=$admin_password" \ + --from-literal="database-password=$database_password" + unset admin_password database_password + echo "Created $NAMESPACE/mumble-postgres for the dedicated PostgreSQL chart." +else + echo "Keeping existing $NAMESPACE/mumble-postgres." +fi + +echo "Secrets are ready for the Argo CD-managed Mumble release." diff --git a/chart/mumble/Chart.lock b/chart/mumble/Chart.lock new file mode 100644 index 0000000..dc2878d --- /dev/null +++ b/chart/mumble/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 18.12.4 +digest: sha256:517a59bc973bf05118c845d87617688f9cec2080e4112ec494eb4b104c00cb52 +generated: "2026-10-19T21:39:30.696228885+02:00" diff --git a/chart/mumble/Chart.yaml b/chart/mumble/Chart.yaml new file mode 100644 index 0000000..047f43c --- /dev/null +++ b/chart/mumble/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: mumble +description: Mumble server with external PostgreSQL and TLS certificate reload +type: application +version: 0.0.0 +appVersion: v1.5.939 +dependencies: + - name: postgresql + version: 18.12.4 + repository: https://charts.bitnami.com/bitnami + condition: postgresql.enabled diff --git a/chart/mumble/templates/workload.yaml b/chart/mumble/templates/workload.yaml new file mode 100644 index 0000000..526bd3b --- /dev/null +++ b/chart/mumble/templates/workload.yaml @@ -0,0 +1,312 @@ +{{- if .Values.mumble.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: mumble + labels: + app.kubernetes.io/name: mumble +{{- with .Values.service.annotations }} + annotations: +{{ toYaml . | indent 4 }} +{{- end }} +spec: + type: {{ .Values.service.type }} +{{- if eq .Values.service.type "LoadBalancer" }} + loadBalancerIP: {{ .Values.service.loadBalancerIP | quote }} +{{- end }} +{{- if eq .Values.service.type "LoadBalancer" }} + externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }} +{{- end }} + selector: + app.kubernetes.io/name: mumble + ports: + - name: mumble-tcp + port: 64738 + targetPort: mumble-tcp + protocol: TCP + - name: mumble-udp + port: 64738 + targetPort: mumble-udp + protocol: UDP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mumble + labels: + app.kubernetes.io/name: mumble +spec: + replicas: 1 + # Two Mumble processes must not serve the same virtual server/database. + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: mumble + template: + metadata: + labels: + app.kubernetes.io/name: mumble + spec: + # Allows the certificate watcher to send SIGUSR1 to mumble-server. + shareProcessNamespace: true + automountServiceAccountToken: false + terminationGracePeriodSeconds: 30 + securityContext: + fsGroup: 10000 + fsGroupChangePolicy: OnRootMismatch + initContainers: + - name: render-config + image: {{ .Values.images.busybox | quote }} + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + umask 077 + cat > /config/mumble-server.ini <- + awk '$2 ~ /:FCE2$/ && $4 == "0A" { found=1 } + END { exit(found ? 0 : 1) }' /proc/net/tcp /proc/net/tcp6 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + exec: + command: + - /bin/bash + - -ec + - >- + awk '$2 ~ /:FCE2$/ && $4 == "0A" { found=1 } + END { exit(found ? 0 : 1) }' /proc/net/tcp /proc/net/tcp6 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + resources: +{{ toYaml .Values.resources | indent 12 }} + - name: certificate-reloader + image: {{ .Values.images.busybox | quote }} + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -ec + - | + previous="$(sha256sum /certs/tls.crt /certs/tls.key)" + while sleep 60; do + current="$(sha256sum /certs/tls.crt /certs/tls.key)" + if [ "$current" != "$previous" ]; then + set -- $(pidof mumble-server) + if [ "$#" -gt 0 ]; then + kill -USR1 "$@" + echo "Reloaded Mumble TLS certificate" + fi + previous="$current" + fi + done + volumeMounts: + - name: certificate + mountPath: /certs + readOnly: true + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 1m + memory: 2Mi + limits: + cpu: 20m + memory: 16Mi + volumes: + # PostgreSQL stores Mumble's durable state; /data only holds generated config. + - name: data + emptyDir: {} + - name: config + emptyDir: {} + - name: certificate + secret: + secretName: {{ .Values.secrets.tls | quote }} + defaultMode: 0440 +{{- end }} diff --git a/chart/mumble/values.schema.json b/chart/mumble/values.schema.json new file mode 100644 index 0000000..de525ef --- /dev/null +++ b/chart/mumble/values.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "image": {"type": "object", "properties": {"repository": {"type": "string", "minLength": 1}, "tag": {"type": "string"}}}, + "service": {"type": "object", "properties": {"type": {"enum": ["LoadBalancer", "ClusterIP"]}, "loadBalancerIP": {"type": "string"}, "externalTrafficPolicy": {"enum": ["Local", "Cluster"]}, "annotations": {"type": "object", "additionalProperties": {"type": "string"}}}}, + "database": {"type": "object", "properties": {"host": {"type": "string", "minLength": 1}}}, + "secrets": {"type": "object", "properties": {"mumble": {"type": "string", "minLength": 1}, "postgresql": {"type": "string", "minLength": 1}, "tls": {"type": "string", "minLength": 1}}}, + "config": {"type": "object", "properties": {"welcomeText": {"type": "string"}, "registrationName": {"type": "string"}, "registrationURL": {"type": "string"}, "registrationHostname": {"type": "string"}, "registrationLocation": {"type": "string"}}}, + "resources": {"type": "object"} + } +} diff --git a/chart/mumble/values.yaml b/chart/mumble/values.yaml new file mode 100644 index 0000000..556fec8 --- /dev/null +++ b/chart/mumble/values.yaml @@ -0,0 +1,37 @@ +image: + repository: mumblevoip/mumble-server + tag: "" +mumble: + enabled: true +images: + busybox: docker.io/library/busybox:1.38.0 + postgresqlClient: bitnamilegacy/postgresql:17.6.0-debian-12-r4 +service: + type: LoadBalancer + annotations: {} + loadBalancerIP: "" + externalTrafficPolicy: Local +database: + host: postgresql +secrets: + mumble: mumble-secrets + postgresql: postgresql + tls: mumble-tls +config: + welcomeText: Welcome to Mumble + registrationName: mumble.example.org + registrationURL: https://mumble.example.org/ + registrationHostname: mumble.example.org + registrationLocation: "" +resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 500m + memory: 512Mi +postgresql: + enabled: false + auth: + username: mumble + database: mumble diff --git a/install.sh b/install.sh index 33029f7..e1212f0 100755 --- a/install.sh +++ b/install.sh @@ -1,62 +1,6 @@ #!/bin/sh - set -eu - -NAMESPACE=default -SECRET_NAME=mumble-secrets -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) - -for command in kubectl openssl; do - if ! command -v "$command" >/dev/null 2>&1; then - echo "Required command not found: $command" >&2 - exit 1 - fi -done - -for dependency in secret/postgresql secret/brunner-ninja-tls; do - if ! kubectl get "$dependency" --namespace "$NAMESPACE" >/dev/null 2>&1; then - echo "Required Kubernetes object not found: $NAMESPACE/$dependency" >&2 - exit 1 - fi -done - -if ! kubectl get secret "$SECRET_NAME" --namespace "$NAMESPACE" >/dev/null 2>&1; then - database_password=$(openssl rand -hex 32) - superuser_password=$(openssl rand -hex 32) - registration_password=$(openssl rand -hex 32) - - kubectl create secret generic "$SECRET_NAME" \ - --namespace "$NAMESPACE" \ - --from-literal="database-password=$database_password" \ - --from-literal="superuser-password=$superuser_password" \ - --from-literal="registration-password=$registration_password" - - unset database_password superuser_password registration_password - echo "Created $NAMESPACE/$SECRET_NAME with random database, SuperUser, and public-registry passwords." -else - echo "Keeping existing $NAMESPACE/$SECRET_NAME." - - registration_password_present=$(kubectl get secret "$SECRET_NAME" \ - --namespace "$NAMESPACE" \ - -o go-template='{{if index .data "registration-password"}}yes{{end}}') - - if [ -z "$registration_password_present" ] || [ "${ROTATE_REGISTRATION_PASSWORD:-false}" = true ]; then - registration_password=$(openssl rand -hex 32) - kubectl patch secret "$SECRET_NAME" \ - --namespace "$NAMESPACE" \ - --type=merge \ - --patch "{\"stringData\":{\"registration-password\":\"$registration_password\"}}" >/dev/null - unset registration_password - echo "Set a new random public-registry password in $NAMESPACE/$SECRET_NAME." - fi - - unset registration_password_present -fi - -kubectl apply -f "$SCRIPT_DIR/mumble.yaml" -kubectl rollout status deployment/mumble --namespace "$NAMESPACE" --timeout=5m - -echo -echo "Mumble is available at brunner.ninja:64738 (MetalLB 192.168.0.14)." -echo "Retrieve the SuperUser password with:" -echo "kubectl get secret $SECRET_NAME -n $NAMESPACE -o jsonpath='{.data.superuser-password}' | base64 -d; echo" +cd "$(dirname "$0")" +values=${1:?usage: ./install.sh VALUES_FILE} +./test.sh +helm upgrade --install mumble chart/mumble --namespace "${MUMBLE_NAMESPACE:-default}" --create-namespace --values "$values" --wait --timeout 15m diff --git a/mumble.yaml b/mumble.yaml index e90f1e4..b260af3 100644 --- a/mumble.yaml +++ b/mumble.yaml @@ -55,7 +55,7 @@ spec: fsGroupChangePolicy: OnRootMismatch initContainers: - name: render-config - image: busybox:1.37.0 + image: docker.io/library/busybox:1.38.0 imagePullPolicy: IfNotPresent command: - /bin/sh @@ -177,7 +177,7 @@ spec: memory: 128Mi containers: - name: mumble - image: mumblevoip/mumble-server:v1.5.915-1 + image: mumblevoip/mumble-server:v1.5.939 imagePullPolicy: IfNotPresent ports: - name: mumble-tcp @@ -247,7 +247,7 @@ spec: cpu: 500m memory: 512Mi - name: certificate-reloader - image: busybox:1.37.0 + image: docker.io/library/busybox:1.38.0 imagePullPolicy: IfNotPresent command: - /bin/sh diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..5875433 --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +description=$(git describe --tags --long --match 'v[0-9]*' --dirty) +case "$description" in *-dirty) echo 'Refusing to package a dirty tree' >&2; exit 1 ;; esac +version=${description#v} +case "$version" in *-0-g*) version=${version%%-0-g*} ;; esac +printf '%s\n' "$version" diff --git a/test.sh b/test.sh index 7912aad..972d6af 100755 --- a/test.sh +++ b/test.sh @@ -1,24 +1,33 @@ #!/usr/bin/env bash - set -euo pipefail +cd "$(dirname "$0")" +sh -n install.sh bootstrap-homelab-secrets.sh scripts/chart-version.sh +helm repo add bitnami https://charts.bitnami.com/bitnami --force-update >/dev/null +helm dependency build chart/mumble >/dev/null +helm lint chart/mumble +helm lint chart/mumble --set postgresql.enabled=true +python3 - <<'PY' +import subprocess +import yaml -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +def render(*extra): + output = subprocess.check_output(['helm', 'template', 'mumble', 'chart/mumble', + '--namespace', 'default', '--set', 'postgresql.enabled=true', + *extra], text=True) + return [doc for doc in yaml.safe_load_all(output) if doc] -sh -n "${project_dir}/install.sh" -bash -n "${project_dir}/create-ci-kubeconfig.sh" - -if command -v kubeconform >/dev/null 2>&1; then - kubeconform -strict -summary "${project_dir}/mumble.yaml" "${project_dir}/ci-deployer.yaml" -elif command -v docker >/dev/null 2>&1; then - docker run --rm --interactive \ - ghcr.io/yannh/kubeconform:v0.7.0 \ - -strict -summary < "${project_dir}/mumble.yaml" - docker run --rm --interactive \ - ghcr.io/yannh/kubeconform:v0.7.0 \ - -strict -summary < "${project_dir}/ci-deployer.yaml" -else - echo "Required command not found: install kubeconform or Docker" >&2 - exit 1 -fi - -echo "Mumble scripts and Kubernetes resources are valid." +staged = render('--set', 'mumble.enabled=false') +assert not any(doc['kind'] == 'Deployment' for doc in staged) +assert any(doc['kind'] == 'StatefulSet' and doc['metadata']['name'] == 'mumble-postgresql' for doc in staged) +active = render() +deployment = next(doc for doc in active if doc['kind'] == 'Deployment' and doc['metadata']['name'] == 'mumble') +service = next(doc for doc in active if doc['kind'] == 'Service' and doc['metadata']['name'] == 'mumble') +assert not service['spec'].get('loadBalancerIP') +assert {port['protocol'] for port in service['spec']['ports']} == {'TCP', 'UDP'} +assert deployment['spec']['strategy']['type'] == 'Recreate' +pod = deployment['spec']['template']['spec'] +assert [container['name'] for container in pod['initContainers']] == ['render-config'] +assert pod['containers'][0]['image'].endswith('v1.5.939') +assert any(volume.get('secret', {}).get('secretName') == 'mumble-tls' for volume in pod['volumes']) +print('Mumble chart renders staged PostgreSQL and active Mumble without cleartext credentials.') +PY diff --git a/values.example.yaml b/values.example.yaml new file mode 100644 index 0000000..90f7f46 --- /dev/null +++ b/values.example.yaml @@ -0,0 +1,10 @@ +database: + host: postgresql.example.svc.cluster.local +secrets: + mumble: mumble-secrets + postgresql: postgresql + tls: mumble-tls +config: + registrationName: mumble.example.org + registrationURL: https://mumble.example.org/ + registrationHostname: mumble.example.org