Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff54eecf7
|
||
|
|
5be10251f4
|
No files matched your search
@@ -40,9 +40,7 @@ on:
|
||||
- ytdlp-web-player
|
||||
- sure
|
||||
- valkey
|
||||
- stalwart
|
||||
- garage
|
||||
- keycloak
|
||||
- convertx
|
||||
# freesailarr is deliberately absent: none of its ten images takes
|
||||
# its tag from appVersion (they are all pinned in its values.yaml),
|
||||
# so this workflow would rewrite appVersion and republish the chart
|
||||
@@ -69,18 +67,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Configure OP-Connect"
|
||||
uses: 1password/load-secrets-action/configure@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5
|
||||
uses: 1password/load-secrets-action/configure@v5
|
||||
with:
|
||||
connect-host: https://op-connect.jk-dev.de
|
||||
- name: "Load GitHub Credentials"
|
||||
id: load-jk-cluster-bot-creds
|
||||
uses: 1password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5
|
||||
uses: 1password/load-secrets-action@v5
|
||||
env:
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
JK_CLUSTER_BOT_TOKEN: op://GitHub-Actions/github-helm-charts-push-token/credential
|
||||
JK_CLUSTER_USERNAME: op://GitHub-Actions/github-helm-charts-push-token/username
|
||||
JK_CLUSTER_EMAIL: op://GitHub-Actions/github-helm-charts-push-token/email
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ env.JK_CLUSTER_BOT_TOKEN }}
|
||||
- name: "Get Chart Version"
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
count: ${{ steps.select.outputs.count }}
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
- name: "Select the charts to test"
|
||||
id: select
|
||||
env:
|
||||
|
||||
@@ -30,10 +30,9 @@ on:
|
||||
OP_CONNECT_TOKEN:
|
||||
# Needed by exactly one leg - freesailarr, for the 1Password Connect
|
||||
# lookup of its CI Wireguard key. Declared OPTIONAL on purpose, where
|
||||
# the shared publish block in workflow-util/pipelines declares its token
|
||||
# required: on this instance a missing secret does not fail the job, it
|
||||
# fails job CREATION, so the run shows no steps at all (README, "Where
|
||||
# this repo lives"; the long form is on that repository's wiki). With
|
||||
# publish-helm-chart.yml declares its token required: on this instance a
|
||||
# missing secret does not fail the job, it fails job CREATION, so the
|
||||
# run shows no steps at all (README, "Where this repo lives"). With
|
||||
# required: true a pull request from a fork - which carries no secrets -
|
||||
# would lose the entire install-test that way, every chart at once, with
|
||||
# nothing naming the cause. Optional instead: only the freesailarr leg
|
||||
@@ -120,7 +119,7 @@ jobs:
|
||||
# invisible there. The value mirrors K3S_IMAGE above (v1.35.5), which is
|
||||
# the cluster this client talks to.
|
||||
# renovate: datasource=github-releases depName=kubernetes/kubernetes
|
||||
KUBECTL_VERSION: v1.37.1
|
||||
KUBECTL_VERSION: v1.35.5
|
||||
# The same expression as runs-on above, written a second time because the
|
||||
# env context is not available in runs-on - so it cannot be the other way
|
||||
# round, and reading the label back off the runner is not possible either
|
||||
@@ -132,9 +131,9 @@ jobs:
|
||||
RUNNER_LABEL: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
|
||||
uses: azure/setup-helm@v5
|
||||
- name: "Install kubectl"
|
||||
run: |
|
||||
# kubectl and the cluster are a pair: the client talks to the server
|
||||
@@ -197,8 +196,8 @@ jobs:
|
||||
# freesailarr is the one chart whose install-test needs credentials:
|
||||
# without a real Wireguard key gluetun never completes a handshake, and
|
||||
# behind its kill switch not a single app container becomes Ready. The key
|
||||
# comes from 1Password Connect like the registry credentials in the shared
|
||||
# publish block, and it is a CI-ONLY Mullvad key: Mullvad ties an
|
||||
# comes from 1Password Connect like the registry credentials in
|
||||
# publish-helm-chart.yml, and it is a CI-ONLY Mullvad key: Mullvad ties an
|
||||
# address to a key and sends the tunnel to the endpoint it saw last, so
|
||||
# reusing the production key would let every PR run pull the tunnel out
|
||||
# from under the running stack - with the symptom in the cluster and the
|
||||
@@ -206,20 +205,18 @@ jobs:
|
||||
# touches the VPN key.
|
||||
- name: "Configure OP-Connect"
|
||||
if: matrix.chart == 'freesailarr'
|
||||
uses: 1password/load-secrets-action/configure@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5
|
||||
uses: 1password/load-secrets-action/configure@v5
|
||||
with:
|
||||
connect-host: https://op-connect.jk-dev.de
|
||||
- name: "Load freesailarr VPN credentials"
|
||||
id: load-freesailarr-vpn
|
||||
if: matrix.chart == 'freesailarr'
|
||||
uses: 1password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5
|
||||
uses: 1password/load-secrets-action@v5
|
||||
env:
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
FREESAILARR_WG_KEY: op://GitHub-Actions/freesailarr-mullvad-test-config/credential
|
||||
FREESAILARR_WG_ADDRESSES: op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address
|
||||
# The same trap the shared publish block documents at length - since
|
||||
# Gitea #66 that long form lives on the wiki of workflow-util/pipelines,
|
||||
# page "publish-helm-chart", not in this repository: v5 of
|
||||
# The same trap publish-helm-chart.yml documents at length: v5 of
|
||||
# load-secrets-action does NOT export the loaded values as environment
|
||||
# variables for later steps, only its step outputs carry them, and the
|
||||
# loading step logs "Populating variable" and stays green either way. So
|
||||
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-apache-tika:
|
||||
name: Publish Apache Tika Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'apache-tika'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-bentopdf:
|
||||
name: Publish BentoPDF Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'bentopdf'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-calibre-web-automated:
|
||||
name: Publish Calibre Web Automated Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'calibre-web-automated'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Publish ConvertX Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- convertx/**
|
||||
jobs:
|
||||
publish-convertx:
|
||||
name: Publish ConvertX Helm Chart
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'convertx'
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-cyberchef:
|
||||
name: Publish Cyberchef Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'cyberchef'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-freesailarr:
|
||||
name: Publish Freesailarr Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'freesailarr'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Publish Garage Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- garage/**
|
||||
jobs:
|
||||
publish-garage:
|
||||
name: Publish Garage Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
with:
|
||||
chart-name: 'garage'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-gotenberg:
|
||||
name: Publish Gotenberg Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'gotenberg'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Publish Helm Chart with patched Application Version
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
chart-location:
|
||||
required: false
|
||||
type: string
|
||||
default: "."
|
||||
description: "location of the helm chart directory"
|
||||
chart-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "name of the helm chart"
|
||||
workflow_call:
|
||||
inputs:
|
||||
chart-location:
|
||||
required: false
|
||||
type: string
|
||||
default: "."
|
||||
description: "location of the helm chart directory"
|
||||
chart-name:
|
||||
required: true
|
||||
type: string
|
||||
description: "name of the helm chart"
|
||||
outputs:
|
||||
chart-version:
|
||||
description: "the version of the published helm chart"
|
||||
value: ${{ jobs.publish-helm-chart.outputs.chart-version }}
|
||||
secrets:
|
||||
OP_CONNECT_TOKEN:
|
||||
required: true
|
||||
jobs:
|
||||
publish-helm-chart:
|
||||
outputs:
|
||||
chart-version: ${{ steps.get-chart-version.outputs.CHART_VERSION }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@v5
|
||||
- name: "Configure OP-Connect"
|
||||
uses: 1password/load-secrets-action/configure@v5
|
||||
with:
|
||||
connect-host: https://op-connect.jk-dev.de
|
||||
# helm package exits 1 with "found in Chart.yaml, but missing in charts/
|
||||
# directory" while a declared dependency has not been fetched, so this
|
||||
# comes first. It is a no-op for every chart without a dependencies:
|
||||
# block; ci/helm-dependencies.sh explains why it resolves from Chart.yaml
|
||||
# instead of a committed Chart.lock. Note the consequence for the
|
||||
# published artifact: the subchart .tgz is packaged INTO ours, so the
|
||||
# version pinned in Chart.yaml is the version operators get, and nothing
|
||||
# downstream ever sees the dependency again.
|
||||
- name: "Fetch chart dependencies"
|
||||
run: ${{ github.workspace }}/ci/helm-dependencies.sh "${{ inputs.chart-name }}"
|
||||
working-directory: ${{ inputs.chart-location }}
|
||||
- name: "Package Helm Chart"
|
||||
run: helm package ${{ inputs.chart-name }}
|
||||
working-directory: ${{ inputs.chart-location }}
|
||||
- name: "Get Helm Chart Version"
|
||||
id: get-chart-version
|
||||
run: |-
|
||||
CHART_VERSION=$(grep '^version:' ${{ inputs.chart-name}}/Chart.yaml | awk '{print $2}')
|
||||
echo $CHART_VERSION
|
||||
echo "CHART_VERSION=$CHART_VERSION" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ inputs.chart-location }}
|
||||
- name: "Load Chart Registry Credentials"
|
||||
id: load-registry-creds
|
||||
uses: 1password/load-secrets-action@v5
|
||||
env:
|
||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||
REGISTRY_PASSWORD: op://GitHub-Actions/jlab-registry-access/password
|
||||
REGISTRY_USERNAME: op://GitHub-Actions/jlab-registry-access/username
|
||||
REGISTRY_CHARTS_URL: op://GitHub-Actions/jlab-registry-access/charts-url
|
||||
# The target is a classic, index-based Helm registry, not an OCI one, so
|
||||
# there is no "helm registry login" session and no "helm push": the upload
|
||||
# is a single HTTP POST of the packaged .tgz to <charts-url>/api/charts,
|
||||
# authenticated per request. Three properties of this step are load-bearing
|
||||
# and each of them was paid for once already.
|
||||
#
|
||||
# 1) The values are mapped into the step's own env block and read as quoted
|
||||
# shell variables - never interpolated into the command line. load-secrets-
|
||||
# action v5 (bumped in #140) no longer exports the loaded values as
|
||||
# environment variables for later steps the way v4 did; only the step
|
||||
# outputs carry them. Every "${{ env.* }}" reference therefore resolved to
|
||||
# an empty string from that bump on, with nothing reporting an error - the
|
||||
# loading step logs "Populating variable" and stays green either way. The
|
||||
# ":?" guards fail loudly and name the op:// field that is missing, and
|
||||
# quoting keeps an empty value from silently shifting arguments.
|
||||
#
|
||||
# 2) The credentials never reach the process list. "curl --user $U:$P"
|
||||
# would put them in argv; "curl --config -" reads its options from stdin
|
||||
# instead, so nothing sensitive appears in any argument. The price of that
|
||||
# config file is its value syntax: an unquoted value ends at the first
|
||||
# space (measured on curl 8.5.0 - "has space" arrived as "has"), so the
|
||||
# step refuses whitespace in the credentials up front rather than letting
|
||||
# a truncated password come back as a puzzling 401. A Gitea access token
|
||||
# is 40 hex characters, so this only ever fires on a mis-entered item.
|
||||
#
|
||||
# 3) "--fail-with-body" is what makes a failed upload fail the job. Unlike
|
||||
# "helm push", curl exits 0 on HTTP 401/403/409 unless asked not to - the
|
||||
# publish would go green while nothing was uploaded. Measured against Gitea
|
||||
# 1.24.7: a 401 without the flag is exit 0, with the flag exit 22, and the
|
||||
# response body is still printed so the log names the cause.
|
||||
#
|
||||
# The file name is built from the chart name and the version read above
|
||||
# instead of a "<chart>-*.tgz" glob: --upload-file takes exactly one file,
|
||||
# and a glob matching two turns the second into curl's URL argument, while
|
||||
# a glob matching none is passed through literally. Both fail obscurely.
|
||||
# "helm package" keeps the "+" of a "1.4.0+up1.9.2" version verbatim in the
|
||||
# file name, so "<name>-<version>.tgz" is an exact match (measured).
|
||||
- name: "Upload Helm Chart to the Chart Registry"
|
||||
env:
|
||||
REGISTRY_PASSWORD: ${{ steps.load-registry-creds.outputs.REGISTRY_PASSWORD }}
|
||||
REGISTRY_USERNAME: ${{ steps.load-registry-creds.outputs.REGISTRY_USERNAME }}
|
||||
REGISTRY_CHARTS_URL: ${{ steps.load-registry-creds.outputs.REGISTRY_CHARTS_URL }}
|
||||
CHART_NAME: ${{ inputs.chart-name }}
|
||||
CHART_VERSION: ${{ steps.get-chart-version.outputs.CHART_VERSION }}
|
||||
run: |-
|
||||
: "${REGISTRY_CHARTS_URL:?empty - check op://GitHub-Actions/jlab-registry-access/charts-url}"
|
||||
: "${REGISTRY_USERNAME:?empty - check op://GitHub-Actions/jlab-registry-access/username}"
|
||||
: "${REGISTRY_PASSWORD:?empty - check op://GitHub-Actions/jlab-registry-access/password}"
|
||||
: "${CHART_NAME:?empty - the chart-name input did not arrive}"
|
||||
: "${CHART_VERSION:?empty - could not read version: from ${CHART_NAME}/Chart.yaml}"
|
||||
# 1Password masks the full URL, but a curl connection error prints only
|
||||
# "<host>:<port>", which that mask would not cover. Mask the host too.
|
||||
CHARTS_HOST="${REGISTRY_CHARTS_URL#*://}"
|
||||
echo "::add-mask::${CHARTS_HOST%%/*}"
|
||||
case "${REGISTRY_USERNAME}${REGISTRY_PASSWORD}" in
|
||||
*[[:space:]]*)
|
||||
echo "::error::username/password in op://GitHub-Actions/jlab-registry-access must not contain whitespace - curl --config truncates an unquoted value at the first space"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
CHART_PACKAGE="${CHART_NAME}-${CHART_VERSION}.tgz"
|
||||
test -f "$CHART_PACKAGE" || {
|
||||
echo "::error::${CHART_PACKAGE} does not exist - helm package produced: $(ls -1 ./*.tgz 2>&1)"
|
||||
exit 1
|
||||
}
|
||||
echo "uploading ${CHART_PACKAGE}"
|
||||
printf 'user = %s:%s\nurl = %s\n' \
|
||||
"$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" "${REGISTRY_CHARTS_URL%/}/api/charts" |
|
||||
curl --config - \
|
||||
--fail-with-body \
|
||||
--silent --show-error \
|
||||
--request POST \
|
||||
--upload-file "$CHART_PACKAGE" \
|
||||
--write-out '\nHTTP %{http_code}\n'
|
||||
working-directory: ${{ inputs.chart-location }}
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-homeassistant:
|
||||
name: Publish Homeassistant Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'homeassistant'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Publish Keycloak Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- keycloak/**
|
||||
jobs:
|
||||
publish-keycloak:
|
||||
name: Publish Keycloak Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
with:
|
||||
chart-name: 'keycloak'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-matrix-synapse:
|
||||
name: Publish Matrix Synapse Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'matrix-synapse'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-minecraft-router:
|
||||
name: Publish Minecraft-Router Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'minecraft-router'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-minecraft-server:
|
||||
name: Publish Minecraft-Server Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'minecraft-server'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-outline:
|
||||
name: Publish Outline Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'outline'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-paperless-ngx:
|
||||
name: Publish Paperless-NGX Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'paperless-ngx'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-patchmon:
|
||||
name: Publish Patchmon Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'patchmon'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-rallly:
|
||||
name: Publish Rallly Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'rallly'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-rss-bridge:
|
||||
name: Publish RSS-Bridge Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'rss-bridge'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-samba:
|
||||
name: Publish Samba Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'samba'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-satisfactory:
|
||||
name: Publish Satisfactory Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'satisfactory'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-searxng:
|
||||
name: Publish SearXNG Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'searxng'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Publish Stalwart Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- stalwart/**
|
||||
jobs:
|
||||
publish-stalwart:
|
||||
name: Publish Stalwart Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
with:
|
||||
chart-name: 'stalwart'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-sure:
|
||||
name: Publish Sure Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'sure'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-teamspeak-server:
|
||||
name: Publish TeamSpeak Server Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'teamspeak-server'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-urlaubsverwaltung:
|
||||
name: Publish Urlaubsverwaltung Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'urlaubsverwaltung'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-valkey:
|
||||
name: Publish Valkey Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'valkey'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-voucher-vault:
|
||||
name: Publish VoucherVault Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'voucher-vault'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-ytdlp-web-player:
|
||||
name: Publish YTDLP Web Player Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'ytdlp-web-player'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -9,8 +9,7 @@ on:
|
||||
jobs:
|
||||
publish-zipline:
|
||||
name: Publish Zipline Helm Chart
|
||||
uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'zipline'
|
||||
registry-owner: ${{ github.repository_owner }}
|
||||
secrets: inherit
|
||||
@@ -1,37 +0,0 @@
|
||||
name: SonarQube Analysis
|
||||
|
||||
# The analysis itself lives in workflow-util/pipelines, bound to a tag like
|
||||
# every other call to that repository. This hull only decides WHEN it runs.
|
||||
#
|
||||
# No "paths" filter, unlike the publish-<chart>.yml hulls: the analysis looks at
|
||||
# the whole tree - the five shell scripts under ci/, every workflow file, the
|
||||
# YAML and Helm templates, the values.schema.json files, and the secret
|
||||
# detection that covers everything. A filter would mean a change outside the
|
||||
# filter leaves the last result standing while claiming to be current.
|
||||
on:
|
||||
# push on main, and only main, because the SonarQube Community Build analyses
|
||||
# the main branch only - there is no pull-request analysis to hook into. The
|
||||
# consequence belongs said out loud rather than discovered: a finding appears
|
||||
# AFTER the merge. This is an observation of the state of main, not a gate,
|
||||
# and the shared block deliberately carries no quality-gate step.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# workflow_dispatch, and it is not decoration. Three things about this path
|
||||
# are unmeasured - whether node24 is in the runner image, whether the ~59 MiB
|
||||
# scanner download gets through (a 17 MB one once did not, MTU 1500 against a
|
||||
# pod network of 1450), and whether a repository variable reaches a workflow
|
||||
# called from another repository. Any of them may need more than one attempt,
|
||||
# and without a dispatch every attempt would cost a dummy commit on main.
|
||||
# Only the maintainer can trigger it; the bot account cannot dispatch.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sonar:
|
||||
name: SonarQube Analysis
|
||||
uses: workflow-util/pipelines/.gitea/workflows/sonar-analysis.yml@v4
|
||||
# secrets: inherit passes SONAR_TOKEN through. The block declares it as
|
||||
# required, and a missing required secret fails job CREATION on this
|
||||
# instance, not the job - the run would stand there with no steps at all.
|
||||
# SONAR_HOST_URL is a repository variable and needs nothing here.
|
||||
secrets: inherit
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
any-install: ${{ steps.detect.outputs.any-install }}
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: "Detect changed charts"
|
||||
@@ -116,11 +116,11 @@ jobs:
|
||||
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }}
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
|
||||
uses: azure/setup-helm@v5
|
||||
# Both steps below refuse to run while a chart declares a dependency that
|
||||
# is not in its charts/ directory - lint with "[WARNING] chart directory
|
||||
# is missing these dependencies", package with "found in Chart.yaml, but
|
||||
@@ -167,9 +167,9 @@ jobs:
|
||||
# build-chart, so it adds no wall-clock time to the pipeline.
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
|
||||
uses: azure/setup-helm@v5
|
||||
- name: "Render every chart with bare integer quantities"
|
||||
run: ci/run-quantity-render-test.sh
|
||||
|
||||
@@ -190,16 +190,16 @@ jobs:
|
||||
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }}
|
||||
env:
|
||||
# renovate: datasource=github-releases depName=aquasecurity/trivy
|
||||
TRIVY_VERSION: v0.75.0
|
||||
TRIVY_VERSION: v0.74.0
|
||||
# "report" always exits 0. Switch to "gate" to make a finding that no
|
||||
# documented exception covers fail the job - the intended step once the
|
||||
# seccomp gap (#84) is closed.
|
||||
TRIVY_CONFIG_MODE: report
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
|
||||
uses: azure/setup-helm@v5
|
||||
# A plain release download rather than aquasecurity/setup-trivy, and the
|
||||
# reason is measured on the Gitea probe PR of #225, not assumed. That
|
||||
# action runs actions/checkout against aquasecurity/trivy on github.com
|
||||
|
||||
@@ -85,7 +85,7 @@ helm template <chart-name> --set ingress.domain=example.com --set ingress.cluste
|
||||
|
||||
Copy `template-chart/` and replace every `xxx` placeholder — in file names (`templates/xxx.deployment.yaml` → `templates/<app>.deployment.yaml`), in `Chart.yaml`, in the `values.yaml` keys and their `.Values.xxx.*` references inside templates, and in `values.schema.json` (both the values paths and the `xxx.` helper prefixes). The schema and the null-safe fallback helpers come with the template but their content is chart-specific — adapt them along the six points of the README section "Values that must not lie", and validate the result against the chart's real bundle values, not only against its defaults. Then wire up CI:
|
||||
|
||||
1. Add `.github/workflows/publish-<chart-name>.yml` (copy an existing one, e.g. `publish-outline.yml`) — it triggers on pushes to `main` touching `<chart-name>/**` and calls the reusable `publish-helm-chart.yml`, which lives in `workflow-util/pipelines` and is bound to a tag. Copying an existing hull gets the `uses:` path, `registry-owner: ${{ github.repository_owner }}` and `secrets: inherit` right; writing one from memory does not.
|
||||
1. Add `.github/workflows/publish-<chart-name>.yml` (copy an existing one, e.g. `publish-outline.yml`) — it triggers on pushes to `main` touching `<chart-name>/**` and calls the reusable `publish-helm-chart.yml`.
|
||||
2. Add the chart name to the `chart-name` choice list in `.github/workflows/change-app-version.yml`.
|
||||
3. Add install-test fixtures under `ci/<chart-name>/` (`test-values.yaml`, `fixtures.yaml`) or an entry with justification in `ci/excluded-charts.txt` — the PR install-test fails if both are missing.
|
||||
4. Add a `customManagers` block for the chart's main image in `renovate.json5` (one per chart, since the image repository lives in the template and the version in `Chart.yaml`) — without it Renovate never updates that chart's app version. See README section "Dependency updates (Renovate)".
|
||||
@@ -117,4 +117,4 @@ The binding style guide for all current and future charts lives in **README.md (
|
||||
- Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly).
|
||||
- Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them.
|
||||
- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci/<chart>/` or a justified exclusion.
|
||||
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
|
||||
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` is the only chart using one). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
|
||||
@@ -17,31 +17,29 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
||||
| `apache-tika` | Apache Tika | Deployment + Service only (no ingress) |
|
||||
| `bentopdf` | BentoPDF | Stateless Deployment + `config.json` ConfigMap, `subPath`-mounted into the web root and always rendered (empty renders as `{}`) |
|
||||
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
|
||||
| `convertx` | ConvertX | StatefulSet + PVC at `/app/data` (SQLite, uploads and converted output in one volume), no external database. **The only chart that overrides the image's `command` to bypass its entrypoint**: the image carries no `USER` and its entrypoint exists solely to drop to uid 1000 with `gosu`, so the chart starts the app directly and lets `runAsUser`/`fsGroup` do that work instead — the hardening baseline stays intact, at the price that any logic upstream later moves into that entrypoint is skipped silently (re-read it at every app-version update; the reasoning sits at the `command:` line). Pins uid 1000 rather than the repo's 20000, because the image owns `/home/convertx` with it and the uid owns PV data. `readOnlyRootFilesystem: true` with two emptyDirs, `/tmp` and `/home/convertx` — the latter is `HOME`, which the chart also sets explicitly, because LibreOffice, Calibre and fontconfig build their profiles there on the first conversion. Probes use `/healthcheck` (static 200, unauthenticated); `/` is never a 200, it 302s to `/setup` or `/login`. `JWT_SECRET` is a required `OnePasswordItem` — unset, the app mints a fresh one per process and every restart logs everyone out. The ingress `proxy-body-size` annotation is the **only** upload ceiling, since the app enforces none. ~1.4 GiB image, hence a raised `INSTALL_TIMEOUT` |
|
||||
| `cyberchef` | CyberChef | Stateless Deployment |
|
||||
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr, and mediathekarr off by default) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service per app (mediathekarr's carries both of its ports); no global `ingress.domain`. Ingress in two shapes that mix: one host per app (default, each app's host required), or one shared `ingress.host` with the apps as sub-paths (documented ingress exception below) |
|
||||
| `garage` | Garage (S3-compatible object store) | **One** node, `replication_factor = 1` — a deliberate trade with its price named (Gitea #81): the volume layer underneath already replicates and encrypts, so Garage replication on top would be nine times the raw data across two layers that know nothing about each other. What that costs is that the factor applies to the **metadata** too, where a corrupted database is upstream-documented as "generally not recoverable" and a replicated volume replicates the corruption. Hence three settings that are mandatory rather than optional: `metadata_fsync = true` (**off** in Garage's defaults), the automatic metadata snapshots, and those snapshots on a **second PVC** — a file-system snapshot of a live metadata database is not a restore point, so the volume backup covers the blocks and not the index. The first PVC carries `metadata_dir` **and** `data_dir` as two subdirectories, because volume snapshots are atomic per volume: that is what keeps index and blocks consistent with each other. Two further properties shape the whole chart: the image is **`FROM scratch`** and contains exactly one file, `/garage` — no shell, so **exec probes are impossible here** and an init container would need a second image — and the RPC secret has to arrive as an **environment variable**, because `rpc_secret_file` rejects any file with a group bit set while the kubelet sets one (the reasoning sits at the value). Started with `garage server --single-node`, which creates the cluster layout in-process before any listener opens and is idempotent on the layout version, and with `--default-access-key`/`--default-bucket`, which **import** key and bucket from the 1Password item — so the vault is the source of truth instead of a copy of what the server generated. Readiness is the admin API's `/health`, liveness and startup deliberately are **not**: `/health` answers 503 until a layout exists, so a node waiting for one would be restarted forever |
|
||||
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
|
||||
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
|
||||
| `keycloak` | Keycloak (OpenID Connect / SAML identity provider) | **Two** replicas with `cache=ispn`, clustered through Keycloak's default `jdbc-ping` stack — discovery runs over the PostgreSQL that is there anyway, so no headless Service and no JGroups wiring. Deployment, **no PVC** (Keycloak 26 keeps everything in the database, user sessions included). **The chart has two init containers and needs both** (Gitea #83): `db`, `health-enabled` and `metrics-enabled` are Keycloak *build* options, the published image is built with their defaults, and a runtime change makes Keycloak rewrite `/opt/keycloak/lib/quarkus` at start — which `readOnlyRootFilesystem: true` forbids and which upstream describes as a start that "creates an image that is mutable". An empty `emptyDir` over that path does not work either, because `QuarkusEntryPoint` loads `quarkus/quarkus-application.dat` before it dispatches any command, `build` included. So one init container **seeds** the directory into a volume, the next runs `kc.sh build` into it, and the serving container runs `start --optimized` — every container keeping the full hardening baseline. Build options therefore exist in exactly **one** place and a build option in `keycloak.env` fails the render. First chart with a `PodDisruptionBudget` (§11) and first `quay.io` image (hence the entry in `ci/trivy/data/ksv0125.yaml`). Two Services (8080, and the management port 9000 **never** published — it serves `/health/*` and optionally `/metrics` unauthenticated) and two Ingresses. **The admin hostname is not an access control**: `hostname-admin` only decides which URLs the server generates, and upstream states that it "does not prevent accessing the Administration REST API endpoints via the frontend URL" — restricting that API is reverse-proxy work, and the chart renders no `/admin` deny rule. `ingress.domain` is read as `KC_HOSTNAME` (as a full URL, which upstream requires alongside `hostname-admin`), so it stays required with the Ingress off. Liveness is `/health/live` and deliberately not `/health` or `/health/ready` — those include the database, and a liveness probe on them restarts the whole cluster for as long as the database is away. **`keycloak.metrics.enabled` defaults to `true` for the readiness probe, not for the metrics**: upstream's table of health checks carries a "Requires Metrics" column and the *database* check is the row that says yes, so with it off `/health/ready` answers 200 while the database is gone — the `urlaubsverwaltung` shape (#218) with the dependency hidden behind an option whose name says observability. Two traps at the values: upstream's `db-pool-max-size` default of 100 would ask a default PostgreSQL for 200 connections across two replicas (the chart ships 20), and two releases sharing one database **merge into one Infinispan cluster** unless the cluster names differ |
|
||||
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
|
||||
| `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value |
|
||||
| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). **Only the newest Java line is maintained** (chart 5.0.0, #158): the directory carries one line, older ones stay published as frozen artifacts (java8/java17 at `4.0.0`, java21 at `4.1.0`) and receive no further chart changes — publishing once per line out of one directory is what made the lines drift. To run an older JVM with the current chart, set `image.tagOverride`; the chart then checks that `minecraft.version` fits the Java line of the **effective** tag and fails the render with both values named. Two cases pass unchecked on purpose: a tag without a `java<N>` part and a `minecraft.version` that is not a plain numeric `x[.y[.z]]`. `image.tagOverride` is deliberately outside Renovate's reach — `renovate.json5` tracks `appVersion` only. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: <release>-java8` / `-java17` / `-java21` / `-java25`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. Pilot for the strict `values.schema.json` (#68), which is now binding for every chart |
|
||||
| `outline` | Outline | StatefulSet, separate DB/Redis secrets |
|
||||
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects). Configured as a **queue** (PVC + `noeviction`), unlike `stalwart`'s cache: `PAPERLESS_REDIS` is the Celery broker, and `allkeys-lru` evicts across the whole keyspace, where a backlog of waiting consume tasks is exactly what LRU reads as cold. The password comes from the **same 1Password item** as the Redis connection, in a third field `auth-conf` holding the complete line `requirepass <password>` — so the password sits in that item **twice** and a rotation has to touch both fields. Two more traps: with the subchart on, `redis-connection-url` in the vault **must** be repointed at `<release>-valkey-service:6379` or the bundled Valkey runs unused, and `valkeySubchart:` must not be erased — a null override deletes the key, Helm reads an unresolvable `condition:` as *enabled*, so erasing the switch turns the subchart **on** (rejected by `values.schema.json`). Never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
|
||||
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap |
|
||||
| `patchmon` | PatchMon | One Deployment (the 2.x server serves frontend and `/api/*` from one process) plus an optional guacd sidecar; OIDC-only sign-in |
|
||||
| `rallly` | Rallly | Deployment, DB/OIDC/SMTP secrets |
|
||||
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) |
|
||||
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
|
||||
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
|
||||
| `searxng` | SearXNG | Stateless Deployment + settings ConfigMap (`use_default_settings: true` plus a free-form `settings.extra` passthrough); the signing key is a required `OnePasswordItem`, an external Valkey an optional second one. `ingress.domain` is read twice — Ingress host and `SEARXNG_BASE_URL` — so it stays required with the Ingress switched off |
|
||||
| `stalwart` | Stalwart mail server (SMTP, IMAP, POP3, JMAP, ManageSieve) | **The chart configures almost nothing, and that is the chart's defining property.** Since v0.16 Stalwart keeps its whole configuration — every listener, the blob store, the in-memory store, TLS objects, domains and the cluster roles — inside the data store and manages it over JMAP; the only file on disk is a `config.json` that names the data store and nothing else. So the chart renders that ConfigMap (secret-free: `authSecret` uses the `EnvironmentVariable` variant, so the password arrives by `secretKeyRef` and `checksum/config` stays meaningful), one StatefulSet per entry of the `nodes` **map** with `STALWART_ROLE` and `STALWART_HOSTNAME`, one NodePort Service **per port** (not per group — a NodePort is cluster-unique, so `full` and `mx` can only take SMTP behind the same Service, selected by a per-port pod label), a per-group ClusterIP `http` Service for the Ingress, per-group headless Services and NetworkPolicies, and a cert-manager `Certificate` for the mail ports. Everything else is operator work in the WebUI, which is why `NOTES.txt` carries an eight-step first install. Four traps worth knowing before reading the chart: **the role lookup fails open** — a node whose `ClusterRole` is missing from the database enables every listener *and* every task while staying Running with a green probe, so `mx` ships at `replicas: 0` and the NetworkPolicy is a ceiling independent of what was bound; **`NET_BIND_SERVICE` is a documented deviation needed to `exec`, not to bind** (the image sets `setcap cap_net_bind_service=+ep`, so `drop: [ALL]` alone makes the kernel fail `execve` — removing it as "unused" stops the chart starting); **`externalTrafficPolicy` must be `Cluster`** (no pod runs on a control-plane node while the HA proxy delivers to them, so `Local` would drop every packet) and the source address it costs is restored only by the HA proxy's PROXY v2 header; and **`/healthz` is rate-limited to 100 req/60s per source IP**, which is a node IP for kubelet probes, so the probe intervals are calculated rather than chosen. A first install needs `bootstrap.enabled: true` because the setup wizard appears only when `config.json` is *absent*. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects); never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
|
||||
| `sure` | Sure (we-promise, the maintained Maybe-finance fork) | One StatefulSet with **two containers**: the Puma web process and a Sidekiq worker, sharing one ReadWriteOnce PVC at `/rails/storage` — that shared volume is the reason they live in one pod. Only the web container migrates (the image entrypoint runs `db:prepare` solely for the `./bin/rails server` command, so the worker is started through `args:` and skips it). Three 1Password items (app/database/redis); Postgres and Redis stay external. Readiness deliberately probes `/` rather than Rails' `/up`, which is a boot check and touches no database; the worker's three probes ask `sidekiqmon` for this pod's own heartbeat. `ingress.domain` is read for `APP_DOMAIN` and the WebAuthn relying party, so it stays required with the Ingress switched off |
|
||||
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
|
||||
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
|
||||
| `urlaubsverwaltung` | Urlaubsverwaltung | Deployment, DB + OIDC secrets |
|
||||
| `valkey` | Valkey | One instance per application (cache or queue backend), replacing the shared `cluster-redis` whose single ACL file coupled six applications together. StatefulSet with one replica + ClusterIP Service, no cluster mode, no Ingress and no value to add one. The password arrives as a **finished configuration line** (`requirepass <secret>`) in the 1Password field `auth-conf`, is mounted as a file and `include`d by the config — never in argv, never in the environment; an init container refuses to start the pod unless that line is really there, because the alternative failure is an open server that looks healthy. `maxmemory` is **derived from the computed memory limit** and a configured value above it aborts the render. Persistence and eviction are a pair, not two switches (queue = PVC + `noeviction`, the default; cache = emptyDir + `allkeys-lru`). First chart carrying a **NetworkPolicy** (on by default, Gitea #5), and the first whose probes had to be proven capable of failing: `valkey-cli` exits 0 on an error reply unless `-e` is given |
|
||||
| `voucher-vault` | VoucherVault | StatefulSet, DB + OIDC secrets |
|
||||
| `ytdlp-web-player` | ytdlp_web_player | Stateless Deployment, video cache and tool cache as emptyDirs (no PVC). **The first chart with a dependency** (`stalwart` and `paperless-ngx` followed): an optional `oauth2-proxy` subchart, off by default, wired in front of the app because the application ships no authentication at all — see "Chart dependencies" below |
|
||||
| `ytdlp-web-player` | ytdlp_web_player | Stateless Deployment, video cache and tool cache as emptyDirs (no PVC). **The only chart with a dependency**: an optional `oauth2-proxy` subchart, off by default, wired in front of the app because the application ships no authentication at all — see "Chart dependencies" below |
|
||||
| `zipline` | Zipline | StatefulSet, DB secret |
|
||||
|
||||
## Versioning
|
||||
@@ -54,32 +52,23 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
||||
## CI/CD
|
||||
|
||||
- `publish-<chart>.yml` — one thin workflow per chart; triggers on pushes to `main` touching `<chart>/**` (or manually) and calls the reusable workflow below. **A push to a chart directory on `main` publishes that chart**, so the chart version must be bumped in the same change.
|
||||
- `publish-helm-chart.yml` — **not in this repository any more.** The reusable workflow lives in [`workflow-util/pipelines`](https://git.jk-dev.de/workflow-util/pipelines), the shared building-block repo, and the callers here bind it to a tag: `uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3` (Gitea #66). It still does the same thing — `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry, which is a **classic, index-based** Helm registry and not an OCI one, so `helm push` is not used, there is no `helm registry login` session, and every request carries its own Basic auth. What changed is who supplies what:
|
||||
- **`registry-owner: ${{ github.repository_owner }}`** in each caller's `with:`. The owner used to be derived inside the workflow (#63); it is now a required input with no default, so the expression is evaluated **here**, in the calling repository, where the value is measured. That is `pipelines`' house rule — a shared block never guesses an owner segment — and it is why every caller carries that one extra line.
|
||||
- **`secrets: inherit`**, unchanged, for `OP_CONNECT_TOKEN`.
|
||||
- the repository **variable `OP_CONNECT_HOST`**, which replaced the Connect host that used to be hardcoded in our copy. A shared block must not carry one tenant's infrastructure address; the block's first step fails and names the variable when it is unset.
|
||||
- `publish-helm-chart.yml` — reusable workflow: `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry. The target is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (`OP_CONNECT_TOKEN` secret, item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`); the workflow appends `/api/charts` to `charts-url`. No registry address ever appears in this repository. The loaded values are read via **`steps.<id>.outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error. Three properties of the upload step are load-bearing:
|
||||
- **Credentials stay out of the process list.** `curl --user "$U:$P"` would put them in `argv`; the step uses `curl --config -` and pipes the options in on stdin instead.
|
||||
- **A failed upload fails the job.** Unlike `helm push`, `curl` exits **0** on HTTP 401/403/409 — a publish would go green with nothing uploaded. `--fail-with-body` turns those into a non-zero exit and still prints the response body, so the log names the cause.
|
||||
- **The file name is built, not globbed.** `<chart>-<version>.tgz` is assembled from the chart name and the version already read from `Chart.yaml`; `--upload-file` takes exactly one file, and a glob matching two silently turns the second into curl's URL argument. `helm package` keeps the `+` of a `1.4.0+up1.9.2` version verbatim in the file name, so the built name is an exact match.
|
||||
|
||||
The registry credentials and the target URL still come from 1Password Connect (item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`). `charts-url` holds only the **base up to `/api/packages`** and the block composes `<charts-url>/<owner>/helm/api/charts`, so **the publish target and its credentials appear nowhere in this repository.** `ci/helm-dependencies.sh` is still run before `helm package`, and it is still **our** script: the block's checkout clones the calling repository, so it runs from this tree.
|
||||
|
||||
**What this does *not* claim any more**: that no registry address is in the repo at all. That wording was true until a chart took one of our own published charts as a subchart — `stalwart`'s `Chart.yaml` names `https://git.jk-dev.de/api/packages/jlab-cluster/helm` as a dependency `repository:`, because a Helm dependency is resolved from a URL in the chart and there is nowhere else to put it. The narrower claim above is the one that matters and it is unchanged: that is the **read** path, the index is anonymously readable (measured), so it carries **no credential** and grants nothing a `git clone` of this repo does not already imply. The host itself has always been in `CLAUDE.md`; what is new is only the read-only path segment.
|
||||
|
||||
The reasons behind the upload step are **not repeated here**, because they are no longer ours to keep current: why both halves of the composed URL are guarded, why the credentials go in through `curl --config -` instead of `--user`, why `--fail-with-body` is what makes a failed upload fail the job, and why the file name is built rather than globbed, are all on the [`publish-helm-chart` wiki page](https://git.jk-dev.de/workflow-util/pipelines/wiki/publish-helm-chart) of that repository. Nothing was dropped in the move — the upload step's shell body went across unchanged except for two error messages that name the owner as an input.
|
||||
|
||||
Note: the registry **accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact, so a republish of an existing version is still silent — see #160. That is also what makes a manual `workflow_dispatch` of any `publish-<chart>.yml` a harmless way to prove the shared block works.
|
||||
Note: the registry **accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact, so a republish of an existing version is still silent — see #160.
|
||||
- `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list.
|
||||
- `ci/helm-dependencies.sh <chart-dir>` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but three: `ytdlp-web-player`, `stalwart`, `paperless-ngx`). It runs in **five** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in the shared publish block (which is why that block names this script as a prerequisite of its callers — see above), and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs.
|
||||
- `ci/helm-dependencies.sh <chart-dir>` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but one). It runs in **five** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in `publish-helm-chart.yml`, and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs.
|
||||
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix, or run the all-charts install-test after the merge. The **resource-quantity render test** is the exception to that rule: it runs over every chart on every PR — see below.
|
||||
- `install-test.yml` — reusable workflow (`workflow_call`) holding the install-test **job** itself: the runner-label expression, the pinned `kubectl` with its skew check, `envsubst`, the containerised-job detection, the per-leg k3d cluster and the `freesailarr` VPN credentials. Its one input is a JSON array of chart names; its callers decide *which* charts are tested and whether the job runs at all, never *how*. Two callers: `validate-charts.yml` and `install-test-all-charts.yml`. **It is not copied for the second caller** — a second copy is a second chance to test differently, unnoticed, the same argument that gives `ci/excluded-charts.txt` exactly one parser.
|
||||
|
||||
**It stays in this repository, and that is a decision, not an oversight** (Gitea #66, decision 4). It is reusable in form but not in substance: it is welded to the `ci/` fixtures, to the `ci/excluded-charts.txt` parser and to this repository's chart matrix. A building block that only one repository can use is not shared, it is merely moved somewhere else — and moving it would buy nothing while putting a tag between us and a file we change often. `publish-helm-chart.yml` was the opposite case: it takes a chart name and a registry owner and knows nothing about this repository, which is exactly why it could go. This note exists so the question is not asked again in half a year.
|
||||
- `install-test-all-charts.yml` — manually dispatched **all-charts install-test**: the same job, with a matrix over every installable chart instead of the changed ones. Optional input for a subset of charts (empty = all). See below for when to run it.
|
||||
- `sonar.yml` — **SonarQube analysis**, a hull like the publish ones: it triggers on pushes to `main` (no `paths` filter — the analysis looks at the whole tree) or manually, and calls `workflow-util/pipelines/.gitea/workflows/sonar-analysis.yml@v4` with `secrets: inherit`. The project-specific half is `sonar-project.properties` in the repo root. **It is not a gate** and it does not block anything — see below.
|
||||
|
||||
### Where this repo lives, and what a Gitea runner does differently
|
||||
|
||||
Everything below was measured on this instance, not taken from documentation, and it is the reason for every deviation in `validate-charts.yml`. How the move got here is not repeated — that is Gitea #14.
|
||||
|
||||
**Reusable workflows with `secrets: inherit` work**, so the 25 thin `publish-<chart>.yml` hulls run unchanged — including now that the workflow they call lives in another repository (Gitea #66). One quirk worth knowing before debugging the wrong thing: a *missing* secret does not fail the job, it fails job **creation**, so the run shows no steps at all.
|
||||
**Reusable workflows with `secrets: inherit` work**, so the 24 thin `publish-<chart>.yml` hulls run unchanged. One quirk worth knowing before debugging the wrong thing: a *missing* secret does not fail the job, it fails job **creation**, so the run shows no steps at all.
|
||||
|
||||
**Runner labels mean different machines than on GitHub.** `ubuntu-latest` here has **no Docker socket**; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — and its `runs-on` is therefore an expression rather than a fixed label, so the same file still works on a GitHub runner where `ubuntu-docker` does not exist. Every other job (lint/package, the quantity render, the Trivy scan) needs no socket and stays on `ubuntu-latest`.
|
||||
|
||||
@@ -156,7 +145,7 @@ Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the ch
|
||||
- `ci/<chart>/settings.env` — optional overrides: `INSTALL_TIMEOUT` for slow starters, and `REQUIRED_ENV="VAR_A VAR_B"` to declare the variables `fixtures.env.yaml` needs. **A missing or empty declared variable fails the install-test loudly, naming the variable — it never skips the chart.** Skipping would restore exactly the green-but-meaningless signal this mechanism exists to remove; the one legitimate case of absent secrets, a PR from a fork, belongs in an `if` on the workflow job rather than in the script
|
||||
- `ci/excluded-charts.txt` — charts that cannot run in CI, one per line as `<chart> <justification>`. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `template-chart` (scaffold with placeholder image). The justification is mandatory: a line carrying only a chart name is deliberately **not** an exclusion, because a bare name would drop a chart out of CI with nothing to review. **An excluded chart gets no install-test job at all** — see below
|
||||
|
||||
`freesailarr` is the only chart using `fixtures.env.yaml` today: gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The install-test job loads the key from 1Password Connect for that chart only, using the same pattern as the shared publish block — `steps.<id>.outputs.*` into the step's own `env:`, quoted, with `:?` guards that name the missing `op://` field before the cluster is even created. The key in that item is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make every PR run pull the tunnel out from under the running stack — with the symptom in the cluster and the cause in a pull request.
|
||||
`freesailarr` is the only chart using `fixtures.env.yaml` today: gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The install-test job loads the key from 1Password Connect for that chart only, using the same pattern as `publish-helm-chart.yml` — `steps.<id>.outputs.*` into the step's own `env:`, quoted, with `:?` guards that name the missing `op://` field before the cluster is even created. The key in that item is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make every PR run pull the tunnel out from under the running stack — with the symptom in the cluster and the cause in a pull request.
|
||||
|
||||
**An excluded chart is dropped from the matrix, not skipped inside its job (#231).** It used to get a job that ran the script, printed `SKIP` and exited 0 — and GitHub knows only the exit code, so the check list showed `Install-test satisfactory ✓` for a chart whose image was never pulled, indistinguishable from one that really came up in the cluster. `detect-changed-charts` now removes the excluded charts before the matrix is built, so no job exists for them and nothing claims they passed. Because an absence on its own says nothing, that job writes every excluded chart of the PR **with its documented justification** into the job summary — a `Skipped` would only say "not run", and why is the question a review actually asks. The list is parsed in exactly one place: `ci/run-install-test.sh --list-excluded` emits `<chart><TAB><reason>` and the workflow calls it instead of re-implementing the format in YAML, since a second parser would be a second chance to select different charts — silently, and in either direction. The script keeps its own `SKIP` branch for local runs. What cannot be done instead: deciding per chart in an `if` on the install-test job, to get a visible `Skipped` entry rather than an absent one. GitHub rejects it with `context "matrix" is not allowed here` — `jobs.<id>.if` is evaluated **before** the matrix is expanded, so the `matrix` context does not exist there, and the whole workflow ends in failure with zero jobs created (measured on the closed PR #233).
|
||||
|
||||
@@ -175,32 +164,7 @@ Why it exists at all: the answer arrived once by accident. Gitea PR #48 correcte
|
||||
- **Exclusions come from the one parser**, `ci/run-install-test.sh --list-excluded`, and the excluded charts appear in the job summary **with their justification**, exactly as in the PR pipeline.
|
||||
- **The result is written down.** Every matrix leg records its own outcome next to the four facts that decide what the outcome means — date, runner label, `K3S_IMAGE`, `kubectl` version — and a final job records the verdict over the whole matrix. A green run nobody notes is unknown again next time. Per leg and not as one table, because a matrix leg only ever sees its own job summary.
|
||||
- **Expected runtime: 20 charts in 12m41s** (Gitea PR #48, one job, all legs concurrently on one Docker daemon). The figure is there so a later deviation is visible as one; compare it against the chart count the run prints in its own summary — 24 at the time of writing, and one more with every new chart. The job timeout is 30 minutes per leg and lives in `install-test.yml`, i.e. it is the same for both callers on purpose.
|
||||
|
||||
### SonarQube analysis (after the merge, and not a gate)
|
||||
|
||||
`sonar.yml` sends the whole tree through a SonarQube analysis. The workflow itself is the shared block [`sonar-analysis.yml`](https://git.jk-dev.de/workflow-util/pipelines/wiki/sonar-analysis) in `workflow-util/pipelines`, bound to `@v4`; the only thing on this side is `sonar-project.properties` in the repo root, holding the project key `JLab-Helm-Charts`. **That key is read off the existing project in SonarQube, not derived** — it is the one the maintainer set up, and a guessed key (`jlab-cluster_helm-charts`, reasoned from the instance serving two owners) is what made the first run fail. The file carries **no `sonar.projectName`** on purpose: once the project exists, the display name belongs to the server, and a name sent with every analysis would overwrite a rename there silently.
|
||||
|
||||
**What is analysed — measured, not taken from a language list.** The first run (run 375) loaded exactly **two quality profiles, `json` and `yaml`**; the sensors that did work were **IaC Kubernetes** (3.4 s) and the **secrets detection** (`TextAndSecretsSensor`) over all 391 indexed files.
|
||||
|
||||
| What is in this repository | Analyser | Runs? |
|
||||
|---|---|---|
|
||||
| 238 YAML files and 26 Helm template directories | IaC Kubernetes, YAML | yes |
|
||||
| 26 `values.schema.json` | JSON | yes |
|
||||
| the whole tree (391 files) | Secrets | yes |
|
||||
| the 5 shell scripts under `ci/` — the whole CI logic | Shell | **no** |
|
||||
| the 30 workflow files | GitHub Actions | **no** |
|
||||
|
||||
**The two missing rows are not a configuration mistake, and nothing in this repository can fix them: analysers come from the *server*, not from the scanner.** The language list the earlier version of this table was built from describes the newest SonarQube release; this instance does not have the Shell and GitHub Actions plugins. The consequence is worth stating plainly rather than leaving it to be rediscovered — **the files carrying the actual logic, the five `ci/` scripts and the thirty workflows, are not analysed at all.** What runs is IaC over the charts plus secret detection over everything. Whether the instance gets upgraded or gains those plugins is a decision for whoever runs it, not something this repository settles; `shellcheck` and review remain the only check on the scripts until then.
|
||||
|
||||
The secrets row is the valuable one that *does* run. **The premise of this repository is that no credential ever lands in it** — every secret is a 1Password `OnePasswordItem` reference — and until now nothing checked that premise. What it actually reports is still unknown: run 375 built the report (603 kB) but never uploaded it, because the upload is where the wrong project key failed.
|
||||
|
||||
**It is not a gate, and that is a decision.** The SonarQube Community Build analyses the **main branch only**; there is no pull-request analysis to hook into, so the trigger is a push to `main` and **a finding appears after the merge**. At that point there is nothing left to block, which is why the shared block carries no quality-gate step: a red gate behind the merge would only colour `main` red without having prevented anything, and a permanent red is a thing people learn to ignore — taking the credibility of `validate-charts` with it. A gate becomes the right thing under one of two conditions: pull-request analysis is available, or a deployment hangs off the workflow. Until then, read the findings in SonarQube; nothing in this repository turns red because of them.
|
||||
|
||||
**No exclusions, for the first round.** Including the Helm templates, which are not valid YAML because of `{{ }}`. What the Helm and YAML analysers make of them is only learnable by letting them run over them once, and excluding them up front would discard that answer along with the bulk of the repository. If the first run produces noise genuinely not worth reading, the exclusions go into `sonar-project.properties` with the finding they answer named next to them — not before.
|
||||
|
||||
**Prerequisites**, both on the instance and neither in a file here: the repository **variable `SONAR_HOST_URL`** (a variable and not a secret on purpose — the server address is not a credential, and as a variable it stays readable in the log, which is what makes a wrong one diagnosable) and the **secret `SONAR_TOKEN`**, which reaches the block through `secrets: inherit`. The block guards both and names the setting and its place when one is empty. A *missing* `SONAR_TOKEN` never gets that far: the block declares it `required`, and a missing required secret fails job **creation** on this instance, so the run would show no steps at all — the same quirk as everywhere else here.
|
||||
|
||||
**Three things about this path were unmeasured, and run 375 answered all three — positively.** `node24` **is** in the runner image, so the JavaScript scan action runs. The scanner download **gets through**: ~59 MiB of archive plus its signature in roughly three seconds, so the MTU failure that once killed a 17 MB download on this runner (see "What a GitHub runner had been providing quietly") does not repeat at five times the size. And a repository **variable does reach a workflow called from another repository** — the block's guard passed and the scanner started, which it could not have done without `vars.SONAR_HOST_URL` arriving. That last one also settles the identical question the publish route carried for `OP_CONNECT_HOST`: same forge version, same mechanism, a repository variable read inside a workflow called from another repository. What the publish route still has unproven is the upload itself, which has never run. `workflow_dispatch` stays on the hull for the retries: without it every attempt costs a dummy commit on `main`.
|
||||
- **A big image does not move that figure as much as it looks.** `convertx` was expected to, at ~1.4 GiB compressed — by a wide margin the largest image in the repo, and the reason its `settings.env` raises `INSTALL_TIMEOUT` to 900s. Measured on its first green run (Gitea PR #61) the whole leg took **2m35s**, cluster creation and rollout wait included, so it sits inside the existing envelope rather than setting a new one. Worth knowing before the next raised timeout is read as a prediction about the total: the legs run concurrently, so what the all-charts figure tracks is the **slowest** leg plus scheduling, not the sum of the pulls.
|
||||
|
||||
## Dependency updates (Renovate)
|
||||
|
||||
@@ -386,8 +350,6 @@ These guidelines are **binding for every current and future chart** in this repo
|
||||
- Every Deployment/StatefulSet has a `replicas` value (default `1`, the values path per workload matches its section) that also accepts an explicit `0` for per-workload fine control.
|
||||
- Every chart has a global `scaleDown` flag (default `false`): `scaleDown: true` takes precedence over all `replicas` values, renders every Deployment/StatefulSet with `replicas: 0` and suspends CronJobs. It **stops** the app but does not uninstall it — PVCs, Secrets, Services and Ingress stay in place; setting it back to `false` brings the workloads back up.
|
||||
- Implemented via a per-chart `<chart>.replicas` helper in `_helpers.tpl` (reference in `template-chart`); the helper uses an explicit nil check (`kindIs "invalid"`) because Helm's `default` would swallow an explicit `0`.
|
||||
- **A chart whose default is more than one replica ships a `PodDisruptionBudget`** — `policy/v1`, named `<release>-<chart>-pdb`, `minAvailable` as a value (default `1`), selector matching the workload's `app` label. `keycloak` (Gitea #83) is the first and sets the convention. The reason it is a rule and not a per-chart taste: a node drain is the most common reason a pod moves — it is what a node upgrade does — and without a budget the drain is free to take every replica at once, so the second replica buys nothing against the failure it was added for.
|
||||
- **And it is rendered only while the effective replica count is `>= 2`.** `minAvailable: 1` with exactly one replica forbids every voluntary eviction, so `kubectl drain` blocks forever on a pod nothing may move — and the message names the PDB, not the replica count, which sends the reader to the wrong file. The same applies to `scaleDown: true`, where there are no pods and the object would describe a promise about nothing. So the condition is on the **computed** replica count (the `<chart>.replicas` helper), not on the raw value, and `NOTES.txt` prints which of the two happened rather than leaving it to be inferred from `kubectl get pdb`.
|
||||
|
||||
### 12. Values that must not lie (#68, #93, #99, #215)
|
||||
|
||||
@@ -415,9 +377,7 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
|
||||
|
||||
The other half of the same rule: readiness **must** hang on the dependency, and a separate path is not proof that it does. `urlaubsverwaltung` probed `/actuator/health/liveness` and `/actuator/health/readiness` for two years and still stayed 1/1 Ready through a total database outage (#218), because Spring Boot's `readiness` group contains `readinessState` **alone** — the split paths were right, the group content was empty of anything external. Whatever aggregates behind a probe path (Spring health groups, a `/healthz` a framework assembles for you) has to be read off the **running** endpoint, not off the path name; the chart names the members explicitly (`MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db`) so the composition is in the template rather than in an upstream default.
|
||||
|
||||
**4. A fallback reproduces *this chart's* defaults, not the repo standard.** Several charts deviate from the hardening baseline on purpose (no count here — the previous wording named one and went stale the next time a chart landed). A "cleaned up" standard default is not tidying, it is an outage: `calibre-web-automated` must keep `readOnlyRootFilesystem: false`, because in read-only mode the image disables its PUID/PGID remapping and the runtime uid silently moves from 1000 to 911 — **making existing volume data inaccessible**. The same applies to non-standard values that look like mistakes: `satisfactory` keeps `limitFactor: 2` (that is what holds the memory limit at the published 8Gi) and `fsGroupChangePolicy: OnRootMismatch` (which avoids a recursive chown of multi-GB game files on every start).
|
||||
|
||||
**The sharpest case is a capability that is granted and never used.** `stalwart`'s fallback has to carry `capabilities.add: [NET_BIND_SERVICE]`, because the image sets `setcap cap_net_bind_service=+ep` on its binary — which makes it a capability-dumb binary, and `capabilities(7)` then has the kernel fail the **`execve` itself** when the bounding set does not carry the capability: *"If the process did not obtain the full set of file permitted capabilities, then `execve(2)` fails with the error `EPERM`."* So `drop: [ALL]` with the standard empty `add` does not produce an unhardened container, it produces `exec /usr/local/bin/stalwart: operation not permitted` and a chart that cannot start. The capability is then spent entirely on the exec check and is never used to bind anything, which is exactly what makes it a magnet for a hardening pass looking for dead grants. This is why a deviation is documented with its **mechanism** and not only its name: "needed for binding" would have been wrong and would have invited the removal.
|
||||
**4. A fallback reproduces *this chart's* defaults, not the repo standard.** Five charts deviate from the hardening baseline on purpose. A "cleaned up" standard default is not tidying, it is an outage: `calibre-web-automated` must keep `readOnlyRootFilesystem: false`, because in read-only mode the image disables its PUID/PGID remapping and the runtime uid silently moves from 1000 to 911 — **making existing volume data inaccessible**. The same applies to non-standard values that look like mistakes: `satisfactory` keeps `limitFactor: 2` (that is what holds the memory limit at the published 8Gi) and `fsGroupChangePolicy: OnRootMismatch` (which avoids a recursive chown of multi-GB game files on every start).
|
||||
|
||||
**5. A probe default carries the probe's context, not just its timings.** Two different symptoms, both from real charts: `teamspeak-server` addresses its TCP port by the name `file-transfer` because the voice service is UDP and cannot be probed — a default without that name leaves the workload **with no working probe at all**. `patchmon` sends a `Host` header because the server answers **403** to any request whose Host is neither loopback nor a CORS_ORIGIN host — a default without it means the pod never becomes Ready. Port names, `Host` headers and `exec` commands belong in the fallback; where the context is dynamic, pass the root context in and rebuild it (patchmon derives the header from `ingress.domain`).
|
||||
|
||||
@@ -444,20 +404,6 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
|
||||
|
||||
Until `ytdlp-web-player` (#214) this section read "charts have no dependencies (`charts/` directories are empty)". That rule is gone, replaced by a bar rather than a ban — a dependency is **admissible, not free**, and the reason it stopped being a ban is worth keeping: the chart needed an oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other.
|
||||
|
||||
Three charts carry one, and comparing them is the quickest way to see where the bar sits. `ytdlp-web-player` takes **upstream's** `oauth2-proxy` because the app has no authentication and the two are useless apart; there, point 2 above applies in full. `stalwart` (Gitea #65) takes **one of our own published charts**, `valkey`, because Stalwart's **cluster coordinator rides on the in-memory store connection**, so a multi-node install does not work without one. `paperless-ngx` (Gitea #78) takes the same one, because `PAPERLESS_REDIS` is the Celery broker and the application therefore cannot run without a Redis at all — before the subchart, every installation needed a second Helm release whose address lived in a 1Password item and in neither chart. All three are `condition:`-gated and off by default: the subchart is the path for an installation that has no Valkey yet, and one that already has points its connection value at that instead, so the dependency renders nothing.
|
||||
|
||||
**The same subchart, opposite configuration — and that comparison is the useful part.** `stalwart` runs it as a **cache** (`persistence.enabled: false` + `allkeys-lru`): its in-memory store holds rate-limiter counters, tokens and pub/sub, all reconstructible, and the cost it accepts is that the limiters fail open under memory pressure. `paperless-ngx` runs it as a **queue** (PVC + `noeviction`, the subchart's defaults): the broker's contents are work, and a dropped consume task is a document that is silently never processed. `allkeys-lru` would not be a trade there but actively wrong, because LRU evicts typeless across the whole keyspace and a backlog of waiting tasks is exactly what it reads as cold. The price `paperless-ngx` accepts instead: the same instance also serves the Django cache and the Celery result backend (both Redis in 3.x), so a full instance **refuses writes** — new tasks included — rather than dropping cold keys, and the answer is memory sizing, not a policy change. The valkey chart treats the two values as a **pair** and warns against mixing them; these two charts are the worked examples of picking either half deliberately.
|
||||
|
||||
**A dependency on one of our own charts is cheaper in one way and more dangerous in another.** Point 2 stops applying: there are no third-party defaults to leave unpatched, because the subchart follows these guidelines by being one of ours. In exchange it adds a hazard an external dependency does not have, and it is sharp enough to be a rule:
|
||||
|
||||
> **Never change a chart and a chart that depends on it in the same pull request.**
|
||||
|
||||
The dependency resolves from the **published index**, not from the working tree, and a chart is published only by a push to `main` that touches it. So a combined PR runs every `helm` command — lint, package, render **and the install-test** — against the **old** subchart, goes green on an artifact that is not the one in the diff, and leaves the subchart's own change exercised by nothing. Two consecutive PRs: change the subchart, merge, let it publish, then raise the `version:` in the dependent chart.
|
||||
|
||||
Two mechanical details of a self-referencing dependency, both measured for #65: the index is **anonymously readable**, so `ci/helm-dependencies.sh` needs no credential and resolves the exact build-metadata version (`1.0.0+up9.1.2.alpine`) from the URL alone with an empty `HELM_REPOSITORY_CONFIG` — the state a fresh runner is in. And Renovate's built-in `helmv3` manager does follow a self-hosted Gitea index: a dry run (Renovate 44.132.2) with the dependency pinned low reported `newVersion: "1.0.0+up9.1.2.alpine"`, `versioning: "helm"`, timestamp read from our index. Without that second result the published-version form would have had no update path at all, which is why it was measured rather than assumed.
|
||||
|
||||
One more trap, and it is the reason `stalwart` gates its subchart on `valkeySubchart.enabled` rather than the conventional `valkey.enabled`: **everything under the subchart's key is handed to the subchart as its values**, so a `condition:` key placed there is validated against *its* schema. Our charts ship `additionalProperties: false`, so `valkey.enabled` is rejected by the subchart — and because Helm skips a **disabled** subchart's schema validation, the error surfaces only for whoever first switches it on. Put the condition on a key of the parent's own.
|
||||
|
||||
**When a dependency earns its place.** All three, not two of three:
|
||||
|
||||
1. The dependent piece would otherwise be a second Helm release that only exists to serve this one, with the wiring between them living in neither chart.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Fixtures for the convertx install-test: the Secret the 1Password operator
|
||||
# would otherwise materialize from the OnePasswordItem (release name: ci).
|
||||
#
|
||||
# Without it the pod stays in CreateContainerConfigError - the StatefulSet
|
||||
# reads JWT_SECRET from this Secret's "jwt-secret" key, and the stub
|
||||
# OnePasswordItem CRD in ci/common/ has no controller behind it. The key name
|
||||
# here is therefore load-bearing and has to match the chart's secretKeyRef.
|
||||
#
|
||||
# ConvertX needs no external database, cache or storage service: its SQLite
|
||||
# file, uploads and output all live on the chart's own PVC, so this is the
|
||||
# whole fixture set.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-convertx-secret
|
||||
stringData:
|
||||
jwt-secret: convertx-ci-jwt-secret-0123456789abcdef0123456789abcdef
|
||||
@@ -0,0 +1,23 @@
|
||||
# The image is ~1.4 GiB compressed - by a wide margin the largest in this
|
||||
# repo, because LibreOffice, Calibre, ImageMagick, ffmpeg, Inkscape and a TeX
|
||||
# Live installation are all inside it. One layer alone is 1354 MiB.
|
||||
#
|
||||
# The rollout wait therefore has to cover the PULL, not just the start: the
|
||||
# runner reference point is 96 MB in 17.5s, which puts this image in the
|
||||
# region of five minutes of pulling before the container is even created. On
|
||||
# top of that comes the startup probe budget (30 x 10s = 300s), which is
|
||||
# generous because ConvertX shells out to ~20 converter binaries at import
|
||||
# just to log their versions.
|
||||
#
|
||||
# MEASURED, and the estimate was wrong in the safe direction: the first green
|
||||
# install-test of this chart (Gitea PR #61) took 2m35s for the WHOLE job -
|
||||
# cluster creation, fixtures, pull, install and rollout wait together. The
|
||||
# 96 MB/17.5s reference does not transfer to ghcr.io on this runner.
|
||||
#
|
||||
# 900s stays anyway, and deliberately. A rollout-wait timeout is a ceiling,
|
||||
# not a cost: kubectl waits exactly as long as the rollout needs, so a
|
||||
# generous number is free on a good day and only spends time on a chart that
|
||||
# is already failing. What it buys is the bad day - a cold registry, or a node
|
||||
# that has to extract the 1354 MiB layer onto slow disk. The default of 300s
|
||||
# would have no headroom at all for either.
|
||||
INSTALL_TIMEOUT=900
|
||||
@@ -0,0 +1,15 @@
|
||||
# The OnePasswordItem CR only needs a non-null item path; the actual
|
||||
# Kubernetes Secret comes from ci/convertx/fixtures.yaml.
|
||||
secrets:
|
||||
jwtSecretRef: op://ci/convertx-jwt
|
||||
|
||||
ingress:
|
||||
domain: convertx.ci.local
|
||||
# clusterIssuer is strictly required (no default); the Ingress just has to
|
||||
# apply - there is no cert-manager in the CI cluster.
|
||||
clusterIssuer: ci
|
||||
|
||||
# The chart's default is 10Gi; the install-test converts nothing, so 1Gi is
|
||||
# enough and provisions faster on k3d's local-path class.
|
||||
storage:
|
||||
amount: 1Gi
|
||||
@@ -1,45 +0,0 @@
|
||||
# Fixtures for the garage install-test: the two Secrets the 1Password operator
|
||||
# would otherwise materialize from the OnePasswordItems the chart renders
|
||||
# (release name: ci, stub CRD in ci/common/).
|
||||
#
|
||||
# NO THROWAWAY INFRASTRUCTURE: Garage depends on nothing - no database, no
|
||||
# cache, no other service. The whole deployment is one process and two volumes,
|
||||
# which is also why this chart is cheap enough to test on every PR.
|
||||
#
|
||||
# THE FIELD NAMES MATTER AS MUCH AS THE VALUES, and here the VALUES matter too,
|
||||
# which is unusual for a fixture: Garage validates all four of them on start and
|
||||
# refuses to run on a malformed one. So these are not placeholders - a value
|
||||
# that merely looks right (a 31-byte RPC secret, a 6-character access key) fails
|
||||
# the run, which is exactly the check that makes the fixture worth having.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-garage-rpc-secret
|
||||
stringData:
|
||||
# EXACTLY 32 bytes, hex-encoded = 64 hex characters. Garage decodes this and
|
||||
# rejects anything else with "Invalid RPC secret key: expected 32 bytes of
|
||||
# random hex" - it is the network key of the RPC layer, required even for a
|
||||
# single node.
|
||||
#
|
||||
# A throwaway value, and a visibly throwaway one: this is not a secret, it is
|
||||
# a test vector. Nothing here survives the run, the cluster is deleted with
|
||||
# the job, and the node it keys has no peers.
|
||||
rpc-secret: "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-garage-s3-secret
|
||||
stringData:
|
||||
# The three fields Garage IMPORTS on start with --default-access-key and
|
||||
# --default-bucket. Each one is validated (src/model/key_table.rs,
|
||||
# src/model/bucket_alias_table.rs):
|
||||
# access-key at least 8 characters, ASCII alphanumeric plus - _ .
|
||||
# secret-key at least 16 characters, printable ASCII
|
||||
# bucket a valid S3 bucket name, 3-63 characters, lowercase
|
||||
# Shortening any of them below its minimum is the quickest way to check that
|
||||
# this test really exercises the import path: the container exits instead of
|
||||
# starting.
|
||||
access-key: "ciGarageTestKey01"
|
||||
secret-key: "c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2"
|
||||
bucket: "ci-garage-test"
|
||||
@@ -1,103 +0,0 @@
|
||||
# CI values for the garage install-test.
|
||||
#
|
||||
# WHY THIS CHART IS TESTED RATHER THAN EXCLUDED, and what the test is worth:
|
||||
# with garage.singleNode (the chart default) the server creates its own cluster
|
||||
# layout before it opens any listener, so the pod reaches Ready without an
|
||||
# operator. That is what makes an install-test possible here at all - without
|
||||
# the flag the readiness probe would honestly never go green (no layout, no
|
||||
# quorum, /health answers 503) and a green run would have required a probe that
|
||||
# lies.
|
||||
#
|
||||
# WHAT A GREEN RUN PROVES - the four measurement points the plan for Gitea #81
|
||||
# left open, three of which this closes on every PR:
|
||||
#
|
||||
# 1. Garage accepts metadata_dir and data_dir as two SUBDIRECTORIES OF ONE
|
||||
# PVC and creates them itself. The image is FROM scratch and has no mkdir,
|
||||
# so if the assumption were wrong the pod could not start at all.
|
||||
# 2. readOnlyRootFilesystem: true holds with no emptyDir anywhere - the
|
||||
# container writes only into the two volumes.
|
||||
# 3. The layout is really applied in-process and the admin API answers 200 on
|
||||
# /health afterwards: Ready is exactly that HTTP 200, so the rollout
|
||||
# status IS the assertion.
|
||||
# Plus the mechanical half: the Secret key names match what the StatefulSet
|
||||
# references, the rendered garage.toml PARSES AS GARAGE READS IT (it opens
|
||||
# LMDB, validates replication_factor against --single-node and refuses
|
||||
# malformed keys), the access key and the bucket are imported from the vault
|
||||
# fields, the resource quantities are valid, and the schema accepts a
|
||||
# realistic non-default values file.
|
||||
#
|
||||
# WHAT IT DOES NOT PROVE, and no install-test here could:
|
||||
# - that the metadata auto-snapshot really lands in metadata_snapshots_dir.
|
||||
# The first snapshot is taken after interval/2, and the shortest interval
|
||||
# that is not absurd is still minutes - longer than the run. A snapshot
|
||||
# interval of seconds would test the timer, not the thing.
|
||||
# - anything about Longhorn: the snapshot semantics the chart's whole storage
|
||||
# layout is built on are a property of the storage layer, and the runner
|
||||
# has local-path.
|
||||
# - durability. metadata_fsync is set, and whether an fsync survives a power
|
||||
# cut is not observable in a container.
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/garage/fixtures.yaml as ci-garage-rpc-secret and
|
||||
# ci-garage-s3-secret (the OnePasswordItem stub CRD comes from ci/common/).
|
||||
rpcSecretRef: op://ci/garage-rpc
|
||||
s3SecretRef: op://ci/garage-s3
|
||||
|
||||
garage:
|
||||
# Both ON, i.e. the chart's defaults, and they are the reason the run means
|
||||
# something: the key and the bucket are imported FROM the fixture Secret, so
|
||||
# a wrong field name or a malformed value fails the start rather than passing
|
||||
# unnoticed.
|
||||
singleNode: true
|
||||
defaultAccessKey: true
|
||||
defaultBucket: true
|
||||
|
||||
# ON against the chart's default of off, together with a root domain. It
|
||||
# renders a second listener, a second containerPort, a Service port and the
|
||||
# [s3_web] section of garage.toml - all of which Garage has to accept, and
|
||||
# none of which the default install exercises.
|
||||
web:
|
||||
enabled: true
|
||||
rootDomain: ".web.garage.ci.example.com"
|
||||
|
||||
s3:
|
||||
region: garage-ci
|
||||
rootDomain: ".s3.garage.ci.example.com"
|
||||
|
||||
# Shortened from the chart's "6h" so the snapshot WORKER is created and
|
||||
# scheduled during the run. It still does not take a snapshot - the first one
|
||||
# is due after interval/2, i.e. 15 minutes here - and that gap is named above
|
||||
# rather than papered over with a one-second interval that would only prove
|
||||
# the timer fires.
|
||||
metadataAutoSnapshotInterval: "30m"
|
||||
|
||||
resources:
|
||||
# Small, to stay inside the runner's budget, while still exercising the
|
||||
# limit derivation: 256Mi x limitFactor 3 = 768Mi.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 32Mi
|
||||
|
||||
storage:
|
||||
data:
|
||||
amount: 512Mi
|
||||
# k3d's dynamic provisioner. The intended production value,
|
||||
# longhorn-encrypted-ns, does not exist on the runner - and a pre-bound
|
||||
# hostPath PV would be worse than this one, because the kubelet does not
|
||||
# apply fsGroup to a hostPath volume at all, so the test would silently
|
||||
# stop exercising the ownership path that matters for a volume the
|
||||
# container did not create.
|
||||
storageClass: local-path
|
||||
snapshots:
|
||||
amount: 256Mi
|
||||
storageClass: local-path
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that the
|
||||
# object is accepted and that the Service port it names by NAME ("s3")
|
||||
# actually exists - a typo there is otherwise invisible until someone opens
|
||||
# the URL.
|
||||
enabled: true
|
||||
domain: garage.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
@@ -1,96 +0,0 @@
|
||||
# Fixtures for the keycloak install-test: a throwaway PostgreSQL plus the two
|
||||
# Secrets the 1Password operator would otherwise materialize from the
|
||||
# OnePasswordItems the chart renders (release name: ci, stub CRD in ci/common/).
|
||||
#
|
||||
# WHY A REAL DATABASE AND NOT A STUB: Keycloak does nothing without one. It
|
||||
# creates its entire schema through Liquibase on first start, and - the reason
|
||||
# this fixture is the valuable half of the test - jdbc-ping discovers the second
|
||||
# replica THROUGH this database. A stub would turn a cluster test into a
|
||||
# templating test.
|
||||
#
|
||||
# The image is pinned to a major line like every other fixture here
|
||||
# (postgres:16-alpine); ci/** is excluded from Renovate on purpose, because
|
||||
# this is test scaffolding and churning it only risks breaking the
|
||||
# install-test.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-keycloak-db-secret
|
||||
stringData:
|
||||
# The field NAMES are what matters here, not the values: the operator
|
||||
# materializes one Secret key per item field, and the Deployment references
|
||||
# these two by name. A rename on either side leaves the pod in
|
||||
# CreateContainerConfigError, which is a state no render can show.
|
||||
#
|
||||
# They must agree with the POSTGRES_USER / POSTGRES_PASSWORD below, because
|
||||
# Keycloak really connects.
|
||||
username: keycloak
|
||||
password: ci-keycloak-db-password
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-keycloak-bootstrap-admin-secret
|
||||
stringData:
|
||||
# The temporary bootstrap administrator. Keycloak creates it while the master
|
||||
# realm is created, so these values are exercised on the very first start -
|
||||
# and the variables are named KC_BOOTSTRAP_ADMIN_* only since Keycloak 26.
|
||||
#
|
||||
# A visibly throwaway password: nothing here survives the run, the cluster is
|
||||
# deleted with the job, and the only thing it protects is a realm that exists
|
||||
# for a few minutes.
|
||||
username: ci-temp-admin
|
||||
password: ci-bootstrap-admin-password
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: public.ecr.aws/docker/library/postgres:16-alpine
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: keycloak
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: ci-keycloak-db-password
|
||||
- name: POSTGRES_DB
|
||||
value: keycloak
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "keycloak"]
|
||||
periodSeconds: 5
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
# emptyDir: the database exists for the length of one job. Nothing here
|
||||
# is meant to survive, and a PVC would only add a provisioner to the
|
||||
# list of things that can fail a chart's test.
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
@@ -1,17 +0,0 @@
|
||||
# The default 300s is not enough here, and the three parts that fill the budget
|
||||
# are worth naming so the number can be lowered again if one of them changes:
|
||||
#
|
||||
# 1. TWO INIT CONTAINERS PER POD. The build one runs a full Quarkus
|
||||
# augmentation - upstream's own issue reports measure "Quarkus
|
||||
# augmentation completed in" at roughly 13-23 seconds, and a CI runner is
|
||||
# not a fast machine.
|
||||
# 2. THE FIRST START CREATES THE WHOLE SCHEMA through Liquibase, and with two
|
||||
# replicas the second pod waits on the first's migration lock. So the two
|
||||
# pods do not become Ready in parallel.
|
||||
# 3. The rollout is only complete when BOTH pods are Ready, which is
|
||||
# deliberate (it is what makes cluster formation part of the assertion)
|
||||
# and which means the slow parts add up rather than overlap.
|
||||
#
|
||||
# The chart's own startupProbe budget is 600s (10s x 60); this timeout has to
|
||||
# be at least that, or the test gives up before the probe would have.
|
||||
INSTALL_TIMEOUT=900
|
||||
@@ -1,143 +0,0 @@
|
||||
# CI values for the keycloak install-test.
|
||||
#
|
||||
# WHY THIS CHART IS TESTED RATHER THAN EXCLUDED, and what a green run is worth.
|
||||
# Keycloak has no volume, needs one throwaway PostgreSQL and reaches Ready on
|
||||
# its own, so an exclusion would be hard to justify. More importantly, this is
|
||||
# the only place where the four riskiest assumptions of the chart are exercised
|
||||
# at all - none of them is observable in a render.
|
||||
#
|
||||
# WHAT A GREEN RUN PROVES, point by point (the measurement points of Gitea #83):
|
||||
#
|
||||
# 1. THE TWO INIT CONTAINERS WORK AT ALL. The seed container copies the
|
||||
# image's own augmentation output into the volume, the build container
|
||||
# mounts that volume at /opt/keycloak/lib/quarkus and runs `kc.sh build`
|
||||
# into it - both with readOnlyRootFilesystem: true. If the seeding
|
||||
# assumption were wrong (QuarkusEntryPoint loads
|
||||
# quarkus/quarkus-application.dat before it dispatches any command,
|
||||
# `build` included), the build container could not start, and the pod
|
||||
# would sit in Init with the reason only in its log.
|
||||
# 2. readOnlyRootFilesystem: true HOLDS FOR THE SERVING CONTAINER, with
|
||||
# emptyDirs only at /opt/keycloak/lib/quarkus, /opt/keycloak/data and
|
||||
# /tmp - and NOT at /opt/keycloak/conf, which would hide the
|
||||
# cache-ispn.xml that cache=ispn reads.
|
||||
# 3. `start --optimized` ACCEPTS THE PRE-BUILT INSTALLATION and does not
|
||||
# re-augment. A disagreement between the build options and the serving
|
||||
# container would show up here as a crash loop rather than as a slow
|
||||
# start, because the path it would want to rewrite is read-only.
|
||||
# 4. TWO REPLICAS BOTH COME UP AND STAY UP against one database - the
|
||||
# rollout is complete only when BOTH pods are Ready, so a second replica
|
||||
# that crashed, deadlocked on the schema migration lock or fought the
|
||||
# first over the database would fail the run. Measured in Gitea PR #87:
|
||||
# 71.8s from `helm install` to both Ready, 0 restarts.
|
||||
# Plus the mechanical half: the Secret key names match what the Deployment
|
||||
# references, /health/started, /health/live and /health/ready all answer on
|
||||
# the management port (so health-enabled really was baked in by the init
|
||||
# container), the resource quantities are valid, and the schema accepts a
|
||||
# realistic non-default values file.
|
||||
#
|
||||
# WHAT IT DOES NOT PROVE, and the first entry is the one not to misread:
|
||||
# - THAT jdbc-ping ACTUALLY FOUND THE PEER. Two replicas both reaching Ready
|
||||
# is NOT that proof, and believing it was is the mistake this paragraph
|
||||
# exists to prevent: readiness reports this node's own health, so a node
|
||||
# that never found its peer is Ready, healthy and alone - and two such
|
||||
# nodes are two islands that each believe they are the cluster. The
|
||||
# Infinispan view has to be read out of the log, which is what step 2 of
|
||||
# NOTES.txt tells the operator to do. So measurement point 4 of the plan
|
||||
# stays open after this test; what the test does rule out is the louder
|
||||
# half (a second replica that cannot start or cannot share the database).
|
||||
# - that hostname-admin serves the console cleanly on a second hostname. No
|
||||
# ingress controller answers in CI and nothing drives a browser, so
|
||||
# measurement point 5 stays open too. What IS checked is that both Ingress
|
||||
# objects are accepted, that the Service port they name exists, and that
|
||||
# Keycloak accepts both hostname values as full URLs at start.
|
||||
# - anything about an upgrade. The rolling-update restriction between minor
|
||||
# versions is a property of two versions meeting, and this test installs
|
||||
# one.
|
||||
# - that the PostgreSQL here resembles production. It is one pod with an
|
||||
# emptyDir, so nothing about durability, connection limits or latency
|
||||
# transfers.
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/keycloak/fixtures.yaml as ci-keycloak-db-secret and
|
||||
# ci-keycloak-bootstrap-admin-secret (the OnePasswordItem stub CRD comes from
|
||||
# ci/common/).
|
||||
databaseSecretRef: op://ci/keycloak-db
|
||||
bootstrapAdminSecretRef: op://ci/keycloak-bootstrap-admin
|
||||
|
||||
keycloak:
|
||||
# TWO, i.e. the chart's default, and the whole reason this fixture is not a
|
||||
# one-replica shortcut. With one replica the install-test would be green
|
||||
# without ever showing whether the second node is found - and "a path the
|
||||
# test does not walk is a path we have not proven" is the lesson Gitea #80
|
||||
# paid for. The install-test script waits for the Deployment's rollout, and a
|
||||
# rollout is only complete when BOTH pods are Ready, so cluster formation is
|
||||
# inside the assertion rather than next to it.
|
||||
replicas: 2
|
||||
|
||||
db:
|
||||
# The throwaway PostgreSQL from fixtures.yaml. Plain Service name: both are
|
||||
# in the same namespace.
|
||||
host: postgres
|
||||
database: keycloak
|
||||
# Small but not degenerate: 2 replicas x 5 = 10 connections against
|
||||
# postgres:16-alpine's default max_connections of 100, which leaves the
|
||||
# fixture comfortable while still exercising the pool options.
|
||||
pool:
|
||||
initialSize: 1
|
||||
minSize: 1
|
||||
maxSize: 5
|
||||
|
||||
# The chart default, spelled out because it is load-bearing for what this
|
||||
# test means: metrics-enabled is what puts the DATABASE check into
|
||||
# /health/ready (upstream's check table, "Requires Metrics: Yes"). With it
|
||||
# off, readiness would pass without ever touching the database, and a green
|
||||
# run would say nothing about the connection at all.
|
||||
#
|
||||
# It is also a BUILD option, so with it true the init container has to build
|
||||
# an installation differing from the published image in three options rather
|
||||
# than two - which is what makes a build that silently did nothing fail the
|
||||
# run instead of looking identical.
|
||||
metrics:
|
||||
enabled: true
|
||||
|
||||
# Set on purpose, although one release cannot collide with itself: it
|
||||
# exercises the KC_CACHE_EMBEDDED_CLUSTER_NAME path, and a name Keycloak
|
||||
# rejected would fail the start rather than pass unnoticed.
|
||||
cache:
|
||||
clusterName: ci-keycloak
|
||||
|
||||
resources:
|
||||
# Below the chart's defaults to stay inside the runner's budget, and the
|
||||
# memory number is chosen rather than rounded: the image sizes the heap at
|
||||
# 70% of the LIMIT, so 600Mi x limitFactor 3 = 1800Mi gives a ~1260Mi heap.
|
||||
# Upstream's floor is "at least 750 MB" for the limit; going much below
|
||||
# this makes the JVM the reason a run fails.
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 600Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
build:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that
|
||||
# both objects are accepted and that the Service port they name BY NAME
|
||||
# ("http") actually exists - a typo there is otherwise invisible until
|
||||
# somebody opens the URL.
|
||||
enabled: true
|
||||
domain: keycloak.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
admin:
|
||||
# The second hostname, on as well, so the chart renders KC_HOSTNAME_ADMIN
|
||||
# and Keycloak has to accept it as a full URL. That is the half of
|
||||
# measurement point 5 a cluster-less test can reach: Keycloak validates
|
||||
# both hostname values at start ("verified that full URL is used, incl.
|
||||
# scheme and hostname"), so a chart that built a bare host here would fail
|
||||
# the start instead of serving a broken console later.
|
||||
enabled: true
|
||||
domain: keycloak-admin.ci.example.com
|
||||
@@ -1,19 +1,11 @@
|
||||
# Fixtures for the paperless-ngx install-test: a throwaway postgres, the
|
||||
# secrets the 1Password operator would otherwise materialize (release name:
|
||||
# ci), and storage plumbing. The chart hardcodes storageClassName
|
||||
# Fixtures for the paperless-ngx install-test: throwaway postgres + redis,
|
||||
# the secrets the 1Password operator would otherwise materialize (release
|
||||
# name: ci), and storage plumbing. The chart hardcodes storageClassName
|
||||
# "longhorn" (RWX for the consume PVC), which does not exist in k3d: a
|
||||
# no-provisioner StorageClass named "longhorn" plus pre-bound hostPath PVs
|
||||
# stand in for it. Paperless runs as uid 1000 with a read-only rootfs and
|
||||
# hostPath volumes do not support fsGroup, so a node-setup Job pre-creates
|
||||
# the exact subPath directories world-writable.
|
||||
#
|
||||
# THERE IS NO THROWAWAY REDIS HERE ANY MORE, and that is the change this file
|
||||
# carries: the release brings its own Valkey through the subchart
|
||||
# (ci/paperless-ngx/test-values.yaml says what that proves), so the broker under
|
||||
# test is the chart's own object rather than a Deployment next to it. Leaving a
|
||||
# redis Deployment in place would have been the worse of the two: the url would
|
||||
# have had to name one of them, and whichever it named, the other would have sat
|
||||
# there making the run look like it covered something it did not.
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
@@ -84,43 +76,12 @@ spec:
|
||||
path: /var/lib/ci-pv/paperless-ngx/consume
|
||||
type: DirectoryOrCreate
|
||||
---
|
||||
# The two Secrets below stand in for ONE 1Password item. Both the chart's
|
||||
# redis OnePasswordItem and the subchart's auth OnePasswordItem point at
|
||||
# op://ci/paperless-redis, and a OnePasswordItem materializes EVERY field of the
|
||||
# item it names - so in a real cluster both Secrets carry all three fields. They
|
||||
# are written out that way here rather than trimmed to what each consumer reads,
|
||||
# because the trimmed version would have been a fixture that is tidier than
|
||||
# reality and would have hidden the only thing worth knowing: the extra keys are
|
||||
# unused, not a leak and not a bug.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-paperless-ngx-redis-secret
|
||||
stringData:
|
||||
# Names the SUBCHART's Service, which is the mandatory vault step of this
|
||||
# configuration (NOTES.txt prints it). A wrong name here is the failure this
|
||||
# install-test exists to catch: paperless would come up healthy and process
|
||||
# nothing.
|
||||
redis-connection-url: redis://:cipass@ci-valkey-service:6379
|
||||
key-prefix: ci
|
||||
# Same password as in the url above - that duplication is the chart's
|
||||
# documented trap 1, and the fixture is the cheapest place to keep it honest:
|
||||
# let the two drift and the run goes red on authentication.
|
||||
auth-conf: requirepass cipass
|
||||
---
|
||||
# What the subchart's own OnePasswordItem (ci-valkey-secret) would become. The
|
||||
# key the Valkey actually mounts is "auth-conf", and its value has to be a
|
||||
# COMPLETE configuration line, because the Valkey configuration includes the
|
||||
# file: give it anything that is not "requirepass <password>" and the init
|
||||
# container refuses to start the pod, which is the failure that chart exists to
|
||||
# make loud.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-valkey-secret
|
||||
stringData:
|
||||
auth-conf: requirepass cipass
|
||||
redis-connection-url: redis://:cipass@ci-valkey-service:6379
|
||||
redis-connection-url: redis://:cipass@redis:6379
|
||||
key-prefix: ci
|
||||
---
|
||||
apiVersion: v1
|
||||
@@ -192,3 +153,41 @@ spec:
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: public.ecr.aws/docker/library/redis:7-alpine
|
||||
args: ["redis-server", "--requirepass", "cipass"]
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: 6379
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
@@ -1,36 +1,4 @@
|
||||
# CI values for the paperless-ngx install-test.
|
||||
#
|
||||
# THE SUBCHART IS ON, AND THAT IS THE POINT OF THIS FILE. Gitea #65 added the
|
||||
# same valkey dependency to stalwart but covered it by a local RENDER only -
|
||||
# deliberately, because Stalwart's in-memory store is an object in its database,
|
||||
# so a Valkey in that namespace would have been reachable and unused and the
|
||||
# test would have looked like it covered the clustering path while covering
|
||||
# nothing of it. Here it is the opposite: paperless-ngx reads PAPERLESS_REDIS
|
||||
# from the vault item, so pointing that url at the subchart's Service makes the
|
||||
# bundled Valkey the broker this release actually uses. So this is the first
|
||||
# install of the valkey-subchart path anywhere, and what it proves is:
|
||||
#
|
||||
# 1. The dependency resolves from the published index on a real runner with no
|
||||
# credentials, and lint/package/render/install all survive it.
|
||||
# 2. The subchart's objects are accepted inside a parent release: its
|
||||
# StatefulSet becomes Ready, which means its init container found a
|
||||
# "requirepass " line in the mounted auth file and its three exec probes
|
||||
# authenticated and got PONG back.
|
||||
# 3. The PVC path of the subchart works under a parent release name
|
||||
# (ci-valkey-*), i.e. the names really are <release>-valkey-<kind>.
|
||||
# 4. paperless-ngx talks to it: the url in the fixture Secret names
|
||||
# ci-valkey-service, so a wrong Service name or a password mismatch between
|
||||
# the two fields of the "item" shows up as a failing run instead of as a
|
||||
# quietly idle Valkey.
|
||||
#
|
||||
# What it still CANNOT prove is that documents are processed - that needs a real
|
||||
# consume run and is out of scope here, as before.
|
||||
secrets:
|
||||
# One item for both, which is the configuration the chart is designed around:
|
||||
# redis-connection-url, key-prefix AND auth-conf live in the same 1Password
|
||||
# item. In CI the operator does not exist, so ci/paperless-ngx/fixtures.yaml
|
||||
# stands in for it - and it stands in TWICE, because a OnePasswordItem
|
||||
# materializes every field of the item it names. See the fixture.
|
||||
redisSecretRef: op://ci/paperless-redis
|
||||
databaseSecretRef: op://ci/paperless-db
|
||||
paperlessSecretRef: op://ci/paperless
|
||||
@@ -48,42 +16,5 @@ paperless:
|
||||
# neither erase them nor break rendering.
|
||||
barcodes:
|
||||
|
||||
valkeySubchart:
|
||||
enabled: true
|
||||
|
||||
valkey:
|
||||
secrets:
|
||||
# The SAME op:// path as secrets.redisSecretRef above, typed out again
|
||||
# because Helm cannot default one value from another (Gitea #78). Typing it
|
||||
# here is also what exercises the parent's guard in the "satisfied"
|
||||
# direction; the failing direction is covered by a local render, since the
|
||||
# harness installs one configuration per chart.
|
||||
authSecretRef: op://ci/paperless-redis
|
||||
|
||||
persistence:
|
||||
# QUEUE, the chart's and the subchart's default - and the risky half, which
|
||||
# is why it is not switched off here: a provisioned PVC, the pod's fsGroup
|
||||
# against a volume it did not create, and an append-only file written and
|
||||
# replayed on it.
|
||||
enabled: true
|
||||
appendonly: true
|
||||
# k3d's dynamic provisioner. The subchart's default, longhorn-encrypted-ns,
|
||||
# does not exist on the runner, and the no-provisioner "longhorn" class the
|
||||
# paperless fixtures define is pre-bound to hostPath PVs for paperless' own
|
||||
# claims - a hostPath volume gets no fsGroup from the kubelet at all, so
|
||||
# reusing it here would silently stop testing the ownership path.
|
||||
storageClass: local-path
|
||||
size: 128Mi
|
||||
|
||||
valkey:
|
||||
resources:
|
||||
# Small, to stay inside the runner's budget, while still exercising the
|
||||
# subchart's derivation: 64Mi x limitFactor 3 = 192Mi limit, maxmemory =
|
||||
# 0.75 x that = 144Mi.
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
ephemeral-storage: 32Mi
|
||||
|
||||
# smbConnection and oidc stay disabled (defaults): the SMB CSI driver and a
|
||||
# real OIDC login flow are out of scope for the CI install-test.
|
||||
@@ -1,104 +0,0 @@
|
||||
# Fixtures for the stalwart install-test: a throwaway Postgres, the one Secret
|
||||
# the 1Password operator would otherwise materialize, and a stub CRD for
|
||||
# cert-manager's Certificate (release name: ci; the OnePasswordItem stub CRD
|
||||
# comes from ci/common/).
|
||||
#
|
||||
# NO VALKEY HERE, DELIBERATELY. The in-memory store is an object in Stalwart's
|
||||
# DATABASE, not a chart value, so a Valkey in this namespace would be reachable
|
||||
# and unused - it would make the test look like it covered the clustering path
|
||||
# while covering nothing of it. The test runs a single node (nodes.full
|
||||
# replicas 1, nodes.mx replicas 0), which is the configuration that needs no
|
||||
# in-memory store at all. The subchart path is covered by a local render only,
|
||||
# named as a known gap in the PR that introduced this chart.
|
||||
#
|
||||
# A stub CRD for Certificate rather than certificate.enabled: false, because
|
||||
# switching the Certificate off would also drop the volume and volumeMount that
|
||||
# carry it - and the mount is on the risky side of readOnlyRootFilesystem. With
|
||||
# the stub the object applies, the Secret it names never appears, and the
|
||||
# optional: true on the volume is exercised for real: the pods must start
|
||||
# without it. That is the behaviour an operator meets on every first install,
|
||||
# before cert-manager has answered the challenge.
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: certificates.cert-manager.io
|
||||
spec:
|
||||
group: cert-manager.io
|
||||
names:
|
||||
kind: Certificate
|
||||
listKind: CertificateList
|
||||
plural: certificates
|
||||
singular: certificate
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
served: true
|
||||
storage: true
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
type: object
|
||||
x-kubernetes-preserve-unknown-fields: true
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-stalwart-datastore-secret
|
||||
stringData:
|
||||
# The ONLY field this chart reads from the data store item. config.json
|
||||
# references it by variable name through
|
||||
# "authSecret": {"@type":"EnvironmentVariable","variableName":"STALWART_DB_PASSWORD"}
|
||||
# so this value has to match the throwaway Postgres below. A real password
|
||||
# rather than an empty string on purpose: upstream's resolver treats an empty
|
||||
# environment variable as "not found" and fails the store open, so an empty
|
||||
# value here would fail the run for the right reason in the wrong place.
|
||||
password: ci-stalwart-throwaway-password
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: public.ecr.aws/docker/library/postgres:16-alpine
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: stalwart
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: ci-stalwart-throwaway-password
|
||||
- name: POSTGRES_DB
|
||||
value: stalwart
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "stalwart"]
|
||||
periodSeconds: 5
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
@@ -1,123 +0,0 @@
|
||||
# CI values for the stalwart install-test.
|
||||
#
|
||||
# WHAT THIS TEST CAN AND CANNOT PROVE — read this before trusting a green run.
|
||||
#
|
||||
# It CANNOT prove a working mail server, and no install-test here could. Since
|
||||
# v0.16 every listener, the blob store, the in-memory store, the TLS object and
|
||||
# the cluster roles live in Stalwart's DATABASE and are created by a human in
|
||||
# the WebUI. Against an empty database there is no SMTP service to test, so a
|
||||
# green run says nothing about mail.
|
||||
#
|
||||
# What it DOES prove is the part that is the chart's own responsibility, and the
|
||||
# first item is the reason this chart is tested rather than excluded:
|
||||
#
|
||||
# 1. THE CONTAINER EXECS AT ALL. The image carries
|
||||
# "setcap cap_net_bind_service=+ep" on its binary, which makes the kernel
|
||||
# fail execve itself when the capability is not in the bounding set. The
|
||||
# first draft of this chart had capabilities.drop: [ALL] with nothing
|
||||
# added and would have failed with "operation not permitted" in every
|
||||
# install - caught by reading the Dockerfile, but exactly the class of
|
||||
# failure an install-test exists to catch rather than reason about.
|
||||
# 2. readOnlyRootFilesystem: true holds with the two image volumes plus /tmp
|
||||
# as emptyDirs, and the init container really can write config.json into
|
||||
# the /etc/stalwart emptyDir.
|
||||
# 3. The rendered config.json parses AS STALWART READS IT, not just as JSON:
|
||||
# the server opens the data store, which means "@type": "PostgreSql", the
|
||||
# integer-millisecond timeout and the authSecret EnvironmentVariable
|
||||
# indirection are all accepted. This is the one check that the password
|
||||
# really arrives without ever being in the ConfigMap.
|
||||
# 4. The Secret key names match what the templates reference, the ConfigMap
|
||||
# mount works, the NodePorts can be allocated, the resource quantities are
|
||||
# valid, the schema accepts a realistic (non-default) values file, and the
|
||||
# NetworkPolicy objects are accepted without cutting off the probes.
|
||||
#
|
||||
# RECOVERY MODE IS ON, and that is not a shortcut - it is the only mode that can
|
||||
# work here. The bootstrap wizard appears only when config.json is ABSENT, and
|
||||
# this chart always renders one; upstream's documented path for a templated
|
||||
# config.json is to start once with STALWART_RECOVERY_MODE=1. Recovery mode
|
||||
# brings up the management HTTP endpoint and keeps the mail ports closed, which
|
||||
# is precisely the reachable surface the probes use.
|
||||
#
|
||||
# THE ONE THING THIS FILE COULD NOT BE VERIFIED AGAINST: whether /healthz/live
|
||||
# and /healthz/ready are served on the RECOVERY listener. The source shows them
|
||||
# handled in the general HTTP request path with no auth and no network
|
||||
# restriction, but recovery mode binds a single listener and skips most config
|
||||
# parsing, and there is no cluster in the authoring environment to try it on.
|
||||
# If the install-test goes red with the pods never becoming Ready, that is the
|
||||
# reason, and the fix is either an exec probe or an entry in
|
||||
# ci/excluded-charts.txt - see the PR for #65.
|
||||
bootstrap:
|
||||
enabled: true
|
||||
logLevel: debug
|
||||
|
||||
dataStore:
|
||||
postgres:
|
||||
host: postgres
|
||||
database: stalwart
|
||||
username: stalwart
|
||||
tls:
|
||||
# OFF, against the chart's own default of true. The throwaway Postgres in
|
||||
# fixtures.yaml serves no TLS, and the alternative - allowInvalidCerts -
|
||||
# would be worse: it would pass whether or not TLS worked, so a chart
|
||||
# change that broke the TLS path would still go green. A deviation that
|
||||
# fails loudly beats one that hides.
|
||||
enable: false
|
||||
allowInvalidCerts: false
|
||||
|
||||
secrets:
|
||||
# Materialized by ci/stalwart/fixtures.yaml as ci-stalwart-datastore-secret.
|
||||
dataStoreSecretRef: op://ci/stalwart-datastore
|
||||
# Both deliberately unset: neither is needed to bring a pod up, and leaving
|
||||
# them out exercises the conditional env and OnePasswordItem blocks in the
|
||||
# "absent" direction, which is the shape a minimal install really has.
|
||||
recoveryAdminSecretRef: null
|
||||
s3SecretRef: null
|
||||
|
||||
ingress:
|
||||
# Left ON although no ingress controller answers in CI: the point is that the
|
||||
# object is accepted and that the per-group http Service it names actually
|
||||
# exists - a selector typo there is otherwise invisible until someone opens
|
||||
# the URL.
|
||||
enabled: true
|
||||
domain: stalwart.ci.example.com
|
||||
clusterIssuer: ci-selfsigned
|
||||
nodeGroup: full
|
||||
|
||||
certificate:
|
||||
# On, against a STUB CRD (fixtures.yaml). Nothing issues the Secret it names,
|
||||
# which is the interesting case: the volume is mounted optional, so the pods
|
||||
# must start without it. That is what every real first install looks like
|
||||
# before cert-manager has answered the challenge.
|
||||
enabled: true
|
||||
clusterIssuer: ci-selfsigned
|
||||
|
||||
networkPolicy:
|
||||
# Left ON. probeSources stays empty, so the http rule renders without a
|
||||
# "from" selector - the configuration this chart ships. Whether the policy
|
||||
# actually FILTERS is a property of the cluster's CNI and is not claimed by
|
||||
# this test; what is claimed is that the objects are accepted and that they
|
||||
# do not keep the kubelet's probes from working.
|
||||
enabled: true
|
||||
|
||||
nodes:
|
||||
full:
|
||||
replicas: 1
|
||||
exposedPorts: [smtp, submissions, imaps, http]
|
||||
resources:
|
||||
# Small, to stay inside the runner's budget, while still exercising the
|
||||
# limit derivation: 256Mi x limitFactor 3 = 768Mi.
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 64Mi
|
||||
mx:
|
||||
# Stays at 0, which is also the chart default. Two reasons rather than one:
|
||||
# a second node would need an in-memory store (the render aborts without
|
||||
# one), and the "mx" ClusterRole does not exist in an empty database, so a
|
||||
# running mx pod would fail-open into a second full node and the two would
|
||||
# fight over the same NodePort-backed SMTP Service. The StatefulSet is
|
||||
# still created and the harness still waits on it - a 0-replica rollout
|
||||
# completes immediately, so this covers the explicit-zero path of the
|
||||
# replicas helper.
|
||||
replicas: 0
|
||||
exposedPorts: [smtp, http]
|
||||
@@ -20,9 +20,3 @@ ksv0125:
|
||||
- cr.jk-dev.de
|
||||
- ghcr.io
|
||||
- docker.io
|
||||
# quay.io, added with the keycloak chart (Gitea #83) - Keycloak publishes
|
||||
# its official image only there (quay.io/keycloak/keycloak), and it is so
|
||||
# far the only chart in this repo using that registry. Listed rather than
|
||||
# suppressed, for the reason the comment above gives: the check keeps being
|
||||
# able to say something about an image from a genuinely foreign source.
|
||||
- quay.io
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v2
|
||||
name: convertx
|
||||
description: A Helm chart to deploy ConvertX on Kubernetes
|
||||
icon: https://raw.githubusercontent.com/C4illin/ConvertX/main/public/android-chrome-512x512.png
|
||||
|
||||
type: application
|
||||
|
||||
version: 1.0.0+upv0.19.0
|
||||
appVersion: "v0.19.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -0,0 +1,224 @@
|
||||
{{- $replicas := include "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.replicas) -}}
|
||||
{{- $startup := fromYaml (include "convertx.startupProbe" .Values.convertx.startupProbe) -}}
|
||||
{{- $autoDelete := include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours -}}
|
||||
{{- $registration := include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") -}}
|
||||
{{- $unauthenticated := include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") -}}
|
||||
{{- $httpAllowed := include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") -}}
|
||||
ConvertX {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
ACCESS
|
||||
https://{{ .Values.ingress.domain }}
|
||||
|
||||
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
|
||||
cluster issuer. Until it has been issued the browser shows a warning; follow it with
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- else }}
|
||||
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate,
|
||||
and neither ingress.clusterIssuer nor ingress.domain is needed. ConvertX has
|
||||
no base-URL setting of its own — it builds no absolute links — so nothing
|
||||
outside the Ingress reads the domain in this chart.
|
||||
|
||||
Reach the release inside the cluster with
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||
|
||||
and open http://localhost:8080. Note that the session cookie is marked
|
||||
"secure"{{ if eq $httpAllowed "true" }} — except that convertx.httpAllowed is true in this release, which
|
||||
clears that flag, so logging in over plain http works{{ else }}, so logging in over plain http will NOT work: the browser
|
||||
refuses to store the cookie. convertx.httpAllowed: true clears that flag{{ end }}.
|
||||
{{- end }}
|
||||
{{ if eq $replicas "0" }}
|
||||
SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}convertx.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so the endpoint
|
||||
above does not answer until you set it back and upgrade again. PVC, Secret{{ if .Values.ingress.enabled }},
|
||||
Service and Ingress stay in place, so neither the conversions nor the
|
||||
certificate are touched.{{ else }} and
|
||||
Service stay in place, so the conversions on the PVC are untouched.{{ end }}
|
||||
{{ end }}
|
||||
WHOEVER OPENS THE PAGE FIRST BECOMES THE ADMINISTRATOR
|
||||
A fresh ConvertX has no users, and with no users it serves a SETUP FORM to
|
||||
every visitor — no password, no invitation, nothing to bypass. The first
|
||||
account created that way owns the instance, and convertx.accountRegistration
|
||||
does not gate it: that value decides whether accounts TWO and onwards can be
|
||||
created, not the first one.
|
||||
|
||||
So on a fresh install, register yourself before the DNS name is reachable by
|
||||
anyone else. There is no second chance and no "reset admin" command; the
|
||||
repair is deleting the users row from the SQLite database on the PVC.
|
||||
{{- if eq $unauthenticated "true" }}
|
||||
|
||||
This release has convertx.allowUnauthenticated: true, so the point above is
|
||||
moot in the worst way — see the warning further down.
|
||||
{{- end }}
|
||||
|
||||
THE 1PASSWORD ITEM MUST CARRY THE FIELD THE CHART READS
|
||||
secrets.jwtSecretRef points at
|
||||
{{ .Values.secrets.jwtSecretRef }}
|
||||
The 1Password operator materializes that item as the Kubernetes Secret
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-secret, with one Secret key per FIELD of the item. So the field
|
||||
name is not free-form: this chart reads exactly one field, and it must be
|
||||
spelled
|
||||
|
||||
jwt-secret a long random string — openssl rand -hex 32
|
||||
|
||||
An item without it does not fail the install. The release goes through and
|
||||
the pod then hangs in CreateContainerConfigError naming the key it could not
|
||||
find. Watch the materialization with
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get onepassworditem {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
|
||||
WHY THIS VALUE IS REQUIRED RATHER THAN OPTIONAL: ConvertX signs its session
|
||||
cookies with JWT_SECRET, and when the variable is unset it quietly generates
|
||||
a random one PER PROCESS. Every restart — a node drain, an image update, an
|
||||
OOM kill — would then invalidate every session, with no log line and no
|
||||
error. Rotating the field has the same effect on purpose; after a rotation:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
|
||||
Editing the value INSIDE the item does not roll the pod by itself (Helm
|
||||
never sees a secret's content). Pointing secrets.jwtSecretRef at a DIFFERENT
|
||||
item does — the pod template carries a checksum over the rendered
|
||||
OnePasswordItem.
|
||||
|
||||
TWO SWITCHES THAT OPEN THE INSTANCE UP
|
||||
convertx.accountRegistration {{ $registration }}
|
||||
convertx.allowUnauthenticated {{ $unauthenticated }}
|
||||
|
||||
Upstream warns explicitly against enabling either on an instance reachable
|
||||
from the internet, and this chart puts a public DNS name in front by
|
||||
default. accountRegistration lets anyone with the URL create an account;
|
||||
allowUnauthenticated removes the login entirely, so anyone who reaches the
|
||||
Service can upload files and read every conversion on the volume.
|
||||
{{- if or (eq $registration "true") (eq $unauthenticated "true") }}
|
||||
|
||||
*** {{ if and (eq $registration "true") (eq $unauthenticated "true") }}BOTH OF THEM ARE ON{{ else }}ONE OF THEM IS ON{{ end }} IN THIS RELEASE. ***
|
||||
{{- if eq $unauthenticated "true" }}
|
||||
convertx.allowUnauthenticated: true — there is no login. Anyone who can
|
||||
reach {{ if .Values.ingress.enabled }}https://{{ .Values.ingress.domain }}{{ else }}the Service{{ end }} can use it and read other people's files.
|
||||
{{- end }}
|
||||
{{- if eq $registration "true" }}
|
||||
convertx.accountRegistration: true — anyone with the URL can sign up.
|
||||
{{- end }}
|
||||
If this instance is reachable from the internet, that is almost certainly
|
||||
not what you wanted.
|
||||
{{- end }}
|
||||
|
||||
RETENTION IS A VALUE, AND IT IS THE ONLY THING THAT EMPTIES THE VOLUME
|
||||
{{- if eq $autoDelete "0" }}
|
||||
convertx.autoDeleteEveryNHours is 0 — THE CLEANUP IS OFF. Uploads and
|
||||
converted output accumulate on the PVC for as long as the release lives and
|
||||
nothing reclaims them. Watch the volume, or set a non-zero number of hours.
|
||||
{{- else }}
|
||||
convertx.autoDeleteEveryNHours is {{ $autoDelete }} — ConvertX deletes the uploads and the
|
||||
output of any job older than {{ $autoDelete }} hours. That is a hard delete, not an
|
||||
archive: a conversion nobody downloaded in time is gone. Set it to 0 to
|
||||
switch the cleanup off, and then size storage.amount for unbounded growth.
|
||||
{{- end }}
|
||||
|
||||
STORAGE
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, {{ .Values.storage.amount }}, {{ if .Values.storage.storageClass }}storage class "{{ .Values.storage.storageClass }}"{{ else }}on the cluster's default storage class
|
||||
(storage.storageClass is unset){{ end }}, mounted at /app/data.
|
||||
|
||||
Everything this release persists is on that one volume: the SQLite database
|
||||
(users, jobs, file names), every uploaded file and every converted result.
|
||||
There is no external database and no object storage, so one snapshot of the
|
||||
PVC is a complete backup — and losing it loses the accounts along with the
|
||||
files.
|
||||
|
||||
Because the PVC is rendered by the chart, helm uninstall deletes it and the
|
||||
data with it, unless the storage class reclaim policy says otherwise.
|
||||
{{- if gt (int $replicas) 1 }}
|
||||
|
||||
convertx.replicas is {{ $replicas }}, and that does not work. The PVC is ReadWriteOnce, so
|
||||
the extra pods normally stay Pending on "Multi-Attach error for volume"; on
|
||||
a genuinely RWX storage class they would start and then corrupt the SQLite
|
||||
database two processes are now writing. Set it back to 1.
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
THE INGRESS IS THE ONLY UPLOAD CEILING
|
||||
ingress.maxUploadFileSize {{ .Values.ingress.maxUploadFileSize }}
|
||||
|
||||
ConvertX enforces no size limit of its own — none, at any setting — so this
|
||||
annotation is the whole story. A file above it never reaches the
|
||||
application: the user gets the ingress controller's 413 page and nothing
|
||||
appears in the pod log, which makes it look like a broken upload button
|
||||
rather than a limit.
|
||||
|
||||
Raising it has a second cost: the upload lands on the PVC above, so a
|
||||
ceiling far beyond storage.amount just moves the failure from a 413 to a
|
||||
full volume.
|
||||
{{- else }}
|
||||
|
||||
UPLOAD CEILING — NONE FROM THIS CHART
|
||||
ingress.enabled is false, so the chart sets no body-size limit anywhere, and
|
||||
ConvertX enforces none of its own. Whatever proxy you put in front brings
|
||||
its ceiling with it — ingress-nginx defaults to 1m, small enough to look
|
||||
like a broken upload button.
|
||||
{{- end }}
|
||||
|
||||
WHAT A GREEN POD PROVES
|
||||
All three probes are HTTP GETs against /healthcheck, which ConvertX answers
|
||||
with a static 200 and no authentication. It touches neither the database nor
|
||||
the volume, so a green pod proves the process is serving — not that a
|
||||
conversion works and not that the volume is writable.
|
||||
|
||||
It is deliberately NOT "/": with the defaults of this chart "/" answers 302,
|
||||
to /setup while no account exists and to /login afterwards. A probe would
|
||||
pass on that redirect, and pass on a different one from day two onwards.
|
||||
|
||||
The startup probe grants {{ $startup.failureThreshold }} × {{ $startup.periodSeconds }}s = {{ mul $startup.failureThreshold $startup.periodSeconds }}s. That budget is generous because
|
||||
ConvertX shells out to about twenty converter binaries at startup just to
|
||||
log their versions.
|
||||
|
||||
THE FIRST PULL IS SLOW, AND THAT IS NORMAL
|
||||
The image is roughly 1.4 GiB compressed — LibreOffice, Calibre, ImageMagick,
|
||||
ffmpeg, Inkscape and a TeX Live installation are all inside it. On a node
|
||||
that has not seen it before, expect several minutes in ContainerCreating
|
||||
before the startup probe budget above even begins. A pod that looks stuck
|
||||
there is usually still pulling:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} describe pod {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0
|
||||
|
||||
THIS CHART BYPASSES THE IMAGE ENTRYPOINT
|
||||
The image runs as root and drops privileges itself; this chart starts the
|
||||
application directly (command: bun run dist/src/index.js) as uid 1000, with
|
||||
fsGroup doing what the entrypoint's chown would have done. PUID, PGID and
|
||||
UMASK therefore configure NOTHING here — setting them through convertx.env
|
||||
has no effect at all, and the uid belongs in convertx.securityContext.pod.
|
||||
|
||||
The cost to know about: if upstream moves real work into that entrypoint,
|
||||
this chart skips it silently. Re-read the upstream entrypoint.sh at every
|
||||
app-version update.
|
||||
{{- if .Values.convertx.env }}
|
||||
|
||||
EXTRA ENVIRONMENT VARIABLES ARE ACTIVE
|
||||
convertx.env adds {{ len .Values.convertx.env }} variable(s), rendered after the ones the chart sets
|
||||
itself. Two warnings that go with that block:
|
||||
|
||||
- WEBROOT moves every route of the application, /healthcheck included, so
|
||||
it breaks all three probes while the app is perfectly healthy — and this
|
||||
chart's Ingress still maps "/". Do not set it here.
|
||||
- An entry repeating a name the chart already sets is additive, not a
|
||||
replacement. At install yours wins; on an EXISTING release Kubernetes
|
||||
merges env by name and the chart's own value survives instead, while helm
|
||||
upgrade still reports success. Read it back off the StatefulSet.
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
|
||||
EXTRA INGRESS ANNOTATIONS ARE ACTIVE
|
||||
{{- range $key, $value := . }}
|
||||
{{ $key }}: {{ $value }}
|
||||
{{- end }}
|
||||
These are merged with the cert-manager and proxy-body-size annotations the
|
||||
chart always sets. Annotations are interpreted by the ingress controller, so
|
||||
a misspelled key is silently ignored — it does not fail the render.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,327 @@
|
||||
{{/*
|
||||
Render the body of a container "resources:" block from a values dict of the form
|
||||
|
||||
resources:
|
||||
limitFactor: 3 # optional, default 3
|
||||
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
|
||||
limits: { ... } # optional, every entry optional
|
||||
|
||||
Rules (variant B, see issue #19):
|
||||
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
|
||||
to the Kubernetes key "ephemeral-storage").
|
||||
- an explicitly set limit always wins.
|
||||
- a missing memory / ephemeral-storage limit is computed as
|
||||
limitFactor x the corresponding request.
|
||||
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
|
||||
unlimited; CPU is compressible, so this cannot kill the workload).
|
||||
|
||||
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||
the chart's name as define prefix, because sharing it would mean a library
|
||||
chart. template-chart/templates/_helpers.tpl is the reference implementation.
|
||||
*/}}
|
||||
{{- define "convertx.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
{{- $factor := 3.0 -}}
|
||||
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
||||
{{- $factor = float64 $resources.limitFactor -}}
|
||||
{{- end -}}
|
||||
{{- $requests := dict -}}
|
||||
{{- range $key, $value := ($resources.requests | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $requests (include "convertx.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $limits := dict -}}
|
||||
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $limits (include "convertx.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- range $key, $request := $requests -}}
|
||||
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||
{{- $_ := set $limits $key (include "convertx.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $out := dict -}}
|
||||
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
||||
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Normalize a resource name from values to its Kubernetes key.
|
||||
*/}}
|
||||
{{- define "convertx.resourceKey" -}}
|
||||
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
||||
Input: dict "quantity" <string|number> "factor" <float64>.
|
||||
Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
|
||||
("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer
|
||||
results are printed without a decimal point.
|
||||
*/}}
|
||||
{{- define "convertx.multiplyQuantity" -}}
|
||||
{{- $quantity := include "convertx.formatNumber" .quantity -}}
|
||||
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||
{{- if not $number -}}
|
||||
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $suffix := trimPrefix $number $quantity -}}
|
||||
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
||||
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $product := mulf (float64 $number) .factor -}}
|
||||
{{- if eq $product (floor $product) -}}
|
||||
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s%s" (include "convertx.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation.
|
||||
|
||||
Why this exists (issue #157): the null-safe fallback helpers pass values
|
||||
through fromYaml, which parses every number as float64. Go's "%v" switches
|
||||
float64 to exponential notation from about 1e6 upwards, so a bare byte count
|
||||
like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the
|
||||
render aborted. Strings ("512Mi") are passed through untouched.
|
||||
*/}}
|
||||
{{- define "convertx.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render the replica count for the StatefulSet.
|
||||
|
||||
Rules (issue #25):
|
||||
- scaleDown: true takes precedence and always yields 0.
|
||||
- otherwise an explicitly set replicas value is used as given, including an
|
||||
explicit 0 - Helm's "default" would swallow 0, hence the explicit
|
||||
kindIs "invalid" nil check.
|
||||
- a missing replicas value defaults to 1.
|
||||
*/}}
|
||||
{{- define "convertx.replicas" -}}
|
||||
{{- if .scaleDown -}}
|
||||
0
|
||||
{{- else if not (kindIs "invalid" .replicas) -}}
|
||||
{{- .replicas -}}
|
||||
{{- else -}}
|
||||
1
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
||||
null / empty override falls back to the defaults instead of erasing them.
|
||||
|
||||
Input: dict "defaults" <dict> "given" <dict|nil> "path" <string, optional
|
||||
values path used in error messages>. Output: YAML of the merged dict (consume
|
||||
it with fromYaml).
|
||||
|
||||
Why this exists (issues #61 and #99): Helm merges values maps recursively, but
|
||||
an override key written without children - e.g. a leftover
|
||||
|
||||
securityContext:
|
||||
|
||||
after deleting its last remaining sub-key - is YAML null. Where the chart
|
||||
defines a default, Helm treats that null as a deletion of the default; the
|
||||
template then renders "securityContext: null": no readOnlyRootFilesystem, no
|
||||
dropped capabilities, no allowPrivilegeEscalation: false. The workload starts
|
||||
and reports healthy while being unhardened, so nothing points at the mistake.
|
||||
This helper defines the opposite semantics: an empty block means "chart
|
||||
defaults apply".
|
||||
*/}}
|
||||
{{- define "convertx.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
{{- $given := .given -}}
|
||||
{{- $path := .path | default "value" -}}
|
||||
{{- if kindIs "invalid" $given -}}
|
||||
{{- $given = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $given) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
||||
{{- end -}}
|
||||
{{- $out := deepCopy $defaults -}}
|
||||
{{- range $key, $value := $given -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $default := index $defaults $key -}}
|
||||
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
||||
{{- $_ := set $out $key (fromYaml (include "convertx.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
||||
{{- else if kindIs "map" $default -}}
|
||||
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $key $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Pick one sub-block out of a values block that may itself be null.
|
||||
|
||||
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
||||
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
|
||||
non-mapping block with a message that names the values path. Needed because
|
||||
".Values.convertx.securityContext" can itself be erased, and indexing into nil
|
||||
inside a template is not an error but silently yields nothing.
|
||||
*/}}
|
||||
{{- define "convertx.subBlock" -}}
|
||||
{{- $block := .block -}}
|
||||
{{- if kindIs "invalid" $block -}}
|
||||
{{- $block = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $block) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
||||
{{- end -}}
|
||||
{{- toYaml (dict "value" (index $block .key)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective security contexts, probes and resources (issue #99).
|
||||
|
||||
These defaults MUST stay in sync with the corresponding blocks in values.yaml,
|
||||
which stays the documented, user-facing place for them; this copy is only the
|
||||
fallback for the case where an override has erased the block.
|
||||
|
||||
TWO OF THEM REPRODUCE A DELIBERATE DEVIATION AND MUST NOT BE "TIDIED":
|
||||
|
||||
- runAsUser / runAsGroup are 1000, not the repo's arbitrary 20000. The image
|
||||
creates its convertx user at uid 1000 and owns /home/convertx with it, and
|
||||
that uid owns the data on the PersistentVolume. Restoring the repo default
|
||||
here would move the runtime uid off the volume's data.
|
||||
- the probes point at /healthcheck, not at "/". "/" answers 302 in every
|
||||
default configuration of this chart, and the redirect target changes once
|
||||
the first account exists. The port name "http" is part of the context and
|
||||
belongs here too: a default that loses it leaves the workload with a probe
|
||||
that cannot succeed.
|
||||
*/}}
|
||||
{{- define "convertx.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "pod" "path" "convertx.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 1000
|
||||
"runAsGroup" 1000
|
||||
"fsGroup" 1000
|
||||
"fsGroupChangePolicy" "Always"
|
||||
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "convertx.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "container" "path" "convertx.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
|
||||
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.container") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "convertx.livenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "convertx.readinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "convertx.startupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 30 -}}
|
||||
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.startupProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective resources block, fed into the resources helper above so that an
|
||||
erased "resources:" still yields the chart's requests and computed limits
|
||||
instead of a container without any resource accounting.
|
||||
|
||||
The numbers reproduce values.yaml, which carries the reasoning: they are sized
|
||||
for converter subprocesses (LibreOffice, Calibre, ImageMagick, ffmpeg), not
|
||||
for an idle web server, and the ephemeral-storage figure has to cover the two
|
||||
emptyDirs this chart mounts.
|
||||
*/}}
|
||||
{{- define "convertx.effectiveResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" 0.25 "memory" "1Gi" "ephemeralStorage" "1Gi") -}}
|
||||
{{- $merged := fromYaml (include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.resources")) -}}
|
||||
{{- include "convertx.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a values boolean as the literal string the application accepts.
|
||||
|
||||
Input: dict "value" <bool|string|nil> "path" <values path, for the error>
|
||||
"default" <bool, used when the value is nil>
|
||||
|
||||
Why this is not just "{{ .Values.x }}" (README, "Values that must not lie",
|
||||
point 6): ConvertX parses each of these variables as
|
||||
|
||||
process.env.X?.toLowerCase() === "true"
|
||||
|
||||
so ANYTHING that is not the literal "true" means false - "yes", "1", "on",
|
||||
"TRUE " with a trailing space. Two of the three variables this helper renders
|
||||
open the instance up (ACCOUNT_REGISTRATION, ALLOW_UNAUTHENTICATED), and a
|
||||
value written to switch one of them ON that silently lands on OFF is the kind
|
||||
of failure nobody goes looking for, because the symptom is the safe state.
|
||||
|
||||
So: a bool renders as "true"/"false", the strings "true"/"false" render
|
||||
normalized to lower case, nil takes the given default, and everything else
|
||||
ABORTS THE RENDER naming the values path. Go's "not" is deliberately unused
|
||||
here - it treats every non-empty string as true, so "not \"false\"" is false
|
||||
and the string form would flip the wrong way.
|
||||
*/}}
|
||||
{{- define "convertx.boolString" -}}
|
||||
{{- $v := .value -}}
|
||||
{{- if kindIs "invalid" $v -}}
|
||||
{{- $v = .default -}}
|
||||
{{- end -}}
|
||||
{{- if kindIs "bool" $v -}}
|
||||
{{- ternary "true" "false" $v -}}
|
||||
{{- else -}}
|
||||
{{- $s := lower (printf "%v" $v) -}}
|
||||
{{- if or (eq $s "true") (eq $s "false") -}}
|
||||
{{- $s -}}
|
||||
{{- else -}}
|
||||
{{- fail (printf "%s must be true or false, got %q - ConvertX compares this variable against the literal string \"true\", so any other spelling would silently mean false" .path $v) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render AUTO_DELETE_EVERY_N_HOURS.
|
||||
|
||||
An explicit 0 is the documented way to switch the cleanup off, so this cannot
|
||||
go through Helm's "default": that swallows a 0 and would restore 24-hour
|
||||
deletion on exactly the install that asked for none. Same nil-check shape as
|
||||
the replicas helper, and for the same reason.
|
||||
*/}}
|
||||
{{- define "convertx.autoDeleteEveryNHours" -}}
|
||||
{{- if kindIs "invalid" . -}}
|
||||
24
|
||||
{{- else -}}
|
||||
{{- include "convertx.formatNumber" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }}
|
||||
# ConvertX enforces no upload limit of its own (it starts its HTTP server
|
||||
# with maxRequestBodySize: Number.MAX_SAFE_INTEGER), so these two
|
||||
# annotations are the ONLY ceiling on an upload. Both spellings are set
|
||||
# because the two nginx ingress controllers read different keys.
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.ingress.maxUploadFileSize }}
|
||||
nginx.org/client-max-body-size: {{ .Values.ingress.maxUploadFileSize }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "A Domain/Host must be provided" .Values.ingress.domain }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port:
|
||||
name: http
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "A Domain/Host must be provided" .Values.ingress.domain }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.storage.amount }}
|
||||
{{- if .Values.storage.storageClass }}
|
||||
storageClassName: {{ .Values.storage.storageClass }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for the JWT signing key must be provided (secrets.jwtSecretRef)" .Values.secrets.jwtSecretRef }}
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: http
|
||||
name: http
|
||||
type: ClusterIP
|
||||
@@ -0,0 +1,201 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
replicas: {{ include "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.replicas) }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
template:
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
# Hash of the rendered OnePasswordItem, so that repointing
|
||||
# secrets.jwtSecretRef at a DIFFERENT vault item really rolls the pod
|
||||
# instead of leaving the old signing key in the running environment.
|
||||
# A change of the item's CONTENT inside the vault is not covered -
|
||||
# Helm never sees it; see the comment on secrets in values.yaml.
|
||||
# The name differs from checksum/config on purpose: that one promises
|
||||
# "the config changed", this one only "the config source changed".
|
||||
#
|
||||
# operator.1password.io/auto-restart is deliberately NOT set: the
|
||||
# 1Password operator only restarts Deployments, so on a StatefulSet it
|
||||
# is a silent no-op that advertises a guarantee it does not give.
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/convertx.secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "convertx.podSecurityContext" .Values.convertx.securityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
image: "ghcr.io/c4illin/convertx:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
|
||||
# THE IMAGE ENTRYPOINT IS BYPASSED ON PURPOSE. READ THIS BEFORE THE
|
||||
# NEXT APP-VERSION BUMP - IT IS THE ONE BET THIS CHART MAKES.
|
||||
#
|
||||
# ghcr.io/c4illin/convertx declares
|
||||
# ENTRYPOINT ["/entrypoint.sh"]
|
||||
# CMD ["bun", "run", "dist/src/index.js"]
|
||||
# and carries no USER, so the image runs as root by default and
|
||||
# /entrypoint.sh is what drops it to uid 1000 with gosu. Setting
|
||||
# "command" here replaces the ENTRYPOINT (and discards the CMD), so
|
||||
# the application is started directly and Kubernetes does the
|
||||
# privilege work instead: runAsUser/runAsGroup 1000 in the pod
|
||||
# security context, and fsGroup 1000 in place of the entrypoint's
|
||||
# recursive chown of /app/data. The hardening baseline of this repo
|
||||
# stays intact - no root, no capabilities, read-only root fs.
|
||||
#
|
||||
# What the entrypoint of v0.19.0 actually does, read rather than
|
||||
# assumed: nothing but uid/gid plumbing. No migration, no config
|
||||
# generation, no secret generation. ConvertX creates and migrates
|
||||
# its SQLite schema in-process at import time, so starting it
|
||||
# directly is migration-safe. Its very first branch is
|
||||
# if [ -z "$PUID" ] || [ "$PUID" -eq 0 ]; then ... exec "$@"
|
||||
# i.e. with PUID unset it is already a pure pass-through - which is
|
||||
# why bypassing it costs nothing TODAY and is exactly why the risk
|
||||
# below is about tomorrow. The one line we do lose is
|
||||
# "export HOME=/home/convertx", and this chart sets HOME itself
|
||||
# below rather than relying on the runtime's passwd lookup.
|
||||
#
|
||||
# THE RISK: if upstream ever moves real work into the entrypoint - a
|
||||
# data migration is the obvious candidate - this chart skips it
|
||||
# silently. There is no error, no log line and no failed probe; the
|
||||
# application simply starts against an unmigrated volume. So
|
||||
# RE-READ entrypoint.sh AT EVERY APP-VERSION UPDATE, not only on
|
||||
# major jumps, and if it has grown anything beyond uid/gid handling,
|
||||
# stop and reconsider this override.
|
||||
command:
|
||||
- bun
|
||||
- run
|
||||
- dist/src/index.js
|
||||
|
||||
# Not decoration. The application resolves its data paths relative
|
||||
# to the working directory - uploadsDir is "./data/uploads/" and
|
||||
# outputDir is "./data/output/", hardcoded and configurable by no
|
||||
# environment variable. The image sets WORKDIR /app, so this only
|
||||
# restates it; it is here because a different working directory
|
||||
# would put every upload somewhere other than the mounted PVC, and
|
||||
# the application would report no error at all.
|
||||
workingDir: /app
|
||||
|
||||
env:
|
||||
# The entrypoint would have exported this; the image declares no
|
||||
# ENV HOME, so without this line HOME comes from the container
|
||||
# runtime's /etc/passwd lookup for uid 1000. That happens to
|
||||
# resolve to /home/convertx in this image - and "happens to" is
|
||||
# not a basis for the directory LibreOffice, Calibre, Inkscape and
|
||||
# fontconfig write their profiles into on the first conversion.
|
||||
# Set explicitly, and mounted writable below.
|
||||
- name: HOME
|
||||
value: "/home/convertx"
|
||||
# The port the application binds. containerPort, the Service's
|
||||
# targetPort and the three probes all name the same 3000; moving
|
||||
# it means moving all four, which is why it is pinned here rather
|
||||
# than exposed as a value.
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
# Signs the session cookies. Without it ConvertX falls back to
|
||||
# randomUUID() per process, so every restart logs everyone out -
|
||||
# see secrets.jwtSecretRef in values.yaml.
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
key: jwt-secret
|
||||
# The three booleans go through convertx.boolString, which renders
|
||||
# the literal "true"/"false" the application's strict comparison
|
||||
# needs and aborts the render on anything else.
|
||||
- name: ACCOUNT_REGISTRATION
|
||||
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") }}"
|
||||
- name: ALLOW_UNAUTHENTICATED
|
||||
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") }}"
|
||||
- name: HTTP_ALLOWED
|
||||
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") }}"
|
||||
- name: AUTO_DELETE_EVERY_N_HOURS
|
||||
value: "{{ include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours }}"
|
||||
{{- range $value := .Values.convertx.env }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name | quote }}
|
||||
# quote is required, not cosmetic: a value like "8080" or "true"
|
||||
# renders unquoted as a YAML number/bool, and the API server then
|
||||
# rejects the whole manifest.
|
||||
value: {{ tpl $value.value $ | quote }}
|
||||
{{- else if $value.valueFrom }}
|
||||
{{- if $value.valueFrom.secretKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
|
||||
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
|
||||
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
|
||||
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
# Probes, resources and both security contexts go through the
|
||||
# null-safe helpers (#99): an erased block means "chart defaults
|
||||
# apply" instead of a vanished probe or an unhardened container.
|
||||
# Never wrap these in an "if" - a guard hides the erased case
|
||||
# completely instead of rendering a visible null.
|
||||
livenessProbe:
|
||||
{{- include "convertx.livenessProbe" .Values.convertx.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "convertx.readinessProbe" .Values.convertx.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "convertx.startupProbe" .Values.convertx.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "convertx.effectiveResources" .Values.convertx.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "convertx.containerSecurityContext" .Values.convertx.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
# SQLite database, uploads and converted output - everything the
|
||||
# application persists lives under this one path.
|
||||
- mountPath: /app/data
|
||||
name: data
|
||||
# Two writable emptyDirs so the root filesystem can stay
|
||||
# read-only. Neither is a precaution: ConvertX itself writes only
|
||||
# under /app/data, but it runs its conversions as subprocesses
|
||||
# (soffice, ebook-convert, magick, gs, xelatex, ffmpeg...) that
|
||||
# inherit its environment, and those need scratch space.
|
||||
#
|
||||
# /home/convertx is HOME. LibreOffice creates its user profile
|
||||
# there on first use - it is invoked without
|
||||
# -env:UserInstallation, so there is no way to point it elsewhere
|
||||
# - and Calibre, Inkscape, ImageMagick and fontconfig do the same
|
||||
# with their own config and cache directories. The directory
|
||||
# exists in the image (useradd -m) and is owned by 1000:1000, but
|
||||
# it sits on the read-only root filesystem, so it has to be
|
||||
# overmounted to be usable.
|
||||
- mountPath: /home/convertx
|
||||
name: home
|
||||
# /tmp is the scratch directory of the toolchain: Ghostscript,
|
||||
# TeX Live and Calibre all use it during a conversion.
|
||||
#
|
||||
# Both of these count against the container's ephemeral-storage
|
||||
# limit - see the resources comment in values.yaml.
|
||||
- mountPath: /tmp
|
||||
name: tmp
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
- name: home
|
||||
emptyDir: {}
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "convertx values",
|
||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key that the chart does not read is rejected at render time instead of being silently ignored. The only objects that stay open are the ones passed verbatim into the Kubernetes pod spec (probes, security contexts, global) and the free-form Ingress annotation map - the chart does not interpret their contents, so it cannot judge which keys are meaningful. The hardening, probe and resources blocks additionally accept null, because an erased block falls back to the chart defaults (issue #99).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"global": {
|
||||
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
|
||||
"type": "object"
|
||||
},
|
||||
"scaleDown": {
|
||||
"description": "true renders the StatefulSet with replicas: 0 and overrides convertx.replicas. PVC, Secret, Service and Ingress stay in place.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"jwtSecretRef": {
|
||||
"description": "REQUIRED. op:// path of the 1Password item carrying the field 'jwt-secret', which becomes JWT_SECRET. Enforced by required() in the template rather than by a 'required' array here: the key ships as null, so JSON Schema's required - which tests presence, not null-ness - could never fire on it.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"convertx": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "0 is allowed and scales the workload down. Values above 1 are accepted by the schema but do not work: the SQLite database and the uploads share one ReadWriteOnce PVC.",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"accountRegistration": {
|
||||
"description": "ACCOUNT_REGISTRATION - lets anyone with the URL create an account. Defaults to false; upstream warns against enabling it on an internet-facing instance. A string is accepted for the 'true'/'false' spellings only; anything else aborts the render with a message naming this path, because the application would silently read it as false.",
|
||||
"type": ["boolean", "string", "null"]
|
||||
},
|
||||
"allowUnauthenticated": {
|
||||
"description": "ALLOW_UNAUTHENTICATED - drops authentication entirely. Defaults to false; upstream describes this as local-only. Same accepted spellings as accountRegistration.",
|
||||
"type": ["boolean", "string", "null"]
|
||||
},
|
||||
"httpAllowed": {
|
||||
"description": "HTTP_ALLOWED - clears the 'secure' flag on the session cookies. Only needed when ConvertX is served without TLS in front; behind this chart's Ingress the browser speaks https, so the default false is correct. Same accepted spellings as accountRegistration.",
|
||||
"type": ["boolean", "string", "null"]
|
||||
},
|
||||
"autoDeleteEveryNHours": {
|
||||
"description": "AUTO_DELETE_EVERY_N_HOURS - how old a job may get before ConvertX deletes its uploads and output. 0 disables the cleanup and is honoured as such (the chart uses a nil check, not Helm's 'default', which would swallow the 0). This is the retention policy for the PVC.",
|
||||
"type": ["integer", "number", "null"],
|
||||
"minimum": 0
|
||||
},
|
||||
"securityContext": {
|
||||
"$ref": "#/definitions/securityContext"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/definitions/resources"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"description": "The data PVC: SQLite database, uploads and converted output. Not null-safe on purpose - an erased block yields a PVC without a size, which the API server rejects at apply time rather than the chart sizing the volume silently.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"storageClass": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the whole Ingress template. false frees BOTH clusterIssuer and domain: nothing outside the Ingress reads the domain in this chart, because ConvertX has no base-URL or redirect-URI variable.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxUploadFileSize": {
|
||||
"description": "Rendered into the ingress-nginx proxy-body-size annotation, so it only takes effect while the Ingress is enabled. It is the only upload ceiling that exists - ConvertX enforces none of its own.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"clusterIssuer": {
|
||||
"description": "REQUIRED while ingress.enabled is true (enforced by required() in the template).",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"domain": {
|
||||
"description": "REQUIRED while ingress.enabled is true (enforced by required() in the template).",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager and proxy-body-size annotations the chart always sets. Free-form keys by nature.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limitFactor": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"requests": {
|
||||
"$ref": "#/definitions/resourceList"
|
||||
},
|
||||
"limits": {
|
||||
"$ref": "#/definitions/resourceList"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourceList": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cpu": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"memory": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"ephemeral-storage": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"ephemeralStorage": {
|
||||
"type": ["string", "number", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueFrom": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"secretKeyRef": {
|
||||
"$ref": "#/definitions/keyRef"
|
||||
},
|
||||
"configMapKeyRef": {
|
||||
"$ref": "#/definitions/keyRef"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keyRef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99) instead of rendering an unhardened workload. Those defaults pin runAsUser/runAsGroup 1000 - the uid the image creates and the uid that owns the data on the PersistentVolume - rather than the repo's arbitrary 20000.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pod": {
|
||||
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
},
|
||||
"container": {
|
||||
"description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"probe": {
|
||||
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The defaults target /healthcheck on the port named http - overriding the path to '/' would probe a 302 whose target changes once the first account exists.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
||||
# every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and
|
||||
# takes precedence over any per-workload replicas value. PVC, Secret, Service
|
||||
# and Ingress stay in place; scaleDown: false brings the workload back up.
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// item path for the JWT signing key. REQUIRED - the chart aborts the
|
||||
# render without it, and that is deliberate rather than convenient.
|
||||
#
|
||||
# ConvertX signs its session cookies with JWT_SECRET and falls back to
|
||||
# randomUUID() when the variable is unset (src/services/user.ts:
|
||||
# "secret: process.env.JWT_SECRET ?? randomUUID()"). That fallback is
|
||||
# per PROCESS, not per install: every restart - a node drain, an image
|
||||
# update, an OOM kill - mints a fresh secret and silently invalidates every
|
||||
# session. Nothing logs it; users simply find themselves logged out. A value
|
||||
# that has to survive a restart is a secret, so it is one here.
|
||||
#
|
||||
# The item must carry this field, spelled exactly like this, because the
|
||||
# 1Password operator copies an item's FIELD NAMES into the Secret's keys:
|
||||
#
|
||||
# jwt-secret a long random string (openssl rand -hex 32)
|
||||
#
|
||||
# A field spelled differently does not fail the install: the operator writes
|
||||
# a Secret without that key and the pod stays in CreateContainerConfigError,
|
||||
# which looks like a broken chart rather than a typo in a vault item.
|
||||
jwtSecretRef: null
|
||||
|
||||
convertx:
|
||||
# Replica count for this workload; 0 is allowed and scales it down.
|
||||
# The chart-global scaleDown flag takes precedence.
|
||||
#
|
||||
# Raising this above 1 does not work and the chart does not stop you, so the
|
||||
# reason is here: the SQLite database, the uploads and the converted output
|
||||
# all live on the single ReadWriteOnce PVC below, which is a plain chart
|
||||
# object rather than a volumeClaimTemplate. A second pod normally never
|
||||
# schedules ("Multi-Attach error for volume"); on a genuinely RWX storage
|
||||
# class it would schedule and then corrupt the WAL-mode SQLite file that two
|
||||
# processes are now writing. On top of that each pod would sign cookies with
|
||||
# its own JWT_SECRET only if the secret were unset - which is exactly why
|
||||
# secrets.jwtSecretRef above is required.
|
||||
replicas: 1
|
||||
|
||||
# --- Two switches that open the instance up. Both default to false. ---
|
||||
#
|
||||
# UPSTREAM WARNS EXPLICITLY AGAINST ENABLING EITHER ON AN INTERNET-FACING
|
||||
# INSTANCE, and this chart puts an Ingress with a public DNS name in front
|
||||
# by default, so that warning applies to the standard install here.
|
||||
#
|
||||
# Both are parsed strictly by the application: it compares the lowercased
|
||||
# string against "true" and treats everything else as false. The chart
|
||||
# therefore accepts only a boolean or the strings "true"/"false" (any case)
|
||||
# and ABORTS THE RENDER on anything else - "yes" or "1" would otherwise be
|
||||
# filed under "off" without a word, which is the wrong direction to fail in
|
||||
# for a value whose whole job is to open the service up.
|
||||
|
||||
# Lets anyone with the URL create an account. Note what it does NOT gate:
|
||||
# the FIRST account is always creatable, because a ConvertX with no users
|
||||
# serves a setup form to whoever opens the page first. Keeping this false
|
||||
# protects accounts two and onwards, not account one.
|
||||
accountRegistration: false
|
||||
|
||||
# Drops authentication entirely - anyone reaching the Service can upload
|
||||
# files and read every conversion. Upstream describes this as local-only.
|
||||
allowUnauthenticated: false
|
||||
|
||||
# Clears the "secure" flag on the session cookies (the app computes
|
||||
# "secure: !HTTP_ALLOWED"). Only needed when ConvertX is served over plain
|
||||
# http, and that is NOT our case: the Ingress terminates TLS, so the browser
|
||||
# speaks https and sets the cookie happily while only the in-cluster hop
|
||||
# between ingress controller and pod is http - which the browser never sees.
|
||||
# Turning this on behind our Ingress weakens the cookies for no gain.
|
||||
# (Same shape as outline's forceHttps, and the same reason to leave it off.)
|
||||
httpAllowed: false
|
||||
|
||||
# How often ConvertX deletes jobs - uploads and converted output - older
|
||||
# than this many hours. 0 disables the cleanup entirely.
|
||||
#
|
||||
# This is the retention policy for the PVC below, which is why it is a value
|
||||
# and not left at the upstream default. With cleanup disabled the volume
|
||||
# grows with every conversion and nothing reclaims it; size storage.amount
|
||||
# for that case, or accept that the pod eventually fills its volume.
|
||||
#
|
||||
# An explicit 0 is honoured (the chart uses a nil check, not Helm's
|
||||
# "default", which would swallow the 0 and silently restore 24-hour
|
||||
# deletion on a value set precisely to switch it off).
|
||||
autoDeleteEveryNHours: 24
|
||||
|
||||
# DOCUMENTED DEVIATION FROM THE HARDENING BASELINE - runAsUser/runAsGroup.
|
||||
#
|
||||
# The deviation is about the NUMBER only: nothing in the baseline is
|
||||
# relaxed. The repo default is an arbitrary uid (20000); this chart pins
|
||||
# 1000, and the reason is the image, not a preference.
|
||||
#
|
||||
# ghcr.io/c4illin/convertx carries NO USER instruction - verified in the
|
||||
# published v0.19.0 image config, where "User" is empty - so without a
|
||||
# securityContext the pod would run as root. Its Dockerfile creates
|
||||
# "convertx" with uid 1000, gid 1000 and home /home/convertx (useradd -u
|
||||
# 1000 -g convertx -d /home/convertx -m), and the image's entrypoint exists
|
||||
# only to drop to that user with gosu.
|
||||
#
|
||||
# 1000 rather than 20000 for two reasons that both have to hold:
|
||||
# - /home/convertx belongs to 1000:1000 in the image, and HOME is where
|
||||
# LibreOffice, Calibre and fontconfig put their profiles. An arbitrary
|
||||
# uid gets a HOME of "/" from the runtime's passwd lookup instead.
|
||||
# - the uid owns data on a PersistentVolume, so changing it later makes
|
||||
# existing conversions inaccessible. This number is not free to tidy.
|
||||
securityContext:
|
||||
pod:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
# fsGroup is what replaces the entrypoint's recursive chown of
|
||||
# /app/data: the kubelet applies the group to the mounted volume, so
|
||||
# the container can write it as uid 1000 without any privileged step.
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: Always
|
||||
# RuntimeDefault applies the container runtime's default seccomp filter,
|
||||
# which blocks the syscalls a normal workload never makes. It sits in the
|
||||
# pod context on purpose: from here it covers every container in the pod
|
||||
# including init containers, while a container-level seccompProfile would
|
||||
# have to be repeated for each one (and silently missed on the next one
|
||||
# added). A container that genuinely needs a different filter can still
|
||||
# override it under securityContext.container, which wins over this value.
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add: []
|
||||
|
||||
# ConvertX serves GET /healthcheck: a static 200 with {"status":"ok"},
|
||||
# explicitly exempted from authentication (src/pages/healthcheck.tsx,
|
||||
# "auth: false"). All three probes use it.
|
||||
#
|
||||
# Why NOT "/", the obvious default: with the chart defaults "/" is never a
|
||||
# 200. It answers 302 to /setup while no user exists and 302 to /login
|
||||
# afterwards, so a probe against it would pass on a redirect whose target
|
||||
# changes the moment somebody registers - a green signal that means
|
||||
# something different on day two than on day one. httpGet does count 3xx as
|
||||
# success, which is what makes that the quiet kind of wrong.
|
||||
#
|
||||
# What the endpoint proves, and what it does not: the handler touches
|
||||
# neither the database nor the volume, so it cannot report a broken PVC.
|
||||
# It is still the right readiness target here, because the SQLite database
|
||||
# is opened at module import - before app.listen() is reached - so a process
|
||||
# that answers /healthcheck at all has already opened its database. There is
|
||||
# no external dependency in this chart to hang readiness on; the "database"
|
||||
# is a file on the pod's own volume.
|
||||
#
|
||||
# WEBROOT SHIFTS THIS PATH. The application mounts every route under the
|
||||
# WEBROOT prefix, /healthcheck included. This chart does not offer WEBROOT
|
||||
# as a value (see the env block below), and setting it through env without
|
||||
# also moving these three paths leaves the pod probing a 404 forever.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthcheck
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthcheck
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
# The startup probe gates liveness/readiness, so those need no
|
||||
# initialDelaySeconds. Budget = periodSeconds * failureThreshold = 300s.
|
||||
#
|
||||
# Generous on purpose. At import time - under NODE_ENV=production, which
|
||||
# the image sets - ConvertX shells out to roughly twenty converter binaries
|
||||
# to log their versions (soffice, ebook-convert, xelatex, magick, ffmpeg,
|
||||
# markitdown and the rest). Those are fire-and-forget and do not block
|
||||
# app.listen(), but they all contend for the pod's CPU at exactly the moment
|
||||
# the first probe arrives, and the first soffice/ebook-convert run also
|
||||
# builds its profile under HOME. A tight budget turns a slow node into a
|
||||
# crash loop.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /healthcheck
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 30
|
||||
|
||||
# Extra environment variables, appended to the ones the chart always sets.
|
||||
# Values are rendered through tpl; value, secretKeyRef and configMapKeyRef
|
||||
# forms are supported.
|
||||
#
|
||||
# This is where the upstream variables this chart does not expose as values
|
||||
# belong - BRANDING, LANGUAGE, HIDE_HISTORY, MAX_CONVERT_PROCESS,
|
||||
# UNAUTHENTICATED_USER_SHARING, TZ, FFMPEG_ARGS, FFMPEG_OUTPUT_ARGS. They
|
||||
# change the UI or a converter's behaviour and nothing this chart renders,
|
||||
# so they need no value of their own.
|
||||
#
|
||||
# THREE VARIABLES DO NOT BELONG HERE, and they fail in three different ways:
|
||||
#
|
||||
# WEBROOT moves EVERY route, /healthcheck included, so the three
|
||||
# probes above start failing against a 404 while the app is
|
||||
# perfectly healthy - and this chart's Ingress still maps "/".
|
||||
# Serving under a sub-path needs the probe paths and the
|
||||
# Ingress path changed together; the chart does not do that
|
||||
# for you.
|
||||
# PUID/PGID are read by the image entrypoint, which this chart bypasses
|
||||
# /UMASK (see the command: override in the StatefulSet). They reach
|
||||
# nothing and configure nothing here. Set the uid through
|
||||
# securityContext.pod above, which is what actually applies.
|
||||
# PORT the app would listen on it, but containerPort, the Service
|
||||
# and the probes all still name 3000. Moving the port means
|
||||
# moving all four, so the chart pins it instead.
|
||||
#
|
||||
# Additive means additive: an entry repeating a name the chart already sets
|
||||
# does not replace it. At install the later entry - yours - wins; on an
|
||||
# EXISTING release Kubernetes merges env by name, the pair collapses to the
|
||||
# chart's own value, and helm upgrade still reports success. Read the value
|
||||
# back off the StatefulSet rather than trusting a green upgrade.
|
||||
env: []
|
||||
|
||||
resources:
|
||||
# Limits are optional: memory and ephemeral-storage limits default to
|
||||
# limitFactor x the request, an explicitly set limit always wins, and a
|
||||
# CPU limit is only set when configured explicitly under "limits".
|
||||
#
|
||||
# The numbers are sized for conversions, not for an idle web server, and
|
||||
# each one has a reason:
|
||||
#
|
||||
# cpu 0.25 An idle Bun process uses almost nothing, but a
|
||||
# conversion saturates whatever it can get and the startup
|
||||
# fires ~20 version probes at once. Per repo convention no
|
||||
# CPU LIMIT is computed, so this is the scheduling floor
|
||||
# and the throttling share under contention, not a cap -
|
||||
# a conversion may burst well past it on an idle node.
|
||||
#
|
||||
# memory 1Gi soffice --headless is a few hundred MB on its own,
|
||||
# ebook-convert pulls in Qt WebEngine, and ImageMagick on
|
||||
# a large raster can want more than either. 1Gi requested,
|
||||
# 3Gi limit (limitFactor 3). Memory is NOT compressible:
|
||||
# a conversion that crosses the limit is an OOM kill of
|
||||
# the whole pod, so raise the request before feeding the
|
||||
# instance large video or raw-image work.
|
||||
#
|
||||
# ephemeral- 1Gi, and this one is easy to get wrong: EMPTYDIR VOLUMES
|
||||
# storage COUNT AGAINST IT. The chart mounts two (/tmp and
|
||||
# /home/convertx) for the converter toolchain, and
|
||||
# Ghostscript, TeX and Calibre all scratch to /tmp. The
|
||||
# uploads and the converted output do NOT count - those
|
||||
# live on the PVC - so the figure covers scratch space
|
||||
# only. Crossing the 3Gi limit evicts the pod.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 0.25
|
||||
memory: 1Gi
|
||||
ephemeralStorage: 1Gi
|
||||
|
||||
# The volume behind /app/data: the SQLite database, every uploaded file and
|
||||
# every converted result. There is no second store and no object storage.
|
||||
#
|
||||
# Not null-safe on purpose, following the repo's documented exception: an
|
||||
# erased block yields a PVC without a size, which the API server rejects at
|
||||
# apply time. That is louder than a volume quietly sized by the chart.
|
||||
storage:
|
||||
amount: 10Gi
|
||||
storageClass: null
|
||||
|
||||
ingress:
|
||||
# Gates the whole Ingress template. Checked in both directions as the style
|
||||
# guide requires: NOTHING outside the Ingress reads ingress.domain in this
|
||||
# chart - ConvertX has no base-URL, public-URL or redirect-URI variable at
|
||||
# all (verified against the complete env list in src/helpers/env.ts) - so
|
||||
# switching the Ingress off frees BOTH domain and clusterIssuer here.
|
||||
enabled: true
|
||||
|
||||
# THE ONLY UPLOAD CEILING THERE IS.
|
||||
#
|
||||
# ConvertX sets no size limit of its own: it starts Elysia with
|
||||
# maxRequestBodySize: Number.MAX_SAFE_INTEGER, and v0.19.0 has no upload
|
||||
# variable of any kind. So unlike outline - where an app limit and an
|
||||
# ingress limit sat a factor of ten apart and the smaller one won
|
||||
# confusingly - there is exactly one number here, and it is this one.
|
||||
#
|
||||
# Two consequences worth knowing before changing it:
|
||||
# - A file above this limit never reaches ConvertX. The user gets the
|
||||
# ingress controller's 413 page, not a message from the application,
|
||||
# and nothing appears in the pod log.
|
||||
# - With ingress.enabled: false there is NO ceiling from this chart at
|
||||
# all, and whatever proxy you put in front brings its own - usually
|
||||
# ingress-nginx's 1m default, which is small enough to look like a
|
||||
# broken upload button.
|
||||
#
|
||||
# Defaulted generously because the point of the application is converting
|
||||
# video and large documents. What it costs: the body transits the ingress
|
||||
# controller and then lands on the PVC, so raising it means checking
|
||||
# storage.amount as well.
|
||||
maxUploadFileSize: 1024m
|
||||
|
||||
clusterIssuer: null
|
||||
domain: null
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer and proxy-body-size annotations the chart always sets.
|
||||
annotations: {}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -1,26 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: garage
|
||||
description: A Helm chart to deploy Garage, an S3-compatible object store, as a single node on Kubernetes
|
||||
icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg
|
||||
|
||||
type: application
|
||||
|
||||
# appVersion is the exact image tag, and the "v" is part of it - so it is part
|
||||
# of appVersion and of the "+up" suffix. Precedent: stalwart (+upv0.16.24),
|
||||
# calibre-web-automated, matrix-synapse, ytdlp-web-player.
|
||||
#
|
||||
# The image is docker.io/dxflrs/garage, the MULTI-ARCH one. Upstream's own chart
|
||||
# pins dxflrs/amd64_garage instead; that is a per-architecture image and pinning
|
||||
# it would tie the chart to amd64 for no gain.
|
||||
#
|
||||
# THE TAG TRAP THIS VERSION HAS TO SURVIVE: next to the release tags the
|
||||
# registry continuously receives COMMIT-SHA TAGS (40 hex characters, the newest
|
||||
# of them dated 2026-10-03). A Renovate rule without a version constraint would
|
||||
# offer those as "updates", which is why the garage block in renovate.json5
|
||||
# carries a versioningTemplate anchored to ^v<major>.<minor>.<patch>$ - same
|
||||
# shape as the stalwart block, for the same reason.
|
||||
version: 1.0.0+upv2.4.1
|
||||
appVersion: "v2.4.1"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -1,144 +0,0 @@
|
||||
{{- $settings := fromYaml (include "garage.settings" .) -}}
|
||||
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
Garage {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
ONE node, replication_factor = {{ $settings.replicationFactor | int64 }}, metadata engine {{ $settings.dbEngine }}.
|
||||
|
||||
S3 ENDPOINT (in-cluster, path-style addressing):
|
||||
http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.s3 | int64 }}
|
||||
region: {{ $settings.s3.region }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
published at: https://{{ .Values.ingress.domain }}
|
||||
{{- end }}
|
||||
{{- if $settings.web.enabled }}
|
||||
static website endpoint on port {{ $settings.ports.web | int64 }}{{ with $settings.web.rootDomain }} (root domain {{ . }}){{ else }} - WITHOUT a rootDomain it serves nothing; set garage.web.rootDomain{{ end }}
|
||||
{{- end }}
|
||||
|
||||
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
|
||||
The operator materializes one Secret key per item FIELD, so a missing or
|
||||
misnamed field leaves the pod in CreateContainerConfigError.
|
||||
|
||||
{{ .Values.secrets.rpcSecretRef }}
|
||||
rpc-secret 32 bytes of random hex: openssl rand -hex 32
|
||||
{{ if .Values.secrets.s3SecretRef }} {{ .Values.secrets.s3SecretRef }}
|
||||
access-key >= 8 chars, [A-Za-z0-9-_.]: openssl rand -hex 12
|
||||
secret-key >= 16 printable ASCII chars: openssl rand -hex 32
|
||||
bucket a valid S3 bucket name (3-63 chars, lowercase)
|
||||
{{ else }} (no S3 key item configured - secrets.s3SecretRef is unset)
|
||||
{{ end }}
|
||||
{{- if and $settings.singleNode (or $settings.defaultAccessKey $settings.defaultBucket) }}
|
||||
THE DIRECTION IS INWARDS: Garage IMPORTS{{ if $settings.defaultAccessKey }} the access key{{ end }}{{ if $settings.defaultBucket }}{{ if $settings.defaultAccessKey }} and{{ end }} the bucket{{ end }} from
|
||||
that item on start. The vault is the source of truth and nothing has to be
|
||||
copied out of the server.
|
||||
{{- end }}
|
||||
|
||||
FIRST START
|
||||
{{ if $settings.singleNode }}
|
||||
Nothing to do by hand. The server created its own cluster layout before it
|
||||
opened any listener{{ if $settings.defaultAccessKey }}, imported the access key{{ end }}{{ if $settings.defaultBucket }} and created the bucket{{ end }}.
|
||||
Verify it, in this order - all commands need /garage as the command, because
|
||||
the image has no shell:
|
||||
|
||||
1. kubectl -n {{ .Release.Namespace }} rollout status statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
Ready means the admin API answered 200 on /health, which means a layout
|
||||
exists and the node has quorum. Not-Ready with a running container means
|
||||
the layout is missing - check the logs for "Created initial layout".
|
||||
|
||||
2. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage status
|
||||
One node, with a role (zone dc1, a capacity taken from the detected disk
|
||||
size). A node WITHOUT a role means no layout was applied.
|
||||
|
||||
3. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage layout show
|
||||
{{- if $settings.defaultBucket }}
|
||||
|
||||
4. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage bucket list
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage bucket info <bucket>
|
||||
The bucket exists and the imported key has read/write/owner on it.
|
||||
{{- end }}
|
||||
|
||||
5. One write through the S3 API from a client pod, which is the only step
|
||||
that proves the endpoint rather than the process.
|
||||
{{ else }}
|
||||
garage.singleNode is false, so THIS RELEASE SERVES NOTHING YET and the pod
|
||||
will stay not-Ready until a layout exists - that is correct, not a defect: the
|
||||
readiness probe reports /health, and /health answers 503 while there is no
|
||||
layout. Apply one by hand, in this order:
|
||||
|
||||
1. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage status
|
||||
Note the node id and the current layout version (0 on a fresh install).
|
||||
|
||||
2. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- \
|
||||
/garage layout assign -z dc1 -c <capacity> <node-id>
|
||||
The capacity is this node's usable size, roughly storage.data.amount
|
||||
({{ .Values.storage.data.amount }}). It takes BYTE suffixes B, KB, MB, GB, TB, PB - not
|
||||
Kubernetes' Gi/Mi, so write e.g. 10GB, not 10Gi.
|
||||
|
||||
3. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage layout show
|
||||
Read it before applying. The next step is NOT reversible by repeating it.
|
||||
|
||||
4. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage layout apply --version <N+1>
|
||||
N is the version from step 1, so a fresh cluster takes --version 1.
|
||||
NEVER call this twice with the same --version: the flag is a guard
|
||||
against applying a staged change you have not seen, and reusing a number
|
||||
is how a layout is applied twice.
|
||||
|
||||
5. kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- /garage bucket create <bucket>
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- \
|
||||
/garage key import --yes <access-key> <secret-key>
|
||||
kubectl -n {{ .Release.Namespace }} exec {{ $pod }} -- \
|
||||
/garage bucket allow --read --write --owner --key <access-key> <bucket>
|
||||
|
||||
"key import" rather than "key create", so the credential still comes FROM
|
||||
the vault item. "key create" generates one and then it has to travel the
|
||||
other way, through a terminal, with the vault as a copy.
|
||||
|
||||
6. The pod becomes Ready within one probe period after step 4.
|
||||
{{ end }}
|
||||
WHAT THIS CHART DOES NOT RENDER, AND WHAT IS MISSING WITHOUT IT
|
||||
|
||||
- THE LONGHORN BACKUP LABELS. recurring-job.longhorn.io/daily-snapshot and
|
||||
.../daily-backup belong to the consuming bundle, not to this chart. They do
|
||||
NOT apply automatically: without them the off-site half of the backup plan
|
||||
does not exist, it only looks like it does.
|
||||
- The namespace's volume-encryption secret, if the StorageClass is
|
||||
longhorn-encrypted-ns: it resolves
|
||||
${pvc.namespace}-pvc-encryption-secret, and without that entry the PVCs
|
||||
stay Pending.
|
||||
- No kubernetes_discovery CRD and no bootstrap peers: a single node has no
|
||||
members to find.
|
||||
|
||||
TRAPS WORTH KNOWING BEFORE YOU NEED THEM
|
||||
|
||||
- THE METADATA IS NOT IN YOUR VOLUME BACKUP in any usable form. A
|
||||
file-system snapshot of a live metadata database is not a restore point -
|
||||
taken during a write it is corrupt. What IS a restore point are Garage's
|
||||
own snapshots, on the second PVC, taken every {{ $settings.metadataAutoSnapshotInterval }}.
|
||||
- THE WINDOW IS SHORT: exactly TWO snapshots are kept, the first after half
|
||||
the interval and every later one after the interval times a random
|
||||
1.0-1.2. With metadata_auto_snapshot_interval = {{ $settings.metadataAutoSnapshotInterval }} the oldest
|
||||
recovery point is therefore between one and two and a half intervals old,
|
||||
never more. A corruption found later than that is older than every
|
||||
snapshot that still exists.
|
||||
- metadata_fsync is ON here and OFF in Garage's defaults. If you ever see it
|
||||
missing from garage.toml, that is a regression, not a cleanup.
|
||||
- replication_factor 1 applies to the METADATA as well. Upstream calls a
|
||||
corrupted metadata database "generally not recoverable", and the volume
|
||||
layer replicates a corrupted file as faithfully as a good one.
|
||||
- ROTATING THE S3 SECRET IS NOT A RE-SAVE. Garage refuses to start when the
|
||||
access key already exists with a different secret ("is associated with a
|
||||
secret key different than the one given in GARAGE_DEFAULT_SECRET_KEY"). To
|
||||
rotate: either put a NEW access-key id into the item (the old key stays and
|
||||
can be deleted with "/garage key delete"), or delete the old key first and
|
||||
then update both fields.
|
||||
- A CHANGE INSIDE A VAULT ITEM RESTARTS NOTHING. Helm never sees it. After
|
||||
changing a field:
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
(Repointing a secrets.*SecretRef at a different item DOES roll the pod -
|
||||
that is covered by checksum/secret-ref.)
|
||||
- THE IMAGE HAS NO SHELL. "kubectl exec -it ... -- sh" does not work and
|
||||
never will; every command above uses /garage directly. Exec probes are
|
||||
impossible in this chart for the same reason.
|
||||
- THE RPC SECRET IS IN THE PROCESS ENVIRONMENT, not in a file. That is
|
||||
forced, not chosen: Garage rejects a secret file with any group bit set,
|
||||
and the kubelet sets one. values.yaml has the mechanism.
|
||||
@@ -1,392 +0,0 @@
|
||||
{{/*
|
||||
Render the body of a container "resources:" block from a values dict of the form
|
||||
|
||||
resources:
|
||||
limitFactor: 3 # optional, default 3
|
||||
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
|
||||
limits: { ... } # optional, every entry optional
|
||||
|
||||
Rules (variant B, see issue #19):
|
||||
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
|
||||
to the Kubernetes key "ephemeral-storage").
|
||||
- an explicitly set limit always wins.
|
||||
- a missing memory / ephemeral-storage limit is computed as
|
||||
limitFactor x the corresponding request.
|
||||
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
|
||||
unlimited; CPU is compressible, so this cannot kill the workload).
|
||||
|
||||
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||
the chart's name as define prefix, because sharing it would mean a library
|
||||
chart, and a dependency is admissible but expensive (README, guideline 14) -
|
||||
this chart takes none. The reference implementation is
|
||||
template-chart/templates/_helpers.tpl.
|
||||
*/}}
|
||||
{{- define "garage.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
{{- $factor := 3.0 -}}
|
||||
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
||||
{{- $factor = float64 $resources.limitFactor -}}
|
||||
{{- end -}}
|
||||
{{- $requests := dict -}}
|
||||
{{- range $key, $value := ($resources.requests | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $requests (include "garage.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $limits := dict -}}
|
||||
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $limits (include "garage.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- range $key, $request := $requests -}}
|
||||
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||
{{- $_ := set $limits $key (include "garage.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $out := dict -}}
|
||||
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
||||
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Normalize a resource name from values to its Kubernetes key.
|
||||
*/}}
|
||||
{{- define "garage.resourceKey" -}}
|
||||
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
||||
Input: dict "quantity" <string|number> "factor" <float64>.
|
||||
*/}}
|
||||
{{- define "garage.multiplyQuantity" -}}
|
||||
{{- $quantity := include "garage.formatNumber" .quantity -}}
|
||||
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||
{{- if not $number -}}
|
||||
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $suffix := trimPrefix $number $quantity -}}
|
||||
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
||||
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $product := mulf (float64 $number) .factor -}}
|
||||
{{- if eq $product (floor $product) -}}
|
||||
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s%s" (include "garage.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation
|
||||
(issue #157): the null-safe fallback helpers pass values through fromYaml,
|
||||
which parses every number as float64, and Go's "%v" switches float64 to
|
||||
exponential notation from about 1e6 upwards - "1.048576e+06" is not a valid
|
||||
Kubernetes quantity. Strings ("512Mi") are passed through untouched.
|
||||
*/}}
|
||||
{{- define "garage.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render the replica count for the StatefulSet.
|
||||
|
||||
Input: dict "scaleDown" <flag|nil> "replicas" <value|nil>.
|
||||
|
||||
- scaleDown: true takes precedence and always yields 0.
|
||||
- otherwise an explicitly set replicas value is used as given, including an
|
||||
explicit 0 - Helm's "default" would swallow 0, hence the kindIs "invalid"
|
||||
nil check.
|
||||
- a missing replicas value defaults to 1.
|
||||
|
||||
A NOTE SPECIFIC TO THIS CHART: more than one replica is not a cluster. Garage
|
||||
nodes find each other through RPC and a cluster LAYOUT, and this chart renders
|
||||
neither peer bootstrapping nor the kubernetes_discovery CRD (see values.yaml).
|
||||
A second replica would come up as a lone node with its own empty metadata db on
|
||||
its own volume, and with --single-node it refuses to start at all once it sees
|
||||
the other node. The value exists because guideline 11 requires it and because
|
||||
0 is the documented way to stop the release; the schema caps it at 1.
|
||||
*/}}
|
||||
{{- define "garage.replicas" -}}
|
||||
{{- if .scaleDown -}}
|
||||
0
|
||||
{{- else if not (kindIs "invalid" .replicas) -}}
|
||||
{{- .replicas -}}
|
||||
{{- else -}}
|
||||
1
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
||||
null / empty override falls back to the defaults instead of erasing them
|
||||
(issues #61, #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
|
||||
<string>. Output: YAML of the merged dict (consume it with fromYaml).
|
||||
|
||||
An override key written without children is YAML null, and Helm treats that
|
||||
null as a deletion of the chart's default. This helper defines the opposite
|
||||
semantics - an empty block means "chart defaults apply" - while an explicitly
|
||||
set value still wins, including false and 0.
|
||||
*/}}
|
||||
{{- define "garage.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
{{- $given := .given -}}
|
||||
{{- $path := .path | default "value" -}}
|
||||
{{- if kindIs "invalid" $given -}}
|
||||
{{- $given = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $given) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
||||
{{- end -}}
|
||||
{{- $out := deepCopy $defaults -}}
|
||||
{{- range $key, $value := $given -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $default := index $defaults $key -}}
|
||||
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
||||
{{- $_ := set $out $key (fromYaml (include "garage.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
||||
{{- else if kindIs "map" $default -}}
|
||||
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $key $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Pick one sub-block out of a values block that may itself be null.
|
||||
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
||||
Returns YAML of dict "value" <the sub-block, may be nil>.
|
||||
*/}}
|
||||
{{- define "garage.subBlock" -}}
|
||||
{{- $block := .block -}}
|
||||
{{- if kindIs "invalid" $block -}}
|
||||
{{- $block = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $block) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
||||
{{- end -}}
|
||||
{{- toYaml (dict "value" (index $block .key)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
THE EFFECTIVE GARAGE SETTINGS - everything the ConfigMap and the StatefulSet
|
||||
read out of .Values.garage, merged onto this chart's defaults.
|
||||
|
||||
Why the whole settings block goes through the null-safe helper and not only the
|
||||
three usual suspects (guideline 12.2: "the rule is about the shape, not about
|
||||
those three block kinds"): every value below is dereferenced by a template, and
|
||||
an erased one would not abort the render - it would render a garage.toml with
|
||||
an empty right-hand side, or a containerPort with no number. The first produces
|
||||
a pod that crash-loops on a config parse error, the second a manifest the API
|
||||
server rejects at apply time. Both are failures that arrive after the render
|
||||
looked fine, which is exactly the class #99 is about. Satisfactory shipped the
|
||||
same shape with half its blocks null-safe and half not (Gitea #16), and the
|
||||
half that was not took the Service's port with it.
|
||||
|
||||
The defaults here MUST stay in sync with values.yaml, which remains the
|
||||
documented, user-facing place for them; this copy is only the fallback.
|
||||
|
||||
Usage: {{ $settings := fromYaml (include "garage.settings" .) }}
|
||||
*/}}
|
||||
{{- define "garage.settings" -}}
|
||||
{{- $given := (fromYaml (include "garage.subBlock" (dict "block" .Values "key" "garage" "path" ".Values"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"singleNode" true
|
||||
"defaultAccessKey" true
|
||||
"defaultBucket" true
|
||||
"replicationFactor" 1
|
||||
"dbEngine" "lmdb"
|
||||
"metadataFsync" true
|
||||
"dataFsync" false
|
||||
"metadataAutoSnapshotInterval" "6h"
|
||||
"compressionLevel" 1
|
||||
"blockSize" "1M"
|
||||
"bindAddress" "[::]"
|
||||
"adminBindAddress" "0.0.0.0"
|
||||
"ports" (dict "s3" 3900 "rpc" 3901 "web" 3902 "admin" 3903)
|
||||
"s3" (dict "region" "garage" "rootDomain" nil)
|
||||
"web" (dict "enabled" false "rootDomain" nil) -}}
|
||||
{{- include "garage.defaultedDict" (dict "defaults" $defaults "given" $given "path" "garage") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The three directories, defined once because TWO templates have to agree on
|
||||
them: garage.toml names them and the StatefulSet mounts the volumes underneath
|
||||
them. They are deliberately NOT values - a path that disagrees with its mount
|
||||
is a pod that writes its metadata into the container's ephemeral layer, which
|
||||
looks like it works until the pod is replaced.
|
||||
|
||||
The split is the point of this chart's storage layout (see values.yaml):
|
||||
metadata and data are two SUBDIRECTORIES OF ONE VOLUME, the metadata snapshots
|
||||
sit on a SECOND one. Garage creates all three itself on start
|
||||
(src/model/garage.rs, create_dir_all for metadata_dir and data_dir;
|
||||
src/model/snapshot.rs for the snapshots dir), which matters here because the
|
||||
image is FROM scratch and carries no mkdir - nothing else in this pod could
|
||||
create them.
|
||||
*/}}
|
||||
{{- define "garage.dataVolumePath" -}}/mnt/garage{{- end -}}
|
||||
{{- define "garage.metadataDir" -}}{{ include "garage.dataVolumePath" . }}/meta{{- end -}}
|
||||
{{- define "garage.dataDir" -}}{{ include "garage.dataVolumePath" . }}/data{{- end -}}
|
||||
{{- define "garage.snapshotsDir" -}}/mnt/garage-snapshots{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective security contexts, probes and resources (issue #99).
|
||||
|
||||
The defaults below MUST stay in sync with values.yaml. They reproduce THIS
|
||||
chart's defaults, not the repo standard - see the fsGroupChangePolicy note.
|
||||
*/}}
|
||||
{{- define "garage.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "garage.subBlock" (dict "block" . "key" "pod" "path" "garage.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 20000
|
||||
"runAsGroup" 1000
|
||||
"fsGroup" 1000
|
||||
"fsGroupChangePolicy" "OnRootMismatch"
|
||||
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||
{{- include "garage.defaultedDict" (dict "defaults" $defaults "given" $given "path" "garage.securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "garage.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "garage.subBlock" (dict "block" . "key" "container" "path" "garage.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
|
||||
{{- include "garage.defaultedDict" (dict "defaults" $defaults "given" $given "path" "garage.securityContext.container") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
LIVENESS: a TCP connect to the admin port, and NOT the /health endpoint the
|
||||
readiness probe uses. The reason is the one zipline wrote down (README,
|
||||
guideline 12.3) in its sharpest form: /health answers 503 until the cluster
|
||||
LAYOUT exists (src/rpc/system.rs, "Layout not yet configured, special case" ->
|
||||
ClusterHealthStatus::Unavailable -> 503 in src/api/admin/special.rs). A node
|
||||
waiting for its layout - every install with garage.singleNode: false, and any
|
||||
install whose layout was lost - would therefore be killed and restarted
|
||||
forever, and a restart is precisely what cannot fix it.
|
||||
|
||||
What the TCP probe does prove is what a liveness probe is for: the process is
|
||||
alive and its listener is open. A wedged process that stops accepting is
|
||||
restarted; a healthy process that has nothing to serve yet is not.
|
||||
*/}}
|
||||
{{- define "garage.livenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"tcpSocket" (dict "port" "admin")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "garage.defaultedDict" (dict "defaults" $defaults "given" . "path" "garage.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
READINESS: GET /health on the admin port - the one endpoint that answers the
|
||||
question a readiness probe asks, namely "can this node serve requests".
|
||||
|
||||
Measured against the source at tag v2.4.1 rather than chosen:
|
||||
|
||||
- the S3 API server is spawned independently of the layout state
|
||||
(src/garage/server.rs, the S3/web/admin servers are all spawned after
|
||||
initial_config), so the S3 socket binds and ANSWERS - with errors - before
|
||||
any layout exists. A tcpSocket probe on port 3900 would therefore report a
|
||||
service ready that accepts nothing, which is the lie this chart is not
|
||||
allowed to tell.
|
||||
- /health needs no token (@special in src/api/admin/router_v2.rs) and maps
|
||||
ClusterHealthStatus straight onto the status code: Healthy and Degraded ->
|
||||
200, Unavailable -> 503 with "Quorum is not available for some/all
|
||||
partitions, reads and writes will fail". An unconfigured layout is a
|
||||
special case in src/rpc/system.rs and yields Unavailable.
|
||||
|
||||
So with garage.singleNode: true the pod becomes Ready a moment after start
|
||||
(initial_config applies the layout BEFORE the servers are spawned), and with
|
||||
singleNode: false it stays not-Ready until an operator applies a layout by hand
|
||||
- which is the truth, not a defect: until then the S3 endpoint accepts nothing
|
||||
and no Service should route to it.
|
||||
*/}}
|
||||
{{- define "garage.readinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/health" "port" "admin")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "garage.defaultedDict" (dict "defaults" $defaults "given" . "path" "garage.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
STARTUP: TCP again, for the same reason as liveness - a startup probe that
|
||||
fails forever kills the container, so it must not depend on a layout that only
|
||||
an operator can create.
|
||||
|
||||
Budget = periodSeconds * failureThreshold = 5 * 24 = 120s. It covers the slow
|
||||
part of a real start, which is not Garage: attaching and mounting two network
|
||||
volumes, plus opening (and on an unclean shutdown recovering) the LMDB
|
||||
metadata database. Garage's own startup work after that is sub-second.
|
||||
*/}}
|
||||
{{- define "garage.startupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"tcpSocket" (dict "port" "admin")
|
||||
"periodSeconds" 5
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 24 -}}
|
||||
{{- include "garage.defaultedDict" (dict "defaults" $defaults "given" . "path" "garage.startupProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective resources block, fed into the resources helper above so that an
|
||||
erased "resources:" still yields the chart's requests and computed limits.
|
||||
*/}}
|
||||
{{- define "garage.effectiveResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" "100m" "memory" "512Mi" "ephemeral-storage" "32Mi") -}}
|
||||
{{- $merged := fromYaml (include "garage.defaultedDict" (dict "defaults" $defaults "given" . "path" "garage.resources")) -}}
|
||||
{{- include "garage.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The additive env block, rendered through tpl with the value, secretKeyRef and
|
||||
configMapKeyRef forms (guideline 10).
|
||||
|
||||
ADDITIVE MEANS ADDITIVE: an entry repeating a name this chart already sets does
|
||||
not replace it. At install time both reach the pod and the later one wins; on
|
||||
an EXISTING release the pair collapses to the chart's own value, because
|
||||
Kubernetes merges env by name - and helm upgrade still reports success. So do
|
||||
not use this block to override GARAGE_RPC_SECRET or the three
|
||||
GARAGE_DEFAULT_* variables; change the values that produce them.
|
||||
*/}}
|
||||
{{- define "garage.env" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- range $value := .env }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name | quote }}
|
||||
# quote is required, not cosmetic: a value like "3900" or "true" renders
|
||||
# unquoted as a YAML number/bool, and the API server then rejects the whole
|
||||
# manifest with "cannot unmarshal number into Go struct field EnvVar.value of
|
||||
# type string".
|
||||
value: {{ tpl $value.value $root | quote }}
|
||||
{{- else if $value.valueFrom }}
|
||||
{{- if $value.valueFrom.secretKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
|
||||
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -1,173 +0,0 @@
|
||||
{{- $settings := fromYaml (include "garage.settings" .) }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
data:
|
||||
# The complete garage.toml. It carries NO SECRET: the RPC secret arrives as
|
||||
# the environment variable GARAGE_RPC_SECRET (see the StatefulSet and
|
||||
# values.yaml for why the file form cannot be used here), so this ConfigMap
|
||||
# is safe to read and the checksum/config annotation on the pod template
|
||||
# still means "the configuration changed".
|
||||
#
|
||||
# It is mounted with subPath at /etc/garage.toml, which is the path Garage
|
||||
# looks at without being told (-c defaults to /etc/garage.toml). A subPath
|
||||
# mount is never refreshed by the kubelet, which is exactly why the checksum
|
||||
# annotation is mandatory rather than decorative.
|
||||
garage.toml: |
|
||||
# Managed by the garage Helm chart - changes here are overwritten on the
|
||||
# next helm upgrade. Edit the chart's values instead.
|
||||
|
||||
# ONE node, and the replication factor is 1 BY DECISION, with the risk
|
||||
# named (Gitea #81): longhorn-encrypted-ns already keeps three replicas of
|
||||
# the encrypted volume, and Garage replication 3 on top of that would be
|
||||
# nine times the raw data for two layers that know nothing about each
|
||||
# other.
|
||||
#
|
||||
# WHAT THAT COSTS, because it is not free: upstream says of
|
||||
# replication_factor = 1 "Do not use this for anything else than test
|
||||
# deployments", and the reason is on its known-issues page - the same
|
||||
# factor applies to the METADATA, so a single-replica setup is especially
|
||||
# exposed to metadata corruption, and a corrupted database file is
|
||||
# "generally not recoverable". Longhorn does not help against that: it
|
||||
# replicates a corrupted file three times. Redundancy against device
|
||||
# failure, yes; against file corruption, no. The three settings below are
|
||||
# the countermeasures that do work at factor 1, and none of them is
|
||||
# optional.
|
||||
replication_factor = {{ $settings.replicationFactor | int64 }}
|
||||
|
||||
# LMDB is Garage's default since 0.9 and the faster engine. The price is
|
||||
# named: the reported corruption after an unclean shutdown stays possible,
|
||||
# the settings below only lower the probability, and at factor 1 there is
|
||||
# no peer to recover from - only the last snapshot. Upstream ties its LMDB
|
||||
# recommendation to replication factor >= 2; running it at 1 is this
|
||||
# chart's deliberate deviation.
|
||||
#
|
||||
# The alternative is "sqlite", which is slower on metadata throughput. With
|
||||
# a single mail server as the only consumer that ceiling is unlikely to be
|
||||
# reached, so sqlite is a defensible choice for anyone who weighs the
|
||||
# durability of a single-file database higher. "fjall" is experimental.
|
||||
db_engine = "{{ $settings.dbEngine }}"
|
||||
|
||||
# metadata_dir and data_dir are two SUBDIRECTORIES OF ONE VOLUME; the
|
||||
# snapshots live on a second one. Longhorn snapshots are atomic PER VOLUME
|
||||
# and never across volumes, so keeping the index and the blocks together is
|
||||
# what prevents the one failure mode that matters for a mail store: a
|
||||
# backup whose metadata points at objects whose blocks were not written yet
|
||||
# ("the mailbox lists a message whose body is missing").
|
||||
#
|
||||
# The snapshots are on the OTHER volume for the opposite reason: a recovery
|
||||
# point must not die with the volume whose corruption it is there to heal.
|
||||
metadata_dir = "{{ include "garage.metadataDir" . }}"
|
||||
data_dir = "{{ include "garage.dataDir" . }}"
|
||||
metadata_snapshots_dir = "{{ include "garage.snapshotsDir" . }}"
|
||||
|
||||
# metadata_fsync IS OFF IN GARAGE'S DEFAULTS, and this is the single
|
||||
# setting most easily lost in a "tidy up the obvious defaults" pass. It is
|
||||
# set here on purpose: Longhorn's block layer cannot help if the engine
|
||||
# never calls fsync - a write that was never handed to the kernel is not in
|
||||
# any replica and not in any snapshot. Garage's own example configuration
|
||||
# pairs it with metadata_auto_snapshot_interval, as here.
|
||||
metadata_fsync = {{ $settings.metadataFsync }}
|
||||
# data_fsync stays off, which is Garage's default and the right trade for
|
||||
# the blocks: a lost block is one object, is detectable by a scrub, and is
|
||||
# replaceable from the sender's copy; a lost metadata write can take the
|
||||
# whole index with it. Turning it on costs an fsync per written block.
|
||||
data_fsync = {{ $settings.dataFsync }}
|
||||
|
||||
# THE ONLY CONSISTENT RECOVERY POINT THIS DEPLOYMENT HAS. File-system
|
||||
# snapshots of metadata_dir guarantee nothing - Garage says so itself: a
|
||||
# snapshot taken during a metadata write is corrupt and useless as a
|
||||
# restore point, which is why Garage recommends ITS OWN snapshot mechanism
|
||||
# in all cases. This is the correction that reversed a premise of the
|
||||
# original plan: Longhorn backs up the blocks, not the metadata.
|
||||
#
|
||||
# KNOW THE WINDOW: Garage keeps exactly TWO snapshots (KEEP_SNAPSHOTS = 2),
|
||||
# the first one after interval/2 and every later one after interval times a
|
||||
# random 1.0-1.2. At "6h" that means the first after ~3h and a restore
|
||||
# window of roughly 6-14.4h afterwards. A corruption discovered later than
|
||||
# that is older than every snapshot that still exists.
|
||||
metadata_auto_snapshot_interval = "{{ $settings.metadataAutoSnapshotInterval }}"
|
||||
|
||||
block_size = "{{ $settings.blockSize }}"
|
||||
# An integer is the zstd level; the literal "none" disables compression and
|
||||
# has to be QUOTED to stay valid TOML. Garage's deserializer takes exactly
|
||||
# those two forms - and 0 is not the second one: it is a valid integer that
|
||||
# zstd reads as its default level (3), so writing 0 to "switch compression
|
||||
# off" quietly turns it up.
|
||||
{{- if kindIs "string" $settings.compressionLevel }}
|
||||
compression_level = {{ $settings.compressionLevel | quote }}
|
||||
{{- else }}
|
||||
compression_level = {{ $settings.compressionLevel | int64 }}
|
||||
{{- end }}
|
||||
|
||||
# Inter-node RPC. There are no other nodes here (no bootstrap_peers, no
|
||||
# kubernetes_discovery - see values.yaml), but Garage binds this listener
|
||||
# regardless and refuses to start without an rpc_secret, which arrives from
|
||||
# the environment.
|
||||
rpc_bind_addr = "{{ $settings.bindAddress }}:{{ $settings.ports.rpc | int64 }}"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "{{ $settings.bindAddress }}:{{ $settings.ports.s3 | int64 }}"
|
||||
# The region name clients must use. It is not a cloud region, it is a label
|
||||
# both sides have to agree on - a consumer configured for a different
|
||||
# region gets a signature error, not a redirect.
|
||||
s3_region = "{{ $settings.s3.region }}"
|
||||
{{- with $settings.s3.rootDomain }}
|
||||
# Vhost-style bucket access (<bucket>.<root_domain>). Only useful with an
|
||||
# Ingress and a wildcard DNS entry in front of it; path-style access works
|
||||
# without it and is what an in-cluster consumer uses.
|
||||
root_domain = {{ . | quote }}
|
||||
{{- end }}
|
||||
|
||||
{{- if $settings.web.enabled }}
|
||||
|
||||
[s3_web]
|
||||
# Static website hosting, OFF by default (see values.yaml): it is a second
|
||||
# HTTP listener and serves nothing at all without a root_domain, while the
|
||||
# consumer this chart was built for speaks S3.
|
||||
bind_addr = "{{ $settings.bindAddress }}:{{ $settings.ports.web | int64 }}"
|
||||
{{- with $settings.web.rootDomain }}
|
||||
root_domain = {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
[admin]
|
||||
# Bound on 0.0.0.0 rather than [::] BECAUSE ALL THREE PROBES USE THIS PORT.
|
||||
# A dual-stack listener on [::] only accepts the kubelet's IPv4 connection
|
||||
# while net.ipv6.bindv6only is 0 - true by default on Linux, but a node
|
||||
# sysctl this chart cannot see, and the failure would be a pod that never
|
||||
# becomes Ready for a reason nothing in the manifest points at. The S3 and
|
||||
# RPC listeners keep the upstream [::] default; set garage.bindAddress to
|
||||
# "0.0.0.0" on an IPv4-only cluster, or garage.adminBindAddress to "[::]"
|
||||
# on an IPv6-only one.
|
||||
#
|
||||
# NO admin_token IS SET, and that is not an omission: /health, the one
|
||||
# endpoint the probes use, is explicitly token-free (@special in the admin
|
||||
# router), and nothing in this deployment calls the authenticated API - the
|
||||
# CLI inside the pod talks RPC, not HTTP. A token here would be a fifth
|
||||
# vault field guarding nothing that is in use.
|
||||
#
|
||||
# WHAT IS AND IS NOT REACHABLE AS A RESULT, stated precisely because "no
|
||||
# token" reads like "nothing is open":
|
||||
# /health open, by design, and the probes depend on it
|
||||
# /metrics OPEN TOO - the metrics_require_token switch defaults to
|
||||
# false, so without a metrics credential the endpoint needs no
|
||||
# authentication. That is internal metrics on a ClusterIP port;
|
||||
# to close it, set that switch true and add a credential.
|
||||
#
|
||||
# (And a note for whoever edits these lines: do NOT write them
|
||||
# as "<key> = <value>" with a credential-ish key name. Trivy's
|
||||
# KSV-0109 reads an assignment inside this ConfigMap's DATA -
|
||||
# comments included - as a stored secret and reports HIGH. It
|
||||
# cost one run here, 2026-10-03.)
|
||||
# everything else 401/403 until a token exists. Note that a config
|
||||
# token is not the only way to get one: Garage 2.x also keeps
|
||||
# admin tokens in the cluster ("garage admin-token create"),
|
||||
# and those work without any entry in this file.
|
||||
# If you do want a config token, it is admin_token_file / the
|
||||
# GARAGE_ADMIN_TOKEN environment variable - and the file form carries the
|
||||
# same permission trap as rpc_secret_file (see values.yaml).
|
||||
api_bind_addr = "{{ $settings.adminBindAddress }}:{{ $settings.ports.admin | int64 }}"
|
||||
@@ -1,50 +0,0 @@
|
||||
{{- /*
|
||||
OFF BY DEFAULT (ingress.enabled: false), because the consumer this chart was
|
||||
built for speaks S3 inside the cluster and needs no published endpoint. The
|
||||
values exist anyway, built to this repo's ingress convention, so that
|
||||
publishing the endpoint later is a values change rather than a chart change.
|
||||
|
||||
GUIDELINE 1, CHECKED IN BOTH DIRECTIONS (README, "Values that must not lie"):
|
||||
nothing outside this template reads ingress.domain. The S3 API's vhost-style
|
||||
suffix is its own value, garage.s3.rootDomain, because the two are not the same
|
||||
thing - a wildcard vhost domain and the single host an Ingress answers on. So
|
||||
with the Ingress switched off, BOTH domain and clusterIssuer are free, and
|
||||
their required() lives here where it can actually fire.
|
||||
|
||||
WHAT IT PUBLISHES: the S3 API. There is no web UI to expose - Garage's admin
|
||||
API is JSON and the authenticated half is closed in this chart (no token), and
|
||||
the project's web interface is a separate program that is not part of this
|
||||
image. Anyone publishing this should know what the object they are putting on
|
||||
the internet is: an S3 endpoint that authenticates every request with a
|
||||
signature, whose credentials are the two fields of the s3 vault item.
|
||||
*/ -}}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port:
|
||||
name: s3
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- end }}
|
||||
@@ -1,63 +0,0 @@
|
||||
{{- /*
|
||||
TWO claims, and the split is the durability design of this chart rather than a
|
||||
sizing convenience (Gitea #81):
|
||||
|
||||
1. <release>-garage-pvc metadata_dir AND data_dir, as two
|
||||
subdirectories of this one volume
|
||||
2. <release>-garage-snapshots-pvc metadata_snapshots_dir
|
||||
|
||||
Claim 1 keeps index and blocks on ONE volume because Longhorn snapshots are
|
||||
atomic per volume and never across volumes. Split over two volumes, a backup
|
||||
can capture metadata that points at objects whose blocks were not written yet -
|
||||
for a mail store exactly the failure "the mailbox lists a message whose body is
|
||||
missing".
|
||||
|
||||
Claim 2 exists because Garage's own metadata snapshots are the only consistent
|
||||
recovery point here (file-system snapshots of a live metadata database are not
|
||||
one), and a recovery point stored on the volume whose corruption it should
|
||||
repair is not a recovery point.
|
||||
|
||||
Both blocks are DELIBERATELY NOT null-safe: an erased size yields an invalid
|
||||
PVC that is rejected at apply time, which is loud. A chart-side fallback would
|
||||
silently size somebody's object store - and on an existing release it could not
|
||||
help anyway, because a PVC cannot shrink. Same reasoning as satisfactory's
|
||||
documented exception (Gitea #39) and valkey's persistence block.
|
||||
|
||||
Neither claim is ever renamed: a renamed PVC makes Kubernetes provision a new,
|
||||
empty volume and orphans the data.
|
||||
*/ -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.storage.data.amount }}
|
||||
{{- if .Values.storage.data.storageClass }}
|
||||
storageClassName: {{ .Values.storage.data.storageClass }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-snapshots-pvc
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
# Sized for TWO snapshots of the metadata database plus head room, not
|
||||
# for the objects - Garage keeps exactly two and deletes the older ones
|
||||
# itself. A snapshot is a compacted copy of the LMDB file, so this needs
|
||||
# roughly twice the size metadata_dir reaches.
|
||||
storage: {{ .Values.storage.snapshots.amount }}
|
||||
{{- if .Values.storage.snapshots.storageClass }}
|
||||
storageClassName: {{ .Values.storage.snapshots.storageClass }}
|
||||
{{- end }}
|
||||
@@ -1,71 +0,0 @@
|
||||
{{- /*
|
||||
The 1Password items this chart materializes. Two of them, because they have
|
||||
different audiences: the RPC secret belongs to this node alone, while the S3
|
||||
access key is the credential the CONSUMER uses - the same item a consuming
|
||||
chart (stalwart's secrets.s3SecretRef) points at, which is why it carries the
|
||||
bucket name as a third field: endpoint, bucket, key and secret then have one
|
||||
source of truth instead of being kept in step by hand.
|
||||
|
||||
WHAT THE FIELD NAMES ARE AND WHY THEY ARE NOT FREE-FORM: the operator
|
||||
materializes one Secret key per FIELD of the item, and this chart references
|
||||
those keys by name in the StatefulSet's env block. An item without the
|
||||
expected field leaves the container in CreateContainerConfigError. NOTES.txt
|
||||
lists all four fields for the installer.
|
||||
|
||||
AND THE LIMIT THAT CANNOT BE CLOSED: a change to the CONTENT of either item
|
||||
restarts nothing. Helm never sees a secret the 1Password operator
|
||||
materializes, so there is nothing to hash. What this chart does cover is the
|
||||
op:// PATH, through checksum/secret-ref on the pod template. After rotating a
|
||||
value INSIDE an item the manual step is
|
||||
kubectl -n <ns> rollout restart statefulset/<release>-garage-sfs
|
||||
and for the access key that is not enough on its own - see NOTES.txt, Garage
|
||||
refuses to adopt a changed secret for an access key it already knows.
|
||||
The 1Password operator's auto-restart annotation is NOT set here and must not
|
||||
be: verified against operator 1.12.0 it enumerates Deployments only, so on a
|
||||
StatefulSet it is a silent no-op advertising a guarantee it does not provide
|
||||
(README, guideline 9).
|
||||
*/ -}}
|
||||
{{- $settings := fromYaml (include "garage.settings" .) -}}
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-rpc-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
# Must carry the field: rpc-secret
|
||||
#
|
||||
# 32 bytes of random hex - Garage rejects anything else with "Invalid RPC
|
||||
# secret key: expected 32 bytes of random hex". Generate it with
|
||||
# openssl rand -hex 32
|
||||
# It is required even for a single node: Garage uses it as the network key of
|
||||
# its RPC layer and refuses to start without it ("rpc_secret value is
|
||||
# missing, not present in config file or in environment").
|
||||
itemPath: {{ required "You must provide an RPC secret reference (secrets.rpcSecretRef) - the 1Password item holding this cluster's 32-byte hex 'rpc-secret' field" .Values.secrets.rpcSecretRef }}
|
||||
{{- $s3Needed := and $settings.singleNode (or $settings.defaultAccessKey $settings.defaultBucket) }}
|
||||
{{- if or .Values.secrets.s3SecretRef $s3Needed }}
|
||||
---
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-s3-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
# Must carry the fields: access-key, secret-key, bucket
|
||||
#
|
||||
# THE DIRECTION IS INWARDS, and that is the part worth knowing: with
|
||||
# garage.singleNode plus defaultAccessKey/defaultBucket, Garage IMPORTS these
|
||||
# three values on start (--default-access-key / --default-bucket read them
|
||||
# from the environment). The vault item is the source of truth, and nothing
|
||||
# has to be copied out of the server afterwards.
|
||||
#
|
||||
# Garage validates all three on import and refuses to START on a malformed
|
||||
# one, so they are not free-form labels (src/model/key_table.rs,
|
||||
# src/model/bucket_alias_table.rs):
|
||||
# access-key at least 8 characters, ASCII alphanumeric plus - _ .
|
||||
# secret-key at least 16 characters, printable ASCII (U+0021-U+007E)
|
||||
# bucket a valid S3 bucket name (3-63 characters, lowercase)
|
||||
# NOTES.txt prints the generators.
|
||||
itemPath: {{ required "You must provide an S3 key reference (secrets.s3SecretRef) - the 1Password item holding the fields access-key, secret-key and bucket. It is required while garage.singleNode is true together with defaultAccessKey or defaultBucket, because Garage imports those values on start" .Values.secrets.s3SecretRef }}
|
||||
{{- end }}
|
||||
@@ -1,45 +0,0 @@
|
||||
{{- $settings := fromYaml (include "garage.settings" .) }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
# The S3 endpoint. In-cluster consumers reach it at
|
||||
# http://<release>-garage-service.<namespace>.svc.cluster.local:3900
|
||||
# with path-style addressing (bucket in the path), which needs no
|
||||
# root_domain and no DNS wildcard.
|
||||
- protocol: TCP
|
||||
port: {{ $settings.ports.s3 | int64 }}
|
||||
targetPort: s3
|
||||
name: s3
|
||||
# The admin endpoint. With this chart's configuration it serves exactly two
|
||||
# things without authentication - /health (token-free by design) and
|
||||
# /metrics (because metrics_require_token defaults to false) - and answers
|
||||
# everything else with 401/403 until an admin token exists, whether from
|
||||
# the config or from "garage admin-token create". So it is a monitoring
|
||||
# surface rather than a management one, and it is a ClusterIP port.
|
||||
- protocol: TCP
|
||||
port: {{ $settings.ports.admin | int64 }}
|
||||
targetPort: admin
|
||||
name: admin
|
||||
{{- if $settings.web.enabled }}
|
||||
- protocol: TCP
|
||||
port: {{ $settings.ports.web | int64 }}
|
||||
targetPort: web
|
||||
name: web
|
||||
{{- end }}
|
||||
# DELIBERATELY NO RPC PORT. Port 3901 exists on the pod because Garage binds
|
||||
# it either way, but nothing should reach it: a single node has no peers, and
|
||||
# this chart renders neither bootstrap_peers nor the kubernetes_discovery CRD.
|
||||
# A Service port would advertise a cluster interface that is not one.
|
||||
#
|
||||
# ClusterIP, and no value to change it. The S3 API authenticates every
|
||||
# request, but the way out of the cluster is an Ingress with a certificate
|
||||
# (ingress.enabled, off by default), not a NodePort that puts an object store
|
||||
# on a routable address by accident.
|
||||
type: ClusterIP
|
||||
@@ -1,208 +0,0 @@
|
||||
{{- $settings := fromYaml (include "garage.settings" .) }}
|
||||
{{- /*
|
||||
Catch the one flag combination Garage rejects at START, at render time instead:
|
||||
--default-access-key and --default-bucket "can only be used in single-node
|
||||
mode" (src/garage/server.rs). Without this check the chart would install
|
||||
cleanly and the container would exit immediately, with the reason only in the
|
||||
pod log.
|
||||
*/ -}}
|
||||
{{- if and (not $settings.singleNode) (or $settings.defaultAccessKey $settings.defaultBucket) }}
|
||||
{{- fail "garage.defaultAccessKey and garage.defaultBucket require garage.singleNode: true - Garage refuses to start otherwise (\"Flags --default-access-key and --default-bucket can only be used in single-node mode\"). With singleNode: false, create the key and the bucket by hand after applying the cluster layout; NOTES.txt has the commands." }}
|
||||
{{- end }}
|
||||
{{- $importKey := and $settings.singleNode (or $settings.defaultAccessKey $settings.defaultBucket) }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
replicas: {{ include "garage.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.garage.replicas) }}
|
||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
template:
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
# Hash of the rendered ConfigMap: a changed garage.toml changes the pod
|
||||
# template and thus restarts the pod. Without it the change would reach
|
||||
# nothing - the config is mounted with subPath (never refreshed by the
|
||||
# kubelet) and Garage reads it once, at start.
|
||||
checksum/config: {{ include (print $.Template.BasePath "/garage.configmap.yaml") . | sha256sum }}
|
||||
# Hash of the rendered OnePasswordItems. This covers the SOURCE of the
|
||||
# secrets, not their content: repointing secrets.rpcSecretRef or
|
||||
# secrets.s3SecretRef at a different vault item rolls the pod instead of
|
||||
# leaving the old credentials in the running process. A change INSIDE an
|
||||
# item cannot be covered - Helm never sees it (see garage.secret.yaml).
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/garage.secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "garage.podSecurityContext" .Values.garage.securityContext | nindent 8 }}
|
||||
# NO INIT CONTAINER, and that is a property of the image rather than a
|
||||
# choice: dxflrs/garage is built FROM scratch and contains exactly one
|
||||
# file, /garage. There is no shell, no mkdir, no wget - so an init
|
||||
# container would have to be a SECOND image to pin, track and update.
|
||||
# Nothing needs one: Garage creates metadata_dir, data_dir and the
|
||||
# snapshots directory itself on start (create_dir_all in
|
||||
# src/model/garage.rs and src/model/snapshot.rs), and the volumes are made
|
||||
# writable by fsGroup.
|
||||
#
|
||||
# The same property rules out exec probes in this chart for good - all
|
||||
# three probes are HTTP or TCP, see the helpers.
|
||||
containers:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
# Fully qualified: an unqualified "dxflrs/garage" resolves to Docker
|
||||
# Hub by convention rather than by rule. The multi-arch image, not
|
||||
# upstream's own chart's dxflrs/amd64_garage.
|
||||
image: "docker.io/dxflrs/garage:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
# command AND args, because the image's CMD is ["/garage", "server"]
|
||||
# and flags have to go after the subcommand. Overriding only args
|
||||
# would drop the "server" subcommand.
|
||||
command:
|
||||
- /garage
|
||||
args:
|
||||
- server
|
||||
{{- if $settings.singleNode }}
|
||||
# --single-node is what makes this chart's default install reach
|
||||
# Ready without an operator (Gitea #81, second plan correction):
|
||||
# initial_config() in src/garage/server.rs creates the cluster
|
||||
# layout in-process BEFORE the S3, web and admin servers are
|
||||
# spawned - zone "dc1", capacity taken from the detected disk size,
|
||||
# then apply_staged_changes(1).
|
||||
#
|
||||
# IT IS IDEMPOTENT, and it cannot damage an existing cluster. The
|
||||
# layout version decides: 0 creates the layout, 1 does nothing at
|
||||
# all, anything above 1 refuses to start ("Refusing to run in
|
||||
# single-node mode: layout version is already superior to 1"), and
|
||||
# a node that sees other nodes refuses as well ("more nodes are
|
||||
# already present in the cluster"). There is no --version argument
|
||||
# that could be passed twice with the same value, which is the trap
|
||||
# the CLI path (garage layout apply --version N+1) has.
|
||||
#
|
||||
# It requires replication_factor = 1 and says so loudly if it is
|
||||
# not.
|
||||
- --single-node
|
||||
{{- if or $settings.defaultAccessKey $settings.defaultBucket }}
|
||||
# Imports the access key from GARAGE_DEFAULT_ACCESS_KEY /
|
||||
# GARAGE_DEFAULT_SECRET_KEY below, i.e. FROM the vault item INTO
|
||||
# Garage. Re-running is guarded upstream: a key that already exists
|
||||
# with a different secret aborts the start, and one that was deleted
|
||||
# in the cluster cannot be added back.
|
||||
- --default-access-key
|
||||
{{- end }}
|
||||
{{- if $settings.defaultBucket }}
|
||||
# Creates the bucket from GARAGE_DEFAULT_BUCKET and grants the key
|
||||
# above read/write/owner on it. Implies --default-access-key.
|
||||
- --default-bucket
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
env:
|
||||
# THE RPC SECRET AS AN ENVIRONMENT VARIABLE, NOT AS A FILE - and the
|
||||
# full reason is in values.yaml under secrets.rpcSecretRef. The
|
||||
# short version, because this is the line a later round will want to
|
||||
# "fix": rpc_secret_file CANNOT WORK in a non-root pod.
|
||||
# read_secret_file rejects any file whose mode has a group or other
|
||||
# bit set (src/garage/secrets.rs: mode & 0o077), and the kubelet
|
||||
# OR-s the group-read bit onto every file of a secret volume as soon
|
||||
# as fsGroup is set. Without fsGroup the file stays root-owned and
|
||||
# uid 20000 cannot read it. Kubernetes can set the group of such a
|
||||
# file, never its owner, so no combination satisfies both.
|
||||
- name: GARAGE_RPC_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-rpc-secret
|
||||
key: rpc-secret
|
||||
{{- if $importKey }}
|
||||
- name: GARAGE_DEFAULT_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-s3-secret
|
||||
key: access-key
|
||||
- name: GARAGE_DEFAULT_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-s3-secret
|
||||
key: secret-key
|
||||
{{- end }}
|
||||
{{- if and $settings.singleNode $settings.defaultBucket }}
|
||||
- name: GARAGE_DEFAULT_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-s3-secret
|
||||
key: bucket
|
||||
{{- end }}
|
||||
{{- with .Values.garage.env }}
|
||||
{{- include "garage.env" (dict "root" $ "env" .) | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- containerPort: {{ $settings.ports.s3 | int64 }}
|
||||
protocol: TCP
|
||||
name: s3
|
||||
- containerPort: {{ $settings.ports.rpc | int64 }}
|
||||
protocol: TCP
|
||||
name: rpc
|
||||
{{- if $settings.web.enabled }}
|
||||
- containerPort: {{ $settings.ports.web | int64 }}
|
||||
protocol: TCP
|
||||
name: web
|
||||
{{- end }}
|
||||
# The port all three probes use. Its NAME is what the probe defaults
|
||||
# reference, so renaming it here breaks them.
|
||||
- containerPort: {{ $settings.ports.admin | int64 }}
|
||||
protocol: TCP
|
||||
name: admin
|
||||
# Probes, resources and both security contexts go through the
|
||||
# null-safe helpers (#99): an erased block means "chart defaults
|
||||
# apply" instead of a vanished probe or an unhardened container.
|
||||
# Never wrap these in an "if" - a guard hides the erased case
|
||||
# completely instead of rendering a visible null.
|
||||
#
|
||||
# Which probe points at what, and why liveness and startup must NOT
|
||||
# point at /health, is argued at each helper in _helpers.tpl.
|
||||
livenessProbe:
|
||||
{{- include "garage.livenessProbe" .Values.garage.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "garage.readinessProbe" .Values.garage.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "garage.startupProbe" .Values.garage.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "garage.effectiveResources" .Values.garage.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "garage.containerSecurityContext" .Values.garage.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
# ONE volume carrying metadata_dir and data_dir as subdirectories,
|
||||
# so a Longhorn snapshot catches index and blocks in one atomic
|
||||
# operation. Garage creates both subdirectories on start.
|
||||
- name: data
|
||||
mountPath: {{ include "garage.dataVolumePath" . }}
|
||||
# The SECOND volume, holding the only consistent recovery point this
|
||||
# deployment has. Separate on purpose: a snapshot that dies with the
|
||||
# volume whose corruption it should repair is not a recovery point.
|
||||
- name: snapshots
|
||||
mountPath: {{ include "garage.snapshotsDir" . }}
|
||||
- name: config
|
||||
mountPath: /etc/garage.toml
|
||||
subPath: garage.toml
|
||||
readOnly: true
|
||||
# No emptyDir for /tmp, and that is checked rather than assumed: with
|
||||
# readOnlyRootFilesystem: true the root filesystem of this container
|
||||
# holds exactly one file. Garage writes under metadata_dir, data_dir
|
||||
# and the snapshots directory and uses no temporary directory
|
||||
# (verified in src/model, src/db and src/block at v2.4.1).
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
- name: snapshots
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-snapshots-pvc
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap
|
||||
@@ -1,308 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "garage values",
|
||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key this chart does not read is rejected at render time instead of being silently ignored; the objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotation maps) stay open, because the chart does not interpret their contents. The blocks the chart dereferences additionally accept null, because an erased block means 'chart defaults apply' (issue #99) rather than a vanished setting. Note what this schema CANNOT enforce: secrets.rpcSecretRef ships as null and JSON Schema's 'required' tests presence, not null-ness, so it would never fire - the enforcement is required() in the template (issue #18).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"global": {
|
||||
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
|
||||
"type": "object"
|
||||
},
|
||||
"scaleDown": {
|
||||
"description": "true renders the StatefulSet with replicas: 0 and takes precedence over garage.replicas.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rpcSecretRef": {
|
||||
"description": "op:// item path. The item must carry the field 'rpc-secret' with 32 bytes of random hex. Required always - Garage refuses to start without an RPC secret even as a single node.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"s3SecretRef": {
|
||||
"description": "op:// item path. The item must carry the fields 'access-key', 'secret-key' and 'bucket'. Required while garage.singleNode is true together with defaultAccessKey or defaultBucket, because Garage imports those values on start.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"garage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "0 or 1. Capped at 1 because more than one replica is not a cluster: this chart renders no peer bootstrapping and no discovery CRD, so a second pod would be a lone node with its own empty metadata database.",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"singleNode": {
|
||||
"description": "Run 'garage server --single-node', which creates the cluster layout in-process before the listeners open. Idempotent and guarded by the layout version; requires replicationFactor 1.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultAccessKey": {
|
||||
"description": "Import the S3 access key from the vault item on start (--default-access-key). Requires singleNode; the chart fails the render on the mixed combination, because Garage refuses it at start.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultBucket": {
|
||||
"description": "Create the bucket from the vault item's 'bucket' field on start (--default-bucket) and grant the imported key access to it. Requires singleNode.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"replicationFactor": {
|
||||
"description": "1 for this deployment, and raising it is not an upgrade path: the factor is part of the cluster layout, so increasing it is a layout operation with data movement on a cluster that would first need more nodes.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"dbEngine": {
|
||||
"description": "Metadata engine. Changing it on an existing release needs 'garage convert-db', not a values change.",
|
||||
"type": "string",
|
||||
"enum": ["lmdb", "sqlite", "fjall"]
|
||||
},
|
||||
"metadataFsync": {
|
||||
"description": "true here, against Garage's own default of false: the volume layer cannot help with a write the engine never handed to the kernel.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dataFsync": {
|
||||
"description": "false, Garage's default: a lost block is one object and is replaceable, a lost metadata write can take the index with it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadataAutoSnapshotInterval": {
|
||||
"description": "Go-style duration, e.g. '6h'. The only consistent recovery point this deployment has; an empty string is rejected because switching the snapshots off is not a configuration this chart offers at replication factor 1.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"blockSize": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"compressionLevel": {
|
||||
"description": "zstd level for stored blocks, or the literal string \"none\" to disable compression. NOT 0 for disabling: Garage accepts 0 as an integer and zstd reads it as its default level (3), i.e. more compression than 1, not none. Negative integers are zstd's fast modes.",
|
||||
"oneOf": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string", "enum": ["none"] }
|
||||
]
|
||||
},
|
||||
"bindAddress": {
|
||||
"description": "Host part of the S3, web and RPC listeners. '[::]' accepts IPv4 as well while net.ipv6.bindv6only is 0.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"adminBindAddress": {
|
||||
"description": "Host part of the admin listener, separate from the others because all three probes use it.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"ports": {
|
||||
"description": "May be null / empty: the chart then falls back to its defaults. The ConfigMap renders these into garage.toml's bind addresses and the StatefulSet renders them as containerPorts, so both files follow one source.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"s3": { "$ref": "#/definitions/port" },
|
||||
"rpc": { "$ref": "#/definitions/port" },
|
||||
"web": { "$ref": "#/definitions/port" },
|
||||
"admin": { "$ref": "#/definitions/port" }
|
||||
}
|
||||
},
|
||||
"s3": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"region": {
|
||||
"description": "A label both sides must agree on; a consumer configured for a different one gets a signature error.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"rootDomain": {
|
||||
"description": "Optional suffix for vhost-style bucket access. Deliberately not coupled to ingress.domain - the two are different things.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Static website hosting (the s3_web endpoint). Off: a second HTTP listener that serves nothing without a rootDomain.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"rootDomain": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"securityContext": {
|
||||
"$ref": "#/definitions/securityContext"
|
||||
},
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"description": "The two PVCs. Deliberately NOT null-safe: an erased block yields an invalid PVC, which fails loudly at apply time. Issue #99 covers only the silent class, and on an existing release a chart-side fallback could not help anyway, because a PVC cannot shrink.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"data": {
|
||||
"description": "metadata_dir AND data_dir as two subdirectories of one volume, so a volume snapshot catches index and blocks atomically.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"amount": { "type": ["string", "null"] },
|
||||
"storageClass": { "type": ["string", "null"] }
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"description": "metadata_snapshots_dir, on a second volume so the recovery point does not die with the volume whose corruption it should repair.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"amount": { "type": ["string", "null"] },
|
||||
"storageClass": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the whole Ingress template (issue #93). Checked in both directions for this chart: nothing outside that template reads ingress.domain, so switching the Ingress off frees domain as well as clusterIssuer.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"domain": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"port": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limitFactor": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"requests": {
|
||||
"$ref": "#/definitions/resourceList"
|
||||
},
|
||||
"limits": {
|
||||
"$ref": "#/definitions/resourceList"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourceList": {
|
||||
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cpu": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"memory": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"ephemeral-storage": {
|
||||
"type": ["string", "number", "null"]
|
||||
},
|
||||
"ephemeralStorage": {
|
||||
"type": ["string", "number", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"description": "Additive container env entries. An entry repeating a name the chart already sets does not replace it - on an existing release the pair collapses to the chart's own value while helm upgrade reports success.",
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"valueFrom": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"secretKeyRef": {
|
||||
"$ref": "#/definitions/keyRef"
|
||||
},
|
||||
"configMapKeyRef": {
|
||||
"$ref": "#/definitions/keyRef"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keyRef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99). This chart's fallback reproduces ITS defaults, deviations included - fsGroupChangePolicy: OnRootMismatch rather than the repo's Always, because an object store's data directory holds one file per block and a recursive chown on every pod start turns a restart into a multi-minute outage that grows with the store.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pod": {
|
||||
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
},
|
||||
"container": {
|
||||
"description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"probe": {
|
||||
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The defaults carry the probe's CONTEXT: the port NAME 'admin' for all three, /health for readiness only, and a TCP connect for liveness and startup - because /health answers 503 until a cluster layout exists, and a probe that can only fail would restart a node forever.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
# Garage - an S3-compatible object store, deployed as a SINGLE NODE.
|
||||
#
|
||||
# READ THIS FIRST, because it is the premise every value below rests on
|
||||
# (Gitea #81): this chart deploys ONE Garage node with replication_factor = 1,
|
||||
# on the assumption that the volume layer underneath it is replicated and
|
||||
# encrypted (longhorn-encrypted-ns in this cluster). Garage replication on top
|
||||
# of that would be three times the raw data again, from two layers that know
|
||||
# nothing about each other.
|
||||
#
|
||||
# WHAT THAT TRADE REALLY COSTS - named here because the chart cannot fix it:
|
||||
# upstream says of replication_factor = 1 "Do not use this for anything else
|
||||
# than test deployments", and the reason is that THE SAME FACTOR APPLIES TO THE
|
||||
# METADATA. A corrupted metadata database is, in upstream's words, "generally
|
||||
# not recoverable", and Longhorn does not help: it replicates the corrupted
|
||||
# file three times. Redundancy against device failure, yes; against file
|
||||
# corruption, no. What does help is the set of settings below -
|
||||
# metadata_fsync, the automatic metadata snapshots, and those snapshots living
|
||||
# on a second volume - and none of them is optional in this chart.
|
||||
#
|
||||
# THE IMAGE IS "FROM scratch" AND CONTAINS EXACTLY ONE FILE, /garage. No shell,
|
||||
# no coreutils, no package manager. Three consequences that come up every time
|
||||
# someone changes this chart:
|
||||
# - exec probes are impossible here, for good. All three probes are HTTP or
|
||||
# TCP (see the probe blocks below).
|
||||
# - an init container would need a SECOND image to pin, track and update.
|
||||
# Nothing needs one: Garage creates its own directories on start.
|
||||
# - "kubectl exec" works, but only with /garage as the command and without a
|
||||
# shell: kubectl exec <pod> -- /garage status
|
||||
|
||||
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
||||
# the StatefulSet with replicas: 0 and takes precedence over garage.replicas.
|
||||
# PVCs, Secrets, the Service and the Ingress stay in place; scaleDown: false
|
||||
# brings the workload back up.
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// path of the 1Password item carrying the field "rpc-secret": 32 bytes
|
||||
# of random hex, i.e. "openssl rand -hex 32". REQUIRED - Garage uses it as
|
||||
# the network key of its RPC layer and refuses to start without it, single
|
||||
# node or not.
|
||||
#
|
||||
# IT ARRIVES AS AN ENVIRONMENT VARIABLE (GARAGE_RPC_SECRET), NOT AS A FILE,
|
||||
# and that is the one place where this chart departs from how valkey does it.
|
||||
# valkey argues explicitly for the file form - the password never appears in
|
||||
# the process environment - and that argument is right in general. It cannot
|
||||
# be followed here, and not because the file form would be inconvenient:
|
||||
# IT IS STRUCTURALLY IMPOSSIBLE in a non-root pod.
|
||||
#
|
||||
# - Garage's rpc_secret_file rejects any file whose mode has a group OR
|
||||
# other bit set: "if metadata.mode() & 0o077 != 0" in
|
||||
# src/garage/secrets.rs (v2.4.1, line 144). The error message says
|
||||
# "world-readable", but the mask tests the group bits as well.
|
||||
# - The kubelet OR-s the group-read bit onto every file of a secret volume
|
||||
# as soon as fsGroup is set: pkg/volume/volume_linux.go,
|
||||
# changeFilePermission does Chmod(info.Mode() | 0440) for a read-only
|
||||
# volume. So with fsGroup, Garage refuses to start.
|
||||
# - Without fsGroup the file stays owned by root, and our uid 20000 cannot
|
||||
# read it at all.
|
||||
# - Kubernetes can set the GROUP of such a file, never its OWNER. There is
|
||||
# no combination that satisfies both checks.
|
||||
#
|
||||
# The two ways around it were weighed and rejected (see Gitea #81):
|
||||
# allow_world_readable_secrets = true would switch the permission check off
|
||||
# for ALL of Garage's secret files, including ones nobody has set yet; and an
|
||||
# init container copying the file with mode 0600 would need a second image
|
||||
# for a scratch-based chart. What stays is the environment variable, which is
|
||||
# also the dominant pattern in this repo (paperless-ngx, stalwart).
|
||||
#
|
||||
# The residual price, stated rather than hidden: the secret is in the
|
||||
# container's process environment, so anyone who can exec into the pod or
|
||||
# read its /proc can see it. Garage itself declares the variable with
|
||||
# hide_env_values, so it does not print it in its own output.
|
||||
#
|
||||
# DO NOT "FIX" THIS BACK TO rpc_secret_file. The pod stops starting, and the
|
||||
# error ("File /etc/garage-rpc/rpc-secret is world-readable!") points at a
|
||||
# file mode nobody can change from inside Kubernetes.
|
||||
rpcSecretRef: null
|
||||
|
||||
# op:// path of the 1Password item carrying the fields "access-key",
|
||||
# "secret-key" and "bucket" - the S3 credential a CONSUMER uses, which is why
|
||||
# it is a separate item from the RPC secret above: a consuming chart
|
||||
# (stalwart's secrets.s3SecretRef) points at this same item, and keeping the
|
||||
# bucket name in it means endpoint, bucket, key and secret have one source of
|
||||
# truth instead of being kept in step by hand.
|
||||
#
|
||||
# Required while garage.singleNode is true together with defaultAccessKey or
|
||||
# defaultBucket (the default), because Garage then IMPORTS these values on
|
||||
# start. The direction is inwards: the vault item is written first, Garage
|
||||
# adopts it, and nothing has to be copied out of the server afterwards.
|
||||
#
|
||||
# Garage validates all three and refuses to START on a malformed one:
|
||||
# access-key at least 8 characters, ASCII alphanumeric plus - _ .
|
||||
# secret-key at least 16 characters, printable ASCII
|
||||
# bucket a valid S3 bucket name (3-63 characters, lowercase)
|
||||
# NOTES.txt prints the generators for both keys.
|
||||
#
|
||||
# ROTATION IS NOT A RE-SAVE. Garage refuses to start when the access key
|
||||
# already exists with a different secret ("is associated with a secret key
|
||||
# different than the one given in GARAGE_DEFAULT_SECRET_KEY"), so rotating
|
||||
# the secret means a NEW key id, or deleting the old key with the CLI first.
|
||||
# NOTES.txt has the sequence.
|
||||
s3SecretRef: null
|
||||
|
||||
garage:
|
||||
# Replica count. 1 or 0 - and 0 is the documented way to stop the release
|
||||
# without uninstalling it (the chart-global scaleDown flag does the same for
|
||||
# everything at once).
|
||||
#
|
||||
# MORE THAN ONE REPLICA IS NOT A CLUSTER, which is why the schema caps this
|
||||
# at 1: a second pod would come up as a lone node with its own empty metadata
|
||||
# database on its own volume, because this chart renders neither
|
||||
# bootstrap_peers nor the kubernetes_discovery CRD. With singleNode: true it
|
||||
# would refuse to start at all once it saw the first node, which is the
|
||||
# better of the two outcomes.
|
||||
replicas: 1
|
||||
|
||||
# THE FIRST START, DONE BY GARAGE ITSELF INSTEAD OF BY HAND.
|
||||
#
|
||||
# --single-node makes the server create its own cluster layout on start
|
||||
# (zone "dc1", capacity from the detected disk size) BEFORE it opens the S3,
|
||||
# web and admin listeners. Without it, a fresh install has no layout, serves
|
||||
# nothing, and stays not-Ready until an operator runs three CLI commands -
|
||||
# which is a defensible way to run it, and NOTES.txt documents that path, but
|
||||
# it is not a sane DEFAULT: a default install that never becomes Ready is not
|
||||
# a default.
|
||||
#
|
||||
# IT CANNOT DAMAGE AN EXISTING CLUSTER, and that is worth knowing before
|
||||
# trusting it with data. The layout version decides (src/garage/server.rs,
|
||||
# initial_config): version 0 creates the layout, version 1 does nothing at
|
||||
# all, anything above 1 aborts the start with "layout version is already
|
||||
# superior to 1", and a node that sees other nodes aborts with "more nodes
|
||||
# are already present in the cluster". It also requires replication_factor =
|
||||
# 1 and says so if it is not. There is no --version argument here, so the
|
||||
# trap of the CLI path - "never make multiple calls with the same value of
|
||||
# the --version flag" - does not exist.
|
||||
#
|
||||
# Set to false to run the manual path (and then set defaultAccessKey and
|
||||
# defaultBucket to false as well; the chart refuses the mixed combination,
|
||||
# because Garage does).
|
||||
singleNode: true
|
||||
|
||||
# Import the S3 access key from the vault item on start
|
||||
# (--default-access-key, reading GARAGE_DEFAULT_ACCESS_KEY and
|
||||
# GARAGE_DEFAULT_SECRET_KEY). Requires singleNode.
|
||||
#
|
||||
# This is what removes the one manual step that could not be automated in any
|
||||
# other way: without it Garage GENERATES the key, and it would have to be
|
||||
# copied out of the server into the vault by hand - a secret travelling in the
|
||||
# wrong direction, through a terminal, with the vault as a copy rather than
|
||||
# the original.
|
||||
defaultAccessKey: true
|
||||
|
||||
# Create the bucket from the vault item's "bucket" field on start
|
||||
# (--default-bucket) and grant the imported key read/write/owner on it.
|
||||
# Implies defaultAccessKey upstream; requires singleNode.
|
||||
defaultBucket: true
|
||||
|
||||
# ONE. Everything above is the reason, and raising it here is NOT an upgrade
|
||||
# path: the factor is part of the cluster layout, so increasing it is a layout
|
||||
# operation with data movement, on a cluster that would first need more nodes
|
||||
# - which this chart does not deploy. The value exists so that a future
|
||||
# multi-node setup is not blocked by the chart, not as a switch to flip.
|
||||
replicationFactor: 1
|
||||
|
||||
# "lmdb" (Garage's default since 0.9 and the faster engine), "sqlite", or the
|
||||
# experimental "fjall".
|
||||
#
|
||||
# The deviation to know about: upstream ties its LMDB recommendation to
|
||||
# replication factor >= 2, and we run it at 1. The reported corruption after
|
||||
# an unclean shutdown stays possible, metadata_fsync and the snapshots below
|
||||
# only lower the probability, and at factor 1 there is no peer to recover
|
||||
# from - only the last snapshot. sqlite is the defensible alternative for
|
||||
# anyone who weighs a single-file database's durability higher than metadata
|
||||
# throughput; with one mail server as the only consumer that ceiling is
|
||||
# unlikely to be reached. Changing this on an EXISTING release needs
|
||||
# "garage convert-db", not a values change.
|
||||
dbEngine: lmdb
|
||||
|
||||
# ON, against Garage's own default of OFF. This is the single setting here
|
||||
# most likely to be lost in a "clean up the obvious defaults" pass, so:
|
||||
# Longhorn's block layer cannot help if the engine never calls fsync - a
|
||||
# write that was never handed to the kernel is in no replica and in no
|
||||
# snapshot. Garage's own example configuration pairs it with the snapshot
|
||||
# interval below, as this chart does.
|
||||
metadataFsync: true
|
||||
|
||||
# OFF, which is Garage's default and the right trade for the blocks: a lost
|
||||
# block is one object, is detectable by a scrub, and is replaceable from the
|
||||
# sender's copy; a lost metadata write can take the whole index with it.
|
||||
# Turning it on costs an fsync per written block.
|
||||
dataFsync: false
|
||||
|
||||
# THE ONLY CONSISTENT RECOVERY POINT THIS DEPLOYMENT HAS, and the reason the
|
||||
# second PVC exists. File-system snapshots of a live metadata database
|
||||
# guarantee nothing - Garage says so itself: a snapshot taken during a
|
||||
# metadata write is corrupt and useless as a restore point, which is why
|
||||
# Garage recommends its own mechanism in all cases. Longhorn backs up the
|
||||
# blocks; it does not back up the metadata in any usable way.
|
||||
#
|
||||
# KNOW THE WINDOW BEFORE RELYING ON IT: Garage keeps exactly TWO snapshots,
|
||||
# the first after interval/2 and every later one after interval times a
|
||||
# random 1.0-1.2. At "6h" that is the first after ~3h and a restore window of
|
||||
# roughly 6-14.4h afterwards. A corruption discovered later than that is
|
||||
# older than every snapshot that still exists. Shorten the interval to narrow
|
||||
# the window - at the cost of a compacted copy of the database every time.
|
||||
#
|
||||
# An empty string is rejected by the schema. A null falls back to this
|
||||
# default, because switching the snapshots off is not a configuration this
|
||||
# chart offers at replication factor 1.
|
||||
metadataAutoSnapshotInterval: "6h"
|
||||
|
||||
# Garage's defaults, exposed because they are the two knobs that trade disk
|
||||
# against CPU. blockSize is the chunk size objects are split into;
|
||||
# compressionLevel is the zstd level for stored blocks (1 = fast, higher =
|
||||
# smaller, negative levels are zstd's "fast" modes).
|
||||
#
|
||||
# TO DISABLE COMPRESSION, WRITE THE STRING "none" - and not 0, which is the
|
||||
# trap this comment exists for: Garage's deserializer accepts an integer or
|
||||
# the literal "none" (src/util/config.rs, deserialize_compression), and 0 is
|
||||
# a perfectly valid integer that zstd reads as "use my default level", i.e.
|
||||
# level 3 - MORE compression than the 1 below, not less. The schema therefore
|
||||
# accepts an integer or exactly "none", and the ConfigMap quotes the string
|
||||
# form so the rendered TOML stays valid.
|
||||
blockSize: "1M"
|
||||
compressionLevel: 1
|
||||
|
||||
# The host part of the S3, web and RPC listeners. "[::]" is upstream's
|
||||
# default and accepts IPv4 as well, as long as net.ipv6.bindv6only is 0 -
|
||||
# the Linux default, but a node sysctl this chart cannot see. Set "0.0.0.0"
|
||||
# on an IPv4-only cluster that has changed it.
|
||||
bindAddress: "[::]"
|
||||
|
||||
# The admin listener is bound on 0.0.0.0 SEPARATELY AND ON PURPOSE, because
|
||||
# all three probes use it. If the kubelet's IPv4 probe could not reach a
|
||||
# dual-stack listener, the symptom would be a pod that never becomes Ready
|
||||
# for a reason nothing in the manifest points at. On an IPv6-only cluster set
|
||||
# this to "[::]".
|
||||
adminBindAddress: "0.0.0.0"
|
||||
|
||||
# The four ports, in ONE place: the ConfigMap renders them into garage.toml's
|
||||
# bind addresses and the StatefulSet renders them as containerPorts, so a
|
||||
# value here that disagreed with the other file would be a listener nothing
|
||||
# routes to. 3900/3901/3902/3903 are upstream's defaults.
|
||||
#
|
||||
# The probes address the admin port by NAME ("admin"), not by number, so they
|
||||
# follow a change here.
|
||||
ports:
|
||||
s3: 3900
|
||||
rpc: 3901
|
||||
web: 3902
|
||||
admin: 3903
|
||||
|
||||
s3:
|
||||
# The region name clients must use. Not a cloud region - a label both sides
|
||||
# have to agree on; a consumer configured for a different one gets a
|
||||
# signature error, not a redirect.
|
||||
region: garage
|
||||
|
||||
# Optional suffix for vhost-style bucket access (<bucket>.<rootDomain>).
|
||||
# Only useful together with an Ingress and a wildcard DNS entry;
|
||||
# path-style access works without it and is what an in-cluster consumer
|
||||
# uses. Deliberately NOT ingress.domain: the two are different things, and
|
||||
# coupling them would make the Ingress host decide how buckets are
|
||||
# addressed.
|
||||
rootDomain: null
|
||||
|
||||
web:
|
||||
# Static website hosting (the s3_web endpoint), OFF. It is a second HTTP
|
||||
# listener, it serves nothing at all without a rootDomain, and the consumer
|
||||
# this chart was built for speaks S3. Switching it on renders the
|
||||
# containerPort, the Service port and the [s3_web] section.
|
||||
enabled: false
|
||||
rootDomain: null
|
||||
|
||||
resources:
|
||||
# Limits are optional: memory and ephemeral-storage limits default to
|
||||
# limitFactor x the request, an explicitly set limit always wins, and a CPU
|
||||
# limit is only set when configured explicitly under "limits" - CPU is
|
||||
# compressible, so leaving it unlimited throttles instead of OOM-killing.
|
||||
#
|
||||
# 512Mi x 3 = 1536Mi memory limit. Garage's own guidance is "at least 1GB"
|
||||
# for a node, and LMDB's memory-mapped database makes the RESIDENT size
|
||||
# follow the working set rather than the database size. The ephemeral
|
||||
# request covers container logs only: everything Garage writes goes to one
|
||||
# of the two PVCs.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 32Mi
|
||||
|
||||
# LIVENESS: a TCP connect to the admin port, deliberately NOT the /health
|
||||
# endpoint readiness uses. /health answers 503 while no cluster layout
|
||||
# exists, so a liveness probe on it would kill and restart a node that is
|
||||
# merely waiting for its layout - forever, and a restart is exactly what
|
||||
# cannot fix that. Same lesson as zipline (README, guideline 12.3), in its
|
||||
# sharpest form.
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: admin
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# READINESS: GET /health on the admin port - the one endpoint that answers
|
||||
# the question a readiness probe asks.
|
||||
#
|
||||
# MEASURED AGAINST THE SOURCE AT v2.4.1, NOT CHOSEN: the S3 API server is
|
||||
# spawned independently of the layout state, so the S3 socket binds and
|
||||
# answers - with errors - before any layout exists. A tcpSocket probe on 3900
|
||||
# would therefore report a service ready that accepts nothing. /health needs
|
||||
# no token and maps the cluster health straight onto the status code: 200 for
|
||||
# Healthy and Degraded, 503 for Unavailable, and an unconfigured layout is a
|
||||
# special case that yields Unavailable.
|
||||
#
|
||||
# So with singleNode: true the pod becomes Ready a moment after start, and
|
||||
# with singleNode: false it stays not-Ready until a layout is applied by
|
||||
# hand. The second case is the truth and not a defect: until then the S3
|
||||
# endpoint accepts nothing and no Service should route to it.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: admin
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# STARTUP: TCP again, for the same reason as liveness - a startup probe that
|
||||
# fails forever kills the container, so it must not depend on a layout only
|
||||
# an operator can create. The startup probe gates liveness and readiness,
|
||||
# which is why those carry no initialDelaySeconds.
|
||||
#
|
||||
# Budget = periodSeconds * failureThreshold = 120s. It covers the slow part
|
||||
# of a real start, which is not Garage: attaching and mounting two network
|
||||
# volumes, plus opening (and after an unclean shutdown recovering) the LMDB
|
||||
# database. Garage's own startup work after that is sub-second.
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: admin
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 24
|
||||
|
||||
securityContext:
|
||||
pod:
|
||||
runAsNonRoot: true
|
||||
# Any uid works: the image is FROM scratch, so there is no /etc/passwd to
|
||||
# contradict and nothing in it is owned by a user. 20000 is this repo's
|
||||
# default. Note that upstream's own chart uses 1000 - an install migrated
|
||||
# from it would need chown'ed volumes or this value changed to 1000.
|
||||
runAsUser: 20000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
# OnRootMismatch, DEVIATING from this repo's "Always" default, and the
|
||||
# reason is the data: an object store's data directory holds one file per
|
||||
# block, which is hundreds of thousands of files for a few hundred GB.
|
||||
# "Always" chowns all of them recursively on EVERY pod start, which turns
|
||||
# a restart into a multi-minute outage that gets worse as the store grows.
|
||||
# OnRootMismatch checks the top directory's ownership and skips the walk
|
||||
# when it already matches - which it does, from the second start onwards.
|
||||
# Same reasoning as satisfactory's documented deviation.
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# EMPTY, and it can stay empty: the ports are all above 1024, and the
|
||||
# binary carries no file capabilities (it is a static Rust binary in a
|
||||
# scratch image), so there is no execve-time capability check like
|
||||
# stalwart's. Nothing here needs a capability.
|
||||
add: []
|
||||
|
||||
# Additive environment entries for the Garage container, rendered through tpl
|
||||
# and supporting the value, secretKeyRef and configMapKeyRef forms.
|
||||
#
|
||||
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
|
||||
# does not replace it. At install time both reach the pod and the later one
|
||||
# wins; on an EXISTING release the pair collapses to the chart's own value,
|
||||
# because Kubernetes merges env by name - and helm upgrade still reports
|
||||
# success. So do not use this to override GARAGE_RPC_SECRET or the
|
||||
# GARAGE_DEFAULT_* variables; change the values that produce them.
|
||||
#
|
||||
# Useful entries that are genuinely additive: RUST_LOG (the image sets
|
||||
# garage=info) and GARAGE_ALLOW_PUNYCODE.
|
||||
env: []
|
||||
|
||||
storage:
|
||||
# Both blocks are deliberately NOT null-safe: an erased size yields an
|
||||
# invalid PVC that is rejected at apply time, which is loud. A chart-side
|
||||
# fallback would silently size somebody's object store - and on an existing
|
||||
# release it could not help anyway, because a PVC cannot shrink.
|
||||
data:
|
||||
# metadata_dir AND data_dir, as two subdirectories of this ONE volume.
|
||||
# Together on purpose: Longhorn snapshots are atomic per volume and never
|
||||
# across volumes, so this is what keeps index and blocks consistent with
|
||||
# each other in a snapshot. 10Gi is a starting size for a mail blob store,
|
||||
# not a recommendation - it is the value most likely to need changing
|
||||
# before the first install.
|
||||
amount: 10Gi
|
||||
# null uses the cluster's default StorageClass. In this cluster the
|
||||
# intended value is longhorn-encrypted-ns, which is also where the backup
|
||||
# story lives - and note what this chart does NOT render: the Longhorn
|
||||
# recurring-job labels (daily-snapshot, daily-backup) belong to the
|
||||
# consuming bundle, and without them the off-site half of the backup plan
|
||||
# does not exist, it only looks like it does.
|
||||
storageClass: null
|
||||
snapshots:
|
||||
# metadata_snapshots_dir. Sized for TWO compacted copies of the metadata
|
||||
# database plus head room, not for the objects - Garage keeps exactly two
|
||||
# and deletes older ones itself.
|
||||
amount: 5Gi
|
||||
storageClass: null
|
||||
|
||||
ingress:
|
||||
# OFF. The consumer this chart was built for speaks S3 inside the cluster, so
|
||||
# there is nothing to publish. The values exist, built to this repo's
|
||||
# convention, so that publishing the endpoint later is a values change.
|
||||
#
|
||||
# Guideline 1, checked in both directions: nothing outside the Ingress
|
||||
# template reads ingress.domain (the vhost suffix is its own value,
|
||||
# garage.s3.rootDomain), so switching the Ingress off frees BOTH domain and
|
||||
# clusterIssuer. With it on, both are required and the required() fires in
|
||||
# the template.
|
||||
enabled: false
|
||||
clusterIssuer: null
|
||||
domain: null
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer annotation the chart always sets. An S3 endpoint behind
|
||||
# nginx usually wants nginx.ingress.kubernetes.io/proxy-body-size: "0" -
|
||||
# without it the controller's 1m default truncates uploads.
|
||||
annotations: {}
|
||||
@@ -1,50 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: keycloak
|
||||
description: A Helm chart to deploy Keycloak, an OpenID Connect and SAML identity provider, as a two-node cluster on Kubernetes
|
||||
icon: https://www.keycloak.org/resources/images/icon.svg
|
||||
|
||||
type: application
|
||||
|
||||
# appVersion is the exact image tag of quay.io/keycloak/keycloak. No "v"
|
||||
# prefix here - upstream tags are plain three-part versions (26.8.0).
|
||||
#
|
||||
# THE TAG LANDSCAPE, AND WHY THIS CHART NEEDS NO VERSION CONSTRAINT. Measured
|
||||
# on 2026-10-04 against quay.io's tag API, this registry carries four shapes
|
||||
# side by side:
|
||||
#
|
||||
# 1. release tags 26.8.0, 26.7.5, ... <- what we track
|
||||
# 2. REBUILD tags 26.8.0-0, 26.7.3-1, 26.7.2-2 - the same app
|
||||
# version rebuilt (base image or CVE fix) without an app-version bump.
|
||||
# 26.8.0-0 resolves to the SAME manifest digest as 26.8.0.
|
||||
# 3. floating minor tags 26.8, 26.7, 26.6, ... - they move
|
||||
# 4. floating names latest, nightly - they move daily
|
||||
#
|
||||
# This is the same GENUS of trap as garage's commit-SHA tags and stalwart's
|
||||
# moving names, and it is the one case in this repo where the answer came out
|
||||
# the other way: plain `docker` versioning already excludes all three unwanted
|
||||
# shapes, so the renovate.json5 block carries NO versioningTemplate. Docker
|
||||
# versioning splits a tag at the first dash and only ever offers candidates
|
||||
# with the SAME suffix - our value has none, so the -N rebuilds are invisible
|
||||
# to it - and 26.8 fails on the missing third part while latest/nightly parse
|
||||
# as no version at all.
|
||||
#
|
||||
# THAT IS MEASURED, NOT ASSUMED, and the measurement is written down because
|
||||
# the next round will otherwise add a constraint "to be safe" (renovate 44.132.5,
|
||||
# --platform=local --dry-run=lookup against the real registry):
|
||||
# appVersion 26.8.0 -> updates [], warnings []
|
||||
# appVersion 26.7.0 -> exactly one: 26.8.0 (minor)
|
||||
# appVersion 26.7.4 -> exactly two: 26.7.5 (patch), 26.8.0 (minor)
|
||||
# No run offered a rebuild tag, a floating minor, latest or nightly.
|
||||
#
|
||||
# AND THE OTHER HALF, WHICH THE VERSION NUMBER CANNOT CARRY: Keycloak supports
|
||||
# a rolling update only WITHIN a minor line. A minor jump needs the cluster
|
||||
# shut down. The global `automerge: true` in renovate.json5 applies here
|
||||
# anyway - a deliberate acceptance of that risk, not an oversight - so the
|
||||
# procedure lives in NOTES.txt and on the wiki page instead of in a
|
||||
# packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before
|
||||
# merging an app-version PR that changes the minor.
|
||||
version: 1.0.0+up26.8.0
|
||||
appVersion: "26.8.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -1,204 +0,0 @@
|
||||
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
|
||||
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}}
|
||||
{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
|
||||
{{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{ $replicas }} replica(s), cache=ispn with jdbc-ping discovery over the database,
|
||||
external PostgreSQL at {{ $settings.db.host | default "<keycloak.db.host NOT SET>" }}:{{ $settings.db.port | int64 }}/{{ $settings.db.database }}.
|
||||
|
||||
ENDPOINTS
|
||||
{{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }}
|
||||
{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only
|
||||
{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin
|
||||
{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
|
||||
served on the public host and Keycloak generates its URLs for it
|
||||
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.http | int64 }}
|
||||
|
||||
Management port {{ $settings.ports.management | int64 }} is NOT published and must never be: it serves
|
||||
/health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }}
|
||||
WITHOUT authentication. It has a ClusterIP Service
|
||||
({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only.
|
||||
|
||||
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
|
||||
The operator materializes one Secret key per item FIELD, so a missing or
|
||||
misnamed field leaves the pod in CreateContainerConfigError - with no message
|
||||
naming the field.
|
||||
|
||||
{{ .Values.secrets.databaseSecretRef | default "<secrets.databaseSecretRef NOT SET>" }}
|
||||
username the PostgreSQL role
|
||||
password that role's password
|
||||
The role must be able to CREATE its schema: Keycloak builds and migrates
|
||||
the whole schema itself on first start.
|
||||
{{ if $settings.bootstrapAdmin.enabled }}
|
||||
{{ .Values.secrets.bootstrapAdminSecretRef | default "<secrets.bootstrapAdminSecretRef NOT SET>" }}
|
||||
username the TEMPORARY bootstrap administrator (upstream default name:
|
||||
temp-admin)
|
||||
password its password
|
||||
{{ else }}
|
||||
(no bootstrap admin item - keycloak.bootstrapAdmin.enabled is false, which is
|
||||
the correct state once a permanent administrator exists)
|
||||
{{ end }}
|
||||
Both arrive as ENVIRONMENT VARIABLES, not as files, and that is forced:
|
||||
Keycloak 26 has no _FILE form for an option's value. The price: the password
|
||||
is in the container's process environment.
|
||||
|
||||
FIRST INSTALL - AND STEP 4 IS MANDATORY, NOT ADVICE
|
||||
|
||||
1. kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ $deploy }}
|
||||
Ready means /health/ready answered 200, which means the database is
|
||||
reachable and the schema is in place. The first start is the slow one: it
|
||||
creates the entire schema through Liquibase, and with {{ $replicas }} replica(s)
|
||||
the second waits on the first's migration lock.
|
||||
|
||||
2. Check that the cluster really formed. THIS STEP IS NOT OPTIONAL AND NO
|
||||
PROBE REPLACES IT: a node that never found its peer is Ready, healthy and
|
||||
alone, and two such nodes are two islands that each believe they are the
|
||||
cluster. The install-test cannot catch it either.
|
||||
kubectl -n {{ .Release.Namespace }} logs deployment/{{ $deploy }} | grep -i "ISPN000094\|view accepted\|members"
|
||||
Look for a view with {{ $replicas }} members. One member per pod means
|
||||
jdbc-ping did not find the peer - check that both pods can reach each
|
||||
other on port {{ $settings.ports.jgroups | int64 }} and that they really share one database.
|
||||
|
||||
3. Log in at {{ if $adminHostname }}{{ include "keycloak.adminUrl" . }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }}
|
||||
with the bootstrap credentials from the vault item above.
|
||||
|
||||
4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one.
|
||||
The bootstrap account is TEMPORARY by design - upstream: "the account
|
||||
should exist only for the duration necessary to perform operations needed
|
||||
to gain permanent and more secure admin access. After that, the account
|
||||
needs to be removed manually." This is different from Keycloak before 26,
|
||||
where KEYCLOAK_ADMIN created a regular administrator and there was nothing
|
||||
to clean up.
|
||||
a) In the master realm, create a user, set a password, assign the
|
||||
realm-admin / admin role.
|
||||
b) Verify you can log in as that user.
|
||||
c) Delete the temp-admin user.
|
||||
d) helm upgrade with keycloak.bootstrapAdmin.enabled: false
|
||||
|
||||
5. Until step 4d, EVERY START LOGS a line about the initial administrator not
|
||||
being created, because the master realm already exists and the bootstrap
|
||||
path is skipped. It is harmless. It is also the kind of message somebody
|
||||
eventually investigates, which is why 4d is part of the install and not a
|
||||
tidy-up.
|
||||
|
||||
MINOR UPGRADES NEED THE CLUSTER DOWN - AND THE AUTOMERGE DOES NOT KNOW THAT
|
||||
|
||||
Keycloak supports a rolling update only WITHIN a minor line. A minor jump
|
||||
(26.8 -> 26.9) requires every node shut down before any new node starts;
|
||||
mixing two minor versions in one Infinispan cluster is not a supported
|
||||
configuration, and nothing in the cluster reports that it happened.
|
||||
|
||||
THIS REPOSITORY'S RENOVATE AUTOMERGES EVERY UPDATE TYPE, MAJORS INCLUDED.
|
||||
That is a deliberate, accepted risk and not an oversight (Gitea #83), but it
|
||||
means an app-version PR that moves the minor can reach main, publish the
|
||||
chart and be rolled out as a rolling update without anybody deciding to.
|
||||
Watch app-version PRs for a changed minor, and if one has landed, take this
|
||||
path rather than letting the rollout happen:
|
||||
|
||||
1. Decide it with the tool instead of the version number. With the OLD
|
||||
version and the OLD configuration:
|
||||
kc.sh update-compatibility metadata --file=/tmp/kc-compat.json
|
||||
then with the NEW version and the NEW configuration:
|
||||
kc.sh update-compatibility check --file=/tmp/kc-compat.json
|
||||
Pass EVERY configuration option on both calls - upstream: "Omitting any
|
||||
configuration options results in incomplete metadata, and could lead to
|
||||
a wrong reported result".
|
||||
Exit codes, which is what to automate on: 0 rolling update possible,
|
||||
1 unexpected error, 2 invalid CLI option, 3 and 4 not possible - the
|
||||
deployment must be shut down.
|
||||
|
||||
2. If it says shut down:
|
||||
helm upgrade ... --set scaleDown=true # every pod stops
|
||||
# back up the database AND the configuration before anything else
|
||||
helm upgrade ... --set scaleDown=false # with the new version
|
||||
|
||||
A NOTE ON WHAT IS NOT SETTLED: upstream's 26.6 release notes call patch-level
|
||||
rolling updates supported and active by default, while the 26.8.0 page for
|
||||
update-compatibility says a rolling update is possible "when the Keycloak
|
||||
version is the same" and uses a PATCH pair (26.2.0 -> 26.2.1) as its example
|
||||
of an incompatible one. Those two cannot both be current. Until it is clear,
|
||||
run the check for patch updates too and trust its exit code rather than the
|
||||
size of the version step.
|
||||
|
||||
TRAPS WORTH KNOWING BEFORE YOU NEED THEM
|
||||
|
||||
- THE ADMIN HOSTNAME IS NOT AN ACCESS CONTROL. hostname-admin decides which
|
||||
URLs the server generates, nothing more. Upstream: "Using the
|
||||
hostname-admin option does not prevent accessing the Administration REST
|
||||
API endpoints via the frontend URL specified by the hostname option. If you
|
||||
want to restrict access to the Administration REST API, you need to do it
|
||||
on the reverse proxy level." So /admin/realms/... is reachable through the
|
||||
public host whatever you put on the admin host, and an IP allow-list on the
|
||||
admin Ingress restricts nothing on its own. This chart renders no deny rule
|
||||
for /admin - see values.yaml for why not.
|
||||
|
||||
- A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the
|
||||
database, so - upstream - "clusters that share the same database but should
|
||||
remain separate will merge unless they have distinct cluster names. The
|
||||
default cluster name is ISPN." Two releases of this chart on one database
|
||||
therefore form ONE cluster, silently.
|
||||
{{ with $settings.cache.clusterName }}This release sets cache-embedded-cluster-name={{ . }}.{{ else }}This release uses the default name (ISPN) - keycloak.cache.clusterName is
|
||||
unset. Read its comment in values.yaml before setting it: upstream also
|
||||
warns about cache invalidation between differently-named clusters.{{ end }}
|
||||
|
||||
- READINESS ONLY CHECKS THE DATABASE WHILE metrics-enabled IS TRUE. Upstream's
|
||||
table of health checks has a "Requires Metrics" column and the DATABASE
|
||||
check is the row that says yes. So with keycloak.metrics.enabled false,
|
||||
/health/ready answers 200 with the database gone and the pod keeps taking
|
||||
logins it cannot serve. This release has it {{ if $settings.metrics.enabled }}ON, which is the chart default
|
||||
and the reason the default is what it is.{{ else }}OFF, so the readiness probe
|
||||
cannot currently detect a database outage.{{ end }}
|
||||
|
||||
- THERE IS NO ConfigMap IN THIS CHART, so no checksum/config annotation. That
|
||||
is not an omission: Keycloak is configured entirely through options and
|
||||
KC_* variables, so there is no config file whose change could fail to
|
||||
restart anything.
|
||||
|
||||
- A CHANGE INSIDE A VAULT ITEM RESTARTS NOTHING. Helm never sees it. After
|
||||
rotating a field:
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart deployment/{{ $deploy }}
|
||||
(Repointing a secrets.*SecretRef at a different item DOES roll the pods -
|
||||
that is covered by checksum/secret-ref.)
|
||||
|
||||
- THE POD HAS TWO INIT CONTAINERS AND NEEDS BOTH. db, health-enabled and
|
||||
metrics-enabled are Keycloak BUILD options, the published image is built
|
||||
with their defaults, and a runtime change makes Keycloak rewrite
|
||||
/opt/keycloak/lib/quarkus at start - which readOnlyRootFilesystem: true
|
||||
forbids. So one init container seeds that directory into a volume and the
|
||||
next runs `kc.sh build` into it; the serving container then runs
|
||||
`start --optimized`. If a pod is stuck in Init, read those logs first:
|
||||
kubectl -n {{ .Release.Namespace }} logs <pod> -c {{ .Release.Name }}-{{ .Chart.Name }}-build
|
||||
Build options are set ONLY through keycloak.db.vendor,
|
||||
keycloak.metrics.enabled and keycloak.features. Putting one into
|
||||
keycloak.env fails the render on purpose.
|
||||
|
||||
- POOL SIZES ARE PER REPLICA. keycloak.db.pool.maxSize is {{ $settings.db.pool.maxSize | int64 }}, so with the
|
||||
current {{ $replicas }} replica(s) this release can open up to {{ mul ($settings.db.pool.maxSize | int64) $replicas }} connections.
|
||||
Upstream's default is 100 per node, which with two nodes exceeds a default
|
||||
PostgreSQL's max_connections of 100. Raise it together with the server,
|
||||
never alone.
|
||||
{{ if and $settings.podDisruptionBudget.enabled (ge $replicas 2) }}
|
||||
- A PodDisruptionBudget IS in place ({{ .Release.Name }}-{{ .Chart.Name }}-pdb, minAvailable
|
||||
{{ $settings.podDisruptionBudget.minAvailable | int64 }}), so a node drain cannot take all replicas at once.
|
||||
{{ else if $settings.podDisruptionBudget.enabled }}
|
||||
- NO PodDisruptionBudget was rendered, because the effective replica count is
|
||||
{{ $replicas }}. That is deliberate: minAvailable 1 with a single replica forbids every
|
||||
voluntary eviction, so `kubectl drain` would block forever on a pod nothing
|
||||
may move - and the error would name the PDB rather than the replica count.
|
||||
Run two replicas to get the budget.
|
||||
{{ else }}
|
||||
- NO PodDisruptionBudget (keycloak.podDisruptionBudget.enabled is false). A
|
||||
node drain may take every replica at once.
|
||||
{{ end }}
|
||||
WHAT THIS CHART DOES NOT RENDER
|
||||
|
||||
- No PVC and no StatefulSet: Keycloak 26 keeps everything in the database,
|
||||
user sessions included (persistent sessions are the default; volatile ones
|
||||
are deprecated as of 26.8). There is no disk state to keep.
|
||||
- No NetworkPolicy. jdbc-ping opens direct pod-to-pod connections on port
|
||||
{{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the
|
||||
replicas, or the cluster silently becomes two islands.
|
||||
- No deny rule for /admin on the public Ingress, and no restriction of the
|
||||
administration REST API. That is reverse-proxy work - see the first trap.
|
||||
@@ -1,545 +0,0 @@
|
||||
{{/*
|
||||
Render the body of a container "resources:" block from a values dict of the form
|
||||
|
||||
resources:
|
||||
limitFactor: 3 # optional, default 3
|
||||
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
|
||||
limits: { ... } # optional, every entry optional
|
||||
|
||||
Rules (variant B, see issue #19):
|
||||
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
|
||||
to the Kubernetes key "ephemeral-storage").
|
||||
- an explicitly set limit always wins.
|
||||
- a missing memory / ephemeral-storage limit is computed as
|
||||
limitFactor x the corresponding request.
|
||||
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
|
||||
unlimited; CPU is compressible, so this cannot kill the workload).
|
||||
|
||||
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||
the chart's name as define prefix, because sharing it would mean a library
|
||||
chart, and a dependency is admissible but expensive (README, guideline 14) -
|
||||
this chart takes none. The reference implementation is
|
||||
template-chart/templates/_helpers.tpl.
|
||||
*/}}
|
||||
{{- define "keycloak.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
{{- $factor := 3.0 -}}
|
||||
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
||||
{{- $factor = float64 $resources.limitFactor -}}
|
||||
{{- end -}}
|
||||
{{- $requests := dict -}}
|
||||
{{- range $key, $value := ($resources.requests | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $requests (include "keycloak.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $limits := dict -}}
|
||||
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $limits (include "keycloak.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- range $key, $request := $requests -}}
|
||||
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||
{{- $_ := set $limits $key (include "keycloak.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $out := dict -}}
|
||||
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
||||
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Normalize a resource name from values to its Kubernetes key.
|
||||
*/}}
|
||||
{{- define "keycloak.resourceKey" -}}
|
||||
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
||||
Input: dict "quantity" <string|number> "factor" <float64>.
|
||||
*/}}
|
||||
{{- define "keycloak.multiplyQuantity" -}}
|
||||
{{- $quantity := include "keycloak.formatNumber" .quantity -}}
|
||||
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||
{{- if not $number -}}
|
||||
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $suffix := trimPrefix $number $quantity -}}
|
||||
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
||||
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $product := mulf (float64 $number) .factor -}}
|
||||
{{- if eq $product (floor $product) -}}
|
||||
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s%s" (include "keycloak.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation
|
||||
(issue #157): the null-safe fallback helpers pass values through fromYaml,
|
||||
which parses every number as float64, and Go's "%v" switches float64 to
|
||||
exponential notation from about 1e6 upwards - "1.048576e+06" is not a valid
|
||||
Kubernetes quantity. Strings ("700Mi") are passed through untouched.
|
||||
*/}}
|
||||
{{- define "keycloak.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
||||
null / empty override falls back to the defaults instead of erasing them
|
||||
(issues #61, #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
|
||||
<string>. Output: YAML of the merged dict (consume it with fromYaml).
|
||||
|
||||
An override key written without children is YAML null, and Helm treats that
|
||||
null as a deletion of the chart's default. This helper defines the opposite
|
||||
semantics - an empty block means "chart defaults apply" - while an explicitly
|
||||
set value still wins, including false and 0.
|
||||
*/}}
|
||||
{{- define "keycloak.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
{{- $given := .given -}}
|
||||
{{- $path := .path | default "value" -}}
|
||||
{{- if kindIs "invalid" $given -}}
|
||||
{{- $given = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $given) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
||||
{{- end -}}
|
||||
{{- $out := deepCopy $defaults -}}
|
||||
{{- range $key, $value := $given -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $default := index $defaults $key -}}
|
||||
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
||||
{{- $_ := set $out $key (fromYaml (include "keycloak.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
||||
{{- else if kindIs "map" $default -}}
|
||||
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $key $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Pick one sub-block out of a values block that may itself be null.
|
||||
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
||||
Returns YAML of dict "value" <the sub-block, may be nil>.
|
||||
*/}}
|
||||
{{- define "keycloak.subBlock" -}}
|
||||
{{- $block := .block -}}
|
||||
{{- if kindIs "invalid" $block -}}
|
||||
{{- $block = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $block) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
||||
{{- end -}}
|
||||
{{- toYaml (dict "value" (index $block .key)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
THE EFFECTIVE KEYCLOAK SETTINGS - everything the Deployment, the Services, the
|
||||
PodDisruptionBudget and NOTES.txt read out of .Values.keycloak, merged onto this
|
||||
chart's defaults.
|
||||
|
||||
Why the whole settings block goes through the null-safe helper and not only the
|
||||
three usual suspects (guideline 12.2: "the rule is about the shape, not about
|
||||
those three block kinds"): every value below is dereferenced by a template, and
|
||||
an erased one would not abort the render - it would render a containerPort with
|
||||
no number, a `--db=` with an empty right-hand side, or a database host that is
|
||||
the empty string. The first is rejected by the API server at apply time, the
|
||||
second fails the BUILD inside the init container, and the third is a pod waiting
|
||||
on a connection that cannot be made. All three arrive after the render looked
|
||||
fine, which is exactly the class #99 is about. satisfactory shipped the same
|
||||
shape with half its blocks null-safe and half not (Gitea #16), and the half that
|
||||
was not took the Service's port with it.
|
||||
|
||||
The defaults here MUST stay in sync with values.yaml, which remains the
|
||||
documented, user-facing place for them; this copy is only the fallback.
|
||||
|
||||
Usage: {{ $settings := fromYaml (include "keycloak.settings" .) }}
|
||||
*/}}
|
||||
{{- define "keycloak.settings" -}}
|
||||
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" .Values "key" "keycloak" "path" ".Values"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"replicas" 2
|
||||
"db" (dict
|
||||
"vendor" "postgres"
|
||||
"host" nil
|
||||
"port" 5432
|
||||
"database" "keycloak"
|
||||
"pool" (dict "initialSize" 2 "minSize" 2 "maxSize" 20))
|
||||
"bootstrapAdmin" (dict "enabled" true)
|
||||
"metrics" (dict "enabled" true)
|
||||
"features" (list)
|
||||
"cache" (dict "clusterName" nil)
|
||||
"ports" (dict "http" 8080 "management" 9000 "jgroups" 7800)
|
||||
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
|
||||
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render the replica count for the Deployment.
|
||||
|
||||
Input: dict "scaleDown" <flag|nil> "replicas" <value|nil>.
|
||||
|
||||
- scaleDown: true takes precedence and always yields 0.
|
||||
- otherwise an explicitly set replicas value is used as given, including an
|
||||
explicit 0 - Helm's "default" would swallow 0, hence the kindIs "invalid"
|
||||
nil check.
|
||||
- a missing replicas value defaults to 2.
|
||||
|
||||
A NOTE SPECIFIC TO THIS CHART: the default is 2, not 1, and that is the point
|
||||
of the chart rather than a tuning choice - an SSO outage takes every dependent
|
||||
login with it. Two replicas are cheap here because node discovery runs over the
|
||||
database that is there anyway (cache=ispn plus Keycloak's default jdbc-ping
|
||||
stack). What two replicas cost is the rolling-update restriction: upstream
|
||||
supports a rolling update only inside a minor line, so a minor jump becomes a
|
||||
procedure instead of a restart. See NOTES.txt.
|
||||
*/}}
|
||||
{{- define "keycloak.replicas" -}}
|
||||
{{- if .scaleDown -}}
|
||||
0
|
||||
{{- else if not (kindIs "invalid" .replicas) -}}
|
||||
{{- .replicas -}}
|
||||
{{- else -}}
|
||||
2
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
THE BUILD OPTIONS, IN ONE PLACE - and this helper being the ONLY place is what
|
||||
keeps the chart startable (Gitea #83).
|
||||
|
||||
Keycloak separates BUILD options from runtime options. Verified against the
|
||||
26.8.0 "All configuration" table, where a build option carries the tool icon:
|
||||
db, health-enabled, metrics-enabled, features and features-disabled are build
|
||||
options; db-url-host, db-username, db-password, the db-pool-* sizes, hostname,
|
||||
hostname-admin, hostname-strict, proxy-headers, http-enabled, http-port,
|
||||
http-management-port, cache, cache-stack and the cache-embedded-* options are
|
||||
not.
|
||||
|
||||
The published image is augmented with the build options' DEFAULTS (db=dev-file,
|
||||
health-enabled=false, metrics-enabled=false). Supplying different ones at
|
||||
runtime makes Keycloak re-augment itself at start, and the augmentation
|
||||
rewrites /opt/keycloak/lib/quarkus - inside the root filesystem, which
|
||||
readOnlyRootFilesystem: true makes read-only. Upstream's container guide calls
|
||||
that start what it is: it "creates an image that is mutable".
|
||||
|
||||
So the BUILD init container receives exactly these options and the serving
|
||||
container runs `start --optimized` and passes NO build option at all. That
|
||||
asymmetry is deliberate: with `--optimized` Keycloak expects a pre-built
|
||||
installation and a build option handed to it alongside is an error, and - more
|
||||
importantly - there is no second list that could drift out of step with this
|
||||
one. If a build option must be added, it is added HERE and nowhere else.
|
||||
|
||||
THEY ARE RENDERED AS ENVIRONMENT VARIABLES, NOT AS CLI ARGUMENTS, and that is a
|
||||
safety decision rather than a style one. `kc.sh build` reads KC_* exactly as it
|
||||
reads flags, and as variables the values are handed over by the kubelet instead
|
||||
of being parsed by a shell - so a feature name containing a space cannot become
|
||||
two arguments, and nothing here has to be shell-quoted.
|
||||
|
||||
health-enabled is hard-coded true rather than exposed as a value: all three
|
||||
probes address /health/* on the management port, so a release with it switched
|
||||
off would have no working probe at all - the same defect teamspeak-server's
|
||||
port name exists to prevent (guideline 12.5).
|
||||
*/}}
|
||||
{{- define "keycloak.buildEnv" -}}
|
||||
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
|
||||
- name: KC_DB
|
||||
value: {{ required "A database vendor must be provided (keycloak.db.vendor)" $settings.db.vendor | quote }}
|
||||
- name: KC_HEALTH_ENABLED
|
||||
value: "true"
|
||||
- name: KC_METRICS_ENABLED
|
||||
value: {{ $settings.metrics.enabled | toString | quote }}
|
||||
{{- with $settings.features }}
|
||||
- name: KC_FEATURES
|
||||
value: {{ join "," . | quote }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The variable names of Keycloak BUILD options, used to refuse them in the
|
||||
additive env block (see keycloak.env below).
|
||||
|
||||
Read off the 26.8.0 option table. It is not claimed to be exhaustive - upstream
|
||||
can add a build option tomorrow and this list would not know - which is why it
|
||||
is the SECOND line of defence and not the first. The first is structural: the
|
||||
serving container is rendered with `start --optimized` and no build option, so
|
||||
there is no list of options on it to disagree with the init container's. This
|
||||
guard only stops somebody re-introducing one through values.
|
||||
*/}}
|
||||
{{- define "keycloak.buildOptionEnvNames" -}}
|
||||
- KC_DB
|
||||
- KC_DB_KIND
|
||||
- KC_DB_DRIVER
|
||||
- KC_HEALTH_ENABLED
|
||||
- KC_HTTP_MANAGEMENT_HEALTH_ENABLED
|
||||
- KC_METRICS_ENABLED
|
||||
- KC_FEATURES
|
||||
- KC_FEATURES_DISABLED
|
||||
- KC_HTTP_RELATIVE_PATH
|
||||
- KC_HTTP_MANAGEMENT_RELATIVE_PATH
|
||||
- KC_HTTPS_CLIENT_AUTH
|
||||
- KC_HTTPS_MANAGEMENT_CLIENT_AUTH
|
||||
- KC_TRANSACTION_XA_ENABLED
|
||||
- KC_LEGACY_OBSERVABILITY_INTERFACE
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The frontend URL (KC_HOSTNAME) and the admin URL (KC_HOSTNAME_ADMIN).
|
||||
|
||||
BOTH ARE FULL URLs, NOT BARE HOSTS, and that is upstream's requirement: "If
|
||||
hostname-admin is configured, hostname must be set to a URL (not just
|
||||
hostname). Otherwise Keycloak would not know what is the correct frontend URL
|
||||
(incl. port etc.)", and both values are "verified that full URL is used, incl.
|
||||
scheme and hostname". https:// is the only scheme this chart renders - the
|
||||
Ingress always terminates TLS, so the browser-facing scheme is https even
|
||||
though the pod itself serves plain HTTP.
|
||||
|
||||
ingress.domain is required HERE and not only in the Ingress template, because
|
||||
this is the read that makes it required even with ingress.enabled: false
|
||||
(guideline 12.1): Keycloak refuses to start in production mode without a
|
||||
hostname, hostname-strict being true by default.
|
||||
*/}}
|
||||
{{- define "keycloak.frontendUrl" -}}
|
||||
https://{{ required "A Domain/Host must be provided (ingress.domain) - Keycloak derives KC_HOSTNAME from it and refuses to start without a hostname, so it stays required even with ingress.enabled: false" .Values.ingress.domain }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "keycloak.adminUrl" -}}
|
||||
https://{{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" (((.Values.ingress | default dict).admin | default dict).domain) }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Is the admin hostname in play? Only then are KC_HOSTNAME_ADMIN and the second
|
||||
Ingress rendered.
|
||||
|
||||
WHAT IT IS NOT: an access control. hostname-admin decides which URLs the server
|
||||
GENERATES; upstream states plainly that it "does not prevent accessing the
|
||||
Administration REST API endpoints via the frontend URL specified by the hostname
|
||||
option" and that restricting that API is reverse-proxy work. The full reasoning
|
||||
sits at ingress.admin.enabled in values.yaml, and the same sentence is in
|
||||
NOTES.txt and on the wiki page - three places on purpose, because the value's
|
||||
name suggests a boundary and the next reader will assume one.
|
||||
*/}}
|
||||
{{- define "keycloak.adminHostnameEnabled" -}}
|
||||
{{- $ingress := .Values.ingress | default dict -}}
|
||||
{{- $admin := $ingress.admin | default dict -}}
|
||||
{{- if kindIs "invalid" $admin.enabled -}}
|
||||
true
|
||||
{{- else -}}
|
||||
{{- $admin.enabled -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective security contexts, probes and resources (issue #99).
|
||||
|
||||
The defaults below MUST stay in sync with values.yaml. They reproduce THIS
|
||||
chart's defaults, not the repo standard - see the uid/gid note.
|
||||
*/}}
|
||||
{{- define "keycloak.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "pod" "path" "keycloak.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 1000
|
||||
"runAsGroup" 1000
|
||||
"fsGroup" 1000
|
||||
"fsGroupChangePolicy" "Always"
|
||||
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "keycloak.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "container" "path" "keycloak.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
|
||||
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.securityContext.container") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
LIVENESS: /health/live on the management port, and deliberately NOT /health or
|
||||
/health/ready.
|
||||
|
||||
Guideline 12.3 in the one shape where upstream has already separated the
|
||||
checks: /health/live reports whether this process is alive and contains no
|
||||
database check, while /health/ready and the aggregate /health do. A liveness
|
||||
probe on either of those restarts every pod in the cluster for as long as the
|
||||
database is away - which cannot fix a database, and which turns a degraded login
|
||||
service into no login service. Same lesson as zipline; here it is one path
|
||||
away, which is also why the wrong path would look entirely plausible in a diff.
|
||||
*/}}
|
||||
{{- define "keycloak.livenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/health/live" "port" "management")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
READINESS: /health/ready - which includes the database, and must. A node that
|
||||
cannot reach its database answers every login with an error, so no Service
|
||||
should route to it. This is the other half of guideline 12.3: a dependency-free
|
||||
liveness path is only correct when readiness really does hang on the
|
||||
dependency.
|
||||
|
||||
AND THE PATH ALONE DOES NOT GUARANTEE THAT, which is the part worth reading
|
||||
before anybody "cleans up" keycloak.metrics.enabled. Upstream's table of
|
||||
available checks carries a "Requires Metrics" column, and the DATABASE check -
|
||||
"Returns the status of the database connection pool" - is the row that says
|
||||
YES; Cluster, Graceful shutdown and Keycloak Initialized say no. So with
|
||||
metrics-enabled=false this endpoint still answers 200 while the database is
|
||||
gone, and the pod keeps taking logins it cannot serve.
|
||||
|
||||
That is exactly the urlaubsverwaltung shape (#218): the probe path was right
|
||||
and the aggregate behind it contained nothing external. Here the dependency
|
||||
hides behind an option whose name says observability, which is why
|
||||
keycloak.metrics.enabled defaults to TRUE and why its comment in values.yaml
|
||||
argues about readiness rather than about scraping.
|
||||
*/}}
|
||||
{{- define "keycloak.readinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/health/ready" "port" "management")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
STARTUP: /health/started, Keycloak's own "finished booting" endpoint. It gates
|
||||
liveness and readiness, which is why neither carries an initialDelaySeconds.
|
||||
|
||||
Budget = periodSeconds * failureThreshold = 10 * 60 = 600s. What has to fit in
|
||||
it is not only the JVM and Infinispan: on a FIRST install Keycloak creates its
|
||||
whole schema through Liquibase, and with two replicas starting together the
|
||||
second waits on the first's migration lock. Too short a budget there is a
|
||||
CrashLoopBackOff that reads as an application fault and is really a stopwatch.
|
||||
|
||||
The Quarkus augmentation is NOT inside this budget - it runs in the init
|
||||
container, which has no probe and no deadline. That is the second thing the
|
||||
init container buys, after the read-only root filesystem.
|
||||
*/}}
|
||||
{{- define "keycloak.startupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/health/started" "port" "management")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 60 -}}
|
||||
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.startupProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective resources blocks, fed into the resources helper above so that an
|
||||
erased "resources:" still yields the chart's requests and computed limits.
|
||||
|
||||
Two blocks, because the init container does something different from the
|
||||
serving container: a one-shot, CPU-bound Quarkus augmentation that is gone
|
||||
before the serving container starts.
|
||||
*/}}
|
||||
{{- define "keycloak.effectiveResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" "500m" "memory" "700Mi" "ephemeral-storage" "256Mi") -}}
|
||||
{{- $merged := fromYaml (include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.resources")) -}}
|
||||
{{- include "keycloak.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "keycloak.buildResources" -}}
|
||||
{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "resources" "path" "keycloak.build"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" "500m" "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
|
||||
{{- $merged := fromYaml (include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.build.resources")) -}}
|
||||
{{- include "keycloak.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The additive env block, rendered through tpl with the value, secretKeyRef and
|
||||
configMapKeyRef forms (guideline 10).
|
||||
|
||||
ADDITIVE MEANS ADDITIVE: an entry repeating a name this chart already sets does
|
||||
not replace it. At install time both reach the pod and the later one wins; on an
|
||||
EXISTING release the pair collapses to the chart's own value, because Kubernetes
|
||||
merges env by name - and helm upgrade still reports success. So do not use this
|
||||
block to override KC_HOSTNAME or the database variables; change the values that
|
||||
produce them.
|
||||
|
||||
AND ONE CLASS OF ENTRY IS REFUSED OUTRIGHT: a BUILD option's variable name. A
|
||||
build option in the serving container's environment disagrees with what the init
|
||||
container built, and Keycloak answers a disagreement by re-augmenting - onto a
|
||||
read-only path. The result is a crash loop whose cause is nowhere near the
|
||||
error message, so the render fails here instead, naming the variable. This is a
|
||||
`fail`, not a silent drop, for the reason guideline 12.6 gives: a value that
|
||||
cannot be honoured belongs in an abort, not in an implicit branch.
|
||||
*/}}
|
||||
{{- define "keycloak.env" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $buildNames := fromYamlArray (include "keycloak.buildOptionEnvNames" .) -}}
|
||||
{{- range $value := .env }}
|
||||
{{- if has (upper ($value.name | toString)) $buildNames }}
|
||||
{{- fail (printf "keycloak.env may not set %q: it is a Keycloak BUILD option, and a build option on the serving container disagrees with what the init container built - Keycloak then re-augments itself onto the read-only /opt/keycloak/lib/quarkus and crash-loops. Build options are set through the chart's own values (keycloak.db.vendor, keycloak.metrics.enabled, keycloak.features), which feed `kc.sh build` in the init container. See values.yaml, point 2." $value.name) }}
|
||||
{{- end }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name | quote }}
|
||||
# quote is required, not cosmetic: a value like "8080" or "true" renders
|
||||
# unquoted as a YAML number/bool, and the API server then rejects the whole
|
||||
# manifest with "cannot unmarshal number into Go struct field EnvVar.value of
|
||||
# type string".
|
||||
value: {{ tpl $value.value $root | quote }}
|
||||
{{- else if $value.valueFrom }}
|
||||
{{- if $value.valueFrom.secretKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
|
||||
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||
- name: {{ $value.name }}
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
|
||||
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The three writable paths, defined once because two containers have to agree on
|
||||
them.
|
||||
|
||||
/opt/keycloak/lib/quarkus is the one that matters and the one that is not
|
||||
guessable: it is where the Quarkus augmentation writes, i.e. where `kc.sh
|
||||
build` puts generated-bytecode.jar, transformed-bytecode.jar,
|
||||
quarkus-application.dat and build-system.properties. Read out of the published
|
||||
image's layer, not inferred. The init container writes it, the serving
|
||||
container reads it, and because the directory is also the only place a
|
||||
re-augmentation would write, mounting it writable is what lets the rest of the
|
||||
root filesystem stay read-only.
|
||||
|
||||
/opt/keycloak/data is needed because Quarkus places its temporary directory
|
||||
under the Keycloak home rather than honouring java.io.tmpdir (upstream issue
|
||||
#37533). It is EMPTY in the image, so an emptyDir over it hides nothing.
|
||||
/opt/keycloak/lib/quarkus is NOT empty in the image - which is exactly why it
|
||||
is seeded by the init container rather than merely mounted; an unseeded
|
||||
emptyDir there hides quarkus-application.dat, and QuarkusEntryPoint loads that
|
||||
before any Keycloak code runs.
|
||||
*/}}
|
||||
{{- define "keycloak.quarkusBuildDir" -}}/opt/keycloak/lib/quarkus{{- end -}}
|
||||
{{- define "keycloak.dataDir" -}}/opt/keycloak/data{{- end -}}
|
||||
@@ -1,373 +0,0 @@
|
||||
{{- $settings := fromYaml (include "keycloak.settings" .) }}
|
||||
{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
|
||||
{{- /*
|
||||
WHY THIS WORKLOAD HAS TWO INIT CONTAINERS, read before changing either of them
|
||||
(Gitea #83). Three measured facts force the shape:
|
||||
|
||||
1. db, health-enabled and metrics-enabled are BUILD options (verified in the
|
||||
26.8.0 option table, where each carries the tool icon). The published
|
||||
image is augmented with their DEFAULTS - db=dev-file, health-enabled=false.
|
||||
2. Supplying different build options at runtime makes Keycloak re-augment
|
||||
itself at start, rewriting four files in /opt/keycloak/lib/quarkus. kc.sh
|
||||
says so itself: "only exit code 10 means that implicit reaugmentation
|
||||
occurred and a relaunch is needed". Upstream's container guide calls that
|
||||
start what it is - it "creates an image that is mutable", which is exactly
|
||||
what readOnlyRootFilesystem: true forbids. And not setting db is no way
|
||||
out: THE PROBES ALONE force it, because health-enabled is a build option.
|
||||
3. So the build has to happen before the serving container starts, into a
|
||||
volume. But an EMPTY emptyDir over /opt/keycloak/lib/quarkus does not
|
||||
work either, and this is the part that is not guessable:
|
||||
QuarkusEntryPoint - the main class kc.sh runs for EVERY command, `build`
|
||||
included - loads "quarkus/quarkus-application.dat" (the literal string is
|
||||
in QuarkusEntryPoint.class) before it dispatches anything. With that file
|
||||
hidden by a fresh mount, `kc.sh build` cannot start at all.
|
||||
|
||||
Hence: SEED copies the image's own augmentation output into the volume (reading
|
||||
the real directory, writing only into the volume - so a read-only root
|
||||
filesystem is fine), BUILD then mounts the now-seeded volume at the real path
|
||||
and re-augments INTO it (again writing only into the volume), and the serving
|
||||
container mounts the finished volume and runs `start --optimized`.
|
||||
|
||||
What that buys, and the reason it is worth two containers rather than one with
|
||||
readOnlyRootFilesystem: false: every container in this pod keeps the full
|
||||
hardening baseline. There is no documented deviation to carry, and no Trivy
|
||||
suppression that would also silence the serving container.
|
||||
*/ -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
spec:
|
||||
replicas: {{ include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas) }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
template:
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
annotations:
|
||||
# NO checksum/config HERE, AND THAT IS NOT AN OVERSIGHT. This chart
|
||||
# renders no ConfigMap at all - Keycloak is configured entirely through
|
||||
# options and KC_* variables - so there is no file whose change could
|
||||
# fail to restart the workload. The rule (#82) has nothing to attach
|
||||
# to. values.yaml states this at the top, because an absent annotation
|
||||
# looks identical to a forgotten one.
|
||||
#
|
||||
# Hash of the rendered OnePasswordItems. This covers the SOURCE of the
|
||||
# secrets, not their content: repointing secrets.databaseSecretRef or
|
||||
# secrets.bootstrapAdminSecretRef at a different vault item rolls the
|
||||
# pods instead of leaving the old credentials in the running processes.
|
||||
# A change INSIDE an item cannot be covered - Helm never sees it (see
|
||||
# keycloak.secret.yaml) - and NOTES.txt names the manual restart.
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/keycloak.secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "keycloak.podSecurityContext" .Values.keycloak.securityContext | nindent 8 }}
|
||||
initContainers:
|
||||
# --- 1. SEED -------------------------------------------------------
|
||||
# Copy the image's own augmentation output into the shared volume, so
|
||||
# that the BUILD container below can bootstrap at all (fact 3 in the
|
||||
# comment at the top of this file).
|
||||
#
|
||||
# It reads /opt/keycloak/lib/quarkus from the image and writes only
|
||||
# into the volume, which is why readOnlyRootFilesystem: true holds
|
||||
# here without any special handling.
|
||||
#
|
||||
# `command` is overridden because the image's entrypoint is kc.sh and
|
||||
# this step runs no Keycloak command - the only other chart in this
|
||||
# repo that overrides an entrypoint is convertx, and the same
|
||||
# obligation applies: this line has to be re-read whenever the app
|
||||
# version moves, because it depends on the image shipping a shell and
|
||||
# coreutils. Both are present (usr/bin/sh -> bash, usr/bin/cp, verified
|
||||
# in the image layer at 26.8.0).
|
||||
#
|
||||
# `cp -R` and not `cp -a`: -a preserves ownership, which a non-root
|
||||
# process cannot do - it would fail with a diagnostic and a non-zero
|
||||
# exit. Only the content and the directory structure matter here.
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-seed
|
||||
image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
args:
|
||||
- |
|
||||
set -eu
|
||||
cp -R /opt/keycloak/lib/quarkus/. /quarkus-build/
|
||||
ls -l /quarkus-build/
|
||||
resources:
|
||||
{{- include "keycloak.buildResources" .Values.keycloak.build | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: quarkus-build
|
||||
mountPath: /quarkus-build
|
||||
# --- 2. BUILD ------------------------------------------------------
|
||||
# The Quarkus augmentation, i.e. `kc.sh build`. It writes into the
|
||||
# seeded volume, which is mounted at the real path here - so the root
|
||||
# filesystem stays read-only and the output survives into the serving
|
||||
# container.
|
||||
#
|
||||
# THE BUILD OPTIONS ARRIVE AS ENVIRONMENT VARIABLES, NOT AS CLI
|
||||
# ARGUMENTS, and that is a safety decision rather than a style one:
|
||||
# `command` is a fixed shell string here, and interpolating chart
|
||||
# values into a shell string is how a feature name with a space in it
|
||||
# becomes two arguments. As variables they are passed by the kubelet
|
||||
# and never parsed by a shell. `kc.sh build` reads KC_* exactly as it
|
||||
# reads flags.
|
||||
#
|
||||
# This is the ONE place build options exist in this chart. The serving
|
||||
# container passes `start --optimized` and no build option at all, so
|
||||
# there is no second list to drift out of step - and keycloak.env
|
||||
# refuses a build option's variable name outright (see _helpers.tpl).
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-build
|
||||
image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- build
|
||||
env:
|
||||
{{- include "keycloak.buildEnv" . | nindent 12 }}
|
||||
resources:
|
||||
{{- include "keycloak.buildResources" .Values.keycloak.build | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: quarkus-build
|
||||
mountPath: {{ include "keycloak.quarkusBuildDir" . }}
|
||||
# Quarkus puts its temporary directory under the Keycloak home
|
||||
# rather than honouring java.io.tmpdir (upstream issue #37533), so
|
||||
# the augmentation needs both of these writable.
|
||||
- name: data
|
||||
mountPath: {{ include "keycloak.dataDir" . }}
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
containers:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
# Fully qualified: an unqualified reference would resolve to Docker
|
||||
# Hub by convention rather than by rule. quay.io is Keycloak's own
|
||||
# registry and the first quay.io image in this repository - which is
|
||||
# why ci/trivy/data/ksv0125.yaml had to learn about it.
|
||||
image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
# `start`, not `start-dev` - the development mode has insecure
|
||||
# defaults and a local cache. The image declares no CMD, only
|
||||
# ENTRYPOINT ["/opt/keycloak/bin/kc.sh"], so the subcommand has to
|
||||
# come from here.
|
||||
#
|
||||
# --optimized says "this installation is already built, do not check
|
||||
# and do not augment". It is what makes the read-only root filesystem
|
||||
# possible, and it is also why NO build option may be passed to this
|
||||
# container: with --optimized a build option is an error, and without
|
||||
# it the container would rebuild on a path it cannot write.
|
||||
args:
|
||||
- start
|
||||
- --optimized
|
||||
env:
|
||||
# THE HOSTNAME, DERIVED AND NOT LEFT OPEN - this is the
|
||||
# security-relevant pair of this chart. Upstream on proxy-headers:
|
||||
# "Misconfiguration might leave the server exposed to security
|
||||
# vulnerabilities." The failure mode is Host header injection: with
|
||||
# a hostname resolved from request headers, an attacker controls the
|
||||
# URLs the server generates, which is how tokens and password
|
||||
# resets end up pointed at somebody else's host.
|
||||
#
|
||||
# Two things keep that shut. hostname-strict stays at its default
|
||||
# true, so nothing is taken from the request. And hostname is a
|
||||
# FULL URL rather than a bare host, which upstream requires as soon
|
||||
# as hostname-admin is in play: "If hostname-admin is configured,
|
||||
# hostname must be set to a URL (not just hostname). Otherwise
|
||||
# Keycloak would not know what is the correct frontend URL".
|
||||
- name: KC_HOSTNAME
|
||||
value: {{ include "keycloak.frontendUrl" . | quote }}
|
||||
{{- if $adminHostname }}
|
||||
# THE ADMIN HOSTNAME - and it is NOT an access boundary. It decides
|
||||
# which URLs the server generates for the administration console
|
||||
# (redirects, assets, the API address the console itself calls).
|
||||
# Upstream, verbatim: "Using the hostname-admin option does not
|
||||
# prevent accessing the Administration REST API endpoints via the
|
||||
# frontend URL specified by the hostname option. If you want to
|
||||
# restrict access to the Administration REST API, you need to do it
|
||||
# on the reverse proxy level."
|
||||
#
|
||||
# So /admin/realms/... stays reachable through ingress.domain. This
|
||||
# chart renders no deny rule for it: a path rule on /admin that has
|
||||
# not been tested against a running console breaks redirects and
|
||||
# asset loading, and a measure that only looks like one is worse
|
||||
# than none. The full argument is at ingress.admin in values.yaml.
|
||||
- name: KC_HOSTNAME_ADMIN
|
||||
value: {{ include "keycloak.adminUrl" . | quote }}
|
||||
{{- end }}
|
||||
# Behind the Ingress the TLS ends at the controller, so the pod
|
||||
# itself serves plain HTTP - which Keycloak only does when told to.
|
||||
- name: KC_HTTP_ENABLED
|
||||
value: "true"
|
||||
# xforwarded, because the ingress-nginx controller sets
|
||||
# X-Forwarded-* and not RFC 7239 Forwarded. This is not optional
|
||||
# decoration: without it Keycloak sees the controller's address and
|
||||
# scheme instead of the client's.
|
||||
- name: KC_PROXY_HEADERS
|
||||
value: "xforwarded"
|
||||
- name: KC_HTTP_PORT
|
||||
value: {{ $settings.ports.http | int64 | quote }}
|
||||
- name: KC_HTTP_MANAGEMENT_PORT
|
||||
value: {{ $settings.ports.management | int64 | quote }}
|
||||
# CLUSTERING. ispn is already the default for `start` in production
|
||||
# mode; it is set explicitly because it is the decision that makes
|
||||
# the second replica a cluster member rather than a second island,
|
||||
# and an explicit value cannot be lost to a change of upstream
|
||||
# defaults.
|
||||
#
|
||||
# cache-stack is deliberately NOT set: the default is jdbc-ping,
|
||||
# which discovers peers through the database that is there anyway.
|
||||
# The alternative, cache-stack=kubernetes with DNS_PING, is marked
|
||||
# "(deprecated)" upstream and would need a headless Service and a
|
||||
# jgroups.dns.query - none of which this chart renders.
|
||||
- name: KC_CACHE
|
||||
value: "ispn"
|
||||
{{- with $settings.cache.clusterName }}
|
||||
# Only when set. The trap it addresses: "clusters that share the
|
||||
# same database but should remain separate will merge unless they
|
||||
# have distinct cluster names". See values.yaml - the default is
|
||||
# upstream's ISPN on purpose.
|
||||
- name: KC_CACHE_EMBEDDED_CLUSTER_NAME
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
# A stable node name, from the pod name. Upstream's default is "a
|
||||
# random name ... generated on each start, which makes it difficult
|
||||
# to correlate metrics and logs across restarts" - and with two
|
||||
# replicas that correlation is the first thing anybody needs when
|
||||
# the cluster misbehaves.
|
||||
- name: KC_CACHE_EMBEDDED_NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
# THE DATABASE. Note what is NOT here: KC_DB. The vendor is a build
|
||||
# option and is baked in by the init container above; setting it
|
||||
# here would make this container re-augment on a read-only path.
|
||||
- name: KC_DB_URL_HOST
|
||||
value: {{ required "A database host must be provided (keycloak.db.host)" $settings.db.host | quote }}
|
||||
- name: KC_DB_URL_PORT
|
||||
value: {{ $settings.db.port | int64 | quote }}
|
||||
- name: KC_DB_URL_DATABASE
|
||||
value: {{ required "A database name must be provided (keycloak.db.database)" $settings.db.database | quote }}
|
||||
- name: KC_DB_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: username
|
||||
# AS A VARIABLE, NOT A FILE, and that is forced: Keycloak 26 has no
|
||||
# _FILE indirection for an option's value - no KC_DB_PASSWORD_FILE
|
||||
# and no generic KC_<OPTION>_FILE. Checked against the 26.8.0
|
||||
# option table, where every KC_*_FILE variable is a path option
|
||||
# (certificates, key stores, trust stores, log and export files).
|
||||
# The price is named at secrets.databaseSecretRef in values.yaml.
|
||||
- name: KC_DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: password
|
||||
# POOL SIZES, per replica. Upstream's db-pool-max-size default is
|
||||
# 100, which with two replicas asks a default PostgreSQL
|
||||
# (max_connections = 100) for twice what it has. See values.yaml.
|
||||
- name: KC_DB_POOL_INITIAL_SIZE
|
||||
value: {{ $settings.db.pool.initialSize | int64 | quote }}
|
||||
- name: KC_DB_POOL_MIN_SIZE
|
||||
value: {{ $settings.db.pool.minSize | int64 | quote }}
|
||||
- name: KC_DB_POOL_MAX_SIZE
|
||||
value: {{ $settings.db.pool.maxSize | int64 | quote }}
|
||||
{{- if $settings.bootstrapAdmin.enabled }}
|
||||
# THE TEMPORARY BOOTSTRAP ADMINISTRATOR. Needed for a first
|
||||
# install, because Keycloak "only allows to create the initial
|
||||
# admin user from a local network connection", which a container
|
||||
# never has.
|
||||
#
|
||||
# Two things NOTES.txt repeats because they cost time otherwise:
|
||||
# these variable names exist only since Keycloak 26 (before:
|
||||
# KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD), and the account they
|
||||
# create is TEMPORARY - creating a permanent administrator
|
||||
# afterwards is a mandatory step, not advice.
|
||||
- name: KC_BOOTSTRAP_ADMIN_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-bootstrap-admin-secret
|
||||
key: username
|
||||
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-bootstrap-admin-secret
|
||||
key: password
|
||||
{{- end }}
|
||||
{{- with .Values.keycloak.env }}
|
||||
{{- include "keycloak.env" (dict "root" $ "env" .) | nindent 12 }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- containerPort: {{ $settings.ports.http | int64 }}
|
||||
protocol: TCP
|
||||
name: http
|
||||
# THE MANAGEMENT PORT. All three probes address it BY NAME, so
|
||||
# renaming it here breaks them. It serves /health/* and - with
|
||||
# keycloak.metrics.enabled - /metrics, both UNAUTHENTICATED, which
|
||||
# is why no Ingress in this chart points at it and none should be
|
||||
# added.
|
||||
- containerPort: {{ $settings.ports.management | int64 }}
|
||||
protocol: TCP
|
||||
name: management
|
||||
# JGroups. Declared for the record rather than for routing: nothing
|
||||
# serves it and no Service exposes it. jdbc-ping finds the peers
|
||||
# through the database and then opens DIRECT pod-to-pod
|
||||
# connections on this port, so the replicas need plain L3
|
||||
# reachability between pod IPs - no Service, headless or otherwise.
|
||||
- containerPort: {{ $settings.ports.jgroups | int64 }}
|
||||
protocol: TCP
|
||||
name: jgroups
|
||||
# Probes, resources and both security contexts go through the
|
||||
# null-safe helpers (#99): an erased block means "chart defaults
|
||||
# apply" instead of a vanished probe or an unhardened container.
|
||||
# Never wrap these in an "if" - a guard hides the erased case
|
||||
# completely instead of rendering a visible null.
|
||||
#
|
||||
# Which probe points where, and why liveness must NOT use /health or
|
||||
# /health/ready, is argued at each helper in _helpers.tpl.
|
||||
livenessProbe:
|
||||
{{- include "keycloak.livenessProbe" .Values.keycloak.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "keycloak.readinessProbe" .Values.keycloak.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "keycloak.startupProbe" .Values.keycloak.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "keycloak.effectiveResources" .Values.keycloak.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }}
|
||||
volumeMounts:
|
||||
# The finished augmentation, produced by the two init containers.
|
||||
# Writable, although --optimized means nothing should write here:
|
||||
# it is an emptyDir either way, and a writable mount is what keeps
|
||||
# a re-augmentation (should a future version decide it needs one) a
|
||||
# slow start rather than a crash loop.
|
||||
- name: quarkus-build
|
||||
mountPath: {{ include "keycloak.quarkusBuildDir" . }}
|
||||
# Empty in the image, so this hides nothing. Needed because Quarkus
|
||||
# places its temporary directory under the Keycloak home instead of
|
||||
# honouring java.io.tmpdir (upstream issue #37533).
|
||||
- name: data
|
||||
mountPath: {{ include "keycloak.dataDir" . }}
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
# DELIBERATELY NOT MOUNTED: /opt/keycloak/conf. It is not empty in
|
||||
# the image - it carries keycloak.conf and, more importantly,
|
||||
# cache-ispn.xml, which is the Infinispan configuration cache=ispn
|
||||
# reads. An emptyDir there would hide it and the cluster would come up
|
||||
# with no cache configuration.
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: quarkus-build
|
||||
emptyDir: {}
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
@@ -1,108 +0,0 @@
|
||||
{{- /*
|
||||
TWO INGRESS OBJECTS, AND THE SECOND ONE IS NOT A SECURITY BOUNDARY.
|
||||
|
||||
The public one publishes the login endpoints, the token endpoints and the
|
||||
account console. The admin one publishes the SAME backend under a second
|
||||
hostname, so that the administration console - whose URLs Keycloak generates
|
||||
from hostname-admin - can live on an internal ingress class or in a different
|
||||
DNS zone.
|
||||
|
||||
WHAT IT DOES NOT DO, because the value's name invites the opposite assumption.
|
||||
Upstream, verbatim: "Using the hostname-admin option does not prevent accessing
|
||||
the Administration REST API endpoints via the frontend URL specified by the
|
||||
hostname option. If you want to restrict access to the Administration REST API,
|
||||
you need to do it on the reverse proxy level." So /admin/realms/... stays
|
||||
reachable through ingress.domain, and an IP allow-list on the admin host
|
||||
restricts nothing by itself.
|
||||
|
||||
This chart deliberately renders NO deny rule for /admin on the public Ingress.
|
||||
Two reasons, and the second is the one that decided it: the administration
|
||||
console loads assets and performs redirects across more paths than /admin, so a
|
||||
rule written from the path name breaks the console in ways that only show up in
|
||||
a browser; and a rule nobody has tested against a running console is a measure
|
||||
that looks like one, which is worse than an absent one. Whoever needs the
|
||||
restriction puts it on the proxy in front of the public host, where it can be
|
||||
tested.
|
||||
|
||||
GUIDELINE 12.1, CHECKED IN BOTH DIRECTIONS: ingress.domain is read a second
|
||||
time outside this template - as KC_HOSTNAME on the Deployment - so it stays
|
||||
required even with ingress.enabled: false, and its required() lives in the
|
||||
keycloak.frontendUrl helper rather than here. clusterIssuer is read only in
|
||||
this file, so switching both Ingresses off frees it.
|
||||
*/ -}}
|
||||
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port:
|
||||
name: http
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- end }}
|
||||
{{- if $adminEnabled }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
---
|
||||
{{- end }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
annotations:
|
||||
# Its own certificate, for its own host. A separate TLS secret
|
||||
# (tls-<release>-keycloak-admin-ingress) rather than a shared one, because
|
||||
# these are two different hostnames that may well sit on two different
|
||||
# ingress classes - the shared-secret form in the style guide is for
|
||||
# several objects on ONE host.
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
|
||||
{{- with ((.Values.ingress.admin | default dict).annotations) }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
# Falls back to ingress.className when not set. An internal ingress class
|
||||
# here is the useful half of the admin hostname - and still not the access
|
||||
# control the comment at the top of this file is about.
|
||||
ingressClassName: {{ (.Values.ingress.admin | default dict).className | default .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
|
||||
http:
|
||||
paths:
|
||||
# The SAME backend and the same path as the public Ingress. The
|
||||
# administration console is not a separate application and not a
|
||||
# separate path - only the hostname differs, and Keycloak generates
|
||||
# its admin URLs for that hostname because of KC_HOSTNAME_ADMIN.
|
||||
- backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port:
|
||||
name: http
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
|
||||
{{- end }}
|
||||
@@ -1,40 +0,0 @@
|
||||
{{- $settings := fromYaml (include "keycloak.settings" .) }}
|
||||
{{- /*
|
||||
THE MANAGEMENT SERVICE, AND WHAT IT IS AND IS NOT FOR.
|
||||
|
||||
Keycloak serves /health, /health/live, /health/ready, /health/started and -
|
||||
with keycloak.metrics.enabled - /metrics on the management port, and it serves
|
||||
them WITHOUT AUTHENTICATION. The probes do not need this Service (the kubelet
|
||||
addresses the container port directly); a metrics scraper or an uptime check
|
||||
inside the cluster does, which is the only reason it exists.
|
||||
|
||||
IT IS A SEPARATE OBJECT FROM THE SERVING SERVICE ON PURPOSE. Putting port 9000
|
||||
next to port 8080 on one Service would mean the Service an Ingress already
|
||||
points at also carries the unauthenticated health and metrics surface - and
|
||||
then publishing it is one wrong port name in an Ingress backend away, with
|
||||
nothing in the diff that reads as a mistake. Separated, naming it is a
|
||||
deliberate act.
|
||||
|
||||
AND IT MUST NEVER GET AN INGRESS. There is no value in this chart that would
|
||||
publish it, and none should be added. An unauthenticated readiness endpoint on
|
||||
the internet tells an attacker when the database is down; /metrics tells them
|
||||
the realm and client names. Upstream's own alternative for this - setting
|
||||
legacy-observability-interface=true, which moves health back onto the serving
|
||||
port where the Ingress already is - is explicitly "not recommended for security
|
||||
reasons", and this chart does not offer it.
|
||||
*/ -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-management-service
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: {{ $settings.ports.management | int64 }}
|
||||
targetPort: management
|
||||
name: management
|
||||
type: ClusterIP
|
||||
@@ -1,38 +0,0 @@
|
||||
{{- $settings := fromYaml (include "keycloak.settings" .) }}
|
||||
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) }}
|
||||
{{- /*
|
||||
THE FIRST PodDisruptionBudget IN THIS REPOSITORY, and the convention it sets is
|
||||
in README ("Pod disruption budgets") and in the wiki style guide.
|
||||
|
||||
WHY IT EXISTS HERE: with two replicas, a node drain is otherwise free to take
|
||||
both pods at once - and a drain is the most common reason a pod moves, because
|
||||
it is what a node upgrade does. Without this object the second replica buys
|
||||
nothing against the failure it was added for. minAvailable rather than
|
||||
maxUnavailable, because the guarantee wanted is about the service: at least one
|
||||
node answers logins throughout the drain.
|
||||
|
||||
AND WHY IT IS NOT RENDERED BELOW TWO REPLICAS - the trap that makes a PDB worth
|
||||
a condition rather than an unconditional object: minAvailable: 1 with exactly
|
||||
ONE replica forbids every voluntary eviction, so `kubectl drain` blocks forever
|
||||
on a pod nothing is allowed to move. The message then names the PDB and not the
|
||||
replica count, which sends the reader to the wrong file. The same applies to
|
||||
scaleDown: true, where there are no pods at all and the object would only
|
||||
describe a promise about nothing.
|
||||
|
||||
So: rendered when the effective replica count is >= 2, skipped otherwise, and
|
||||
NOTES.txt prints which of the two happened so that an operator does not have to
|
||||
guess from `kubectl get pdb`.
|
||||
*/ -}}
|
||||
{{- if and $settings.podDisruptionBudget.enabled (ge $replicas 2) }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-pdb
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
spec:
|
||||
minAvailable: {{ $settings.podDisruptionBudget.minAvailable | int64 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
{{- end }}
|
||||
@@ -1,84 +0,0 @@
|
||||
{{- /*
|
||||
The 1Password items this chart materializes. Two of them, because they have
|
||||
different lifetimes rather than different audiences: the database credential
|
||||
lives as long as the release, while the bootstrap administrator is meant to be
|
||||
switched off again after the first install.
|
||||
|
||||
WHAT THE FIELD NAMES ARE AND WHY THEY ARE NOT FREE-FORM: the operator
|
||||
materializes one Secret key per FIELD of the item, and the Deployment
|
||||
references those keys by name. An item without the expected field leaves the
|
||||
container in CreateContainerConfigError - not in a crash loop, and not with a
|
||||
message about the field. NOTES.txt lists all four fields for the installer.
|
||||
|
||||
THEY ARRIVE AS ENVIRONMENT VARIABLES AND THAT IS FORCED, NOT PREFERRED.
|
||||
Keycloak 26 has no _FILE indirection for an option's value: there is no
|
||||
KC_DB_PASSWORD_FILE and no generic KC_<OPTION>_FILE. Checked against the 26.8.0
|
||||
option table - every KC_*_FILE variable there is a PATH option (certificate,
|
||||
key store, trust store, log file, export file), i.e. "point at this file", not
|
||||
"read this option's value from a file". So the file form valkey argues for is
|
||||
simply unavailable here. DO NOT "FIX" THIS to a file mount: there is no option
|
||||
that would read it. (Keycloak's Vault SPI, KC_VAULT=file, is a different and
|
||||
larger mechanism with its own naming scheme, and adopting it would be its own
|
||||
decision.)
|
||||
|
||||
AND THE LIMIT THAT CANNOT BE CLOSED: a change to the CONTENT of either item
|
||||
restarts nothing. Helm never sees a secret the 1Password operator
|
||||
materializes, so there is nothing to hash. What this chart does cover is the
|
||||
op:// PATH, through checksum/secret-ref on the pod template. After rotating a
|
||||
value INSIDE an item the manual step is
|
||||
kubectl -n <ns> rollout restart deployment/<release>-keycloak-deployment
|
||||
|
||||
The 1Password operator's auto-restart annotation is NOT set here. Unlike the
|
||||
StatefulSet charts in this repo it WOULD work - the operator enumerates
|
||||
Deployments - so this is a decision rather than a constraint: its trigger is
|
||||
the item's version COUNTER and not its content, so a re-save with identical
|
||||
values rolls the whole cluster. For an identity provider, an unannounced
|
||||
rolling restart because somebody opened a vault entry is worse than a
|
||||
deliberate one. README guideline 9 has the precedence trap if it is ever set.
|
||||
*/ -}}
|
||||
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
spec:
|
||||
# Must carry the fields: username, password
|
||||
#
|
||||
# The role needs to own its schema: Keycloak creates and migrates the whole
|
||||
# schema itself through Liquibase on first start, so a read-write grant on
|
||||
# existing tables is not enough.
|
||||
#
|
||||
# Host, port and database name are NOT in this item - they are plain values
|
||||
# (keycloak.db.host / .port / .database), because they are not secret and
|
||||
# because moving a database should not mean editing a vault entry.
|
||||
itemPath: {{ required "You must provide a database secret reference (secrets.databaseSecretRef) - the 1Password item holding the fields 'username' and 'password' of the PostgreSQL role Keycloak uses" .Values.secrets.databaseSecretRef }}
|
||||
{{- if $settings.bootstrapAdmin.enabled }}
|
||||
---
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-bootstrap-admin-secret
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
spec:
|
||||
# Must carry the fields: username, password
|
||||
#
|
||||
# THIS ACCOUNT IS TEMPORARY, and that is a change from Keycloak before 26 -
|
||||
# the old KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD pair created a regular
|
||||
# administrator. Upstream on the current pair: "A user or service admin
|
||||
# account created using one of the methods described below is temporary ...
|
||||
# the account should exist only for the duration necessary to perform
|
||||
# operations needed to gain permanent and more secure admin access. After
|
||||
# that, the account needs to be removed manually."
|
||||
#
|
||||
# So creating a permanent administrator after the first install is a
|
||||
# MANDATORY step, and switching keycloak.bootstrapAdmin.enabled to false
|
||||
# afterwards removes both this item and the per-start log line about the
|
||||
# initial administrator not being created. NOTES.txt has the sequence.
|
||||
#
|
||||
# Upstream's default for the username is "temp-admin"; this chart takes both
|
||||
# fields from the vault so the pair has one source of truth.
|
||||
itemPath: {{ required "You must provide a bootstrap admin secret reference (secrets.bootstrapAdminSecretRef) - the 1Password item holding the fields 'username' and 'password' of the TEMPORARY bootstrap administrator. Required while keycloak.bootstrapAdmin.enabled is true; set that to false once a permanent administrator exists" .Values.secrets.bootstrapAdminSecretRef }}
|
||||
{{- end }}
|
||||
@@ -1,30 +0,0 @@
|
||||
{{- $settings := fromYaml (include "keycloak.settings" .) }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
ports:
|
||||
# The serving port. Both Ingresses target it BY NAME ("http"), and
|
||||
# in-cluster consumers reach it at
|
||||
# http://<release>-keycloak-service.<namespace>.svc.cluster.local:8080
|
||||
- protocol: TCP
|
||||
port: {{ $settings.ports.http | int64 }}
|
||||
targetPort: http
|
||||
name: http
|
||||
# DELIBERATELY NO MANAGEMENT PORT AND NO JGROUPS PORT HERE. The management
|
||||
# port has a Service of its own (keycloak.management.service.yaml) so that
|
||||
# whatever scrapes it has to name it explicitly rather than inherit it from
|
||||
# the Service an Ingress points at - mixing the two is how an
|
||||
# unauthenticated health endpoint ends up one annotation away from the
|
||||
# internet. JGroups needs no Service at all: jdbc-ping discovers peers
|
||||
# through the database and the nodes then talk pod IP to pod IP.
|
||||
#
|
||||
# ClusterIP, and no value to change it. The way out of the cluster is the
|
||||
# Ingress with a certificate, not a NodePort that puts an identity provider
|
||||
# on a routable address by accident.
|
||||
type: ClusterIP
|
||||
@@ -1,270 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "keycloak values",
|
||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key this chart does not read is rejected at render time instead of being silently ignored; the objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotation maps) stay open, because the chart does not interpret their contents. The blocks the chart dereferences additionally accept null, because an erased block means 'chart defaults apply' (issue #99) rather than a vanished setting. Note what this schema CANNOT enforce: secrets.databaseSecretRef, keycloak.db.host, ingress.domain and ingress.admin.domain all ship as null and JSON Schema's 'required' tests presence, not null-ness, so a 'required' array would never fire - the enforcement is required() in the templates (issue #18).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"global": {
|
||||
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
|
||||
"type": "object"
|
||||
},
|
||||
"scaleDown": {
|
||||
"description": "true renders the Deployment with replicas: 0 and takes precedence over keycloak.replicas. Also the first half of the minor-upgrade procedure, because Keycloak supports a rolling update only inside a minor line.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"databaseSecretRef": {
|
||||
"description": "op:// item path. The item must carry the fields 'username' and 'password' of the PostgreSQL role, which has to be able to create its own schema. Always required - the chart has no default database.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"bootstrapAdminSecretRef": {
|
||||
"description": "op:// item path. The item must carry the fields 'username' and 'password' of the TEMPORARY bootstrap administrator. Required while keycloak.bootstrapAdmin.enabled is true.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"keycloak": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "Default 2, because an SSO outage takes every dependent login with it. An explicit 0 is allowed and is the per-workload way to stop the release. Above 1 the rolling-update restriction applies: a minor version jump needs the whole cluster down.",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"db": {
|
||||
"description": "May be null / empty: the chart then falls back to its defaults. The host has no default and is enforced by required() in the Deployment.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"vendor": {
|
||||
"description": "A Keycloak BUILD option - it is passed to `kc.sh build` in the init container, never to the serving container. dev-file and dev-mem are rejected on purpose: they are Keycloak's development defaults, and an identity provider on an embedded database works until the pod moves. Only 'postgres' is exercised by this chart's CI.",
|
||||
"type": "string",
|
||||
"enum": ["postgres", "mariadb", "mysql", "mssql", "oracle"]
|
||||
},
|
||||
"host": {
|
||||
"description": "Host of the external PostgreSQL. Required - enforced by required() in the Deployment, because a null here would render an empty KC_DB_URL_HOST and a pod that waits on a connection it can never make.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"port": { "$ref": "#/definitions/port" },
|
||||
"database": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1
|
||||
},
|
||||
"pool": {
|
||||
"description": "Connection pool sizes, PER REPLICA. Upstream's db-pool-max-size default is 100; this chart ships 20, because two replicas at 100 ask a default PostgreSQL (max_connections = 100) for twice what it has.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"initialSize": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"minSize": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"maxSize": { "type": ["integer", "null"], "minimum": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootstrapAdmin": {
|
||||
"description": "The temporary bootstrap administrator. Switch this off once a permanent administrator exists - it also stops the per-start log line about the initial administrator not being created.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"description": "A Keycloak BUILD option (metrics-enabled). ON, and not for the metrics: upstream's table of health checks has a 'Requires Metrics' column, and the DATABASE check is the row that says yes. With this false, /health/ready still answers 200 while the database is gone - the urlaubsverwaltung shape (#218), with the dependency hidden behind an option whose name says observability. The price is that /metrics then answers on the management port, unauthenticated, carrying realm and client names - that port is ClusterIP-only and in no Ingress. Switching this off is a decision about whether readiness can fail.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"description": "Additional Keycloak feature flags (--features), a BUILD option. A list rather than a comma-separated string, so two entries cannot arrive as one unknown feature name - which Keycloak rejects at build time, inside the init container. The pattern keeps a value out that could not be a feature name.",
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]*$"
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"clusterName": {
|
||||
"description": "--cache-embedded-cluster-name. null means Keycloak's default, 'ISPN'. Read the comment in values.yaml before setting it: two releases sharing one database MERGE into one cluster unless the names differ, but upstream also warns that cache invalidation does not work between differently-named clusters.",
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"ports": {
|
||||
"description": "May be null / empty: the chart then falls back to its defaults. The Deployment renders these as containerPorts and the Services address them by name, so both follow one source.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"http": { "$ref": "#/definitions/port" },
|
||||
"management": {
|
||||
"description": "The management port. It carries the unauthenticated /health/* endpoints and optionally /metrics, and all three probes address it by name. It is deliberately in no Ingress.",
|
||||
"$ref": "#/definitions/port"
|
||||
},
|
||||
"jgroups": {
|
||||
"description": "JGroups transport port. Declared as a containerPort for the record; jdbc-ping opens direct pod-to-pod connections on it.",
|
||||
"$ref": "#/definitions/port"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"description": "Resources of the SERVING container. The memory request is not only about scheduling here: the image sizes the JVM heap relative to the container limit (-XX:MaxRAMPercentage=70), so request x limitFactor decides how much memory the application believes it has.",
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"build": {
|
||||
"description": "The build init container, which runs the one-shot Quarkus augmentation. Its resources are transient - it is gone before the serving container starts - but they count for scheduling, because a pod's effective request is the maximum of its init containers' and the sum of its containers'.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"resources": { "$ref": "#/definitions/resources" }
|
||||
}
|
||||
},
|
||||
"livenessProbe": { "$ref": "#/definitions/probe" },
|
||||
"readinessProbe": { "$ref": "#/definitions/probe" },
|
||||
"startupProbe": { "$ref": "#/definitions/probe" },
|
||||
"securityContext": { "$ref": "#/definitions/securityContext" },
|
||||
"podDisruptionBudget": {
|
||||
"description": "The first PodDisruptionBudget in this repository. It is rendered only while the effective replica count is >= 2: minAvailable 1 with a single replica forbids every voluntary eviction, so a node drain would block forever on a pod nothing may move.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"minAvailable": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"env": { "$ref": "#/definitions/env" }
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the public Ingress object only (issue #93). Checked in both directions for this chart: ingress.domain is ALSO read as KC_HOSTNAME on the Deployment, so switching the Ingress off frees clusterIssuer but NOT domain - Keycloak refuses to start in production mode without a hostname.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": { "type": ["string", "null"] },
|
||||
"domain": {
|
||||
"description": "The public hostname, a plain host - the chart renders it as the full URL https://<domain> for KC_HOSTNAME, which upstream requires as soon as hostname-admin is in play. Required even with enabled: false.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": { "type": ["string", "null"] },
|
||||
"annotations": {
|
||||
"description": "Extra annotations for the public Ingress, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
|
||||
"type": "object"
|
||||
},
|
||||
"admin": {
|
||||
"description": "The administration console on a hostname of its own. NOT AN ACCESS CONTROL: hostname-admin decides which URLs the server generates; upstream states that it does not prevent reaching the Administration REST API through the public hostname, and that restricting that API is reverse-proxy work. The chart renders no deny rule for /admin.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"domain": {
|
||||
"description": "The admin hostname, a plain host - rendered as https://<domain> for KC_HOSTNAME_ADMIN. Required while enabled.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"className": {
|
||||
"description": "null falls back to ingress.className. An internal ingress class here keeps the console off the public controller, which is the useful half of the admin hostname.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"port": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"resources": {
|
||||
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limitFactor": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"requests": { "$ref": "#/definitions/resourceList" },
|
||||
"limits": { "$ref": "#/definitions/resourceList" }
|
||||
}
|
||||
},
|
||||
"resourceList": {
|
||||
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cpu": { "type": ["string", "number", "null"] },
|
||||
"memory": { "type": ["string", "number", "null"] },
|
||||
"ephemeral-storage": { "type": ["string", "number", "null"] },
|
||||
"ephemeralStorage": { "type": ["string", "number", "null"] }
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"description": "Additive container env entries. An entry repeating a name the chart already sets does not replace it - on an existing release the pair collapses to the chart's own value while helm upgrade reports success. A BUILD option's variable name (KC_DB, KC_HEALTH_ENABLED, KC_METRICS_ENABLED, KC_FEATURES, ...) is refused by the template with a fail: on the serving container it would disagree with what the init container built, and Keycloak answers that by re-augmenting onto a read-only path.",
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"value": { "type": "string" },
|
||||
"valueFrom": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"secretKeyRef": { "$ref": "#/definitions/keyRef" },
|
||||
"configMapKeyRef": { "$ref": "#/definitions/keyRef" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keyRef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"key": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99). This chart's fallback reproduces ITS defaults, which deviate from the repo's usual 20000/1000 pair: the published image declares keycloak:x:1000:0 in /etc/passwd and copies the whole install tree with --chown=1000:0, so uid 1000 is the OWNER of everything the process reads. The GROUP is 1000 and not the image's own 0 - counted across the image layer, 521 of 522 entries are owned by 1000:0 with every mode granting the owner full access, so group 0 (upstream's OpenShift arbitrary-uid pattern) buys nothing once the owning uid is pinned, and leaving it out keeps a root GID out of the pod. Every container in the pod, both init containers included, uses the same container context: readOnlyRootFilesystem: true holds throughout, which is the reason the chart has two init containers rather than one writable one.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pod": {
|
||||
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
},
|
||||
"container": {
|
||||
"description": "Merged onto the chart's container hardening defaults and rendered as the securityContext of every container in the pod; kept open because the chart does not interpret the individual fields.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"probe": {
|
||||
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The defaults carry the probe's CONTEXT: the port NAME 'management' for all three, and three DIFFERENT paths - /health/live for liveness (no database check), /health/ready for readiness (with one), /health/started for startup. Pointing liveness at /health or /health/ready would restart every pod of the cluster for as long as the database is away.",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
# Keycloak - an OpenID Connect and SAML identity provider, deployed as a
|
||||
# TWO-NODE cluster against an external PostgreSQL.
|
||||
#
|
||||
# READ THIS FIRST, because three properties shape every value below and none of
|
||||
# them is obvious from the outside (Gitea #83).
|
||||
#
|
||||
# 1. THERE IS NO ConfigMap IN THIS CHART, AND THAT IS NOT AN OMISSION.
|
||||
# Keycloak is configured entirely through options - CLI arguments and KC_*
|
||||
# environment variables - so no file is rendered, mounted or hashed. The
|
||||
# repo's checksum/config rule (#82) therefore has nothing to attach to:
|
||||
# there is no ConfigMap whose change could fail to restart the workload.
|
||||
# What IS covered is the op:// PATH of the two vault items, through
|
||||
# checksum/secret-ref on the pod template. A change INSIDE an item still
|
||||
# restarts nothing - Helm never sees it - and NOTES.txt names the manual
|
||||
# rollout restart for that case.
|
||||
#
|
||||
# 2. THE CHART RUNS A BUILD STEP IN AN INIT CONTAINER, AND IT HAS TO.
|
||||
# Keycloak distinguishes BUILD options from runtime options. Three of the
|
||||
# options this chart needs are build options - verified against the 26.8.0
|
||||
# "All configuration" page, where each carries the tool icon:
|
||||
# db (default dev-file)
|
||||
# health-enabled (default false)
|
||||
# metrics-enabled (default false)
|
||||
# The published image is augmented with those DEFAULTS. Setting any of them
|
||||
# at runtime makes Keycloak re-augment itself at start, and the augmentation
|
||||
# rewrites four files INSIDE the install tree:
|
||||
# /opt/keycloak/lib/quarkus/{generated-bytecode.jar,
|
||||
# transformed-bytecode.jar,quarkus-application.dat,
|
||||
# build-system.properties}
|
||||
# (read out of the image layer itself, not inferred). Upstream says what
|
||||
# that start does, in the container guide: it "detects and applies the build
|
||||
# options first", which "significantly increases startup time and CREATES AN
|
||||
# IMAGE THAT IS MUTABLE". A mutable image is exactly what
|
||||
# readOnlyRootFilesystem: true forbids - so the stock image, probes on
|
||||
# /health and a read-only root filesystem are not a combination that can
|
||||
# start. And "then do not set db" is no way out: THE PROBES ALONE force the
|
||||
# augmentation, because health-enabled is a build option too.
|
||||
#
|
||||
# So an init container from the SAME image runs `kc.sh build` into an
|
||||
# emptyDir that the main container mounts at /opt/keycloak/lib/quarkus, and
|
||||
# the main container runs `start --optimized`. No second image to pin (this
|
||||
# repository publishes charts, not images), hardening intact, and the
|
||||
# augmentation happens once in the init container instead of inside the
|
||||
# serving container on every start.
|
||||
#
|
||||
# THE CONDITION THAT MAKES IT SAFE, and the line a later round will want to
|
||||
# "simplify": the two containers must never disagree about the build
|
||||
# options, because a disagreement makes the MAIN container re-augment - on
|
||||
# a path that is now read-only, i.e. a crash loop. This chart enforces that
|
||||
# structurally rather than by care: the build options exist in exactly one
|
||||
# place (the keycloak.buildArgs helper, which only the init container uses),
|
||||
# the main container passes `start --optimized` and NO build option at all,
|
||||
# and the render FAILS if a keycloak.env entry carries a build option's
|
||||
# variable name. Do not add a KC_DB, KC_HEALTH_ENABLED, KC_METRICS_ENABLED
|
||||
# or KC_FEATURES to the main container "for clarity".
|
||||
#
|
||||
# 3. NO PVC, AND NO StatefulSet. Keycloak 26 keeps everything in the database,
|
||||
# user sessions included (persistent user sessions are the default since 26;
|
||||
# volatile sessions are deprecated as of 26.8). There is no state on disk to
|
||||
# keep and no stable identity to preserve, so this is a Deployment - the
|
||||
# repo's StatefulSet rule is about STATEFUL applications, and here it would
|
||||
# be decoration.
|
||||
|
||||
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
||||
# the Deployment with replicas: 0 and takes precedence over keycloak.replicas.
|
||||
# Secrets, Services and Ingresses stay in place; scaleDown: false brings the
|
||||
# workload back up.
|
||||
#
|
||||
# THIS IS ALSO HALF OF THE MINOR-UPGRADE PROCEDURE. Keycloak supports a rolling
|
||||
# update only inside a minor line, so a 26.8 -> 26.9 jump is: scaleDown: true,
|
||||
# back up configuration and database, change the version, scaleDown: false.
|
||||
# NOTES.txt has the sequence and the compatibility check that decides it.
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// path of the 1Password item carrying the database credentials.
|
||||
# REQUIRED - the chart has no default database and Keycloak does not start
|
||||
# without one.
|
||||
#
|
||||
# Must carry the fields:
|
||||
# username the database role -> KC_DB_USERNAME
|
||||
# password that role's password -> KC_DB_PASSWORD
|
||||
#
|
||||
# Host, port and database name are NOT in the vault: they are plain values
|
||||
# under keycloak.db below, because they are not secret and because putting
|
||||
# them in the item would mean editing a vault entry to move a database.
|
||||
#
|
||||
# THEY ARRIVE AS ENVIRONMENT VARIABLES, NOT AS FILES, and that is forced
|
||||
# rather than chosen. Keycloak 26 has no _FILE indirection for an option's
|
||||
# value: there is no KC_DB_PASSWORD_FILE and no generic KC_<OPTION>_FILE.
|
||||
# Checked against the 26.8.0 "All configuration" page - every KC_*_FILE
|
||||
# variable there is a PATH option (certificate, key store, trust store, log
|
||||
# file, export file), i.e. "point at this file", never "read this option's
|
||||
# value from a file". The file form valkey argues for is simply unavailable
|
||||
# here, as it was for garage, for a different reason.
|
||||
#
|
||||
# The residual price, stated rather than hidden: the password is in the
|
||||
# container's process environment, so anyone who can exec into the pod or
|
||||
# read its /proc can see it. (Keycloak's own Vault SPI, KC_VAULT=file, is a
|
||||
# different and larger mechanism - a directory of secret files with its own
|
||||
# naming scheme - and deliberately not used here.)
|
||||
databaseSecretRef: null
|
||||
|
||||
# op:// path of the 1Password item carrying the TEMPORARY bootstrap
|
||||
# administrator. Required while keycloak.bootstrapAdmin.enabled is true
|
||||
# (the default), which is what a first install needs.
|
||||
#
|
||||
# Must carry the fields:
|
||||
# username -> KC_BOOTSTRAP_ADMIN_USERNAME (upstream default: temp-admin)
|
||||
# password -> KC_BOOTSTRAP_ADMIN_PASSWORD
|
||||
#
|
||||
# TWO THINGS ABOUT THIS ACCOUNT THAT CATCH PEOPLE OUT, both in NOTES.txt as
|
||||
# well because they are first-install work:
|
||||
# - the variables are named this way only since Keycloak 26. Before that
|
||||
# they were KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD, and a values file
|
||||
# copied from an older deployment silently creates no administrator at
|
||||
# all.
|
||||
# - the account is TEMPORARY. Upstream: "A user or service admin account
|
||||
# created using one of the methods described below is temporary ... the
|
||||
# account should exist only for the duration necessary to perform
|
||||
# operations needed to gain permanent and more secure admin access.
|
||||
# After that, the account needs to be removed manually." Creating a
|
||||
# permanent administrator is therefore a MANDATORY step after the first
|
||||
# install, not a recommendation.
|
||||
bootstrapAdminSecretRef: null
|
||||
|
||||
keycloak:
|
||||
# TWO, and the reason is the kind of service this is. A mail server may be
|
||||
# away briefly because SMTP retries; a blob store has one consumer. An SSO
|
||||
# server takes EVERY dependent login down with it, so the second replica is
|
||||
# the point of this chart rather than a tuning option.
|
||||
#
|
||||
# What makes two replicas cheap here: node discovery runs over the database
|
||||
# that is there anyway. cache=ispn plus Keycloak's default jdbc-ping stack
|
||||
# means no headless Service, no JGroups DNS wiring and no extra option - the
|
||||
# nodes register themselves in a table and then talk directly to each other
|
||||
# on port 7800. (cache-stack=kubernetes, the DNS_PING variant, is marked
|
||||
# deprecated upstream.)
|
||||
#
|
||||
# What it costs: twice the database connections, which is why the pool sizes
|
||||
# below are not upstream's defaults, and the rolling-update restriction -
|
||||
# with one replica a minor jump is a restart, with two it is a procedure.
|
||||
#
|
||||
# An explicit 0 is allowed and is the per-workload way to stop the release;
|
||||
# the chart-global scaleDown flag does the same for everything at once.
|
||||
replicas: 2
|
||||
|
||||
db:
|
||||
# THE DATABASE VENDOR, AND THE ONE VALUE THAT IS A BUILD OPTION (point 2
|
||||
# at the top of this file). It is passed to `kc.sh build` in the init
|
||||
# container and never to the serving container. Changing it re-runs the
|
||||
# build on the next pod start, which is correct and costs one init-container
|
||||
# run - it does NOT migrate any data.
|
||||
#
|
||||
# "postgres" is the only value this chart is tested against. The others
|
||||
# upstream accepts (mariadb, mysql, mssql, oracle) would render, but
|
||||
# nothing here or in CI has ever started them. dev-file and dev-mem are
|
||||
# rejected by the schema: they are Keycloak's development defaults, and an
|
||||
# identity provider on an embedded H2 file is the kind of install that
|
||||
# works until the pod moves.
|
||||
vendor: postgres
|
||||
|
||||
# Host of the external PostgreSQL. REQUIRED - there is no sensible default
|
||||
# and a wrong one is a pod that waits on a connection it will never get.
|
||||
host: null
|
||||
port: 5432
|
||||
database: keycloak
|
||||
|
||||
# CONNECTION POOL, AND A DELIBERATE DEPARTURE FROM UPSTREAM'S DEFAULT.
|
||||
# db-pool-max-size ships as 100 upstream (verified in the 26.8.0 option
|
||||
# table). With two replicas that is 200 connections against a PostgreSQL
|
||||
# whose own default max_connections is 100 - so the upstream default does
|
||||
# not merely waste connections, it hands out more than a default server
|
||||
# has, and the failure arrives under load as "too many clients already"
|
||||
# rather than at install time.
|
||||
#
|
||||
# 20 per replica = 40 in total leaves room for everything else on that
|
||||
# server. Raise it together with the server's max_connections, never alone,
|
||||
# and remember to multiply by keycloak.replicas when you do.
|
||||
#
|
||||
# initialSize and minSize have no upstream default; keeping a few warm
|
||||
# connections removes the first-request latency after an idle period.
|
||||
pool:
|
||||
initialSize: 2
|
||||
minSize: 2
|
||||
maxSize: 20
|
||||
|
||||
bootstrapAdmin:
|
||||
# Render KC_BOOTSTRAP_ADMIN_USERNAME / KC_BOOTSTRAP_ADMIN_PASSWORD from the
|
||||
# vault item above. ON, because a fresh install has no way in otherwise:
|
||||
# Keycloak "only allows to create the initial admin user from a local
|
||||
# network connection", which a container never has.
|
||||
#
|
||||
# SET THIS TO false ONCE A PERMANENT ADMINISTRATOR EXISTS. While the
|
||||
# variables stay set, every start logs a line about the initial
|
||||
# administrator not being created, because the master realm already exists
|
||||
# and the bootstrap path is skipped. That line is harmless and it is NOT a
|
||||
# defect - but it is the kind of message somebody eventually investigates,
|
||||
# so switching this off after the first install removes both the noise and
|
||||
# a credential the pod no longer needs.
|
||||
enabled: true
|
||||
|
||||
# metrics-enabled, a BUILD option - it goes to `kc.sh build`, so changing it
|
||||
# re-runs the init containers.
|
||||
#
|
||||
# ON, AND NOT FOR THE METRICS. This value is misnamed from our point of view:
|
||||
# it also decides whether /health/ready CONTAINS A DATABASE CHECK AT ALL.
|
||||
# Upstream's table of available checks has a "Requires Metrics" column, and
|
||||
# the Database check - "Returns the status of the database connection pool" -
|
||||
# is the one row that says YES. The Cluster, Graceful shutdown and Keycloak
|
||||
# Initialized checks say no.
|
||||
#
|
||||
# So with metrics-enabled=false, a pod whose database is gone still reports
|
||||
# Ready and keeps serving errors to every login. That is precisely the shape
|
||||
# urlaubsverwaltung cost this repo two years of false confidence (#218, and
|
||||
# README guideline 12.3): the probe path was right and the thing behind it
|
||||
# contained nothing external. Here the dependency hides behind an option
|
||||
# whose name says "observability".
|
||||
#
|
||||
# The price, which is real and is why the value still exists: /metrics then
|
||||
# answers on the management port, UNAUTHENTICATED, carrying realm names,
|
||||
# client ids and login counters. That port is ClusterIP-only and in no
|
||||
# Ingress (see keycloak.ports.management), so the exposure is in-cluster.
|
||||
#
|
||||
# SWITCHING THIS OFF IS A DECISION ABOUT WHETHER READINESS CAN FAIL, not
|
||||
# about scraping. If you do it, the readiness probe stops being able to
|
||||
# detect a database outage, and nothing else in this chart replaces it.
|
||||
metrics:
|
||||
enabled: true
|
||||
|
||||
# Additional Keycloak feature flags (--features), e.g. ["organization",
|
||||
# "declarative-ui"]. A BUILD option: entries here change what the init
|
||||
# container builds, so adding one re-runs the build on the next pod start.
|
||||
#
|
||||
# Deliberately a list rather than a free-form string: the option is
|
||||
# comma-separated, and a string would let two entries arrive as one unknown
|
||||
# feature name, which Keycloak rejects at BUILD time - in the init container,
|
||||
# where the error is one `kubectl logs -c` away from being invisible.
|
||||
features: []
|
||||
|
||||
cache:
|
||||
# --cache-embedded-cluster-name. NULL, i.e. Keycloak's default "ISPN", and
|
||||
# the value exists because of a trap rather than a need.
|
||||
#
|
||||
# THE TRAP: node discovery runs through the database, so - upstream's own
|
||||
# words - "clusters that share the same database but should remain separate
|
||||
# will merge unless they have distinct cluster names". Two releases of this
|
||||
# chart pointed at the SAME database therefore form ONE Infinispan cluster,
|
||||
# silently, with no error on either side. If that is your situation, the
|
||||
# first thing to check is whether they should be sharing a database at all;
|
||||
# separate databases need no cluster name at all.
|
||||
#
|
||||
# WHY IT IS NOT SET PER RELEASE BY DEFAULT, although that is the obvious
|
||||
# fix: the same upstream page warns about the other direction - "As
|
||||
# invalidation of caches between clusters with distinct names will not
|
||||
# work, this will lead to stale caches and incorrect behavior. Use only
|
||||
# together with the stateless feature." That warning is not unambiguous
|
||||
# about which configuration it applies to, and a chart default that trades
|
||||
# a documented merge for a possibly undocumented invalidation gap is not an
|
||||
# improvement. So: the value is here, the trap is written down, and the
|
||||
# default is upstream's.
|
||||
clusterName: null
|
||||
|
||||
# THE THREE PORTS, IN ONE PLACE, because two templates have to agree on them:
|
||||
# the Deployment renders them as containerPorts and the Services address them
|
||||
# BY NAME. The probes also address the management port by name, so renaming
|
||||
# one here breaks them.
|
||||
ports:
|
||||
# The serving port. Behind the Ingress, plain HTTP - see http-enabled in
|
||||
# the Deployment.
|
||||
http: 8080
|
||||
|
||||
# THE MANAGEMENT PORT, AND THE ONE THING TO KNOW BEFORE TOUCHING ANY OF
|
||||
# THIS: it carries /health, /health/live, /health/ready, /health/started
|
||||
# and - with keycloak.metrics.enabled - /metrics, and it carries them
|
||||
# WITHOUT AUTHENTICATION. All three probes use it, which is why it exists
|
||||
# as a containerPort and as a ClusterIP Service.
|
||||
#
|
||||
# IT MUST NEVER GET AN INGRESS. There is no value in this chart that would
|
||||
# publish it and none should be added: an unauthenticated readiness
|
||||
# endpoint on the internet tells an attacker when the database is down, and
|
||||
# /metrics tells them the realm and client names. Upstream's alternative
|
||||
# (legacy-observability-interface=true, which moves health back onto 8080
|
||||
# where the Ingress is) is explicitly "not recommended for security
|
||||
# reasons" and this chart does not offer it.
|
||||
management: 9000
|
||||
|
||||
# JGroups. Not served to anything and not in any Service - it is declared
|
||||
# so that the manifest says where cluster traffic goes, and so that a
|
||||
# NetworkPolicy (none yet in this chart) has a port to name. jdbc-ping
|
||||
# discovers peers through the database and then opens DIRECT pod-to-pod
|
||||
# connections on this port, so the two replicas need plain L3 reachability
|
||||
# between their pod IPs - not a Service, and not a headless one either.
|
||||
jgroups: 7800
|
||||
|
||||
resources:
|
||||
# Limits are optional: memory and ephemeral-storage limits default to
|
||||
# limitFactor x the request, an explicitly set limit always wins, and a CPU
|
||||
# limit is only set when configured explicitly - CPU is compressible, so
|
||||
# leaving it unlimited throttles instead of OOM-killing.
|
||||
#
|
||||
# 700Mi x 3 = 2100Mi, AND THAT NUMBER IS LOAD-BEARING RATHER THAN TIDY.
|
||||
# The image sizes the JVM heap RELATIVE to the container limit
|
||||
# (-XX:MaxRAMPercentage=70, -XX:InitialRAMPercentage=50 in kc.sh), so the
|
||||
# limit is the value the heap is computed from, not a ceiling that is
|
||||
# hopefully never reached. Upstream's container guide: "you should always
|
||||
# set the memory limit for the container", "at least 750 MB", and "For
|
||||
# smaller production-ready deployments, the recommended memory limit is
|
||||
# 2 GB". 2100Mi is that recommendation; 70% of it is a ~1470Mi heap.
|
||||
#
|
||||
# Lowering the request without raising limitFactor shrinks the heap
|
||||
# proportionally. That is the connection to keep in mind here - in most
|
||||
# charts of this repo the request is about scheduling, in this one it also
|
||||
# decides how much memory the application believes it has.
|
||||
#
|
||||
# The ephemeral-storage request covers the three emptyDirs (the Quarkus
|
||||
# build output, /opt/keycloak/data and /tmp) plus container logs. The build
|
||||
# output is a few MB; the head room is for the augmentation's temporary
|
||||
# files.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 700Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
# The two INIT CONTAINERS - the seed step and the Quarkus build. Both use
|
||||
# this resources block, because the seed step is a single `cp` whose cost
|
||||
# disappears next to the build's and a second block would only be a second
|
||||
# thing to keep in step.
|
||||
build:
|
||||
# Resources for the build init containers. Their own block, because they do
|
||||
# something quite different from the serving container: a single-shot
|
||||
# Quarkus augmentation, CPU-bound, measured by upstream's own issue reports
|
||||
# at roughly 13-23 seconds of "Quarkus augmentation completed in ...".
|
||||
#
|
||||
# More CPU here shortens every pod start, and the memory is transient - the
|
||||
# init containers are gone before the serving container begins, so these
|
||||
# requests do not add to the pod's running footprint. They DO count for
|
||||
# scheduling, though: a pod's effective request is the maximum of the init
|
||||
# containers' and the sum of the containers', so raising cpu above the
|
||||
# serving container's request raises what the scheduler needs to find.
|
||||
resources:
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 256Mi
|
||||
|
||||
# LIVENESS: /health/live on the management port - the endpoint that reports
|
||||
# whether this process is alive, and deliberately NOT /health or
|
||||
# /health/ready.
|
||||
#
|
||||
# This is guideline 12.3 in the one shape where upstream has already done the
|
||||
# work for us: Keycloak splits the three checks, and /health/live contains no
|
||||
# database check, while /health/ready and the aggregate /health do. A
|
||||
# liveness probe on either of those restarts every pod of the cluster for as
|
||||
# long as the database is away - which cannot fix a database, and which turns
|
||||
# a degraded login service into none at all. Same lesson as zipline (README,
|
||||
# guideline 12.3); here it is one path away.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: management
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# READINESS: /health/ready - which includes the database, and must. A node
|
||||
# that cannot reach its database serves errors to every login, so no Service
|
||||
# should route to it.
|
||||
#
|
||||
# AND THAT "INCLUDES THE DATABASE" IS CONDITIONAL, which is the trap worth
|
||||
# knowing before anybody tidies up keycloak.metrics.enabled: the database
|
||||
# check is only part of the report while metrics-enabled is true (upstream's
|
||||
# check table, "Requires Metrics: Yes"). The path alone guarantees nothing.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: management
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# STARTUP: /health/started, Keycloak's own "the server has finished booting"
|
||||
# endpoint. It gates liveness and readiness, which is why neither of those
|
||||
# carries an initialDelaySeconds.
|
||||
#
|
||||
# BUDGET = periodSeconds x failureThreshold = 10 x 60 = 600s, and the gap
|
||||
# between that and the MEASURED time is deliberate rather than lazy.
|
||||
#
|
||||
# MEASURED, in the install-test of Gitea PR #87 on the Gitea docker runner
|
||||
# (k3s v1.35.5, two replicas, empty database, image pulled during the run):
|
||||
# from `helm install` to BOTH pods Ready in 71.8 seconds - the first at
|
||||
# ~70.8s, the second one second later, 0 restarts. That figure already
|
||||
# contains the image pull, both init containers and the schema creation.
|
||||
#
|
||||
# Why the budget is still eight times that: everything in the measured run
|
||||
# was the cheap case. A first install against a real database creates the
|
||||
# whole schema through Liquibase, and with two replicas the second waits on
|
||||
# the first's migration lock instead of arriving a second later; a loaded
|
||||
# node, a slower disk or a bigger existing schema all push in the same
|
||||
# direction. A budget that is too short there produces a CrashLoopBackOff
|
||||
# that looks like an application fault and is really a stopwatch - and the
|
||||
# cost of the headroom is nothing, because a pod that IS healthy becomes
|
||||
# Ready on the first probe either way.
|
||||
#
|
||||
# The Quarkus augmentation is NOT in this budget - it runs in the init
|
||||
# container, which has no probe and no deadline of its own. That is the
|
||||
# second thing the init container buys.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/started
|
||||
port: management
|
||||
periodSeconds: 10
|
||||
failureThreshold: 60
|
||||
timeoutSeconds: 5
|
||||
|
||||
securityContext:
|
||||
pod:
|
||||
runAsNonRoot: true
|
||||
# uid 1000, NOT this repo's 20000, and that is the image's requirement
|
||||
# rather than a free choice: it declares "keycloak:x:1000:0" in
|
||||
# /etc/passwd and copies the whole install tree with --chown=1000:0, so
|
||||
# 1000 is the OWNER of everything the process reads and executes. Read
|
||||
# off the image rather than guessed - `USER 1000` and the chown are in
|
||||
# the config blob's history of quay.io/keycloak/keycloak:26.8.0.
|
||||
#
|
||||
# GROUP 1000 AND NOT 0, WHICH IS WHERE THIS CHART DIVERGES FROM THE
|
||||
# IMAGE'S OWN CONVENTION - deliberately, and measured before deciding.
|
||||
# Upstream puts the keycloak user in the ROOT GROUP and makes the install
|
||||
# tree group-writable (mode 775/664, owner 1000, group 0). That is the
|
||||
# OpenShift arbitrary-uid pattern: it lets ANY uid that happens to be in
|
||||
# group 0 write there. We do not need it, because we pin the uid that
|
||||
# OWNS the files - counted across the image layer, 521 of 522 entries are
|
||||
# owned by 1000:0 and every mode grants the owner full access, so uid
|
||||
# 1000 gets what it needs through the owner bits whatever its group is.
|
||||
# The one remaining entry is /opt itself, which needs only traverse.
|
||||
#
|
||||
# What that buys: no root primary or supplementary GID in this pod, i.e.
|
||||
# no AVD-KSV-0116 finding and no suppression for it. A suppression would
|
||||
# have had to cover the whole chart, serving container included, to
|
||||
# silence a check that is making a fair point.
|
||||
#
|
||||
# If this is ever changed back to 0, the reason has to be a path that
|
||||
# uid 1000 does NOT own - and there is none in this image today.
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: Always
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
# TRUE, and keeping it true is the whole reason this chart has an init
|
||||
# container (point 2 at the top). Setting it to false would make the
|
||||
# init container unnecessary - and would also mean an identity provider
|
||||
# that rewrites its own installation directory at every start. Do not
|
||||
# trade one for the other without reading that section.
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# EMPTY, and it can stay empty: 8080, 9000 and 7800 are all above
|
||||
# 1024, and the entrypoint is a shell script calling java - no file
|
||||
# capabilities, so no execve-time capability check like stalwart's.
|
||||
add: []
|
||||
|
||||
podDisruptionBudget:
|
||||
# A PodDisruptionBudget, the FIRST in this repository - and with two
|
||||
# replicas it is the piece that makes the second one worth having. Without
|
||||
# it a node drain is free to take both pods at once, and a drain is the
|
||||
# most common reason a pod moves: a node upgrade, not a crash.
|
||||
enabled: true
|
||||
|
||||
# minAvailable, not maxUnavailable, because the guarantee we want is about
|
||||
# the service and not about the disruption: at least one node answers
|
||||
# logins throughout a drain.
|
||||
#
|
||||
# THE TRAP THIS CHART AVOIDS BY NOT RENDERING THE OBJECT BELOW TWO
|
||||
# REPLICAS: minAvailable: 1 with exactly one replica forbids every
|
||||
# voluntary eviction, so `kubectl drain` hangs forever on a pod nothing can
|
||||
# move - and the message points at the PDB, not at the replica count. The
|
||||
# template therefore skips the PDB entirely while the effective replica
|
||||
# count is below 2 (keycloak.replicas: 1, or scaleDown: true), and
|
||||
# NOTES.txt prints whether it was rendered.
|
||||
minAvailable: 1
|
||||
|
||||
# Additive environment entries for the Keycloak container, rendered through
|
||||
# tpl and supporting the value, secretKeyRef and configMapKeyRef forms.
|
||||
#
|
||||
# ADDITIVE MEANS ADDITIVE: an entry repeating a name the chart already sets
|
||||
# does not replace it. At install time both reach the pod and the later one
|
||||
# wins; on an EXISTING release the pair collapses to the chart's own value,
|
||||
# because Kubernetes merges env by name - and helm upgrade still reports
|
||||
# success. So do not use this to override KC_HOSTNAME or the database
|
||||
# variables; change the values that produce them.
|
||||
#
|
||||
# AND ONE CLASS OF ENTRY IS REFUSED AT RENDER TIME: a BUILD option's variable
|
||||
# name (KC_DB, KC_HEALTH_ENABLED, KC_METRICS_ENABLED, KC_FEATURES and the
|
||||
# rest of the verified list in _helpers.tpl). A build option in the serving
|
||||
# container's environment disagrees with what the init container built, and
|
||||
# Keycloak answers a disagreement by re-augmenting - onto a read-only path,
|
||||
# i.e. a crash loop whose cause is three files away from the error message.
|
||||
# The render fails instead, naming the variable. Build options belong in the
|
||||
# values that feed the init container.
|
||||
#
|
||||
# Genuinely additive and useful here: JAVA_OPTS_APPEND, KC_LOG_LEVEL,
|
||||
# KC_SPI_* provider settings.
|
||||
env: []
|
||||
|
||||
ingress:
|
||||
# The PUBLIC ingress - the login endpoint, the token endpoints, the account
|
||||
# console. On by default, because an identity provider nothing can reach is
|
||||
# not an identity provider.
|
||||
enabled: true
|
||||
|
||||
# The public hostname. REQUIRED, AND REQUIRED EVEN WITH ingress.enabled:
|
||||
# false - this is guideline 12.1, checked in both directions: the value is
|
||||
# read a second time OUTSIDE the Ingress template, as KC_HOSTNAME on the
|
||||
# Deployment. Keycloak refuses to start in production mode without a
|
||||
# hostname (hostname-strict defaults to true), so gating it away would
|
||||
# produce a release that renders cleanly and never starts.
|
||||
#
|
||||
# IT IS RENDERED AS A FULL URL, https://<domain>, not as a bare host, and
|
||||
# that is upstream's requirement rather than a preference: "If hostname-admin
|
||||
# is configured, hostname must be set to a URL (not just hostname). Otherwise
|
||||
# Keycloak would not know what is the correct frontend URL". The chart
|
||||
# derives the URL, so this value stays a plain hostname.
|
||||
domain: null
|
||||
|
||||
# cert-manager cluster issuer. Required while either Ingress is enabled;
|
||||
# nothing outside the Ingress templates reads it, so switching both off frees
|
||||
# it.
|
||||
clusterIssuer: null
|
||||
className: nginx
|
||||
|
||||
# Extra annotations for the public Ingress; merged with the cert-manager
|
||||
# annotation the chart always sets.
|
||||
annotations: {}
|
||||
|
||||
admin:
|
||||
# THE ADMIN CONSOLE ON A HOSTNAME OF ITS OWN - and read the next paragraph
|
||||
# before you count on it for anything.
|
||||
#
|
||||
# THIS IS NOT AN ACCESS CONTROL. It sets Keycloak's hostname-admin option,
|
||||
# which decides WHICH URLs THE SERVER GENERATES - redirects, the CSS and JS
|
||||
# the console loads, and the API address the console itself calls. It does
|
||||
# not close anything. Upstream, verbatim: "Using the hostname-admin option
|
||||
# does not prevent accessing the Administration REST API endpoints via the
|
||||
# frontend URL specified by the hostname option. If you want to restrict
|
||||
# access to the Administration REST API, you need to do it on the reverse
|
||||
# proxy level."
|
||||
#
|
||||
# So /admin/realms/... stays reachable through ingress.domain, with or
|
||||
# without this block, and an IP restriction on the admin host buys exactly
|
||||
# nothing on its own. Whoever wants the administration API restricted does
|
||||
# it on the reverse proxy in front of the PUBLIC host. This chart renders
|
||||
# no such rule: a path rule on /admin that nobody has tested against a
|
||||
# running console breaks redirects and asset loading, and a security
|
||||
# measure that only looks like one is worse than an absent one.
|
||||
#
|
||||
# What the second hostname IS good for: the console generates its own URLs
|
||||
# for it, so it can live on an internal ingress class or a different DNS
|
||||
# zone, and operators reach it by a name that is not the public login name.
|
||||
enabled: true
|
||||
|
||||
# The admin hostname. Required while ingress.admin.enabled is true. Also
|
||||
# rendered as a full URL (https://<domain>) for the same upstream reason as
|
||||
# ingress.domain.
|
||||
domain: null
|
||||
|
||||
# null falls back to ingress.className. Set it to an internal ingress class
|
||||
# to keep the console off the public controller - which is a useful thing
|
||||
# to do, and still not the access control the paragraph above is about.
|
||||
className: null
|
||||
|
||||
# Extra annotations for the admin Ingress only; merged with the cert-manager
|
||||
# annotation. This is where an IP allow-list would go
|
||||
# (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat
|
||||
# above firmly in mind.
|
||||
annotations: {}
|
||||
@@ -5,86 +5,9 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s
|
||||
|
||||
type: application
|
||||
|
||||
version: 12.2.0+up3.2.1
|
||||
version: 12.1.3+up3.2.1
|
||||
|
||||
appVersion: 3.2.1
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
|
||||
# The third chart in this repo with a dependency (after ytdlp-web-player and
|
||||
# stalwart). README, "Chart dependencies", holds the bar a dependency has to
|
||||
# clear and the full price list; what is repeated here is only what is specific
|
||||
# to THIS chart, because the generic half is already written down twice.
|
||||
#
|
||||
# WHY ONE HERE. paperless-ngx cannot run without a Redis: PAPERLESS_REDIS is
|
||||
# the Celery broker, and without it the web UI comes up looking healthy while
|
||||
# consumption, OCR, the scheduled tasks and the index updates never run. So
|
||||
# every installation needs one, and before this block the only way to have one
|
||||
# was a second Helm release whose address lived in a 1Password item and in
|
||||
# neither chart.
|
||||
#
|
||||
# WHY IT IS OFF BY DEFAULT: an installation that already has a Redis (or wants
|
||||
# a shared one, which is what secrets.redisSecretRef' key-prefix field exists
|
||||
# for) points redis-connection-url at it, and this dependency renders nothing.
|
||||
# The subchart is the path for an installation that has none.
|
||||
#
|
||||
# THE SUBCHART IS OUR OWN valkey CHART, resolved from the published index - not
|
||||
# upstream's. So README guideline 14.2 ("we consume their chart as it comes")
|
||||
# does not apply and there are no third-party defaults to leave unpatched: the
|
||||
# subchart follows this repo's conventions because it IS this repo's chart.
|
||||
# What does apply in full is the subchart being invisible to everything
|
||||
# downstream that watches Helm release versions, and the PR-ordering hazard
|
||||
# below.
|
||||
dependencies:
|
||||
- name: valkey
|
||||
# KEEP THIS VERSION MOVING - it is the only path a Valkey update has into a
|
||||
# cluster through this chart. The version declared here is baked into our
|
||||
# packaged .tgz, so anything watching Helm release versions (Fleet
|
||||
# included) sees only "paperless-ngx": a new valkey chart, a security fix
|
||||
# included, arrives ONLY when this line is raised and a new paperless-ngx
|
||||
# version is published. Nothing reports the gap.
|
||||
#
|
||||
# Renovate's BUILT-IN helmv3 manager reads this block and opens the PR, and
|
||||
# ci/renovate-bump-chart.sh bumps this chart's patch version, which is what
|
||||
# actually ships it. No customManagers entry exists or would help - that
|
||||
# manager is for exactly this case, where repository and version sit
|
||||
# together in one file. Measured for Gitea #65 against our self-hosted
|
||||
# index (Renovate 44.132.2, dry run with the dependency pinned low:
|
||||
# newVersion "1.0.0+up9.1.2.alpine", versioning "helm"), so the chain is
|
||||
# proven for this registry and this build-metadata suffix and was not
|
||||
# re-measured here.
|
||||
#
|
||||
# Independent of appVersion: raising it is a patch or minor bump of this
|
||||
# chart and is never tied to the paperless-ngx image.
|
||||
version: "1.0.0+up9.1.2.alpine"
|
||||
# Our own published index, and the READ path only - anonymously readable
|
||||
# (measured), so ci/helm-dependencies.sh needs no credential and was not
|
||||
# touched. The publish target and its credentials still live in 1Password
|
||||
# and appear nowhere in this repository; see README, "CI/CD".
|
||||
repository: "https://git.jk-dev.de/api/packages/jlab-cluster/helm"
|
||||
# Off by default, so a plain install renders none of its objects.
|
||||
#
|
||||
# NOT "valkey.enabled", AND THAT IS NOT A STYLE CHOICE. Everything under
|
||||
# the "valkey:" key is handed to the subchart as ITS values and validated
|
||||
# against ITS schema, and our valkey chart ships additionalProperties:
|
||||
# false with no "enabled" property:
|
||||
# Error: values don't meet the specifications of the schema(s) in the
|
||||
# following chart(s): valkey: - at '': additional properties 'enabled'
|
||||
# not allowed
|
||||
# Worse, Helm skips a DISABLED subchart's schema validation, so with the
|
||||
# default of false that error would stay invisible through lint, render and
|
||||
# install-test and surface only for whoever first switched it on - the one
|
||||
# case the value exists for. The condition is therefore a key of OUR OWN,
|
||||
# outside the subchart's value subtree.
|
||||
condition: valkeySubchart.enabled
|
||||
#
|
||||
# !!! NEVER CHANGE valkey/ AND THIS CHART IN THE SAME PULL REQUEST !!!
|
||||
#
|
||||
# The dependency resolves out of the PUBLISHED index, not out of the working
|
||||
# tree, and valkey/ is published only by a push to main that touches it. A
|
||||
# single PR carrying both therefore runs lint, package, render AND the
|
||||
# install-test against the OLD valkey .tgz and goes green about an artifact
|
||||
# that is not the one in the diff. Two consecutive pull requests: change
|
||||
# valkey/, merge, let it publish, then raise the version here. README, "Chart
|
||||
# dependencies", and the wiki's valkey page carry the long form.
|
||||
- name: jklein
|
||||
@@ -3,9 +3,6 @@
|
||||
{{- $barcodes := fromYaml (include "paperless-ngx.barcodes" .Values.paperless.files.barcodes) -}}
|
||||
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
||||
{{- $scripts := .Values.paperless.scripts | default dict -}}
|
||||
{{- $valkeySub := (.Values.valkeySubchart | default dict).enabled -}}
|
||||
{{- $valkeyAuth := ((.Values.valkey | default dict).secrets | default dict).authSecretRef -}}
|
||||
{{- $valkeySvc := printf "%s-valkey-service" .Release.Name -}}
|
||||
{{- $pre := default (list) $scripts.preConsumptionScripts -}}
|
||||
{{- $post := default (list) $scripts.postConsumptionScripts -}}
|
||||
Paperless-ngx {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
@@ -73,12 +70,6 @@ WHAT THE 1PASSWORD ITEMS MUST CONTAIN
|
||||
key-prefix PAPERLESS_REDIS_PREFIX — set this per release when
|
||||
several releases share one Redis, or two instances
|
||||
will consume each other's task queue
|
||||
auth-conf the COMPLETE line "requirepass PASSWORD", not a
|
||||
bare password. Read by the bundled Valkey only, so
|
||||
it is {{ if $valkeySub }}REQUIRED for this release — see the Valkey
|
||||
section below{{ else }}unused while valkeySubchart.enabled is
|
||||
false, and the item needs it only once you switch
|
||||
the bundled Valkey on{{ end }}.
|
||||
|
||||
secrets.paperlessSecretRef → {{ .Values.secrets.paperlessSecretRef }}
|
||||
Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret, fields:
|
||||
@@ -126,9 +117,9 @@ CHANGING A SECRET'S CONTENT RESTARTS NOTHING
|
||||
fields points the release at whatever that new endpoint contains, empty or
|
||||
not, and the next start migrates it.
|
||||
|
||||
POSTGRES IS NOT PART OF THIS CHART{{ if not $valkeySub }}, AND NEITHER IS REDIS{{ end }}
|
||||
{{ if $valkeySub }}The database{{ else }}Both{{ end }} ha{{ if $valkeySub }}s{{ else }}ve{{ end }} to exist and be reachable before the first start.{{ if not $valkeySub }} They fail in
|
||||
opposite ways, and only one of them is loud:{{ end }}
|
||||
POSTGRES AND REDIS ARE NOT PART OF THIS CHART
|
||||
Both have to exist and be reachable before the first start. They fail in
|
||||
opposite ways, and only one of them is loud:
|
||||
|
||||
- Without the database paperless does not come up at all. All three probes
|
||||
ask for "/" on port 8000, so you get no useful error: the pod never turns
|
||||
@@ -138,72 +129,7 @@ POSTGRES IS NOT PART OF THIS CHART{{ if not $valkeySub }}, AND NEITHER IS REDIS{
|
||||
broker: consumption, OCR, the scheduled tasks and the index updates simply
|
||||
never run, and documents dropped into the consume directory stay there.
|
||||
A wrong redis-connection-url costs you the whole processing side and
|
||||
nothing turns red.{{ if $valkeySub }} This release brings its own Valkey (below), so
|
||||
the address is in your hands — which is exactly why the url is the one
|
||||
thing to check first.{{ end }}
|
||||
{{ if $valkeySub }}
|
||||
THIS RELEASE BRINGS ITS OWN VALKEY — ONE MANDATORY STEP IS IN THE VAULT
|
||||
valkeySubchart.enabled is true, so the release contains a Valkey of its own:
|
||||
StatefulSet {{ .Release.Name }}-valkey-sfs, Service {{ $valkeySvc }}, its own
|
||||
NetworkPolicy, and a PersistentVolumeClaim (persistence is on — this is a
|
||||
queue, see below).
|
||||
|
||||
1. THE CONNECTION URL MUST POINT AT IT. This is a step, not a hint: the url
|
||||
lives in the vault and the chart cannot write it. In the item
|
||||
"{{ .Values.secrets.redisSecretRef }}", field redis-connection-url:
|
||||
|
||||
redis://:PASSWORD@{{ $valkeySvc }}:6379
|
||||
|
||||
Pointing elsewhere is not an error anywhere: paperless talks to that other
|
||||
Redis, the Valkey next to it runs along unused, and nothing reports it.
|
||||
|
||||
2. THE PASSWORD IS IN THE ITEM TWICE — once inside redis-connection-url and
|
||||
once in the "requirepass PASSWORD" line of auth-conf. Both are the same
|
||||
password, and a rotation has to touch BOTH fields. Change only one and
|
||||
paperless can no longer authenticate against its own Valkey. After any
|
||||
such change, restart both workloads by hand (Helm cannot see inside a
|
||||
1Password item, so nothing rolls automatically):
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-valkey-sfs
|
||||
|
||||
3. AUTH-CONF IS A CONFIGURATION LINE, NOT A PASSWORD. The Valkey mounts the
|
||||
field as a file and includes it, so the password never reaches a process
|
||||
list or an environment variable. An init container refuses to start the
|
||||
pod unless a line beginning with "requirepass " is really there — without
|
||||
that check a typo would leave a Valkey running with NO password and
|
||||
looking perfectly healthy.
|
||||
{{- if and $valkeyAuth (ne ($valkeyAuth | toString) ($.Values.secrets.redisSecretRef | toString)) }}
|
||||
|
||||
NOTE: valkey.secrets.authSecretRef points at a DIFFERENT item than
|
||||
secrets.redisSecretRef:
|
||||
valkey.secrets.authSecretRef → {{ $valkeyAuth }}
|
||||
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
|
||||
That is supported and may well be deliberate — the bundled Valkey is then fed
|
||||
from its own item. It is only worth knowing that you now keep TWO items in
|
||||
step: the password in auth-conf over there and the one inside
|
||||
redis-connection-url here have to stay identical.
|
||||
{{- end }}
|
||||
|
||||
BOTH ITEMS' FIELDS LAND IN BOTH SECRETS. A OnePasswordItem materializes every
|
||||
field of the item it names, so with one shared item the Valkey's Secret also
|
||||
carries redis-connection-url and key-prefix, and this chart's redis Secret
|
||||
also carries auth-conf. Untidy and harmless — nothing mounts a field it does
|
||||
not reference. It is mentioned so it does not get "cleaned up".
|
||||
|
||||
IT IS CONFIGURED AS A QUEUE, and that is a decision: persistence on, eviction
|
||||
policy "noeviction". PAPERLESS_REDIS is the Celery broker, so its contents are
|
||||
work that must not vanish — "allkeys-lru" would evict across the whole
|
||||
keyspace and a backlog of waiting tasks is exactly what LRU reads as cold.
|
||||
The price: the same instance also serves the Django cache, so when it fills,
|
||||
writes are REFUSED rather than old keys dropped — new tasks included. The
|
||||
answer is to give it more memory (valkey.valkey.resources.requests.memory),
|
||||
never to switch the policy. values.yaml argues this in full.
|
||||
|
||||
SCALEDOWN DOES NOT REACH IT. scaleDown: true scales paperless to zero and
|
||||
leaves the Valkey running, which is the right way round for a queue: the
|
||||
backlog stays where it is.
|
||||
{{- end }}
|
||||
nothing turns red.
|
||||
|
||||
THE FIRST START MIGRATES, AND THE STARTUP PROBE IS THE CLOCK
|
||||
Every start runs the database migrations before the server answers, and the
|
||||
|
||||
@@ -20,12 +20,10 @@ Usage (the values path to the resources dict varies per chart):
|
||||
{{- include "paperless-ngx.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||
the chart's name as define prefix, because sharing it would mean a LIBRARY
|
||||
chart, and a dependency is admissible but expensive (README, guideline 14).
|
||||
This chart does take one - the optional valkey subchart - which changes
|
||||
nothing here: a library chart for one helper would still be the wrong trade,
|
||||
and the subchart carries its own copy of this helper anyway. This file in
|
||||
template-chart is the reference implementation.
|
||||
the chart's name as define prefix, because sharing it would mean a library
|
||||
chart, and a dependency is admissible but expensive (README, guideline 14) -
|
||||
this chart takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
*/}}
|
||||
{{- define "paperless-ngx.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
{{- /*
|
||||
With the valkey subchart on, THIS is the item that also carries the "auth-conf"
|
||||
field the bundled Valkey reads (NOTES.txt prints all three fields). The rule
|
||||
that valkey.secrets.authSecretRef must then be set is enforced in
|
||||
values.schema.json and NOT by a fail() here - measured, see that file: Helm
|
||||
validates the schema before it renders, and it renders a subchart's templates
|
||||
before the parent's, so a check in this file could never beat the subchart's own
|
||||
required() to it.
|
||||
*/ -}}
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
|
||||
@@ -4,48 +4,6 @@
|
||||
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key that the chart does not read is rejected at render time instead of being silently ignored. The only objects that stay open are the ones passed verbatim into the Kubernetes pod spec (probes, security contexts, global) - the chart does not interpret their contents, so it cannot judge which keys are meaningful. The hardening, probe and resources blocks of BOTH workloads, and the barcodes block from issue #61, additionally accept null, because an erased block falls back to the chart defaults (issues #61 and #99).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["valkeySubchart"],
|
||||
"allOf": [
|
||||
{
|
||||
"title": "With the valkey subchart switched on, valkey.secrets.authSecretRef is required",
|
||||
"description": "The one cross-key rule in this file, and it lives here rather than in a template on purpose: Helm validates the schema BEFORE it renders anything, and it renders a subchart's templates before the parent's. A fail() in this chart's templates therefore loses the race against the subchart's own required(), whose message names 'secrets.authSecretRef' and cannot know that the path belongs to the same 1Password item as secrets.redisSecretRef. Measured for Gitea #78 - a guard that cannot fire is the mistake found while testing #77. Set it to the SAME op:// path as secrets.redisSecretRef: the bundled Valkey reads its password from the field 'auth-conf' in that item, whose value must be the complete line 'requirepass <password>'. A different item is allowed; two items then have to be kept in step.",
|
||||
"if": {
|
||||
"required": ["valkeySubchart"],
|
||||
"properties": {
|
||||
"valkeySubchart": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["valkey"],
|
||||
"properties": {
|
||||
"valkey": {
|
||||
"type": "object",
|
||||
"required": ["secrets"],
|
||||
"properties": {
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"required": ["authSecretRef"],
|
||||
"properties": {
|
||||
"authSecretRef": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"global": {
|
||||
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
|
||||
@@ -303,21 +261,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"valkeySubchart": {
|
||||
"description": "The dependency condition for the valkey subchart. A key of THIS chart, deliberately outside the 'valkey' subtree: everything under 'valkey:' is handed to the subchart as its values, and this repo's valkey chart has additionalProperties: false with no 'enabled' property, so 'valkey.enabled' is rejected by the subchart itself. Helm skips a disabled subchart's schema validation, so that error would only ever have surfaced for whoever first set it to true. NOT null-safe and 'enabled' is REQUIRED, which is the opposite of this chart's other blocks (issue #99) and is deliberate: Helm treats a condition whose path does not resolve as ENABLED, so an erased 'valkeySubchart:' would switch the subchart ON - measured for Gitea #78, where it surfaced as the subchart's own required() error. An erased block must not flip behaviour, least of all in that direction, so it is rejected here instead.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"valkey": {
|
||||
"description": "Values passed through to the valkey subchart - this repo's OWN valkey chart, resolved from the published index. Open on purpose and at every level: the subchart owns this schema (it ships its own, and a stricter one than this), and a closed copy here would reject whatever that chart adds in its next release while duplicating a validation that already happens. This chart reads NONE of these keys; the dependency condition is valkeySubchart.enabled instead. Two shapes that are easy to get wrong: secrets.authSecretRef must be set BY HAND to the same op:// path as secrets.redisSecretRef (a values.yaml is never templated, so a parent cannot default it - Gitea #78), and the item's field 'auth-conf' holds the complete line 'requirepass <password>', not a bare password. values.yaml argues the queue-vs-cache choice and names the three operator traps.",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# op:// path of the item holding the Redis/Valkey connection. Fields:
|
||||
# "redis-connection-url" and "key-prefix" always, plus "auth-conf" when the
|
||||
# bundled Valkey subchart is used - see the valkeySubchart block at the end
|
||||
# of this file. NOTES.txt prints all three.
|
||||
redisSecretRef: null
|
||||
databaseSecretRef: null
|
||||
paperlessSecretRef: null
|
||||
@@ -180,172 +176,3 @@ oidc:
|
||||
# Some providers need this since the allauth update in paperless-ngx 3.0
|
||||
# (login fails at the callback with invalid_client otherwise).
|
||||
tokenAuthMethod: null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The bundled Valkey - this repo's own valkey chart as an optional subchart
|
||||
# ---------------------------------------------------------------------------
|
||||
# The switch for the dependency declared in Chart.yaml. Off by default: an
|
||||
# installation that already has a Redis points redis-connection-url at it and
|
||||
# this renders nothing.
|
||||
#
|
||||
# WHY THE KEY IS HERE AND NOT "valkey.enabled": everything under "valkey:" is
|
||||
# handed to the subchart as its values and validated against ITS strict schema,
|
||||
# which has no "enabled" property - so "valkey.enabled" is rejected BY THE
|
||||
# SUBCHART. And because Helm skips a disabled subchart's schema validation, the
|
||||
# error would only ever have appeared for whoever first switched it on.
|
||||
# Chart.yaml carries the exact error text.
|
||||
#
|
||||
# DO NOT ERASE THIS BLOCK. Unlike every other block in this file (issue #99),
|
||||
# "valkeySubchart: null" is not "the default applies" - a null override DELETES
|
||||
# the key, the dependency's condition path then does not resolve, and Helm
|
||||
# treats an unresolvable condition as ENABLED. So erasing the switch turns the
|
||||
# subchart ON, with no password set and only the subchart's own error to show
|
||||
# for it. Measured for Gitea #78; values.schema.json rejects it before anything
|
||||
# renders. An empty block ("valkeySubchart: {}") is fine, because Helm merges
|
||||
# that with the default below.
|
||||
valkeySubchart:
|
||||
enabled: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Values passed THROUGH to the valkey subchart
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read as the SUBCHART's own values, so the key names are its schema, not this
|
||||
# chart's. `valkey/values.yaml` in this repo is the reference. Nothing here has
|
||||
# any effect while valkeySubchart.enabled is false.
|
||||
#
|
||||
# What still applies in full: the subchart is invisible to everything
|
||||
# downstream that watches Helm release versions (Chart.yaml has the detail and
|
||||
# the PR-ordering warning - never change valkey/ and this chart in the same
|
||||
# PR), and this chart's scaleDown flag does NOT reach it. scaleDown: true
|
||||
# scales paperless to zero and leaves the Valkey running; that is the right way
|
||||
# round for a queue, because the backlog stays where it is.
|
||||
valkey:
|
||||
secrets:
|
||||
# REQUIRED once the subchart is on, and it has to be set BY HAND to the
|
||||
# SAME op:// path as secrets.redisSecretRef above - because the
|
||||
# "requirepass" line lives in that same item (see the three traps below).
|
||||
#
|
||||
# IT CANNOT DEFAULT TO IT, and that is a property of Helm, not an omission:
|
||||
# a values.yaml is never templated and a parent chart cannot compute a
|
||||
# subchart's value - values are merged statically before any template runs.
|
||||
# Writing "{{ .Values.secrets.redisSecretRef }}" here does not resolve, it
|
||||
# lands verbatim in the subchart's itemPath and fails as a YAML parse error
|
||||
# pointing at the subchart's template, which names neither this value nor
|
||||
# the real cause. Measured; Gitea #78 has the output. So the path is typed
|
||||
# twice, and forgetting it is caught by a conditional rule in
|
||||
# values.schema.json - NOT by a fail() in a template, which cannot work
|
||||
# here: Helm renders a subchart's templates BEFORE the parent's, so the
|
||||
# subchart's own required() always wins that race and reports
|
||||
# "secrets.authSecretRef" instead of this value's real path. The schema runs
|
||||
# earlier than any rendering, which is why the rule lives there.
|
||||
#
|
||||
# MIND THE FIELD, it is not a password. The item must carry a field
|
||||
# "auth-conf" whose value is the COMPLETE configuration line
|
||||
# requirepass <password>
|
||||
# The valkey chart mounts that field as a file and `include`s it, so the
|
||||
# password never reaches the process list, an environment variable, or
|
||||
# Helm's field of view. An init container refuses to start the pod unless
|
||||
# the line is really there, because the alternative failure is an open
|
||||
# server that looks perfectly healthy. A bare password does not work.
|
||||
#
|
||||
# OVERRIDING IT WITH A DIFFERENT ITEM IS ALLOWED and is not a mistake: it
|
||||
# is the way to feed the bundled Valkey from its own item. The cost is that
|
||||
# two items then have to be kept in step instead of one.
|
||||
authSecretRef: null
|
||||
|
||||
# QUEUE, not cache - a decision, with the conflict it does not resolve.
|
||||
#
|
||||
# The valkey chart treats persistence and eviction as a PAIR and warns
|
||||
# against mixing them: QUEUE is persistence.enabled: true + "noeviction"
|
||||
# (its default), CACHE is persistence.enabled: false + "allkeys-lru".
|
||||
# stalwart picked CACHE. paperless-ngx is the other case - which happens to
|
||||
# be the subchart's own default, so the block below changes nothing. It is
|
||||
# written out anyway, for two reasons: the subchart's persistence block is
|
||||
# deliberately NOT null-safe (an erased one renders an invalid PVC), and the
|
||||
# decision belongs where it is argued rather than one chart away. The
|
||||
# eviction policy is NOT restated - valkey.maxmemoryPolicy stays at the
|
||||
# subchart's "noeviction", which is the queue half of the pair. storageClass
|
||||
# stays at the subchart default too (longhorn-encrypted-ns): queue payloads
|
||||
# are the same documents that are in the database, so encrypted at rest.
|
||||
#
|
||||
# Read off the paperless-ngx 3.2.1 source (src/paperless/settings/__init__.py
|
||||
# and settings/custom.py), not assumed: PAPERLESS_REDIS is ONE url, and
|
||||
# parse_redis_url returns it twice for a TCP url, so one instance carries
|
||||
# five consumers:
|
||||
#
|
||||
# 1. CELERY_BROKER_URL - the queue: consume, OCR, index
|
||||
# updates, classifier training, the
|
||||
# scheduled tasks
|
||||
# 2. CELERY_RESULT_BACKEND - also Redis in 3.x (it was the
|
||||
# database before), CELERY_RESULT_EXPIRES
|
||||
# 3600
|
||||
# 3. CHANNEL_LAYERS - channels_redis.pubsub, expiry 15s;
|
||||
# pub/sub, not stored keys
|
||||
# 4. CACHES["default"] - Django cache, recomputable
|
||||
# 5. CACHES["read-cache"] - Cachalot DB read cache, TTL 3600,
|
||||
# off unless
|
||||
# PAPERLESS_DB_READ_CACHE_ENABLED
|
||||
#
|
||||
# WHY QUEUE: "allkeys-lru" would not be a trade here, it would be actively
|
||||
# wrong. LRU evicts across the WHOLE keyspace with no regard for key type,
|
||||
# and a backlog of waiting consume tasks is exactly what LRU reads as cold -
|
||||
# written once, read later. So a cache configuration would preferentially
|
||||
# throw away the work and keep the cache entries. A dropped consume or index
|
||||
# task means a document that is silently never processed: the file stays in
|
||||
# the consume directory although the directory watcher has already fired.
|
||||
# And an evicted Celery result means a task that shows as "started" in the UI
|
||||
# forever.
|
||||
#
|
||||
# THE CONFLICT THAT STAYS, stated rather than smoothed over: two of the five
|
||||
# consumers are caches and they get the policy that does not suit them. When
|
||||
# the instance fills, "noeviction" makes Valkey REFUSE WRITES - which hits
|
||||
# cache writes and NEW TASKS alike, so paperless starts erroring instead of
|
||||
# quietly degrading. That is the loud direction and therefore the right one,
|
||||
# but it is a price and not a side effect, and the answer to it is to SIZE
|
||||
# MEMORY for the working set, never to switch the policy. What makes the
|
||||
# price small: all three cache-like consumers are TTL-bounded (15s, 3600s,
|
||||
# 3600s), so they bound themselves and do not need eviction to stay in check,
|
||||
# and the read cache is off by default.
|
||||
persistence:
|
||||
enabled: true
|
||||
appendonly: true
|
||||
# Sized for a task backlog and its results, not for documents - the
|
||||
# documents are on paperless' own PVC. Grow it if a release regularly
|
||||
# queues thousands of tasks (a PVC can grow, it can never shrink).
|
||||
size: 1Gi
|
||||
|
||||
# networkPolicy and the Valkey resources are left at the subchart's defaults
|
||||
# on purpose: its NetworkPolicy is on and allows the release's own namespace,
|
||||
# which with a subchart is where paperless runs. allowedNamespaces only needs
|
||||
# an entry when the two are in DIFFERENT namespaces, which a subchart rules
|
||||
# out.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE THREE TRAPS OF THE BUNDLED VALKEY - all three are operator work
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. THE PASSWORD IS IN THE ITEM TWICE: inside redis-connection-url and again
|
||||
# in the "requirepass" line of auth-conf. A rotation has to touch BOTH
|
||||
# fields; touching one leaves a paperless that cannot authenticate against
|
||||
# its own bundled Valkey. The alternative - assembling the url from parts -
|
||||
# would break the secret contract of every running release (a major) and
|
||||
# route the password through a template. Deliberately not chosen; the trap
|
||||
# is named instead.
|
||||
#
|
||||
# 2. WITH THE SUBCHART ON, redis-connection-url MUST POINT AT THE SUBCHART:
|
||||
# redis://:<password>@<release>-valkey-service:6379
|
||||
# The name is determined and confirmed against the rendered output, but the
|
||||
# url lives in the vault, so this is a MANDATORY STEP of the installation
|
||||
# and not a hint. Left pointing at an external Redis, the bundled Valkey
|
||||
# runs along unused and nothing says so. NOTES.txt prints the exact name for
|
||||
# the release at hand.
|
||||
#
|
||||
# 3. A OnePasswordItem MATERIALIZES EVERY FIELD OF THE ITEM. With both CRs
|
||||
# pointing at the same item, the Valkey Secret therefore also carries
|
||||
# redis-connection-url and key-prefix, and this chart's redis Secret also
|
||||
# carries auth-conf. Untidy, harmless, and written down here so nobody reads
|
||||
# it as a leak or a bug and "tidies it up": the keys are unused, and no
|
||||
# container mounts a field it does not reference.
|
||||
#
|
||||
# And one rule that is about this repository rather than about an installation:
|
||||
# changes to valkey/ must not share a pull request with changes to this chart.
|
||||
# Chart.yaml says why; README and the wiki's valkey page carry the long form.
|
||||
+2
-2
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi
|
||||
|
||||
type: application
|
||||
|
||||
version: 8.1.8+up4.15.4
|
||||
appVersion: "4.15.4"
|
||||
version: 8.1.6+up4.15.2
|
||||
appVersion: "4.15.2"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
+23
-109
@@ -334,6 +334,29 @@
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"convertx: the registry carries versioned tags (v0.19.0 and its predecessors) next",
|
||||
"to THREE moving ones - 'latest', 'main' and a 'pr-<n>' per open pull request.",
|
||||
"v0.19.0 and 'latest' currently share the same digest (sha256:8590a21a…), which is",
|
||||
"exactly the trap: an appVersion of 'latest' would look correct today and change the",
|
||||
"running image on the next upstream push, without a commit here. 'main' and 'pr-<n>'",
|
||||
"are worse still - they carry unreviewed code.",
|
||||
"The anchored regex versioning below is what keeps all three out: it demands the v",
|
||||
"prefix and three numeric parts, which none of them can satisfy. Precedent and the",
|
||||
"full argument: the ytdlp-web-player block near the end of this file.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["convertx/Chart.yaml"],
|
||||
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||
depNameTemplate: "ghcr.io/c4illin/convertx",
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
// The "v" stays outside every capture group: it is part of the tag and
|
||||
// carries no ordering. A group is a claim about how the value is
|
||||
// compared, and the anchors are what exclude latest/main/pr-<n>.
|
||||
versioningTemplate: "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)$",
|
||||
},
|
||||
{
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["cyberchef/Chart.yaml"],
|
||||
@@ -342,40 +365,6 @@
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"garage: the registry carries three kinds of tag, and the first one is the reason",
|
||||
"this block cannot be the plain four-liner most charts get. Measured on 2026-10-03",
|
||||
"against docker.io/dxflrs/garage (340 tags):",
|
||||
"",
|
||||
" 1. COMMIT-SHA TAGS, 40 hex characters, pushed almost daily - the newest 22 tags",
|
||||
" in the registry are all of that kind (99923a87... on 2026-10-03), while the",
|
||||
" current release v2.4.1 dates from 2026-09-08. Without a version constraint",
|
||||
" Renovate offers those as 'updates', and an appVersion of 40 hex characters",
|
||||
" pins a commit nobody chose and republishes the chart for it.",
|
||||
" 2. release tags v<major>.<minor>.<patch>, which is what we track.",
|
||||
" 3. prereleases and internal builds: v2.0.0-rc1, v2.0.0-beta1 and the four",
|
||||
" v1.99.x-internal tags. Note that those sort ABOVE v1.3.1 numerically, so they",
|
||||
" are not kept out by ordering - they have to be excluded by shape.",
|
||||
"",
|
||||
"The regex versioning below does both jobs at once, as in the stalwart block: a",
|
||||
"candidate that does not match is not a version at all, so the SHA tags (no 'v', no",
|
||||
"dots) and every suffixed tag (the terminating anchor) are skipped. The three",
|
||||
"numeric parts additionally reject a two-part line tag, should upstream ever add one.",
|
||||
"",
|
||||
"The 'v' stays outside the capture groups: it is part of the tag and carries no",
|
||||
"ordering, and a group is a claim about how the value is compared. Being part of the",
|
||||
"tag, it is part of appVersion (+upv2.4.1) - precedent: stalwart, matrix-synapse,",
|
||||
"calibre-web-automated, ytdlp-web-player.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["garage/Chart.yaml"],
|
||||
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||
depNameTemplate: "docker.io/dxflrs/garage",
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
versioningTemplate: "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)$",
|
||||
},
|
||||
{
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["gotenberg/Chart.yaml"],
|
||||
@@ -393,51 +382,6 @@
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"keycloak: the counter-case to garage and stalwart, and the one block in this file whose",
|
||||
"DESIGN DECISION IS AN ABSENCE. quay.io/keycloak/keycloak carries four tag shapes side by",
|
||||
"side (measured 2026-10-04 against the tag API): release tags 26.8.0; REBUILD tags with a",
|
||||
"numeric suffix, 26.8.0-0 / 26.7.3-1 / 26.7.2-2, where the same app version is rebuilt for",
|
||||
"a base-image or CVE fix (26.8.0-0 resolves to the SAME manifest digest as 26.8.0);",
|
||||
"floating minor tags 26.8 / 26.7; and the floating names latest and nightly, the latter",
|
||||
"rebuilt daily.",
|
||||
"",
|
||||
"That is the same genus of trap as garage's commit-SHA tags - and here plain `docker`",
|
||||
"versioning already handles all of it, so this block carries NO versioningTemplate.",
|
||||
"Docker versioning splits a tag at the first dash and only ever offers candidates with the",
|
||||
"SAME suffix as the current value; ours has none, so the -N rebuilds are invisible to it.",
|
||||
"The floating minors fail on the missing third part, and latest/nightly parse as no",
|
||||
"version at all.",
|
||||
"",
|
||||
"MEASURED, NOT ASSUMED, and written down because a later round will otherwise add a",
|
||||
"constraint 'to be safe' - which would be a line that does nothing, and one more thing to",
|
||||
"keep correct. Renovate 44.132.5, --platform=local --dry-run=lookup, real registry:",
|
||||
" appVersion 26.8.0 -> updates [], warnings []",
|
||||
" appVersion 26.7.0 -> exactly one: 26.8.0 (updateType minor)",
|
||||
" appVersion 26.7.4 -> exactly two: 26.7.5 (patch), 26.8.0 (minor)",
|
||||
"No run offered a rebuild tag, a floating minor, latest or nightly. The 26.7.4 fixture is",
|
||||
"the guard the nginx and qbittorrent rules taught us to run: it proves real updates still",
|
||||
"come through rather than the filter quietly emptying the list.",
|
||||
"",
|
||||
"AND WHAT THIS BLOCK DELIBERATELY DOES NOT CARRY: an automerge exception. Keycloak",
|
||||
"supports a rolling update only WITHIN a minor line - a minor jump needs every node shut",
|
||||
"down first - so a packageRules entry switching automerge off for minor and major was",
|
||||
"proposed and DECIDED AGAINST (Gitea #83): the global automerge at the top of this file",
|
||||
"applies here too. That is an accepted risk rather than an oversight, and the consequence",
|
||||
"is documented instead of prevented: keycloak/templates/NOTES.txt and the wiki page carry",
|
||||
"the minor-upgrade procedure (scaleDown, back up, bump, scale up) together with the",
|
||||
"statement that the automerge does not know about it, plus the `kc.sh",
|
||||
"update-compatibility metadata|check` calls whose exit code is meant to drive exactly this",
|
||||
"decision. If a minor PR has merged itself, that procedure is the repair.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["keycloak/Chart.yaml"],
|
||||
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||
depNameTemplate: "quay.io/keycloak/keycloak",
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["matrix-synapse/Chart.yaml"],
|
||||
@@ -564,36 +508,6 @@
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"stalwart: the registry carries three kinds of tag - versioned ones (v0.16.24), the",
|
||||
"same versions with an -alpine suffix (v0.16.24-alpine), and three MOVING names:",
|
||||
"'latest', 'latest-alpine' and the truncated line tags 'v0.16' / 'v0.16-alpine'.",
|
||||
"Measured on 2026-10-02: 'latest', 'v0.16' and 'v0.16.24' all resolve to the same",
|
||||
"digest (sha256:ec011be2285...), pushed within seconds of each other - so 'latest'",
|
||||
"and 'v0.16' pin nothing and would move the running image with no commit here.",
|
||||
"",
|
||||
"The anchored regex below is what keeps all five out, and it does so for two",
|
||||
"independent reasons rather than one: the v prefix plus THREE numeric parts rejects",
|
||||
"'latest' and 'latest-alpine' (no numbers) and 'v0.16' / 'v0.16-alpine' (two parts),",
|
||||
"and the terminating anchor rejects the -alpine suffix. Choosing the glibc image over",
|
||||
"the alpine one is a deployment decision and belongs in the chart, not in a",
|
||||
"compatibility suffix Renovate would then have to maintain - contrast the valkey",
|
||||
"block below, where '-alpine' IS the chosen flavour and docker versioning carries it.",
|
||||
"",
|
||||
"The 'v' stays outside every capture group, as in the ytdlp-web-player block: it is",
|
||||
"part of the tag and carries no ordering, and a group is a claim about how the value",
|
||||
"is compared. It is part of the tag, so it is part of appVersion (+upv0.16.24) -",
|
||||
"precedent: calibre-web-automated, matrix-synapse, ytdlp-web-player.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["stalwart/Chart.yaml"],
|
||||
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||
depNameTemplate: "docker.io/stalwartlabs/stalwart",
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
versioningTemplate: "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)$",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"sure: the registry carries four kinds of tag side by side - releases (0.7.4), the",
|
||||
|
||||
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/RSS-Bridge/rss-bridge/master/static/logo
|
||||
|
||||
type: application
|
||||
|
||||
version: 2.0.0+up2026.09.30
|
||||
appVersion: "2026-09-30"
|
||||
version: 1.0.2+up2025.08.05
|
||||
appVersion: "2025-08-05"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -1,50 +0,0 @@
|
||||
# Project-specific half of the SonarQube analysis. The workflow is the shared
|
||||
# block in workflow-util/pipelines and is identical for every repository that
|
||||
# uses it; everything that is about THIS repository lives here.
|
||||
#
|
||||
# The key is the one the project already has on the server, given by the
|
||||
# maintainer. It is not derivable and must not be guessed: the project is
|
||||
# created in SonarQube first, and the key is then read off it.
|
||||
#
|
||||
# The first attempt here was a guess, "jlab-cluster_helm-charts", reasoned from
|
||||
# the instance serving two owners. It failed with
|
||||
#
|
||||
# ERROR You're not authorized to analyze this project or the project doesn't
|
||||
# exist on SonarQube and you're not authorized to create it.
|
||||
#
|
||||
# which is the message for BOTH a wrong key and a token without "Execute
|
||||
# Analysis" on the project - the server does not distinguish them, so a wrong
|
||||
# key here looks exactly like a permission problem over there. Once the key is
|
||||
# known to be right, that message means the token.
|
||||
sonar.projectKey=JLab-Helm-Charts
|
||||
|
||||
# NO sonar.projectName, deliberately.
|
||||
#
|
||||
# The project exists on the server, so the display name belongs to the server:
|
||||
# whoever renames it there expects the rename to hold. A name sent with the
|
||||
# analysis would overwrite it on every single run, silently, and the person who
|
||||
# renamed it would only see it snap back. A value that rewrites someone else's
|
||||
# setting without saying so is not a value this repository carries. If the name
|
||||
# is wrong, it gets fixed in SonarQube, not here.
|
||||
|
||||
# The whole tree, which is also the scanner's default - stated rather than
|
||||
# implied, because the absence of exclusions below is a decision and an empty
|
||||
# file would not show that.
|
||||
sonar.sources=.
|
||||
|
||||
# NO EXCLUSIONS, deliberately, for the first round.
|
||||
#
|
||||
# The interesting case is the Helm templates: because of {{ }} they are not
|
||||
# valid YAML, and what the Helm and YAML analysers do with them is something we
|
||||
# can only find out by letting them run over them once. Excluding them up front
|
||||
# would throw away the only chance to learn it, and it would exclude the 26
|
||||
# chart template directories that are the bulk of this repository.
|
||||
#
|
||||
# The same goes for the secret detection, which is the most valuable analyser
|
||||
# here: the premise of this repository is that no credential ever lands in it -
|
||||
# every secret is a 1Password OnePasswordItem reference - and until now nothing
|
||||
# checked that premise.
|
||||
#
|
||||
# If the first run produces noise that is genuinely not worth reading, the
|
||||
# exclusions belong here, with the finding they answer named in a comment. Not
|
||||
# before: an exclusion written on a guess hides whatever else matched it.
|
||||
@@ -1,129 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: stalwart
|
||||
description: A Helm chart to deploy a cluster-capable Stalwart mail server (SMTP, IMAP, JMAP, ManageSieve) on Kubernetes
|
||||
icon: https://avatars.githubusercontent.com/u/76623657
|
||||
|
||||
type: application
|
||||
|
||||
# appVersion is the exact image tag, and the "v" is part of it - so it is part
|
||||
# of appVersion and of the "+up" suffix. Precedent: calibre-web-automated
|
||||
# (+upv4.0.6), matrix-synapse (+upv1.159.0), ytdlp-web-player (+upv1.2.0).
|
||||
#
|
||||
# The glibc image, not "-alpine". Both flavours are published for every release
|
||||
# from the same source and carry the same uid/gid 2000 and the same two volumes;
|
||||
# the choice is a deployment decision, and keeping it out of the tag is what
|
||||
# lets renovate.json5 anchor the version with a plain three-part regex instead
|
||||
# of maintaining a compatibility suffix. The moving tags "latest", "v0.16" and
|
||||
# their -alpine twins are excluded by that same anchor: measured on 2026-10-02
|
||||
# all three of latest / v0.16 / v0.16.24 resolved to one digest pushed seconds
|
||||
# apart, so either floating name would change the running image with no commit
|
||||
# in this repository and nothing to report it.
|
||||
version: 1.0.1+upv0.16.24
|
||||
appVersion: "v0.16.24"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
|
||||
# The second chart in this repo with a dependency (after ytdlp-web-player).
|
||||
# README, "Chart dependencies", holds the bar it has to clear and the price;
|
||||
# both are restated here because the price is paid by whoever reads this file
|
||||
# next, not by whoever added the line.
|
||||
#
|
||||
# WHY ONE HERE. Stalwart's in-memory store is not a cache that can be dropped:
|
||||
# from v0.16 it also carries the cluster coordinator ("the Default variant
|
||||
# reuses the Redis connection already configured on the in-memory store"), so a
|
||||
# multi-node install does not work without it. Redis/Valkey is admissible as
|
||||
# the in-memory store ONLY - never as the data or blob store. Offering it inside
|
||||
# the chart means the wiring (host, port, egress rule) is in one place instead
|
||||
# of split across two releases that have to agree.
|
||||
#
|
||||
# WHY IT IS OFF BY DEFAULT: an installation that already runs a Valkey points
|
||||
# externalServices.valkey.host at it and this dependency renders nothing. The
|
||||
# subchart is the path for an installation that has none.
|
||||
#
|
||||
# THE SUBCHART IS OUR OWN valkey CHART, resolved from the published index -
|
||||
# NOT upstream's. So guideline 14.2 ("we consume their chart as it comes") does
|
||||
# not apply here and there is nothing of someone else's to leave unpatched: the
|
||||
# subchart follows this repo's conventions because it IS this repo's chart. What
|
||||
# does apply is everything about the subchart being invisible downstream, plus
|
||||
# one hazard that a self-referencing dependency adds and an external one does
|
||||
# not - see the PR-ordering warning below.
|
||||
dependencies:
|
||||
- name: valkey
|
||||
# KEEP THIS VERSION MOVING - it is the only path a Valkey update has.
|
||||
#
|
||||
# A subchart is invisible to everything downstream that watches Helm release
|
||||
# versions: what this file declares is baked into our packaged .tgz, and
|
||||
# Fleet only ever sees the version of "stalwart". So a new valkey chart - a
|
||||
# security fix included - reaches a cluster ONLY when this line is raised
|
||||
# and a new stalwart version is published. Nothing reports the gap.
|
||||
#
|
||||
# Renovate's BUILT-IN helmv3 manager reads this block and opens the PR (no
|
||||
# customManagers entry exists or would help - repository and version sit
|
||||
# together in one file, which is the case that manager is for), and
|
||||
# ci/renovate-bump-chart.sh bumps this chart's patch version, which is what
|
||||
# actually ships it. That chain is the whole safety net; switched off, this
|
||||
# line needs a human on a calendar instead.
|
||||
#
|
||||
# MEASURED, not assumed, because the registry is self-hosted and the version
|
||||
# carries build metadata - two things that could each have broken the chain
|
||||
# silently. Renovate 44.132.2, dry run on 2026-10-02 with this dependency
|
||||
# pinned at a lower version: manager helmv3 matched the file, took
|
||||
# registryUrls from the line below, and offered
|
||||
# newVersion: "1.0.0+up9.1.2.alpine", versioning: "helm"
|
||||
# with the release timestamp read from our index. So both the self-hosted
|
||||
# index and the "+up..." suffix are handled.
|
||||
#
|
||||
# Independent of appVersion: raising it is a patch or minor bump of this
|
||||
# chart and is never tied to the Stalwart image.
|
||||
version: "1.0.0+up9.1.2.alpine"
|
||||
# Our own published index, and the READ path only. It is anonymously
|
||||
# readable - measured 2026-10-02: GET .../index.yaml returns 200 without
|
||||
# credentials - so ci/helm-dependencies.sh needs no secret and was not
|
||||
# touched. Verified with an empty HELM_REPOSITORY_CONFIG, i.e. the state a
|
||||
# fresh runner is in: helm reports "Getting updates for unmanaged Helm
|
||||
# repositories" and resolves the exact build-metadata version from the URL
|
||||
# alone, with no "helm repo add" anywhere. Both "helm dependency update" and
|
||||
# "helm dependency build" were tried.
|
||||
#
|
||||
# The PUBLISH target and its credentials still live in 1Password and appear
|
||||
# nowhere in this repository - see README, "CI/CD".
|
||||
repository: "https://git.jk-dev.de/api/packages/jlab-cluster/helm"
|
||||
# Off by default, so a plain install renders none of its objects.
|
||||
#
|
||||
# NOT "valkey.enabled", AND THAT IS NOT A STYLE CHOICE. Everything under the
|
||||
# "valkey:" key is passed to the subchart as its values, and our valkey
|
||||
# chart ships a values.schema.json with additionalProperties: false and no
|
||||
# "enabled" property - so "valkey.enabled" is rejected by the SUBCHART:
|
||||
# Error: values don't meet the specifications of the schema(s) in the
|
||||
# following chart(s): valkey: - at '': additional properties 'enabled'
|
||||
# not allowed
|
||||
# Measured. Worse, Helm skips a disabled subchart's schema validation, so
|
||||
# the error appears only for whoever first sets it to true - the default
|
||||
# path renders clean.
|
||||
#
|
||||
# The condition is therefore a key of OUR OWN, outside the subchart's value
|
||||
# subtree, which is also the honest shape: "enabled" is not a valkey value.
|
||||
# It only ever looked like one because upstream's schema was permissive.
|
||||
# The alternative - adding "enabled" to valkey/values.schema.json - would
|
||||
# have to be its own PR and be published first (see the warning below), for
|
||||
# a key that chart does not read.
|
||||
condition: valkeySubchart.enabled
|
||||
#
|
||||
# !!! NEVER CHANGE valkey/ AND THIS CHART IN THE SAME PULL REQUEST !!!
|
||||
#
|
||||
# The hazard a self-referencing dependency adds. This dependency resolves out of
|
||||
# the PUBLISHED index, not out of the working tree, and valkey/ is only
|
||||
# published by a push to main that touches it. So in one PR carrying both:
|
||||
#
|
||||
# - every helm command here - lint, package, render, AND the install-test -
|
||||
# pulls the OLD valkey .tgz, because the new one does not exist yet;
|
||||
# - the PR therefore goes green against a version of the subchart that is not
|
||||
# the one in the diff, and the diff to valkey/ is reviewed while nothing
|
||||
# actually exercised it;
|
||||
# - after the merge both charts publish, and the first install of the new
|
||||
# stalwart is the first time the new valkey is used anywhere.
|
||||
#
|
||||
# Two consecutive pull requests instead: change valkey/, merge, let it publish,
|
||||
# then raise the version here. The ordering is not a nicety - the green check on
|
||||
# the combined PR is a statement about the wrong artifact.
|
||||
@@ -1,292 +0,0 @@
|
||||
{{- $release := include "stalwart.fullname" . -}}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
||||
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
|
||||
{{- $ingressGroup := "" -}}
|
||||
{{- if .Values.ingress.enabled }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
|
||||
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
|
||||
Since v0.16 Stalwart keeps its whole configuration inside the data store and
|
||||
manages it over JMAP. The only file on disk is config.json, which names the
|
||||
data store and nothing else. So this release has given you the workloads, the
|
||||
Services, the NetworkPolicies, the Ingress and a certificate — but no
|
||||
domains, no listeners, no blob store, no in-memory store and no cluster
|
||||
roles. Those are the steps below, and without them this is not yet a mail
|
||||
server.
|
||||
|
||||
NODE GROUPS IN THIS RELEASE
|
||||
{{- range $group, $spec := $nodes }}
|
||||
{{- $replicas := include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas) }}
|
||||
{{ printf "%-10s" $group }} replicas {{ $replicas }}, reachable on: {{ $spec.exposedPorts | join ", " }}
|
||||
{{- end }}
|
||||
{{- if .Values.scaleDown }}
|
||||
|
||||
scaleDown: true — every group is at replicas 0 and nothing is running. The
|
||||
Services, Ingress, Secrets and the Certificate stay in place, so setting it
|
||||
back to false brings the release up without re-issuing the certificate.
|
||||
{{- end }}
|
||||
{{- if .Values.bootstrap.enabled }}
|
||||
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
RECOVERY MODE IS ON — THIS RELEASE SERVES NO MAIL
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
bootstrap.enabled is true, so every pod runs with STALWART_RECOVERY_MODE=1:
|
||||
the management HTTP endpoint on {{ (index $ports "http").containerPort }} is the ONLY thing listening and
|
||||
every mail port is closed. The Services and the Ingress look exactly as
|
||||
healthy as they do in normal operation, which is why this banner exists.
|
||||
|
||||
This is correct for a first install (step 1 below). When the database is
|
||||
provisioned, set bootstrap.enabled: false and upgrade again.
|
||||
{{- end }}
|
||||
|
||||
THE 1PASSWORD ITEMS MUST CARRY THE FIELDS THIS CHART READS
|
||||
One Secret key is created per FIELD of the item, and these templates
|
||||
reference the keys by name — an item missing a field leaves the container in
|
||||
CreateContainerConfigError. An EMPTY field fails the same way as a missing
|
||||
one: Stalwart's secret resolver treats an empty environment variable as "not
|
||||
found".
|
||||
{{- if .Values.secrets.dataStoreSecretRef }}
|
||||
|
||||
secrets.dataStoreSecretRef → {{ .Values.secrets.dataStoreSecretRef }}
|
||||
field: password (the data store password)
|
||||
The USERNAME is not in this item and cannot be: authUsername is a plain
|
||||
String on the DataStore object with no secret form. It is
|
||||
dataStore.postgres.username = "{{ .Values.dataStore.postgres.username }}" and is rendered into the
|
||||
ConfigMap.
|
||||
{{- else }}
|
||||
|
||||
secrets.dataStoreSecretRef is NOT SET, so no data store password reaches the
|
||||
pods and config.json references an environment variable that will not exist.
|
||||
Stalwart fails to open the store with
|
||||
Environment variable '{{ include "stalwart.env.dbPassword" . }}' not found
|
||||
Set it unless the database genuinely takes no password.
|
||||
{{- end }}
|
||||
{{- if .Values.secrets.recoveryAdminSecretRef }}
|
||||
|
||||
secrets.recoveryAdminSecretRef → {{ .Values.secrets.recoveryAdminSecretRef }}
|
||||
field: recovery-admin (the COMPLETE value "admin:<password>")
|
||||
One field, not two. The upstream parser splits on the first ":" and drops
|
||||
the entry when either half is empty, so a half-filled item would silently
|
||||
mean "no recovery admin" instead of failing — and this credential logs in
|
||||
regardless of directory state.
|
||||
|
||||
REMOVE IT WHEN YOU ARE DONE. It is a back door. Once a permanent
|
||||
administrator exists in the database, set secrets.recoveryAdminSecretRef
|
||||
to null and upgrade; the variable then disappears from the pods.
|
||||
{{- else }}
|
||||
|
||||
secrets.recoveryAdminSecretRef is NOT SET. That is the right end state once a
|
||||
permanent administrator exists — but during a first install or a recovery
|
||||
start it means Stalwart generates a temporary administrator password and
|
||||
prints it to STDOUT, i.e. into the pod log, freshly on every restart and
|
||||
visible to anyone who can read logs. Catch it with
|
||||
kubectl -n {{ .Release.Namespace }} logs sts/{{ $release }}-{{ $ingressGroup | default "<group>" }}-sfs | grep -A3 bootstrap
|
||||
{{- end }}
|
||||
{{- if .Values.secrets.s3SecretRef }}
|
||||
|
||||
secrets.s3SecretRef → {{ .Values.secrets.s3SecretRef }}
|
||||
fields: access-key, secret-key
|
||||
Exposed to the containers as {{ include "stalwart.env.s3AccessKey" . }} and
|
||||
{{ include "stalwart.env.s3SecretKey" . }}. The chart does NOT configure the blob
|
||||
store; point the BlobStore object in the WebUI at these names:
|
||||
accessKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3AccessKey" . }}"}
|
||||
secretKey: {"@type":"EnvironmentVariable","variableName":"{{ include "stalwart.env.s3SecretKey" . }}"}
|
||||
{{- end }}
|
||||
|
||||
CHANGING WHAT IS INSIDE AN ITEM RESTARTS NOTHING. Helm never sees a secret's
|
||||
content, so there is nothing to hash. Repointing one of the values above at a
|
||||
different vault item DOES roll the pods (checksum/secret-ref covers the
|
||||
op:// path). After rotating a password inside an item:
|
||||
{{- range $group, $spec := $nodes }}
|
||||
kubectl -n {{ $.Release.Namespace }} rollout restart statefulset/{{ $release }}-{{ $group }}-sfs
|
||||
{{- end }}
|
||||
|
||||
FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
|
||||
1. bootstrap.enabled: true, nodes.mx.replicas: 0.
|
||||
The setup wizard everyone reaches for appears only when config.json is
|
||||
ABSENT, and this chart always renders one — so the wizard never runs here.
|
||||
Recovery mode is the documented path for a templated config.json: it
|
||||
opens the management endpoint and keeps the mail ports shut. With a second
|
||||
group running, the wizard would be unusable anyway: each request would
|
||||
land on a random pod.
|
||||
|
||||
2. Open the management UI and create the permanent administrator.
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $release }}-{{ $ingressGroup | default "<group>" }}-http-service {{ (index $ports "http").containerPort }}:{{ (index $ports "http").containerPort }}
|
||||
then http://localhost:{{ (index $ports "http").containerPort }}/admin
|
||||
|
||||
3. In the WebUI, create: the blob store (S3), the in-memory store
|
||||
({{ if $valkeyHost }}{{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }}{{ else }}no address configured — see externalServices.valkey{{ end }}),
|
||||
the coordinator (Default — it reuses the in-memory store connection), the
|
||||
search store (Default — it uses the data store, so no extra service is
|
||||
needed), the TLS object pointing at the certificate from step 5, the
|
||||
NetworkListener objects, and one ClusterRole per node group below.
|
||||
|
||||
THE LISTENER PORTS MUST MATCH THIS RELEASE EXACTLY. The chart cannot
|
||||
create listener objects, so these numbers are a second copy of a decision
|
||||
made in the WebUI, and a mismatch is silent — the Service points at a port
|
||||
nothing listens on, every pod stays Ready, nothing reports it:
|
||||
{{- range $portName, $port := $ports }}
|
||||
{{ printf "%-12s" $portName }} bind {{ printf "%-5s" (printf "%v" $port.containerPort) }}{{ if $port.public }} (published on node port {{ $port.nodePort }}){{ else }} (not published){{ end }}
|
||||
{{- end }}
|
||||
|
||||
AND ONE ClusterRole PER GROUP, NAMED EXACTLY:
|
||||
{{- range $group, $spec := $nodes }}
|
||||
{{ $group }}
|
||||
{{- end }}
|
||||
A node whose role name is not in the database does not fail. It becomes a
|
||||
FULL node — every listener and every task — while staying Running with a
|
||||
green readiness probe. The only trace is one log line,
|
||||
'Cluster role "<name>" not found in registry', and it is printed AFTER the
|
||||
listeners are already bound.
|
||||
|
||||
4. Configure HTTP forwarding before the auto-ban notices the Ingress.
|
||||
Stalwart sees the ingress controller's pod IP for every WebUI and JMAP
|
||||
request, not the client's. Sooner or later the auto-ban bans that one
|
||||
address and the WebUI and JMAP go dark for everyone at once. The PROXY
|
||||
protocol is not the fix for HTTP: enable "forwarded" handling with
|
||||
X-Forwarded-For evaluation, and discard forwarding headers the client
|
||||
brought itself. The outage arrives weeks later and looks like a server
|
||||
fault, which is why this is a step and not a tip.
|
||||
|
||||
5. Point the TLS object at the mounted certificate.
|
||||
{{- if .Values.certificate.enabled }}
|
||||
certificate: {{ .Values.certificate.mountPath }}/tls.crt
|
||||
privateKey: {{ .Values.certificate.mountPath }}/tls.key
|
||||
cert-manager writes these into tls-{{ $release }}-mail. Watch it with
|
||||
kubectl -n {{ .Release.Namespace }} get certificate {{ $release }}-mail-certificate
|
||||
Until it is issued the mail ports serve a self-signed certificate: the
|
||||
volume is mounted optional so that a pending challenge does not hold every
|
||||
pod in ContainerCreating.
|
||||
{{- else }}
|
||||
certificate.enabled is false, so this chart issues no mail certificate.
|
||||
The Ingress certificate does NOT cover the mail ports — the HA proxy
|
||||
terminates no TLS, so Stalwart has to present one itself for STARTTLS on
|
||||
25 and implicit TLS on 465/993. Provide it another way or clients will see
|
||||
a name mismatch.
|
||||
{{- end }}
|
||||
|
||||
6. Set send-proxy-v2 on the HA proxy. ALL OR NOTHING WITH STEP 3.
|
||||
externalTrafficPolicy is {{ .Values.service.externalTrafficPolicy }}, which it has to be — no Stalwart
|
||||
pod runs on a control-plane node, and the HA proxy delivers to the
|
||||
control-plane nodes, so Local would drop every packet. The cost is that
|
||||
kube-proxy masquerades the source address, and on a mail server a lost
|
||||
source address switches off SPF, takes DMARC with it, makes every
|
||||
blocklist report "clean", collapses per-IP rate limits onto one address,
|
||||
removes greylisting's key, and makes the auto-ban lock out every sender at
|
||||
once. The Received: chain becomes worthless — the one item there that
|
||||
destroys evidence retroactively rather than just failing.
|
||||
|
||||
The PROXY v2 header restores it, for port 25 as well, because the HA proxy
|
||||
opens its own connection to the node. But a trusted network makes the
|
||||
header MANDATORY: without it a connection from there hangs until timeout,
|
||||
and with it from an untrusted network Stalwart reads "PROXY TCP4 ..." as
|
||||
an SMTP command. Test on a spare port with its own frontend, and move 25
|
||||
only once a Received: line shows the real sender address.
|
||||
|
||||
7. Restart the pods — "a newly added listener binds only on the next start" —
|
||||
and set bootstrap.enabled: false in the same round.
|
||||
|
||||
8. Raise nodes.mx.replicas only after the "mx" ClusterRole exists (step 3),
|
||||
and publish DNS (below).
|
||||
|
||||
DNS A MAIL SERVER CANNOT RUN WITHOUT
|
||||
None of this is rendered by the chart; all of it is required.
|
||||
MX {{ .Values.ingress.domain | default "<your mail domain>" }}. → this deployment's public name
|
||||
SPF TXT "v=spf1 ..." naming the HA proxy's address as a sender
|
||||
DKIM TXT at <selector>._domainkey — the key is generated IN Stalwart
|
||||
(Domains → DKIM), so this record is published after step 3
|
||||
DMARC TXT at _dmarc — start at p=none with rua= and tighten only once
|
||||
the reports are clean
|
||||
REVERSE DNS PTR for the HA PROXY's public address, matching its forward
|
||||
name. Not the node's and not the pod's: the HA proxy is what
|
||||
other mail servers see connecting. Many receivers reject mail
|
||||
outright when it is missing, and nothing in Kubernetes can set
|
||||
it — it belongs to whoever owns the IP range.
|
||||
|
||||
WHAT A GREEN POD PROVES HERE, AND WHAT IT DOES NOT
|
||||
Liveness asks /healthz/live, readiness and startup ask /healthz/ready.
|
||||
Read off the source at v0.16.24: /healthz/ready returns 200 as soon as a data
|
||||
store HANDLE exists — it does not query the database. So Ready does not mean
|
||||
the database is reachable, and it does not catch a pod that fell back to an
|
||||
ephemeral in-memory store because config.json was missing. A green pod here
|
||||
means the process is answering HTTP.
|
||||
|
||||
The probe intervals are calculated, not chosen: Stalwart rate-limits
|
||||
anonymous /healthz requests to 100 per 60s PER SOURCE IP, probes arrive from
|
||||
the node IP, and so every pod on a node shares one bucket. The defaults cost
|
||||
6 requests/min/pod and leave room for about 16 pods per node. Tightening them
|
||||
produces 429s, and a 429 on liveness is a restart loop.
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
WEB UI AND JMAP
|
||||
https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}"
|
||||
Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
|
||||
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress
|
||||
|
||||
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE. Upstream moves
|
||||
administration to https://<hostname>/admin once setup is complete, so this
|
||||
Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the
|
||||
database (step 3). Between finishing setup and creating that listener the
|
||||
Ingress resolves to a port that answers nothing.
|
||||
{{- else }}
|
||||
|
||||
NO INGRESS
|
||||
ingress.enabled is false, so there is no Ingress and no WebUI certificate.
|
||||
ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its
|
||||
dnsNames, and without it the certificate would carry no name at all.
|
||||
{{- end }}
|
||||
{{- if .Values.valkeySubchart.enabled }}
|
||||
|
||||
THE VALKEY SUBCHART IS ENABLED
|
||||
Reachable at {{ $valkeyHost }}:{{ .Values.externalServices.valkey.port }} — this repo's own valkey chart,
|
||||
pulled from the published index. Enter that address as the in-memory store in
|
||||
step 3; the coordinator's Default variant reuses the same connection.
|
||||
|
||||
ITS 1PASSWORD ITEM IS A SEPARATE ONE, AND ITS FIELD IS NOT A PASSWORD:
|
||||
valkey.secrets.authSecretRef points at
|
||||
{{ .Values.valkey.secrets.authSecretRef | default "<NOT SET - the subchart's own required() will abort the render>" }}
|
||||
and that item carries ONE field, "auth-conf", whose value is the complete
|
||||
configuration line
|
||||
|
||||
requirepass <password>
|
||||
|
||||
not a bare password. The valkey chart mounts the file and includes it, and
|
||||
an init container refuses to start the pod unless the line is really there —
|
||||
because the alternative failure is an open server that looks healthy.
|
||||
|
||||
IT IS CONFIGURED AS A CACHE, NOT A QUEUE
|
||||
persistence.enabled false + maxmemoryPolicy allkeys-lru. Everything Stalwart
|
||||
keeps here — rate-limiter counters, auth tokens, sessions, auto-ban entries,
|
||||
the coordination pub/sub — is reconstructible after a restart; nothing in it
|
||||
is the system of record.
|
||||
|
||||
The cost of allkeys-lru, so it is not a surprise: under memory pressure keys
|
||||
are discarded, so the rate limiters and the auto-ban FAIL OPEN. Size memory
|
||||
for the working set rather than switching to noeviction — with noeviction
|
||||
Stalwart's writes to the store start erroring, which takes out
|
||||
authentication instead of just the limits.
|
||||
|
||||
scaleDown and checksum/config stop at the subchart boundary, so scaling this
|
||||
release to zero does NOT stop the Valkey, and a new valkey chart release
|
||||
reaches you only when stalwart itself is republished.
|
||||
{{- end }}
|
||||
{{- if not .Values.networkPolicy.enabled }}
|
||||
|
||||
NETWORKPOLICY IS OFF
|
||||
networkPolicy.enabled is false, so nothing limits reachability except the
|
||||
Services. That removes the second line of defence against the fail-open in
|
||||
step 3: a group that accidentally became a full node is then reachable on
|
||||
every port it bound.
|
||||
{{- else if not .Values.networkPolicy.probeSources }}
|
||||
|
||||
NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
|
||||
networkPolicy.probeSources is empty, so the rule for port {{ (index $ports "http").containerPort }} renders with
|
||||
no "from" selector. Deliberate: the kubelet probes from the worker node's IP,
|
||||
which this chart cannot know, and a wrong guess blocks every probe — a pod
|
||||
that never becomes Ready reads as a broken application rather than as a
|
||||
policy. The port carries no nodePort, so this is open inside the cluster, not
|
||||
from outside. Narrow it with networkPolicy.probeSources, and then add the
|
||||
ingress controller's namespace to networkPolicy.allowedNamespaces or the
|
||||
Ingress goes dark.
|
||||
{{- end }}
|
||||
@@ -1,433 +0,0 @@
|
||||
{{/*
|
||||
Resource / replica / null-safety helpers.
|
||||
|
||||
These are copied from template-chart with "stalwart." as the define prefix,
|
||||
as in every other chart here: sharing them would mean a library chart, and a
|
||||
dependency is admissible but expensive (README, guideline 14) - this chart
|
||||
already carries one and does not need a second. template-chart holds the
|
||||
reference implementation; the resource-quantity render test in CI exists
|
||||
because a copy can carry an old bug into a diff nobody reads.
|
||||
*/}}
|
||||
{{- define "stalwart.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
{{- $factor := 3.0 -}}
|
||||
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
||||
{{- $factor = float64 $resources.limitFactor -}}
|
||||
{{- end -}}
|
||||
{{- $requests := dict -}}
|
||||
{{- range $key, $value := ($resources.requests | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $requests (include "stalwart.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $limits := dict -}}
|
||||
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $limits (include "stalwart.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- range $key, $request := $requests -}}
|
||||
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||
{{- $_ := set $limits $key (include "stalwart.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $out := dict -}}
|
||||
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
||||
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Normalize a resource name from values to its Kubernetes key.
|
||||
*/}}
|
||||
{{- define "stalwart.resourceKey" -}}
|
||||
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
||||
Input: dict "quantity" <string|number> "factor" <float64>.
|
||||
*/}}
|
||||
{{- define "stalwart.multiplyQuantity" -}}
|
||||
{{- $quantity := include "stalwart.formatNumber" .quantity -}}
|
||||
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||
{{- if not $number -}}
|
||||
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $suffix := trimPrefix $number $quantity -}}
|
||||
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
||||
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
||||
{{- end -}}
|
||||
{{- $product := mulf (float64 $number) .factor -}}
|
||||
{{- if eq $product (floor $product) -}}
|
||||
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s%s" (include "stalwart.formatNumber" $product) $suffix -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render a number as a plain decimal string, never in exponential notation
|
||||
(issue #157): the null-safe helpers pass values through fromYaml, which parses
|
||||
every number as float64, and Go's "%v" switches to exponential notation from
|
||||
about 1e6 upwards - not a valid Kubernetes quantity.
|
||||
*/}}
|
||||
{{- define "stalwart.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The replica count of one node group.
|
||||
Input: dict "scaleDown" <bool|nil> "replicas" <int|nil>.
|
||||
The explicit kindIs "invalid" check is what keeps an explicit 0 - which this
|
||||
chart relies on, since the "mx" group ships with replicas: 0.
|
||||
*/}}
|
||||
{{- define "stalwart.replicas" -}}
|
||||
{{- if .scaleDown -}}
|
||||
0
|
||||
{{- else if not (kindIs "invalid" .replicas) -}}
|
||||
{{- .replicas -}}
|
||||
{{- else -}}
|
||||
1
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
||||
null / empty override falls back to the defaults instead of erasing them
|
||||
(issues #61 and #99). Input: dict "defaults" <dict> "given" <dict|nil> "path"
|
||||
<string>. Output: YAML, consume it with fromYaml.
|
||||
*/}}
|
||||
{{- define "stalwart.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
{{- $given := .given -}}
|
||||
{{- $path := .path | default "value" -}}
|
||||
{{- if kindIs "invalid" $given -}}
|
||||
{{- $given = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $given) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
||||
{{- end -}}
|
||||
{{- $out := deepCopy $defaults -}}
|
||||
{{- range $key, $value := $given -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $default := index $defaults $key -}}
|
||||
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
||||
{{- $_ := set $out $key (fromYaml (include "stalwart.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
||||
{{- else if kindIs "map" $default -}}
|
||||
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
||||
{{- else -}}
|
||||
{{- $_ := set $out $key $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $out -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Pick one sub-block out of a values block that may itself be null.
|
||||
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
||||
*/}}
|
||||
{{- define "stalwart.subBlock" -}}
|
||||
{{- $block := .block -}}
|
||||
{{- if kindIs "invalid" $block -}}
|
||||
{{- $block = dict -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $block) -}}
|
||||
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
||||
{{- end -}}
|
||||
{{- toYaml (dict "value" (index $block .key)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective security contexts, probes and resources (issue #99).
|
||||
|
||||
The defaults below MUST stay in sync with values.yaml, which stays the
|
||||
documented, user-facing place for them; this copy is only the fallback for the
|
||||
case where an override has erased the block.
|
||||
|
||||
Two rules these fallbacks exist to honour (README, guideline 12):
|
||||
- reproduce THIS chart's defaults, not the repo standard. uid/gid 2000 is the
|
||||
image's own user, and there is deliberately NO fsGroup here because this
|
||||
chart mounts no PVC.
|
||||
- carry the probe's CONTEXT, not just its timings. The paths and the port
|
||||
name are part of these defaults; without them the probes would hit "/" on
|
||||
a port that may not exist, and liveness would stop being independent of
|
||||
the data store.
|
||||
*/}}
|
||||
{{- define "stalwart.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "pod" "path" "securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 2000
|
||||
"runAsGroup" 2000
|
||||
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "stalwart.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "stalwart.subBlock" (dict "block" . "key" "container" "path" "securityContext"))).value -}}
|
||||
{{/*
|
||||
NET_BIND_SERVICE is part of the FALLBACK, not only of values.yaml, and that is
|
||||
the whole point of rule "reproduce THIS chart's defaults, not the repo
|
||||
standard". The image carries setcap cap_net_bind_service=+ep on its binary, so
|
||||
with an empty bounding set the kernel fails the execve itself - an erased
|
||||
securityContext block that fell back to the repo-standard "add: []" would stop
|
||||
the chart from starting at all, with "operation not permitted" and nothing
|
||||
pointing at this file.
|
||||
*/}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL") "add" (list "NET_BIND_SERVICE")) -}}
|
||||
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" $given "path" "securityContext.container") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The probe fallbacks carry the calculated intervals, not just the paths. The
|
||||
arithmetic is in values.yaml: Stalwart rate-limits anonymous /healthz requests
|
||||
to 100 per 60s PER SOURCE IP, probes come from the node IP, so every pod on a
|
||||
node shares one bucket. 20/20/10 costs 6 requests/min/pod in both phases
|
||||
(the startup probe gates the other two, so they never run together) and leaves
|
||||
room for ~16 pods per node. The obvious 10/10/5 would halve that to ~8.
|
||||
Tightening these here without redoing that sum produces 429s, and a 429 on
|
||||
liveness is a restart loop.
|
||||
*/}}
|
||||
{{- define "stalwart.livenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/healthz/live" "port" "http")
|
||||
"periodSeconds" 20
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "stalwart.readinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/healthz/ready" "port" "http")
|
||||
"periodSeconds" 20
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "stalwart.startupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/healthz/ready" "port" "http")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 30 -}}
|
||||
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" . "path" "startupProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
=============================================================================
|
||||
Chart-specific helpers
|
||||
=============================================================================
|
||||
*/}}
|
||||
|
||||
{{/*
|
||||
The common name prefix. One place, because it appears in every object name and
|
||||
in the Service selectors.
|
||||
*/}}
|
||||
{{- define "stalwart.fullname" -}}
|
||||
{{- printf "%s-%s" .Release.Name .Chart.Name -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The validated "ports" map. Rejects a non-mapping and an empty map, and checks
|
||||
every entry for the one field the chart cannot do without.
|
||||
|
||||
Why the number is checked here rather than where it is used: an erased
|
||||
ports.smtp.number used to render "port:" and "targetPort:" empty into the
|
||||
Service and an empty "containerPort" into the StatefulSet - rejected by nothing
|
||||
until the API server saw it (the satisfactory lesson, Gitea #16).
|
||||
*/}}
|
||||
{{- define "stalwart.ports" -}}
|
||||
{{- $ports := .Values.ports -}}
|
||||
{{- if kindIs "invalid" $ports -}}
|
||||
{{- fail "ports must be a mapping of port name to { containerPort, nodePort, protocol, public }, and it cannot be erased: every Service, containerPort and NetworkPolicy rule in this chart is rendered from it" -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $ports) -}}
|
||||
{{- fail (printf "ports must be a mapping, got %s" (kindOf $ports)) -}}
|
||||
{{- end -}}
|
||||
{{- if eq (len $ports) 0 -}}
|
||||
{{- fail "ports is empty - the chart would render workloads with no containerPort and no Service at all" -}}
|
||||
{{- end -}}
|
||||
{{- range $name, $spec := $ports -}}
|
||||
{{- if not (kindIs "map" $spec) -}}
|
||||
{{- fail (printf "ports.%s must be a mapping, got %s (%v)" $name (kindOf $spec) $spec) -}}
|
||||
{{- end -}}
|
||||
{{- if kindIs "invalid" $spec.containerPort -}}
|
||||
{{- fail (printf "ports.%s.containerPort is required - a port without one renders an empty containerPort and an empty Service targetPort. Note containerPort and nodePort are two different numbers here and are not interchangeable: containerPort is what the application binds (and what the NetworkListener object in the database must say), nodePort is what the cluster publishes" $name) -}}
|
||||
{{- end -}}
|
||||
{{- if and $spec.public (kindIs "invalid" $spec.nodePort) -}}
|
||||
{{- fail (printf "ports.%s is marked public but has no nodePort - a NodePort Service without a nodePort would be assigned a random one, which the HA proxy in front of the cluster does not know about" $name) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $ports -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The validated "nodes" map, with each group's exposedPorts checked against the
|
||||
ports map. Input: the root context.
|
||||
|
||||
The exposedPorts check is the one that earns its keep: a typo there would
|
||||
silently drop a port from a group's containerPorts and from its NetworkPolicy,
|
||||
leaving a Service selecting pods that never bound the port.
|
||||
*/}}
|
||||
{{- define "stalwart.nodes" -}}
|
||||
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
||||
{{- $nodes := .Values.nodes -}}
|
||||
{{- if kindIs "invalid" $nodes -}}
|
||||
{{- fail "nodes must be a mapping of cluster-role name to node group, and it cannot be erased: it is what every StatefulSet in this chart is rendered from" -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "map" $nodes) -}}
|
||||
{{- fail (printf "nodes must be a mapping of cluster-role name to node group, got %s - note it is a MAP and not a list, so that an override never depends on a position" (kindOf $nodes)) -}}
|
||||
{{- end -}}
|
||||
{{- if eq (len $nodes) 0 -}}
|
||||
{{- fail "nodes is empty - the release would contain Services and an Ingress but no workload" -}}
|
||||
{{- end -}}
|
||||
{{- range $group, $spec := $nodes -}}
|
||||
{{- if not (kindIs "map" $spec) -}}
|
||||
{{- fail (printf "nodes.%s must be a mapping, got %s (%v)" $group (kindOf $spec) $spec) -}}
|
||||
{{- end -}}
|
||||
{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" $group) -}}
|
||||
{{- fail (printf "nodes.%s is not usable as a Kubernetes name segment: a group key becomes part of every object name and of a label value, so it must be lowercase alphanumeric with dashes. It is ALSO the cluster-role name passed in STALWART_ROLE, so renaming it here without renaming the ClusterRole object in the database turns the group into a full node" $group) -}}
|
||||
{{- end -}}
|
||||
{{- $exposed := $spec.exposedPorts -}}
|
||||
{{- if kindIs "invalid" $exposed -}}
|
||||
{{- fail (printf "nodes.%s.exposedPorts is required - a group with no ports is unreachable, and because this list is also the NetworkPolicy's ingress rule, an erased one is indistinguishable from a deliberate lockout" $group) -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "slice" $exposed) -}}
|
||||
{{- fail (printf "nodes.%s.exposedPorts must be a list of port names, got %s (%v)" $group (kindOf $exposed) $exposed) -}}
|
||||
{{- end -}}
|
||||
{{- range $exposed -}}
|
||||
{{- if not (hasKey $ports .) -}}
|
||||
{{- fail (printf "nodes.%s.exposedPorts names %q, which is not a key of ports: - known ports are %s. Left unchecked this would drop the port from the group's containerPorts and from its NetworkPolicy while the Service still selected its pods" $group . (keys $ports | sortAlpha | join ", ")) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if not (has "http" $exposed) -}}
|
||||
{{- fail (printf "nodes.%s.exposedPorts does not include \"http\", but all three probes target that port - the group would never become Ready. Every group needs it, including an SMTP-only one; it is simply not published there" $group) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- toYaml $nodes -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective resources of one node group.
|
||||
Input: dict "group" <name> "given" <the group's resources block|nil>
|
||||
"limitFactor" <the chart-global .Values.limitFactor|nil>.
|
||||
|
||||
The per-group defaults are reproduced here, not a single generic set: "mx" is
|
||||
deliberately lighter than "full", and a fallback that handed an erased
|
||||
mx.resources the heavier figures would quietly change what the chart had been
|
||||
rendering. A group the chart does not ship gets the "full" figures, which is
|
||||
the safe direction - too much accounting rather than too little.
|
||||
*/}}
|
||||
{{- define "stalwart.effectiveResources" -}}
|
||||
{{- $factor := 3 -}}
|
||||
{{- if not (kindIs "invalid" .limitFactor) -}}
|
||||
{{- $factor = .limitFactor -}}
|
||||
{{- end -}}
|
||||
{{- $requests := dict "cpu" "200m" "memory" "512Mi" "ephemeral-storage" "64Mi" -}}
|
||||
{{- if eq .group "mx" -}}
|
||||
{{- $requests = dict "cpu" "100m" "memory" "256Mi" "ephemeral-storage" "64Mi" -}}
|
||||
{{- end -}}
|
||||
{{- $defaults := dict "limitFactor" $factor "requests" $requests -}}
|
||||
{{- $merged := fromYaml (include "stalwart.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "nodes.%s.resources" .group))) -}}
|
||||
{{- include "stalwart.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The label a pod carries for one port it is reachable on. The per-port Services
|
||||
select on these, which is what lets one NodePort Service serve several node
|
||||
groups - NodePorts are cluster-globally unique, so "full" and "mx" can only
|
||||
accept SMTP behind the SAME Service object.
|
||||
*/}}
|
||||
{{- define "stalwart.portLabel" -}}
|
||||
{{- printf "stalwart.jk-dev.de/port-%s" . -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The address of the in-memory store, and the render abort that goes with it.
|
||||
Input: the root context. Returns the host, or "" when none is needed.
|
||||
|
||||
The abort is the point. From v0.16 the cluster coordinator's Default variant
|
||||
"reuses the Redis connection already configured on the in-memory store", so
|
||||
more than one node without an in-memory store is a cluster with no
|
||||
coordination path - and the symptom is not a failed install but a cluster that
|
||||
comes up and disagrees with itself. Note what this value does NOT do: it does
|
||||
not configure Stalwart. The in-memory store is an object in the database; this
|
||||
is the address to type into the WebUI, and the port for the egress rule.
|
||||
*/}}
|
||||
{{- define "stalwart.valkeyHost" -}}
|
||||
{{- $given := "" -}}
|
||||
{{- if not (kindIs "invalid" .Values.externalServices) -}}
|
||||
{{- $valkey := .Values.externalServices.valkey -}}
|
||||
{{- if and (not (kindIs "invalid" $valkey)) (not (kindIs "invalid" $valkey.host)) -}}
|
||||
{{- $given = $valkey.host -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $subchartOn := false -}}
|
||||
{{- if and (not (kindIs "invalid" .Values.valkeySubchart)) .Values.valkeySubchart.enabled -}}
|
||||
{{- $subchartOn = true -}}
|
||||
{{- end -}}
|
||||
{{- if $given -}}
|
||||
{{- $given -}}
|
||||
{{- else if $subchartOn -}}
|
||||
{{/* The Service our own valkey chart renders:
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
and inside a subchart .Chart.Name is the SUBCHART's name, so this is
|
||||
"<release>-valkey-service" with no special case. Reproduced rather than
|
||||
included, because a parent cannot call a subchart's define with the
|
||||
subchart's context.
|
||||
|
||||
Note the "-service" suffix: this repo's naming scheme ends every Service
|
||||
that way, which is NOT what upstream's valkey chart does (it would be a
|
||||
bare "<release>-valkey"). An earlier draft of this chart depended on
|
||||
upstream's and carried their name; getting this wrong points the operator
|
||||
at an address that does not resolve, and nothing in the chart would say
|
||||
so. */}}
|
||||
{{- printf "%s-valkey-service" .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $total := 0 -}}
|
||||
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $total = add $total (int (include "stalwart.replicas" (dict "scaleDown" $.Values.scaleDown "replicas" $spec.replicas))) -}}
|
||||
{{- end -}}
|
||||
{{- if gt $total 1 -}}
|
||||
{{- fail (printf "this release runs %d Stalwart pods, which is a cluster, but no in-memory store has an address: externalServices.valkey.host is empty and valkeySubchart.enabled is false. Stalwart's cluster coordinator rides on the in-memory store connection, so a multi-node install has no coordination path at all - and that failure does not show up as a failed install, it shows up as nodes that do not agree with each other. Set externalServices.valkey.host to an existing instance, or set valkeySubchart.enabled: true to deploy the subchart" $total) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The environment-variable names that config.json references and the StatefulSet
|
||||
sets. They are helpers rather than literals in two files because a mismatch
|
||||
between the two is not a render error: the server starts, resolves authSecret,
|
||||
finds no such variable, and fails to open the data store with
|
||||
"Environment variable '<name>' not found". One definition, used by both.
|
||||
|
||||
Note the upstream resolver treats an EMPTY value as not found, so a 1Password
|
||||
item whose field exists but is blank fails exactly like a missing field.
|
||||
*/}}
|
||||
{{- define "stalwart.env.dbPassword" -}}STALWART_DB_PASSWORD{{- end -}}
|
||||
{{- define "stalwart.env.s3AccessKey" -}}STALWART_S3_ACCESS_KEY{{- end -}}
|
||||
{{- define "stalwart.env.s3SecretKey" -}}STALWART_S3_SECRET_KEY{{- end -}}
|
||||
|
||||
{{/*
|
||||
The node group the Ingress routes to, validated. Input: the root context.
|
||||
*/}}
|
||||
{{- define "stalwart.ingressNodeGroup" -}}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $group := required "ingress.nodeGroup must name the node group the Ingress routes to" .Values.ingress.nodeGroup -}}
|
||||
{{- if not (hasKey $nodes $group) -}}
|
||||
{{- fail (printf "ingress.nodeGroup is %q, which is not a key of nodes: - known groups are %s" $group (keys $nodes | sortAlpha | join ", ")) -}}
|
||||
{{- end -}}
|
||||
{{- $group -}}
|
||||
{{- end -}}
|
||||
@@ -1,56 +0,0 @@
|
||||
{{- /*
|
||||
The certificate for the MAIL ports, issued by cert-manager and mounted into
|
||||
every node group read-only.
|
||||
|
||||
WHY THE INGRESS CERTIFICATE IS NOT ENOUGH: the Ingress covers the WebUI and
|
||||
JMAP only. The HA proxy in front of the cluster terminates no TLS, so Stalwart
|
||||
itself has to present a certificate for STARTTLS on 25 and implicit TLS on 465
|
||||
and 993. Those connections never pass through the ingress controller.
|
||||
|
||||
WHY NOT STALWART'S OWN ACME: the HA proxy forwards 25, 465 and 993 and not 80,
|
||||
so an HTTP-01 challenge answered by Stalwart is unreachable from outside.
|
||||
DNS-01 inside Stalwart would work and would duplicate what cert-manager
|
||||
already does here.
|
||||
|
||||
THIS OBJECT ONLY OBTAINS THE FILES. Pointing Stalwart at them is a separate,
|
||||
manual step: the TLS object in the DATABASE has to name the two paths below.
|
||||
Until it does, the mail ports serve whatever default certificate Stalwart
|
||||
generates for itself, and clients see a name mismatch while every Kubernetes
|
||||
object here is green.
|
||||
*/ -}}
|
||||
{{- if .Values.certificate.enabled }}
|
||||
{{- $domain := required "A Domain/Host must be provided (ingress.domain) - it is read here for the mail certificate as well as by the Ingress, so it stays required even with ingress.enabled false" .Values.ingress.domain -}}
|
||||
{{- /* An explicit if, NOT
|
||||
.Values.certificate.clusterIssuer | default (required "..." .Values.ingress.clusterIssuer)
|
||||
which is the obvious one-liner and is wrong: Go evaluates a pipeline's
|
||||
arguments eagerly, so required() fires whenever ingress.clusterIssuer is
|
||||
null even though certificate.clusterIssuer is set and the fallback is
|
||||
never needed. Measured: ingress.enabled=false plus
|
||||
certificate.clusterIssuer=<issuer> aborted the render. */ -}}
|
||||
{{- $issuer := .Values.certificate.clusterIssuer -}}
|
||||
{{- if not $issuer -}}
|
||||
{{- $issuer = required "A Cluster-Issuer must be provided: set certificate.clusterIssuer for the mail certificate (usually a DNS-01 issuer), or ingress.clusterIssuer, which it falls back to" .Values.ingress.clusterIssuer -}}
|
||||
{{- end -}}
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ include "stalwart.fullname" . }}-mail-certificate
|
||||
labels:
|
||||
app: {{ include "stalwart.fullname" . }}
|
||||
spec:
|
||||
# Deliberately NOT the Ingress TLS secret name (tls-<release>-stalwart-ingress).
|
||||
# Two separate certificates on purpose: this one usually comes from a
|
||||
# different issuer (DNS-01, see below) and covers different names, and
|
||||
# sharing one secret would make the ingress controller and the mail
|
||||
# listeners fight over which names the certificate has to carry.
|
||||
secretName: tls-{{ include "stalwart.fullname" . }}-mail
|
||||
issuerRef:
|
||||
name: {{ $issuer }}
|
||||
kind: ClusterIssuer
|
||||
group: cert-manager.io
|
||||
dnsNames:
|
||||
- {{ $domain | quote }}
|
||||
{{- range .Values.certificate.extraDnsNames }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,52 +0,0 @@
|
||||
{{- /*
|
||||
config.json - the ONLY file Stalwart reads from disk, and the only part of its
|
||||
configuration this chart owns.
|
||||
|
||||
It is exactly the DataStore object, with no wrapper: upstream's declarative
|
||||
deployment page states "It only needs to contain the DataStore object", and the
|
||||
reader confirms it - crates/store/src/registry/local.rs deserializes the whole
|
||||
file straight into the DataStore enum, which is internally tagged on "@type".
|
||||
Everything else (listeners, blob store, in-memory store, TLS objects, domains,
|
||||
cluster roles) is a JMAP object in the database and is created in the WebUI.
|
||||
|
||||
WHY A CONFIGMAP AND NOT A SECRET: there is no credential in here. authSecret
|
||||
is a tagged sub-object with an EnvironmentVariable variant, so the password is
|
||||
referenced by NAME and arrives through the env (see the StatefulSet). That is
|
||||
what keeps checksum/config meaningful - Helm can hash this file because Helm
|
||||
can see all of it.
|
||||
|
||||
The username is NOT a secret and cannot be made one: authUsername is a plain
|
||||
String on the object with no secret form, unlike authSecret. It is public here
|
||||
by necessity, not by oversight.
|
||||
*/ -}}
|
||||
{{- $pg := .Values.dataStore.postgres -}}
|
||||
{{- $store := dict
|
||||
"@type" "PostgreSql"
|
||||
"host" (required "dataStore.postgres.host is required - without it Stalwart has no database, and a config.json that cannot be parsed or reached is the one case that does abort the start loudly" $pg.host)
|
||||
"port" (int $pg.port)
|
||||
"database" $pg.database
|
||||
"authUsername" $pg.username
|
||||
"authSecret" (dict "@type" "EnvironmentVariable" "variableName" (include "stalwart.env.dbPassword" .))
|
||||
"useTls" $pg.tls.enable
|
||||
"allowInvalidCerts" $pg.tls.allowInvalidCerts
|
||||
"poolMaxConnections" (int $pg.poolMaxConnections)
|
||||
"timeout" (int $pg.timeoutMs) -}}
|
||||
{{- if not (kindIs "invalid" $pg.options) -}}
|
||||
{{- $_ := set $store "options" $pg.options -}}
|
||||
{{- end -}}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "stalwart.fullname" . }}-configmap
|
||||
labels:
|
||||
app: {{ include "stalwart.fullname" . }}
|
||||
data:
|
||||
# Rendered through toPrettyJson from a dict rather than written as a JSON
|
||||
# literal: "timeout" is a Duration, which upstream's object encoding defines
|
||||
# as an INTEGER COUNT OF MILLISECONDS and which explicitly "does not accept a
|
||||
# string" - the serde impl deserializes it through u64::deserialize into
|
||||
# Duration::from_millis. A hand-written literal is where a "15s" creeps in,
|
||||
# and the server then refuses to parse the file at startup. Going through the
|
||||
# dict also means "port" and "poolMaxConnections" cannot come out quoted.
|
||||
config.json: |
|
||||
{{- toPrettyJson $store | nindent 4 }}
|
||||
@@ -1,65 +0,0 @@
|
||||
{{- /*
|
||||
The Ingress for the WebUI and JMAP only. SMTP, IMAP and ManageSieve are not
|
||||
HTTP and cannot pass through it - those reach the pods through the per-port
|
||||
NodePort Services.
|
||||
|
||||
It targets exactly ONE node group (ingress.nodeGroup) through that group's own
|
||||
http Service, never a port-label selector: the management surface is not safe
|
||||
to spread across a group that may not run the management tasks.
|
||||
|
||||
TWO TRAPS THAT BELONG TO THIS OBJECT AND ARE NOT SOLVED BY IT:
|
||||
|
||||
1. The auto-ban sees the INGRESS CONTROLLER's pod IP for every JMAP and WebUI
|
||||
request, not the client's. Sooner or later it bans that address and the
|
||||
WebUI and JMAP go dark for everyone at once. The PROXY protocol is not the
|
||||
remedy for HTTP: the remedy is Stalwart's "forwarded" handling with
|
||||
X-Forwarded-For evaluation, plus discarding whatever forwarding headers the
|
||||
client brought along. That is a configuration step in the database, and it
|
||||
is mandatory rather than optional - the failure arrives weeks later and
|
||||
looks like a server problem.
|
||||
|
||||
2. AFTER BOOTSTRAP, PORT 8080 IS NOT THE ADMIN PORT ANY MORE. Upstream:
|
||||
"After the restart, the WebUI is reached at https://<hostname>/admin on the
|
||||
configured server hostname, and HTTP access on port 8080 is no longer
|
||||
available for administration." So this Ingress only works once an HTTP
|
||||
NetworkListener on 8080 exists in the database. Between finishing the setup
|
||||
and creating that listener, the Ingress resolves to a port that answers
|
||||
nothing.
|
||||
*/ -}}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- $group := include "stalwart.ingressNodeGroup" . -}}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $spec := index $nodes $group -}}
|
||||
{{- if not (has "http" $spec.exposedPorts) -}}
|
||||
{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the Ingress to point at" $group $group) -}}
|
||||
{{- end -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "stalwart.fullname" . }}-ingress
|
||||
labels:
|
||||
app: {{ include "stalwart.fullname" . }}
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided (ingress.clusterIssuer)" .Values.ingress.clusterIssuer }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
# The http Service of the "{{ $group }}" group only.
|
||||
name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
|
||||
port:
|
||||
name: http
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ include "stalwart.fullname" . }}-ingress
|
||||
{{- end }}
|
||||
@@ -1,136 +0,0 @@
|
||||
{{- /*
|
||||
One NetworkPolicy per node group.
|
||||
|
||||
This object is the CEILING ON REACHABILITY, and that is its real job here: it
|
||||
holds no matter which ports the process actually bound. A node whose
|
||||
ClusterRole is missing from the database fail-opens into a full node and binds
|
||||
every listener - including IMAP and the admin interface on a group meant to
|
||||
take SMTP only - while staying Running with a green probe. The per-port
|
||||
Services publish only what was asked for, and this policy is the second,
|
||||
independent limit. See values.yaml for the source-level detail.
|
||||
|
||||
!!! THE PORT NUMBERS HERE ARE POD PORTS (25/465/993/8080), NOT NODEPORTS !!!
|
||||
|
||||
This is the easiest mistake to make in this chart, so it is spelled out. A
|
||||
NetworkPolicy is evaluated at the POD's network interface, after kube-proxy has
|
||||
already translated the NodePort to the targetPort. Writing 30025 here would
|
||||
match nothing: the packet arrives at the pod on 25. The policy would then look
|
||||
strict, be accepted by the API server, and silently block every inbound
|
||||
connection - or, if it is the only rule, block the lot.
|
||||
|
||||
Egress is restricted BY PORT and not by destination, because a NetworkPolicy
|
||||
matches CIDRs and selectors and never hostnames - so dataStore.postgres.host
|
||||
and externalServices.*.host cannot appear in it. What it still buys: a
|
||||
compromised pod cannot open arbitrary outbound ports.
|
||||
|
||||
And a NetworkPolicy is only a boundary where the cluster's CNI enforces one. An
|
||||
unenforced policy is accepted and filters nothing.
|
||||
*/ -}}
|
||||
{{- if .Values.networkPolicy.enabled }}
|
||||
{{- $root := . -}}
|
||||
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
||||
{{- $egress := .Values.networkPolicy.egress -}}
|
||||
{{- $probeSources := .Values.networkPolicy.probeSources | default list -}}
|
||||
{{- $allowedNs := .Values.networkPolicy.allowedNamespaces | default list -}}
|
||||
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
|
||||
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ $name }}-networkpolicy
|
||||
labels:
|
||||
app: {{ $name }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: {{ $name }}-sfs
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
{{- /* The public mail ports: open to any source. They have to be - the
|
||||
senders are the whole internet, and after NodePort masquerading the
|
||||
source address is a control-plane node anyway. */}}
|
||||
{{- $publicPorts := list }}
|
||||
{{- range $portName := $spec.exposedPorts }}
|
||||
{{- $port := index $ports $portName }}
|
||||
{{- if $port.public }}{{- $publicPorts = append $publicPorts $portName }}{{- end }}
|
||||
{{- end }}
|
||||
{{- if $publicPorts }}
|
||||
- ports:
|
||||
{{- range $portName := $publicPorts }}
|
||||
{{- $port := index $ports $portName }}
|
||||
# {{ $portName }}: published on nodePort {{ int $port.nodePort }}, reaching the pod on {{ int $port.containerPort }}
|
||||
- protocol: {{ $port.protocol | default "TCP" }}
|
||||
port: {{ int $port.containerPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- /* The http port: probes plus the ingress controller. */}}
|
||||
{{- $http := index $ports "http" }}
|
||||
- ports:
|
||||
- protocol: {{ $http.protocol | default "TCP" }}
|
||||
port: {{ int $http.containerPort }}
|
||||
{{- if or $probeSources $allowedNs }}
|
||||
# Restricted because networkPolicy.probeSources or .allowedNamespaces is
|
||||
# set. MIND THE KUBELET: probes come from the IP of the worker node
|
||||
# running the pod, so that address has to be in probeSources or the pod
|
||||
# never becomes Ready - and a pod that is never Ready reads as a broken
|
||||
# application rather than as a policy.
|
||||
from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
# Set by Kubernetes itself on every namespace (>= 1.21), so this
|
||||
# needs no labelling work.
|
||||
kubernetes.io/metadata.name: {{ $root.Release.Namespace }}
|
||||
{{- range $allowedNs }}
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- range $probeSources }}
|
||||
- ipBlock:
|
||||
cidr: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
# NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside
|
||||
# the cluster: http carries no nodePort, so nothing outside reaches it
|
||||
# through this chart. The alternative was to guess the worker node CIDRs,
|
||||
# and a wrong guess blocks every kubelet probe. Narrow it by setting
|
||||
# networkPolicy.probeSources (and then .allowedNamespaces for the
|
||||
# ingress controller, or the Ingress goes dark).
|
||||
{{- end }}
|
||||
egress:
|
||||
{{- if $egress.dns }}
|
||||
- ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
{{- end }}
|
||||
{{- if $egress.smtp }}
|
||||
# Outbound delivery: port 25 on any host on the internet. Uniform for every
|
||||
# group on purpose - the chart does not know which groups run the
|
||||
# outboundMta task, the value would drift away from the database, and the
|
||||
# failure mode of getting it wrong is mail silently not leaving.
|
||||
- ports:
|
||||
- protocol: TCP
|
||||
port: 25
|
||||
{{- end }}
|
||||
{{- $outPorts := list (int $root.Values.dataStore.postgres.port) }}
|
||||
{{- with $root.Values.externalServices }}
|
||||
{{- if .s3 }}{{- $outPorts = append $outPorts (int .s3.port) }}{{- end }}
|
||||
{{- if .valkey }}{{- $outPorts = append $outPorts (int .valkey.port) }}{{- end }}
|
||||
{{- end }}
|
||||
{{- range $root.Values.networkPolicy.egress.extraPorts }}
|
||||
{{- $outPorts = append $outPorts (int .) }}
|
||||
{{- end }}
|
||||
# The data store, the blob store, the in-memory store and anything added
|
||||
# through networkPolicy.egress.extraPorts. Ports only - see the header.
|
||||
- ports:
|
||||
{{- range $outPorts | uniq }}
|
||||
- protocol: TCP
|
||||
port: {{ . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user