Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3b05503e
|
||
|
|
813c796a7b
|
||
|
|
e0b552d93e
|
||
|
|
9df2b68ed9
|
||
|
|
733fea5d9e
|
||
|
|
1d3bacfb12
|
||
|
|
8074ed14a5
|
||
|
|
d9267b2180 | ||
|
|
a414af089d | ||
|
|
93fdb3eaea | ||
|
|
819d7b2cf7 | ||
|
|
7115eb87f2 | ||
|
|
9344a45992
|
||
|
|
4715d85b99 | ||
|
|
5ff6e401c7 | ||
|
|
8074e0de59
|
||
|
|
1edcf6fb9c
|
||
|
|
c36c5ee2c5
|
||
|
|
02b3c3ce75
|
||
|
|
ddbf82e0d4
|
||
|
|
5b13569948
|
||
|
|
feb70cf1e4
|
||
|
|
03bb454eb7
|
||
|
|
afa1efb598 | ||
|
|
60670182ff
|
||
|
|
ae621746a1
|
||
|
|
82b90475ce | ||
|
|
87221abf29 | ||
|
|
fdd798e1d9
|
||
|
|
417dff0f80
|
||
|
|
4766126618
|
||
|
|
31e4260580
|
||
|
|
301fc0d4e5
|
||
|
|
b9917505cd
|
||
|
|
c3889a66b6
|
||
|
|
241eda15a4 | ||
|
|
c1fddf1966 | ||
|
|
0f92701f4e | ||
|
|
a6c27ebef3
|
||
|
|
f9a5e48461 | ||
|
|
055d9f6a31 | ||
|
|
1009738c15 | ||
|
|
9f8b421b64 | ||
|
|
3def619306 | ||
|
|
b020532942 | ||
|
|
1fa5179813 | ||
|
|
eb6e40d9a0 | ||
|
|
20c2bf9d97 | ||
|
|
a446454900 | ||
|
|
49ebd4be3c | ||
|
|
be5ebdbe07 | ||
|
|
7c31ab45c5 | ||
|
|
63a4783d55 | ||
|
|
e0c2de883c | ||
|
|
706dee7458 | ||
|
|
14412a4670 | ||
|
|
2b7c24be09 | ||
|
|
9c0e1e6306 | ||
|
|
024e4e9de1 | ||
|
|
78d7020dab
|
||
|
|
b9a2e3daa3 | ||
|
|
5a4eeefd0e
|
||
|
|
622e4c3ff8
|
||
|
|
7e168da492
|
||
|
|
9f1c776672 | ||
|
|
f49bfa4af8 | ||
|
|
9c0039685b
|
||
|
|
63662c8ea8 | ||
|
|
485f4fa52b
|
||
|
|
b1709e6336 | ||
|
|
f761767630
|
||
|
|
f8acdfdfe1
|
No files matched your search
@@ -38,7 +38,9 @@ on:
|
||||
- searxng
|
||||
- bentopdf
|
||||
- ytdlp-web-player
|
||||
# freesailarr is deliberately absent: none of its nine images takes
|
||||
- sure
|
||||
- valkey
|
||||
# freesailarr is deliberately absent: none of its ten images takes
|
||||
# its tag from appVersion (they are all pinned in its values.yaml),
|
||||
# so this workflow would rewrite appVersion and republish the chart
|
||||
# without changing a single deployed image. Update its images through
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,414 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
@@ -11,6 +11,8 @@ jobs:
|
||||
any-chart-changed: ${{ steps.detect.outputs.any-chart-changed }}
|
||||
scan-charts: ${{ steps.detect.outputs.scan-charts }}
|
||||
any-scan: ${{ steps.detect.outputs.any-scan }}
|
||||
install-charts: ${{ steps.detect.outputs.install-charts }}
|
||||
any-install: ${{ steps.detect.outputs.any-install }}
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v7
|
||||
@@ -54,6 +56,55 @@ jobs:
|
||||
echo "any-scan=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# --- The install-test matrix: changed charts minus the excluded ones
|
||||
# (issue #231). A chart in ci/excluded-charts.txt used to get a job
|
||||
# that ran the script, printed SKIP and exited 0 - and GitHub knows
|
||||
# only the exit code, so the check list showed a green tick for a
|
||||
# chart nothing was ever attempted on. Dropping it here means no job
|
||||
# exists for it and nothing claims it passed.
|
||||
#
|
||||
# The list is NOT parsed here. ci/run-install-test.sh --list-excluded
|
||||
# emits "<chart><TAB><reason>", and that script is the one parser;
|
||||
# re-implementing the format in YAML would be a second chance to
|
||||
# select different charts, silently. The comparison below is jq's
|
||||
# "!=" on whole strings, so a chart name is never read as a pattern.
|
||||
INSTALL_CHARTS="$CHARTS"
|
||||
SUMMARY_ROWS=""
|
||||
while IFS=$'\t' read -r EX_NAME EX_REASON; do
|
||||
[ -n "$EX_NAME" ] || continue
|
||||
if [ "$(echo "$INSTALL_CHARTS" | jq -r --arg c "$EX_NAME" 'index($c) != null')" = "true" ]; then
|
||||
# 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
|
||||
INSTALL_CHARTS="$(echo "$INSTALL_CHARTS" | jq -c --arg c "$EX_NAME" 'map(select(. != $c))')"
|
||||
done <<< "$(ci/run-install-test.sh --list-excluded)"
|
||||
echo "install-charts=$INSTALL_CHARTS" >> "$GITHUB_OUTPUT"
|
||||
if [ "$INSTALL_CHARTS" = "[]" ]; then
|
||||
echo "any-install=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "any-install=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# An absent job says "not run" but never why, and an absence is easy
|
||||
# to miss entirely. So every excluded chart of this PR is named here
|
||||
# WITH its documented justification - which is the question a review
|
||||
# actually asks. This summary is the visible half of the change; the
|
||||
# dropped matrix entry is only the half that stops the lie.
|
||||
if [ -n "$SUMMARY_ROWS" ]; then
|
||||
{
|
||||
echo "### Charts excluded from the install-test"
|
||||
echo
|
||||
echo "These charts are changed by this PR but listed in \`ci/excluded-charts.txt\`, so they 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
|
||||
echo "Install-test matrix: $INSTALL_CHARTS"
|
||||
|
||||
build-chart:
|
||||
name: "Build ${{ matrix.chart }}"
|
||||
needs: detect-changed-charts
|
||||
@@ -106,8 +157,9 @@ 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 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.
|
||||
# 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.
|
||||
# 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
|
||||
@@ -148,11 +200,27 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@v5
|
||||
# A plain release download rather than aquasecurity/setup-trivy, and the
|
||||
# reason is measured on the Gitea probe PR of #225, not assumed. That
|
||||
# action runs actions/checkout against aquasecurity/trivy on github.com
|
||||
# and hands it `github.token` - which on Gitea is a GITEA token.
|
||||
# github.com answers 401, checkout retries twice and the job ends on
|
||||
# "fatal: could not read Username for 'https://github.com': terminal
|
||||
# prompts disabled", an error naming neither the action nor the token.
|
||||
# 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 even started. One tarball behaves
|
||||
# identically on both forges, and the `# renovate:` comment above still
|
||||
# keeps TRIVY_VERSION current - verified by dry run: pinned to v0.72.0
|
||||
# the lookup offers v0.74.0 as a minor update.
|
||||
- name: "Install Trivy"
|
||||
uses: aquasecurity/setup-trivy@v0.3.1
|
||||
with:
|
||||
version: ${{ env.TRIVY_VERSION }}
|
||||
cache: true
|
||||
run: |
|
||||
curl -sSfL -o /tmp/trivy.tar.gz \
|
||||
"https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${TRIVY_VERSION#v}_Linux-64bit.tar.gz"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
tar -xzf /tmp/trivy.tar.gz -C "$HOME/.local/bin" trivy
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/trivy" --version
|
||||
# Findings land in the job summary, not only in the log: exceptions with
|
||||
# their reasons come from ci/trivy/trivyignore.yaml, the trusted-registry
|
||||
# list from ci/trivy/data/ksv0125.yaml.
|
||||
@@ -160,83 +228,30 @@ jobs:
|
||||
run: ci/run-trivy-config.sh "${{ matrix.chart }}"
|
||||
|
||||
install-test:
|
||||
name: "Install-test ${{ matrix.chart }}"
|
||||
name: "Install-test"
|
||||
needs: [detect-changed-charts, build-chart]
|
||||
if: needs.detect-changed-charts.outputs.any-chart-changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 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
|
||||
matrix:
|
||||
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }}
|
||||
env:
|
||||
K3D_VERSION: v5.9.0
|
||||
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v7
|
||||
- name: "Install Helm"
|
||||
uses: azure/setup-helm@v5
|
||||
# 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
|
||||
- name: "Create k3d cluster"
|
||||
run: k3d cluster create install-test --image "$K3S_IMAGE" --wait --timeout 180s
|
||||
# Excluded charts (see ci/excluded-charts.txt) are skipped inside the
|
||||
# script with their documented reason. The variables below are empty for
|
||||
# every other chart and are read only by the chart 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 }}"
|
||||
- name: "Delete k3d cluster"
|
||||
if: always()
|
||||
run: k3d cluster delete install-test || true
|
||||
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.
|
||||
#
|
||||
# "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
|
||||
@@ -7,26 +7,70 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
The main Claude session acts as the **task manager** and does not implement issues itself:
|
||||
|
||||
1. **Plan first, get approval**: The manager presents a plan for the issue and waits for the user's explicit approval. No implementation before approval.
|
||||
2. **Document the final plan on the issue**: Once approved, the plan is posted as a comment on the GitHub issue before implementation starts (skip if the issue body already contains the full plan; later decisions go there as comments too).
|
||||
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. **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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
- **`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.
|
||||
- **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.
|
||||
|
||||
## 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 via GitHub 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 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):
|
||||
|
||||
```bash
|
||||
helm lint --strict <chart-name>
|
||||
@@ -35,7 +79,7 @@ helm template <chart-name> --set ingress.domain=example.com --set ingress.cluste
|
||||
|
||||
## Creating a new chart
|
||||
|
||||
Copy `template-chart/` and replace every `xxx` placeholder — in file names (`templates/xxx.deployment.yaml` → `templates/<app>.deployment.yaml`), in `Chart.yaml`, in the `values.yaml` keys and their `.Values.xxx.*` references inside templates, and in `values.schema.json` (both the values paths and the `xxx.` helper prefixes). The schema and the null-safe fallback helpers come with the template but their content is chart-specific — adapt them along the five points of the README section "Values that must not lie", and validate the result against the chart's real bundle values, not only against its defaults. Then wire up CI:
|
||||
Copy `template-chart/` and replace every `xxx` placeholder — in file names (`templates/xxx.deployment.yaml` → `templates/<app>.deployment.yaml`), in `Chart.yaml`, in the `values.yaml` keys and their `.Values.xxx.*` references inside templates, and in `values.schema.json` (both the values paths and the `xxx.` helper prefixes). The schema and the null-safe fallback helpers come with the template but their content is chart-specific — adapt them along the six points of the README section "Values that must not lie", and validate the result against the chart's real bundle values, not only against its defaults. Then wire up CI:
|
||||
|
||||
1. Add `.github/workflows/publish-<chart-name>.yml` (copy an existing one, e.g. `publish-outline.yml`) — it triggers on pushes to `main` touching `<chart-name>/**` and calls the reusable `publish-helm-chart.yml`.
|
||||
2. Add the chart name to the `chart-name` choice list in `.github/workflows/change-app-version.yml`.
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
# helm-charts
|
||||
|
||||
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 via GitHub Actions.
|
||||
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.
|
||||
|
||||
## 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 |
|
||||
| `cyberchef` | CyberChef | Stateless Deployment |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value |
|
||||
| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). Published once per Java line — the image tag comes from `.Chart.AppVersion`, so the Java line is chosen by picking the chart version, and `minecraft.version` ships a default matching that line. 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 |
|
||||
| `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 | Two Deployments (frontend + backend), OIDC secret |
|
||||
| `patchmon` | PatchMon | One Deployment (the 2.x server serves frontend and `/api/*` from one process) plus an optional guacd sidecar; OIDC-only sign-in |
|
||||
| `rallly` | Rallly | Deployment, DB/OIDC/SMTP secrets |
|
||||
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) |
|
||||
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
|
||||
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
|
||||
| `searxng` | SearXNG | Stateless Deployment + settings ConfigMap (`use_default_settings: true` plus a free-form `settings.extra` passthrough); the signing key is a required `OnePasswordItem`, an external Valkey an optional second one. `ingress.domain` is read twice — Ingress host and `SEARXNG_BASE_URL` — so it stays required with the Ingress switched off |
|
||||
| `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 |
|
||||
@@ -38,7 +48,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
|
||||
- Bump the **chart version** in every change that touches a chart directory — a push to `main` publishes the chart.
|
||||
- **App-version updates go through PRs** (so the validate and install-test pipelines run). The manually dispatched `Change App-Version` workflow (rewrites `version:`/`appVersion:` and commits directly to `main`) exists but bypasses both pipelines. For app **major** jumps, update stepwise: first to the last release of the old major line (own PR, merge, publish), then to the newest major.
|
||||
|
||||
## CI/CD (GitHub Actions)
|
||||
## CI/CD
|
||||
|
||||
- `publish-<chart>.yml` — one thin workflow per chart; triggers on pushes to `main` touching `<chart>/**` (or manually) and calls the reusable workflow below. **A push to a chart directory on `main` publishes that chart**, so the chart version must be bumped in the same change.
|
||||
- `publish-helm-chart.yml` — reusable workflow: `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry. The target is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (`OP_CONNECT_TOKEN` secret, item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`); the workflow appends `/api/charts` to `charts-url`. No registry address ever appears in this repository. The loaded values are read via **`steps.<id>.outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error. Three properties of the upload step are load-bearing:
|
||||
@@ -49,7 +59,57 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
|
||||
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 (same changed-charts matrix) 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.
|
||||
- `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.
|
||||
|
||||
### Where this repo lives, and what a Gitea runner does differently
|
||||
|
||||
Everything below was measured on this instance, not taken from documentation, and it is the reason for every deviation in `validate-charts.yml`. How the move got here is not repeated — that is Gitea #14.
|
||||
|
||||
**Reusable workflows with `secrets: inherit` work**, so the 24 thin `publish-<chart>.yml` hulls run unchanged. One quirk worth knowing before debugging the wrong thing: a *missing* secret does not fail the job, it fails job **creation**, so the run shows no steps at all.
|
||||
|
||||
**Runner labels mean different machines than on GitHub.** `ubuntu-latest` here has **no Docker socket**; `ubuntu-docker` has one. Exactly one job cares — the install-test, because k3d is a Docker client — and its `runs-on` is therefore an expression rather than a fixed label, so the same file still works on a GitHub runner where `ubuntu-docker` does not exist. Every other job (lint/package, the quantity render, the Trivy scan) needs no socket and stays on `ubuntu-latest`.
|
||||
|
||||
**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.
|
||||
|
||||
### Resource-quantity render test (all charts, every PR)
|
||||
|
||||
@@ -73,6 +133,8 @@ 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`)
|
||||
@@ -80,15 +142,31 @@ Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the ch
|
||||
- `ci/<chart>/fixtures.yaml` — dummy secrets with in-cluster connection data, throwaway infrastructure (postgres/redis/dex), static hostPath PVs/StorageClasses where the chart pins a storage class or needs RWX, and seed jobs (e.g. `synapse generate`)
|
||||
- `ci/<chart>/fixtures.env.yaml` — fixtures that need a value which must not be committed. The file holds `${VARIABLE}` placeholders and nothing else; `ci/run-install-test.sh` renders it with `envsubst` and pipes the result straight into `kubectl` (never printed). Substitution is restricted to the names from `REQUIRED_ENV`, so an undeclared variable is never filled in and no unrelated `$…` in the manifest is touched
|
||||
- `ci/<chart>/settings.env` — optional overrides: `INSTALL_TIMEOUT` for slow starters, and `REQUIRED_ENV="VAR_A VAR_B"` to declare the variables `fixtures.env.yaml` needs. **A missing or empty declared variable fails the install-test loudly, naming the variable — it never skips the chart.** Skipping would restore exactly the green-but-meaningless signal this mechanism exists to remove; the one legitimate case of absent secrets, a PR from a fork, belongs in an `if` on the workflow job rather than in the script
|
||||
- `ci/excluded-charts.txt` — charts that cannot run in CI, each with a justification. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `template-chart` (scaffold with placeholder image)
|
||||
- `ci/excluded-charts.txt` — charts that cannot run in CI, one per line as `<chart> <justification>`. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `template-chart` (scaffold with placeholder image). The justification is mandatory: a line carrying only a chart name is deliberately **not** an exclusion, because a bare name would drop a chart out of CI with nothing to review. **An excluded chart gets no install-test job at all** — see below
|
||||
|
||||
`freesailarr` is the only chart using `fixtures.env.yaml` today: gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The install-test job loads the key from 1Password Connect for that chart only, using the same pattern as `publish-helm-chart.yml` — `steps.<id>.outputs.*` into the step's own `env:`, quoted, with `:?` guards that name the missing `op://` field before the cluster is even created. The key in that item is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make every PR run pull the tunnel out from under the running stack — with the symptom in the cluster and the cause in a pull request.
|
||||
|
||||
**An excluded chart is dropped from the matrix, not skipped inside its job (#231).** It used to get a job that ran the script, printed `SKIP` and exited 0 — and GitHub knows only the exit code, so the check list showed `Install-test satisfactory ✓` for a chart whose image was never pulled, indistinguishable from one that really came up in the cluster. `detect-changed-charts` now removes the excluded charts before the matrix is built, so no job exists for them and nothing claims they passed. Because an absence on its own says nothing, that job writes every excluded chart of the PR **with its documented justification** into the job summary — a `Skipped` would only say "not run", and why is the question a review actually asks. The list is parsed in exactly one place: `ci/run-install-test.sh --list-excluded` emits `<chart><TAB><reason>` and the workflow calls it instead of re-implementing the format in YAML, since a second parser would be a second chance to select different charts — silently, and in either direction. The script keeps its own `SKIP` branch for local runs. What cannot be done instead: deciding per chart in an `if` on the install-test job, to get a visible `Skipped` entry rather than an absent one. GitHub rejects it with `context "matrix" is not allowed here` — `jobs.<id>.if` is evaluated **before** the matrix is expanded, so the `matrix` context does not exist there, and the whole workflow ends in failure with zero jobs created (measured on the closed PR #233).
|
||||
|
||||
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.
|
||||
|
||||
## Dependency updates (Renovate)
|
||||
|
||||
Renovate runs as the self-hosted `jlab-renovate` GitHub App. 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`.
|
||||
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`.
|
||||
|
||||
What Renovate keeps current:
|
||||
|
||||
@@ -96,15 +174,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 |
|
||||
| **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 |
|
||||
| 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, 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -130,18 +208,35 @@ 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 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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### PR policy
|
||||
|
||||
- **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 rather than using GitHub auto-merge, which would merge immediately as long as `main` has no required status checks. 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 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.
|
||||
- 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
|
||||
|
||||
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 five 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.
|
||||
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`.
|
||||
4. Add a `customManagers` block for the chart's main image in `renovate.json5` (copy a neighbouring one, swap `managerFilePatterns` and `depNameTemplate`) — without it the chart's app version is never updated. Check the image's tag list first: if it carries a flavour, date or prerelease suffix, add a `versioningTemplate` with a comment saying why. **Floating tags belong outside the pattern**: `ytdlp-web-player` anchors its versioning regex on the `v` prefix precisely so `latest` and `stable` — both of which move — can never be selected.
|
||||
@@ -160,7 +255,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.
|
||||
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.
|
||||
|
||||
### 1. Naming
|
||||
|
||||
@@ -181,7 +276,8 @@ 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 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 — 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 — 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.
|
||||
|
||||
### 4. Ingress
|
||||
@@ -192,7 +288,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 is pinned to `/`. `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 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.
|
||||
|
||||
### 5. Security hardening
|
||||
|
||||
@@ -245,6 +341,7 @@ These guidelines are **binding for every current and future chart** in this repo
|
||||
|
||||
- Every workload container offers an additive `env` value block, appended to the env entries the chart always sets.
|
||||
- Entries are rendered through `tpl` (so values may contain Helm templating) and support the `value`, `secretKeyRef` and `configMapKeyRef` forms (pattern: `template-chart/templates/xxx.deployment.yaml`).
|
||||
- **Additive means additive: an entry repeating a name the chart already sets does not replace it, and what the pair does depends on when it arrives.** At install both entries reach the pod and the later one — the user's — wins. On an *existing* release the same entry is lost: Kubernetes merges `env` by name, the pair collapses to the chart's own value, and `helm upgrade` still reports success. Measured on `urlaubsverwaltung` (helm 3.19.0, Kubernetes 1.35): `helm get manifest` shows the variable twice while the live Deployment shows it once, carrying the chart's value. The failure mode is silence, so a chart whose `NOTES.txt` tells operators to override one of its own variables has to say that the override only takes at install time — and that a green upgrade proves nothing, the value belongs read back off the Deployment.
|
||||
|
||||
### 11. Scaling
|
||||
|
||||
@@ -252,7 +349,7 @@ These guidelines are **binding for every current and future chart** in this repo
|
||||
- Every chart has a global `scaleDown` flag (default `false`): `scaleDown: true` takes precedence over all `replicas` values, renders every Deployment/StatefulSet with `replicas: 0` and suspends CronJobs. It **stops** the app but does not uninstall it — PVCs, Secrets, Services and Ingress stay in place; setting it back to `false` brings the workloads back up.
|
||||
- Implemented via a per-chart `<chart>.replicas` helper in `_helpers.tpl` (reference in `template-chart`); the helper uses an explicit nil check (`kindIs "invalid"`) because Helm's `default` would swallow an explicit `0`.
|
||||
|
||||
### 12. Values that must not lie (#68, #93, #99)
|
||||
### 12. Values that must not lie (#68, #93, #99, #215)
|
||||
|
||||
Every chart ships a **`values.schema.json`** with `additionalProperties: false` on **every chart-owned object level** — not just the top one. The gap this closes is a key that looks like configuration and does nothing: the pilot (#68) found three of them in one chart, one of them nested three levels deep and quietly leaving a game world unpinned. Objects that are passed **verbatim** into the pod spec stay open (probes, `securityContext.pod`/`.container`, `global`, free-form annotation maps): Kubernetes owns their schema, and restricting them would only reject fields that do not exist yet. The schema **adds to** `required()`, it does not replace it.
|
||||
|
||||
@@ -268,20 +365,36 @@ 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.
|
||||
|
||||
**4. A fallback reproduces *this chart's* defaults, not the repo standard.** Five charts deviate from the hardening baseline on purpose. A "cleaned up" standard default is not tidying, it is an outage: `calibre-web-automated` must keep `readOnlyRootFilesystem: false`, because in read-only mode the image disables its PUID/PGID remapping and the runtime uid silently moves from 1000 to 911 — **making existing volume data inaccessible**. The same applies to non-standard values that look like mistakes: `satisfactory` keeps `limitFactor: 2` (that is what holds the memory limit at the published 8Gi) and `fsGroupChangePolicy: OnRootMismatch` (which avoids a recursive chown of multi-GB game files on every start).
|
||||
|
||||
**5. A probe default carries the probe's context, not just its timings.** Two different symptoms, both from real charts: `teamspeak-server` addresses its TCP port by the name `file-transfer` because the voice service is UDP and cannot be probed — a default without that name leaves the workload **with no working probe at all**. `patchmon` sends a `Host` header because the server answers **403** to any request whose Host is neither loopback nor a CORS_ORIGIN host — a default without it means the pod never becomes Ready. Port names, `Host` headers and `exec` commands belong in the fallback; where the context is dynamic, pass the root context in and rebuild it (patchmon derives the header from `ingress.domain`).
|
||||
|
||||
**6. A value that reads as the negation of the variable it feeds must actually be negated — and `not` cannot do it.** `satisfactory` shipped `game.seasonalEvents` straight into the image's `DISABLESEASONALEVENTS` (#215): the default `true` switched the events **off** in every install for the chart's whole life, while the schema described the same key as "true means events are enabled". The fix is the loud one — rename the key so it says what it does (`game.enableSeasonalEvents`) rather than silently inverting the template under existing releases, because the old key then fails schema validation instead of quietly meaning the opposite. Two mechanics come with it: Go's `not` treats **every non-empty string as true**, so `not "false"` is `false` and the string form these schemas deliberately accept flips the wrong way — compare explicitly (`eq (lower (printf "%v" $v)) "true"`) instead. And a value that cannot be read as either boolean belongs in a `fail`, not in an implicit branch: `enableSeasonalEvents: "yes"` must abort the render, not be quietly filed under "off". When such a rename lands, the new default is the one that **reproduces what the chart already rendered**; changing the meaning and the behaviour in the same release turns the loud fix back into a silent one.
|
||||
|
||||
**Two things about the mechanics that are easy to lose:**
|
||||
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **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 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.
|
||||
- `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.
|
||||
- `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.1+up3.3.1.0.full
|
||||
version: 10.0.2+up3.3.1.0.full
|
||||
appVersion: "3.3.1.0-full"
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "apache-tika.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "apache-tika.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- 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.2+up2.8.8
|
||||
version: 1.0.3+up2.8.8
|
||||
appVersion: "2.8.8"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "bentopdf.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "bentopdf.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- 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: 6.1.0+upv4.0.6
|
||||
version: 7.0.1+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
|
||||
{{ if .Values.secrets.cwaSecretRef }}{{ .Values.secrets.cwaSecretRef }}{{ else }}(secrets.cwaSecretRef is unset — see below){{ end }}
|
||||
{{ .Values.secrets.cwaSecretRef }}
|
||||
→ 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,13 +74,10 @@ 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 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 }}
|
||||
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.
|
||||
|
||||
CHANGING THE ITEM'S CONTENT RESTARTS NOTHING
|
||||
Helm never sees what is inside a OnePasswordItem, so it cannot hash it into the
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "calibre-web-automated.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "calibre-web-automated.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -167,8 +169,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "calibre-web-automated.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ .Values.secrets.cwaSecretRef }}
|
||||
itemPath: {{ required "A 1Password item reference must be provided (secrets.cwaSecretRef)" .Values.secrets.cwaSecretRef }}
|
||||
@@ -17,6 +17,7 @@
|
||||
"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,6 +5,15 @@
|
||||
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:
|
||||
|
||||
@@ -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/validate-charts.yml. A CI-only key
|
||||
# the install-test job of .github/workflows/install-test.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,8 +1,11 @@
|
||||
# Nine containers in one pod, several GB of images to pull, and the app
|
||||
# Ten 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). The default of 300s is not enough for that.
|
||||
INSTALL_TIMEOUT=900
|
||||
# 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
|
||||
|
||||
# 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 eight app containers
|
||||
# freesailarr: the whole stack in one pod - gluetun plus nine 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,3 +37,11 @@ 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
|
||||
+52
-8
@@ -4,6 +4,7 @@
|
||||
# fixtures under ci/<chart>/ and requires every workload to become Ready.
|
||||
#
|
||||
# Usage: ci/run-install-test.sh <chart-name>
|
||||
# ci/run-install-test.sh --list-excluded
|
||||
#
|
||||
# Layout (repo root):
|
||||
# ci/common/ fixtures applied for every chart (OnePasswordItem stub CRD)
|
||||
@@ -14,12 +15,51 @@
|
||||
# ci/excluded-charts.txt charts that cannot run in CI, with reasons
|
||||
set -euo pipefail
|
||||
|
||||
CHART="${1:?usage: $0 <chart-name>}"
|
||||
NAMESPACE="default"
|
||||
RELEASE="ci"
|
||||
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
|
||||
# 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
|
||||
# workflow calls it through "--list-excluded" instead of re-implementing it.
|
||||
#
|
||||
# The format is one chart per line: first whitespace-separated field the chart
|
||||
# name, the rest of the line its mandatory justification. Blank lines and lines
|
||||
# starting with "#" are comments. A line carrying only a name and no reason is
|
||||
# deliberately NOT an exclusion (the "NF > 1" below): the justification is what
|
||||
# the README requires, and a bare name would silently drop a chart out of CI
|
||||
# with nothing to review.
|
||||
list_excluded() {
|
||||
[ -f "$CI_DIR/excluded-charts.txt" ] || return 0
|
||||
{ grep -vE '^[[:space:]]*(#|$)' "$CI_DIR/excluded-charts.txt" || true; } \
|
||||
| awk 'NF > 1 { name = $1; $1 = ""; sub(/^[[:space:]]+/, ""); print name "\t" $0 }'
|
||||
}
|
||||
|
||||
# The reason a chart is excluded, empty if it is not. Exact string match on the
|
||||
# name, not a regex, so a chart name is never read as a pattern.
|
||||
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.
|
||||
if [ "${1:-}" = "--list-excluded" ]; then
|
||||
list_excluded
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CHART="${1:?usage: $0 <chart-name> | --list-excluded}"
|
||||
|
||||
# Per-chart override via ci/<chart>/settings.env (INSTALL_TIMEOUT in seconds).
|
||||
# REQUIRED_ENV is a space-separated list of environment variables the chart's
|
||||
# fixtures need; see the check below.
|
||||
@@ -32,12 +72,16 @@ if [ -f "$CI_DIR/$CHART/settings.env" ]; then
|
||||
fi
|
||||
|
||||
# --- Exclusion list ---------------------------------------------------------
|
||||
if [ -f "$CI_DIR/excluded-charts.txt" ]; then
|
||||
REASON="$(grep -E "^$CHART([[:space:]]|$)" "$CI_DIR/excluded-charts.txt" | sed -E "s/^$CHART[[:space:]]*//" || true)"
|
||||
if [ -n "$REASON" ]; then
|
||||
echo "SKIP: chart '$CHART' is excluded from the install-test: $REASON"
|
||||
exit 0
|
||||
fi
|
||||
# Through excluded_reason(), i.e. the same parser --list-excluded uses, so a
|
||||
# local run and the workflow matrix can never disagree about which charts are
|
||||
# excluded. In CI this branch is no longer reached: the detect job drops the
|
||||
# excluded charts before the matrix is built, so no install-test job exists for
|
||||
# them (#231). It stays for local runs, where "ci/run-install-test.sh
|
||||
# satisfactory" should say why it does nothing instead of trying.
|
||||
REASON="$(excluded_reason "$CHART")"
|
||||
if [ -n "$REASON" ]; then
|
||||
echo "SKIP: chart '$CHART' is excluded from the install-test: $REASON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$ROOT/$CHART" ] || [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
|
||||
@@ -59,7 +103,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/validate-charts.yml.
|
||||
# install-test job in .github/workflows/install-test.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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
@@ -0,0 +1,10 @@
|
||||
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. 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, 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."
|
||||
|
||||
- id: AVD-KSV-0021
|
||||
statement: "GIDs follow the pinned UIDs above; the same image expectation and the same PVC ownership risk apply."
|
||||
@@ -109,6 +109,19 @@ 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"]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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
|
||||
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/gchq/CyberChef/master/src/web/static/ima
|
||||
|
||||
type: application
|
||||
|
||||
version: 9.0.1+up11.4.0
|
||||
version: 9.0.2+up11.4.0
|
||||
appVersion: "11.4.0"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "cyberchef.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "cyberchef.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "cyberchef.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
+12
-10
@@ -1,23 +1,25 @@
|
||||
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 and request front end 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, request front end and an optional public-broadcaster indexer 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 nine here would suggest
|
||||
# icon is its one project's logo. Picking any of the ten 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.0.5+up1.0.0
|
||||
version: 3.1.1+up1.0.0
|
||||
# The chart's own version marker, freely chosen and tied to no upstream. The
|
||||
# stack bundles nine independently versioned projects, so there is no single
|
||||
# "app version" to track here.
|
||||
# stack bundles ten independently versioned projects (nine of them on by
|
||||
# default), so there is no single "app version" to track here.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
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 }}.
|
||||
Nine containers in ONE pod, sharing gluetun's network namespace.
|
||||
{{ if .Values.mediathekarr.enabled }}Ten{{ else }}Nine{{ end }} 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,6 +134,101 @@ 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,9 +19,10 @@ Usage:
|
||||
resources:
|
||||
{{- include "freesailarr.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "freesailarr.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -135,7 +136,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 nine containers, each with its own env block.
|
||||
helper here because this chart has ten containers, each with its own env block.
|
||||
|
||||
Usage:
|
||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.<app>.env) | nindent 12 }}
|
||||
@@ -176,6 +177,14 @@ 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 -}}
|
||||
@@ -186,7 +195,8 @@ values and is never exposed from outside the cluster.
|
||||
(dict "name" "radarr" "port" 7878)
|
||||
(dict "name" "sonarr" "port" 8989)
|
||||
(dict "name" "seerr" "port" 5055)
|
||||
(dict "name" "bazarr" "port" 6767) -}}
|
||||
(dict "name" "bazarr" "port" 6767)
|
||||
(dict "name" "mediathekarr" "port" 5007 "extraPorts" (list (dict "name" "ma-newznab" "port" 5008))) -}}
|
||||
{{- if (index $.Values $app.name).enabled -}}
|
||||
{{- $apps = append $apps $app -}}
|
||||
{{- end -}}
|
||||
@@ -214,8 +224,9 @@ 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 seerr, which has no base-path support at all and can
|
||||
therefore only live at the root.
|
||||
"/<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).
|
||||
|
||||
Input: dict "root" $ "app" <app name>.
|
||||
*/}}
|
||||
@@ -226,7 +237,7 @@ Input: dict "root" $ "app" <app name>.
|
||||
{{- $path = $config.path -}}
|
||||
{{- end -}}
|
||||
{{- if or (kindIs "invalid" $path) (eq (toString $path) "") -}}
|
||||
{{- if eq .app "seerr" -}}
|
||||
{{- if has .app (list "seerr" "mediathekarr") -}}
|
||||
{{- $path = "/" -}}
|
||||
{{- else -}}
|
||||
{{- $path = printf "/%s" .app -}}
|
||||
@@ -250,9 +261,12 @@ 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 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.
|
||||
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.
|
||||
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.
|
||||
@@ -269,11 +283,14 @@ 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) -}}
|
||||
{{- 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) -}}
|
||||
{{- $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) -}}
|
||||
{{- 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" (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. 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) -}}
|
||||
{{- end -}}
|
||||
{{- $_ := set $seen $path $app.name -}}
|
||||
{{- $apps = append $apps (dict "name" $app.name "port" $app.port "path" $path) -}}
|
||||
@@ -356,6 +373,9 @@ 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 -}}
|
||||
@@ -366,7 +386,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
|
||||
downloads: qbittorrent, radarr, sonarr, mediathekarr
|
||||
movies: radarr, bazarr
|
||||
tv: sonarr, bazarr
|
||||
|
||||
@@ -375,7 +395,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 -}}true{{- end -}}
|
||||
{{- if or $v.qbittorrent.enabled $v.radarr.enabled $v.sonarr.enabled $v.mediathekarr.enabled -}}true{{- end -}}
|
||||
{{- else if eq .category "movies" -}}
|
||||
{{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}}
|
||||
{{- else if eq .category "tv" -}}
|
||||
@@ -885,6 +905,47 @@ 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
|
||||
@@ -905,7 +966,7 @@ configInit:
|
||||
|
||||
{{/*
|
||||
The effective pod security context. Separate from the per-container table
|
||||
because it is the one block shared by all nine containers.
|
||||
because it is the one block shared by all containers of the pod.
|
||||
*/}}
|
||||
{{- define "freesailarr.podSecurityContext" -}}
|
||||
{{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}}
|
||||
|
||||
@@ -21,5 +21,16 @@ 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,6 +521,109 @@ 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.
|
||||
@@ -542,11 +645,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 }}
|
||||
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled .Values.mediathekarr.enabled }}
|
||||
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }}
|
||||
- name: media-downloads
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
|
||||
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr, sonarr or mediathekarr is enabled" .Values.media.downloads.existingClaim }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
|
||||
@@ -579,12 +682,16 @@ 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" }}
|
||||
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" "mediathekarr" }}
|
||||
{{- $config := index $.Values $app }}
|
||||
{{- if or (eq $app "gluetun") $config.enabled }}
|
||||
- metadata:
|
||||
|
||||
@@ -535,6 +535,76 @@
|
||||
"$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": {
|
||||
@@ -746,6 +816,27 @@
|
||||
"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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
-4
@@ -4,10 +4,11 @@
|
||||
# the Wireguard tunnel.
|
||||
#
|
||||
# Image pinning (documented deviation from the Chart.AppVersion standard):
|
||||
# NO image tag comes from appVersion. All nine images, gluetun included, are
|
||||
# NO image tag comes from appVersion. All ten images, gluetun included, are
|
||||
# pinned here via <app>.image.repository / <app>.image.tag, because the stack
|
||||
# combines nine independently versioned upstream projects and none of them is
|
||||
# "the" one app version.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
@@ -84,7 +85,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
|
||||
# downloads: qbittorrent, radarr, sonarr, mediathekarr
|
||||
# movies: radarr, bazarr
|
||||
# tv: sonarr, bazarr
|
||||
#
|
||||
@@ -874,3 +875,259 @@ 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.1+up8.36.0
|
||||
appVersion: "8.36.0"
|
||||
version: 8.0.3+up8.37.0
|
||||
appVersion: "8.37.0"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "gotenberg.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "gotenberg.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- 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.0+up2026.8.3
|
||||
version: 6.1.1+up2026.8.3
|
||||
|
||||
appVersion: "2026.8.3"
|
||||
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "homeassistant.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "homeassistant.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -167,8 +169,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- 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.0+upv1.159.0
|
||||
version: 7.1.1+upv1.159.0
|
||||
appVersion: "v1.159.0"
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "matrix-synapse.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "matrix-synapse.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "matrix-synapse.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -5,8 +5,8 @@ icon: https://avatars.githubusercontent.com/u/988985
|
||||
|
||||
type: application
|
||||
|
||||
version: 4.1.0+up1.46.4
|
||||
appVersion: "1.46.4"
|
||||
version: 4.1.3+up1.47.1
|
||||
appVersion: "1.47.1"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "minecraft-router.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "minecraft-router.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "minecraft-router.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -5,16 +5,25 @@ icon: https://avatars.githubusercontent.com/u/988985
|
||||
|
||||
type: application
|
||||
|
||||
# The Java line of this chart version is chosen through appVersion, which is
|
||||
# the image tag: the chart is published once per line (java8 / java17 / java21 /
|
||||
# java25) and minecraft.version in values.yaml must match the line published
|
||||
# here.
|
||||
# ONLY THE NEWEST JAVA LINE IS MAINTAINED (chart 5.0.0, issue #158).
|
||||
# appVersion is the image tag and therefore fixes the JVM. Up to chart 4.1.0 the
|
||||
# chart was published once per line (java8 / java17 / java21 / java25) out of
|
||||
# this single directory, which is what made the lines drift apart: whatever
|
||||
# change landed while a line happened to lie in the directory reached that line
|
||||
# only. That is over. This directory carries exactly one line - the newest -
|
||||
# and it is the only one that gets chart changes.
|
||||
# The older lines stay published as frozen artifacts and are still installable:
|
||||
# java8 and java17 at 4.0.0, java21 at 4.1.0. They will not be updated again.
|
||||
# To run an older JVM with the CURRENT chart, set image.tagOverride in values
|
||||
# instead of installing a frozen chart version; the chart then checks that
|
||||
# minecraft.version fits the line of that tag and fails the render if it does
|
||||
# not.
|
||||
# Since chart 2.0.0 the tag is a DATED upstream release ("<release>-java<N>")
|
||||
# instead of the rolling line tag ("java<N>"): the rolling tags are rebuilt
|
||||
# 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: 4.1.0+up2026.8.2.java25
|
||||
version: 6.0.1+up2026.8.2.java25
|
||||
appVersion: "2026.8.2-java25"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
||||
{{- $budget = printf "%.0f seconds" (mulf $startup.periodSeconds $startup.failureThreshold) -}}
|
||||
{{- end -}}
|
||||
{{- $line := regexFind "java[0-9]+" (printf "%v" .Chart.AppVersion) -}}
|
||||
{{- $tag := include "minecraft-server.imageTag" . -}}
|
||||
{{- $line := include "minecraft-server.javaLine" . -}}
|
||||
{{- $tagOverridden := ne (include "minecraft-server.imageTagSource" .) ".Chart.AppVersion" -}}
|
||||
{{- $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 -}}
|
||||
@@ -26,7 +28,7 @@
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $memPinned := not (kindIs "invalid" (index $givenReq "memory")) -}}
|
||||
minecraft-server {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
minecraft-server {{ $tag }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
ACCESS — A MINECRAFT SERVER, NOT A WEB SERVICE
|
||||
This release has no Ingress, no certificate and no URL. Clients speak the
|
||||
@@ -76,25 +78,53 @@ ACCESS — A MINECRAFT SERVER, NOT A WEB SERVICE
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $svc }} {{ .Values.server.service.port }}:{{ .Values.server.service.port }}
|
||||
|
||||
THIS CHART IS PUBLISHED ONCE PER JAVA LINE — THIS IS {{ $line | default "an unrecognized line" | upper }}
|
||||
The JVM is fixed by the image tag, which comes from the chart's appVersion
|
||||
({{ .Chart.AppVersion }}), not from a value. This build runs
|
||||
minecraft.version {{ .Values.minecraft.version }}.
|
||||
THE JAVA LINE OF THIS RELEASE IS {{ $line | default "NOT RECOGNIZABLE FROM ITS TAG" | upper }}
|
||||
The JVM is fixed by the image tag, not by a value of the game. This release
|
||||
runs
|
||||
|
||||
Java 8 runs Minecraft up to 1.16.5, 1.18-1.20.4 need Java 17, 1.20.5-1.21.11
|
||||
need Java 21 and 26.1 and later need Java 25 (read off Mojang's version
|
||||
manifest, javaVersion.majorVersion per release). Nothing in this chart checks
|
||||
that minecraft.version fits the line: a mismatch renders and installs without
|
||||
a word, and only the container fails afterwards — CrashLoopBackOff once the
|
||||
startup budget{{ if $budget }} ({{ $budget }}){{ end }} is spent, with the reason in the log and
|
||||
itzg/minecraft-server:{{ $tag }}
|
||||
|
||||
with minecraft.version {{ .Values.minecraft.version }}.
|
||||
{{- if $tagOverridden }}
|
||||
|
||||
That tag comes from image.tagOverride, NOT from the chart's appVersion
|
||||
({{ .Chart.AppVersion }}). So the chart version no longer says what this release runs:
|
||||
"helm list" reports {{ .Chart.Version }} while the pod is on the tag
|
||||
above. That is the price of the override, and the reason it is not called
|
||||
image.tag.
|
||||
{{- else }}
|
||||
|
||||
That tag comes from the chart's appVersion; image.tagOverride is empty, so
|
||||
the chart version tells the whole truth about the image.
|
||||
{{- end }}
|
||||
|
||||
Since chart 5.0.0 THAT PAIRING IS CHECKED: if the line of the tag above does
|
||||
not fit minecraft.version, the render fails and names both values. Two cases
|
||||
are let through on purpose — a tag without a recognizable "java<N>" part, and
|
||||
a minecraft.version that is not a plain numeric x[.y[.z]] (a snapshot, a
|
||||
pre-release, "LATEST"). In those two the old failure mode still applies: the
|
||||
release installs, and only the container fails afterwards — CrashLoopBackOff
|
||||
once the startup budget{{ if $budget }} ({{ $budget }}){{ end }} is spent, with the reason in the log and
|
||||
nowhere else:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} logs {{ $pod }}
|
||||
|
||||
So moving the server across a line boundary is a change of CHART VERSION, not
|
||||
a change of minecraft.version: pick the published chart of the target line and
|
||||
set minecraft.version to match it. Changing only minecraft.version leaves the
|
||||
old JVM in place.
|
||||
The boundaries are Mojang's (javaVersion.majorVersion in the piston-meta
|
||||
version manifest, per release):
|
||||
|
||||
up to 1.16.5 Java 8
|
||||
1.17 - 1.17.1 Java 16
|
||||
1.18 - 1.20.4 Java 17
|
||||
1.20.5 - 1.21.11 Java 21
|
||||
26.1 and later Java 25
|
||||
|
||||
ONLY THE NEWEST LINE IS MAINTAINED. This chart directory carries one line and
|
||||
gets chart changes only for that one; java8 and java17 stay frozen at chart
|
||||
4.0.0, java21 at 4.1.0. So moving the server across a line boundary is no
|
||||
longer a change of chart version: set image.tagOverride to a tag of the target
|
||||
line AND minecraft.version to a release of it, in the same upgrade. Changing
|
||||
only one of the two now fails the render instead of leaving a broken pod
|
||||
behind.
|
||||
{{- if eq $replicas "0" }}
|
||||
|
||||
SCALED TO ZERO
|
||||
@@ -174,9 +204,11 @@ 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.
|
||||
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.
|
||||
|
||||
secrets.minecraftServerRef → {{ .Values.secrets.minecraftServerRef | default "NOT SET" }}
|
||||
secrets.minecraftServerRef → {{ .Values.secrets.minecraftServerRef }}
|
||||
|
||||
The operator copies the item's FIELDS into the Kubernetes Secret, one key per
|
||||
field name. This release consumes:
|
||||
|
||||
@@ -19,9 +19,10 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "minecraft-server.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "minecraft-server.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -183,6 +184,133 @@ last one silently won. Fail loudly instead.
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The tag of the itzg/minecraft-server image this release actually runs.
|
||||
|
||||
Default: .Chart.AppVersion, exactly as before chart 5.0.0. image.tagOverride
|
||||
replaces it when set - that is the one way to run a Java line other than the
|
||||
one this chart version publishes, now that only the newest line is maintained
|
||||
(see Chart.yaml). The value is deliberately NOT called image.tag: it is an
|
||||
emergency exit, not a knob to turn in passing, and it is not managed by
|
||||
Renovate (renovate.json5 tracks appVersion only).
|
||||
*/}}
|
||||
{{- define "minecraft-server.imageTag" -}}
|
||||
{{- $override := "" -}}
|
||||
{{- if kindIs "map" .Values.image -}}
|
||||
{{- if not (kindIs "invalid" .Values.image.tagOverride) -}}
|
||||
{{- $override = printf "%v" .Values.image.tagOverride | trim -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if $override -}}{{ $override }}{{- else -}}{{ .Chart.AppVersion }}{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Where the effective tag came from - used in the guard's message so the reader
|
||||
knows which of the two values to change.
|
||||
*/}}
|
||||
{{- define "minecraft-server.imageTagSource" -}}
|
||||
{{- $override := "" -}}
|
||||
{{- if kindIs "map" .Values.image -}}
|
||||
{{- if not (kindIs "invalid" .Values.image.tagOverride) -}}
|
||||
{{- $override = printf "%v" .Values.image.tagOverride | trim -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if $override -}}image.tagOverride{{- else -}}.Chart.AppVersion{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The Java line the effective image tag carries ("java25", "java17", ...), or the
|
||||
empty string when the tag does not name one.
|
||||
|
||||
regexFind rather than a suffix comparison, so flavoured tags keep working:
|
||||
"2026.8.2-java25", "java21-alpine" and "latest-java17-jdk" all resolve.
|
||||
*/}}
|
||||
{{- define "minecraft-server.javaLine" -}}
|
||||
{{- regexFind "java[0-9]+" (include "minecraft-server.imageTag" .) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The Java line minecraft.version requires, or the empty string when the value is
|
||||
not a version this chart can compare.
|
||||
|
||||
The boundaries are Mojang's, not this chart's: they are javaVersion.majorVersion
|
||||
from the piston-meta version manifest, swept over all 102 releases on 2026-09-02.
|
||||
The transitions are exactly four, and monotonic in release order:
|
||||
|
||||
1.0 - 1.16.5 java 8 (1.6.1-1.6.4 predate the field and carry none)
|
||||
1.17 - 1.17.1 java 16
|
||||
1.18 - 1.20.4 java 17
|
||||
1.20.5 - 1.21.11 java 21
|
||||
26.1 and later java 25
|
||||
|
||||
DELIBERATE GAP: a minecraft.version that is not a plain numeric "x", "x.y" or
|
||||
"x.y.z" - a snapshot ("26w13a"), a pre-release ("1.21.11-pre1"), "LATEST" -
|
||||
yields the empty string and is let through unchecked. The chart cannot compare
|
||||
it, and a guard that rejects everything it does not understand gets worked
|
||||
around instead of respected. The mismatch then still surfaces the old way:
|
||||
CrashLoopBackOff once the startup budget is spent, with the reason in the log.
|
||||
*/}}
|
||||
{{- define "minecraft-server.requiredJavaLine" -}}
|
||||
{{- $v := printf "%v" .Values.minecraft.version | trim -}}
|
||||
{{- if regexMatch "^[0-9]+(\\.[0-9]+){0,2}$" $v -}}
|
||||
{{- $n := len (splitList "." $v) -}}
|
||||
{{- $sv := $v -}}
|
||||
{{- if eq $n 1 -}}{{- $sv = printf "%s.0.0" $v -}}{{- else if eq $n 2 -}}{{- $sv = printf "%s.0" $v -}}{{- end -}}
|
||||
{{- if semverCompare "< 1.17.0" $sv -}}java8
|
||||
{{- else if semverCompare "< 1.18.0" $sv -}}java16
|
||||
{{- else if semverCompare "< 1.20.5" $sv -}}java17
|
||||
{{- else if semverCompare "< 26.1.0" $sv -}}java21
|
||||
{{- else -}}java25
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Guard: minecraft.version must fit the Java line of the effective image tag.
|
||||
|
||||
Until chart 5.0.0 nothing checked this - a mismatch rendered and installed
|
||||
without a word, and only the container failed afterwards. It now fails at render
|
||||
time, which is a behaviour change for an installation that already runs a
|
||||
mismatched pair (it does not run: it fails earlier and visibly instead).
|
||||
|
||||
The check runs ALWAYS, not only when image.tagOverride is set: Helm merges user
|
||||
values into the chart defaults indistinguishably, so a template cannot tell a
|
||||
deliberately set minecraft.version from the chart's own default.
|
||||
|
||||
DELIBERATE GAP, the second one: an image tag without a recognizable java<N> part
|
||||
is let through - see minecraft-server.javaLine. Same reasoning as for an
|
||||
uncomparable minecraft.version.
|
||||
*/}}
|
||||
{{- define "minecraft-server.validateJavaLine" -}}
|
||||
{{- $tag := include "minecraft-server.imageTag" . -}}
|
||||
{{- $line := include "minecraft-server.javaLine" . -}}
|
||||
{{- $required := include "minecraft-server.requiredJavaLine" . -}}
|
||||
{{- if and $line $required -}}
|
||||
{{- if ne $line $required -}}
|
||||
{{- $source := include "minecraft-server.imageTagSource" . -}}
|
||||
{{- $swapped := regexReplaceAll "java[0-9]+" $tag $required -}}
|
||||
{{- fail (printf (join "\n" (list
|
||||
"minecraft-server: the image tag and minecraft.version belong to different Java lines."
|
||||
""
|
||||
" image tag %-22s is the %s line (from %s)"
|
||||
" minecraft.version %-22s needs the %s line"
|
||||
""
|
||||
"Java 8 runs Minecraft up to 1.16.5, 1.17-1.17.1 need Java 16, 1.18-1.20.4 need"
|
||||
"Java 17, 1.20.5-1.21.11 need Java 21, and 26.1 and later need Java 25 (Mojang's"
|
||||
"version manifest, javaVersion.majorVersion per release)."
|
||||
""
|
||||
"Change one of the two:"
|
||||
" - set minecraft.version to a release of the %s line, or"
|
||||
" - set image.tagOverride to an itzg/minecraft-server tag of the %s line"
|
||||
" (this tag with its line swapped would be \"%s\" - check that upstream"
|
||||
" published it before using it)."))
|
||||
(printf "%q" $tag) $line $source
|
||||
(printf "%q" (printf "%v" .Values.minecraft.version)) $required
|
||||
$line $required $swapped) -}}
|
||||
{{- end -}}
|
||||
{{- 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.
|
||||
@@ -219,9 +347,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 charts
|
||||
in this repo deliberately have no dependencies. The reference implementation
|
||||
lives in template-chart.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "minecraft-server.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ .Values.secrets.minecraftServerRef }}
|
||||
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 }}
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- include "minecraft-server.validateModpackSources" . }}
|
||||
{{- include "minecraft-server.validateJavaLine" . }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
@@ -58,7 +59,11 @@ spec:
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
image: "itzg/minecraft-server:{{ .Chart.AppVersion }}"
|
||||
{{- /* Tag from .Chart.AppVersion unless image.tagOverride replaces
|
||||
it; validateJavaLine above rejects a tag whose Java line does
|
||||
not fit minecraft.version. Template comment on purpose - a
|
||||
YAML comment here would change the rendered manifest. */}}
|
||||
image: "itzg/minecraft-server:{{ include "minecraft-server.imageTag" . }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
{{- include "minecraft-server.containerSecurityContext" .Values.securityContext | nindent 12 }}
|
||||
|
||||
@@ -12,11 +12,23 @@
|
||||
"scaleDown": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"image": {
|
||||
"description": "May be null / empty: the effective tag then falls back to the chart's appVersion, which is the pre-5.0.0 behaviour.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tagOverride": {
|
||||
"description": "Tag of the itzg/minecraft-server image. Empty or null means the chart's appVersion. Deliberately not called \"tag\": it decouples the running image from the chart version and is not managed by Renovate. The Java line of the effective tag is validated against minecraft.version at render time; a tag without a recognizable \"java<N>\" part is let through on purpose.",
|
||||
"type": ["string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,48 @@
|
||||
# Services stay in place; scaleDown: false brings the workloads back up.
|
||||
scaleDown: false
|
||||
|
||||
image:
|
||||
# Tag of the itzg/minecraft-server image. Empty means "use the chart's
|
||||
# appVersion" (2026.8.2-java25 here), which is what every release before chart
|
||||
# 5.0.0 did unconditionally.
|
||||
#
|
||||
# WHY THIS EXISTS. Since chart 5.0.0 only the NEWEST Java line is maintained
|
||||
# (see Chart.yaml). The older lines stay published as frozen artifacts - java8
|
||||
# and java17 at chart 4.0.0, java21 at 4.1.0 - and never receive another chart
|
||||
# change. To run an older JVM with the CURRENT chart, set the tag here instead
|
||||
# of installing a frozen chart version:
|
||||
#
|
||||
# image:
|
||||
# tagOverride: "2026.8.2-java21"
|
||||
# minecraft:
|
||||
# version: "1.21.11"
|
||||
#
|
||||
# It is called tagOverride, not tag, because it is an emergency exit: it
|
||||
# decouples the running image from the chart version, so the release no longer
|
||||
# says in its metadata what it actually runs. Prefer moving to the current
|
||||
# line; use this when a modpack pins you to an older Minecraft version.
|
||||
#
|
||||
# DELIBERATELY NOT MANAGED BY RENOVATE. renovate.json5 tracks appVersion in
|
||||
# Chart.yaml (partitioned by its "java<N>" compatibility group) and nothing
|
||||
# else. A tag set here is never bumped by anything - it is yours to review.
|
||||
#
|
||||
# The Java line of the effective tag (this value, or appVersion when it is
|
||||
# empty) is checked against minecraft.version at render time; a mismatch fails
|
||||
# the render with both values named. Two cases pass unchecked on purpose: a
|
||||
# tag without a recognizable "java<N>" part, and a minecraft.version that is
|
||||
# not a plain numeric x[.y[.z]] (snapshots, pre-releases, "LATEST").
|
||||
tagOverride: ""
|
||||
|
||||
secrets:
|
||||
# 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.
|
||||
# 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).
|
||||
#
|
||||
# Documented limit (chart 3.0.0): changing this path rolls the pod (the
|
||||
# StatefulSet hashes the rendered OnePasswordItem into checksum/secret-ref),
|
||||
@@ -223,14 +261,31 @@ minecraft:
|
||||
memory: "4"
|
||||
# Minecraft version to run. Pinned rather than "LATEST", which would silently
|
||||
# drift to a new release on every restart.
|
||||
# This value must match the Java line of the chart version: the image tag
|
||||
# comes from .Chart.AppVersion, so the JVM is fixed per published chart
|
||||
# version. Java 8 runs Minecraft up to 1.16.5, 1.18-1.20.4 need Java 17,
|
||||
# 1.20.5-1.21.11 need Java 21 and 26.1 and later need Java 25. The two upper
|
||||
# boundaries are read off Mojang's version manifest (piston-meta, field
|
||||
# javaVersion.majorVersion per release), not estimated: 1.21.11 (2025-12-09)
|
||||
# is the last release asking for 21, and every release from 26.1 (2026-03-24)
|
||||
# on asks for 25. This chart publishes appVersion "2026.8.2-java25".
|
||||
#
|
||||
# This value must match the Java line of the EFFECTIVE image tag - appVersion,
|
||||
# or image.tagOverride when that is set. Since chart 5.0.0 the chart checks it
|
||||
# and FAILS THE RENDER on a mismatch, naming both values; before 5.0.0 a
|
||||
# mismatch installed silently and only the container failed afterwards.
|
||||
#
|
||||
# up to 1.16.5 Java 8
|
||||
# 1.17 - 1.17.1 Java 16 (never published as a chart line here)
|
||||
# 1.18 - 1.20.4 Java 17
|
||||
# 1.20.5 - 1.21.11 Java 21
|
||||
# 26.1 and later Java 25
|
||||
#
|
||||
# These are Mojang's boundaries, not this chart's: javaVersion.majorVersion
|
||||
# from the piston-meta version manifest, swept over all 102 releases on
|
||||
# 2026-09-02. There are exactly four transitions and they are monotonic in
|
||||
# release order (1.6.1-1.6.4 predate the field and declare none).
|
||||
#
|
||||
# Only a plain numeric x[.y[.z]] is compared. A snapshot ("26w13a"), a
|
||||
# pre-release ("1.21.11-pre1") or "LATEST" is let through unchecked - see
|
||||
# image.tagOverride for why the two gaps are deliberate.
|
||||
#
|
||||
# Quote the value. Unquoted YAML reads 1.20 as the number 1.2, and the chart
|
||||
# would pass "1.2" to the image as VERSION.
|
||||
#
|
||||
# This chart publishes appVersion "2026.8.2-java25".
|
||||
version: "26.2"
|
||||
|
||||
# curseforge, ftb and curseforgeServerpack are mutually exclusive - enabling
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon-
|
||||
|
||||
type: application
|
||||
|
||||
version: 7.1.0+up1.9.2
|
||||
appVersion: 1.9.2
|
||||
version: 7.1.3+up1.10.1
|
||||
appVersion: 1.10.1
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "outline.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "outline.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- 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.1+up3.1.0
|
||||
version: 12.1.3+up3.2.1
|
||||
|
||||
appVersion: 3.1.0
|
||||
appVersion: 3.2.1
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "paperless-ngx.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- 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.1+up2.1.3
|
||||
version: 7.0.2+up2.1.3
|
||||
appVersion: "2.1.3"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "patchmon.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "patchmon.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- 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.1+up4.13.1
|
||||
appVersion: "4.13.1"
|
||||
version: 8.1.6+up4.15.2
|
||||
appVersion: "4.15.2"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "rallly.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "rallly.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "rallly.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
+184
-30
@@ -16,15 +16,45 @@
|
||||
// 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: ["dependencies"],
|
||||
labels: ["renovate"],
|
||||
dependencyDashboard: 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,
|
||||
// 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,
|
||||
|
||||
// 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
|
||||
@@ -50,6 +80,14 @@
|
||||
//
|
||||
// Without that entry Renovate skips the task silently and every chart PR
|
||||
// stays red on the version-bump check.
|
||||
//
|
||||
// On the Gitea side (#225) that entry EXISTS and was verified rather than
|
||||
// hoped for: the shared instance's first run on the migrated repo produced
|
||||
// a searxng PR whose diff carries `version: 1.0.4+up... -> 1.0.5+up...`
|
||||
// next to the appVersion. No built-in manager touches `version:`, so that
|
||||
// line can only come from this hook. Nothing has to be configured for the
|
||||
// move - but the allow-list lives on that instance, not in this repo, so it
|
||||
// is also not visible from here if it is ever removed.
|
||||
postUpgradeTasks: {
|
||||
commands: ["ci/renovate-bump-chart.sh {{{packageFileDir}}}"],
|
||||
fileFilters: ["**/Chart.yaml"],
|
||||
@@ -72,23 +110,43 @@
|
||||
},
|
||||
{
|
||||
description: [
|
||||
"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.",
|
||||
"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.",
|
||||
],
|
||||
matchUpdateTypes: ["patch", "minor", "digest"],
|
||||
automerge: true,
|
||||
automergeType: "pr",
|
||||
// Renovate waits for the checks itself instead of handing the PR to
|
||||
// GitHub's auto-merge. GitHub would merge immediately when the branch has
|
||||
// no required status checks configured — and validate-charts is not (yet)
|
||||
// a required check.
|
||||
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,",
|
||||
@@ -110,10 +168,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. 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.",
|
||||
"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.",
|
||||
],
|
||||
matchManagers: ["helmv3"],
|
||||
addLabels: ["subchart"],
|
||||
@@ -201,14 +259,49 @@
|
||||
//
|
||||
// When adding a chart, add a block here — see README "Creating a new chart".
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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 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.
|
||||
// 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",
|
||||
},
|
||||
|
||||
{
|
||||
description: "apache-tika: four-part version plus flavour suffix (3.3.1.0-full); the compatibility group keeps the chart on -full.",
|
||||
@@ -362,8 +455,9 @@
|
||||
// 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, unattended, because date updates land in the patch/minor
|
||||
// automerge rule. With NaN the pair is neither equal nor ordered at all.
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
@@ -391,6 +485,33 @@
|
||||
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",
|
||||
@@ -417,6 +538,39 @@
|
||||
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.0+up4.23.10
|
||||
version: 7.1.1+up4.23.10
|
||||
|
||||
appVersion: 4.23.10
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "samba.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "samba.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -167,8 +169,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- 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: 6.1.0+upv1.9.10
|
||||
version: 7.0.2+upv1.9.10
|
||||
appVersion: "v1.9.10"
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -1,26 +1,29 @@
|
||||
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) -}}
|
||||
{{- $gamePort := int .Values.server.gamePort -}}
|
||||
{{- $msgPort := int .Values.server.messagingPort -}}
|
||||
{{- $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 -}}
|
||||
{{- $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 := .Values.server.storage | default "unset" -}}
|
||||
{{- $res := fromYaml (include "satisfactory.effectiveResources" .Values.server.resources) -}}
|
||||
{{- $storage := $server.storage | default "unset" -}}
|
||||
{{- $res := fromYaml (include "satisfactory.effectiveResources" $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 := (.Values.server.resources | default dict).limitFactor | default 2 -}}
|
||||
{{- $factor := ($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) -}}
|
||||
{{- $budget = printf "%.0fs x %.0f = %.0f minutes" $startup.periodSeconds $startup.failureThreshold (divf (mulf $startup.periodSeconds $startup.failureThreshold) 60.0) -}}
|
||||
{{- end -}}
|
||||
{{- $seasonalRaw := printf "%v" .Values.game.seasonalEvents -}}
|
||||
{{- $seasonalOff := eq (lower $seasonalRaw) "true" -}}
|
||||
{{- $beta := eq (lower (printf "%v" .Values.game.experimental)) "true" -}}
|
||||
{{- $debug := eq (lower (printf "%v" .Values.dev.debug)) "true" -}}
|
||||
{{- $game := fromYaml (include "satisfactory.game" .Values.game) -}}
|
||||
{{- $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" -}}
|
||||
{{- $outOfRange := or (lt $gamePort 30000) (gt $gamePort 32767) (lt $msgPort 30000) (gt $msgPort 32767) -}}
|
||||
{{- $skipRaw := "" -}}
|
||||
{{- range $entry := .Values.env -}}
|
||||
@@ -213,14 +216,37 @@ RESOURCE LIMITS ARE COMPUTED FROM limitFactor ({{ $factor }})
|
||||
unlimited container gets throttled rather than killed — a memory limit can
|
||||
end a session, a missing CPU limit only slows it down.
|
||||
|
||||
game.seasonalEvents IS PASSED THROUGH, NOT INVERTED
|
||||
The chart hands game.seasonalEvents to the container as
|
||||
DISABLESEASONALEVENTS unchanged. It is currently {{ $seasonalRaw }}, so the container
|
||||
runs with DISABLESEASONALEVENTS={{ $seasonalRaw }} — seasonal events are {{ if $seasonalOff }}OFF{{ else }}ON{{ end }}.
|
||||
{{ if $seasonalOff }}To turn them ON, set game.seasonalEvents: false.{{ else }}To turn them OFF, set game.seasonalEvents: true.{{ end }}
|
||||
Read that twice: the value behaves as the opposite of what its name suggests
|
||||
and of what values.schema.json claims. Believe this paragraph and the
|
||||
rendered env var, not the key name.
|
||||
SEASONAL EVENTS — THE KEY WAS RENAMED IN 7.0.0, AND IT IS BREAKING
|
||||
Seasonal (holiday) events are {{ if $seasonalOff }}OFF{{ else }}ON{{ end }} in this release: game.enableSeasonalEvents is
|
||||
{{ $game.enableSeasonalEvents }}, which the chart negates into DISABLESEASONALEVENTS={{ $seasonalEnv }}.
|
||||
{{ if $seasonalOff }}To turn them ON, set game.enableSeasonalEvents: true.{{ else }}To turn them OFF, set game.enableSeasonalEvents: false.{{ end }}
|
||||
|
||||
UPGRADING FROM 6.x — WHAT YOU HAVE TO DO
|
||||
The old key game.seasonalEvents IS GONE. It is not deprecated and not
|
||||
quietly accepted: values.schema.json rejects it, so a values file that still
|
||||
carries it fails the upgrade with a validation error instead of installing
|
||||
something you did not ask for. That noise is deliberate — see below.
|
||||
|
||||
Remove the old key and, if you want the events, add the new one:
|
||||
|
||||
game:
|
||||
enableSeasonalEvents: true # false, the default, keeps them off
|
||||
|
||||
AND THE MEANING IS INVERTED FROM WHAT THE OLD KEY'S NAME SUGGESTED
|
||||
Up to 6.1.0 the value was handed to the container UNCHANGED, into a variable
|
||||
whose name says the opposite: game.seasonalEvents: true wrote
|
||||
DISABLESEASONALEVENTS=true. So the shipped default had the events switched
|
||||
OFF, in every install, for as long as this chart has existed — while
|
||||
values.schema.json described the same key as "true means events are
|
||||
enabled". If you left the default alone believing that description, you have
|
||||
never had seasonal events, and the new default (enableSeasonalEvents: false)
|
||||
changes nothing about that: it renders exactly the DISABLESEASONALEVENTS
|
||||
this chart has always rendered, so upgrading alone does not move your
|
||||
server. You have to ask for the events, and now the key name means what it
|
||||
says when you do.
|
||||
|
||||
Only true and false are accepted, boolean or quoted; anything else aborts
|
||||
the render rather than being guessed at.
|
||||
|
||||
THE USER ID IS PINNED, AND PUID/PGID DO NOTHING
|
||||
The pod runs as 1000:1000 because the image supports rootless operation only
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "satisfactory.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "satisfactory.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "satisfactory.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
@@ -288,6 +291,109 @@ Probe specifics that a rebuilt default must not lose:
|
||||
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "startupProbe") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective game block (issue #99, found alongside #215).
|
||||
|
||||
"--set game=null" — or a leftover "game:" with all sub-keys deleted — used to
|
||||
abort the render with a nil-pointer error at .Values.game.maxPlayers, because
|
||||
this block never got the null-safe treatment the probes, security contexts and
|
||||
resources have. It now falls back to the same defaults values.yaml documents.
|
||||
|
||||
The defaults below MUST stay in sync with the game block in values.yaml.
|
||||
Note that enableSeasonalEvents defaults to FALSE: that is what keeps
|
||||
DISABLESEASONALEVENTS at "true", the value this chart has always rendered.
|
||||
*/}}
|
||||
{{- define "satisfactory.game" -}}
|
||||
{{- $defaults := dict
|
||||
"experimental" false
|
||||
"maxPlayers" 4
|
||||
"enableSeasonalEvents" false -}}
|
||||
{{- 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).
|
||||
|
||||
The image only offers the negative switch, so the chart negates the positive
|
||||
value here — that is the whole point of the rename: the key says what it does
|
||||
and the env var keeps the name the image expects.
|
||||
|
||||
Go's "not" cannot do this: it treats every non-empty string as true, so the
|
||||
string form "false" (which fleet bundles habitually write for booleans, and
|
||||
which the schema therefore accepts) would flip the wrong way. Hence the
|
||||
explicit comparison — and an explicit fail for anything that is neither, so a
|
||||
typo like "yes" cannot be silently read as "events off". Null never reaches
|
||||
here; satisfactory.game has already replaced it with the default.
|
||||
*/}}
|
||||
{{- define "satisfactory.disableSeasonalEvents" -}}
|
||||
{{- $raw := printf "%v" . -}}
|
||||
{{- if eq (lower $raw) "true" -}}
|
||||
false
|
||||
{{- else if eq (lower $raw) "false" -}}
|
||||
true
|
||||
{{- else -}}
|
||||
{{- fail (printf "game.enableSeasonalEvents must be true or false (boolean or string), got %q" $raw) -}}
|
||||
{{- end -}}
|
||||
{{- 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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -7,5 +8,5 @@ spec:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.server.storage }}
|
||||
storage: {{ $server.storage }}
|
||||
storageClassName: longhorn
|
||||
@@ -1,3 +1,6 @@
|
||||
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||
{{- $gamePort := include "satisfactory.formatNumber" $server.gamePort -}}
|
||||
{{- $msgPort := include "satisfactory.formatNumber" $server.messagingPort -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
@@ -8,19 +11,19 @@ spec:
|
||||
type: NodePort
|
||||
ports:
|
||||
- name: "api"
|
||||
port: {{ .Values.server.gamePort }}
|
||||
port: {{ $gamePort }}
|
||||
protocol: TCP
|
||||
nodePort: {{ .Values.server.gamePort }}
|
||||
targetPort: {{ .Values.server.gamePort }}
|
||||
nodePort: {{ $gamePort }}
|
||||
targetPort: {{ $gamePort }}
|
||||
- name: "game"
|
||||
port: {{ .Values.server.gamePort }}
|
||||
port: {{ $gamePort }}
|
||||
protocol: UDP
|
||||
nodePort: {{ .Values.server.gamePort }}
|
||||
targetPort: {{ .Values.server.gamePort }}
|
||||
nodePort: {{ $gamePort }}
|
||||
targetPort: {{ $gamePort }}
|
||||
- name: "messaging"
|
||||
port: {{ .Values.server.messagingPort }}
|
||||
port: {{ $msgPort }}
|
||||
protocol: TCP
|
||||
nodePort: {{ .Values.server.messagingPort }}
|
||||
targetPort: {{ .Values.server.messagingPort }}
|
||||
nodePort: {{ $msgPort }}
|
||||
targetPort: {{ $msgPort }}
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
@@ -1,3 +1,6 @@
|
||||
{{- $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:
|
||||
@@ -9,7 +12,7 @@ spec:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) }}
|
||||
replicas: {{ include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" (include "satisfactory.formatNumber" $server.replicas)) }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
@@ -23,9 +26,9 @@ spec:
|
||||
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
|
||||
env:
|
||||
- name: DEBUG
|
||||
value: "{{ .Values.dev.debug }}"
|
||||
value: "{{ $dev.debug }}"
|
||||
- name: MAXPLAYERS
|
||||
value: "{{ .Values.game.maxPlayers }}"
|
||||
value: "{{ include "satisfactory.formatNumber" $game.maxPlayers }}"
|
||||
# PUID/PGID are only used by the image's root entrypoint path;
|
||||
# with the default non-root security context they are ignored.
|
||||
- name: PGID
|
||||
@@ -35,13 +38,15 @@ spec:
|
||||
- name: SKIPUPDATE
|
||||
value: "false"
|
||||
- name: STEAMBETA
|
||||
value: "{{ .Values.game.experimental }}"
|
||||
value: "{{ $game.experimental }}"
|
||||
# The image only knows the negative switch, so game.enableSeasonalEvents
|
||||
# is negated here (#215): true -> "false" (events run).
|
||||
- name: DISABLESEASONALEVENTS
|
||||
value: "{{ .Values.game.seasonalEvents }}"
|
||||
value: "{{ include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents }}"
|
||||
- name: SERVERGAMEPORT
|
||||
value: "{{ .Values.server.gamePort }}"
|
||||
value: "{{ include "satisfactory.formatNumber" $server.gamePort }}"
|
||||
- name: SERVERMESSAGINGPORT
|
||||
value: "{{ .Values.server.messagingPort }}"
|
||||
value: "{{ include "satisfactory.formatNumber" $server.messagingPort }}"
|
||||
{{- range $value := .Values.env }}
|
||||
{{- if $value.value }}
|
||||
- name: {{ $value.name }}
|
||||
@@ -64,13 +69,13 @@ spec:
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: "api"
|
||||
containerPort: {{ .Values.server.gamePort }}
|
||||
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||
protocol: TCP
|
||||
- name: "game"
|
||||
containerPort: {{ .Values.server.gamePort }}
|
||||
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||
protocol: UDP
|
||||
- name: "messaging"
|
||||
containerPort: {{ .Values.server.messagingPort }}
|
||||
containerPort: {{ include "satisfactory.formatNumber" $server.messagingPort }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
|
||||
@@ -79,7 +84,7 @@ spec:
|
||||
startupProbe:
|
||||
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "satisfactory.effectiveResources" .Values.server.resources | nindent 12 }}
|
||||
{{- include "satisfactory.effectiveResources" $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). 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); 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.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -13,17 +13,19 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"dev": {
|
||||
"type": "object",
|
||||
"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"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"debug": {
|
||||
"description": "Passed to the image as DEBUG.",
|
||||
"description": "Passed to the image as DEBUG. Null means the chart default (false).",
|
||||
"type": ["boolean", "string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"game": {
|
||||
"type": "object",
|
||||
"description": "May be null / empty: the chart then falls back to its documented game defaults (issue #99) instead of aborting the render with a nil-pointer error, which is what an erased block used to do.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"experimental": {
|
||||
@@ -33,32 +35,36 @@
|
||||
"maxPlayers": {
|
||||
"type": ["integer", "string", "null"]
|
||||
},
|
||||
"seasonalEvents": {
|
||||
"description": "Inverted by the chart into DISABLESEASONALEVENTS, so true means events are enabled.",
|
||||
"enableSeasonalEvents": {
|
||||
"description": "Seasonal (holiday) events. NEGATED by the chart into the image's DISABLESEASONALEVENTS: true renders DISABLESEASONALEVENTS=\"false\" and the events run, false renders \"true\" and they do not. Only true/false are accepted (boolean or string); anything else aborts the render. Default false, which reproduces what the chart has always rendered - up to 6.1.0 the key was game.seasonalEvents and was passed through unchanged, so its default true wrote DISABLESEASONALEVENTS=true and the events were off despite this description claiming the opposite (issue #215). The old key game.seasonalEvents no longer exists and is rejected here rather than silently ignored.",
|
||||
"type": ["boolean", "string", "null"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"type": "object",
|
||||
"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"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"replicas": {
|
||||
"type": "integer",
|
||||
"description": "Null means the chart default (1). An explicit 0 is kept and scales the StatefulSet down.",
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 0
|
||||
},
|
||||
"gamePort": {
|
||||
"type": "integer",
|
||||
"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"],
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"messagingPort": {
|
||||
"type": "integer",
|
||||
"description": "Null means the chart default (30888). Same erased-port trap as gamePort above.",
|
||||
"type": ["integer", "null"],
|
||||
"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.",
|
||||
"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.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"resources": {
|
||||
|
||||
@@ -4,20 +4,50 @@
|
||||
# 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
|
||||
|
||||
game:
|
||||
experimental: false
|
||||
maxPlayers: 4
|
||||
seasonalEvents: true
|
||||
# Seasonal (holiday) events. The image only knows the negative switch
|
||||
# DISABLESEASONALEVENTS, so the chart NEGATES this value on the way in:
|
||||
# enableSeasonalEvents: true renders DISABLESEASONALEVENTS="false" (events
|
||||
# run), false renders "true" (events off). Only true/false are accepted, in
|
||||
# boolean or string form; anything else aborts the render instead of being
|
||||
# read as one of the two.
|
||||
#
|
||||
# The default is false on purpose, and it is not a preference: it reproduces
|
||||
# what this chart has ALWAYS rendered. Up to 6.1.0 the key was called
|
||||
# game.seasonalEvents and was passed through UNCHANGED, so its default true
|
||||
# wrote DISABLESEASONALEVENTS=true — the events were off in every install,
|
||||
# while values.schema.json claimed "true means events are enabled" (#215).
|
||||
# Keeping the rendered result identical is what makes this rename loud
|
||||
# instead of silent: nobody's server changes behaviour by upgrading, and the
|
||||
# old key is now rejected by the schema, so anyone who set it has to decide
|
||||
# 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.2+up2026.8.29.d226b78bc
|
||||
appVersion: "2026.8.29-d226b78bc"
|
||||
version: 1.0.6+up2026.9.13.3b8009065
|
||||
appVersion: "2026.9.13-3b8009065"
|
||||
|
||||
maintainers:
|
||||
- name: jklein
|
||||
@@ -19,9 +19,10 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "searxng.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "searxng.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,9 +169,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 charts
|
||||
in this repo deliberately have no dependencies. template-chart holds the
|
||||
reference implementation.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "searxng.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -6,6 +6,9 @@ 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, because charts in this repo have no dependencies.
|
||||
# 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.
|
||||
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 -
|
||||
# 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.
|
||||
# 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.
|
||||
valkeySecretRef: null
|
||||
#
|
||||
# Documented limitation (README, guideline 9): the CONTENT of these items is
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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/
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
@@ -0,0 +1,165 @@
|
||||
{{- $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 }}
|
||||
@@ -0,0 +1,451 @@
|
||||
{{/*
|
||||
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 -}}
|
||||
@@ -0,0 +1,6 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,6 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.storage.amount }}
|
||||
{{- if .Values.storage.storageClass }}
|
||||
storageClassName: {{ .Values.storage.storageClass }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for the app secret must be provided (secrets.appSecretRef)" .Values.secrets.appSecretRef }}
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: http
|
||||
name: http
|
||||
type: ClusterIP
|
||||
@@ -0,0 +1,231 @@
|
||||
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: {}
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"$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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
# 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: {}
|
||||
@@ -5,5 +5,5 @@ icon: https://avatars.githubusercontent.com/u/136759148
|
||||
|
||||
type: application
|
||||
|
||||
version: 5.1.0+up6.0.0.beta12.1
|
||||
appVersion: "6.0.0-beta12.1"
|
||||
version: 5.1.3+up6.0.0.beta13.1
|
||||
appVersion: "6.0.0-beta13.1"
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "teamspeak-server.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "teamspeak-server.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "teamspeak-server.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "xxx.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "xxx.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,9 +170,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 charts
|
||||
in this repo deliberately have no dependencies. This file is the reference
|
||||
implementation.
|
||||
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). This file is the reference implementation.
|
||||
*/}}
|
||||
{{- define "xxx.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -4,5 +4,5 @@ description: A Helm chart to deploy urlaubsverwaltung on Kubernetes
|
||||
icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png
|
||||
type: application
|
||||
|
||||
version: 8.1.0+up6.7.0
|
||||
appVersion: 6.7.0
|
||||
version: 10.0.1+up6.13.1
|
||||
appVersion: 6.13.1
|
||||
@@ -199,33 +199,89 @@ THE MEMORY LIMIT SIZES THE JAVA HEAP — IT IS NOT JUST A CEILING
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} logs deployment/{{ .Release.Name }}-{{ .Chart.Name }}-deployment | head -1
|
||||
|
||||
DO NOT HEALTH-CHECK THIS RELEASE ON /actuator/health
|
||||
That endpoint aggregates every health indicator, and one of them is the mail
|
||||
sender pointed at localhost:1025 by this chart (see below). Nothing listens
|
||||
there, so the aggregate answers 503 DOWN on a completely healthy release,
|
||||
for the lifetime of that release:
|
||||
THE THREE HEALTH PATHS AND WHAT EACH ONE ANSWERS FOR
|
||||
This chart sets two health settings in the deployment template, and they are
|
||||
what makes the three paths mean different things:
|
||||
|
||||
{"components":{ ... "mail":{"status":"DOWN"} ... },"status":"DOWN"}
|
||||
MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db
|
||||
MANAGEMENT_HEALTH_MAIL_ENABLED=false
|
||||
|
||||
Everything else in it — db, ping, diskSpace, ssl — is UP. The probes this
|
||||
chart configures deliberately use the /actuator/health/liveness and
|
||||
/actuator/health/readiness GROUPS instead, which contain only the
|
||||
application's lifecycle state and answer 200. Any external monitor you point
|
||||
at this release has to use the same two paths, or it will page you forever
|
||||
about a mail server you never configured.
|
||||
/actuator/health everything the release actually runs: db,
|
||||
diskSpace, ping, ssl and the two lifecycle
|
||||
states. 200 UP on a healthy release. The mail
|
||||
indicator is NOT in it — see the mail section.
|
||||
→ point external monitoring here. This is the only path that also fails on
|
||||
diskSpace or ssl.
|
||||
|
||||
WHAT A READY POD DOES NOT PROVE
|
||||
Those two groups report lifecycle state and nothing else — the datasource is
|
||||
not in them. Measured here: with the database scaled away underneath a
|
||||
running pod, both groups kept answering 200, the pod stayed 1/1 Ready and was
|
||||
never restarted. The database is verified exactly once, at boot, by the
|
||||
migration.
|
||||
/actuator/health/readiness readinessState AND db. Goes DOWN (503) on a
|
||||
database outage, which takes the pod out of Ready
|
||||
and stops traffic reaching it.
|
||||
→ the readinessProbe.
|
||||
|
||||
Know the resulting picture in advance: during a database outage the pod stays
|
||||
Ready, its endpoint keeps receiving traffic{{ if .Values.ingress.enabled }}, the Ingress keeps routing to
|
||||
it{{ end }}, and users get application errors while Kubernetes reports everything
|
||||
healthy. Nothing in the pod's status or restart count points at the database.
|
||||
Look there first anyway.
|
||||
/actuator/health/liveness livenessState alone, deliberately WITHOUT db.
|
||||
Stays 200 through a database outage.
|
||||
→ the livenessProbe and the startupProbe.
|
||||
|
||||
The split is the point: a database outage has to keep traffic away, but
|
||||
restarting the pod does not bring the database back. Adding db to the
|
||||
liveness group would turn every outage into a crash loop on top of the
|
||||
outage. Measured on this chart, with the database scaled to zero underneath a
|
||||
running pod: readiness 503, liveness 200, pod 0/1 Ready, restart count 0 —
|
||||
the pod left Ready about 20 seconds after the database went away.
|
||||
|
||||
The other half of that measurement matters just as much: when the database
|
||||
came back, the pod returned to Ready by itself within about five seconds,
|
||||
still with restart count 0. Nothing had to be rolled, deleted or scaled by
|
||||
hand. That is what leaving the liveness group alone buys — the outage is
|
||||
ridden out and then simply over.
|
||||
|
||||
How readiness actually fails is worth knowing before you tune it. The db
|
||||
indicator does not answer 503 quickly — it waits out the JDBC connection
|
||||
timeout first, measured at 30 seconds against a database whose Service has no
|
||||
endpoints at all. readinessProbe.timeoutSeconds is 5, so the kubelet gives up
|
||||
long before the answer arrives and logs
|
||||
|
||||
Readiness probe failed: ... context deadline exceeded
|
||||
|
||||
rather than a 503. The pod leaves Ready either way, and faster; the 503 is
|
||||
what a client with patience sees. Raising timeoutSeconds past the JDBC
|
||||
timeout would swap the timeout for the 503 and delay the reaction — there is
|
||||
no reason to.
|
||||
|
||||
WHAT A READY POD DOES AND DOES NOT PROVE
|
||||
Ready now means the pod can reach the database — that is checked on every
|
||||
readiness period, not once at boot. It still does not mean the identity
|
||||
provider is reachable: OIDC is resolved once at startup (see below) and no
|
||||
health indicator watches it afterwards, so a provider that goes down after
|
||||
the rollout leaves a Ready pod on which nobody can log in.
|
||||
|
||||
Ready also proves reachability, not correctness: the db indicator validates a
|
||||
connection, it never looks at the schema. Measured here — a database that came
|
||||
back EMPTY put the pod straight back to Ready with /actuator/health at 200,
|
||||
while not one table existed in it. A restored-but-wrong database reads as
|
||||
healthy, so a probe going green after a database incident is not on its own
|
||||
evidence that the data came back with it.
|
||||
|
||||
A database outage now shows up in the pod's status: READY drops to 0/1 while
|
||||
STATUS stays Running and RESTARTS stays put. That combination — running, not
|
||||
restarting, not Ready — is the signature of a dependency outage rather than
|
||||
of a broken release. The reason is in the endpoint, not in the log:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||
curl -s localhost:8080/actuator/health
|
||||
|
||||
The body names components only when show-details is on; the chart does not
|
||||
set it, so the aggregate answers {"status":"DOWN","groups":[...]} and the
|
||||
status code carries the message. To see WHICH component is down, add
|
||||
|
||||
urlaubsverwaltung.env:
|
||||
- name: MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS
|
||||
value: always
|
||||
|
||||
and know what that publishes to anyone who can reach the endpoint: the
|
||||
database product, the container's filesystem path and free bytes, the
|
||||
certificate chains, and the stack-trace class of whatever failed.
|
||||
|
||||
Note also that there is no shell in this image: "kubectl exec" cannot open
|
||||
one, and neither can a debug sidecar sharing the process namespace run the
|
||||
@@ -241,25 +297,61 @@ MAIL AND THE APPLICATION URL ARE PLACEHOLDERS UNTIL YOU SET THEM
|
||||
UV_MAIL_APPLICATIONURL = http://localhost:8080
|
||||
|
||||
Nothing in this pod listens on localhost:1025, so no notification mail leaves
|
||||
the release and the mail health indicator stays DOWN. UV_MAIL_APPLICATIONURL
|
||||
is what the app puts into the links inside those mails — it is NOT derived
|
||||
from ingress.domain. Left as it is, every link points at the reader's own
|
||||
machine.
|
||||
the release. UV_MAIL_APPLICATIONURL is what the app puts into the links
|
||||
inside those mails — it is NOT derived from ingress.domain. Left as it is,
|
||||
every link points at the reader's own machine.
|
||||
|
||||
urlaubsverwaltung.env IS ADDITIVE ONLY, AND A COLLISION BREAKS THE UPGRADE
|
||||
BECAUSE that wiring is a placeholder, the chart sets
|
||||
|
||||
MANAGEMENT_HEALTH_MAIL_ENABLED=false
|
||||
|
||||
and the mail indicator is gone from /actuator/health. Otherwise the aggregate
|
||||
would answer 503 DOWN on a perfectly healthy release for its whole lifetime,
|
||||
and any monitor pointed at it would page forever about a mail server nobody
|
||||
configured.
|
||||
|
||||
THE PART THAT BITES LATER: the day you configure real mail delivery, switch
|
||||
the indicator back on in the SAME change —
|
||||
|
||||
urlaubsverwaltung.env:
|
||||
- name: MANAGEMENT_HEALTH_MAIL_ENABLED
|
||||
value: "true"
|
||||
|
||||
Left at false it does not report a false alarm, it reports nothing: the
|
||||
endpoint answers 200 while the mail server is unreachable and notifications
|
||||
are silently not delivered. The setting is only honest while there is no mail
|
||||
server to be honest about.
|
||||
|
||||
Note that this variable repeats a name the chart already sets, so per the rule
|
||||
below it only takes effect when it goes in at INSTALL time. Put into an
|
||||
already installed release it does nothing at all, and says so nowhere: helm
|
||||
reports a successful upgrade while the container keeps "false".
|
||||
|
||||
urlaubsverwaltung.env IS ADDITIVE ONLY, AND A COLLISION IS LOST ON UPGRADE
|
||||
The entries are appended AFTER the chart's own list{{ if $envNames }} (this release appends:
|
||||
{{ join ", " $envNames }}){{ end }}. An entry that repeats a name the
|
||||
chart already sets does not replace it — it renders the variable twice, and
|
||||
that has a sharp edge: adding such a name to an ALREADY INSTALLED release
|
||||
makes the next upgrade fail in the API server, before anything changes, with
|
||||
what happens to that pair depends on WHEN it first appears:
|
||||
|
||||
UPGRADE FAILED: cannot patch "{{ .Release.Name }}-{{ .Chart.Name }}-deployment" ...
|
||||
The order in patch list ... doesn't match $setElementOrder list
|
||||
AT INSTALL both entries reach the pod and the later one — yours — wins in the
|
||||
container. The API server accepts it with a warning:
|
||||
|
||||
because env entries are merged by name and a duplicate name breaks that
|
||||
ordering. A fresh install with the same values goes through, so the failure
|
||||
looks arbitrary until you know it. Treat the variables above as fixed for the
|
||||
life of a release: to really change one, reinstall.
|
||||
Warning: spec.template.spec.containers[0].env[N]: hides previous definition
|
||||
of "NAME", which may be dropped when using apply
|
||||
|
||||
AT UPGRADE that warning comes true. Kubernetes merges env by name, so the pair
|
||||
collapses back to ONE entry and the surviving value is the CHART's, not yours.
|
||||
Nothing fails: helm prints "Release has been upgraded", the revision counts up,
|
||||
and the override is simply gone. Measured on this chart (helm 3.19.0,
|
||||
Kubernetes 1.35): "helm get manifest" shows the variable twice while the live
|
||||
Deployment shows it once, with the chart's value.
|
||||
|
||||
So the trap is silence, not an error. Treat the variables above as fixed for
|
||||
the life of a release: to really change one, reinstall. And never read a green
|
||||
upgrade as proof that an override took effect — read it off the object:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get deployment {{ .Release.Name }}-{{ .Chart.Name }}-deployment \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].env}'
|
||||
|
||||
Anything sensitive belongs in valueFrom.secretKeyRef rather than a plain
|
||||
value: a value is stored in the pod spec and readable by anyone who can read
|
||||
|
||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
||||
resources:
|
||||
{{- include "urlaubsverwaltung.resources" .Values.<app>.resources | nindent 12 }}
|
||||
|
||||
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.
|
||||
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.
|
||||
*/}}
|
||||
{{- define "urlaubsverwaltung.resources" -}}
|
||||
{{- $resources := . | default dict -}}
|
||||
@@ -168,8 +170,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 charts
|
||||
in this repo deliberately have no dependencies.
|
||||
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).
|
||||
*/}}
|
||||
{{- define "urlaubsverwaltung.defaultedDict" -}}
|
||||
{{- $defaults := .defaults | default dict -}}
|
||||
|
||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
spec:
|
||||
itemPath: {{ .Values.secrets.databaseSecretRef }}
|
||||
itemPath: {{ required "A database Secret Reference must be provided (secrets.databaseSecretRef)" .Values.secrets.databaseSecretRef }}
|
||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-oidc-secret
|
||||
spec:
|
||||
itemPath: {{ .Values.secrets.oidcSecretRef }}
|
||||
itemPath: {{ required "An OIDC Secret Reference must be provided (secrets.oidcSecretRef)" .Values.secrets.oidcSecretRef }}
|
||||
@@ -50,6 +50,32 @@ spec:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
key: password
|
||||
# Health indicators and probe groups
|
||||
#
|
||||
# Spring Boot creates the "liveness" and "readiness" groups by
|
||||
# itself here (measured: /actuator/health answers
|
||||
# {"groups":["liveness","readiness"]} with nothing configured), but
|
||||
# the readiness group ships with readinessState ALONE. Without the
|
||||
# line below a total database outage leaves /actuator/health/readiness
|
||||
# at 200 and the pod 1/1 Ready while every request fails (#218).
|
||||
# Naming the group's members REPLACES the default, so readinessState
|
||||
# has to be repeated. "db" is the datasource indicator's real name,
|
||||
# read off the running endpoint.
|
||||
- name: MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE
|
||||
value: "readinessState,db"
|
||||
# The liveness group is deliberately NOT extended: a database
|
||||
# outage has to keep traffic away, but restarting the pod does not
|
||||
# bring the database back — it would only add a crash loop to the
|
||||
# outage.
|
||||
#
|
||||
# The mail indicator is switched off because the mail wiring below
|
||||
# points at localhost:1025, where nothing listens. Left on, it holds
|
||||
# the aggregate /actuator/health at 503 DOWN for the whole life of a
|
||||
# healthy release (#217). Anyone who later configures real mail
|
||||
# delivery has to switch it back on via urlaubsverwaltung.env, or
|
||||
# the endpoint stays blind to a broken mail server.
|
||||
- name: MANAGEMENT_HEALTH_MAIL_ENABLED
|
||||
value: "false"
|
||||
# Mail
|
||||
- name: UV_MAIL_FROM
|
||||
value: urlaubsverwaltung@example.org
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"databaseSecretRef": {
|
||||
"description": "op:// item path carrying \"jdbc-url\", \"username\" and \"password\". Required - the chart deploys no database and all state lives in the external one. 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"]
|
||||
},
|
||||
"oidcSecretRef": {
|
||||
"description": "op:// item path carrying \"client-id\", \"client-secret\", \"issuer-name\", \"issuer-uri\", \"jwt-issuer-uri\" and \"redirect-uri\". Required - the chart offers no non-OIDC login and the app resolves the issuer at boot. 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,7 +5,21 @@
|
||||
scaleDown: false
|
||||
|
||||
secrets:
|
||||
# REQUIRED - op:// item path carrying "jdbc-url", "username" and "password".
|
||||
# This chart deploys NO database; all state lives in the external one, and
|
||||
# three datasource environment variables read these keys unconditionally.
|
||||
# Without the value the render aborts - required() in
|
||||
# urlaubsverwaltung-db.secret.yaml is the whole enforcement, and
|
||||
# values.schema.json cannot help here (README, guideline 12).
|
||||
databaseSecretRef: null
|
||||
# REQUIRED - op:// item path carrying "client-id", "client-secret",
|
||||
# "issuer-name", "issuer-uri", "jwt-issuer-uri" and "redirect-uri". This chart
|
||||
# offers no login path besides OIDC, and the identity provider is required at
|
||||
# boot, not at login: the application resolves <issuer-uri>/.well-known/
|
||||
# openid-configuration while building its security beans, before the HTTP port
|
||||
# opens, and exits when it cannot. Without the value the render aborts -
|
||||
# required() in urlaubsverwaltung-oidc.secret.yaml is the whole enforcement,
|
||||
# and values.schema.json cannot help here (README, guideline 12).
|
||||
oidcSecretRef: null
|
||||
|
||||
ingress:
|
||||
@@ -45,6 +59,42 @@ urlaubsverwaltung:
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
# HEALTH INDICATORS — TWO SETTINGS THE CHART FIXES IN THE TEMPLATE
|
||||
#
|
||||
# The chart sets these two as plain environment variables, not as values,
|
||||
# because urlaubsverwaltung.env below already overrides anything:
|
||||
#
|
||||
# MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db
|
||||
# puts the datasource into the readiness group, so a database outage
|
||||
# takes the pod out of Ready instead of leaving it serving errors (#218).
|
||||
# The liveness group stays untouched on purpose — an outage must keep
|
||||
# traffic away, not restart the pod in a loop.
|
||||
# MANAGEMENT_HEALTH_MAIL_ENABLED=false
|
||||
# removes the mail indicator, which otherwise pins the aggregate
|
||||
# /actuator/health at 503 DOWN forever, because the chart's mail wiring
|
||||
# points at localhost:1025 where nothing listens (#217).
|
||||
#
|
||||
# THE CONSEQUENCE TO KNOW BEFORE IT BITES: whoever later configures real mail
|
||||
# delivery (SPRING_MAIL_HOST/SPRING_MAIL_PORT via urlaubsverwaltung.env) MUST
|
||||
# switch the indicator back on in the same change:
|
||||
#
|
||||
# env:
|
||||
# - name: MANAGEMENT_HEALTH_MAIL_ENABLED
|
||||
# value: "true"
|
||||
#
|
||||
# Left at false, /actuator/health keeps answering 200 while the configured
|
||||
# mail server is unreachable and no notification mail is delivered. The
|
||||
# setting trades a permanently false alarm for silence, and silence is only
|
||||
# correct as long as there is nothing to report about.
|
||||
#
|
||||
# That override repeats a name the chart already sets, and env is additive, so
|
||||
# it renders the variable twice. At INSTALL time both entries reach the pod and
|
||||
# the later one — yours — wins in the container. Added to an ALREADY INSTALLED
|
||||
# release it is lost instead: Kubernetes merges env by name, the pair collapses
|
||||
# to the chart's own entry, and helm still reports a successful upgrade. The
|
||||
# override does not fail loudly, it fails silently. See the "env IS ADDITIVE
|
||||
# ONLY" section of NOTES.txt — the switch has to go in at install time, and
|
||||
# after any such change the result belongs verified on the live Deployment.
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
port: 8080
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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/
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user