Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffcf3f344f
|
No files matched your search
@@ -38,10 +38,7 @@ on:
|
||||
- searxng
|
||||
- bentopdf
|
||||
- ytdlp-web-player
|
||||
- sure
|
||||
- valkey
|
||||
- convertx
|
||||
# freesailarr is deliberately absent: none of its ten images takes
|
||||
# freesailarr is deliberately absent: none of its nine images takes
|
||||
# its tag from appVersion (they are all pinned in its values.yaml),
|
||||
# so this workflow would rewrite appVersion and republish the chart
|
||||
# without changing a single deployed image. Update its images through
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
# The all-charts install-test: the same job the pull-request pipeline runs, with
|
||||
# a matrix over every installable chart instead of the changed ones (Gitea #52).
|
||||
#
|
||||
# The question it answers is not "did this change break a chart" - that is the
|
||||
# pull-request pipeline's job - but "does the environment under an UNCHANGED
|
||||
# chart still hold". The install-test matrix follows the charts a pull request
|
||||
# touches, so a chart nobody has edited since the forge move carries a green
|
||||
# history from a different runner, and that green says nothing about today.
|
||||
#
|
||||
# It exists because the answer arrived once by accident: Gitea PR #48 corrected
|
||||
# a comment in 21 charts, which made every one of them "changed", and so became
|
||||
# the first all-charts run after the move - 19 of 20 matrix charts came up in
|
||||
# 12m41s in one job, and exactly one did not (rss-bridge, Gitea #51). That
|
||||
# result was worth having and nobody had planned it. This workflow makes it
|
||||
# repeatable.
|
||||
#
|
||||
# WHEN TO RUN IT: after a change to the runner, to K3S_IMAGE, to the pinned
|
||||
# KUBECTL_VERSION, to the fixtures under ci/ - and after a forge move. Not on a
|
||||
# schedule: it would spend compute every day on a question that changes rarely,
|
||||
# and a red run nobody asked for is a red run nobody reads.
|
||||
name: Install-test (all charts)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
charts:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
description: "charts to test, separated by spaces or commas - empty means every installable chart"
|
||||
jobs:
|
||||
select-charts:
|
||||
name: "Select charts"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
charts: ${{ steps.select.outputs.charts }}
|
||||
count: ${{ steps.select.outputs.count }}
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v7
|
||||
- name: "Select the charts to test"
|
||||
id: select
|
||||
env:
|
||||
# The dispatch input is read as an environment variable and never
|
||||
# interpolated into the script body. A "${{ inputs.charts }}" inside
|
||||
# the shell would let whoever triggers the run write commands into
|
||||
# this step; it is a small door and there is no reason to leave it
|
||||
# open. The name check further down is the second half of that: these
|
||||
# values end up as a matrix entry, a k3d cluster name and an argument
|
||||
# to ci/run-install-test.sh.
|
||||
REQUESTED: ${{ inputs.charts }}
|
||||
run: |
|
||||
FAIL=0
|
||||
|
||||
# --- The candidates -------------------------------------------------
|
||||
# Empty input: every directory with a Chart.yaml, which is the same
|
||||
# definition detect-changed-charts uses for "this is a chart". No list
|
||||
# to maintain - adding a chart enrols it, the way the quantity render
|
||||
# test enrols one.
|
||||
#
|
||||
# A subset was asked for: exactly those names. This is what lets
|
||||
# someone test a single chart against the runner, which is what Gitea
|
||||
# #51 needs - and it is why an unknown or excluded name below is an
|
||||
# error rather than a silent omission. Asking for one chart and
|
||||
# getting a green run over zero charts would be the worst answer this
|
||||
# workflow could give.
|
||||
if [ -z "$(printf '%s' "$REQUESTED" | tr -d '[:space:],')" ]; then
|
||||
SUBSET=false
|
||||
CANDIDATES="$(ls -d -- */Chart.yaml 2>/dev/null | sed 's|/Chart.yaml$||' | sort)"
|
||||
echo "No subset requested - every chart directory is a candidate."
|
||||
else
|
||||
SUBSET=true
|
||||
CANDIDATES="$(printf '%s' "$REQUESTED" | tr ',' ' ' | tr -s '[:space:]' '\n' | grep -v '^$' | sort -u)"
|
||||
echo "Requested subset: $(echo "$CANDIDATES" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
for C in $CANDIDATES; do
|
||||
case "$C" in
|
||||
*[!a-z0-9-]*)
|
||||
echo "::error::'$C' is not a chart name - chart directories in this repository are lowercase letters, digits and dashes."
|
||||
FAIL=1
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
if [ ! -f "$C/Chart.yaml" ]; then
|
||||
echo "::error::'$C' is not a chart directory of this repository (no $C/Chart.yaml)."
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
|
||||
CHARTS="$(printf '%s\n' $CANDIDATES | jq -R . | jq -sc 'unique')"
|
||||
|
||||
# --- The exclusion list, through the one parser ---------------------
|
||||
# ci/run-install-test.sh --list-excluded emits "<chart><TAB><reason>"
|
||||
# and is the only thing in this repository that reads the format of
|
||||
# ci/excluded-charts.txt - here no more than in validate-charts.yml,
|
||||
# because a second parser would be a second chance to select
|
||||
# different charts, silently and in either direction (issue #231).
|
||||
# The comparison is jq's "!=" on whole strings, so a chart name is
|
||||
# never read as a pattern.
|
||||
#
|
||||
# The two callers differ in what they do with a hit, and on purpose.
|
||||
# Over all charts, an excluded chart is dropped and named with its
|
||||
# reason, exactly as in the pull-request pipeline. In a requested
|
||||
# subset it is an ERROR: someone typed that name, and quietly
|
||||
# returning a green run for a chart that was never attempted is the
|
||||
# lie #231 removed from the check list.
|
||||
SUMMARY_ROWS=""
|
||||
while IFS=$'\t' read -r EX_NAME EX_REASON; do
|
||||
[ -n "$EX_NAME" ] || continue
|
||||
if [ "$(echo "$CHARTS" | jq -r --arg c "$EX_NAME" 'index($c) != null')" = "true" ]; then
|
||||
if [ "$SUBSET" = true ]; then
|
||||
echo "::error::chart '$EX_NAME' was requested but is listed in ci/excluded-charts.txt: $EX_REASON"
|
||||
FAIL=1
|
||||
fi
|
||||
# A "|" in the justification would break out of the table cell.
|
||||
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$EX_NAME\` | ${EX_REASON//|/\\|} |"$'\n'
|
||||
echo "Excluded from the install-test: $EX_NAME - $EX_REASON"
|
||||
fi
|
||||
CHARTS="$(echo "$CHARTS" | jq -c --arg c "$EX_NAME" 'map(select(. != $c))')"
|
||||
done <<< "$(ci/run-install-test.sh --list-excluded)"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
|
||||
# An empty matrix does not fail - the install-test job would simply
|
||||
# not be created and the run would end green, having tested nothing.
|
||||
# That is the shape of signal this whole issue is about, so it is a
|
||||
# loud error instead.
|
||||
if [ "$CHARTS" = "[]" ]; then
|
||||
echo "::error::no chart left to test - every candidate is listed in ci/excluded-charts.txt."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COUNT="$(echo "$CHARTS" | jq -r 'length')"
|
||||
echo "charts=$CHARTS" >> "$GITHUB_OUTPUT"
|
||||
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
|
||||
echo "Install-test matrix ($COUNT charts): $CHARTS"
|
||||
|
||||
# --- The run record, first half -------------------------------------
|
||||
# What was asked for, when, and against what. The per-chart outcome
|
||||
# and the environment each leg measured are written by the legs
|
||||
# themselves (the called workflow's "Record the result" step), the
|
||||
# verdict by the record job below. A green run nobody notes is
|
||||
# unknown again next time.
|
||||
{
|
||||
echo "## All-charts install-test"
|
||||
echo
|
||||
echo "- Started: **$(date -u '+%Y-%m-%d %H:%M') UTC**"
|
||||
echo "- Requested: $( [ "$SUBSET" = true ] && echo "subset \`$REQUESTED\`" || echo "**every installable chart**" )"
|
||||
echo "- Matrix: **$COUNT charts** — $(echo "$CHARTS" | jq -r 'map("`" + . + "`") | join(", ")')"
|
||||
echo "- Expected runtime, so a deviation is visible as one: **20 charts in 12m41s**, measured on Gitea PR #48 — one job, all legs concurrently on one Docker daemon. The job timeout is 30 minutes per leg."
|
||||
echo "- One throwaway k3d cluster per chart, no reuse: a reused cluster would carry leftovers, a warm image cache and fixtures already applied, and \"came up from nothing\" is the statement this test exists to make."
|
||||
echo
|
||||
echo "Each chart's outcome, with the runner label, \`K3S_IMAGE\` and \`kubectl\` version it was measured against, is in the install-test job summaries; the verdict over the whole matrix is at the end."
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ -n "$SUMMARY_ROWS" ]; then
|
||||
{
|
||||
echo "### Charts excluded from the install-test"
|
||||
echo
|
||||
echo "These charts are listed in \`ci/excluded-charts.txt\` and get **no** install-test job. There is no green tick for them, because nothing was attempted (issue #231)."
|
||||
echo
|
||||
echo "| Chart | Documented reason |"
|
||||
echo "| --- | --- |"
|
||||
printf '%s' "$SUMMARY_ROWS"
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
# The same workflow validate-charts.yml calls, with the same inputs, differing
|
||||
# only in the chart list. Not a copy of the job: see the header of
|
||||
# .github/workflows/install-test.yml for what would drift.
|
||||
all-charts:
|
||||
name: "All charts"
|
||||
needs: select-charts
|
||||
uses: ./.github/workflows/install-test.yml
|
||||
with:
|
||||
charts: ${{ needs.select-charts.outputs.charts }}
|
||||
secrets: inherit
|
||||
|
||||
# The verdict over the matrix as a whole. A matrix leg only ever sees its own
|
||||
# job summary, so this is the one place that can say "all of them" - and the
|
||||
# aggregate is what the question "has the environment changed" is answered
|
||||
# with. It runs on failure too, hence "if: always()".
|
||||
record:
|
||||
name: "Record the run"
|
||||
needs: [select-charts, all-charts]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Write the verdict"
|
||||
env:
|
||||
RESULT: ${{ needs.all-charts.result }}
|
||||
COUNT: ${{ needs.select-charts.outputs.count }}
|
||||
run: |
|
||||
case "$RESULT" in
|
||||
success)
|
||||
VERDICT="**all $COUNT charts installed and became Ready** on this runner, on $(date -u '+%Y-%m-%d')"
|
||||
;;
|
||||
failure)
|
||||
VERDICT="**at least one chart did not come up.** Which ones, and whether the chart failed or the cluster never came up, is in the per-chart records above. That is a result and not an accident to re-run: rss-bridge failing this way on Gitea PR #48 is why Gitea #51 exists"
|
||||
;;
|
||||
skipped|cancelled)
|
||||
VERDICT="**no verdict** - the matrix did not run ($RESULT). Nothing was tested; do not read this run as a green one"
|
||||
;;
|
||||
*)
|
||||
VERDICT="**no verdict** - unexpected matrix result '$RESULT'"
|
||||
;;
|
||||
esac
|
||||
{
|
||||
echo "## Verdict"
|
||||
echo
|
||||
echo "$VERDICT"
|
||||
echo
|
||||
echo "Worth writing down where it belongs: a result from this run is what the next \"has anything drifted\" question is compared against. If it moved a chart forward or backward, say so on the chart's wiki page or in \`Lessons-Learned\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,414 +0,0 @@
|
||||
# The install-test, as one job with two callers.
|
||||
#
|
||||
# Both callers install a chart the SAME way; they differ only in which charts
|
||||
# they name. validate-charts.yml passes the charts a pull request changed;
|
||||
# install-test-all-charts.yml passes every installable chart on demand, to ask
|
||||
# whether the environment underneath an UNCHANGED chart still holds (Gitea
|
||||
# #52) - a chart untouched since the forge move carries a green history from a
|
||||
# different runner, and that says nothing about today.
|
||||
#
|
||||
# It is a called workflow and not a second job, for the same reason
|
||||
# ci/excluded-charts.txt has exactly one parser: a second copy is a second
|
||||
# chance to test DIFFERENTLY, unnoticed. The parts that would drift are worth
|
||||
# naming, because each was paid for once already - the pinned kubectl with its
|
||||
# skew check, envsubst, the detection of a containerised job, the per-leg
|
||||
# cluster name. A copy that slowly disagrees about any of them does not go red;
|
||||
# it turns a green run into a statement about something other than what was
|
||||
# assumed.
|
||||
#
|
||||
# What stays with the callers: WHICH charts are tested, and whether the job
|
||||
# runs at all (needs/if). Everything that decides HOW one is installed is here.
|
||||
name: Install-test
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
charts:
|
||||
required: true
|
||||
type: string
|
||||
description: 'JSON array of chart names, e.g. ["cyberchef","zipline"]. The entries of ci/excluded-charts.txt must already be gone - see the matrix comment below.'
|
||||
secrets:
|
||||
OP_CONNECT_TOKEN:
|
||||
# Needed by exactly one leg - freesailarr, for the 1Password Connect
|
||||
# lookup of its CI Wireguard key. Declared OPTIONAL on purpose, where
|
||||
# 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
|
||||
# fails, loudly, at the ":?" guards that name the missing op:// field.
|
||||
required: false
|
||||
description: "1Password Connect token; only the freesailarr leg reads it"
|
||||
jobs:
|
||||
install-test:
|
||||
name: "Install-test ${{ matrix.chart }}"
|
||||
# The one job in this repo that does not simply say ubuntu-latest, and the
|
||||
# expression is not decoration - the same label means different machines on
|
||||
# the two forges this repo currently lives on (#225).
|
||||
#
|
||||
# This is the only job that needs a Docker socket: it creates a k3d
|
||||
# cluster, and k3d is a Docker client. On GitHub's hosted runners every
|
||||
# label carries Docker, so the question never came up. On the Gitea
|
||||
# instance it was MEASURED on a probe pull request, per job:
|
||||
# detect-changed-charts, build-chart, the quantity render and the Trivy
|
||||
# scan all pass on `ubuntu-latest` - none of them needs Docker - while the
|
||||
# install-test dies there at "Create k3d cluster" with "Cannot connect to
|
||||
# the Docker daemon at unix:///var/run/docker.sock", an error that points
|
||||
# at k3d and never at the runner label. `ubuntu-docker` is the label that
|
||||
# carries a socket, and Gitea does evaluate this expression: the probe job
|
||||
# was picked up by the docker runner with labels ["ubuntu-docker"].
|
||||
#
|
||||
# Why an expression and not a plain `ubuntu-docker`: that label does not
|
||||
# exist on GitHub, so every chart PR would sit queued forever for as long
|
||||
# as this repo still builds on github.com - blocking the merge with no
|
||||
# error anywhere. github.server_url is the one context value that separates
|
||||
# the two without naming our instance.
|
||||
#
|
||||
# The label is necessary and was, for a while, not sufficient:
|
||||
# `ubuntu-docker`'s daemon reported "Cgroup Driver: none" plus "Running in
|
||||
# rootless-mode without cgroups", and k3d got as far as starting the k3s
|
||||
# node, which restarted immediately with an empty log because k3s needs
|
||||
# cgroup delegation (#225). Nothing in this file addressed that - it was
|
||||
# dealt with on the instance side. It no longer blocks, and the evidence is
|
||||
# Gitea PR #48: 19 of 20 matrix charts installed and became Ready on this
|
||||
# label. The twentieth, rss-bridge, failed and is Gitea #51.
|
||||
runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
|
||||
# Blocking since Renovate automerges patch/minor updates: a chart that no
|
||||
# longer starts must stop the PR before it reaches main and publishes. This
|
||||
# ends the stabilization phase of issue #22 - the job reported failures
|
||||
# without blocking while it proved itself across several chart PRs.
|
||||
#
|
||||
# The 30 minutes are per leg and hold for the all-charts caller too, with
|
||||
# the measured figure to compare against: Gitea PR #48 ran 20 charts in
|
||||
# 12m41s in one job, all legs concurrently on one Docker daemon. If a run
|
||||
# ever comes near this timeout, it is raised HERE, once, for both callers -
|
||||
# a value that differs per caller would mean the two tests give up at
|
||||
# different points while claiming to be the same test.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# The charts arrive from the caller ALREADY filtered against
|
||||
# ci/excluded-charts.txt: validate-charts.yml does it in
|
||||
# detect-changed-charts, install-test-all-charts.yml in select-charts, and
|
||||
# both go through ci/run-install-test.sh --list-excluded, the one parser
|
||||
# of that file. Both also name every chart they dropped WITH its
|
||||
# documented reason in their own job summary, because an absent matrix
|
||||
# entry says "not run" and never why (issue #231).
|
||||
#
|
||||
# DO NOT move that decision in here, e.g. as an "if" on this job
|
||||
# if: ... && !contains(fromJSON(inputs.excluded-charts), matrix.chart)
|
||||
# to get a visible "Skipped" per chart instead of an absent entry.
|
||||
# GitHub rejects it - jobs.<id>.if is evaluated BEFORE the matrix is
|
||||
# expanded, so the matrix context does not exist there:
|
||||
# context "matrix" is not allowed here.
|
||||
# available contexts are "github", "inputs", "needs", "vars".
|
||||
# Measured on run 33650724109 (PR #233, closed): the whole workflow ended
|
||||
# in failure with zero jobs created. The per-chart value has to be gone
|
||||
# from the matrix before this workflow is called.
|
||||
matrix:
|
||||
chart: ${{ fromJSON(inputs.charts) }}
|
||||
env:
|
||||
K3D_VERSION: v5.9.0
|
||||
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
|
||||
# Pinned rather than taken from the runner image. The argument is not
|
||||
# "Gitea's image has no kubectl" - that is only how it was noticed. A
|
||||
# kubectl inherited from whatever the image happens to ship drifts
|
||||
# unannounced with every image rebuild, and an install-test is supposed
|
||||
# to run against a KNOWN client. That GitHub's hosted image already
|
||||
# carries one does not make the pin unnecessary; it only makes the drift
|
||||
# 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.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
|
||||
# (RUNNER_NAME below names the machine, not the label that picked it).
|
||||
# It exists for the run record at the end of this job: "on which runner
|
||||
# was this green tick measured" is the whole question of Gitea #52. Kept
|
||||
# right under runs-on, so a change to one that forgets the other is
|
||||
# visible on a single screen.
|
||||
RUNNER_LABEL: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@v5
|
||||
- name: "Install kubectl"
|
||||
run: |
|
||||
# kubectl and the cluster are a pair: the client talks to the server
|
||||
# K3S_IMAGE starts, and Kubernetes supports at most one minor of
|
||||
# skew between them. Renovate updates KUBECTL_VERSION on its own
|
||||
# while K3S_IMAGE is tracked by nothing, so without this check the
|
||||
# two drift apart until the client is talking to a server it no
|
||||
# longer supports - silently, because kubectl only warns. And it is
|
||||
# not theoretical: measured by dry run, a lookup from v1.34.0
|
||||
# offers v1.37.0 in ONE step, because Renovate fills the non-major
|
||||
# bucket with the newest candidate rather than walking the minors.
|
||||
# So this check WILL fire one day, on purpose, with a message that
|
||||
# says which of the two pins to move.
|
||||
#
|
||||
# Deliberately not solved with an allowedVersions constraint pinning
|
||||
# kubectl to the k3s minor: that is the nginx trap the README
|
||||
# describes - a filter that silently stops offering updates the day
|
||||
# the other side moves. A loud failure beats a quiet freeze.
|
||||
read -r KMAJ KMIN <<<"$(printf '%s' "$KUBECTL_VERSION" | sed -E 's/^v([0-9]+)\.([0-9]+).*/\1 \2/')"
|
||||
read -r SMAJ SMIN <<<"$(printf '%s' "$K3S_IMAGE" | sed -E 's/.*:v([0-9]+)\.([0-9]+).*/\1 \2/')"
|
||||
SKEW=$(( (KMAJ * 100 + KMIN) - (SMAJ * 100 + SMIN) ))
|
||||
if [ "${SKEW#-}" -gt 1 ]; then
|
||||
echo "::error::kubectl $KUBECTL_VERSION and k3s $K3S_IMAGE are more than one minor apart. Bump K3S_IMAGE to match, or pin KUBECTL_VERSION back."
|
||||
exit 1
|
||||
fi
|
||||
curl -sSfL -o /tmp/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/kubectl" version --client
|
||||
- name: "Provide envsubst"
|
||||
run: |
|
||||
# ci/run-install-test.sh renders ci/<chart>/fixtures.env.yaml with
|
||||
# envsubst - today only freesailarr has one, and without the tool
|
||||
# the fixtures collapse to "error: no objects passed to apply",
|
||||
# gluetun never gets its Wireguard key, and nothing behind its kill
|
||||
# switch becomes Ready. GitHub's hosted image ships gettext-base,
|
||||
# the Gitea runner image does not. The script is fine as it is; a
|
||||
# standard tool is simply missing.
|
||||
#
|
||||
# CHECKED FOR, NOT PINNED - and that is a different decision from
|
||||
# kubectl above, on purpose. kubectl's VERSION changes what the test
|
||||
# means: its distance to the API server is a real source of failure,
|
||||
# which is why it is pinned, tracked and guarded. envsubst
|
||||
# substitutes text; which release performs the substitution does not
|
||||
# change the result. Pinning it would buy nothing and add a line for
|
||||
# Renovate to carry. So: present - do nothing, which keeps the
|
||||
# GitHub path free of any package install; missing - fetch it, and
|
||||
# the difference between the two runners is stated rather than
|
||||
# hidden behind an unconditional apt-get.
|
||||
if command -v envsubst >/dev/null 2>&1; then
|
||||
echo "envsubst present at $(command -v envsubst) - nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
echo "envsubst missing on this runner image - installing gettext-base"
|
||||
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq --no-install-recommends gettext-base
|
||||
command -v envsubst
|
||||
# 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
|
||||
# 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
|
||||
# cause in a pull request. Loaded only for this chart; no other chart run
|
||||
# touches the VPN key.
|
||||
- name: "Configure OP-Connect"
|
||||
if: matrix.chart == 'freesailarr'
|
||||
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@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 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
|
||||
# the values are mapped into each step's own env block via
|
||||
# steps.<id>.outputs.* and read as quoted shell variables. The ":?" guards
|
||||
# run before the cluster is even created and name the op:// field that is
|
||||
# missing - an empty key would otherwise reach gluetun and look like a
|
||||
# broken chart instead of a missing secret. They print the variable name,
|
||||
# never its value.
|
||||
- name: "Verify the VPN credentials arrived"
|
||||
if: matrix.chart == 'freesailarr'
|
||||
env:
|
||||
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
|
||||
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
|
||||
run: |-
|
||||
: "${FREESAILARR_WG_KEY:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/credential}"
|
||||
: "${FREESAILARR_WG_ADDRESSES:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address}"
|
||||
echo "Both VPN values arrived non-empty."
|
||||
- name: "Install k3d"
|
||||
run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash
|
||||
# One cluster per matrix leg, named after the chart. It used to be the
|
||||
# fixed name "install-test", and that was wrong on GitHub too - it just
|
||||
# could not show there, because GitHub gives every matrix leg its own VM
|
||||
# with its own Docker daemon, so two legs never saw each other's
|
||||
# cluster. The environment hid the bug for two years rather than
|
||||
# preventing it. Gitea runs all legs of a matrix CONCURRENTLY in ONE job
|
||||
# on ONE daemon, and there the fixed name collides at once: measured on
|
||||
# the probe PR of #225, one leg logs "Re-using existing network
|
||||
# 'k3d-install-test'", the other dies on "The container name
|
||||
# /k3d-install-test-server-0 is already in use" - and then the first
|
||||
# leg's rollback deletes the shared network out from under the second,
|
||||
# which fails with "network k3d-install-test not found". Both legs red,
|
||||
# neither cause in either message. DO NOT put the fixed name back.
|
||||
#
|
||||
# Why the bare chart name and not a prefixed one: k3d rejects a cluster
|
||||
# name over 32 characters ("Cluster name must be <= 32 characters",
|
||||
# measured against k3d v5.9.0 - 32 passes, 33 fails), and every
|
||||
# container is named k3d-<cluster>-server-0 from it. The longest chart
|
||||
# here is calibre-web-automated at 21, so "install-test-<chart>" would
|
||||
# be 34 and break exactly that one chart and no other - the kind of
|
||||
# prefix that is worse than the fixed name because it only sometimes
|
||||
# breaks. The guard below states the limit instead of leaving it to be
|
||||
# rediscovered.
|
||||
- name: "Create k3d cluster"
|
||||
run: |
|
||||
CLUSTER="${{ matrix.chart }}"
|
||||
if [ "${#CLUSTER}" -gt 32 ]; then
|
||||
echo "::error::chart name '$CLUSTER' is ${#CLUSTER} characters and k3d allows at most 32 for a cluster name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Does THIS job run inside a container? That, and not which forge we
|
||||
# are on, is what decides whether k3d's cluster is reachable (#227).
|
||||
#
|
||||
# k3d publishes the API server port on the Docker *host*. When the
|
||||
# job runs straight on the host - GitHub's hosted runners - that is
|
||||
# the same machine, and the "https://0.0.0.0:<port>" k3d writes into
|
||||
# the kubeconfig works. When the job itself is a container, as every
|
||||
# Gitea Actions job is, the host is the dind container and 0.0.0.0
|
||||
# points at the job container itself, where nothing listens. The
|
||||
# cluster comes up fine and the first kubectl call is refused.
|
||||
#
|
||||
# Deliberately NOT keyed on github.server_url, although that would
|
||||
# pick the right branch today: a self-hosted GitHub runner in a
|
||||
# container has exactly this problem, and a condition that says
|
||||
# "GitHub" while meaning "not containerised" is the kind of lie this
|
||||
# repo exists to remove. So the state is detected instead of
|
||||
# inferred, and the detection is the same question the fix needs
|
||||
# answered anyway: ask the daemon about ourselves. Inside a
|
||||
# container that the daemon knows, this returns our network and k3d
|
||||
# joins it, which puts k3s and this job on one bridge where Docker's
|
||||
# own DNS resolves the container names. On a host it returns nothing,
|
||||
# because the hostname is not a container, and the old path is taken
|
||||
# unchanged.
|
||||
# The "|| true" is load-bearing: this step runs under `bash -e -o
|
||||
# pipefail`, and on a host `docker inspect` exits non-zero, which
|
||||
# would abort the step instead of taking the else branch.
|
||||
JOB_NET="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$(hostname)" 2>/dev/null | awk '{print $1}' || true)"
|
||||
|
||||
if [ -n "$JOB_NET" ]; then
|
||||
echo "Containerised job: $(hostname) on docker network '$JOB_NET' - k3d joins it."
|
||||
k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --network "$JOB_NET" --wait --timeout 180s
|
||||
# Same reason: the published port is useless from in here, so the
|
||||
# kubeconfig is pointed at the load balancer container by name,
|
||||
# over the shared network. If the API server certificate did not
|
||||
# carry that name this would fail on the TLS handshake and the
|
||||
# cluster-info below would say so - it holds without an extra
|
||||
# --tls-san, measured on the probe PR of #225.
|
||||
kubectl config set-cluster "k3d-$CLUSTER" --server="https://k3d-$CLUSTER-serverlb:6443"
|
||||
else
|
||||
echo "Job runs on the docker host - k3d's published port is reachable as written."
|
||||
k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s
|
||||
fi
|
||||
|
||||
# Reachability is proven HERE, and the shape of this check is the
|
||||
# product of getting it wrong twice. Both mistakes are written down
|
||||
# because both look like something to simplify away.
|
||||
#
|
||||
# It is a LOOP and not a single call. k3d's --wait returns when the
|
||||
# node container reports ready, but the API server can still answer
|
||||
# discovery with 503 for a moment after that. Measured: a single
|
||||
# `kubectl cluster-info` right here failed with "ServiceUnavailable:
|
||||
# the server is currently unable to handle the request" on one of
|
||||
# two legs of the SAME GitHub run while the other passed. A check
|
||||
# that is itself racy produces exactly the sporadic red this job
|
||||
# must never have (#227) - and a test people have learned to re-run
|
||||
# is one they have stopped believing.
|
||||
#
|
||||
# It counts READY NODES and not "did kubectl answer". `kubectl get
|
||||
# nodes` exits 0 on an EMPTY list, so the first version passed
|
||||
# against a cluster whose API server was up and whose node had never
|
||||
# registered: the log said "No resources found", the step went
|
||||
# green, and the failure surfaced five minutes later as a pod stuck
|
||||
# in Pending with no node and no scheduling event - nowhere near its
|
||||
# cause. A check that a broken cluster passes is worse than no
|
||||
# check. `kubectl wait --for=condition=Ready nodes --all` is not the
|
||||
# shorter way to write this: it has the identical hole, because with
|
||||
# zero nodes the condition is vacuously satisfied.
|
||||
#
|
||||
# The evidence that this version earns its keep: only after it
|
||||
# counted nodes did the real cause become visible in the dump below
|
||||
# - k3s could not start containerd ("error creating fsnotify
|
||||
# watcher: too many open files", an inotify limit on the host), and
|
||||
# nothing above this line had ever said so.
|
||||
for ATTEMPT in $(seq 1 90); do
|
||||
READY_NODES="$(kubectl get nodes --no-headers 2>/dev/null | awk '$2 == "Ready"' | wc -l | tr -d ' ')"
|
||||
[ "$READY_NODES" -ge 1 ] && break
|
||||
if [ "$ATTEMPT" -eq 90 ]; then
|
||||
echo "::error::the k3d cluster '$CLUSTER' answers but has no Ready node after 180s"
|
||||
kubectl get nodes -o wide || true
|
||||
kubectl describe nodes || true
|
||||
echo "--- k3s server log ---"
|
||||
docker logs "k3d-$CLUSTER-server-0" 2>&1 | tail -80 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
kubectl get nodes -o wide
|
||||
# Every chart reaching this step is one the exclusion filter let through,
|
||||
# so the script's SKIP branch is never taken here - it exists for local
|
||||
# runs. The variables below are empty for every chart but the one that
|
||||
# declares them in ci/<chart>/settings.env (REQUIRED_ENV); the script
|
||||
# fails loudly if a declared one is missing, it never skips over it.
|
||||
- name: "Install chart and wait for readiness"
|
||||
# The id is read by the run record at the end of this job: its outcome
|
||||
# is what separates "the chart did not come up" from "the cluster never
|
||||
# came up", and the two mean opposite things for Gitea #52.
|
||||
id: install
|
||||
env:
|
||||
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
|
||||
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
|
||||
run: ci/run-install-test.sh "${{ matrix.chart }}"
|
||||
# Must carry the same per-leg name as the create step above: a blanket
|
||||
# cleanup, or the old fixed name, would tear down the neighbouring leg's
|
||||
# cluster on a runner that shares one Docker daemon - which is precisely
|
||||
# the half of the collision that turned one red leg into two.
|
||||
- name: "Delete k3d cluster"
|
||||
if: always()
|
||||
run: k3d cluster delete "${{ matrix.chart }}" || true
|
||||
# The run record (Gitea #52). A green install-test says "this chart came
|
||||
# up" and nothing about WHERE, and where is exactly what the question
|
||||
# needs: a chart that has not been touched since the forge move carries a
|
||||
# green history from another environment. So every leg writes down the
|
||||
# four facts that decide what its result means - date, runner label,
|
||||
# K3S_IMAGE, kubectl - next to its own outcome. A green run nobody notes
|
||||
# is unknown again next time.
|
||||
#
|
||||
# Per leg, and not one table for the whole matrix, because there is no
|
||||
# place to write one: a matrix leg only ever sees its own
|
||||
# GITHUB_STEP_SUMMARY, on either forge. The aggregate verdict is written
|
||||
# by the calling workflow, which does see the matrix as a whole.
|
||||
#
|
||||
# It reports steps.install.outcome and not the job status: a leg that died
|
||||
# in "Create k3d cluster" never installed anything, and recording that as
|
||||
# a failed chart would blame the chart for the environment - precisely the
|
||||
# confusion this issue is about.
|
||||
- name: "Record the result"
|
||||
if: always()
|
||||
env:
|
||||
CHART: ${{ matrix.chart }}
|
||||
INSTALL_OUTCOME: ${{ steps.install.outcome }}
|
||||
run: |
|
||||
case "$INSTALL_OUTCOME" in
|
||||
success) RESULT="installed, every workload Ready" ;;
|
||||
failure) RESULT="**FAILED** - the chart did not come up" ;;
|
||||
*) RESULT="**FAILED before the install** - cluster or tooling, not the chart (install step outcome: '${INSTALL_OUTCOME:-none}')" ;;
|
||||
esac
|
||||
{
|
||||
echo "### Install-test \`$CHART\`: $RESULT"
|
||||
echo
|
||||
echo "| Date (UTC) | Runner label | Runner name | K3S_IMAGE | kubectl |"
|
||||
echo "| --- | --- | --- | --- | --- |"
|
||||
echo "| $(date -u '+%Y-%m-%d %H:%M') | \`$RUNNER_LABEL\` | \`${RUNNER_NAME:-unknown}\` | \`$K3S_IMAGE\` | \`$KUBECTL_VERSION\` |"
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,15 +0,0 @@
|
||||
name: Publish Sure Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- sure/**
|
||||
jobs:
|
||||
publish-sure:
|
||||
name: Publish Sure Helm Chart
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'sure'
|
||||
secrets: inherit
|
||||
@@ -1,15 +0,0 @@
|
||||
name: Publish Valkey Helm Chart
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- valkey/**
|
||||
jobs:
|
||||
publish-valkey:
|
||||
name: Publish Valkey Helm Chart
|
||||
uses: ./.github/workflows/publish-helm-chart.yml
|
||||
with:
|
||||
chart-name: 'valkey'
|
||||
secrets: inherit
|
||||
@@ -157,9 +157,8 @@ jobs:
|
||||
# against - resource quantities formatted with printf "%v", where float64
|
||||
# flips to exponential notation above ~1e6 - lives in the resources helper,
|
||||
# and that helper is copied into every chart's templates/_helpers.tpl,
|
||||
# because sharing it would mean a library chart (README, guideline 14). So
|
||||
# it comes back by copy: a new chart started from an older one, or from
|
||||
# template-chart before the fix.
|
||||
# because charts here have no dependencies. So it comes back by copy: a new
|
||||
# chart started from an older one, or from template-chart before the fix.
|
||||
# In that PR the chart is new and its copy of the bug is in no diff anyone
|
||||
# reads. Running over everything also means there is no list to maintain -
|
||||
# adding a chart enrolls it. Two renders per chart, no cluster, a few
|
||||
@@ -228,30 +227,198 @@ jobs:
|
||||
run: ci/run-trivy-config.sh "${{ matrix.chart }}"
|
||||
|
||||
install-test:
|
||||
name: "Install-test"
|
||||
name: "Install-test ${{ matrix.chart }}"
|
||||
needs: [detect-changed-charts, build-chart]
|
||||
if: needs.detect-changed-charts.outputs.any-install == 'true'
|
||||
# The job itself lives in .github/workflows/install-test.yml and is called
|
||||
# from here and from install-test-all-charts.yml (Gitea #52). Everything
|
||||
# that decides HOW a chart is installed moved there unchanged - the runner
|
||||
# label expression, the pinned kubectl with its skew check, envsubst, the
|
||||
# containerised-job detection, the per-leg cluster name, the freesailarr VPN
|
||||
# credentials. What stayed here is WHICH charts are tested and whether the
|
||||
# job runs at all. A copied job would be a second chance to test
|
||||
# differently, unnoticed, which is the same argument that gives
|
||||
# ci/excluded-charts.txt exactly one parser.
|
||||
# The one job in this repo that does not simply say ubuntu-latest, and the
|
||||
# expression is not decoration - the same label means different machines on
|
||||
# the two forges this repo currently lives on (#225).
|
||||
#
|
||||
# "install-charts", not "charts": the changed charts minus the ones in
|
||||
# ci/excluded-charts.txt, filtered in detect-changed-charts, which also
|
||||
# names every excluded chart of this pull request with its documented
|
||||
# reason in its job summary (issue #231). The called workflow's matrix
|
||||
# comment carries the rest of that lesson, including why the decision
|
||||
# cannot be made per matrix leg instead.
|
||||
uses: ./.github/workflows/install-test.yml
|
||||
with:
|
||||
charts: ${{ needs.detect-changed-charts.outputs.install-charts }}
|
||||
# As the 24 publish-<chart>.yml hulls do, and measured to work on this
|
||||
# instance (README, "Where this repo lives"). The called workflow declares
|
||||
# exactly one secret, OP_CONNECT_TOKEN, and only its freesailarr leg reads
|
||||
# it.
|
||||
secrets: inherit
|
||||
# This is the only job that needs a Docker socket: it creates a k3d
|
||||
# cluster, and k3d is a Docker client. On GitHub's hosted runners every
|
||||
# label carries Docker, so the question never came up. On the Gitea
|
||||
# instance it was MEASURED on a probe pull request, per job:
|
||||
# detect-changed-charts, build-chart, the quantity render and the Trivy
|
||||
# scan all pass on `ubuntu-latest` - none of them needs Docker - while the
|
||||
# install-test dies there at "Create k3d cluster" with "Cannot connect to
|
||||
# the Docker daemon at unix:///var/run/docker.sock", an error that points
|
||||
# at k3d and never at the runner label. `ubuntu-docker` is the label that
|
||||
# carries a socket, and Gitea does evaluate this expression: the probe job
|
||||
# was picked up by the docker runner with labels ["ubuntu-docker"].
|
||||
#
|
||||
# Why an expression and not a plain `ubuntu-docker`: that label does not
|
||||
# exist on GitHub, so every chart PR would sit queued forever for as long
|
||||
# as this repo still builds on github.com - blocking the merge with no
|
||||
# error anywhere. github.server_url is the one context value that separates
|
||||
# the two without naming our instance.
|
||||
#
|
||||
# KNOWN, and not fixed by this line: `ubuntu-docker`'s daemon is ROOTLESS
|
||||
# and reports "Cgroup Driver: none" plus "Running in rootless-mode without
|
||||
# cgroups". k3d then gets as far as starting the k3s node, which restarts
|
||||
# immediately with an empty log, because k3s needs cgroup delegation. Until
|
||||
# the instance's Docker-in-Docker provides it, this job cannot pass on
|
||||
# Gitea - see #225. The label is necessary, it is just not sufficient.
|
||||
runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }}
|
||||
# Blocking since Renovate automerges patch/minor updates: a chart that no
|
||||
# longer starts must stop the PR before it reaches main and publishes. This
|
||||
# ends the stabilization phase of issue #22 - the job reported failures
|
||||
# without blocking while it proved itself across several chart PRs.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# "install-charts", not "charts": the changed charts minus the ones in
|
||||
# ci/excluded-charts.txt, filtered in detect-changed-charts. Excluded
|
||||
# charts are named with their reason in that job's summary (issue #231).
|
||||
#
|
||||
# DO NOT try to move that decision into an "if" on this job, e.g.
|
||||
# if: ... && !contains(fromJSON(needs.detect-changed-charts.outputs.excluded-charts), matrix.chart)
|
||||
# to get a visible "Skipped" per chart instead of an absent entry.
|
||||
# GitHub rejects it - jobs.<id>.if is evaluated BEFORE the matrix is
|
||||
# expanded, so the matrix context does not exist there:
|
||||
# context "matrix" is not allowed here.
|
||||
# available contexts are "github", "inputs", "needs", "vars".
|
||||
# Measured on run 33650724109 (PR #233, closed): the whole workflow ended
|
||||
# in failure with zero jobs created. The per-chart value has to be gone
|
||||
# from the matrix by the time this job is reached.
|
||||
matrix:
|
||||
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }}
|
||||
env:
|
||||
K3D_VERSION: v5.9.0
|
||||
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
|
||||
# Pinned rather than taken from the runner image. The argument is not
|
||||
# "Gitea's image has no kubectl" - that is only how it was noticed. A
|
||||
# kubectl inherited from whatever the image happens to ship drifts
|
||||
# unannounced with every image rebuild, and an install-test is supposed
|
||||
# to run against a KNOWN client. That GitHub's hosted image already
|
||||
# carries one does not make the pin unnecessary; it only makes the drift
|
||||
# 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.35.5
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@v5
|
||||
- name: "Install kubectl"
|
||||
run: |
|
||||
# kubectl and the cluster are a pair: the client talks to the server
|
||||
# K3S_IMAGE starts, and Kubernetes supports at most one minor of
|
||||
# skew between them. Renovate updates KUBECTL_VERSION on its own
|
||||
# while K3S_IMAGE is tracked by nothing, so without this check the
|
||||
# two drift apart until the client is talking to a server it no
|
||||
# longer supports - silently, because kubectl only warns. And it is
|
||||
# not theoretical: measured by dry run, a lookup from v1.34.0
|
||||
# offers v1.37.0 in ONE step, because Renovate fills the non-major
|
||||
# bucket with the newest candidate rather than walking the minors.
|
||||
# So this check WILL fire one day, on purpose, with a message that
|
||||
# says which of the two pins to move.
|
||||
#
|
||||
# Deliberately not solved with an allowedVersions constraint pinning
|
||||
# kubectl to the k3s minor: that is the nginx trap the README
|
||||
# describes - a filter that silently stops offering updates the day
|
||||
# the other side moves. A loud failure beats a quiet freeze.
|
||||
read -r KMAJ KMIN <<<"$(printf '%s' "$KUBECTL_VERSION" | sed -E 's/^v([0-9]+)\.([0-9]+).*/\1 \2/')"
|
||||
read -r SMAJ SMIN <<<"$(printf '%s' "$K3S_IMAGE" | sed -E 's/.*:v([0-9]+)\.([0-9]+).*/\1 \2/')"
|
||||
SKEW=$(( (KMAJ * 100 + KMIN) - (SMAJ * 100 + SMIN) ))
|
||||
if [ "${SKEW#-}" -gt 1 ]; then
|
||||
echo "::error::kubectl $KUBECTL_VERSION and k3s $K3S_IMAGE are more than one minor apart. Bump K3S_IMAGE to match, or pin KUBECTL_VERSION back."
|
||||
exit 1
|
||||
fi
|
||||
curl -sSfL -o /tmp/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/kubectl" version --client
|
||||
# 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
|
||||
# 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
|
||||
# cause in a pull request. Loaded only for this chart; no other chart run
|
||||
# touches the VPN key.
|
||||
- name: "Configure OP-Connect"
|
||||
if: matrix.chart == 'freesailarr'
|
||||
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@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 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
|
||||
# the values are mapped into each step's own env block via
|
||||
# steps.<id>.outputs.* and read as quoted shell variables. The ":?" guards
|
||||
# run before the cluster is even created and name the op:// field that is
|
||||
# missing - an empty key would otherwise reach gluetun and look like a
|
||||
# broken chart instead of a missing secret. They print the variable name,
|
||||
# never its value.
|
||||
- name: "Verify the VPN credentials arrived"
|
||||
if: matrix.chart == 'freesailarr'
|
||||
env:
|
||||
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
|
||||
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
|
||||
run: |-
|
||||
: "${FREESAILARR_WG_KEY:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/credential}"
|
||||
: "${FREESAILARR_WG_ADDRESSES:?empty - check op://GitHub-Actions/freesailarr-mullvad-test-config/ip-address}"
|
||||
echo "Both VPN values arrived non-empty."
|
||||
- name: "Install k3d"
|
||||
run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash
|
||||
# One cluster per matrix leg, named after the chart. It used to be the
|
||||
# fixed name "install-test", and that was wrong on GitHub too - it just
|
||||
# could not show there, because GitHub gives every matrix leg its own VM
|
||||
# with its own Docker daemon, so two legs never saw each other's
|
||||
# cluster. The environment hid the bug for two years rather than
|
||||
# preventing it. Gitea runs all legs of a matrix CONCURRENTLY in ONE job
|
||||
# on ONE daemon, and there the fixed name collides at once: measured on
|
||||
# the probe PR of #225, one leg logs "Re-using existing network
|
||||
# 'k3d-install-test'", the other dies on "The container name
|
||||
# /k3d-install-test-server-0 is already in use" - and then the first
|
||||
# leg's rollback deletes the shared network out from under the second,
|
||||
# which fails with "network k3d-install-test not found". Both legs red,
|
||||
# neither cause in either message. DO NOT put the fixed name back.
|
||||
#
|
||||
# Why the bare chart name and not a prefixed one: k3d rejects a cluster
|
||||
# name over 32 characters ("Cluster name must be <= 32 characters",
|
||||
# measured against k3d v5.9.0 - 32 passes, 33 fails), and every
|
||||
# container is named k3d-<cluster>-server-0 from it. The longest chart
|
||||
# here is calibre-web-automated at 21, so "install-test-<chart>" would
|
||||
# be 34 and break exactly that one chart and no other - the kind of
|
||||
# prefix that is worse than the fixed name because it only sometimes
|
||||
# breaks. The guard below states the limit instead of leaving it to be
|
||||
# rediscovered.
|
||||
- name: "Create k3d cluster"
|
||||
run: |
|
||||
CLUSTER="${{ matrix.chart }}"
|
||||
if [ "${#CLUSTER}" -gt 32 ]; then
|
||||
echo "::error::chart name '$CLUSTER' is ${#CLUSTER} characters and k3d allows at most 32 for a cluster name."
|
||||
exit 1
|
||||
fi
|
||||
k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s
|
||||
# Every chart reaching this step is one the exclusion filter let through,
|
||||
# so the script's SKIP branch is never taken here - it exists for local
|
||||
# runs. The variables below are empty for every chart but the one that
|
||||
# declares them in ci/<chart>/settings.env (REQUIRED_ENV); the script
|
||||
# fails loudly if a declared one is missing, it never skips over it.
|
||||
- name: "Install chart and wait for readiness"
|
||||
env:
|
||||
FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }}
|
||||
FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }}
|
||||
run: ci/run-install-test.sh "${{ matrix.chart }}"
|
||||
# Must carry the same per-leg name as the create step above: a blanket
|
||||
# cleanup, or the old fixed name, would tear down the neighbouring leg's
|
||||
# cluster on a runner that shares one Docker daemon - which is precisely
|
||||
# the half of the collision that turned one red leg into two.
|
||||
- name: "Delete k3d cluster"
|
||||
if: always()
|
||||
run: k3d cluster delete "${{ matrix.chart }}" || true
|
||||
@@ -10,71 +10,35 @@ The main Claude session acts as the **task manager** and does not implement issu
|
||||
2. **Document the final plan on the issue**: Once approved, the plan is posted as a comment on the issue before implementation starts (skip if the issue body already contains the full plan; later decisions go there as comments too).
|
||||
3. **Delegate implementation**: After approval, the manager launches a dedicated **development agent**. The agent implements the issue completely on its own branch and opens a PR.
|
||||
4. **PR comment handling by the agent**: After opening the PR, the development agent watches it for review comments. New comments are addressed automatically (code changes, reply on the thread), and addressed threads are marked as **resolved**.
|
||||
5. **Update the wiki — mandatory, and before the merge is requested.** The wiki is this repo's knowledge base; a change that lands in `main` while the wiki still describes the old state has not been finished, it has been half-finished in a way nothing fails on. The development agent edits the affected pages as part of its issue, so code and documentation are reviewed together. See "The wiki" below for what obliges an edit and what does not.
|
||||
6. **Agent lifetime**: The development agent terminates itself once its PR has been merged (or closed).
|
||||
7. Only after the PR has been **merged by the user** may work on the next issue begin. The user merges; never merge yourself.
|
||||
8. **After the merge, verify the linked issue was closed** (auto-close via "Fixes #n"); close it manually if not. Gitea auto-closes on the same keywords, but only for an issue in the same repository — a migrated issue whose number changed is closed by hand. Check in the same step that the wiki edit really landed.
|
||||
5. **Agent lifetime**: The development agent terminates itself once its PR has been merged (or closed).
|
||||
6. Only after the PR has been **merged by the user** may work on the next issue begin. The user merges; never merge yourself.
|
||||
7. **After the merge, verify the linked issue was closed** (auto-close via "Fixes #n"); close it manually if not. Gitea auto-closes on the same keywords, but only for an issue in the same repository — a migrated issue whose number changed is closed by hand.
|
||||
|
||||
Additional rules for all commits, PRs, issues, and comments:
|
||||
|
||||
- Do **not** add a `Co-Authored-By` trailer and do **not** append "Generated with Claude Code" footers.
|
||||
- **Commits must be signed**, and in the Coder workspace they are: signing runs through its own SSH signer (`gpg.format = ssh`, `gpg.ssh.program = coder-git-ssh-sign`, `commit.gpgsign = true`), so no 1Password step is involved. Verify before opening the PR — `git log --format='%G?' -1` must print `G` — and if signing fails, stop and report rather than pushing unsigned work, because branch protection rejects it at merge time.
|
||||
- **A PR that does not finish its issue writes `Part of #n`, never `Fixes #n`.** The keyword closes the issue on merge, so a `Fixes` on a PR that leaves work behind marks an open task as done — and reopening by hand is the only repair, which only happens if someone remembers to look. This is not hypothetical: Gitea PR #48 carried `Fixes #40` while `rss-bridge` had been deliberately left out of the sweep, and the merge closed Gitea #40 with one occurrence still open. Step 8 above is the other half of the same rule: the auto-close it verifies is only the right outcome when the keyword was the right one.
|
||||
|
||||
## The wiki
|
||||
|
||||
The knowledge base is the Gitea wiki at `https://git.jk-dev.de/jlab-cluster/helm-charts/wiki` — one page per chart (installation, required 1Password fields, every configurable value, the chart's traps) plus the long form of this workflow, the pipelines, the style guide and the incidents each rule was bought with. It is a **separate git repository**:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.jk-dev.de:30022/jlab-cluster/helm-charts.wiki.git
|
||||
```
|
||||
|
||||
Its default branch is `main`, not `master`.
|
||||
|
||||
Flat layout, one Markdown file per page, page name = file name, links are relative page names without the extension (`[CI/CD](CI-CD)`). A new page must also be listed in `_Sidebar.md`, or it is reachable only by search.
|
||||
|
||||
**There is no PR flow here, so fetch and rebase before every push and never force-push** — two agents have already edited the same page minutes apart, and a force push deletes the other edit silently, with nothing left to report it.
|
||||
|
||||
**What obliges a wiki edit** (step 5 of the workflow above, same round of work, never a follow-up issue):
|
||||
|
||||
| Change | Page |
|
||||
| --- | --- |
|
||||
| a value added, removed, renamed, or its default changed | the chart's page — values table, and the operating notes if behaviour moved |
|
||||
| runtime behaviour changed (probes, hardening, restart semantics, a value becoming required) | the chart's page, plus `Lessons-Learned` if the reason could be repeated elsewhere |
|
||||
| a new chart | a new chart page, a row in `Charts`, an entry in `_Sidebar.md` |
|
||||
| a convention added or changed | `Chart-Style-Guide` (and README, which stays the code-side copy) |
|
||||
| pipeline, fixture or runner behaviour changed | `CI-CD` |
|
||||
| this issue workflow changed | `Workflow` — **and** this file, which is the binding copy |
|
||||
| a Renovate rule added or changed | `Renovate` |
|
||||
| something broke in a way a rule now prevents | `Lessons-Learned` |
|
||||
|
||||
**What does not oblige an edit:** a plain app-version bump. Renovate automerges those continuously and never touches the wiki, which is why chart pages carry no version numbers at all and name `Chart.yaml` as the source of truth. An app update that *does* change values or behaviour is not a plain bump, and the table applies.
|
||||
|
||||
Wiki commits follow the same rules as repo commits: no `Co-Authored-By`, no "Generated with Claude Code" footer.
|
||||
- **Commits must be signed.** Verify the signature before opening the PR; if `op-ssh-sign` fails, stop and report rather than pushing unsigned work.
|
||||
|
||||
## Which forge, and with which tool
|
||||
|
||||
Everything runs on the Gitea instance JLab-Git: issues, pull requests, CI and Renovate. github.com is a push mirror with Actions and Renovate **switched off**, and it lags until the mirror runs. README, "Where this repo lives", holds the measured differences between a Gitea runner and a GitHub one.
|
||||
The repository is moving from github.com to the Gitea instance JLab-Git (#225). The move is **not finished**, so both are live and the steps below differ by where the work happens. README, "Where this repo lives", holds the measured differences between the two CIs; this section holds only what changes about the *workflow*.
|
||||
|
||||
- **`tea`, not `gh`.** Work happens in a **Coder workspace, and it has no `op`** — that is the normal case here, not a broken environment, so do not wait for the 1Password CLI or propose installing it. Call `tea` directly; it carries the workspace's stored login and opens PRs and issues under the **bot account**, which is accepted. Say so in the report when something is authored that way. (`op plugin run -- tea`, the older instruction, applies only where `op` actually exists.)
|
||||
- **The remote is `origin`, and it points at Gitea** (`ssh://git@git.jk-dev.de:30022/jlab-cluster/helm-charts.git`). The checkout has exactly that one, and so does every worktree cut from it, so a bare `git push` goes to the leading copy; the github.com mirror is fed by Gitea itself and is never pushed to by hand. A clone elsewhere may carry the mirror as a second remote named `github` — wherever more than one exists, say which one you mean.
|
||||
- **Cut every worktree from a freshly fetched `origin/main`.** `git fetch origin main` first, then branch from `origin/main` — never from the local `main` ref, and never assume the shared checkout is current: both can be many merges behind. The staleness is invisible where it matters most, and it outlives the fix: the project-instructions block a subagent receives is a **snapshot** of the spawning session's working directory, taken when **that session started** — not a live read. Fetching mid-session does not refresh it, so the file on disk can be correct while every agent started from then on is still handed the superseded rules. Measured on 2026-09-27: an agent whose own worktree was identical to `origin/main` (`git rev-list --left-right --count origin/main...HEAD` returned `0 0`) received a block from before Gitea #47 and reported a contradiction in this file that two PRs had already fixed, quoting the stale copy as binding. Two consequences, and only these two help: the spawning session's directory must be current **before** the session starts, and an agent checks a rule against the **file in its own worktree** plus a diff against `origin/main` — never against `main`, and never against its own context block, because the first consequence cannot be enforced from inside an agent and the second can.
|
||||
- **Branch protection with a signature requirement is active**, and signatures verify on Gitea. A commit that is not signed does not reach `main` — check before opening a PR, do not discover it at merge time.
|
||||
- **No merge queue.** Gitea has none, and its place is taken by **auto-merge on green checks**: the PR is armed once and merges itself when the required check passes **and the required review exists** — both are active on `main`, so an armed PR without a review sits there instead of merging. Rule 7 above is unchanged — the user merges, and an agent never merges its own PR or arms auto-merge on it.
|
||||
- **A required check per chart is not possible.** Gitea runs a whole matrix as one job under one status context, so the install-test reports once for all charts. What is required on `main` is `validate-charts`, as that single context; per chart is the part that cannot be had. Relevant whenever branch protection is touched.
|
||||
- **Issue numbers changed with the move, and `#n` in this repo's files means the GitHub tracker.** The issues open at the time are Gitea #5–#21 and #24; the mapping is a comment on Gitea #14. Closed ones were not migrated and exist only on GitHub. Beware that Gitea silently resolves a bare `#n` against its own counter, so an old reference can render as a link to something unrelated. When you write a *new* reference, use the Gitea number.
|
||||
- **Renovate runs outside this repo**, as the shared `jlab-renovate-bot` instance; it reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed there. **Do not add a Renovate workflow here** — a second runner means duplicate PRs and two schedulers rewriting each other's branches. README, "Renovate on Gitea", has the evidence.
|
||||
- **Remotes are named `github` and `JLab-Git`** — there is no `origin`. Say which one you mean; `git push` without a remote is ambiguous here.
|
||||
- **Today the merge flow still runs on GitHub.** Issues, pull requests and merges happen there until the maintainer switches Actions off and turns on the mirror. Until then, **do not push to `JLab-Git/main`**: with publish workflows armed on both sides, two CIs publish into the same registry, and that registry accepts a re-upload of an existing version silently, replacing the artifact (#160). Pushing a *branch* to Gitea is fine — only `main` triggers a publish.
|
||||
- **`gh` only as `op plugin run -- gh`**, `tea` only as `op plugin run -- tea`. A bare `gh` falls back to the wrong identity. Every call costs a 1Password confirmation, so bundle them and never poll.
|
||||
- **After the move: no merge queue.** Gitea has none. Its place is taken by **auto-merge on green checks** — the PR is armed once and merges itself when the required checks pass. The manager still decides *whether* to arm it; rule 6 above is unchanged: the user merges, and an agent never merges its own PR.
|
||||
- **Issue numbers will not survive the move.** Gitea renumbers, and issues and PRs share one counter, so a `#n` written today will not land on the same number over there. The migration itself is the maintainer's one-off job over the API; how the old references are dealt with afterwards is open and decided in #225. Until that is decided, a `#n` anywhere in this repo means the GitHub issue.
|
||||
- **Renovate is already running on the Gitea side, and not from this repo.** A shared `jlab-renovate-bot` instance serves the other repositories there and has this one enrolled; it reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed (both verified on its first run). **Do not add a Renovate workflow here** — a second runner against the same repo means duplicate PRs and two schedulers rewriting each other's branches. README, "Renovate on Gitea", has the evidence.
|
||||
|
||||
## Keep the docs current
|
||||
|
||||
- Changes to this issue workflow always go into this CLAUDE.md file.
|
||||
- General repository information (charts, CI pipelines, conventions, tooling) always lives in README.md. Any PR that changes repo-wide behavior must update README.md in the same PR.
|
||||
- **Operator-facing knowledge lives in the wiki**, not in README.md: how a chart is installed, what every value does, what its traps are. The division is `CLAUDE.md` binds the agent, `README.md` explains the repository to someone reading the code, the wiki explains the system to whoever runs it, and a chart's `NOTES.txt` explains one release. Where README and the wiki cover the same rule, both are updated in the same PR — see "The wiki" above.
|
||||
- Runtime verification of chart changes is done in a local, throwaway k3d cluster (k3d is installed on the maintainer's machine): stub CRD for `OnePasswordItem`, dummy secrets, throwaway infrastructure (postgres/redis) where needed.
|
||||
|
||||
## What this repo is
|
||||
|
||||
A collection of self-made Helm charts, one directory per chart, published to a private, classic (index-based) Helm registry by Gitea Actions — an HTTP `POST` of the packaged `.tgz`, not an OCI `helm push`; the target URL and its credentials live in 1Password and never in this repository. Validation happens on three levels: locally with Helm, in the PR pipeline (`validate-charts.yml`: strict lint, package, version-bump check), and in the PR install-test stage (k3d, fixtures under `ci/` — see README):
|
||||
A collection of self-made Helm charts, one directory per chart, published to a private, classic (index-based) Helm registry by CI (GitHub Actions today, Gitea Actions after #225; the workflow files are read by both unchanged) — an HTTP `POST` of the packaged `.tgz`, not an OCI `helm push`; the target URL and its credentials live in 1Password and never in this repository. Validation happens on three levels: locally with Helm, in the PR pipeline (`validate-charts.yml`: strict lint, package, version-bump check), and in the PR install-test stage (k3d, fixtures under `ci/` — see README):
|
||||
|
||||
```bash
|
||||
helm lint --strict <chart-name>
|
||||
|
||||
@@ -2,24 +2,17 @@
|
||||
|
||||
Collection of some self-made helm-charts. Each top-level directory is a standalone Helm chart that gets packaged and uploaded to a private, classic (index-based) Helm registry by CI.
|
||||
|
||||
> **This repo lives on the Gitea instance JLab-Git.** Issues, pull requests, CI and Renovate all run there, and `main` there is the leading copy. github.com is a push mirror with Actions and Renovate **switched off**. The workflow files under `.github/workflows/` are read by Gitea unchanged; "Where this repo lives" below names the places where a Gitea runner behaves differently from a GitHub one, each of them measured rather than assumed.
|
||||
>
|
||||
> **The knowledge base is the [wiki](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki).** It holds one page per chart — prerequisites, an installation walkthrough, the complete list of configurable values and the chart's operating traps — plus the long form of the workflow, the pipelines, the style guide and the [lessons](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned) each rule was bought with. This README stays the overview for someone reading the code; anything an *operator* needs is in the wiki. Keeping it current is a required step of the workflow (`CLAUDE.md`), not an afterthought.
|
||||
>
|
||||
> **A reading note on `#n`.** Issue numbers in this file point at the **GitHub** tracker. Most of them name *closed* issues, which were not migrated and exist only there. Gitea resolves a bare `#n` against its own counter instead — where, for example, `#22` is an unrelated throwaway pull request rather than the install-test issue this file means. The seventeen issues that were still open at the move are Gitea #5–#21 and #24; the mapping is a comment on Gitea #14.
|
||||
> **The repository is moving from github.com to the Gitea instance JLab-Git (#225).** The code is already on both and identical; what still runs the show is GitHub. The workflow files under `.github/workflows/` are read by both forges unchanged, and "Where this repo lives" below names the handful of places where the two behave differently — each of them measured on a probe pull request, not assumed.
|
||||
|
||||
## Charts
|
||||
|
||||
Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/<chart>` — carrying the installation walkthrough, the required 1Password fields and the full values reference. The table below is the one-line version.
|
||||
|
||||
| Chart | Deploys | Notes |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | 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; 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) |
|
||||
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
|
||||
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
|
||||
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
|
||||
@@ -27,17 +20,15 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
||||
| `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 |
|
||||
| `patchmon` | PatchMon | One Deployment (the 2.x server serves frontend and `/api/*` from one process) plus an optional guacd sidecar; OIDC-only sign-in |
|
||||
| `patchmon` | PatchMon | Two Deployments (frontend + backend), OIDC secret |
|
||||
| `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 |
|
||||
| `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 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 |
|
||||
@@ -60,57 +51,23 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
|
||||
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 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.
|
||||
- `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.
|
||||
- `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. The **resource-quantity render test** is the exception to that rule: it runs over every chart on every PR — see below.
|
||||
|
||||
### Where this repo lives, and what a Gitea runner does differently
|
||||
### Where this repo lives, and what Gitea Actions 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.
|
||||
The move to the Gitea instance JLab-Git (#225) is in progress. Order matters and is not negotiable: **CI provably green on Gitea → GitHub Actions off → mirror on.** Any other order leaves a window in which either no CI exists or two of them publish into the same registry — and that registry accepts a re-upload of an existing version silently, replacing the artifact (#160).
|
||||
|
||||
**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.
|
||||
Everything below was measured on a throwaway pull request against the Gitea instance, not taken from documentation. It is the reason for the two deviations now in `validate-charts.yml`.
|
||||
|
||||
**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`.
|
||||
- **Reusable workflows with `secrets: inherit` work.** The 23 thin `publish-<chart>.yml` hulls survive the move unchanged. (A missing secret does not fail the job — Gitea fails at *job creation*, so the run shows no steps at all, which is worth knowing before debugging the wrong thing.)
|
||||
- **`ubuntu-latest` on this instance has no Docker socket; `ubuntu-docker` has.** Same label, different meaning than on GitHub's hosted runners, and only one job in this repo cares: the install-test creates a k3d cluster, and k3d is a Docker client. Measured, per job: `detect-changed-charts`, `build-chart`, the quantity render and the Trivy config scan all pass on `ubuntu-latest` — none of them needs Docker. The install-test on `ubuntu-latest` dies at `Create k3d cluster` with `Cannot connect to the Docker daemon at unix:///var/run/docker.sock`, an error that names k3d and never the runner label.
|
||||
- **`ubuntu-docker` is necessary but not yet sufficient.** Its daemon (8 vCPU, 64 GB, Docker 29.5.2) is **rootless** and reports `Cgroup Driver: none` plus `WARNING: Running in rootless-mode without cgroups`. k3d gets as far as creating the network, pulling the images and starting the node; the k3s server container then restarts immediately with an empty log. k3s needs cgroup delegation. Until the instance's Docker-in-Docker gets it (rootful, or rootless with systemd delegation), **the install-test cannot run on Gitea at all** — see #225.
|
||||
- **`aquasecurity/setup-trivy` cannot be used.** It runs `actions/checkout` against `aquasecurity/trivy` on github.com with `github.token`, which on Gitea is a *Gitea* token; github.com answers 401 and checkout retries twice before ending on `fatal: could not read Username for 'https://github.com'`. Its `actions/cache` step is inert on the instance as well (`Cache action is only supported on GHES version >= 3.5`), and fetching the nested actions cost 4m30s before the job started. The workflow now downloads the release tarball instead — one request, identical behaviour on both forges, and the `# renovate:` comment above `TRIVY_VERSION` still keeps it current (proved by dry run: pinned to `v0.72.0`, the lookup offers `v0.74.0` as a minor).
|
||||
- **Matrix jobs lose their name in the status list.** Gitea does not expand `${{ matrix.chart }}` in a job `name:`; it substitutes the matrix *expression*, so the commit status reads `Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)` and **all legs of a matrix share that one context**. Consequence for branch protection: a per-chart required check is not possible on Gitea, and the required-check names differ from GitHub's — they have to be read off a real run rather than copied.
|
||||
- **Actions are fetched from github.com anonymously and that works.** `actions/checkout`, `azure/setup-helm` and the 1Password action need no replacement; only an action that *itself* authenticates against github.com breaks (see Trivy above).
|
||||
- **No merge queue.** Gitea has none. Its place is taken by auto-merge on green checks, which for a single maintainer is equivalent in effect and different in one way worth remembering: auto-merge merges as soon as the checks it knows about are green, so it is only as strong as the *required* checks configured on `main`. With none configured it merges immediately.
|
||||
|
||||
**A matrix runs concurrently inside ONE job, under ONE status context.** This is the single most consequential difference and it has two halves. Operationally: all legs share one Docker daemon, which is what makes a per-leg resource name mandatory (see the cluster name below). For branch protection: **a required check per chart is not possible**, because there is only one context for the whole matrix — and its name carries the *unexpanded* matrix expression, e.g. `Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)`. Required-check names have to be read off a real run; they cannot be copied from GitHub. That example name is from before the install-test job moved into the reusable `install-test.yml` (Gitea #52) — the matrix expression now reads `${{ fromJSON(inputs.charts) }}` and the context may carry the calling job's name as well, so **read the current name off the next chart PR before using it anywhere**. What *is* required on `main` today is **`validate-charts`**, as the single context it reports under, alongside a **review**: per chart is the part that is impossible, not required checks as such. Both are what makes Renovate's `platformAutomerge: true` safe — see "PR policy".
|
||||
|
||||
**Expected runtimes**, so that a later deviation is visible as one:
|
||||
|
||||
| | measured here | GitHub, same commit |
|
||||
|---|---|---|
|
||||
| `Create k3d cluster` | 19–25 s | 22–23 s |
|
||||
| `install-test` job, two charts in the matrix (cyberchef + freesailarr) | 210–216 s over three runs, spread 6 s | ~145 s wall, two parallel VMs |
|
||||
| `install-test` job, freesailarr alone, ten containers and a real tunnel | 225 s (measured on the MediathekArr change, Gitea #20) | not comparable — Actions there were already off |
|
||||
| `install-test` job, **20 charts** in the matrix — the accidental all-charts run of Gitea PR #48 | **12m41s**, one job, all legs concurrently on one Docker daemon; 19 of 20 charts Ready, the twentieth is Gitea #51 | never ran there |
|
||||
|
||||
The runner is **not slower** — cluster creation is a touch faster. The difference in total time is **structural, not performance**: two matrix legs share one runner here, while GitHub gives each leg its own VM. Reading it as "Gitea is slower" leads to the wrong conclusion.
|
||||
|
||||
**`aquasecurity/setup-trivy` cannot be used.** It runs `actions/checkout` against `aquasecurity/trivy` on github.com and hands it `github.token` — which here is a *Gitea* token. github.com answers 401 and checkout ends on `fatal: could not read Username for 'https://github.com'`, naming neither the action nor the token. Its `actions/cache` step is inert here as well. The workflow downloads the release tarball instead: one request, same behaviour on either forge.
|
||||
|
||||
**Tools are installed, not inherited — and the two cases differ on purpose.** `kubectl` is **pinned** to match `K3S_IMAGE` and guarded against version skew, because a client's distance to the API server changes what the test *means*, and a version that arrives with the runner image drifts with every image rebuild. `envsubst` is only **checked for and fetched if absent**, because it substitutes text and the release doing so does not change the result; pinning it would be upkeep without value. That GitHub's image happens to ship both does not make either unnecessary — it only makes the drift invisible there.
|
||||
|
||||
**Containerisation is detected, not inferred from the platform.** k3d publishes the API server port on the Docker *host*; when the job is itself a container — as every Gitea Actions job is — that host is the dind container, so the `https://0.0.0.0:<port>` k3d writes into the kubeconfig points at the job container, where nothing listens. The workflow asks the daemon about its own container instead of asking which forge it is on: a self-hosted **GitHub** runner in a container has the identical problem, and a condition that says "GitHub" while meaning "not containerised" is the kind of lie this repo exists to remove.
|
||||
|
||||
Still unmeasured, because they never stood in the way: `/dev/fuse` and user namespaces. Said as unmeasured rather than as fine.
|
||||
|
||||
#### What a GitHub runner had been providing quietly
|
||||
|
||||
Getting the install-test to run here surfaced six assumptions that had never been written down anywhere. Five of them say something about the **runner**, one about **our code**, and the split is the point.
|
||||
|
||||
The five environment ones are the harmless kind: they fail loudly, immediately, and are fixed on the node side (`cluster-config`), not here — Docker bridges built at MTU 1500 against a pod network of 1450 (small responses fit in one packet, a 17 MB tarball does not, which is why it looked like a blocked host); `fs.inotify` limits too low for k3s, so containerd never started and the node never registered; no `kubectl` and no `envsubst` in the image; and the kubeconfig pointing at `0.0.0.0`.
|
||||
|
||||
The sixth is different, and it is the one to remember.
|
||||
|
||||
> **The k3d cluster name was hard-coded as `install-test` — and that was wrong on GitHub too.** It simply could never show there: each matrix leg gets its own VM with its own Docker daemon, so two legs never saw each other's cluster. Here, where all legs share one daemon, it collides immediately — one leg logs `Re-using existing network`, the other dies on `The container name /k3d-install-test-server-0 is already in use`, and the first leg's rollback then deletes the shared network out from under the second. Two red legs, and neither message names the cause.
|
||||
>
|
||||
> For two years the roomier environment carried that bug instead of showing it. **A generous environment is no friend when you want to know whether something is correct.**
|
||||
|
||||
#### Two rules for every check added here
|
||||
|
||||
Both were learned by getting them wrong in this repo, and both apply to any new verification step, not just to clusters.
|
||||
|
||||
1. **A check that a broken system passes is worse than no check.** `kubectl get nodes` exits **0 on an empty list**, so a readiness check written that way went green against a cluster whose node had never registered — and the failure then surfaced five minutes later, somewhere unrelated. `kubectl wait --for=condition=Ready nodes --all` is not the shorter way to write it: with zero nodes the condition is vacuously satisfied. Assert the thing you mean (*at least one node, and Ready*), not that the command answered.
|
||||
2. **Green does not mean checked.** A passing job earns exactly as much trust as the assertions inside it. The open case of this kind is Gitea #24: the images in `ci/<chart>/fixtures.yaml` are not followed by Renovate, so the install-test can keep passing against fixture infrastructure that is years stale. **Not solved** — named here so it is not mistaken for solved.
|
||||
Still on the maintainer's side and not delegable: branch protection plus the commit-signature requirement on the Gitea repo, the runner's cgroup problem above, and switching GitHub Actions off before enabling the mirror. The bot token gets `403` on `actions/runners`, `actions/secrets` and `branch_protections`, so none of that can be verified from here.
|
||||
|
||||
### Resource-quantity render test (all charts, every PR)
|
||||
|
||||
@@ -134,8 +91,6 @@ Two details worth knowing before touching it:
|
||||
|
||||
For every chart a PR changes, `ci/run-install-test.sh <chart>` runs against a throwaway k3d cluster: it applies the stub `OnePasswordItem` CRD plus the chart's fixtures, `helm install`s the chart with its CI values, and waits for all Deployments/StatefulSets to roll out (per-chart timeout). Since probes and security hardening are active on all workloads, "Ready" means the app actually answers. On failure the job dumps pod descriptions, events and container logs.
|
||||
|
||||
The job itself is the reusable workflow **`install-test.yml`**; `validate-charts.yml` calls it with the changed charts, `install-test-all-charts.yml` with all of them. Everything about *how* a chart is installed is in that one file, deliberately — see "All-charts install-test" below.
|
||||
|
||||
Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the chart directories, so they are neither packaged into the chart tgz nor trigger the publish workflows on merge):
|
||||
|
||||
- `ci/common/` — fixtures applied for every chart (schema-less stub CRD for `OnePasswordItem`)
|
||||
@@ -151,24 +106,9 @@ Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the ch
|
||||
|
||||
Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to dodge runner rate limits. The job **blocks the PR** on failure — the stabilization phase of #22 ended when Renovate started automerging patch/minor updates, since a chart that no longer starts must not reach `main` and publish unattended. When adding a new chart, add its `ci/<chart>/` fixtures (or an entry in `ci/excluded-charts.txt`) — the install-test fails if both are missing. The script also runs locally against any existing cluster: `k3d cluster create t && ci/run-install-test.sh <chart> && k3d cluster delete t`.
|
||||
|
||||
### All-charts install-test (on demand)
|
||||
|
||||
`install-test-all-charts.yml` runs **the same job** as the PR pipeline — the same `install-test.yml`, the same pinned `kubectl`, the same fixtures — with a matrix over **every installable chart** instead of the charts a PR changed. `workflow_dispatch` only, **no schedule**: a daily run would spend compute on a question that changes rarely, and a red run nobody asked for is a red run nobody reads.
|
||||
|
||||
It answers a different question from the PR pipeline. The PR install-test guards against what a change breaks; this one asks whether the environment under an **unchanged** chart still holds. The install-test matrix follows the charts a PR touches, so a chart nobody has edited since the forge move carries a green history from a *different runner* — and that green says nothing about today. **Run it after a change to the runner, to `K3S_IMAGE`, to the pinned `KUBECTL_VERSION`, to the fixtures under `ci/` — and after a forge move.** It is also the answer for a PR that only changes the harness and therefore tests nothing by itself: run it once the change is on `main`.
|
||||
|
||||
Why it exists at all: the answer arrived once by accident. Gitea PR #48 corrected a *comment* in 21 charts, which made all of them "changed" and turned the run into the first all-charts install-test after the move — **19 of 20 matrix charts came up in 12m41s** in one job, and exactly one did not (`rss-bridge`, whose last green run was from a GitHub runner; Gitea #51). Worth having, and nobody had planned it.
|
||||
|
||||
- **Input `charts`** — space- or comma-separated chart names, empty means all. That is what lets a single chart be tested against the runner, which is what Gitea #51 needs. A name that is not a chart directory, or one listed in `ci/excluded-charts.txt`, is a **loud error**: someone typed it, and a green run over zero charts would be the worst possible answer. An empty matrix fails the run for the same reason.
|
||||
- **One throwaway cluster per chart, no reuse.** Reuse would save time and change what the test proves — leftovers from the previous install, a warm image cache, fixtures already applied. "Came up from nothing" is the statement.
|
||||
- **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.
|
||||
- **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)
|
||||
|
||||
Renovate runs as the shared `jlab-renovate-bot` instance on the Gitea side, which also serves the other repositories there — see "Renovate on Gitea" below. **There is no Renovate workflow in this repo, and none should be added.** (The `jlab-renovate` GitHub App that used to do this is switched off along with the rest of GitHub Actions.) What updates is unaffected either way: the config is **`renovate.json5`** in the repo root (JSON5 for the inline reasoning; requires Renovate >= 41 for `managerFilePatterns`/`customType`). There must be exactly one config file — do not add a second `renovate.json` or `.github/renovate.json`.
|
||||
Renovate runs as the self-hosted `jlab-renovate` GitHub App while the repo is on github.com, and on Gitea as the shared `jlab-renovate-bot` instance that already serves the other repositories there — see "Renovate on Gitea" below. **There is no Renovate workflow in this repo, and none should be added.** Which of the two runs it changes nothing about *what* gets updated: its config is **`renovate.json5`** in the repo root (JSON5 for the inline reasoning; requires Renovate >= 41 for `managerFilePatterns`/`customType`). There must be exactly one config file — do not add a second `renovate.json` or `.github/renovate.json`.
|
||||
|
||||
What Renovate keeps current:
|
||||
|
||||
@@ -176,15 +116,15 @@ What Renovate keeps current:
|
||||
| --- | --- |
|
||||
| Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository |
|
||||
| Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager |
|
||||
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block |
|
||||
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block |
|
||||
| GitHub Actions | Built-in `github-actions` manager |
|
||||
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
|
||||
|
||||
Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `searxng`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one.
|
||||
|
||||
One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), so a non-numeric commit hash captured as `build` becomes whatever digits lead it — `9fea41204` is `9`, `777ba8fa4` is `777`, and a hash starting with a letter is `NaN`. Measured on renovate 41.173.1 and 44.48.0, that makes Renovate offer an image pushed *earlier the same day* as an update to a newer one — a downgrade wearing the clothes of a routine date bump, which is exactly what a review waves through and automerge then carries to `main`. Parts of a tag that carry no ordering belong **outside** every group: the anchored pattern still requires them to be there, they simply take no part in the comparison. `prerelease` is not the way out either — it sorts correctly but marks every tag `isStable === false`, which hands the whole chart to `ignoreUnstable`. `freesailarr` has no **app-version** block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image. It does carry one `customManagers` block, for `mediathekarr`: that image is pinned by digest because its tags carry no version (`latest` is a 2024 build, `beta` moves), and the built-in `helm-values` manager reads only `repository`/`tag` and skips it (`skipReason: invalid-value`), so the two cannot collide. Digest rules need the same guard as versioning rules and for a sharper reason: while the pin is current, a matching rule and a broken one produce the identical empty result. Verified with two dry-run fixtures — the real digest yields no update, a deliberately stale one yields exactly one `digest` update back to the pinned digest.
|
||||
One rule those overrides share, learned from `searxng` and worth knowing before writing the next one: **a capture group is a claim about how the value is compared.** Renovate's regex versioning parses `major`, `minor`, `patch`, `build` and `revision` with `Number.parseInt` (`modules/versioning/regex/index.js`, `_parse`), so a non-numeric commit hash captured as `build` becomes whatever digits lead it — `9fea41204` is `9`, `777ba8fa4` is `777`, and a hash starting with a letter is `NaN`. Measured on renovate 41.173.1 and 44.48.0, that makes Renovate offer an image pushed *earlier the same day* as an update to a newer one, unattended under the patch/minor automerge rule. Parts of a tag that carry no ordering belong **outside** every group: the anchored pattern still requires them to be there, they simply take no part in the comparison. `prerelease` is not the way out either — it sorts correctly but marks every tag `isStable === false`, which hands the whole chart to `ignoreUnstable`. `freesailarr` has no block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image.
|
||||
|
||||
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all ten images alike, the digest-pinned one included — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
|
||||
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all nine images alike — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
|
||||
|
||||
Two dependencies are constrained beyond their versioning. **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning.
|
||||
|
||||
@@ -210,34 +150,28 @@ Everything is derived from the base commit, so the script is idempotent across t
|
||||
>
|
||||
> Without that entry Renovate skips the task without an error and every chart PR stays red on the version check.
|
||||
|
||||
If the dependency dashboard reports `Failed to look up github-releases package …: no-result` for third-party repos, that is the instance's github.com credentials, not this config. The instance needs a read-only PAT as `GITHUB_COM_TOKEN` for github.com datasource lookups; its own platform token is a Gitea token and is worth nothing there.
|
||||
If the dependency dashboard reports `Failed to look up github-releases package …: no-result` for third-party repos, that is the instance's GitHub credentials, not this config: a GitHub App installation token is not a good fit for looking up repos the app is not installed on. Give the instance a separate read-only PAT as `GITHUB_COM_TOKEN` for github.com datasource lookups.
|
||||
|
||||
### Renovate on Gitea
|
||||
|
||||
**Do not add a Renovate workflow to this repository.** It is the obvious thing to reach for — Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — and it is wrong here: a **shared `jlab-renovate-bot` instance** runs outside this repository and has it enrolled. A second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches, which is not a safety net but a fault. This paragraph exists so the next reader who finds no workflow knows why instead of building one.
|
||||
**Do not add a Renovate workflow to this repository.** It is the obvious thing to reach for — Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — and it is wrong here: a **shared `jlab-renovate-bot` instance already runs on the Gitea side** and already has this repository enrolled. A second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches, which is not a safety net but a fault. This paragraph exists so the next reader who finds no workflow knows why instead of building one.
|
||||
|
||||
The instance is outside this repository, which has two consequences worth writing down:
|
||||
|
||||
- **It reads our `renovate.json5` and the per-chart rules do fire.** Measured, not assumed: its first run here opened PRs for `searxng` and `gotenberg` plus the dependency dashboard. Those two are `customManagers` targets of ours — no built-in manager finds an `appVersion:` in a `Chart.yaml` — so the config is demonstrably in effect.
|
||||
- **`allowedCommands` is already granted there, and the version-bump hook runs.** This was the expensive unknown: `postUpgradeTasks` only executes when the command is allow-listed in the *instance* config, and a missing entry is skipped **silently**, leaving every chart PR red on the version check. The proof is in that searxng PR's diff, which carries `version: 1.0.4+up2026.9.11.ffe96f8a6` → `1.0.5+up2026.9.13.96479fff6` alongside the `appVersion` change. Nothing but `ci/renovate-bump-chart.sh` writes `version:`. The allow-list is not visible from this repo, though, so if it is ever dropped the symptom here will be red chart PRs with no explanation.
|
||||
- **It reads our `renovate.json5` and the per-chart rules do fire.** Measured, not assumed: its first run on the migrated repo opened PRs for `searxng` and `gotenberg` plus the dependency dashboard. Those two are `customManagers` targets of ours — no built-in manager finds an `appVersion:` in a `Chart.yaml` — so the config is demonstrably in effect.
|
||||
- **`allowedCommands` is already granted there, and the version-bump hook runs.** This was the expensive unknown: `postUpgradeTasks` only executes when the command is allow-listed in the *instance* config, and a missing entry is skipped **silently**, leaving every chart PR red on the version check. The proof is in that searxng PR's diff, which carries `version: 1.0.4+up2026.9.11.ffe96f8a6` → `1.0.5+up2026.9.13.96479fff6` alongside the `appVersion` change. Nothing but `ci/renovate-bump-chart.sh` writes `version:`. Nothing has to be configured for the move — but the allow-list is not visible from this repo either, so if it is ever dropped, the symptom here will be red chart PRs with no explanation.
|
||||
|
||||
One item is easy to miss and is named in "The version-bump hook" above: the instance needs a `GITHUB_COM_TOKEN` for github.com datasource lookups. Every `github-releases`/`github-tags` lookup and the whole built-in `github-actions` manager resolve against github.com, the platform token is a Gitea one and worth nothing there, and unauthenticated lookups hit the rate limit inside a single pass. The symptom is a `no-result` line on the dependency dashboard, not an error.
|
||||
One item does not come for free on either side and is named in "The version-bump hook" above: a `GITHUB_COM_TOKEN` for github.com datasource lookups. Under the GitHub App those rode on the installation token; a Gitea token is worth nothing at github.com, and unauthenticated lookups hit the rate limit inside a single pass. The symptom is a `no-result` line on the dependency dashboard, not an error.
|
||||
|
||||
### PR policy
|
||||
|
||||
The config is aligned with the one in `cluster-config`, because the same `jlab-renovate-bot` instance serves both and two repos that behave differently under one bot is a source of surprises rather than of safety.
|
||||
|
||||
- **Automerge is global** (`automerge: true`, `automergeType: "pr"`, `platformAutomerge: true`) — every update type, **majors included**. The safeguard is not the absence of automerge, it is the branch protection: `main` requires a **review** and requires `validate-charts` to pass. `platformAutomerge` arms the PR on the forge, and Gitea merges it when *both* conditions are met, so an armed PR without a review simply sits there. This is the setting the older `platformAutomerge: false` comment in `renovate.json5` named as the better one "once required checks exist on `main`" — they now do. Same reasoning as `cluster-config`, which retired its dashboard-approval step for it.
|
||||
- **`minimumReleaseAge: "3 days"`** for `patch`, `minor` and `digest` updates. Renovate does not even open the PR before then. A merge here is never just a merge — a chart publish follows it straight away — so a release that gets yanked hours after it appeared must not reach the registry. Majors carry no age gate: they are held at the review, not by a delay.
|
||||
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. `separateMinorPatch: true` likewise splits minor and patch into separate PRs, so one red install-test does not block unrelated updates.
|
||||
- **No PR caps** (`prConcurrentLimit: 100`, `prHourlyLimit: 0`), as in `cluster-config`. The known cost, written down rather than discovered: the first run after a quiet week can open many PRs at once, and every chart PR starts a k3d install-test — which on a Gitea runner queues inside one job rather than fanning out (see "Where this repo lives"). That queue is the accepted trade for not having updates trickle in four per hour.
|
||||
- **`config:best-practices`** is in `extends`. Two of its members matter here: `helpers:pinGitHubActionDigests` moves the workflow files from `actions/checkout@v7` to SHA pins (wanted), and `docker:pinDigests` is switched off again for `custom.regex` and `helm-values` — an `appVersion:` is a tag by construction (`<repo>:{{ .Chart.AppVersion }}`, and the `+up` suffix is built from the same string), so a digest has nowhere to go. Sidecar digests in `values.yaml` *could* be pinned; that is a separate decision, since each digest change would be a chart patch plus a publish.
|
||||
- **Automerge** for `patch`, `minor` and `digest` updates, after a `minimumReleaseAge` of 3 days and only once the PR pipeline is green — install-test included, which is why that job no longer runs with `continue-on-error`. Minor is in scope because the sidecar images barely move in patch steps (`alpine 3.22` → `3.24`, `nginx 1.30.4` → `1.31.4` are both minor), so a patch-only rule would practically never fire. Renovate merges itself (`platformAutomerge: false`) rather than handing the PR to the forge's own auto-merge, which on **both** GitHub and Gitea would merge immediately as long as `main` has no required status checks. That is a statement about the branch protection, not about the platform: once required checks exist on `main`, `platformAutomerge: true` becomes the better setting on either side. Majors are merged by hand.
|
||||
- **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires.
|
||||
- `prConcurrentLimit: 10` / `prHourlyLimit: 4` keep the k3d install-tests from all starting at once.
|
||||
- The manually dispatched `change-app-version.yml` still exists for one-off bumps, but the normal path for app updates is now a Renovate PR.
|
||||
|
||||
## Creating a new chart
|
||||
|
||||
The wiki carries the same procedure as a worked checklist: [Creating a new chart](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/New-Chart). A new chart also needs a wiki page of its own before its PR is merged.
|
||||
|
||||
1. Copy `template-chart/` to `<chart-name>/` and replace every `xxx` placeholder: template file names (`xxx.deployment.yaml` → `<app>.deployment.yaml`), `Chart.yaml`, the `values.yaml` keys, the `.Values.xxx.*` references inside the templates, **and both the `xxx.` helper prefixes and the values paths in `values.schema.json`**. Then walk the six points in "Values that must not lie" below — the schema and the null-safe fallbacks are copied with the template, but their content is chart-specific and wrong until you adapt it. The same goes for `templates/NOTES.txt`: it comes with the template as a worked example and is placeholder text until you replace it with what an operator of *your* app needs after the install (see "Everything else" below); its leading `{{/* … */}}` block explains the bar and is meant to be deleted.
|
||||
2. Add `.github/workflows/publish-<chart-name>.yml` (copy an existing one, e.g. `publish-outline.yml`).
|
||||
3. Add the chart name to the choice list in `.github/workflows/change-app-version.yml`.
|
||||
@@ -257,7 +191,7 @@ For runtime verification (do the workloads actually start, do probes/security se
|
||||
|
||||
## Chart style guidelines
|
||||
|
||||
These guidelines are **binding for every current and future chart** in this repo. They are the consolidated end state of the conventions overhaul (#32, batches 0–4); `template-chart/` is the reference implementation. The wiki mirrors them as the [chart style guide](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Chart-Style-Guide), with the incident behind each rule collected in [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned) — change a rule here and update that page in the same PR.
|
||||
These guidelines are **binding for every current and future chart** in this repo. They are the consolidated end state of the conventions overhaul (#32, batches 0–4); `template-chart/` is the reference implementation.
|
||||
|
||||
### 1. Naming
|
||||
|
||||
@@ -278,7 +212,7 @@ These guidelines are **binding for every current and future chart** in this repo
|
||||
### 3. Images
|
||||
|
||||
- The main app image tag comes from `{{ .Chart.AppVersion }}` — never hardcoded.
|
||||
- **Documented exception — freesailarr**: the chart bundles ten independently versioned upstreams (nine of them enabled by default), so none of them is "the" app version. **No** tag comes from `appVersion`; every image is pinned as `<app>.image.repository` / `<app>.image.tag` in `values.yaml`, which is also what puts them under the built-in `helm-values` manager. Its `appVersion` is the chart's own version marker, freely chosen and tied to no upstream — raised by hand when a new edition of the chart is marked as a whole. Consequently the chart has **no app-version** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list. `mediathekarr` adds a second deviation on top: its tags carry no version at all, so it is pinned by `image.digest` as well and is the one image in this chart with a `customManagers` block — a `repository`/`tag` pair alone would leave it without any update path.
|
||||
- **Documented exception — freesailarr**: the chart bundles nine independently versioned upstreams, so none of them is "the" app version. **No** tag comes from `appVersion`; all nine images are pinned as `<app>.image.repository` / `<app>.image.tag` in `values.yaml`, which is also what puts every one of them under the built-in `helm-values` manager. Its `appVersion` is the chart's own version marker, freely chosen and tied to no upstream — raised by hand when a new edition of the chart is marked as a whole. Consequently the chart has **no** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list.
|
||||
- **Documented exception — minecraft-server**: the only chart whose main image tag can be overridden, through `image.tagOverride` (#158, empty by default — the tag still comes from `appVersion`). `appVersion` is the tag and therefore fixes the JVM, and since chart 5.0.0 only the **newest** Java line is maintained out of this one directory; the older lines stay published as frozen chart versions (java8 and java17 at 4.0.0, java21 at 4.1.0) and never receive another change. Without the override, running an older JVM would mean installing a frozen chart and giving up every later fix — so the line is a decision at rollout time rather than a property of the chart. The override is named `tagOverride`, not `tag`, because it decouples the running image from the chart version, and it is deliberately outside Renovate's reach (`renovate.json5` tracks the `appVersion` in `Chart.yaml` and nothing else). The chart checks the pairing itself: the Java line of the **effective** tag against `minecraft.version`, failing the render with both values named — a mismatch used to install silently and only break the container afterwards.
|
||||
- Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round.
|
||||
|
||||
@@ -290,7 +224,7 @@ These guidelines are **binding for every current and future chart** in this repo
|
||||
- The TLS secret is named `tls-<release>-<chart>-ingress`.
|
||||
- **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field).
|
||||
- **A shared domain with sub-paths is the second admissible ingress shape.** Where a chart exposes several apps, they may either each take a host of their own or share one host and be told apart by path (`domain.tld/sonarr`, `domain.tld/radarr`, …) — one DNS name and one certificate for the whole bundle. The rules for the shared shape: the domain is a single value (`ingress.host`), each app's path is its own value defaulting to `/<app>`, the paths render in one Ingress object `<release>-<chart>-ingress` with the standard TLS secret `tls-<release>-<chart>-ingress`, and an app that carries a host of its own always wins and keeps its own object — so both shapes mix without extra values. An app that needs a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the same TLS secret name and **omitting** the cert-manager annotation, so that exactly one certificate is requested. Apps that cannot serve a base path must be rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
|
||||
- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr as well as mediathekarr are pinned to `/` — mediathekarr because its UI links assets with root-absolute paths, which a rewrite cannot fix; with both of them on one shared host the duplicate-path guard fires and one of them needs a host of its own (its ingress is therefore off by default). `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
|
||||
- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr is pinned to `/`. `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
|
||||
|
||||
### 5. Security hardening
|
||||
|
||||
@@ -367,12 +301,6 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
|
||||
|
||||
**2. Probes, security contexts and resources go through the null-safe helper, never behind an `if`.** An override written without children is YAML null and erases the block: the container renders `securityContext: null` (unhardened, healthy, silent), a probe disappears, or `resources` loses its requests and computed limits. The `defaultedDict` helper defines the opposite semantics — an empty block means "chart defaults apply" — while an explicitly set value still wins, **including `false` and `0`** (hence the `kindIs "invalid"` check; `default` and `mergeOverwrite` both swallow zero values). Do **not** wrap these blocks in `{{- if }}`: a guard does not render a visible `null`, it removes the probe entirely, which is the same bug with no evidence left.
|
||||
|
||||
The rule is about the **shape, not about those three block kinds**. `satisfactory` shipped 7.0.0 with `game` null-safe and its two neighbours not (Gitea #16): `--set server=null` aborted the render, and a single erased key was the quieter half — a nulled `server.gamePort` rendered `port:` and `nodePort:` empty into the Service and an empty `containerPort` into the StatefulSet, rejected by nothing until the API server saw it. Every chart-owned block a template dereferences belongs behind the helper, and a chart where only *some* of them do is worse than one where none do: whoever sees `game=null` fall back cleanly assumes `server=null` does too.
|
||||
|
||||
**A deliberate exception is fine; an undocumented one is the bug.** `satisfactory.storage` is left un-defaulted on purpose — an erased size yields an invalid PVC that fails at apply time rather than a volume quietly sized by the chart — so `server: null` still renders a PVC without a size. That is a defensible trade and it is *not* the null-safety bug next to it; what makes it safe to leave standing is that the schema, `values.yaml` and the chart page all name it as an exception and point at the issue where it is argued (Gitea #39). Note also where the trade actually bites: only on a **new** install. For an existing release a fallback to the default size would fail too, because a PVC cannot shrink. Do not overturn a reasoned exception inside a bugfix patch — the patch then stops being one.
|
||||
|
||||
**And `helm template` does not render `NOTES.txt`.** A value read there — `server` is read in four files in that chart — stays broken after a fix that looks green in every render, until someone runs a real `helm install`. `helm install --dry-run=client` is not the way around it either: it still wants a reachable API server (measured on helm v3.22.0). What works without a cluster: in a throwaway copy of the chart, wrap the verbatim `NOTES.txt` body in a `{{- define }}` and `include` it into a scratch ConfigMap, which `helm template` *does* render — same engine, same root context, only the sink differs.
|
||||
|
||||
**3. Do not hang liveness on an external dependency where a dependency-free path exists.** `zipline` deliberately probes `/` for liveness and `/api/healthcheck` only for readiness and startup, so a database outage cannot restart the pod. Where no such path exists — `outline` returns 500 on `/` when the database is down — all three probes may share the endpoint, but the reason belongs in a comment on the helper, or the next reader will "fix" the inconsistency.
|
||||
|
||||
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.
|
||||
@@ -385,18 +313,12 @@ Reference implementation: `template-chart/values.schema.json` and `template-char
|
||||
|
||||
**Two things about the mechanics that are easy to lose:**
|
||||
|
||||
- **`null` reaches the schema in two cases out of three, and which one you are in depends on the chart's default, not on what the user wrote.** Measured on Helm 3.22.0 with a probe chart, because the wording here was wrong before and was applied to exactly the case it got wrong:
|
||||
- a user `null` over a **non-null** chart default (`--set x=null`, or `x: null` in a values file) is treated as a deletion and the key is removed *before* schema validation — the schema never sees it, and only the helper can catch it;
|
||||
- a **`null` chart default** survives into validation with or without a user `null` on top of it, because there is nothing to delete it against, and the schema sees the key present with value `null`;
|
||||
- a key **without any default** (e.g. `resources.limits`) likewise survives, and a strict `"type": "object"` aborts the render.
|
||||
|
||||
That is why the helper **and** the widened `["object", "null"]` / `["string", "null"]` types are both needed: each covers a case the other cannot. Everything else stays strictly typed.
|
||||
- **A value that has no valid default cannot be enforced by `values.schema.json` at all, and `required()` is the whole enforcement.** The chain is short and it has no exit (#18): the key ships as `null`, so it is *present* at validation time, and JSON Schema's `required` tests presence and not null-ness — the array never fires, under any input. The two ways to make it fire both break the rule that `helm lint --strict` passes on chart defaults, since lint validates those defaults against the schema: dropping the `null` default gives `missing property '<key>'`, and narrowing `["string", "null"]` to `"string"` gives `got null, want string` — each a red `validate-charts` run on a chart nobody touched. So such a key is guarded with `required()` in the template and **described** as required in the schema; a `required` array next to it would be a guard that cannot fire, which is the same defect as a value that does nothing, dressed as a safeguard. This is why no chart in this repo carries one.
|
||||
- **`null` reaches the schema in one case and not the other.** For a key **with** a chart default, Helm's coalescing treats the `null` as a deletion and removes it *before* schema validation — the schema never sees it, and only the helper can catch it. For a key **without** a default (e.g. `resources.limits`), there is nothing to delete, the `null` survives into validation, and a strict `"type": "object"` aborts the render. That is why the helper **and** the widened `["object", "null"]` types are both needed: each covers a case the other cannot. Everything else stays strictly typed.
|
||||
- **Validate every schema against the real values, not only against chart defaults.** Defaults are valid by construction, so they prove nothing. Render each chart against its bundle in `cluster-config/fleet-cd/` **and** against `helm get values` — the two differ more often than expected (four of ten bundles in one wave, always with the repo file being the worse one). This step caught a schema of mine that would have broken three live paperless releases, because all three write `tikaGotenberg.enabled` as the string `"true"` and the first draft demanded a boolean. Result per chart is a **list of rejected keys**, which is the cluster side's work order before the version is pulled up.
|
||||
|
||||
### 13. Everything else
|
||||
|
||||
- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one**: the rule everywhere else is "the icon is this chart's one upstream's logo", and a chart bundling ten independently versioned projects has no such upstream — picking one of the ten would imply a "main app" the stack deliberately does not have. That is the whole criterion, and it applies to no other chart in the repo: a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict` (`icon is recommended`), which still exits 0.
|
||||
- `icon:` is mandatory in every `Chart.yaml`. **Documented exception — freesailarr, the only chart without one**: the rule everywhere else is "the icon is this chart's one upstream's logo", and a chart bundling nine independently versioned projects has no such upstream — picking one of the nine would imply a "main app" the stack deliberately does not have. That is the whole criterion, and it applies to no other chart in the repo: a single-upstream chart without an icon is a missing icon, not an exception. The omission costs an INFO line in `helm lint --strict` (`icon is recommended`), which still exits 0.
|
||||
- `helm lint --strict` must pass without findings.
|
||||
- **Every chart ships a `templates/NOTES.txt`** (#43), `template-chart` included — so this is a requirement for a new chart, not a nice-to-have it can defer. No list of names here on purpose: a count goes stale the moment the next chart lands, which is exactly how the previous wording came to claim a number it never matched. Reference implementation: `template-chart/templates/NOTES.txt`, a placeholder-carrying template rather than finished prose, so a new chart adapts it instead of starting from an empty file. The bar it sets: print what an operator would otherwise have to look up in the templates (the rendered access URL, **which fields the 1Password item must carry**, the options actually active in this release, and the chart's known traps), conditional on the values really set. A `NOTES.txt` that only repeats the values is skimmed once and never read again. **No real domain anywhere** — the URL renders from `ingress.domain`, and where an example is unavoidable it is `example.com`. A `NOTES.txt` renders no Kubernetes object, so writing or reworking one is purely additive: a minor bump.
|
||||
- CI: every installable chart has fixtures under `ci/<chart>/` (`test-values.yaml`, optionally `fixtures.yaml`/`fixtures.env.yaml`/`settings.env`) or a justified entry in `ci/excluded-charts.txt` — the install-test fails if both are missing.
|
||||
|
||||
@@ -5,5 +5,5 @@ icon: https://www.apache.org/logos/res/tika/default.png
|
||||
|
||||
type: application
|
||||
|
||||
version: 10.0.2+up3.3.1.0.full
|
||||
version: 10.0.1+up3.3.1.0.full
|
||||
appVersion: "3.3.1.0-full"
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "apache-tika.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "apache-tika.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "apache-tika.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/alam00000/bentopdf/main/public/images/fa
|
||||
|
||||
type: application
|
||||
|
||||
version: 1.0.3+up2.8.8
|
||||
version: 1.0.2+up2.8.8
|
||||
appVersion: "2.8.8"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "bentopdf.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "bentopdf.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "bentopdf.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -5,7 +5,7 @@ name: calibre-web-automated
|
||||
description: A Helm chart to deploy calibere-web-automated
|
||||
icon: https://raw.githubusercontent.com/crocodilestick/Calibre-Web-Automated/main/README_images/cwa-logo-round-dark.png
|
||||
|
||||
version: 7.0.1+upv4.0.6
|
||||
version: 6.1.0+upv4.0.6
|
||||
appVersion: v4.0.6
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -61,7 +61,7 @@ THE FIRST START BUILDS A LIBRARY, AND THE STARTUP PROBE IS TIMING IT
|
||||
kubectl -n {{ .Release.Namespace }} logs {{ $pod }} -f
|
||||
|
||||
SECRETS — THE FIELDS THE 1PASSWORD ITEM MUST CARRY
|
||||
{{ .Values.secrets.cwaSecretRef }}
|
||||
{{ if .Values.secrets.cwaSecretRef }}{{ .Values.secrets.cwaSecretRef }}{{ else }}(secrets.cwaSecretRef is unset — see below){{ end }}
|
||||
→ Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
hardcover-api-token becomes the HARDCOVER_TOKEN environment variable
|
||||
calibre-plugins mounted as /config/.config/calibre/customize.py.json,
|
||||
@@ -74,10 +74,13 @@ SECRETS — THE FIELDS THE 1PASSWORD ITEM MUST CARRY
|
||||
hardcover-api-token parks the pod in CreateContainerConfigError; a missing
|
||||
calibre-plugins fails the mount instead ("couldn't find key calibre-plugins"),
|
||||
and the container is never created at all.
|
||||
{{- if not .Values.secrets.cwaSecretRef }}
|
||||
|
||||
secrets.cwaSecretRef itself is required: the template guards it with
|
||||
required(), so a release without it does not render at all rather than
|
||||
creating a OnePasswordItem with an empty itemPath.
|
||||
secrets.cwaSecretRef IS UNSET. The OnePasswordItem has been created with an
|
||||
empty itemPath, the operator has nothing to resolve, and the Secret above will
|
||||
never appear — the pod cannot start. Set it to the item's op:// path and
|
||||
upgrade.
|
||||
{{- end }}
|
||||
|
||||
CHANGING THE ITEM'S CONTENT RESTARTS NOTHING
|
||||
Helm never sees what is inside a OnePasswordItem, so it cannot hash it into the
|
||||
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "calibre-web-automated.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "calibre-web-automated.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -169,9 +167,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "calibre-web-automated.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ required "A 1Password item reference must be provided (secrets.cwaSecretRef)" .Values.secrets.cwaSecretRef }}
|
||||
itemPath: {{ .Values.secrets.cwaSecretRef }}
|
||||
@@ -17,7 +17,6 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cwaSecretRef": {
|
||||
"description": "op:// item path carrying \"hardcover-api-token\" and \"calibre-plugins\". Required - the OnePasswordItem, the HARDCOVER_TOKEN env reference and the plugin volume are all rendered unconditionally. Enforced by required() in the template, not here: the null default keeps this type widened, and a schema \"required\" entry could never fire against a key that is present with value null.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,6 @@
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# REQUIRED - op:// item path for the 1Password operator, carrying the fields
|
||||
# "hardcover-api-token" and "calibre-plugins". Both are needed even if you
|
||||
# never use Hardcover: the OnePasswordItem is created unconditionally, and so
|
||||
# are the environment reference and the volume. Without this value the render
|
||||
# aborts - required() in calibre-web-automated.secret.yaml is the whole
|
||||
# enforcement, and values.schema.json cannot help here (README, guideline 12).
|
||||
# Before the guard existed it produced an OnePasswordItem with an empty
|
||||
# itemPath, the operator had nothing to resolve, the Secret never appeared and
|
||||
# the pod could not start.
|
||||
cwaSecretRef: null
|
||||
|
||||
cwa:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
@@ -5,7 +5,7 @@
|
||||
# (REQUIRED_ENV), and pipes the result straight into kubectl.
|
||||
#
|
||||
# The values come from a 1Password item holding a CI-only Mullvad key, loaded in
|
||||
# the install-test job of .github/workflows/install-test.yml. A CI-only key
|
||||
# the install-test job of .github/workflows/validate-charts.yml. A CI-only key
|
||||
# on purpose: Mullvad ties an address to a key and sends the tunnel to the
|
||||
# endpoint it saw last, so a shared key would let every pull request pull the
|
||||
# tunnel out from under the running stack.
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# Ten containers in one pod, several GB of images to pull, and the app
|
||||
# Nine containers in one pod, several GB of images to pull, and the app
|
||||
# containers only start once gluetun has completed its Wireguard handshake -
|
||||
# each of them then runs its own startup probe (150s budget, 300s for seerr's
|
||||
# first-boot migrations). mediathekarr adds ~850 MB of image and waits for
|
||||
# gluetun's health server BEFORE its process starts (its own start gate), so its
|
||||
# 300s startup budget only begins once the tunnel is up. The default of 300s is
|
||||
# not enough for any of that.
|
||||
INSTALL_TIMEOUT=1200
|
||||
# first-boot migrations). The default of 300s is not enough for that.
|
||||
INSTALL_TIMEOUT=900
|
||||
|
||||
# The gluetun credentials for ci/freesailarr/fixtures.env.yaml. Missing or empty
|
||||
# values fail the install-test loudly instead of skipping it - see
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# freesailarr: the whole stack in one pod - gluetun plus nine app containers
|
||||
# freesailarr: the whole stack in one pod - gluetun plus eight app containers
|
||||
# that share its network namespace and sit behind its kill switch. Nothing here
|
||||
# is a shortcut around that: the install-test brings up a real Wireguard tunnel
|
||||
# with a CI-only Mullvad key (see ci/freesailarr/fixtures.env.yaml), because a
|
||||
@@ -37,11 +37,3 @@ ingress:
|
||||
# in the CI cluster, and the install-test checks readiness, not routing.
|
||||
host: freesailarr.example.com
|
||||
clusterIssuer: ci
|
||||
|
||||
mediathekarr:
|
||||
# On in CI although it is off by default, so the install-test actually covers
|
||||
# the tenth container: its digest-pinned image (single-arch amd64, ~850 MB),
|
||||
# its start gate against gluetun's health server, and its probes on the two
|
||||
# ports. Its ingress stays off - MediathekArr can only be served at the root
|
||||
# of a host, and the root of the shared host above belongs to seerr.
|
||||
enabled: true
|
||||
+7
-10
@@ -21,11 +21,9 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CI_DIR="$ROOT/ci"
|
||||
|
||||
# --- The exclusion list, parsed in exactly one place ------------------------
|
||||
# The consumers of ci/excluded-charts.txt are this script and the two jobs that
|
||||
# build an install-test matrix - detect-changed-charts in
|
||||
# .github/workflows/validate-charts.yml and select-charts in
|
||||
# .github/workflows/install-test-all-charts.yml - both of which drop the
|
||||
# excluded charts before the matrix exists (issue #231). Two parsers would be two
|
||||
# Two consumers read ci/excluded-charts.txt: this script, and the install-test
|
||||
# matrix in .github/workflows/validate-charts.yml, which drops the excluded
|
||||
# charts before the matrix is built (issue #231). Two parsers would be two
|
||||
# chances to select different charts - and the failure would be silent in the
|
||||
# worst direction, a chart the workflow believes excluded and the script
|
||||
# believes testable, or the reverse. So there is one parser, here, and the
|
||||
@@ -49,10 +47,9 @@ excluded_reason() {
|
||||
list_excluded | awk -F'\t' -v chart="$1" '$1 == chart { print $2; exit }'
|
||||
}
|
||||
|
||||
# Emits "<chart><TAB><reason>" for every excluded chart. Read by both jobs that
|
||||
# assemble an install-test matrix (detect-changed-charts in validate-charts.yml,
|
||||
# select-charts in install-test-all-charts.yml) before any chart is picked,
|
||||
# which is why this runs before the argument is required.
|
||||
# Emits "<chart><TAB><reason>" for every excluded chart. Read by the detect job
|
||||
# of validate-charts.yml before any chart is picked, which is why this runs
|
||||
# before the argument is required.
|
||||
if [ "${1:-}" = "--list-excluded" ]; then
|
||||
list_excluded
|
||||
exit 0
|
||||
@@ -103,7 +100,7 @@ fi
|
||||
# REQUIRED_ENV="FREESAILARR_WG_KEY FREESAILARR_WG_ADDRESSES"
|
||||
#
|
||||
# The values come from the environment - in CI from 1Password Connect, see the
|
||||
# install-test job in .github/workflows/install-test.yml.
|
||||
# install-test job in .github/workflows/validate-charts.yml.
|
||||
#
|
||||
# A missing or empty value is a hard error on purpose. Skipping the chart
|
||||
# instead would restore exactly the green-but-meaningless signal this mechanism
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
# Fixtures for the sure install-test: throwaway PostgreSQL 16 (the version
|
||||
# upstream's compose file uses) and Redis, plus the secrets the 1Password
|
||||
# operator would otherwise materialize (release name: ci).
|
||||
#
|
||||
# Both are mandatory for this chart, and for different reasons: the web
|
||||
# container migrates the schema at start and its readiness probe runs a query,
|
||||
# while all three probes of the worker container ask Redis whether the Sidekiq
|
||||
# process still has a heartbeat. A missing Redis therefore does not show up as
|
||||
# "a background job is late" but as a pod that never turns Ready.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-sure-secret
|
||||
stringData:
|
||||
# 64 bytes hex, as upstream's "openssl rand -hex 64" produces. Any value
|
||||
# works for a throwaway database - it is the key the app derives its Active
|
||||
# Record encryption keys from, and nothing here survives the run.
|
||||
secret-key-base: 3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed7e6dc881de23001b3f786850e387550fdab836ed7e6dc881de23001b3f786850
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-sure-db-secret
|
||||
stringData:
|
||||
host: postgres
|
||||
port: "5432"
|
||||
database: sure
|
||||
username: ci
|
||||
password: ci
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-sure-redis-secret
|
||||
stringData:
|
||||
connection-url: redis://:cipass@redis:6379/0
|
||||
---
|
||||
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: ci
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: ci
|
||||
- name: POSTGRES_DB
|
||||
value: sure
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
readinessProbe:
|
||||
# -d names the database the chart actually uses. Without it
|
||||
# pg_isready asks for a database named after the user and the
|
||||
# server logs 'FATAL: database "ci" does not exist' every five
|
||||
# seconds - noise that reads like a chart problem in the diagnostics
|
||||
# dump when something else fails.
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "ci", "-d", "sure"]
|
||||
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
|
||||
---
|
||||
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,6 +0,0 @@
|
||||
# The first start of the web container creates and migrates the whole Rails
|
||||
# schema ("./bin/rails db:prepare", run by the image entrypoint) before Puma
|
||||
# binds its port, and the worker container only registers with Sidekiq once
|
||||
# that schema exists. Both startup probes budget 600s for it, so the rollout
|
||||
# wait has to be longer than a single startup budget, not shorter.
|
||||
INSTALL_TIMEOUT=900
|
||||
@@ -1,10 +0,0 @@
|
||||
secrets:
|
||||
appSecretRef: op://ci/sure-app
|
||||
dbSecretRef: op://ci/sure-db
|
||||
redisSecretRef: op://ci/sure-redis
|
||||
|
||||
ingress:
|
||||
domain: sure.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
|
||||
@@ -24,7 +24,7 @@ misconfigurations:
|
||||
statement: "No CPU limits, on purpose: CPU is compressible, so a limit throttles where the missing limit merely shares - see README 'Resources (auto-limits)'. The chart helper renders a CPU limit only when one is set explicitly."
|
||||
|
||||
- id: AVD-KSV-0020
|
||||
statement: "UIDs are pinned to the value the image expects, not raised above 10000: minecraft-server 1000 (gosu default), teamspeak-server 9987, voucher-vault 33, the matrix-synapse delegation nginx 101, sure 1000 (its Dockerfile declares USER 1000:1000). calibre-web-automated shows the risk - a runtime uid moving from 1000 to 911 makes existing volume data inaccessible, and every chart with a PVC carries that same risk class."
|
||||
statement: "UIDs are pinned to the value the image expects, not raised above 10000: minecraft-server 1000 (gosu default), teamspeak-server 9987, voucher-vault 33, the matrix-synapse delegation nginx 101. calibre-web-automated shows the risk - a runtime uid moving from 1000 to 911 makes existing volume data inaccessible, and every chart with a PVC carries that same risk class."
|
||||
|
||||
- id: AVD-KSV-0021
|
||||
statement: "GIDs follow the pinned UIDs above; the same image expectation and the same PVC ownership risk apply."
|
||||
@@ -109,19 +109,6 @@ misconfigurations:
|
||||
paths: ["**/homeassistant.yaml"]
|
||||
statement: "NET_RAW is the capability in question and is added on purpose for device discovery; see AVD-KSV-0022."
|
||||
|
||||
# --- valkey: the two keyword findings on its ConfigMap ---------------------
|
||||
# Both are keyword matches on the COMMENTS and the shell script in the
|
||||
# ConfigMap, not on data. Worth suppressing with a reason rather than leaving
|
||||
# standing, because "ConfigMap stores secrets" on this chart of all charts
|
||||
# says the opposite of what it does: the whole design exists to keep the
|
||||
# password out of everything Helm renders.
|
||||
- id: AVD-KSV-0109
|
||||
paths: ["**/valkey.yaml"]
|
||||
statement: "False positive on the rendered ConfigMap. The matched strings are the comment '# Nothing here prints the password' and the line pass=\"$(sed -n 's/^requirepass...' \"$AUTH_FILE\")\" of the probe script - i.e. the code that READS the password out of the mounted Secret file at probe time. The ConfigMap contains no credential: the password lives only in the 1Password item, is materialized into a Secret by the operator, mounted as a file and 'include'd by the configuration, which is precisely why no requirepass line is rendered here. Verify by rendering the chart - no value from the vault appears in it."
|
||||
- id: AVD-KSV-01010
|
||||
paths: ["**/valkey.yaml"]
|
||||
statement: "Same class as AVD-KSV-0109 above: the matched keys are 'valkey.conf' and an 'echo \"valkey...' line of the validation script. Both are chart-authored configuration and diagnostics text; nothing user- or credential-derived is stored in this ConfigMap."
|
||||
|
||||
# --- rss-bridge: port 80 is fixed by the image ----------------------------
|
||||
- id: AVD-KSV-0117
|
||||
paths: ["**/rss-bridge.yaml"]
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Fixtures for the valkey install-test: the one Secret the 1Password operator
|
||||
# would otherwise materialize from the OnePasswordItem the chart renders
|
||||
# (release name: ci, stub CRD in ci/common/).
|
||||
#
|
||||
# The field name matters as much as the value. The chart mounts exactly the key
|
||||
# "auth-conf" and its content has to be a COMPLETE Valkey configuration line,
|
||||
# because the configuration "include"s the file - so this fixture is also what
|
||||
# keeps the init container's check honest: give it anything that is not a
|
||||
# "requirepass <password>" line and the pod refuses to start, which is the
|
||||
# failure this chart exists to make loud.
|
||||
#
|
||||
# No throwaway infrastructure here: Valkey depends on nothing. What this
|
||||
# install-test does exercise, and why it is the persistent variant, is the risky
|
||||
# path - a provisioned PVC, the pod's fsGroup against it, and an append-only
|
||||
# file being created and replayed on a volume the container did not create.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ci-valkey-secret
|
||||
stringData:
|
||||
# A throwaway password. Nothing here survives the run - but it is a real
|
||||
# password rather than an empty string on purpose: an empty one would produce
|
||||
# exactly the open server the chart refuses to start, and the run would fail
|
||||
# for the right reason at the wrong place.
|
||||
auth-conf: requirepass ci-valkey-throwaway-password
|
||||
@@ -1,37 +0,0 @@
|
||||
# CI values for the valkey install-test. The PERSISTENT variant on purpose: it is
|
||||
# the chart's default and the only risky path there is (a provisioned PVC, the
|
||||
# pod's fsGroup against it, an append-only file created on a volume the container
|
||||
# did not create). The machinery can run one variant per chart, so the
|
||||
# non-persistent path is covered by a local render only - named as a known gap in
|
||||
# the PR that introduced this chart.
|
||||
secrets:
|
||||
authSecretRef: op://ci/valkey
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
appendonly: true
|
||||
# k3d's dynamic provisioner. The chart's default, longhorn-encrypted-ns, does
|
||||
# not exist on the runner, and a pre-bound hostPath PV would be worse than this
|
||||
# one: the kubelet does not apply fsGroup to a hostPath volume at all, so the
|
||||
# test would silently stop exercising the ownership path it is here for.
|
||||
storageClass: local-path
|
||||
size: 128Mi
|
||||
|
||||
valkey:
|
||||
# Small, so the whole thing stays inside the runner's budget. It still
|
||||
# exercises the derivation: 64Mi x limitFactor 3 = 192Mi limit, maxmemory =
|
||||
# 0.75 x that = 144Mi - and the render aborts if a configured maxmemory ever
|
||||
# ends up above the limit.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
ephemeral-storage: 32Mi
|
||||
|
||||
# Left ON, although nothing in the test connects from another namespace: the
|
||||
# point here is that the object is accepted and does not keep the pod's own
|
||||
# traffic (the exec probes are local to the pod and unaffected) from working.
|
||||
# Whether the policy actually FILTERS is a property of the cluster's CNI and is
|
||||
# not claimed by this test.
|
||||
networkPolicy:
|
||||
enabled: true
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
@@ -1,224 +0,0 @@
|
||||
{{- $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 }}
|
||||
@@ -1,327 +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. 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 -}}
|
||||
@@ -1,34 +0,0 @@
|
||||
{{- 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 }}
|
||||
@@ -1,13 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,6 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,201 +0,0 @@
|
||||
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: {}
|
||||
@@ -1,212 +0,0 @@
|
||||
{
|
||||
"$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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
# 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: {}
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "cyberchef.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "cyberchef.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "cyberchef.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
+10
-12
@@ -1,25 +1,23 @@
|
||||
apiVersion: v2
|
||||
name: freesailarr
|
||||
description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher, request front end and an optional public-broadcaster indexer in one pod, all reaching the internet only through the tunnel
|
||||
description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher and request front end in one pod, all reaching the internet only through the tunnel
|
||||
# No icon on purpose: the stack has no single upstream, and every other chart's
|
||||
# icon is its one project's logo. Picking any of the ten here would suggest
|
||||
# icon is its one project's logo. Picking any of the nine here would suggest
|
||||
# the same false "main app" the versioning deliberately avoids. The field is
|
||||
# optional, so it is omitted rather than filled with an arbitrary choice.
|
||||
|
||||
type: application
|
||||
|
||||
version: 3.1.1+up1.0.0
|
||||
version: 3.0.6+up1.0.0
|
||||
# The chart's own version marker, freely chosen and tied to no upstream. The
|
||||
# stack bundles ten independently versioned projects (nine of them on by
|
||||
# default), so there is no single "app version" to track here.
|
||||
# stack bundles nine independently versioned projects, so there is no single
|
||||
# "app version" to track here.
|
||||
#
|
||||
# It is NOT the source of any image tag: all ten images are pinned in
|
||||
# values.yaml, where Renovate's helm-values manager proposes them - except
|
||||
# mediathekarr, which is pinned by digest and has its own customManagers block
|
||||
# in renovate.json5, because its tags carry no version. Nothing rewrites this
|
||||
# field automatically - the chart has no app-version block in renovate.json5
|
||||
# and is not in the change-app-version.yml choice list. Raise it by hand when
|
||||
# you want to mark a new edition of the chart as a whole.
|
||||
# It is NOT the source of any image tag: all nine images are pinned in
|
||||
# values.yaml, where Renovate's helm-values manager proposes them. Nothing
|
||||
# rewrites this field automatically - the chart has no app-version block in
|
||||
# renovate.json5 and is not in the change-app-version.yml choice list. Raise it
|
||||
# by hand when you want to mark a new edition of the chart as a whole.
|
||||
appVersion: "1.0.0"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{- $sharedNames := list -}}
|
||||
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
|
||||
freesailarr — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
{{ if .Values.mediathekarr.enabled }}Ten{{ else }}Nine{{ end }} containers in ONE pod, sharing gluetun's network namespace.
|
||||
Nine containers in ONE pod, sharing gluetun's network namespace.
|
||||
|
||||
NOTHING IS READY UNTIL THE TUNNEL IS UP
|
||||
gluetun's readiness probe is its health server, which answers 200 only while
|
||||
@@ -134,101 +134,6 @@ QBITTORRENT HAS NO SAVE PATH SET (qbittorrent.savePath is empty)
|
||||
WebUI before starting any download.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.mediathekarr.enabled }}
|
||||
{{- $dir := .Values.mediathekarr.downloads.directory }}
|
||||
{{- $gate := .Values.mediathekarr.startGate }}
|
||||
{{- $mediaStartup := fromYaml (include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "startupProbe" "given" .Values.mediathekarr.startupProbe)) }}
|
||||
|
||||
MEDIATHEKARR: TWO PORTS, AND THE SABNZBD API IS NOT AT /api
|
||||
Every container shares one network namespace, so both consumers reach it over
|
||||
127.0.0.1 — no service name, no DNS, no ingress:
|
||||
|
||||
Prowlarr -> Newznab indexer, http://127.0.0.1:5008
|
||||
Sonarr/Radarr -> SABnzbd host 127.0.0.1, port 5007, API path /download/api
|
||||
|
||||
A request to /api on 5007 answers with a Newznab validation error and sends
|
||||
you looking at the wrong half of the app — the upstream documentation only
|
||||
mentions port 5007 at all.
|
||||
|
||||
Sonarr and Radarr must import from
|
||||
|
||||
/downloads/{{ $dir }}/complete (one sub-directory per category below it)
|
||||
|
||||
which is exactly the path the chart hands MediathekArr and which it reports
|
||||
back as the SABnzbd complete_dir. Any deviation — a remote path mapping, a
|
||||
different mount — imports NOTHING, without an error.
|
||||
|
||||
The UI (setup wizard and download queue) has no ingress{{ if and .Values.mediathekarr.ingress.enabled .Values.mediathekarr.ingress.host }}, other than the one
|
||||
you gave it on {{ .Values.mediathekarr.ingress.host }}{{ end }}:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $name }}-mediathekarr-service 5007:5007
|
||||
{{- if $gate.enabled }}
|
||||
|
||||
IT DELIBERATELY STARTS LATE, AND THAT IS THE POINT
|
||||
The container waits (up to {{ $gate.timeoutSeconds }}s) for gluetun to report healthy before
|
||||
it starts MediathekArr{{ if gt (int $gate.settleSeconds) 0 }}, plus {{ $gate.settleSeconds }}s of settling time{{ end }}. Started before the tunnel,
|
||||
MediathekArr fails its one ruleset fetch, never retries it, and then answers
|
||||
EVERY search with 0 results — container up, Ready, nothing in the log. That
|
||||
is why a green pod proves nothing here and why the gate exits instead of
|
||||
starting anyway on timeout: a crash-looping container is visible, a silently
|
||||
empty indexer is not.
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} logs {{ $name }}-sfs-0 -c mediathekarr | head -3
|
||||
|
||||
The startup probe has to outlast the gate ({{ mul (int $mediaStartup.periodSeconds) (int $mediaStartup.failureThreshold) }}s budget against a {{ $gate.timeoutSeconds }}s gate),
|
||||
otherwise the kubelet kills the container while it is still waiting.
|
||||
{{- else }}
|
||||
|
||||
THE START GATE IS OFF (mediathekarr.startGate.enabled: false)
|
||||
MediathekArr may then start before the Wireguard tunnel is up. Its ruleset
|
||||
fetch fails once, is never retried, and the indexer answers every search
|
||||
with 0 results afterwards — measured over eleven minutes, with the container
|
||||
up, Ready and no error anywhere. Nothing reports this state. Check a search
|
||||
by hand after every restart, or switch the gate back on.
|
||||
{{- end }}
|
||||
|
||||
A FAILED MEDIATHEKARR DOWNLOAD NEVER SAYS WHY
|
||||
MediathekArr does not check the HTTP status code. On a geo-blocked fetch it
|
||||
writes the ~476-byte HTML error page out as .mp4 and logs "Download
|
||||
completed"; only mkvmerge then refuses the file and the status flips to
|
||||
Failed — with fail_message: null. Measured A/B with one identical NZB:
|
||||
direct 94,235,525 bytes and Completed, through a foreign exit 0 bytes and
|
||||
Failed, fail_message null in BOTH cases.
|
||||
|
||||
So a geo block is indistinguishable from a broken server, an expired URL or a
|
||||
full disk, in Sonarr and in the container log alike. And the protection is an
|
||||
accident: it rests entirely on mkvmerge rejecting an HTML page. Anything a
|
||||
geo endpoint returns that mkvmerge accepts is reported as Completed and walks
|
||||
into your library.
|
||||
|
||||
WHETHER A SHOW WORKS THROUGH YOUR EXIT: READ THE MARKER, NOT A LIST
|
||||
The tunnel currently exits {{ with .Values.gluetun.vpn.serverCities }}in {{ . }}{{ else }}{{ with .Values.gluetun.vpn.serverCountries }}in {{ . }}{{ else }}wherever gluetun picks{{ end }}{{ end }}. ARD and ZDF block parts of their
|
||||
catalogue outside Germany, and every container hangs in gluetun's namespace —
|
||||
there is no per-container exit.
|
||||
|
||||
A table of series goes stale; the marker in the media URL does not. It
|
||||
predicted the outcome in 584 of 584 measured cases:
|
||||
|
||||
/de/, /geo/de/, /deutschlandweit/, geoavailability_D -> blocked outside DE
|
||||
/weltweit/ -> open
|
||||
/none/ (ZDF) -> open
|
||||
/dach/, deChAt (WDR variant) -> open
|
||||
|
||||
The rule of thumb "own production = available worldwide" is MEASURABLY WRONG
|
||||
(an ARD own production was blocked, a licensed series was open). Only the
|
||||
marker counts.
|
||||
|
||||
The escape route is one value — gluetun.vpn.serverCountries/serverCities set
|
||||
to a German exit — and its price is that it moves the WHOLE stack, all ten
|
||||
containers, qBittorrent included, into that jurisdiction and onto those
|
||||
servers. That is a deployment decision, not a chart setting to flip lightly.
|
||||
|
||||
OPEN, EXPLICITLY UNMEASURED: HLS ENTRIES
|
||||
Part of the catalogue is served as an HLS playlist instead of a progressive
|
||||
MP4. The image carries an ffmpeg-based M3U8 path, but whether such an entry
|
||||
completes end to end has not been measured. If it does not, those entries fail
|
||||
regardless of any geo block — and, per the paragraph above, without a reason.
|
||||
{{- end }}
|
||||
|
||||
EVERY SERVICE POINTS AT THE SAME POD IP
|
||||
One ClusterIP service per app{{ if .Values.recyclarr.enabled }} (recyclarr has none — no port in daemon mode){{ end }}, but all
|
||||
|
||||
@@ -19,10 +19,9 @@ Usage:
|
||||
resources:
|
||||
{{- include "freesailarr.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 takes none. The reference implementation lives in template-chart.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. The reference implementation lives in template-chart.
|
||||
*/}}
|
||||
{{- define "freesailarr.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -136,7 +135,7 @@ implementation lives in template-chart.
|
||||
{{/*
|
||||
Render additive env entries (the repo-wide <app>.env convention: tpl-rendered,
|
||||
supporting the value, secretKeyRef and configMapKeyRef forms). Shared as a
|
||||
helper here because this chart has ten containers, each with its own env block.
|
||||
helper here because this chart has nine containers, each with its own env block.
|
||||
|
||||
Usage:
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.<app>.env) | nindent 12 }}
|
||||
@@ -177,14 +176,6 @@ recyclarr is deliberately absent: in daemon mode it serves no port, so it gets
|
||||
no container port, no service and no ingress. flaresolverr is present - it
|
||||
listens on 8191 and therefore gets a service - but it has no ingress block in
|
||||
values and is never exposed from outside the cluster.
|
||||
|
||||
"port" is the app's PRIMARY port: the one its service is named after and the one
|
||||
an ingress points at. mediathekarr is the only app with a second one, because it
|
||||
runs two processes in one container - the SABnzbd role on 5007 (the UI, hence
|
||||
the primary) and the Newznab role Prowlarr consumes on 5008. Optional
|
||||
"extraPorts" entries ({name, port}) are additional ports of the SAME service and
|
||||
are declared to gluetun's firewall alongside the primary; they never take part
|
||||
in the ingress, which addresses a service port by name.
|
||||
*/}}
|
||||
{{- define "freesailarr.portApps" -}}
|
||||
{{- $apps := list -}}
|
||||
@@ -195,8 +186,7 @@ in the ingress, which addresses a service port by name.
|
||||
(dict "name" "radarr" "port" 7878)
|
||||
(dict "name" "sonarr" "port" 8989)
|
||||
(dict "name" "seerr" "port" 5055)
|
||||
(dict "name" "bazarr" "port" 6767)
|
||||
(dict "name" "mediathekarr" "port" 5007 "extraPorts" (list (dict "name" "ma-newznab" "port" 5008))) -}}
|
||||
(dict "name" "bazarr" "port" 6767) -}}
|
||||
{{- if (index $.Values $app.name).enabled -}}
|
||||
{{- $apps = append $apps $app -}}
|
||||
{{- end -}}
|
||||
@@ -224,9 +214,8 @@ ingress block with enabled: true. Used by the ingress template.
|
||||
{{/*
|
||||
The sub-path an app is served under in the shared-domain mode, e.g. "/sonarr".
|
||||
Null-safe by construction: an absent or erased <app>.ingress.path falls back to
|
||||
"/<app>", and to "/" for the two apps that can only live at the root - seerr,
|
||||
which has no base-path support at all, and mediathekarr, whose UI links its
|
||||
assets with root-absolute paths (see the guard in sharedIngressApps).
|
||||
"/<app>", and to "/" for seerr, which has no base-path support at all and can
|
||||
therefore only live at the root.
|
||||
|
||||
Input: dict "root" $ "app" <app name>.
|
||||
*/}}
|
||||
@@ -237,7 +226,7 @@ Input: dict "root" $ "app" <app name>.
|
||||
{{- $path = $config.path -}}
|
||||
{{- end -}}
|
||||
{{- if or (kindIs "invalid" $path) (eq (toString $path) "") -}}
|
||||
{{- if has .app (list "seerr" "mediathekarr") -}}
|
||||
{{- if eq .app "seerr" -}}
|
||||
{{- $path = "/" -}}
|
||||
{{- else -}}
|
||||
{{- $path = printf "/%s" .app -}}
|
||||
@@ -261,12 +250,9 @@ Two guards live here rather than in the ingress template, so that every
|
||||
consumer - the ingress objects, the UrlBase env vars and NOTES.txt - sees the
|
||||
same validated list:
|
||||
|
||||
1. seerr or mediathekarr under any path other than "/". seerr has no base-path
|
||||
support (upstream request open since May 2022) and MediathekArr's UI links
|
||||
its assets with root-absolute paths, so a chart that rendered either under
|
||||
a sub-path would produce something that provably cannot work. Both at "/"
|
||||
on one host is caught by guard 2 below - one of them then needs a host of
|
||||
its own.
|
||||
1. seerr under any path other than "/". seerr has no base-path support
|
||||
(upstream request open since May 2022), so a chart that rendered it under
|
||||
a sub-path would produce something that provably cannot work.
|
||||
2. Two apps claiming the same path on the shared host. Only paths on the
|
||||
shared host can collide; apps with their own host are on a different host
|
||||
and are therefore not part of this check.
|
||||
@@ -283,14 +269,11 @@ same validated list:
|
||||
{{- $config := (index $root.Values $app.name).ingress -}}
|
||||
{{- if not ($config.host | default "") -}}
|
||||
{{- $path := include "freesailarr.appPath" (dict "root" $root "app" $app.name) -}}
|
||||
{{- $rootOnly := dict
|
||||
"seerr" "seerr has no base-path support (https://github.com/Fallenbagel/jellyseerr/issues/97)"
|
||||
"mediathekarr" "MediathekArr links its UI assets with root-absolute paths (/download/css/..., measured), so a sub-path stays broken even behind a prefix-stripping rewrite - the browser then asks for those assets at the host root" -}}
|
||||
{{- if and (hasKey $rootOnly $app.name) (ne $path "/") -}}
|
||||
{{- fail (printf "%s.ingress.path is %q, but %s and it can therefore only be served at \"/\". Either leave %s.ingress.path at \"/\" or give it its own %s.ingress.host." $app.name $path (index $rootOnly $app.name) $app.name $app.name) -}}
|
||||
{{- if and (eq $app.name "seerr") (ne $path "/") -}}
|
||||
{{- fail (printf "seerr.ingress.path is %q, but seerr has no base-path support and can only be served at \"/\" (https://github.com/Fallenbagel/jellyseerr/issues/97). Either leave seerr.ingress.path at \"/\" or give seerr its own seerr.ingress.host." $path) -}}
|
||||
{{- end -}}
|
||||
{{- if hasKey $seen $path -}}
|
||||
{{- fail (printf "%s.ingress.path and %s.ingress.path are both %q on the shared host %q - every app under ingress.host needs its own path. Where neither can move (seerr and mediathekarr can only be served at \"/\"), give one of them its own <app>.ingress.host." (index $seen $path) $app.name $path $host) -}}
|
||||
{{- fail (printf "%s.ingress.path and %s.ingress.path are both %q on the shared host %q - every app under ingress.host needs its own path" (index $seen $path) $app.name $path $host) -}}
|
||||
{{- end -}}
|
||||
{{- $_ := set $seen $path $app.name -}}
|
||||
{{- $apps = append $apps (dict "name" $app.name "port" $app.port "path" $path) -}}
|
||||
@@ -373,9 +356,6 @@ alive. It is not what opens the ports today.
|
||||
{{- $ports := list -}}
|
||||
{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
|
||||
{{- $ports = append $ports (int $app.port) -}}
|
||||
{{- range $extra := $app.extraPorts | default list -}}
|
||||
{{- $ports = append $ports (int $extra.port) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $ports = append $ports 9999 -}}
|
||||
{{- join "," $ports -}}
|
||||
@@ -386,7 +366,7 @@ Whether a media category is mounted at all, i.e. whether at least one app that
|
||||
mounts it is enabled. "true" when it is, the empty string when it is not (an
|
||||
empty string is falsy in a template "if", so the result can be used directly).
|
||||
|
||||
downloads: qbittorrent, radarr, sonarr, mediathekarr
|
||||
downloads: qbittorrent, radarr, sonarr
|
||||
movies: radarr, bazarr
|
||||
tv: sonarr, bazarr
|
||||
|
||||
@@ -395,7 +375,7 @@ Input: dict "root" $ "category" <"downloads"|"movies"|"tv">.
|
||||
{{- define "freesailarr.mediaCategoryEnabled" -}}
|
||||
{{- $v := .root.Values -}}
|
||||
{{- if eq .category "downloads" -}}
|
||||
{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled $v.mediathekarr.enabled -}}true{{- end -}}
|
||||
{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled -}}true{{- end -}}
|
||||
{{- else if eq .category "movies" -}}
|
||||
{{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}}
|
||||
{{- else if eq .category "tv" -}}
|
||||
@@ -905,47 +885,6 @@ bazarr:
|
||||
- FOWNER
|
||||
- SETGID
|
||||
- SETUID
|
||||
mediathekarr:
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- >-
|
||||
wget -q -T 5 -O /dev/null "http://127.0.0.1:5008/api?t=caps" &&
|
||||
wget -q -T 5 -O /dev/null "http://127.0.0.1:5007/download/api?mode=version&output=json&apikey=x"
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /download/
|
||||
port: 5007
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /download/
|
||||
port: 5007
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 60
|
||||
resources:
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 0.1
|
||||
memory: 256Mi
|
||||
ephemeralStorage: 100Mi
|
||||
securityContext:
|
||||
runAsUser: {{ .Values.puid }}
|
||||
runAsGroup: {{ .Values.pgid }}
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
configInit:
|
||||
resources:
|
||||
limitFactor: 3
|
||||
@@ -966,7 +905,7 @@ configInit:
|
||||
|
||||
{{/*
|
||||
The effective pod security context. Separate from the per-container table
|
||||
because it is the one block shared by all containers of the pod.
|
||||
because it is the one block shared by all nine containers.
|
||||
*/}}
|
||||
{{- define "freesailarr.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}}
|
||||
|
||||
@@ -21,16 +21,5 @@ spec:
|
||||
port: {{ int $app.port }}
|
||||
targetPort: {{ $app.name }}
|
||||
name: {{ $app.name }}
|
||||
{{- /* An app with more than one port keeps them in ONE service rather than
|
||||
getting a second object: they belong to the same container and the
|
||||
same pod IP, and only the primary port is ever addressed by an
|
||||
ingress. mediathekarr is the only such app today - 5007 (SABnzbd
|
||||
role, primary) and 5008 (Newznab role). */}}
|
||||
{{- range $extra := $app.extraPorts | default list }}
|
||||
- protocol: TCP
|
||||
port: {{ int $extra.port }}
|
||||
targetPort: {{ $extra.name }}
|
||||
name: {{ $extra.name }}
|
||||
{{- end }}
|
||||
type: ClusterIP
|
||||
{{- end }}
|
||||
@@ -521,109 +521,6 @@ spec:
|
||||
subPath: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.mediathekarr.enabled }}
|
||||
# MediathekArr, the tenth container and the only one off by default: a fake
|
||||
# Usenet indexer for Prowlarr (Newznab, port 5008) and a fake SABnzbd
|
||||
# for Sonarr/Radarr (port 5007), fetching from the ARD and ZDF media
|
||||
# libraries. Both consumers sit in this pod and reach it over
|
||||
# 127.0.0.1, so it needs neither a service nor DNS to do its job.
|
||||
- name: mediathekarr
|
||||
# Tag AND digest: the tag stays readable, the digest is what is
|
||||
# actually pulled. "beta" moves, and "latest" is a 2024 build - see
|
||||
# the values comment.
|
||||
image: "{{ .Values.mediathekarr.image.repository }}:{{ .Values.mediathekarr.image.tag }}@{{ required "mediathekarr.image.digest is required - the tag alone is a moving reference" .Values.mediathekarr.image.digest }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
{{- if .Values.mediathekarr.startGate.enabled }}
|
||||
# THE START GATE. Kubernetes cannot order two regular containers, and
|
||||
# an initContainer waiting for gluetun would deadlock (gluetun only
|
||||
# starts once the init containers are done). So the wait happens here,
|
||||
# in front of the image entrypoint: poll gluetun's health server -
|
||||
# 200 only while the tunnel actually works - and exec the entrypoint
|
||||
# afterwards.
|
||||
#
|
||||
# Why this is not optional decoration: MediathekArr fetches its
|
||||
# ruleset once at start and never retries. Started before the tunnel,
|
||||
# it answered every Newznab search with 0 results for over eleven
|
||||
# minutes - container up, Ready, nothing in the log. The failure case
|
||||
# looks exactly like the healthy one, which is why the gate exits
|
||||
# instead of starting anyway when the timeout passes: a crash-looping
|
||||
# container is visible, a silently empty indexer is not.
|
||||
#
|
||||
# wget comes with the (Debian) image, bash is its entrypoint's own
|
||||
# interpreter - no extra tooling and no shell built into the chart.
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
deadline=$(( SECONDS + {{ int .Values.mediathekarr.startGate.timeoutSeconds }} ))
|
||||
echo "mediathekarr: waiting for gluetun to report healthy (127.0.0.1:9999)"
|
||||
until wget -q -T 5 -O /dev/null http://127.0.0.1:9999/; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "mediathekarr: gluetun did not report healthy within {{ int .Values.mediathekarr.startGate.timeoutSeconds }}s - exiting instead of starting without a tunnel, because a MediathekArr started too early answers every search with 0 results and reports nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep {{ int .Values.mediathekarr.startGate.intervalSeconds }}
|
||||
done
|
||||
echo "mediathekarr: gluetun healthy after ${SECONDS}s"
|
||||
{{- if gt (int .Values.mediathekarr.startGate.settleSeconds) 0 }}
|
||||
sleep {{ int .Values.mediathekarr.startGate.settleSeconds }}
|
||||
{{- end }}
|
||||
exec /app/docker_start.sh
|
||||
{{- end }}
|
||||
env:
|
||||
- name: TZ
|
||||
value: {{ .Values.timezone | quote }}
|
||||
{{- /* Both download paths are set by the chart, not left to the
|
||||
image: complete/ has to be visible to Sonarr/Radarr under
|
||||
the SAME absolute path (they are handed this value as the
|
||||
SABnzbd complete_dir, and a deviation imports nothing
|
||||
without an error), and incomplete/ has to live on the media
|
||||
claim rather than in an emptyDir, which would count against
|
||||
the container's ephemeral-storage limit and let one large
|
||||
download evict the whole pod. Setting them also keeps the
|
||||
image's built-in /data/mediathek default out of play, which
|
||||
the read-only root filesystem does not provide. */}}
|
||||
- name: DOWNLOAD_INCOMPLETE_PATH
|
||||
value: {{ printf "/downloads/%s/incomplete" .Values.mediathekarr.downloads.directory | quote }}
|
||||
- name: DOWNLOAD_COMPLETE_PATH
|
||||
value: {{ printf "/downloads/%s/complete" .Values.mediathekarr.downloads.directory | quote }}
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.mediathekarr.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 5007
|
||||
protocol: TCP
|
||||
name: mediathekarr
|
||||
- containerPort: 5008
|
||||
protocol: TCP
|
||||
name: ma-newznab
|
||||
livenessProbe:
|
||||
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "livenessProbe" "given" .Values.mediathekarr.livenessProbe) | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "readinessProbe" "given" .Values.mediathekarr.readinessProbe) | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "freesailarr.probeFor" (dict "root" $ "app" "mediathekarr" "kind" "startupProbe" "given" .Values.mediathekarr.startupProbe) | nindent 12 }}
|
||||
resources:
|
||||
{{- include "freesailarr.resourcesFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.resources) | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "freesailarr.securityContextFor" (dict "root" $ "app" "mediathekarr" "given" .Values.mediathekarr.securityContext) | nindent 12 }}
|
||||
volumeMounts:
|
||||
- mountPath: /app/config
|
||||
name: mediathekarr-config
|
||||
{{- /* The same claim, the same mount path as sonarr/radarr - that
|
||||
is what makes the complete_dir path identical on both
|
||||
sides. */}}
|
||||
- mountPath: /downloads
|
||||
name: {{ include "freesailarr.mediaVolumeName" (dict "root" $ "category" "downloads") }}
|
||||
{{- with .Values.media.downloads.subPath }}
|
||||
subPath: {{ . | quote }}
|
||||
{{- end }}
|
||||
# Not a measured requirement: the container ran through with a
|
||||
# read-only root and without a writable /tmp. Kept as cheap
|
||||
# insurance for the .NET runtime, which is the kind of consumer that
|
||||
# reaches for /tmp on a code path nobody exercised yet.
|
||||
- mountPath: /tmp
|
||||
name: mediathekarr-tmp
|
||||
{{- end }}
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
# The tun device gluetun needs to create the Wireguard tunnel.
|
||||
@@ -645,11 +542,11 @@ spec:
|
||||
setup with three separate claims still renders byte-identically to
|
||||
2.0.1 and the diff itself proves the change is inert there.
|
||||
*/}}
|
||||
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled .Values.mediathekarr.enabled }}
|
||||
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }}
|
||||
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }}
|
||||
- name: media-downloads
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr, sonarr or mediathekarr is enabled" .Values.media.downloads.existingClaim }}
|
||||
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
|
||||
@@ -682,16 +579,12 @@ spec:
|
||||
- name: seerr-tmp
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- if .Values.mediathekarr.enabled }}
|
||||
- name: mediathekarr-tmp
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
|
||||
# are immutable - toggling an app's enabled flag later requires recreating
|
||||
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
|
||||
# before upgrading; existing PVCs and their data survive).
|
||||
volumeClaimTemplates:
|
||||
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" "mediathekarr" }}
|
||||
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" }}
|
||||
{{- $config := index $.Values $app }}
|
||||
{{- if or (eq $app "gluetun") $config.enabled }}
|
||||
- metadata:
|
||||
|
||||
@@ -535,76 +535,6 @@
|
||||
"$ref": "#/definitions/containerSecurityContext"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mediathekarr": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"image": {
|
||||
"$ref": "#/definitions/pinnedImage"
|
||||
},
|
||||
"ingress": {
|
||||
"$ref": "#/definitions/appIngress"
|
||||
},
|
||||
"storage": {
|
||||
"$ref": "#/definitions/appStorage"
|
||||
},
|
||||
"startGate": {
|
||||
"description": "Delays the container until gluetun reports healthy. NOT null-safe on purpose and not merged through the fallback helper: this block decides whether the gate is rendered at all, and an erased block would silently produce a container that starts before the tunnel - the failure this chart writes three paragraphs about, and one that looks green.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"description": "Total wait before the container exits instead of starting without a tunnel. Keep the startup probe's budget (periodSeconds x failureThreshold) above it, or the kubelet kills the container while the gate is still waiting.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"settleSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"directory": {
|
||||
"description": "Sub-directory on the shared downloads claim holding incomplete/ and complete/. The chart derives DOWNLOAD_INCOMPLETE_PATH and DOWNLOAD_COMPLETE_PATH from it; complete/ is what Sonarr/Radarr are told to import from, under the identical absolute path.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"securityContext": {
|
||||
"$ref": "#/definitions/containerSecurityContext"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -816,27 +746,6 @@
|
||||
"object",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"pinnedImage": {
|
||||
"description": "Like image, plus the digest mediathekarr is pinned by: its tags are unusable (\"latest\" is a 2024 build, \"beta\" moves), so the tag stays as the readable label and the digest is what is actually pulled. Deliberately a separate definition instead of adding an optional digest to image: on every other image in this chart a digest key would be a value the templates never read - exactly the silent no-op the schema exists to reject (#68).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"digest": {
|
||||
"description": "Image digest, sha256:<64 hex>. Required - the template refuses to render without it.",
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-261
@@ -4,11 +4,10 @@
|
||||
# the Wireguard tunnel.
|
||||
#
|
||||
# Image pinning (documented deviation from the Chart.AppVersion standard):
|
||||
# NO image tag comes from appVersion. All ten images, gluetun included, are
|
||||
# NO image tag comes from appVersion. All nine images, gluetun included, are
|
||||
# pinned here via <app>.image.repository / <app>.image.tag, because the stack
|
||||
# combines ten independently versioned upstream projects and none of them is
|
||||
# "the" one app version. mediathekarr additionally carries an image.digest -
|
||||
# see the reasoning in its own block at the end of this file.
|
||||
# combines nine independently versioned upstream projects and none of them is
|
||||
# "the" one app version.
|
||||
#
|
||||
# That is also what puts every image under Renovate: the built-in helm-values
|
||||
# manager reads image.repository/image.tag pairs out of this file and proposes
|
||||
@@ -85,7 +84,7 @@ storage:
|
||||
# Media libraries as pre-existing PVCs (shared with the separately deployed
|
||||
# Jellyfin) - the chart mounts but never creates them. Each claim is required
|
||||
# as soon as an app that mounts it is enabled:
|
||||
# downloads: qbittorrent, radarr, sonarr, mediathekarr
|
||||
# downloads: qbittorrent, radarr, sonarr
|
||||
# movies: radarr, bazarr
|
||||
# tv: sonarr, bazarr
|
||||
#
|
||||
@@ -875,259 +874,3 @@ bazarr:
|
||||
- FOWNER
|
||||
- SETGID
|
||||
- SETUID
|
||||
|
||||
# MediathekArr: the tenth container, and the only one that is OFF by default. It fakes a Usenet indexer towards Prowlarr and a SABnzbd downloader
|
||||
# towards Sonarr/Radarr, and fetches the content over plain HTTP from the ARD
|
||||
# and ZDF media libraries. Both consumers already sit in this pod and share
|
||||
# gluetun's network namespace, so they reach it over 127.0.0.1 - no service, no
|
||||
# DNS, no ingress detour.
|
||||
#
|
||||
# Off by default because it is additive and nobody's existing release should
|
||||
# grow a container on an upgrade: enabling it creates a new
|
||||
# volumeClaimTemplate, which a StatefulSet cannot take without being recreated
|
||||
# (see the NOTES and the volumeClaimTemplates comment in the StatefulSet).
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# THE GEO CAVEAT, AND HOW TO CHECK IT YOURSELF
|
||||
# ----------------------------------------------------------------------------
|
||||
# The tunnel leaves through Zurich by this chart's default, and ARD and ZDF
|
||||
# block parts of their catalogue outside Germany. Every container hangs in
|
||||
# gluetun's namespace; there is no bypass for a single container.
|
||||
#
|
||||
# Measured, 480 URLs across 12 channels: 104 of 473 loadable URLs were blocked
|
||||
# through Zurich (22.0%), none of them on the German side. The spread is very
|
||||
# uneven - ONE 97.5%, ZDFneo 47.5%, ZDFinfo 37.5%, Das Erste 20.5%, ZDF 10.3%,
|
||||
# BR 0% - so a single number tells you little about the show you actually want.
|
||||
#
|
||||
# THE PROCEDURE, NOT A TABLE OF SERIES. A list of titles is stale in half a
|
||||
# year; the marker in the CDN path is not. Over two measurement rounds it
|
||||
# predicted the outcome perfectly - 584 of 584 cases in the full survey, not a
|
||||
# single misclassification. Look at the media URL of an entry and read the
|
||||
# path:
|
||||
#
|
||||
# /de/, /geo/de/, /deutschlandweit/, geoavailability_D -> blocked outside DE
|
||||
# /weltweit/ -> open
|
||||
# /none/ (ZDF) -> open
|
||||
# /dach/, deChAt (WDR variant) -> open, Switzerland
|
||||
# is part of DACH
|
||||
#
|
||||
# The obvious rule of thumb "own production = available worldwide" is MEASURABLY
|
||||
# WRONG and is named here so nobody re-invents it: an ARD own production was
|
||||
# blocked, a licensed series was open. Only the marker counts.
|
||||
#
|
||||
# THE ESCAPE ROUTE AND ITS PRICE. The exit is a deployment decision, not a chart
|
||||
# property: set gluetun.vpn.serverCountries/serverCities to a German exit and
|
||||
# the blocks disappear. The price is that this moves the WHOLE stack, all ten
|
||||
# containers - qBittorrent included - into that jurisdiction and onto those
|
||||
# servers. There is no per-container exit, and this chart deliberately does not
|
||||
# pretend otherwise.
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# THE fail_message: null TRAP - READ THIS BEFORE DEBUGGING A FAILED DOWNLOAD
|
||||
# ----------------------------------------------------------------------------
|
||||
# MediathekArr never checks the HTTP status code. Measured A/B with an identical
|
||||
# NZB: direct (DE) -> status Completed, 94,235,525 bytes, fail_message null;
|
||||
# through Zurich -> status Failed, 0 bytes, fail_message ALSO null.
|
||||
#
|
||||
# On a geo-blocked fetch it writes the ~476-byte HTML error page out as .mp4 and
|
||||
# logs "Download completed". Only mkvmerge then refuses the file (exit code 2)
|
||||
# and flips the status to Failed. Two consequences:
|
||||
#
|
||||
# 1. Sonarr is told "failed" without a reason. A geo block looks exactly like
|
||||
# a broken server, an expired URL or a full disk - and the container log
|
||||
# does not mention the 403 either.
|
||||
# 2. That protection is an accident, not error handling. It rests entirely on
|
||||
# mkvmerge rejecting an HTML page. Any geo response mkvmerge would accept
|
||||
# is reported as Completed, and then a broken file walks into the library.
|
||||
# It did not happen in either measurement round (Akamai consistently
|
||||
# returns HTML), but that is not something to rely on.
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# OPEN QUESTION, EXPLICITLY UNMEASURED: HLS
|
||||
# ----------------------------------------------------------------------------
|
||||
# Part of the catalogue is served as an HLS playlist (application/x-mpegURL)
|
||||
# instead of a progressive MP4. The image carries an ffmpeg-based M3U8 path
|
||||
# (log lines "Starting M3U8 download" / "M3U8 download completed", and ffmpeg is
|
||||
# present in the image), so the code is there - but whether such an entry
|
||||
# actually completes end to end has NOT been measured. If it does not, those
|
||||
# entries fail regardless of any geo block. Stated as an open question on
|
||||
# purpose: no assumption in either direction.
|
||||
mediathekarr:
|
||||
enabled: false
|
||||
# Pinned BY DIGEST, unlike every other image in this chart, because the tags
|
||||
# are unusable: "latest" is from 2024-11-12 (= v0.2.5) and the upstream README
|
||||
# points at "beta", which is a moving tag. tag + digest together keep the
|
||||
# human-readable line and the immutable reference.
|
||||
#
|
||||
# The image is SINGLE-ARCH, linux/amd64 only - there is no arm64 variant. The
|
||||
# target cluster is amd64 throughout, so this is a note, not a problem; on a
|
||||
# mixed cluster the pod would need a nodeSelector this chart does not offer.
|
||||
#
|
||||
# Renovate: the built-in helm-values manager cannot follow this pair (the tag
|
||||
# carries no version), so renovate.json5 has a customManagers block that
|
||||
# matches exactly these three lines and proposes digest updates for the beta
|
||||
# tag. Renaming or reordering the keys silently switches that off - the rule
|
||||
# would still be valid, it just would not match any more (#158).
|
||||
image:
|
||||
repository: docker.io/pcjones/mediathekarr
|
||||
tag: beta
|
||||
digest: sha256:3ed7e1487810e1519301bd56a0c420e260e428295b029a47806c0397e4a52a33
|
||||
# TWO ports, not one, and the upstream documentation only mentions the first:
|
||||
# 5007 MediathekArrDownloader - the SABnzbd role and the web UI
|
||||
# 5008 MediathekArrServer - the Newznab role Prowlarr talks to
|
||||
# The SABnzbd API lives under /download/api, NOT under /api: a request to
|
||||
# /api on 5007 answers with a Newznab validation error and sends you looking
|
||||
# in the wrong place. Both ports are declared to gluetun's firewall
|
||||
# automatically (see the firewallInputPorts helper).
|
||||
#
|
||||
# No ingress by default: MediathekArr's UI links its assets with root-absolute
|
||||
# paths (/download/css/..., measured), so it can only be served at the ROOT of
|
||||
# a host - a sub-path is broken even behind a prefix-stripping rewrite,
|
||||
# because the browser then requests the assets at the host root. On the shared
|
||||
# domain the root belongs to seerr, so enabling this ingress there needs
|
||||
# either seerr switched off or a host of its own here. Both consumers reach
|
||||
# MediathekArr over localhost and need no ingress at all; the UI is only
|
||||
# needed to run the setup wizard, for which a port-forward is enough.
|
||||
ingress:
|
||||
enabled: false
|
||||
host: null
|
||||
# Read only in the shared-domain mode. "/" is the only value that works;
|
||||
# anything else fails the render rather than producing a broken UI.
|
||||
path: /
|
||||
# clusterIssuer: null
|
||||
# className: null
|
||||
# annotations: {}
|
||||
storage:
|
||||
# Size of the MediathekArr config volume (/app/config, a small JSON).
|
||||
size: 1Gi
|
||||
# THE START GATE - the sharpest point of this container, and the one whose
|
||||
# failure mode looks green.
|
||||
#
|
||||
# If MediathekArr starts before the tunnel is up, its ruleset fetch fails once
|
||||
# and is never retried: the instance then answered every Newznab search with 0
|
||||
# results for over eleven minutes, while the container was up, Ready and
|
||||
# logged nothing wrong. A green pod proves nothing here. With the start
|
||||
# delayed until gluetun reports healthy, the ruleset loads and the search
|
||||
# matches an instance running with direct egress (the search itself is not
|
||||
# geo-blocked - 56 hits through either exit).
|
||||
#
|
||||
# Kubernetes cannot express "start after that container": gluetun is a regular
|
||||
# container, and an initContainer waiting for it would deadlock. So the gate
|
||||
# lives in this container's command - it polls gluetun's health server on
|
||||
# 127.0.0.1:9999 (the same endpoint gluetun's own probes use, 200 only while
|
||||
# the tunnel works) and only then execs the image entrypoint.
|
||||
#
|
||||
# On timeout the container EXITS instead of starting anyway. That is
|
||||
# deliberate: a crash-looping container is visible, a silently empty indexer
|
||||
# is not.
|
||||
#
|
||||
# Keep startupProbe budget > timeoutSeconds + app start, or the kubelet kills
|
||||
# the container while the gate is still waiting. Defaults: 180s gate,
|
||||
# 5s * 60 = 300s startup budget.
|
||||
startGate:
|
||||
enabled: true
|
||||
# Poll interval and total budget for gluetun becoming healthy. gluetun's own
|
||||
# startup budget is 150s, so 180s covers a normal handshake with room.
|
||||
intervalSeconds: 3
|
||||
timeoutSeconds: 180
|
||||
# Extra pause after gluetun reports healthy, before the app starts. NOT a
|
||||
# measured requirement - gluetun's 200 is the actual condition - but a cheap
|
||||
# buffer for DNS and routing to settle. 0 disables it.
|
||||
settleSeconds: 10
|
||||
# WHERE THE DOWNLOADS GO. Both directories live on the SHARED downloads claim
|
||||
# (media.downloads), mounted at /downloads exactly like sonarr, radarr and
|
||||
# qbittorrent mount it, under this sub-directory:
|
||||
#
|
||||
# /downloads/<directory>/incomplete work in progress
|
||||
# /downloads/<directory>/complete finished, plus one sub-dir per category
|
||||
#
|
||||
# Two reasons, both of them the kind that fails silently:
|
||||
#
|
||||
# 1. complete/ MUST be visible to Sonarr/Radarr under the SAME absolute
|
||||
# path. The chart hands that path to MediathekArr
|
||||
# (DOWNLOAD_COMPLETE_PATH), which reports it to Sonarr as the SABnzbd
|
||||
# complete_dir. Every container here mounts the claim at /downloads, so
|
||||
# the paths are identical by construction - a deviation imports nothing,
|
||||
# without an error.
|
||||
# 2. incomplete/ must NOT be an emptyDir. An emptyDir counts against the
|
||||
# container's ephemeral-storage limit, and one large download makes the
|
||||
# kubelet evict the WHOLE pod - all ten containers. Same trap as the one
|
||||
# documented in ytdlp-web-player.
|
||||
#
|
||||
# Do not set DOWNLOAD_COMPLETE_PATH or DOWNLOAD_INCOMPLETE_PATH in the env
|
||||
# block below: the chart always sets both, and a second entry of the same name
|
||||
# is not a defined state (see README, "Environment variables").
|
||||
downloads:
|
||||
directory: mediathekarr
|
||||
# Extra environment variables, appended to the ones the chart always sets.
|
||||
# Useful ones the image reads: CATEGORIES, MAX_PARALLEL_DOWNLOADS,
|
||||
# MEDIATHEKARR_API_BASE_URL.
|
||||
env: []
|
||||
# Probes on measured 200 endpoints - MediathekArr has NO health endpoint at
|
||||
# all (/health, /healthz, /api/health, /download/health, /status and /ping all
|
||||
# answer 404).
|
||||
#
|
||||
# Liveness is an exec on purpose: one container runs TWO processes, and the
|
||||
# kubelet allows one probe of each kind. The Newznab half can wedge while the
|
||||
# downloader half still answers, and that is exactly the failure that makes
|
||||
# searches come back empty. The command checks both ports and restarts the
|
||||
# container when either stops answering. (The entrypoint already exits when
|
||||
# one of the two processes CRASHES - the exec probe covers the case where it
|
||||
# is alive but not serving.)
|
||||
#
|
||||
# Readiness and startup use the downloader port: that is the one the Service
|
||||
# and any ingress point at, and /download/ answers 200 without a query string.
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- >-
|
||||
wget -q -T 5 -O /dev/null "http://127.0.0.1:5008/api?t=caps" &&
|
||||
wget -q -T 5 -O /dev/null "http://127.0.0.1:5007/download/api?mode=version&output=json&apikey=x"
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /download/
|
||||
port: 5007
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
# The startup probe gates liveness/readiness AND has to outlast the start gate
|
||||
# (see startGate above): 5s * 60 = 300s against a 180s gate.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /download/
|
||||
port: 5007
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 60
|
||||
resources:
|
||||
limitFactor: 3
|
||||
# ephemeral-storage stays small on purpose: downloads land on the media
|
||||
# claim, not on the container filesystem (see downloads above).
|
||||
requests:
|
||||
cpu: 0.1
|
||||
memory: 256Mi
|
||||
ephemeralStorage: 100Mi
|
||||
# Measured, including a real download and the mkvmerge remux: the container
|
||||
# runs fully hardened - non-root, uid/gid from puid/pgid,
|
||||
# readOnlyRootFilesystem: true - as long as its writable paths are volumes.
|
||||
# Those are /app/config (the config claim) and the two download directories on
|
||||
# the media claim; the image's built-in default /data/mediathek is never
|
||||
# touched, because the chart overrides both paths by environment (measured:
|
||||
# read-only root, no /data/mediathek mount, no error).
|
||||
# PUID/PGID are NOT needed (the image's gosu path is only taken when they are
|
||||
# set), and the
|
||||
# commented-out non-root block in its Dockerfile is outdated for the beta.
|
||||
# runAsUser/runAsGroup come from the global puid/pgid values in the template -
|
||||
# do not set them here.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
@@ -5,8 +5,8 @@ icon: https://gotenberg.dev/img/logo.png
|
||||
|
||||
type: application
|
||||
|
||||
version: 8.0.3+up8.37.0
|
||||
appVersion: "8.37.0"
|
||||
version: 8.0.1+up8.36.0
|
||||
appVersion: "8.36.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "gotenberg.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "gotenberg.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "gotenberg.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -4,7 +4,7 @@ description: A Helm chart to deploy Home Assistant on Kubernetes
|
||||
icon: https://www.home-assistant.io/images/home-assistant-logo.svg
|
||||
type: application
|
||||
|
||||
version: 6.1.1+up2026.8.3
|
||||
version: 6.1.0+up2026.8.3
|
||||
|
||||
appVersion: "2026.8.3"
|
||||
|
||||
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "homeassistant.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "homeassistant.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -169,9 +167,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "homeassistant.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -3,7 +3,7 @@ name: matrix-synapse
|
||||
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
|
||||
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
|
||||
type: application
|
||||
version: 7.1.1+upv1.159.0
|
||||
version: 7.1.0+upv1.159.0
|
||||
appVersion: "v1.159.0"
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "matrix-synapse.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "matrix-synapse.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "matrix-synapse.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -5,8 +5,8 @@ icon: https://avatars.githubusercontent.com/u/988985
|
||||
|
||||
type: application
|
||||
|
||||
version: 4.1.3+up1.47.1
|
||||
appVersion: "1.47.1"
|
||||
version: 4.1.1+up1.46.5
|
||||
appVersion: "1.46.5"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "minecraft-router.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "minecraft-router.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "minecraft-router.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -23,7 +23,7 @@ type: application
|
||||
# continuously, so a pod restart could pull a different image than the last one
|
||||
# without anything being recorded anywhere. The dated tags are immutable, so the
|
||||
# image is now pinned by the chart version like every other chart in this repo.
|
||||
version: 6.0.1+up2026.8.2.java25
|
||||
version: 5.0.0+up2026.8.2.java25
|
||||
appVersion: "2026.8.2-java25"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -204,11 +204,9 @@ OPERATORS ARE SYNCHRONIZED FROM VALUES, NOT FROM THE GAME
|
||||
SECRETS — WHICH FIELDS THE 1PASSWORD ITEM MUST CARRY
|
||||
The OnePasswordItem CR {{ $secret }} is rendered unconditionally, so
|
||||
secrets.minecraftServerRef has to be set even when nothing consumes a key from
|
||||
it; it has no default and would otherwise render an itemPath of null. The
|
||||
template guards it with required(), so a release without it does not render at
|
||||
all — this note tells you why the guard is there, not that you got past it.
|
||||
it; it has no default and would otherwise render an itemPath of null.
|
||||
|
||||
secrets.minecraftServerRef → {{ .Values.secrets.minecraftServerRef }}
|
||||
secrets.minecraftServerRef → {{ .Values.secrets.minecraftServerRef | default "NOT SET" }}
|
||||
|
||||
The operator copies the item's FIELDS into the Kubernetes Secret, one key per
|
||||
field name. This release consumes:
|
||||
|
||||
@@ -19,10 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "minecraft-server.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 takes none. The reference implementation lives in template-chart.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. The reference implementation lives in template-chart.
|
||||
*/}}
|
||||
{{- define "minecraft-server.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -347,9 +346,9 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14). The reference implementation lives in template-chart.
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies. The reference implementation
|
||||
lives in template-chart.
|
||||
*/}}
|
||||
{{- define "minecraft-server.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ required "A 1Password item reference must be provided (secrets.minecraftServerRef) - the OnePasswordItem is rendered unconditionally, so it is needed even with RCON and CurseForge both off" .Values.secrets.minecraftServerRef }}
|
||||
itemPath: {{ .Values.secrets.minecraftServerRef }}
|
||||
@@ -28,7 +28,6 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"minecraftServerRef": {
|
||||
"description": "op:// item path for the 1Password operator. Required even when RCON and the CurseForge downloader are both off: the OnePasswordItem is rendered unconditionally. The item's CONTENT may be empty in that case, the reference may not. Enforced by required() in the template, not here: the null default keeps this type widened, and a schema \"required\" entry could never fire against a key that is present with value null.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,15 +37,9 @@ image:
|
||||
tagOverride: ""
|
||||
|
||||
secrets:
|
||||
# REQUIRED - op:// item path for the 1Password operator. The OnePasswordItem
|
||||
# CR is rendered unconditionally, so this reference has to be set even when
|
||||
# nothing consumes a key from it; it has no default and would otherwise render
|
||||
# an itemPath of null. The item's CONTENT may be empty in that case: it only
|
||||
# needs to carry a "curseforge-api-key" key when curseforge.enabled is true
|
||||
# and an "rcon-password" key when server.rcon.enabled is true. Without the
|
||||
# value the render aborts - required() in minecraft-server.secret.yaml is the
|
||||
# whole enforcement, and values.schema.json cannot help here (README,
|
||||
# guideline 12).
|
||||
# op:// item path for the 1Password operator. Only needs to carry a
|
||||
# "curseforge-api-key" key when curseforge.enabled is true and an
|
||||
# "rcon-password" key when server.rcon.enabled is true.
|
||||
#
|
||||
# Documented limit (chart 3.0.0): changing this path rolls the pod (the
|
||||
# StatefulSet hashes the rendered OnePasswordItem into checksum/secret-ref),
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon-
|
||||
|
||||
type: application
|
||||
|
||||
version: 7.1.3+up1.10.1
|
||||
version: 7.1.2+up1.10.1
|
||||
appVersion: 1.10.1
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "outline.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "outline.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "outline.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -5,9 +5,9 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s
|
||||
|
||||
type: application
|
||||
|
||||
version: 12.1.3+up3.2.1
|
||||
version: 12.1.1+up3.1.0
|
||||
|
||||
appVersion: 3.2.1
|
||||
appVersion: 3.1.0
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- 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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "paperless-ngx.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/PatchMon/PatchMon/main/frontend/public/a
|
||||
|
||||
type: application
|
||||
|
||||
version: 7.0.2+up2.1.3
|
||||
version: 7.0.1+up2.1.3
|
||||
appVersion: "2.1.3"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "patchmon.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "patchmon.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "patchmon.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi
|
||||
|
||||
type: application
|
||||
|
||||
version: 8.1.6+up4.15.2
|
||||
appVersion: "4.15.2"
|
||||
version: 8.1.3+up4.15.0
|
||||
appVersion: "4.15.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "rallly.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "rallly.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "rallly.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
+33
-199
@@ -16,45 +16,15 @@
|
||||
// Commit subjects in this repo are plain sentences, not conventional
|
||||
// commits. Renovate's own merged PRs already look like that.
|
||||
":semanticCommitsDisabled",
|
||||
// Same preset as cluster-config, so both repos behave identically on the
|
||||
// shared jlab-renovate-bot instance. Two of the things it pulls in are
|
||||
// load-bearing here: `helpers:pinGitHubActionDigests` (wanted — the
|
||||
// workflow files move from `actions/checkout@v7` to SHA pins) and
|
||||
// `docker:pinDigests`, which is turned off again for the two managers that
|
||||
// cannot carry a digest — see the pinDigests rule below. The
|
||||
// npm-flavoured members (`:pinDevDependencies`,
|
||||
// `security:minimumReleaseAgeNpm`, `:maintainLockFilesWeekly`) are inert
|
||||
// in a repo without a package manager.
|
||||
"config:best-practices",
|
||||
],
|
||||
|
||||
timezone: "Europe/Berlin",
|
||||
labels: ["renovate"],
|
||||
dependencyDashboard: true,
|
||||
labels: ["dependencies"],
|
||||
|
||||
// No caps, as in cluster-config. The first run after a quiet week can open
|
||||
// many PRs at once and every chart PR starts a k3d install-test; on Gitea a
|
||||
// whole matrix runs inside one job on one runner, so those queue instead of
|
||||
// fanning out. That queue is the accepted price for not having updates
|
||||
// trickle in four per hour — nothing merges without a review anyway.
|
||||
prConcurrentLimit: 100,
|
||||
prHourlyLimit: 0,
|
||||
|
||||
// Minor and patch arrive as separate PRs rather than one combined branch.
|
||||
// With 24 charts a shared branch means one red install-test blocks unrelated
|
||||
// updates, and the separation is what lets a patch be reviewed in seconds.
|
||||
separateMinorPatch: true,
|
||||
|
||||
// Every update automerges once the PR is mergeable, majors included, and
|
||||
// `platformAutomerge` hands that decision to the forge instead of letting
|
||||
// Renovate poll: Gitea arms the PR and merges it when the required check
|
||||
// (`validate-charts`) passes AND the required review exists. Both are active
|
||||
// on `main`, which is why this is not unattended merging — the review is the
|
||||
// gate, and without it the armed PR simply sits there. Same reasoning as
|
||||
// cluster-config, where the dashboard approval was retired for it.
|
||||
automerge: true,
|
||||
automergeType: "pr",
|
||||
platformAutomerge: true,
|
||||
// 21 charts plus the workflows: without a cap the first run after a quiet
|
||||
// week opens a wall of PRs, and every chart PR spins up a k3d install-test.
|
||||
prConcurrentLimit: 10,
|
||||
prHourlyLimit: 4,
|
||||
|
||||
// template-chart is the scaffold. Its image is the literal placeholder
|
||||
// ghcr.io/ORG/IMAGE and its appVersion is "xxx" — nothing to update, and the
|
||||
@@ -110,43 +80,26 @@
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"Routine updates wait three days before Renovate even opens the PR. Automerge is",
|
||||
"global now (top of this file), so this rule no longer decides WHAT merges by itself —",
|
||||
"it decides how fresh a release may be when it does. A merge here is never just a",
|
||||
"merge: every chart change publishes a chart straight away, and a release that is",
|
||||
"yanked hours after it appeared should not reach the registry at all.",
|
||||
"cluster-config spends the same three days on the dependencies where a merge is",
|
||||
"expensive (authentik, rancher); here that is every one of them.",
|
||||
"",
|
||||
"Majors deliberately carry no age gate: they are not the updates that slip through",
|
||||
"unnoticed, they are the ones held at the review, and separateMultipleMajor makes",
|
||||
"them arrive one line at a time anyway.",
|
||||
"Patch, minor and digest updates merge themselves once the PR pipeline is green.",
|
||||
"Minor is included because the sidecar images barely move in patch steps —",
|
||||
"alpine 3.22 -> 3.24 and nginx 1.30.4 -> 1.31.4 are both minor, so a patch-only",
|
||||
"rule would practically never fire. Majors stay manual.",
|
||||
],
|
||||
matchUpdateTypes: ["patch", "minor", "digest"],
|
||||
automerge: true,
|
||||
automergeType: "pr",
|
||||
// Renovate waits for the checks itself instead of handing the PR to the
|
||||
// forge's own auto-merge. Both of them — GitHub's, and the auto-merge
|
||||
// that takes the merge queue's place on Gitea, which has none — merge
|
||||
// immediately when the branch has no required status checks configured,
|
||||
// and validate-charts is not (yet) a required check on either side. This
|
||||
// is a statement about the branch protection, not about the platform:
|
||||
// once required checks exist on `main`, true is the better value.
|
||||
platformAutomerge: false,
|
||||
// A release that is hours old should not walk into main unattended; a
|
||||
// chart publish follows the merge straight away.
|
||||
minimumReleaseAge: "3 days",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"`config:best-practices` brings `docker:pinDigests`, and two of this repo's managers",
|
||||
"have nowhere to put a digest. custom.regex reads `appVersion:` out of a Chart.yaml,",
|
||||
"and that value is a TAG by construction: the templates render",
|
||||
"`<repo>:{{ .Chart.AppVersion }}`, and the chart version's `+up<appVersion>` suffix is",
|
||||
"built from the same string — an `@sha256:...` fits into neither. cluster-config makes",
|
||||
"the identical opt-out for its fleet manager, for the identical reason.",
|
||||
"",
|
||||
"helm-values is opted out too, but as a decision rather than a necessity: sidecar",
|
||||
"images there COULD be pinned by digest, and every digest change would then be a chart",
|
||||
"patch plus a publish. Whether that is worth it is its own question, not part of this",
|
||||
"alignment.",
|
||||
"",
|
||||
"Unaffected: the freesailarr/mediathekarr block below, which extracts a digest that is",
|
||||
"written in values.yaml on purpose (pinDigests only ADDS digests where none exists),",
|
||||
"and the GitHub Actions pinning from `helpers:pinGitHubActionDigests` — a different",
|
||||
"datasource, and wanted.",
|
||||
],
|
||||
matchManagers: ["custom.regex", "helm-values"],
|
||||
pinDigests: false,
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"Chart app versions. Kept out of the automerge grouping decisions above only by label,",
|
||||
@@ -168,10 +121,10 @@
|
||||
"This rule adds only a label. It exists because the update matters more here than",
|
||||
"its size suggests: a subchart is invisible to everything downstream that watches",
|
||||
"Helm release versions, so this PR is the ONLY way a new proxy version - a security",
|
||||
"fix included - ever reaches a cluster. Automerge carries that PR to main once it is",
|
||||
"reviewed and the check is green, and ci/renovate-bump-chart.sh bumps the chart patch",
|
||||
"that publishes it (appVersion is unchanged, so it takes the patch branch). A major",
|
||||
"goes the same way - it is the review, not a separate policy, that gives it a look.",
|
||||
"fix included - ever reaches a cluster. Under the automerge rule above, patch and",
|
||||
"minor updates do that on their own once the pipeline is green, and",
|
||||
"ci/renovate-bump-chart.sh bumps the chart patch that publishes it (appVersion is",
|
||||
"unchanged, so it takes the patch branch). A major arrives as a PR to merge by hand.",
|
||||
],
|
||||
matchManagers: ["helmv3"],
|
||||
addLabels: ["subchart"],
|
||||
@@ -259,49 +212,14 @@
|
||||
//
|
||||
// When adding a chart, add a block here — see README "Creating a new chart".
|
||||
//
|
||||
// Exception, deliberately no APP-VERSION block: `freesailarr`. It bundles
|
||||
// ten independently versioned upstreams, so none of them is "the" app
|
||||
// version and no image tag comes from appVersion — they are pinned as
|
||||
// Exception, deliberately no block: `freesailarr`. It bundles nine
|
||||
// independently versioned upstreams, so none of them is "the" app version
|
||||
// and no image tag comes from appVersion — all nine are pinned as
|
||||
// image.repository/image.tag in its values.yaml, where the built-in
|
||||
// helm-values manager already finds them and proposes one update per
|
||||
// image. Adding an appVersion block here would either duplicate those
|
||||
// updates or rewrite an appVersion that selects nothing. Its appVersion is
|
||||
// a hand-maintained marker; see freesailarr/Chart.yaml. The one image the
|
||||
// built-in manager cannot follow — mediathekarr, pinned by digest — has its
|
||||
// own block directly below.
|
||||
|
||||
{
|
||||
description: [
|
||||
"freesailarr / mediathekarr: the only image in this repo pinned by DIGEST, because its",
|
||||
"tags carry no version at all — 'latest' is a 2024 build (v0.2.5) and the upstream README",
|
||||
"points at the moving 'beta'. The built-in helm-values manager reads repository/tag and",
|
||||
"would have nothing to compare, so this manager captures all three keys and lets Renovate",
|
||||
"propose digest updates for the beta tag (currentDigest + currentValue = a digest-only",
|
||||
"lookup; it arrives as a 'digest' update, which the three-day age gate above covers and",
|
||||
"automerge then carries once the PR is reviewed and green).",
|
||||
"",
|
||||
"The pattern is anchored on the three keys in their values.yaml order: repository, tag,",
|
||||
"digest. Reordering or renaming them does not fail anything — it silently stops matching,",
|
||||
"and the image then never gets an update again (#158: a valid rule is not a matching rule).",
|
||||
"",
|
||||
"Verified by dry-run (renovate 44.69.11, --platform=local --dry-run=lookup, real registry),",
|
||||
"two fixtures, because 'no update' is what BOTH a working and a broken rule look like while",
|
||||
"the pin is current:",
|
||||
" currentDigest = the pinned one -> extracted with currentValue 'beta', updates [] (the",
|
||||
" beta tag really does still point at it).",
|
||||
" currentDigest = sha256:0000... -> updates [{updateType: digest, newValue: beta,",
|
||||
" newDigest: sha256:3ed7e14...}] on branch renovate/docker.io-pcjones-mediathekarr-beta.",
|
||||
"The same run also shows the built-in helm-values manager finding this image and skipping it",
|
||||
"(skipReason invalid-value, 'no currentDigest or pinDigests' — it does not read the digest",
|
||||
"key), so the two managers cannot produce competing PRs.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["freesailarr/values.yaml"],
|
||||
matchStrings: [
|
||||
"repository: (?<depName>docker\\.io/pcjones/mediathekarr)\\s+tag: (?<currentValue>[^\\s]+)\\s+digest: (?<currentDigest>sha256:[0-9a-f]{64})",
|
||||
],
|
||||
datasourceTemplate: "docker",
|
||||
},
|
||||
// image. Adding a block here would either duplicate those updates or
|
||||
// rewrite an appVersion that selects nothing. Its appVersion is a
|
||||
// hand-maintained marker; see freesailarr/Chart.yaml.
|
||||
|
||||
{
|
||||
description: "apache-tika: four-part version plus flavour suffix (3.3.1.0-full); the compatibility group keeps the chart on -full.",
|
||||
@@ -334,29 +252,6 @@
|
||||
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"],
|
||||
@@ -478,9 +373,8 @@
|
||||
// isGreaterThan("2026.8.22-777ba8fa4", current) -> false, equals -> true
|
||||
// 777ba8fa4 was pushed at 03:06 and 9fea41204 at 05:38 the SAME day, so
|
||||
// the build group makes Renovate offer the older image as an update -
|
||||
// a downgrade, and one that automerge would carry to main as soon as the
|
||||
// PR is waved through as the routine date bump it looks like. With NaN
|
||||
// the pair is neither equal nor ordered at all.
|
||||
// a downgrade, unattended, because date updates land in the patch/minor
|
||||
// automerge rule. With NaN the pair is neither equal nor ordered at all.
|
||||
//
|
||||
// Uncaptured, the hash still has to be THERE (the pattern is anchored),
|
||||
// it just takes no part in the comparison: two builds of one day are
|
||||
@@ -508,33 +402,6 @@
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"sure: the registry carries four kinds of tag side by side - releases (0.7.4), the",
|
||||
"prereleases -alpha.N / -rc.N / -hotfix.N, the moving names stable/latest/nightly,",
|
||||
"and one sha-<commit> per build. semver versioning is the whole filter: it parses",
|
||||
"the release line, marks every prerelease isStable === false so `ignoreUnstable`",
|
||||
"never offers one, and rejects the moving names and the sha- tags as unparseable",
|
||||
"rather than ever selecting one. That matters more here than the tag list suggests,",
|
||||
"because automerge is global in this config (top of the file) and majors carry no",
|
||||
"age gate: an update this manager offers is one that merges as soon as the required",
|
||||
"check and the required review are there, so the filtering has to happen at the",
|
||||
"lookup, not at the merge. It is also the reason NOT to write a capture-group regex",
|
||||
"here - the moving tags have to stay outside whatever pattern is used, and 'outside'",
|
||||
"is free with semver.",
|
||||
"",
|
||||
"Upstream calls 'latest' the alpha channel and 'stable' the latest release; at the",
|
||||
"time of writing 'stable' resolves to the same digest as 0.7.4, so pinning the",
|
||||
"version tag loses nothing and keeps the chart's appVersion honest.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["sure/Chart.yaml"],
|
||||
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||
depNameTemplate: "ghcr.io/we-promise/sure",
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
versioningTemplate: "semver",
|
||||
},
|
||||
{
|
||||
description: "teamspeak-server: the 6.0.0 line only ships beta tags (6.0.0-beta12.1). Loose versioning compares the numeric parts left to right, so beta12.1 sorts above beta9; semver would rank every beta as an equal prerelease.",
|
||||
customType: "regex",
|
||||
@@ -561,39 +428,6 @@
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"valkey: the simplest block in this file, and deliberately so. docker versioning",
|
||||
"splits the tag at the first dash, reads 9.1.2 as the version and 'alpine' as the",
|
||||
"compatibility suffix, and only ever offers candidates carrying that same suffix -",
|
||||
"which is exactly what is wanted here, because upstream keeps building an -alpine",
|
||||
"tag for every release. No versioningTemplate, no capture groups, nothing to prove.",
|
||||
"",
|
||||
"That is the SECOND design for this block. The first pinned the fully numbered",
|
||||
"9.1.2-alpine3.24 and needed a regex versioning to see a move to alpine3.25, since",
|
||||
"upstream builds exactly one Alpine base per release and the next base arrives as",
|
||||
"9.1.3-alpine3.25 rather than 9.1.2-alpine3.25. It was dropped because the premise",
|
||||
"was wrong: that tag is not immutable either - upstream rebuilds the whole matrix",
|
||||
"periodically (9.1-alpine and 9.0.6-alpine3.24 were pushed minutes apart on",
|
||||
"2026-09-21, two different release lines). Only a digest is immutable. Given two",
|
||||
"equally moving tags, the one whose suffix never changes is the safer pin, and its",
|
||||
"rule needs no proof - which mattered, because a Renovate dry run is impossible in",
|
||||
"the work environment (no node, see #34).",
|
||||
"",
|
||||
"The price, stated rather than discovered later: a rebuild of 9.1.2-alpine changes",
|
||||
"the running image with no commit here and nothing reports it. The remedy exists if",
|
||||
"it ever matters - pinDigests for this one image. It is off globally for",
|
||||
"custom.regex since #34 because an appVersion is a tag by construction, but a",
|
||||
"per-package exception is possible, and it would then be a chart patch plus a",
|
||||
"publish per digest change.",
|
||||
],
|
||||
customType: "regex",
|
||||
managerFilePatterns: ["valkey/Chart.yaml"],
|
||||
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||
depNameTemplate: "docker.io/valkey/valkey",
|
||||
datasourceTemplate: "docker",
|
||||
depTypeTemplate: "app-version",
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"ytdlp-web-player: the registry carries exactly two kinds of tag - four versioned",
|
||||
|
||||
+1
-1
@@ -5,6 +5,6 @@ icon: https://avatars.githubusercontent.com/u/13281359
|
||||
|
||||
type: application
|
||||
|
||||
version: 7.1.1+up4.23.10
|
||||
version: 7.1.0+up4.23.10
|
||||
|
||||
appVersion: 4.23.10
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "samba.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "samba.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -169,9 +167,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "samba.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -3,7 +3,7 @@ name: satisfactory
|
||||
description: A Helm chart to deploy a Satisfactory Gameserver on Kubernetes
|
||||
icon: https://raw.githubusercontent.com/wolveix/satisfactory-server/main/.github/logo.png
|
||||
type: application
|
||||
version: 7.0.2+upv1.9.10
|
||||
version: 7.0.0+upv1.9.10
|
||||
appVersion: "v1.9.10"
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -1,19 +1,17 @@
|
||||
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||
{{- $dev := fromYaml (include "satisfactory.dev" .Values.dev) -}}
|
||||
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" (include "satisfactory.formatNumber" $server.replicas)) -}}
|
||||
{{- $gamePort := int $server.gamePort -}}
|
||||
{{- $msgPort := int $server.messagingPort -}}
|
||||
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) -}}
|
||||
{{- $gamePort := int .Values.server.gamePort -}}
|
||||
{{- $msgPort := int .Values.server.messagingPort -}}
|
||||
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
||||
{{- $pvc := printf "%s-%s-vc" .Release.Name .Chart.Name -}}
|
||||
{{- $storage := $server.storage | default "unset" -}}
|
||||
{{- $res := fromYaml (include "satisfactory.effectiveResources" $server.resources) -}}
|
||||
{{- $storage := .Values.server.storage | default "unset" -}}
|
||||
{{- $res := fromYaml (include "satisfactory.effectiveResources" .Values.server.resources) -}}
|
||||
{{- $lim := $res.limits | default dict -}}
|
||||
{{- $req := $res.requests | default dict -}}
|
||||
{{- $eph := index $lim "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
|
||||
{{- $mem := index $lim "memory" | default "the computed memory limit" -}}
|
||||
{{- $factor := ($server.resources | default dict).limitFactor | default 2 -}}
|
||||
{{- $factor := (.Values.server.resources | default dict).limitFactor | default 2 -}}
|
||||
{{- $startup := fromYaml (include "satisfactory.startupProbe" .Values.startupProbe) -}}
|
||||
{{- $budget := "" -}}
|
||||
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
||||
@@ -23,7 +21,7 @@
|
||||
{{- $seasonalEnv := include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents -}}
|
||||
{{- $seasonalOff := eq $seasonalEnv "true" -}}
|
||||
{{- $beta := eq (lower (printf "%v" $game.experimental)) "true" -}}
|
||||
{{- $debug := eq (lower (printf "%v" $dev.debug)) "true" -}}
|
||||
{{- $debug := eq (lower (printf "%v" .Values.dev.debug)) "true" -}}
|
||||
{{- $outOfRange := or (lt $gamePort 30000) (gt $gamePort 32767) (lt $msgPort 30000) (gt $msgPort 32767) -}}
|
||||
{{- $skipRaw := "" -}}
|
||||
{{- range $entry := .Values.env -}}
|
||||
|
||||
@@ -19,11 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "satisfactory.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 takes none. This file in template-chart is the reference
|
||||
implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. This file in template-chart is the reference implementation.
|
||||
*/}}
|
||||
{{- define "satisfactory.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -170,9 +168,8 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14).
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies.
|
||||
*/}}
|
||||
{{- define "satisfactory.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
@@ -311,63 +308,6 @@ DISABLESEASONALEVENTS at "true", the value this chart has always rendered.
|
||||
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "game") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective dev block (Gitea issue #16, the rest of #99).
|
||||
|
||||
"--set dev=null" — or a leftover "dev:" whose last sub-key was deleted — used
|
||||
to abort the render with a nil-pointer error at .Values.dev.debug, in the
|
||||
StatefulSet and in NOTES.txt. It now falls back to the default values.yaml
|
||||
documents, the same way the game block has since 7.0.0.
|
||||
|
||||
The defaults below MUST stay in sync with the dev block 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.
|
||||
*/}}
|
||||
{{- define "satisfactory.dev" -}}
|
||||
{{- $defaults := dict
|
||||
"debug" false -}}
|
||||
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "dev") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective server block (Gitea issue #16, the rest of #99).
|
||||
|
||||
"--set server=null" used to abort the render at .Values.server.replicas, and a
|
||||
single erased sub-key was worse than that: it rendered silently. A nulled
|
||||
server.gamePort produced a Service with "port:" and "nodePort:" empty and a
|
||||
StatefulSet with an empty containerPort — a manifest the API server rejects
|
||||
only at apply time. Those now fall back to the documented defaults instead.
|
||||
|
||||
TWO keys are deliberately NOT defaulted here.
|
||||
|
||||
server.resources, because it already runs through
|
||||
satisfactory.effectiveResources, which carries its own defaults and is
|
||||
null-safe on its own; repeating limitFactor/4Gi/1/5Gi here would create a
|
||||
third place for them to drift apart.
|
||||
|
||||
server.storage, because values.schema.json documents the opposite decision
|
||||
for it on purpose: an erased size must yield an invalid PVC that fails at
|
||||
apply time rather than a workload quietly sized by the chart. An erased
|
||||
"server:" therefore still renders a PVC without a size — deliberately, not by
|
||||
oversight. That exception is worth re-examining, but it is someone's reasoned
|
||||
decision and not a bugfix's to overturn; it is negotiated in Gitea issue #39.
|
||||
|
||||
Both rely on the same mechanic: keys the defaults do not mention are passed
|
||||
through by satisfactory.defaultedDict unchanged, so an erased "server:" leaves
|
||||
them unset and whatever consumes them downstream decides what that means.
|
||||
|
||||
The defaults below MUST stay in sync with the server block 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.
|
||||
*/}}
|
||||
{{- define "satisfactory.server" -}}
|
||||
{{- $defaults := dict
|
||||
"replicas" 1
|
||||
"gamePort" 30777
|
||||
"messagingPort" 30888 -}}
|
||||
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "server") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render the DISABLESEASONALEVENTS env value from game.enableSeasonalEvents
|
||||
(issue #215).
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -8,5 +7,5 @@ spec:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ $server.storage }}
|
||||
storage: {{ .Values.server.storage }}
|
||||
storageClassName: longhorn
|
||||
@@ -1,6 +1,3 @@
|
||||
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||
{{- $gamePort := include "satisfactory.formatNumber" $server.gamePort -}}
|
||||
{{- $msgPort := include "satisfactory.formatNumber" $server.messagingPort -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
@@ -11,19 +8,19 @@ spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- name: "api"
|
||||
port: {{ $gamePort }}
|
||||
port: {{ .Values.server.gamePort }}
|
||||
protocol: TCP
|
||||
nodePort: {{ $gamePort }}
|
||||
targetPort: {{ $gamePort }}
|
||||
nodePort: {{ .Values.server.gamePort }}
|
||||
targetPort: {{ .Values.server.gamePort }}
|
||||
- name: "game"
|
||||
port: {{ $gamePort }}
|
||||
port: {{ .Values.server.gamePort }}
|
||||
protocol: UDP
|
||||
nodePort: {{ $gamePort }}
|
||||
targetPort: {{ $gamePort }}
|
||||
nodePort: {{ .Values.server.gamePort }}
|
||||
targetPort: {{ .Values.server.gamePort }}
|
||||
- name: "messaging"
|
||||
port: {{ $msgPort }}
|
||||
port: {{ .Values.server.messagingPort }}
|
||||
protocol: TCP
|
||||
nodePort: {{ $msgPort }}
|
||||
targetPort: {{ $msgPort }}
|
||||
nodePort: {{ .Values.server.messagingPort }}
|
||||
targetPort: {{ .Values.server.messagingPort }}
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
@@ -1,6 +1,4 @@
|
||||
{{- $game := fromYaml (include "satisfactory.game" .Values.game) -}}
|
||||
{{- $dev := fromYaml (include "satisfactory.dev" .Values.dev) -}}
|
||||
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
@@ -12,7 +10,7 @@ spec:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" (include "satisfactory.formatNumber" $server.replicas)) }}
|
||||
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
@@ -26,7 +24,7 @@ spec:
|
||||
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
|
||||
env:
|
||||
- name: DEBUG
|
||||
value: "{{ $dev.debug }}"
|
||||
value: "{{ .Values.dev.debug }}"
|
||||
- name: MAXPLAYERS
|
||||
value: "{{ include "satisfactory.formatNumber" $game.maxPlayers }}"
|
||||
# PUID/PGID are only used by the image's root entrypoint path;
|
||||
@@ -44,9 +42,9 @@ spec:
|
||||
- name: DISABLESEASONALEVENTS
|
||||
value: "{{ include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents }}"
|
||||
- name: SERVERGAMEPORT
|
||||
value: "{{ include "satisfactory.formatNumber" $server.gamePort }}"
|
||||
value: "{{ .Values.server.gamePort }}"
|
||||
- name: SERVERMESSAGINGPORT
|
||||
value: "{{ include "satisfactory.formatNumber" $server.messagingPort }}"
|
||||
value: "{{ .Values.server.messagingPort }}"
|
||||
{{- range $value := .Values.env }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name }}
|
||||
@@ -69,13 +67,13 @@ spec:
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: "api"
|
||||
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||
containerPort: {{ .Values.server.gamePort }}
|
||||
protocol: TCP
|
||||
- name: "game"
|
||||
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||
containerPort: {{ .Values.server.gamePort }}
|
||||
protocol: UDP
|
||||
- name: "messaging"
|
||||
containerPort: {{ include "satisfactory.formatNumber" $server.messagingPort }}
|
||||
containerPort: {{ .Values.server.messagingPort }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
|
||||
@@ -84,7 +82,7 @@ spec:
|
||||
startupProbe:
|
||||
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "satisfactory.effectiveResources" $server.resources | nindent 12 }}
|
||||
{{- include "satisfactory.effectiveResources" .Values.server.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: satisfactory-volume
|
||||
mountPath: /config
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "satisfactory 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) - 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); since 7.0.1 that holds for every chart-owned block, dev, game and server included (Gitea issue #16), so no block aborts the render any more. The one key that stays un-defaulted is server.storage, and it says so itself (Gitea issue #39). Note that this chart keeps env, probes and securityContext at the TOP level rather than under a chart-named key.",
|
||||
"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 additionally accept null, because an erased block falls back to the chart defaults (issue #99). Note that this chart keeps env, probes and securityContext at the TOP level rather than under a chart-named key.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -13,12 +13,11 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"dev": {
|
||||
"description": "May be null / empty: the chart then falls back to its documented dev defaults (Gitea issue #16, the rest of issue #99) instead of aborting the render with a nil-pointer error, which is what an erased block used to do.",
|
||||
"type": ["object", "null"],
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"debug": {
|
||||
"description": "Passed to the image as DEBUG. Null means the chart default (false).",
|
||||
"description": "Passed to the image as DEBUG.",
|
||||
"type": ["boolean", "string", "null"]
|
||||
}
|
||||
}
|
||||
@@ -42,29 +41,25 @@
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"description": "May be null / empty: the chart then falls back to its documented server defaults (Gitea issue #16, the rest of issue #99) instead of aborting the render with a nil-pointer error, which is what an erased block used to do. Two keys are exceptions. server.resources is not defaulted here but passed through to the resources fallback, which carries the defaults for it. server.storage is not defaulted at all, deliberately - see its own description.",
|
||||
"type": ["object", "null"],
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "Null means the chart default (1). An explicit 0 is kept and scales the StatefulSet down.",
|
||||
"type": ["integer", "null"],
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"gamePort": {
|
||||
"description": "Null means the chart default (30777). Up to 7.0.0 an erased port rendered an empty port number into the Service and the StatefulSet, which only the API server rejected (Gitea issue #16).",
|
||||
"type": ["integer", "null"],
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"messagingPort": {
|
||||
"description": "Null means the chart default (30888). Same erased-port trap as gamePort above.",
|
||||
"type": ["integer", "null"],
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"storage": {
|
||||
"description": "Size of the data PVC. Not null-safe on purpose: an erased value yields an invalid PVC rather than a silently degraded workload. This stays the documented exception after Gitea issue #16 made every other block in this chart null-safe, so \"server: null\" still renders a PVC without a size; whether failing loudly at apply time beats falling back to 20Gi is negotiated in Gitea issue #39, not inside a bugfix patch.",
|
||||
"description": "Size of the data PVC. Not null-safe on purpose: an erased value yields an invalid PVC rather than a silently degraded workload.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"resources": {
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
# Services stay in place; scaleDown: false brings the workloads back up.
|
||||
scaleDown: false
|
||||
|
||||
# Erasing this block (dev: with no sub-keys, or --set dev=null) falls back to
|
||||
# the defaults below instead of aborting the render — same for game, server and
|
||||
# the hardening/probe/resources blocks (Gitea issue #16, issue #99).
|
||||
dev:
|
||||
debug: false
|
||||
|
||||
@@ -31,23 +28,12 @@ game:
|
||||
# again. Set enableSeasonalEvents: true to actually switch the events on.
|
||||
enableSeasonalEvents: false
|
||||
|
||||
# Erasing this block, or any single key in it, falls back to the defaults below
|
||||
# (Gitea issue #16) — with the documented exception of storage, see there.
|
||||
# Before 7.0.1 "server: null" aborted the render, and a single erased key was
|
||||
# the quieter half: a nulled gamePort rendered an empty port number into the
|
||||
# Service and the StatefulSet, rejected by the API server at apply time and by
|
||||
# nothing before it.
|
||||
server:
|
||||
# Replica count for this workload; 0 is allowed and scales it down.
|
||||
# The chart-global scaleDown flag takes precedence.
|
||||
replicas: 1
|
||||
gamePort: 30777
|
||||
messagingPort: 30888
|
||||
# The size of the data PVC, and the ONE key of this block that is NOT
|
||||
# null-safe — deliberately, not by oversight. An erased storage (including
|
||||
# "server: null") renders a PVC without a size, which fails at apply time
|
||||
# instead of quietly sizing the volume from the chart. Whether that is still
|
||||
# the right trade is negotiated in Gitea issue #39, not here.
|
||||
storage: 20Gi
|
||||
resources:
|
||||
# Limits are optional: memory and ephemeral-storage limits default to
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ type: application
|
||||
# (2026.8.22-9fea41204) and "latest" moves daily. appVersion is therefore the
|
||||
# exact tag, and the +up suffix normalizes the dash to a dot as the versioning
|
||||
# scheme requires - the same shape rss-bridge and minecraft-server use.
|
||||
version: 1.0.6+up2026.9.13.3b8009065
|
||||
appVersion: "2026.9.13-3b8009065"
|
||||
version: 1.0.4+up2026.9.11.ffe96f8a6
|
||||
appVersion: "2026.9.11-ffe96f8a6"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,10 +19,9 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "searxng.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 takes none. template-chart holds the reference implementation.
|
||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
||||
define prefix. template-chart holds the reference implementation.
|
||||
*/}}
|
||||
{{- define "searxng.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -169,9 +168,9 @@ Rules:
|
||||
- keys only present in the override are passed through unchanged.
|
||||
|
||||
NOTE: like the helpers above, this 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 this chart takes no dependency (README,
|
||||
guideline 14). template-chart holds the reference implementation.
|
||||
templates/_helpers.tpl with the chart's name as define prefix, because charts
|
||||
in this repo deliberately have no dependencies. template-chart holds the
|
||||
reference implementation.
|
||||
*/}}
|
||||
{{- define "searxng.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -6,9 +6,6 @@ metadata:
|
||||
spec:
|
||||
# Optional, and only meaningful with settings.limiter: true. The item must
|
||||
# carry a "valkey-url" field pointing at an EXTERNAL Valkey/Redis; the chart
|
||||
# deploys none of its own. Chart dependencies are admissible since #214
|
||||
# (README, guideline 14), but this chart declares none: an external connection
|
||||
# through a secret is the same shape outline and paperless-ngx use for their
|
||||
# databases.
|
||||
# deploys none of its own, because charts in this repo have no dependencies.
|
||||
itemPath: {{ .Values.secrets.valkeySecretRef }}
|
||||
{{- end }}
|
||||
+4
-4
@@ -32,10 +32,10 @@ secrets:
|
||||
# unless the bot detection below is switched on.
|
||||
#
|
||||
# Setting it renders a second OnePasswordItem and passes the URL to the
|
||||
# container as SEARXNG_VALKEY_URL. The chart ships NO Valkey of its own.
|
||||
# Chart dependencies are admissible since #214 (README, guideline 14), but
|
||||
# this chart declares none, so an external connection through a secret is the
|
||||
# same shape outline and paperless-ngx use for their databases.
|
||||
# container as SEARXNG_VALKEY_URL. The chart ships NO Valkey of its own -
|
||||
# charts in this repo have no dependencies (README, guideline 13), so an
|
||||
# external connection through a secret is the same shape outline and
|
||||
# paperless-ngx use for their databases.
|
||||
valkeySecretRef: null
|
||||
#
|
||||
# Documented limitation (README, guideline 9): the CONTENT of these items is
|
||||
|
||||
@@ -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,12 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: sure
|
||||
description: A Helm chart to deploy Sure (we-promise) on Kubernetes
|
||||
icon: https://raw.githubusercontent.com/we-promise/sure/main/public/android-chrome-512x512.png
|
||||
|
||||
type: application
|
||||
|
||||
version: 1.0.0+up0.7.4
|
||||
appVersion: "0.7.4"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -1,165 +0,0 @@
|
||||
{{- $replicas := include "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.replicas) -}}
|
||||
{{- $webStartup := fromYaml (include "sure.webStartupProbe" .Values.sure.web.startupProbe) -}}
|
||||
{{- $workerStartup := fromYaml (include "sure.workerStartupProbe" .Values.sure.worker.startupProbe) -}}
|
||||
{{- $workerLiveness := fromYaml (include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe) -}}
|
||||
Sure {{ .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
|
||||
|
||||
The first account created on a fresh instance becomes the super admin — sign
|
||||
in yourself before handing the URL to anyone.
|
||||
{{- else }}
|
||||
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate.
|
||||
ingress.domain is still required: it becomes APP_DOMAIN (the host in every
|
||||
mail link) and the WebAuthn relying party the app binds passkeys to. Reach
|
||||
the instance inside the cluster with
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||
|
||||
and open http://localhost:8080. With the Ingress off the chart also renders
|
||||
RAILS_ASSUME_SSL and RAILS_FORCE_SSL as "false", which is what makes that
|
||||
plain-HTTP session able to log in at all; mail links still point at
|
||||
https://{{ .Values.ingress.domain }}/.
|
||||
{{- end }}
|
||||
{{ if eq $replicas "0" }}
|
||||
SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}sure.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so neither the web nor
|
||||
the worker process is running. PVC, Secrets, Service{{ if .Values.ingress.enabled }}, Ingress and the
|
||||
certificate stay{{ else }} stay{{ end }} in place; set it back and upgrade to bring both containers up.
|
||||
{{ end }}
|
||||
WHAT THE THREE 1PASSWORD ITEMS MUST CONTAIN
|
||||
The chart creates OnePasswordItem CRs; the operator turns each into a
|
||||
Kubernetes Secret whose keys are the FIELD NAMES of the 1Password item. A
|
||||
missing field does not fail the install — the release goes through and the
|
||||
pod then hangs in CreateContainerConfigError naming the key it could not
|
||||
find (kubectl -n {{ .Release.Namespace }} describe pod).
|
||||
|
||||
secrets.appSecretRef → {{ .Values.secrets.appSecretRef }}
|
||||
Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret, field:
|
||||
secret-key-base SECRET_KEY_BASE, 64 random bytes hex
|
||||
(openssl rand -hex 64)
|
||||
|
||||
secrets.dbSecretRef → {{ .Values.secrets.dbSecretRef }}
|
||||
Secret {{ .Release.Name }}-{{ .Chart.Name }}-db-secret, fields:
|
||||
host DB_HOST, the PostgreSQL hostname
|
||||
port DB_PORT, usually 5432
|
||||
database POSTGRES_DB
|
||||
username POSTGRES_USER
|
||||
password POSTGRES_PASSWORD
|
||||
|
||||
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
|
||||
Secret {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret, field:
|
||||
connection-url REDIS_URL, e.g. redis://:password@host:6379/0
|
||||
|
||||
ROTATING SECRET_KEY_BASE DESTROYS MORE THAN SESSIONS
|
||||
In self-hosted mode Sure derives its Active Record encryption keys from
|
||||
SECRET_KEY_BASE (config/initializers/active_record_encryption.rb: three
|
||||
SHA-256 digests over that one value) unless
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY and its two companions are set. Every
|
||||
encrypted column in the database — provider tokens, OAuth credentials, MFA
|
||||
secrets — is therefore readable only with the SECRET_KEY_BASE it was written
|
||||
under. Replacing that value does not just log everyone out: it makes the
|
||||
existing encrypted data undecryptable, and no rotation of the item brings it
|
||||
back. Treat this field as permanent for the life of the database, or pin the
|
||||
three encryption keys explicitly through sure.web.env / sure.worker.env
|
||||
before the first start.
|
||||
|
||||
CHANGING A SECRET'S CONTENT RESTARTS NOTHING
|
||||
Helm never sees the content of a OnePasswordItem, so it cannot hash it. The
|
||||
pod template carries checksum/secret-ref annotations over the rendered
|
||||
OnePasswordItems, so pointing a *SecretRef at a DIFFERENT item rolls the
|
||||
pod — but editing a value INSIDE the same item does not: the operator
|
||||
updates the Kubernetes Secret while both containers keep the old value in
|
||||
their environment. After such a change:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
|
||||
The operator's auto-restart annotation would not help here and is
|
||||
deliberately not set: it restarts Deployments only, and this is a
|
||||
StatefulSet.
|
||||
|
||||
POSTGRES AND REDIS ARE NOT PART OF THIS CHART
|
||||
Both must exist and be reachable before the first start. Redis is not a
|
||||
cache the app can do without — it holds the Sidekiq queues, the Rails cache
|
||||
store and the rate limiter.
|
||||
|
||||
What the probes do with that, per container:
|
||||
web liveness and startup use /up, which is Rails' boot check and
|
||||
touches no database — a Postgres outage therefore does not restart
|
||||
the pod. Readiness uses "/", which runs a User.count query, so the
|
||||
pod leaves the Service while the database is gone and returns on
|
||||
its own afterwards.
|
||||
worker all three probes run "sidekiqmon processes" and look for this
|
||||
pod's own entry, i.e. a live Sidekiq heartbeat in Redis. There is
|
||||
no HTTP and no dependency-free alternative: with Redis gone the
|
||||
worker has nothing to do, and liveness only restarts after
|
||||
{{ $workerLiveness.failureThreshold }} x {{ $workerLiveness.periodSeconds }}s = {{ mul $workerLiveness.failureThreshold $workerLiveness.periodSeconds }}s of missing heartbeat.
|
||||
|
||||
A pod that never turns Ready is usually a connection problem, not a chart
|
||||
problem: read the container logs, not the pod events.
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} logs statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs -c {{ .Release.Name }}-{{ .Chart.Name }}-web
|
||||
|
||||
THE FIRST START MIGRATES, AND THE STARTUP PROBE IS THE CLOCK
|
||||
The image entrypoint runs "./bin/rails db:prepare" — but only when the
|
||||
command is "./bin/rails server". That is the web container, and only the web
|
||||
container: the worker runs "bundle exec sidekiq" and never migrates, so the
|
||||
two can never race. Puma binds the port after the migration, which means the
|
||||
whole schema creation happens inside the startup probe's budget:
|
||||
|
||||
web {{ $webStartup.failureThreshold }} x {{ $webStartup.periodSeconds }}s = {{ mul $webStartup.failureThreshold $webStartup.periodSeconds }}s
|
||||
worker {{ $workerStartup.failureThreshold }} x {{ $workerStartup.periodSeconds }}s = {{ mul $workerStartup.failureThreshold $workerStartup.periodSeconds }}s (it waits for the schema the web container creates)
|
||||
|
||||
Raise sure.web.startupProbe.failureThreshold BEFORE a large app-version jump
|
||||
or a first import, not after the crash loop has started — a restart lands in
|
||||
the middle of a migration. Back up the database before any app-version bump:
|
||||
migrations are one-way, and a helm rollback returns the old image to a
|
||||
database the new one has already rewritten.
|
||||
|
||||
ONE POD, ONE VOLUME, TWO CONTAINERS
|
||||
Uploads, imports and exports live on the ReadWriteOnce PVC
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, mounted at /rails/storage in both containers. That is
|
||||
the reason web and worker share a pod at all: a background job writes the
|
||||
same files the web process serves, and one RWO volume cannot be attached to
|
||||
two pods.
|
||||
{{- if gt (int $replicas) 1 }}
|
||||
|
||||
sure.replicas is {{ $replicas }}. Expect the extra pods to stay Pending on
|
||||
"Multi-Attach error for volume" unless the storage class is genuinely RWX.
|
||||
{{- end }}
|
||||
|
||||
Backup is two things at the same time: the database holds the accounts and
|
||||
one row per attachment, the PVC holds the bytes. Restoring one without the
|
||||
other gives an app full of broken references. Because the PVC is rendered by
|
||||
the chart, helm uninstall deletes it — the external database survives that,
|
||||
which makes an accidental uninstall look recoverable while the files are
|
||||
already gone.
|
||||
|
||||
THE CHART CONFIGURES NO MAIL AND NO AI
|
||||
Sure sends invitations and notifications over SMTP and can run an assistant
|
||||
against an OpenAI-compatible endpoint. This chart sets neither: no SMTP_*
|
||||
variable, no OPENAI_* variable. Mails are therefore quietly never sent, and
|
||||
the AI features stay off — nothing in the UI says so.
|
||||
|
||||
Both are plain environment variables, so they go into sure.web.env and, for
|
||||
anything a background job needs (the assistant runs in the worker), into
|
||||
sure.worker.env as well. The secretKeyRef form is available for the API key
|
||||
and the SMTP password. Note that upstream sizes AI usage at 1 GB of memory
|
||||
per process; the chart's computed memory limit is 3x its request.
|
||||
{{- if or .Values.sure.web.env .Values.sure.worker.env }}
|
||||
|
||||
Currently set: {{ len .Values.sure.web.env }} extra variable(s) on the web container,
|
||||
{{ len .Values.sure.worker.env }} on the worker, rendered after the ones the chart sets itself.
|
||||
An entry repeating a name the chart already sets wins at install time and is
|
||||
silently lost on the next helm upgrade — read it back off the StatefulSet.
|
||||
{{- end }}
|
||||
@@ -1,451 +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 copied into every chart's templates/_helpers.tpl with the
|
||||
chart's name as define prefix rather than shared through a library chart; the
|
||||
reference implementation is template-chart/templates/_helpers.tpl.
|
||||
*/}}
|
||||
{{- define "sure.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 "sure.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $limits := dict -}}
|
||||
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||
{{- if not (kindIs "invalid" $value) -}}
|
||||
{{- $_ := set $limits (include "sure.resourceKey" $key) $value -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- range $key, $request := $requests -}}
|
||||
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||
{{- $_ := set $limits $key (include "sure.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 "sure.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 "sure.multiplyQuantity" -}}
|
||||
{{- $quantity := include "sure.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 "sure.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 "sure.formatNumber" -}}
|
||||
{{- if kindIs "float64" . -}}
|
||||
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%v" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Render the replica count for the StatefulSet.
|
||||
|
||||
Input: dict
|
||||
"scaleDown" the chart-global .Values.scaleDown flag (may be nil)
|
||||
"replicas" the workload's replicas value (may be nil)
|
||||
|
||||
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 "sure.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.
|
||||
The same shape erases a probe (it disappears) or the resources block (no
|
||||
requests, no computed limits). This helper defines the opposite semantics: an
|
||||
empty block means "chart defaults apply".
|
||||
*/}}
|
||||
{{- define "sure.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 "sure.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.sure.web.securityContext" can itself be erased, and indexing into nil
|
||||
inside a template is not an error but silently yields nothing.
|
||||
*/}}
|
||||
{{- define "sure.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 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. Every
|
||||
reason behind a value is written down there, including what each probe actually
|
||||
proves - a probe default without its path, port name or exec command would
|
||||
leave the workload with no working probe at all (README, guideline 12.5).
|
||||
*/}}
|
||||
{{- define "sure.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "sure.subBlock" (dict "block" . "key" "pod" "path" "sure.securityContext"))).value -}}
|
||||
{{- $defaults := dict
|
||||
"runAsNonRoot" true
|
||||
"runAsUser" 1000
|
||||
"runAsGroup" 1000
|
||||
"fsGroup" 1000
|
||||
"fsGroupChangePolicy" "Always"
|
||||
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" "sure.securityContext.pod") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.containerSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "sure.subBlock" (dict "block" .block "key" "container" "path" .path))).value -}}
|
||||
{{- $defaults := dict
|
||||
"allowPrivilegeEscalation" false
|
||||
"readOnlyRootFilesystem" true
|
||||
"runAsNonRoot" true
|
||||
"capabilities" (dict "drop" (list "ALL")) -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" (printf "%s.container" .path)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Web container probes. /up is Rails' boot check and touches neither Postgres nor
|
||||
Redis, so liveness and startup use it; readiness uses "/", which runs
|
||||
User.count through authenticate_user! in self-hosted mode and therefore fails
|
||||
while the database is unreachable. The long startup budget covers the
|
||||
"./bin/rails db:prepare" the entrypoint runs before Puma binds the port.
|
||||
*/}}
|
||||
{{- define "sure.webLivenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/up" "port" "http")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.webReadinessCommand" -}}
|
||||
{{- list "/bin/bash" "-c" "code=\"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/)\"; test \"$code\" -ge 200 -a \"$code\" -lt 400" | toJson -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.webReadinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"exec" (dict "command" (include "sure.webReadinessCommand" . | fromJsonArray))
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 10
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.webStartupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/up" "port" "http")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"failureThreshold" 60 -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.startupProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Worker container probes. Sidekiq serves no HTTP, so all three ask its own
|
||||
monitor whether THIS process still holds a heartbeat in Redis; the exec command
|
||||
is part of the default, not decoration (README, guideline 12.5).
|
||||
*/}}
|
||||
{{- define "sure.workerProbeCommand" -}}
|
||||
{{- list "/bin/bash" "-c" "bundle exec sidekiqmon processes | grep -q \"^${HOSTNAME}:\"" | toJson -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.workerLivenessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
|
||||
"periodSeconds" 60
|
||||
"timeoutSeconds" 15
|
||||
"failureThreshold" 5 -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.livenessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.workerReadinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
|
||||
"periodSeconds" 30
|
||||
"timeoutSeconds" 15
|
||||
"failureThreshold" 3 -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.readinessProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.workerStartupProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray))
|
||||
"periodSeconds" 20
|
||||
"timeoutSeconds" 15
|
||||
"failureThreshold" 30 -}}
|
||||
{{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.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
|
||||
instead of a container without any resource accounting. Both containers run the
|
||||
same image and carry the same request; see values.yaml for where the number
|
||||
comes from.
|
||||
*/}}
|
||||
{{- define "sure.webResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
|
||||
{{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.resources")) -}}
|
||||
{{- include "sure.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "sure.workerResources" -}}
|
||||
{{- $defaults := dict
|
||||
"limitFactor" 3
|
||||
"requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}}
|
||||
{{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.resources")) -}}
|
||||
{{- include "sure.resources" $merged -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The environment both containers share: the same image, the same database, the
|
||||
same Redis, the same secrets. Only the command differs.
|
||||
|
||||
Input: the root context.
|
||||
|
||||
ingress.domain is read HERE, outside the Ingress template, three times over
|
||||
(APP_DOMAIN, WEBAUTHN_RP_ID, WEBAUTHN_ALLOWED_ORIGINS), so it carries its own
|
||||
required() and stays mandatory when ingress.enabled is false - README,
|
||||
guideline 12.1. An empty value would not fail the render: it would register
|
||||
passkeys against an empty relying party and build mail links without a host.
|
||||
|
||||
SELF_HOSTED=true is not optional for this chart: it is what turns Sure into the
|
||||
single-family, no-subscription mode, and it is also what makes the readiness
|
||||
probe on "/" reach the database at all (see values.yaml).
|
||||
*/}}
|
||||
{{- define "sure.commonEnv" -}}
|
||||
{{- $domain := required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain -}}
|
||||
- name: SELF_HOSTED
|
||||
value: "true"
|
||||
- name: RAILS_ENV
|
||||
value: "production"
|
||||
# Not cosmetic and not optional: /tmp is an emptyDir, which Kubernetes creates
|
||||
# mode 0777 without a sticky bit, and Ruby skips exactly that shape when it
|
||||
# looks for a temporary directory. Without this the app aborts during boot with
|
||||
# "could not find a temporary directory". The directory is created by the
|
||||
# init container in the StatefulSet, which explains the whole case.
|
||||
- name: TMPDIR
|
||||
value: "/tmp/ruby"
|
||||
# The image's home directory (/home/rails) is part of the read-only root
|
||||
# filesystem, and Bundler reacts to that by creating a throwaway home under
|
||||
# TMPDIR on EVERY invocation ("`/home/rails` is not writable. Bundler will use
|
||||
# `/tmp/ruby/bundler2026...` as your home directory temporarily", measured in
|
||||
# the install-test). That is one directory per `bundle exec` - including every
|
||||
# worker probe, every 30 seconds. Pointing HOME at a writable directory turns
|
||||
# that into one stable path.
|
||||
- name: HOME
|
||||
value: "/tmp/home"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
# Sure's production.rb defaults BOTH of these to true. The chart renders them
|
||||
# explicitly from ingress.enabled instead: with the Ingress terminating TLS,
|
||||
# assume_ssl makes Rails treat the forwarded request as secure (so force_ssl
|
||||
# adds HSTS and secure cookies without redirecting), while without an Ingress
|
||||
# both have to be off - a port-forwarded plain-HTTP session would otherwise
|
||||
# either be redirected to https or hand out cookies the browser never returns.
|
||||
- name: RAILS_ASSUME_SSL
|
||||
value: "{{ .Values.ingress.enabled }}"
|
||||
- name: RAILS_FORCE_SSL
|
||||
value: "{{ .Values.ingress.enabled }}"
|
||||
# Action Mailer builds absolute URLs from this host, and it is the fallback the
|
||||
# WebAuthn initializer uses when WEBAUTHN_RP_ID is unset.
|
||||
- name: APP_DOMAIN
|
||||
value: {{ $domain | quote }}
|
||||
# Passkeys are bound to the relying party id; changing it later invalidates
|
||||
# every registered credential (upstream docs/hosting/webauthn.md).
|
||||
- name: WEBAUTHN_RP_ID
|
||||
value: {{ $domain | quote }}
|
||||
- name: WEBAUTHN_ALLOWED_ORIGINS
|
||||
value: {{ printf "https://%s" $domain | quote }}
|
||||
- name: SECRET_KEY_BASE
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
key: secret-key-base
|
||||
- name: DB_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: host
|
||||
- name: DB_PORT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: port
|
||||
- name: POSTGRES_DB
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: database
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: username
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: password
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret
|
||||
key: connection-url
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The additive env block of one container (README, guideline 10).
|
||||
|
||||
Input: dict "root" <root context> "env" <the container's env list>.
|
||||
Entries are rendered through tpl, so a value may contain Helm templating, and
|
||||
the value, secretKeyRef and configMapKeyRef forms are supported.
|
||||
*/}}
|
||||
{{- define "sure.env" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- range $value := .env }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name | quote }}
|
||||
# quote is required, not cosmetic: a value like "3" 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,6 +0,0 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for the database must be provided (secrets.dbSecretRef)" .Values.secrets.dbSecretRef }}
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for Redis must be provided (secrets.redisSecretRef)" .Values.secrets.redisSecretRef }}
|
||||
@@ -1,28 +0,0 @@
|
||||
{{- 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 }}
|
||||
{{- 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 }}
|
||||
@@ -1,13 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for the app secret must be provided (secrets.appSecretRef)" .Values.secrets.appSecretRef }}
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,231 +0,0 @@
|
||||
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 "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.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:
|
||||
# The chart has no ConfigMap, so there is no checksum/config here. What
|
||||
# these annotations cover is the SOURCE of the secrets, not their
|
||||
# content: the Kubernetes Secrets are materialized by the 1Password
|
||||
# operator, so Helm never sees what is in them. Repointing one of the
|
||||
# secrets.*SecretRef values at a different item therefore changes the
|
||||
# pod template and rolls the pod, instead of silently leaving the old
|
||||
# credentials in the running container. A change of an item's CONTENT
|
||||
# inside the vault is not covered - see the comment on secrets in
|
||||
# values.yaml for why that gap cannot be closed here.
|
||||
# The name differs from checksum/config on purpose - that one promises
|
||||
# "the config changed", this one only "the config source changed".
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/sure.secret.yaml") . | sha256sum }}
|
||||
checksum/secret-ref-db: {{ include (print $.Template.BasePath "/sure-db.secret.yaml") . | sha256sum }}
|
||||
checksum/secret-ref-redis: {{ include (print $.Template.BasePath "/sure-redis.secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "sure.podSecurityContext" .Values.sure.securityContext | nindent 8 }}
|
||||
initContainers:
|
||||
# Ruby refuses a world-writable directory that has no sticky bit as its
|
||||
# temporary directory (ruby 3.4, tmpdir.rb: a candidate is skipped when
|
||||
# "world_writable? && !sticky?"), and a Kubernetes emptyDir is created
|
||||
# mode 0777 WITHOUT the sticky bit. With /tmp mounted as an emptyDir and
|
||||
# the image's root filesystem read-only, Ruby then finds no candidate at
|
||||
# all and both containers die during boot - measured in this chart's
|
||||
# first install-test, which logged
|
||||
#
|
||||
# system temporary path is world-writable: /tmp
|
||||
# /tmp is world-writable: /tmp
|
||||
# . is not writable: /rails
|
||||
# ArgumentError: could not find a temporary directory
|
||||
#
|
||||
# raised from MiniMagick, which Active Storage loads while Rails boots.
|
||||
# Note where it does NOT come from: nothing in the app asked for a temp
|
||||
# file yet.
|
||||
#
|
||||
# The fix is a directory that is writable without being world-writable.
|
||||
# This init container creates two of them per app container inside the
|
||||
# same emptyDirs (mode 0700, owned by the pod's uid): "ruby" for TMPDIR,
|
||||
# and "home" for HOME - the image's /home/rails is read-only, and
|
||||
# Bundler answers that by creating a throwaway home under TMPDIR on
|
||||
# every single invocation. It runs as the same non-root uid as the app
|
||||
# and needs no capability at all: the emptyDir root is 0777, so creating an
|
||||
# entry in it is allowed, and chmod on a directory one has just created
|
||||
# needs no privilege. That is deliberately weaker than the chown-as-root
|
||||
# init container paperless-ngx needs.
|
||||
#
|
||||
# The image is the app's own, so there is no second image to pin, no
|
||||
# Renovate block to maintain for it and nothing extra to pull.
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-init-tmp
|
||||
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
# command, not args: the image entrypoint would start the application.
|
||||
command:
|
||||
- /bin/bash
|
||||
- -ec
|
||||
- |
|
||||
for dir in /web-tmp/ruby /web-tmp/home /worker-tmp/ruby /worker-tmp/home; do
|
||||
mkdir -p "$dir"
|
||||
chmod 700 "$dir"
|
||||
done
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumeMounts:
|
||||
- name: web-tmp
|
||||
mountPath: /web-tmp
|
||||
- name: worker-tmp
|
||||
mountPath: /worker-tmp
|
||||
# Fixed, deliberately small: this container runs two mkdirs and two
|
||||
# chmods. Not values-configurable, for the same reason as the init
|
||||
# container in paperless-ngx - there is nothing here to tune.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
ephemeral-storage: 1Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
ephemeral-storage: 10Mi
|
||||
containers:
|
||||
# The web process. Its command is the image's default
|
||||
# (CMD ["./bin/rails", "server"]), which is exactly what makes the
|
||||
# entrypoint run "./bin/rails db:prepare" first - the migration only
|
||||
# happens for that command, so it happens here and nowhere else.
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-web
|
||||
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
{{- include "sure.commonEnv" . | nindent 12 }}
|
||||
{{- include "sure.env" (dict "root" $ "env" .Values.sure.web.env) | nindent 12 }}
|
||||
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 "sure.webLivenessProbe" .Values.sure.web.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "sure.webReadinessProbe" .Values.sure.web.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "sure.webStartupProbe" .Values.sure.web.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "sure.webResources" .Values.sure.web.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.web.securityContext "path" "sure.web.securityContext") | nindent 12 }}
|
||||
volumeMounts:
|
||||
# Active Storage's local disk service, i.e. every uploaded file and
|
||||
# import, lives here (config/storage.yml: Rails.root.join("storage")).
|
||||
- name: storage
|
||||
mountPath: /rails/storage
|
||||
# Writable paths, so readOnlyRootFilesystem: true can stay on.
|
||||
# Each container gets its own emptyDirs: nothing has to be shared
|
||||
# between the two processes, and a shared scratch directory would
|
||||
# only create coupling nobody asked for.
|
||||
#
|
||||
# /tmp: Rack writes multipart uploads (CSV imports, attachments)
|
||||
# here before Active Storage ever sees them. The app does not use
|
||||
# this path directly, though - TMPDIR points at the private
|
||||
# subdirectory the init container creates inside it, because Ruby
|
||||
# rejects a world-writable emptyDir as a temporary directory.
|
||||
- name: web-tmp
|
||||
mountPath: /tmp
|
||||
# /rails/tmp: Rails' own scratch directory - the Bootsnap cache,
|
||||
# Puma's pidfile when PIDFILE is set, and the combined CA bundle the
|
||||
# 00_ssl initializer writes when SSL_CA_FILE is given. Note the
|
||||
# trade-off, read off the Dockerfile: the image ships a Bootsnap
|
||||
# cache precompiled at build time under this path, and mounting an
|
||||
# emptyDir over it hides that cache, so every pod start recompiles
|
||||
# it. Boot time cost, not correctness; the alternative is a
|
||||
# writable image layer, which the hardening standard rules out.
|
||||
- name: web-rails-tmp
|
||||
mountPath: /rails/tmp
|
||||
# /rails/log: production logs go to STDOUT (config/environments/
|
||||
# production.rb), so under normal operation nothing is written here
|
||||
# at all. It stays writable because Rails' default logger path is
|
||||
# log/<env>.log and any rake or console path that falls back to it
|
||||
# would otherwise hit a read-only filesystem. Not measured.
|
||||
- name: web-rails-log
|
||||
mountPath: /rails/log
|
||||
# The Sidekiq process. Same image, different command - and because that
|
||||
# command is NOT "./bin/rails server", the image entrypoint skips
|
||||
# db:prepare here, so the two containers never migrate against each
|
||||
# other.
|
||||
#
|
||||
# args, NOT command, and that distinction is load-bearing: Kubernetes'
|
||||
# "command" replaces the image's ENTRYPOINT, which here is
|
||||
# /rails/bin/docker-entrypoint - the script that preloads jemalloc.
|
||||
# Upstream counts jemalloc among the three things that make the
|
||||
# documented memory figures hold, so replacing the entrypoint would
|
||||
# silently give this container a different memory profile than the one
|
||||
# its requests are sized for. "args" replaces only the CMD, which is
|
||||
# what upstream's compose file does too.
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-worker
|
||||
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- bundle
|
||||
- exec
|
||||
- sidekiq
|
||||
env:
|
||||
{{- include "sure.commonEnv" . | nindent 12 }}
|
||||
{{- include "sure.env" (dict "root" $ "env" .Values.sure.worker.env) | nindent 12 }}
|
||||
livenessProbe:
|
||||
{{- include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "sure.workerReadinessProbe" .Values.sure.worker.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "sure.workerStartupProbe" .Values.sure.worker.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "sure.workerResources" .Values.sure.worker.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.worker.securityContext "path" "sure.worker.securityContext") | nindent 12 }}
|
||||
volumeMounts:
|
||||
# The worker reads and writes the same Active Storage files as the
|
||||
# web process (imports are processed as background jobs), which is
|
||||
# why upstream's compose file mounts one volume into both and why
|
||||
# both containers live in one pod here: a single ReadWriteOnce PVC
|
||||
# cannot be attached to two pods.
|
||||
- name: storage
|
||||
mountPath: /rails/storage
|
||||
- name: worker-tmp
|
||||
mountPath: /tmp
|
||||
- name: worker-rails-tmp
|
||||
mountPath: /rails/tmp
|
||||
- name: worker-rails-log
|
||||
mountPath: /rails/log
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: storage
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
- name: web-tmp
|
||||
emptyDir: {}
|
||||
- name: web-rails-tmp
|
||||
emptyDir: {}
|
||||
- name: web-rails-log
|
||||
emptyDir: {}
|
||||
- name: worker-tmp
|
||||
emptyDir: {}
|
||||
- name: worker-rails-tmp
|
||||
emptyDir: {}
|
||||
- name: worker-rails-log
|
||||
emptyDir: {}
|
||||
@@ -1,234 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||
"title": "sure values",
|
||||
"description": "Strict values schema (issue #68): additionalProperties is false on every chart-owned object level, so a key the 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 annotations) stay open, because the chart does not interpret their contents. The hardening, probe and resources blocks additionally accept null - the chart's null-safe helpers then fall back to ITS defaults (issue #99). The schema adds to required(), it does not replace it.",
|
||||
"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": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"appSecretRef": {
|
||||
"description": "op:// item path of the app secret. Field: secret-key-base (SECRET_KEY_BASE).",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"dbSecretRef": {
|
||||
"description": "op:// item path of the PostgreSQL credentials. Fields: host, port, database, username, password.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"redisSecretRef": {
|
||||
"description": "op:// item path of the Redis connection. Field: connection-url (REDIS_URL).",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"sure": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"description": "Replicas of the StatefulSet; both containers scale with it, because they share one ReadWriteOnce PVC.",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "Pod-level hardening, shared by both containers. May be null / empty: the chart then falls back to ITS defaults (uid/gid 1000, matching the image's USER 1000:1000) instead of rendering an unhardened pod.",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"securityContext": {
|
||||
"$ref": "#/definitions/containerSecurityContext"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
}
|
||||
}
|
||||
},
|
||||
"worker": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"securityContext": {
|
||||
"$ref": "#/definitions/containerSecurityContext"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/definitions/resources"
|
||||
},
|
||||
"livenessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"readinessProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"startupProbe": {
|
||||
"$ref": "#/definitions/probe"
|
||||
},
|
||||
"env": {
|
||||
"$ref": "#/definitions/env"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"description": "The Active Storage PVC, mounted at /rails/storage in both containers. Deliberately NOT null-safe: an erased block yields an invalid PVC, which fails loudly.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"storageClass": {
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ingress": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the Ingress template (issue #93) and decides whether the chart renders RAILS_ASSUME_SSL/RAILS_FORCE_SSL as true or false. It does NOT free ingress.domain: that value is also read for APP_DOMAIN, WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS and keeps its own required() there (guideline 12.1).",
|
||||
"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": {
|
||||
"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": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"containerSecurityContext": {
|
||||
"description": "May be null / empty: the chart then falls back to its container hardening defaults (issue #99) instead of rendering an unhardened container. The pod-level context lives one level up, under sure.securityContext.pod, because it is shared by both containers.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"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 chart's defaults carry the probe's context - the path for the web container, the exec command for the worker - not just its timings (guideline 12.5).",
|
||||
"type": ["object", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
# 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. PVCs, Secrets and
|
||||
# Services stay in place; scaleDown: false brings the workloads back up.
|
||||
scaleDown: false
|
||||
|
||||
# 1Password item paths (op://<vault>/<item>). The 1Password operator turns each
|
||||
# OnePasswordItem into a Kubernetes Secret whose keys are the FIELD NAMES of the
|
||||
# item; the chart references those field names, so a renamed field is a pod in
|
||||
# CreateContainerConfigError, not a render error. NOTES.txt lists every field.
|
||||
#
|
||||
# Helm never sees the CONTENT of these secrets, so no checksum/config-style
|
||||
# annotation can cover them (README, guideline 9). The pod template does carry a
|
||||
# checksum/secret-ref over the rendered OnePasswordItems, so REPOINTING one of
|
||||
# the *SecretRef values at a different item restarts the pod; editing a value
|
||||
# INSIDE the same item does not, and needs a manual
|
||||
# kubectl rollout restart statefulset/<release>-sure-sfs
|
||||
# The operator's operator.1password.io/auto-restart annotation is deliberately
|
||||
# NOT set: it only ever restarts Deployments, so on this StatefulSet it would
|
||||
# advertise a guarantee that does not exist (README, guideline 9).
|
||||
secrets:
|
||||
# SECRET_KEY_BASE (field: secret-key-base)
|
||||
appSecretRef: null
|
||||
# PostgreSQL connection (fields: host, port, database, username, password)
|
||||
dbSecretRef: null
|
||||
# Redis connection URL (field: connection-url)
|
||||
redisSecretRef: null
|
||||
|
||||
sure:
|
||||
# Replica count for the StatefulSet; 0 is allowed and scales it down. The
|
||||
# chart-global scaleDown flag takes precedence.
|
||||
#
|
||||
# Both containers live in the SAME pod, so this value scales web and worker
|
||||
# together. That is a consequence of the single ReadWriteOnce PVC they share
|
||||
# at /rails/storage (Active Storage): splitting them into two workloads would
|
||||
# need a ReadWriteMany volume. Above 1 the extra pods normally stay Pending on
|
||||
# "Multi-Attach error for volume" - see NOTES.txt.
|
||||
replicas: 1
|
||||
# Pod-level hardening, shared by both containers and by the init container.
|
||||
# The upstream image declares USER 1000:1000 (group and user "rails", created
|
||||
# in the Dockerfile), so the chart needs no deviation from the repo hardening
|
||||
# standard; fsGroup 1000 makes the storage PVC writable for that uid.
|
||||
#
|
||||
# readOnlyRootFilesystem: true holds, and the writable paths are emptyDirs:
|
||||
# /rails/tmp, /rails/log and /tmp, one set per container. /tmp needs one extra
|
||||
# step that is easy to mistake for decoration - a tiny init container creates
|
||||
# two private subdirectories in it, and the containers run with TMPDIR and
|
||||
# HOME pointing there. Ruby refuses a world-writable directory without a
|
||||
# sticky bit as its temporary directory and an emptyDir is exactly that, so
|
||||
# without this the app aborts during boot; HOME follows because the image's
|
||||
# /home/rails is read-only and Bundler otherwise builds a throwaway home per
|
||||
# invocation. The StatefulSet carries the measured evidence for both.
|
||||
securityContext:
|
||||
pod:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
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
|
||||
# - web and worker alike - 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
|
||||
# <container>.securityContext.container, which wins over this value.
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# web - Puma, the container that also runs the database migrations
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
securityContext:
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
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 request follows upstream's own sizing note (docs/hosting/docker.md
|
||||
# for 0.7.4): steady-state memory "around 352 MB", comfortable under a
|
||||
# 512 MB limit, while "if you enable AI, run at least 1 GB". 512Mi request
|
||||
# x limitFactor 3 = 1536Mi limit, which holds both the everyday figure and
|
||||
# the AI recommendation without reserving the larger number on the node.
|
||||
# Read from upstream's documentation, not measured here.
|
||||
#
|
||||
# ephemeral-storage is NOT the template default of 10Mi, and the reason is
|
||||
# measured: with 10Mi (limit 30Mi) per container the kubelet evicted the
|
||||
# pod within two minutes of every start - "Pod ephemeral local storage
|
||||
# usage exceeds the total limit of containers 60Mi". Both emptyDirs and
|
||||
# the container's stdout log count against that limit, and this image
|
||||
# rebuilds its Bootsnap cache under /rails/tmp on every pod start because
|
||||
# the emptyDir hides the one baked into the image. 256Mi request (768Mi
|
||||
# limit) is headroom for that, not a measurement of it - the steady-state
|
||||
# figure is still unknown.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 0.1
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 256Mi
|
||||
# PROBES - what each one actually proves (README, guideline 12.3)
|
||||
#
|
||||
# /up is Rails' own health route (config/routes.rb: get "up" =>
|
||||
# "rails/health#show"). READ FROM THE RAILS SOURCE, NOT MEASURED HERE: that
|
||||
# controller renders 200 as soon as the app has booted without raising and
|
||||
# touches NO database and NO Redis. It is a boot check, and the widespread
|
||||
# belief that it tests the database is exactly what guideline 12.3 exists to
|
||||
# catch - upstream's own Helm chart (charts/sure in the app repository) uses
|
||||
# /up for readiness, which is the mistake this chart does not copy.
|
||||
#
|
||||
# Liveness therefore uses /up: a Postgres outage must not restart the pod.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /up
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
# Readiness asks for "/" instead, because that path provably reaches the
|
||||
# database - and it does so through curl rather than an httpGet probe, for
|
||||
# a reason that was measured rather than guessed (see below).
|
||||
#
|
||||
# Why "/" reaches the database: every request runs ApplicationController's
|
||||
# authenticate_user! before_action (app/controllers/concerns/
|
||||
# authentication.rb); without a session cookie - which a probe never has -
|
||||
# it falls into self_hosted_first_login?, i.e.
|
||||
# "config.app_mode.self_hosted? && User.count.zero?". User.count is an SQL
|
||||
# query. Measured in the install-test: against an empty database the app
|
||||
# answered 302 to /registration/new, which is the branch that query decides,
|
||||
# so the path demonstrably touches Postgres. With the database down the
|
||||
# query raises and Rails answers 500.
|
||||
#
|
||||
# Why exec + curl and not httpGet: the kubelet's HTTP prober FOLLOWS
|
||||
# redirects to the same host, and with RAILS_ASSUME_SSL=true (which the
|
||||
# chart sets whenever the Ingress is on) Rails builds that redirect as
|
||||
# https://<pod-ip>:3000/... - a scheme Puma does not speak. The install-test
|
||||
# failed with "http: server gave HTTP response to HTTPS client" on a pod
|
||||
# that was answering correctly. curl without -L asks once and reports the
|
||||
# status code; the test accepts 2xx and 3xx exactly as the kubelet would,
|
||||
# and a 500 from a missing database still fails. curl is part of the image
|
||||
# (the Dockerfile installs it), so nothing extra is pulled in for this.
|
||||
#
|
||||
# One condition this depends on: SELF_HOSTED=true, which the chart always
|
||||
# sets. In managed mode the same code short-circuits before User.count and
|
||||
# "/" would redirect without touching Postgres at all.
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- "code=\"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/)\"; test \"$code\" -ge 200 -a \"$code\" -lt 400"
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
# The startup probe gates liveness and readiness, so neither needs an
|
||||
# initialDelaySeconds. Its budget has to cover the first start, which is the
|
||||
# expensive one: the image entrypoint (/rails/bin/docker-entrypoint) runs
|
||||
# "./bin/rails db:prepare" whenever the command is "./bin/rails server", so
|
||||
# the web container creates and migrates the schema before Puma binds the
|
||||
# port. Budget 10s x 60 = 600s. Raise it before a large app-version jump;
|
||||
# a restart in the middle of a migration is the failure mode it prevents.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /up
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 60
|
||||
# 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 also where the AI features go - the chart models none of them as
|
||||
# values on purpose. OPENAI_ACCESS_TOKEN (secretKeyRef form), OPENAI_MODEL,
|
||||
# OPENAI_URI_BASE, LLM_CONTEXT_WINDOW, OPENAI_REQUEST_TIMEOUT,
|
||||
# AI_RESPONSE_TIMEOUT, ASSISTANT_MAX_TOOL_CALL_ITERATIONS and the SMTP_*
|
||||
# block are plain environment variables to this image. Set them on BOTH
|
||||
# containers where a background job needs them as well (the assistant runs
|
||||
# in the worker).
|
||||
#
|
||||
# Additive means additive: an entry repeating a name the chart already sets
|
||||
# reaches the pod twice at install time (the later one, yours, wins) but is
|
||||
# LOST on a later helm upgrade, because Kubernetes merges env by name and
|
||||
# the pair collapses to the chart's own value - with a green upgrade either
|
||||
# way (README, guideline 10).
|
||||
env: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# worker - Sidekiq: the same image, started with "bundle exec sidekiq" as
|
||||
# its args (the image entrypoint stays in place; see the StatefulSet)
|
||||
# ---------------------------------------------------------------------------
|
||||
worker:
|
||||
securityContext:
|
||||
container:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
resources:
|
||||
# Same sizing as the web container, for a documented reason: upstream
|
||||
# notes that the sample/demo-data generator runs in the WORKER and is the
|
||||
# one operation that deterministically exceeds 512 MB (observed flat at
|
||||
# ~680 MB on a 2 GB container). The computed limit of 3 x 512Mi = 1536Mi
|
||||
# covers that; the request stays at the everyday figure.
|
||||
#
|
||||
# ephemeral-storage: same 256Mi as the web container and for the same
|
||||
# measured reason - see the comment there.
|
||||
limitFactor: 3
|
||||
requests:
|
||||
cpu: 0.1
|
||||
memory: 512Mi
|
||||
ephemeral-storage: 256Mi
|
||||
# PROBES - the worker speaks no HTTP at all, so all three are exec probes.
|
||||
#
|
||||
# The command asks Sidekiq's own monitor whether THIS process is in the
|
||||
# process set: sidekiqmon prints one line per registered process, each
|
||||
# starting with the identity "<hostname>:<pid>:<nonce>" (sidekiq 8.0.5,
|
||||
# lib/sidekiq/component.rb), and in a pod the hostname is the pod name that
|
||||
# bash exposes as $HOSTNAME. Sidekiq keeps that registration alive with a
|
||||
# heartbeat whose Redis key expires after about 60s, so a process that hangs
|
||||
# disappears from the set while its PID still exists - which is precisely
|
||||
# what a process check like "pgrep sidekiq" could not tell you (and would
|
||||
# not tell you anything at all: Sidekiq is PID 1 here, so if it dies the
|
||||
# container dies with it and the check can never fail - a check a broken
|
||||
# system passes, README).
|
||||
#
|
||||
# READ FROM THE SIDEKIQ 8.0.5 SOURCE, NOT MEASURED: sidekiqmon is a plain
|
||||
# Ruby entry point that requires sidekiq/api only - it does NOT boot Rails -
|
||||
# and it reads REDIS_URL from the environment, which the chart sets on this
|
||||
# container. Cost per check is therefore one Ruby start, not an application
|
||||
# boot; the periods below are chosen on the assumption that this takes a few
|
||||
# seconds, and the PR install-test is the first place it actually runs.
|
||||
#
|
||||
# Liveness hangs on Redis, which guideline 12.3 allows only where no
|
||||
# dependency-free path proves anything - that is the case here (see above) -
|
||||
# and it is given a deliberately generous budget of 60s x 5 = 300s of
|
||||
# missing heartbeat, so a short Redis blip does not restart the pod.
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 5
|
||||
# Readiness uses the same check with a tighter budget: a worker that is not
|
||||
# registered in Redis is not doing any work, whatever its process state.
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 3
|
||||
# The startup probe gates the other two. Sidekiq boots the full Rails app
|
||||
# before it registers, and on a first install it waits for the web container
|
||||
# to finish db:prepare, so the budget matches the web container's: 20s x 30
|
||||
# = 600s.
|
||||
startupProbe:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:"
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 15
|
||||
failureThreshold: 30
|
||||
# Extra environment variables for the worker, appended to the ones the chart
|
||||
# always sets. Same rules as sure.web.env above.
|
||||
env: []
|
||||
|
||||
# The Active Storage volume, mounted at /rails/storage in BOTH containers.
|
||||
# Deliberately not null-safe: an erased block yields an invalid PVC, which fails
|
||||
# loudly (README, guideline 12).
|
||||
storage:
|
||||
amount: 10Gi
|
||||
storageClass: null
|
||||
|
||||
ingress:
|
||||
# Gates the whole Ingress template. Note that ingress.domain is read three
|
||||
# more times outside it - APP_DOMAIN (the host Action Mailer builds link URLs
|
||||
# from), WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS (the relying party a
|
||||
# passkey is bound to) - so it keeps its own required() there and stays
|
||||
# mandatory with the Ingress switched off; only clusterIssuer becomes
|
||||
# unnecessary (README, guideline 12.1). Without that guard the app would come
|
||||
# up with WEBAUTHN_RP_ID="" and register passkeys against the wrong relying
|
||||
# party - rendered cleanly, pod green.
|
||||
#
|
||||
# ingress.enabled also decides how the app is told about TLS: enabled renders
|
||||
# RAILS_ASSUME_SSL=true and RAILS_FORCE_SSL=true (the Ingress terminates TLS,
|
||||
# so Rails must trust that and may set secure cookies and HSTS), disabled
|
||||
# renders both as "false" so a plain-HTTP port-forward can log in at all.
|
||||
enabled: true
|
||||
clusterIssuer: null
|
||||
domain: null
|
||||
className: nginx
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer annotation the chart always sets.
|
||||
annotations: {}
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user