diff --git a/.gitea/workflows/chart.yml b/.gitea/workflows/chart.yml new file mode 100644 index 0000000..7a6c9a1 --- /dev/null +++ b/.gitea/workflows/chart.yml @@ -0,0 +1,106 @@ +name: Validate, publish and promote Diun Helm chart + +on: + push: + pull_request: + +env: + HELM_VERSION: v4.3.0 + GITOPS_REPOSITORY: feedc0de/homelab-gitops + +jobs: + validate: + name: Validate chart + runs-on: ubuntu-latest + steps: + - name: Check out source and tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + - name: Install chart test dependency + run: python3 -m pip install --user PyYAML + - name: Lint, render and test chart behavior + run: ./test.sh + + publish: + name: Publish and promote chart + if: gitea.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-latest + steps: + - name: Check out source and tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: ${{ env.HELM_VERSION }} + - name: Select immutable chart version + run: printf 'CHART_VERSION=%s\n' "$(./scripts/chart-version.sh)" >> "${GITHUB_ENV}" + - name: Package and publish chart + env: + PACKAGE_USERNAME: ${{ secrets.PACKAGE_USERNAME }} + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + run: | + set -euo pipefail + test -n "${PACKAGE_USERNAME}" + test -n "${PACKAGE_TOKEN}" + install -d .chart-packages + helm package chart/diun --destination .chart-packages --version "${CHART_VERSION}" + chart_file=".chart-packages/diun-${CHART_VERSION}.tgz" + test -f "${chart_file}" + curl --fail --silent --show-error \ + --user "${PACKAGE_USERNAME}:${PACKAGE_TOKEN}" \ + --request POST \ + --upload-file "${chart_file}" \ + https://code.brunner.ninja/api/packages/feedc0de/helm/api/charts + - name: Check that this is still current main + id: current-main + env: + COMMIT_SHA: ${{ gitea.sha }} + run: | + git fetch --no-tags origin main + if [ "$(git rev-parse FETCH_HEAD)" = "${COMMIT_SHA}" ]; then + echo "promote=true" >> "${GITEA_OUTPUT}" + else + echo "This build is no longer current main; skipping promotion" + echo "promote=false" >> "${GITEA_OUTPUT}" + fi + - name: Check out GitOps repository + if: steps.current-main.outputs.promote == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.GITOPS_REPOSITORY }} + ref: main + token: ${{ secrets.GITOPS_TOKEN }} + path: homelab-gitops + - name: Commit chart promotion + if: steps.current-main.outputs.promote == 'true' + working-directory: homelab-gitops + run: | + set -euo pipefail + ./scripts/update-chart-version.sh diun "${CHART_VERSION}" + git config user.name "feedc0de" + git config user.email "feedc0de@users.noreply.code.brunner.ninja" + git add bootstrap/diun.yaml + if git diff --cached --quiet; then + echo "GitOps already selects ${CHART_VERSION}" + exit 0 + fi + git commit -m "Deploy diun chart ${CHART_VERSION}" + for attempt in 1 2 3; do + if git push origin HEAD:main; then + exit 0 + fi + if [ "${attempt}" = 3 ]; then + echo "GitOps promotion failed after three attempts" >&2 + exit 1 + fi + git fetch origin main + git rebase origin/main + done diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml deleted file mode 100644 index ff384a1..0000000 --- a/.gitea/workflows/deploy.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Validate and deploy Diun - -on: - push: - pull_request: - -env: - KUBECTL_VERSION: v1.36.3 - KUBERNETES_API: https://host.containers.internal:6443 - KUBERNETES_TLS_SERVER_NAME: 192.168.0.2 - NAMESPACE: diun - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: python3 -m pip install --user PyYAML - - run: bash test.sh - - deploy: - if: gitea.ref == 'refs/heads/main' - needs: validate - runs-on: ubuntu-latest - steps: - - 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" - echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" - - 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" - kubectl config set-cluster cluster --server="${KUBERNETES_API}" \ - --tls-server-name="${KUBERNETES_TLS_SERVER_NAME}" - - name: Apply and verify - run: | - export KUBECONFIG="${RUNNER_TEMP}/kubeconfig" - kubectl apply --dry-run=server --validate=false -f app.yaml - kubectl apply --validate=false -f app.yaml - kubectl -n "${NAMESPACE}" rollout restart deployment/diun - kubectl -n "${NAMESPACE}" rollout status deployment/diun --timeout=5m || { - kubectl -n "${NAMESPACE}" describe deployment/diun - kubectl -n "${NAMESPACE}" get pods -o wide - kubectl -n "${NAMESPACE}" logs deployment/diun --all-containers=true --tail=100 || true - exit 1 - } diff --git a/README.md b/README.md index 4038042..d5c7eac 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,38 @@ -# Diun +# Diun Helm chart -Diun checks container images used by Kubernetes Pods and sends -notifications when registry images change. It runs in its own `diun` namespace -using the upstream `crazymax/diun:4.33.0` image. The Kubernetes provider reads -Pods across namespaces by default. +This repository maintains a small adapter chart for the upstream Diun image. +It owns the Deployment, service account and read-only Kubernetes Pod RBAC, +configuration, notification wiring, and optional persistent data claim. The +cluster chooses the chart release and supplies only its Secret references, +registry/Matrix endpoints, and storage settings in +[`homelab-gitops`](https://code.brunner.ninja/feedc0de/homelab-gitops/src/branch/main/apps/diun). -Add this annotation to a Pod template to exclude an application: +The chart uses upstream `crazymax/diun` release tags. Its Chart `appVersion` +is the Diun release; the chart package version is derived from Git tags with +`git describe`. The Matrix integration downloads Shoutrrr 0.14.0 at startup +because later 0.14.1 sent duplicate Matrix authentication data rejected by the +configured Synapse. The init image uses a readable curl image tag. No image +digests or hard-coded download checksums are used. + +## Install + +Add the public chart repository and install the published chart (or use the +same-release convenience script in this checkout): + +```sh +helm repo add brunner.ninja https://brunner.ninja/charts +helm repo update brunner.ninja +./install.sh +``` + +For a new cluster, create a values file that references existing Kubernetes +Secrets for `registry.credentialsSecretName` and +`matrix.tokenSecretName`; do not put their values in Helm values or Git. +See `values.example.yaml` for the chart interface. The default chart supports a +portable PVC; the homelab GitOps values preserve the existing `diun` claim. + +Add this annotation to a Pod template to exclude it from Diun's Kubernetes +provider: ```yaml spec: @@ -15,44 +42,14 @@ spec: diun.enable: "false" ``` -Diun sends Matrix notices to `!ckpLXMvXlZJtuvQBIJ:brunner.ninja` as -`@diun:brunner.ninja`. It calls the Shoutrrr CLI through Diun's script notifier. -An init container downloads the Shoutrrr v0.14.0 release and makes the binary -available to Diun. Version 0.14.0 is pinned because -0.14.1 sends duplicate Matrix authentication data that this Synapse rejects. -The Matrix access token is stored in the `diun-matrix-token` Secret. A separate -`diun-registry-credentials` Secret grants read access to private Quay images. -Synapse password login remains disabled. - -Container images here use readable version tags, with `latest` only when no -version tag is available. Image digests and hard-coded download checksums are -not used. - -## Install - -Run `./install.sh` to create the namespace and Diun resources, then wait for -the Deployment and data PVC. Both Secrets must already exist in the -namespace. The persistent volume stores Diun's database. -`./test.sh` performs local manifest and script checks without contacting the -cluster. - -## Gitea deployment - -Bootstrap the CI identity once using an administrator kubeconfig: - -```sh -./create-ci-kubeconfig.sh -``` - -Create the `feedc0de/diun` Gitea repository and push this directory's `main` -branch, then save the script output as the repository Actions secret -`KUBE_CONFIG_BASE64`. CI may -only read and update the named Diun ConfigMap and Deployment in the `diun` -namespace; it cannot read Secrets or modify RBAC, the PVC, or other resources. -The Diun runtime ServiceAccount separately receives read-only access to Pods -across namespaces as required by the upstream Kubernetes provider. - Diun has no web UI. It notices newly seen image references and changed image digests on scheduled scans; a first sighting is not proof that an image is -outdated. For Uptime Kuma later, create a separate Matrix account and token -and configure its built-in Matrix notification with the same room ID. +outdated. + +## Development and publication + +Run `./test.sh` to lint and render the chart and check resource relationships. +Gitea Actions publishes immutable chart versions to the public `brunner.ninja` +Helm repository and promotes the published version to this Argo Application. +The repository Actions secrets are `PACKAGE_USERNAME`, `PACKAGE_TOKEN`, and +`GITOPS_TOKEN`; chart CI has no Kubernetes credentials. diff --git a/chart/diun/Chart.yaml b/chart/diun/Chart.yaml new file mode 100644 index 0000000..cd01df4 --- /dev/null +++ b/chart/diun/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: diun +description: A Helm chart for running Diun with Kubernetes provider support +type: application +version: 0.0.0 +appVersion: "4.33.0" diff --git a/chart/diun/templates/_helpers.tpl b/chart/diun/templates/_helpers.tpl new file mode 100644 index 0000000..ecdfddf --- /dev/null +++ b/chart/diun/templates/_helpers.tpl @@ -0,0 +1,23 @@ +{{- define "diun.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "diun.fullname" -}} +{{- if contains .Chart.Name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "diun.labels" -}} +app.kubernetes.io/name: {{ include "diun.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "diun.image" -}} +{{- $tag := .Values.image.tag | default .Chart.AppVersion -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end -}} diff --git a/chart/diun/templates/clusterrole.yaml b/chart/diun/templates/clusterrole.yaml new file mode 100644 index 0000000..7dcd4dd --- /dev/null +++ b/chart/diun/templates/clusterrole.yaml @@ -0,0 +1,10 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "diun.fullname" . }} + labels: + {{- include "diun.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] diff --git a/chart/diun/templates/clusterrolebinding.yaml b/chart/diun/templates/clusterrolebinding.yaml new file mode 100644 index 0000000..05d7fcc --- /dev/null +++ b/chart/diun/templates/clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "diun.fullname" . }} + labels: + {{- include "diun.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "diun.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "diun.fullname" . }} + namespace: {{ .Release.Namespace }} diff --git a/chart/diun/templates/configmap.yaml b/chart/diun/templates/configmap.yaml new file mode 100644 index 0000000..c1157cc --- /dev/null +++ b/chart/diun/templates/configmap.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "diun.fullname" . }} + labels: + {{- include "diun.labels" . | nindent 4 }} +data: + LOG_LEVEL: {{ .Values.config.logLevel | quote }} + LOG_JSON: {{ .Values.config.logJson | quote }} + TZ: {{ .Values.config.timeZone | quote }} + DIUN_WATCH_SCHEDULE: {{ .Values.config.watchSchedule | quote }} + DIUN_WATCH_JITTER: {{ .Values.config.watchJitter | quote }} + DIUN_PROVIDERS_KUBERNETES: "true" + DIUN_PROVIDERS_KUBERNETES_WATCHBYDEFAULT: {{ .Values.config.watchByDefault | quote }} + {{- if .Values.registry.enabled }} + DIUN_REGOPTS_0_NAME: {{ .Values.registry.name | quote }} + DIUN_REGOPTS_0_SELECTOR: image + DIUN_REGOPTS_0_USERNAMEFILE: /run/secrets/diun-registry-credentials/username + DIUN_REGOPTS_0_PASSWORDFILE: /run/secrets/diun-registry-credentials/password + {{- end }} + {{- if .Values.matrix.enabled }} + DIUN_NOTIF_SCRIPT_CMD: /bin/sh + DIUN_NOTIF_SCRIPT_ARGS: /etc/diun/notifier/send-matrix.sh + MATRIX_HOMESERVER: {{ .Values.matrix.homeserver | quote }} + MATRIX_ROOM_ID: {{ .Values.matrix.roomId | quote }} + {{- end }} +{{- if .Values.matrix.enabled }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "diun.fullname" . }}-matrix-script + labels: + {{- include "diun.labels" . | nindent 4 }} +data: + send-matrix.sh: | + set -eu + token=$(cat /run/secrets/diun-matrix-token/{{ .Values.matrix.tokenSecretKey }}) + url="matrix://:${token}@${MATRIX_HOMESERVER}/?rooms=${MATRIX_ROOM_ID}" + message="Diun: ${DIUN_ENTRY_IMAGE} ${DIUN_ENTRY_STATUS} (${DIUN_ENTRY_PROVIDER})" + if [ -n "${DIUN_ENTRY_METADATA_POD_NAMESPACE:-}" ]; then + message="${message} in namespace ${DIUN_ENTRY_METADATA_POD_NAMESPACE}" + fi + SHOUTRRR_URL="${url}" /opt/shoutrrr/shoutrrr send --message "${message}" +{{- end }} diff --git a/app.yaml b/chart/diun/templates/deployment.yaml similarity index 56% rename from app.yaml rename to chart/diun/templates/deployment.yaml index e069c9b..208c575 100644 --- a/app.yaml +++ b/chart/diun/templates/deployment.yaml @@ -1,87 +1,52 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: diun - namespace: diun -data: - LOG_LEVEL: info - LOG_JSON: "false" - TZ: Europe/Vienna - DIUN_WATCH_SCHEDULE: "0 */6 * * *" - DIUN_WATCH_JITTER: 30s - DIUN_PROVIDERS_KUBERNETES: "true" - DIUN_PROVIDERS_KUBERNETES_WATCHBYDEFAULT: "true" - DIUN_REGOPTS_0_NAME: registry.brunner.ninja - DIUN_REGOPTS_0_SELECTOR: image - DIUN_REGOPTS_0_USERNAMEFILE: /run/secrets/diun-registry-credentials/username - DIUN_REGOPTS_0_PASSWORDFILE: /run/secrets/diun-registry-credentials/password - DIUN_NOTIF_SCRIPT_CMD: /bin/sh - DIUN_NOTIF_SCRIPT_ARGS: /etc/diun/notifier/send-matrix.sh ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: diun-matrix-script - namespace: diun -data: - send-matrix.sh: | - set -eu - token=$(cat /run/secrets/diun-matrix-token/token) - url="matrix://:${token}@matrix.brunner.ninja:443/?rooms=!ckpLXMvXlZJtuvQBIJ:brunner.ninja" - message="Diun: ${DIUN_ENTRY_IMAGE} ${DIUN_ENTRY_STATUS} (${DIUN_ENTRY_PROVIDER})" - if [ -n "${DIUN_ENTRY_METADATA_POD_NAMESPACE:-}" ]; then - message="${message} in namespace ${DIUN_ENTRY_METADATA_POD_NAMESPACE}" - fi - SHOUTRRR_URL="${url}" /opt/shoutrrr/shoutrrr send --message "${message}" ---- apiVersion: apps/v1 kind: Deployment metadata: - name: diun - namespace: diun + name: {{ include "diun.fullname" . }} labels: - app.kubernetes.io/name: diun + {{- include "diun.labels" . | nindent 4 }} spec: replicas: 1 strategy: type: Recreate selector: matchLabels: - app.kubernetes.io/name: diun + app.kubernetes.io/name: {{ include "diun.name" . }} template: metadata: labels: - app.kubernetes.io/name: diun + app.kubernetes.io/name: {{ include "diun.name" . }} annotations: diun.enable: "false" spec: - serviceAccountName: diun + serviceAccountName: {{ include "diun.fullname" . }} securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 + {{- if .Values.matrix.enabled }} initContainers: - name: install-shoutrrr - image: docker.io/curlimages/curl:8.16.0 + image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}" command: ["/bin/sh", "-ec"] args: - | curl -fsSL -o /tmp/shoutrrr.tar.gz \ - https://github.com/nicholas-fedor/shoutrrr/releases/download/v0.14.0/shoutrrr_linux_amd64_0.14.0.tar.gz + https://github.com/nicholas-fedor/shoutrrr/releases/download/v{{ .Values.shoutrrr.version }}/shoutrrr_linux_amd64_{{ .Values.shoutrrr.version }}.tar.gz tar -xzf /tmp/shoutrrr.tar.gz -C /tools shoutrrr chmod 0555 /tools/shoutrrr volumeMounts: - name: shoutrrr mountPath: /tools + {{- end }} containers: - name: diun - image: crazymax/diun:4.33.0 - imagePullPolicy: IfNotPresent + image: {{ include "diun.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} args: ["serve"] envFrom: - configMapRef: - name: diun + name: {{ include "diun.fullname" . }} startupProbe: exec: command: ["diun", "healthcheck"] @@ -98,18 +63,22 @@ spec: volumeMounts: - name: data mountPath: /data - - name: matrix-token - mountPath: /run/secrets/diun-matrix-token - readOnly: true + {{- if .Values.registry.enabled }} - name: registry-credentials mountPath: /run/secrets/diun-registry-credentials readOnly: true + {{- end }} + {{- if .Values.matrix.enabled }} + - name: matrix-token + mountPath: /run/secrets/diun-matrix-token + readOnly: true - name: matrix-script mountPath: /etc/diun/notifier readOnly: true - name: shoutrrr mountPath: /opt/shoutrrr readOnly: true + {{- end }} securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true @@ -117,16 +86,24 @@ spec: drop: [ALL] volumes: - name: data + {{- if .Values.persistence.enabled }} persistentVolumeClaim: - claimName: diun - - name: matrix-token - secret: - secretName: diun-matrix-token + claimName: {{ .Values.persistence.existingClaim | default (include "diun.fullname" .) }} + {{- else }} + emptyDir: {} + {{- end }} + {{- if .Values.registry.enabled }} - name: registry-credentials secret: - secretName: diun-registry-credentials + secretName: {{ required "registry.credentialsSecretName is required when registry auth is enabled" .Values.registry.credentialsSecretName }} + {{- end }} + {{- if .Values.matrix.enabled }} + - name: matrix-token + secret: + secretName: {{ required "matrix.tokenSecretName is required when Matrix notifications are enabled" .Values.matrix.tokenSecretName }} - name: matrix-script configMap: - name: diun-matrix-script + name: {{ include "diun.fullname" . }}-matrix-script - name: shoutrrr emptyDir: {} + {{- end }} diff --git a/chart/diun/templates/pvc.yaml b/chart/diun/templates/pvc.yaml new file mode 100644 index 0000000..8f1338d --- /dev/null +++ b/chart/diun/templates/pvc.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "diun.fullname" . }} + labels: + {{- include "diun.labels" . | nindent 4 }} + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- if .Values.persistence.storageClassName }} + storageClassName: {{ .Values.persistence.storageClassName | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} +{{- end }} diff --git a/chart/diun/templates/serviceaccount.yaml b/chart/diun/templates/serviceaccount.yaml new file mode 100644 index 0000000..120046e --- /dev/null +++ b/chart/diun/templates/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "diun.fullname" . }} + labels: + {{- include "diun.labels" . | nindent 4 }} +automountServiceAccountToken: true diff --git a/chart/diun/values.schema.json b/chart/diun/values.schema.json new file mode 100644 index 0000000..b0e29d3 --- /dev/null +++ b/chart/diun/values.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["image", "initImage", "shoutrrr", "config", "registry", "matrix", "persistence", "resources"], + "properties": { + "global": { "type": "object", "additionalProperties": true }, + "image": { + "type": "object", "additionalProperties": false, + "required": ["repository", "pullPolicy", "tag"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, + "tag": { "type": "string" } + } + }, + "initImage": { + "type": "object", "additionalProperties": false, + "required": ["repository", "tag"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string", "minLength": 1 } + } + }, + "shoutrrr": { + "type": "object", "additionalProperties": false, + "required": ["version"], + "properties": { "version": { "type": "string", "minLength": 1 } } + }, + "config": { + "type": "object", "additionalProperties": false, + "required": ["logLevel", "logJson", "timeZone", "watchSchedule", "watchJitter", "watchByDefault"], + "properties": { + "logLevel": { "type": "string", "minLength": 1 }, + "logJson": { "type": "string", "enum": ["true", "false"] }, + "timeZone": { "type": "string", "minLength": 1 }, + "watchSchedule": { "type": "string", "minLength": 1 }, + "watchJitter": { "type": "string", "minLength": 1 }, + "watchByDefault": { "type": "boolean" } + } + }, + "registry": { + "type": "object", "additionalProperties": false, + "required": ["enabled", "name", "credentialsSecretName"], + "properties": { + "enabled": { "type": "boolean" }, + "name": { "type": "string", "minLength": 1 }, + "credentialsSecretName": { "type": "string" } + } + }, + "matrix": { + "type": "object", "additionalProperties": false, + "required": ["enabled", "homeserver", "roomId", "tokenSecretName", "tokenSecretKey"], + "properties": { + "enabled": { "type": "boolean" }, + "homeserver": { "type": "string", "minLength": 1 }, + "roomId": { "type": "string" }, + "tokenSecretName": { "type": "string" }, + "tokenSecretKey": { "type": "string", "minLength": 1 } + } + }, + "persistence": { + "type": "object", "additionalProperties": false, + "required": ["enabled", "existingClaim", "storageClassName", "accessModes", "size", "annotations"], + "properties": { + "enabled": { "type": "boolean" }, + "existingClaim": { "type": "string" }, + "storageClassName": { "type": "string" }, + "accessModes": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "size": { "type": "string", "minLength": 1 }, + "annotations": { "type": "object", "additionalProperties": { "type": "string" } } + } + }, + "resources": { "type": "object" } + } +} diff --git a/chart/diun/values.yaml b/chart/diun/values.yaml new file mode 100644 index 0000000..56e172f --- /dev/null +++ b/chart/diun/values.yaml @@ -0,0 +1,42 @@ +image: + repository: crazymax/diun + pullPolicy: IfNotPresent + tag: "" + +initImage: + repository: docker.io/curlimages/curl + tag: 8.23.0 + +shoutrrr: + version: 0.14.0 + +config: + logLevel: info + logJson: "false" + timeZone: Europe/Vienna + watchSchedule: "0 */6 * * *" + watchJitter: 30s + watchByDefault: true + +registry: + enabled: false + name: registry.example.net + credentialsSecretName: "" + +matrix: + enabled: false + homeserver: matrix.example.net:443 + roomId: "" + tokenSecretName: "" + tokenSecretKey: token + +persistence: + enabled: true + existingClaim: "" + storageClassName: "" + accessModes: + - ReadWriteOnce + size: 1Gi + annotations: {} + +resources: {} diff --git a/ci-deployer.yaml b/ci-deployer.yaml deleted file mode 100644 index b48fa7b..0000000 --- a/ci-deployer.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: diun-deployer - namespace: diun -automountServiceAccountToken: false ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: diun-deployer - namespace: diun -rules: - - apiGroups: [""] - resources: ["configmaps"] - resourceNames: ["diun", "diun-matrix-script"] - verbs: ["get", "patch", "update"] - - apiGroups: ["apps"] - resources: ["deployments"] - resourceNames: ["diun"] - verbs: ["get", "patch", "update", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: diun-deployer - namespace: diun -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: diun-deployer -subjects: - - kind: ServiceAccount - name: diun-deployer - namespace: diun ---- -apiVersion: v1 -kind: Secret -metadata: - name: diun-deployer-token - namespace: diun - annotations: - kubernetes.io/service-account.name: diun-deployer -type: kubernetes.io/service-account-token diff --git a/create-ci-kubeconfig.sh b/create-ci-kubeconfig.sh deleted file mode 100755 index d64a213..0000000 --- a/create-ci-kubeconfig.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -namespace=diun -service_account=diun-deployer -secret=diun-deployer-token -expected_context=kubernetes-admin@kubernetes -current_context=$(kubectl config current-context) -if [[ "${current_context}" != "${expected_context}" ]]; then - echo "Refusing cluster mutation: expected context ${expected_context}, got ${current_context}" >&2 - exit 1 -fi - -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 diun --cluster=cluster --user="${service_account}" \ - --namespace="${namespace}" >/dev/null -kubectl config use-context diun >/dev/null - -echo "Store the following 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/diun.yaml b/diun.yaml deleted file mode 100644 index dc5c7a3..0000000 --- a/diun.yaml +++ /dev/null @@ -1,40 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: diun - namespace: diun -automountServiceAccountToken: true ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: diun -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: diun -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: diun -subjects: - - kind: ServiceAccount - name: diun - namespace: diun ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: diun - namespace: diun -spec: - accessModes: [ReadWriteOnce] - storageClassName: rook-ceph-block - resources: - requests: - storage: 1Gi diff --git a/install.sh b/install.sh index 0aff55a..f66c80d 100755 --- a/install.sh +++ b/install.sh @@ -1,23 +1,15 @@ -#!/usr/bin/env bash +#!/bin/sh +set -eu -set -euo pipefail +project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +chart_version=$(cd "${project_dir}" && ./scripts/chart-version.sh) +values_file=${1:-${project_dir}/values.example.yaml} -project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -expected_context=kubernetes-admin@kubernetes -current_context=$(kubectl config current-context) -if [[ "${current_context}" != "${expected_context}" ]]; then - echo "Refusing cluster mutation: expected context ${expected_context}, got ${current_context}" >&2 - exit 1 -fi - -kubectl apply --dry-run=server --filename "${project_dir}/namespace.yaml" -kubectl apply --filename "${project_dir}/namespace.yaml" -kubectl apply --dry-run=server --filename "${project_dir}/diun.yaml" -kubectl apply --filename "${project_dir}/diun.yaml" -kubectl apply --dry-run=server --filename "${project_dir}/app.yaml" -kubectl apply --filename "${project_dir}/app.yaml" -kubectl --namespace diun rollout status deployment/diun --timeout=5m -kubectl --namespace diun wait --for=jsonpath='{.status.phase}'=Bound \ - pvc/diun --timeout=5m - -printf 'Installed Diun in namespace diun\n' +helm repo add brunner.ninja https://brunner.ninja/charts --force-update +helm repo update brunner.ninja +helm upgrade --install diun brunner.ninja/diun \ + --namespace diun \ + --create-namespace \ + --version "${chart_version}" \ + --values "${values_file}" \ + --wait --timeout 10m diff --git a/namespace.yaml b/namespace.yaml deleted file mode 100644 index e0865e2..0000000 --- a/namespace.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: diun - labels: - app.kubernetes.io/name: diun diff --git a/scripts/chart-version.sh b/scripts/chart-version.sh new file mode 100755 index 0000000..a20feb6 --- /dev/null +++ b/scripts/chart-version.sh @@ -0,0 +1,12 @@ +#!/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 a28402a..b18f46c 100755 --- a/test.sh +++ b/test.sh @@ -3,34 +3,60 @@ set -euo pipefail project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -command -v kubectl >/dev/null +chart_dir="${project_dir}/chart/diun" +rendered_file="${TMPDIR:-/tmp}/diun-chart-rendered.yaml" +example_values="${project_dir}/values.example.yaml" + +command -v helm >/dev/null command -v python3 >/dev/null -bash -n "${project_dir}/install.sh" -bash -n "${project_dir}/create-ci-kubeconfig.sh" -bash -n "${project_dir}/test.sh" -python3 - "${project_dir}" <<'PY' -import pathlib +helm lint --strict "${chart_dir}" +helm template diun "${chart_dir}" \ + --namespace diun \ + --values "${example_values}" \ + > "${rendered_file}" +python3 - "${rendered_file}" "${project_dir}" "${example_values}" <<'PY' import sys import yaml +from pathlib import Path -root = pathlib.Path(sys.argv[1]) -expected = { - "namespace.yaml": {"Namespace"}, - "diun.yaml": {"ServiceAccount", "ClusterRole", "ClusterRoleBinding", "PersistentVolumeClaim"}, - "app.yaml": {"ConfigMap", "Deployment"}, - "ci-deployer.yaml": {"ServiceAccount", "Role", "RoleBinding", "Secret"}, -} -for filename, kinds in expected.items(): - with (root / filename).open(encoding="utf-8") as stream: - documents = list(yaml.safe_load_all(stream)) - assert documents and all(isinstance(document, dict) for document in documents), filename - assert kinds <= {document.get("kind") for document in documents}, filename +project_dir = Path(sys.argv[2]) +with open(sys.argv[1], encoding="utf-8") as stream: + objects = [item for item in yaml.safe_load_all(stream) if item] +with (project_dir / "chart/diun/values.yaml").open(encoding="utf-8") as stream: + chart_values = yaml.safe_load(stream) +with (project_dir / "chart/diun/Chart.yaml").open(encoding="utf-8") as stream: + chart_metadata = yaml.safe_load(stream) +with open(sys.argv[3], encoding="utf-8") as stream: + example_values = yaml.safe_load(stream) + +def one(kind, name): + matches = [obj for obj in objects if obj.get("kind") == kind and obj.get("metadata", {}).get("name") == name] + assert len(matches) == 1, f"expected one {kind}/{name}, found {len(matches)}" + return matches[0] + +deployment = one("Deployment", "diun") +pod = deployment["spec"]["template"] +pod_spec = pod["spec"] +container = next(item for item in pod_spec["containers"] if item["name"] == "diun") +selector = deployment["spec"]["selector"]["matchLabels"] +assert all(pod["metadata"]["labels"].get(key) == value for key, value in selector.items()) +assert deployment["spec"]["replicas"] == 1 +expected_tag = chart_values["image"].get("tag") or str(chart_metadata["appVersion"]) +assert container["image"] == f'{chart_values["image"]["repository"]}:{expected_tag}' +assert all(name in container for name in ("startupProbe", "readinessProbe", "livenessProbe")) +assert all(container[name]["exec"]["command"] == ["diun", "healthcheck"] for name in ("startupProbe", "readinessProbe", "livenessProbe")) +assert container["envFrom"][0]["configMapRef"]["name"] == "diun" +assert pod_spec["serviceAccountName"] == "diun" +assert any(item["name"] == "install-shoutrrr" for item in pod_spec["initContainers"]) +assert one("ConfigMap", "diun-matrix-script")["data"].get("send-matrix.sh") +assert one("ClusterRole", "diun")["rules"] == [{"apiGroups": [""], "resources": ["pods"], "verbs": ["get", "list", "watch"]}] +assert one("ClusterRoleBinding", "diun")["roleRef"]["name"] == "diun" +pvc = one("PersistentVolumeClaim", "diun") +assert pvc["spec"]["storageClassName"] == example_values["persistence"]["storageClassName"] +assert pod_spec["volumes"][0]["persistentVolumeClaim"]["claimName"] == pvc["metadata"]["name"] +assert not any(item.get("kind") == "Secret" for item in objects), "chart must reference secrets, never render their data" +volume = next(item for item in pod_spec["volumes"] if item["name"] == "matrix-token") +assert volume["secret"]["secretName"] == example_values["matrix"]["tokenSecretName"] +mount = next(item for item in container["volumeMounts"] if item["name"] == "matrix-token") +assert mount["readOnly"] is True PY -grep -Fq 'resourceNames: ["diun", "diun-matrix-script"]' "${project_dir}/ci-deployer.yaml" -if grep -Eq 'resources: \["secrets"\]|resources:[[:space:]]*\[[^]]*secrets' \ - "${project_dir}/ci-deployer.yaml"; then - echo 'CI deployer must not access Kubernetes Secrets' >&2 - exit 1 -fi - -echo 'Diun manifests and scripts validated' diff --git a/values.example.yaml b/values.example.yaml new file mode 100644 index 0000000..8a41c31 --- /dev/null +++ b/values.example.yaml @@ -0,0 +1,17 @@ +config: + timeZone: UTC + +matrix: + enabled: true + homeserver: matrix.example.net:443 + roomId: "!room-id:example.net" + tokenSecretName: diun-matrix-token + +registry: + enabled: false + +persistence: + enabled: true + storageClassName: example-storage-class + size: 1Gi +