Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff54eecf7
|
||
|
|
5be10251f4
|
||
|
|
61e28a1459
|
||
|
|
56bed94064
|
||
|
|
5c513291a8
|
||
|
|
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
|
||
|
|
757e4bea6f | ||
|
|
01f804a43a
|
||
|
|
487eb11b44 | ||
|
|
d18d5710b5 | ||
|
|
bfa72181eb
|
||
|
|
a1e10a3928 | ||
|
|
a86eda2bbf | ||
|
|
6b0d8c9232
|
||
|
|
3e1088410e | ||
|
|
df5e15b2b6 | ||
|
|
56b9ce032e | ||
|
|
ed1e59b691 | ||
|
|
4bc508db61 | ||
|
|
3407890335 | ||
|
|
746e44b365 | ||
|
|
972a572460 | ||
|
|
fd681dc12f | ||
|
|
ed4456618a | ||
|
|
4314660057 | ||
|
|
107397c3c2 |
No files matched your search
@@ -37,7 +37,11 @@ on:
|
|||||||
- rss-bridge
|
- rss-bridge
|
||||||
- searxng
|
- searxng
|
||||||
- bentopdf
|
- bentopdf
|
||||||
# freesailarr is deliberately absent: none of its nine images takes
|
- ytdlp-web-player
|
||||||
|
- sure
|
||||||
|
- valkey
|
||||||
|
- convertx
|
||||||
|
# freesailarr is deliberately absent: none of its ten images takes
|
||||||
# its tag from appVersion (they are all pinned in its values.yaml),
|
# its tag from appVersion (they are all pinned in its values.yaml),
|
||||||
# so this workflow would rewrite appVersion and republish the chart
|
# so this workflow would rewrite appVersion and republish the chart
|
||||||
# without changing a single deployed image. Update its images through
|
# 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 ConvertX Helm Chart
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- convertx/**
|
||||||
|
jobs:
|
||||||
|
publish-convertx:
|
||||||
|
name: Publish ConvertX Helm Chart
|
||||||
|
uses: ./.github/workflows/publish-helm-chart.yml
|
||||||
|
with:
|
||||||
|
chart-name: 'convertx'
|
||||||
|
secrets: inherit
|
||||||
@@ -43,6 +43,17 @@ jobs:
|
|||||||
uses: 1password/load-secrets-action/configure@v5
|
uses: 1password/load-secrets-action/configure@v5
|
||||||
with:
|
with:
|
||||||
connect-host: https://op-connect.jk-dev.de
|
connect-host: https://op-connect.jk-dev.de
|
||||||
|
# helm package exits 1 with "found in Chart.yaml, but missing in charts/
|
||||||
|
# directory" while a declared dependency has not been fetched, so this
|
||||||
|
# comes first. It is a no-op for every chart without a dependencies:
|
||||||
|
# block; ci/helm-dependencies.sh explains why it resolves from Chart.yaml
|
||||||
|
# instead of a committed Chart.lock. Note the consequence for the
|
||||||
|
# published artifact: the subchart .tgz is packaged INTO ours, so the
|
||||||
|
# version pinned in Chart.yaml is the version operators get, and nothing
|
||||||
|
# downstream ever sees the dependency again.
|
||||||
|
- name: "Fetch chart dependencies"
|
||||||
|
run: ${{ github.workspace }}/ci/helm-dependencies.sh "${{ inputs.chart-name }}"
|
||||||
|
working-directory: ${{ inputs.chart-location }}
|
||||||
- name: "Package Helm Chart"
|
- name: "Package Helm Chart"
|
||||||
run: helm package ${{ inputs.chart-name }}
|
run: helm package ${{ inputs.chart-name }}
|
||||||
working-directory: ${{ inputs.chart-location }}
|
working-directory: ${{ inputs.chart-location }}
|
||||||
@@ -53,52 +64,86 @@ jobs:
|
|||||||
echo $CHART_VERSION
|
echo $CHART_VERSION
|
||||||
echo "CHART_VERSION=$CHART_VERSION" >> $GITHUB_OUTPUT
|
echo "CHART_VERSION=$CHART_VERSION" >> $GITHUB_OUTPUT
|
||||||
working-directory: ${{ inputs.chart-location }}
|
working-directory: ${{ inputs.chart-location }}
|
||||||
- name: "Load Helm Registry Credentials"
|
- name: "Load Chart Registry Credentials"
|
||||||
id: load-jk-cr-creds
|
id: load-registry-creds
|
||||||
uses: 1password/load-secrets-action@v5
|
uses: 1password/load-secrets-action@v5
|
||||||
env:
|
env:
|
||||||
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
|
||||||
JK_CR_TOKEN: op://GitHub-Actions/jk-registry-access/password
|
REGISTRY_PASSWORD: op://GitHub-Actions/jlab-registry-access/password
|
||||||
JK_CR_USERNAME: op://GitHub-Actions/jk-registry-access/username
|
REGISTRY_USERNAME: op://GitHub-Actions/jlab-registry-access/username
|
||||||
JK_CR_CHARTS_LOCATION: op://GitHub-Actions/jk-registry-access/charts-location
|
REGISTRY_CHARTS_URL: op://GitHub-Actions/jlab-registry-access/charts-url
|
||||||
JK_CR_HELM_REGISTRY: op://GitHub-Actions/jk-registry-access/helm-registry
|
# The target is a classic, index-based Helm registry, not an OCI one, so
|
||||||
# The values are mapped into each step's own env block and read as quoted
|
# there is no "helm registry login" session and no "helm push": the upload
|
||||||
# shell variables - never interpolated into the command line.
|
# is a single HTTP POST of the packaged .tgz to <charts-url>/api/charts,
|
||||||
|
# authenticated per request. Three properties of this step are load-bearing
|
||||||
|
# and each of them was paid for once already.
|
||||||
#
|
#
|
||||||
# Two separate failures made this necessary. First, load-secrets-action v5
|
# 1) The values are mapped into the step's own env block and read as quoted
|
||||||
# (bumped in #140) no longer exports the loaded values as environment
|
# shell variables - never interpolated into the command line. load-secrets-
|
||||||
# variables for later steps the way v4 did; only the step outputs carry
|
# action v5 (bumped in #140) no longer exports the loaded values as
|
||||||
# them. Every "${{ env.JK_CR_* }}" reference therefore resolved to an empty
|
# environment variables for later steps the way v4 did; only the step
|
||||||
# string from that bump on, with nothing reporting an error - the loading
|
# outputs carry them. Every "${{ env.* }}" reference therefore resolved to
|
||||||
# step logs "Populating variable" and stays green either way.
|
# an empty string from that bump on, with nothing reporting an error - the
|
||||||
|
# loading step logs "Populating variable" and stays green either way. The
|
||||||
|
# ":?" guards fail loudly and name the op:// field that is missing, and
|
||||||
|
# quoting keeps an empty value from silently shifting arguments.
|
||||||
#
|
#
|
||||||
# Second, an empty value collapsed into a missing argument instead of an
|
# 2) The credentials never reach the process list. "curl --user $U:$P"
|
||||||
# error: with an empty username, "-u" swallowed the following
|
# would put them in argv; "curl --config -" reads its options from stdin
|
||||||
# "--password-stdin", helm stopped reading the password from stdin,
|
# instead, so nothing sensitive appears in any argument. The price of that
|
||||||
# prompted interactively and died on the runner with "inappropriate ioctl
|
# config file is its value syntax: an unquoted value ends at the first
|
||||||
# for device" - a terminal error for what was really an unset variable.
|
# space (measured on curl 8.5.0 - "has space" arrived as "has"), so the
|
||||||
|
# step refuses whitespace in the credentials up front rather than letting
|
||||||
|
# a truncated password come back as a puzzling 401. A Gitea access token
|
||||||
|
# is 40 hex characters, so this only ever fires on a mis-entered item.
|
||||||
#
|
#
|
||||||
# The ":?" guards fail loudly and name the value that is missing, quoting
|
# 3) "--fail-with-body" is what makes a failed upload fail the job. Unlike
|
||||||
# keeps an empty one from shifting arguments, and the token no longer
|
# "helm push", curl exits 0 on HTTP 401/403/409 unless asked not to - the
|
||||||
# appears in the process list.
|
# publish would go green while nothing was uploaded. Measured against Gitea
|
||||||
- name: "Login to JK-Registry"
|
# 1.24.7: a 401 without the flag is exit 0, with the flag exit 22, and the
|
||||||
|
# response body is still printed so the log names the cause.
|
||||||
|
#
|
||||||
|
# The file name is built from the chart name and the version read above
|
||||||
|
# instead of a "<chart>-*.tgz" glob: --upload-file takes exactly one file,
|
||||||
|
# and a glob matching two turns the second into curl's URL argument, while
|
||||||
|
# a glob matching none is passed through literally. Both fail obscurely.
|
||||||
|
# "helm package" keeps the "+" of a "1.4.0+up1.9.2" version verbatim in the
|
||||||
|
# file name, so "<name>-<version>.tgz" is an exact match (measured).
|
||||||
|
- name: "Upload Helm Chart to the Chart Registry"
|
||||||
env:
|
env:
|
||||||
JK_CR_TOKEN: ${{ steps.load-jk-cr-creds.outputs.JK_CR_TOKEN }}
|
REGISTRY_PASSWORD: ${{ steps.load-registry-creds.outputs.REGISTRY_PASSWORD }}
|
||||||
JK_CR_USERNAME: ${{ steps.load-jk-cr-creds.outputs.JK_CR_USERNAME }}
|
REGISTRY_USERNAME: ${{ steps.load-registry-creds.outputs.REGISTRY_USERNAME }}
|
||||||
JK_CR_HELM_REGISTRY: ${{ steps.load-jk-cr-creds.outputs.JK_CR_HELM_REGISTRY }}
|
REGISTRY_CHARTS_URL: ${{ steps.load-registry-creds.outputs.REGISTRY_CHARTS_URL }}
|
||||||
|
CHART_NAME: ${{ inputs.chart-name }}
|
||||||
|
CHART_VERSION: ${{ steps.get-chart-version.outputs.CHART_VERSION }}
|
||||||
run: |-
|
run: |-
|
||||||
: "${JK_CR_HELM_REGISTRY:?empty - check op://GitHub-Actions/jk-registry-access/helm-registry}"
|
: "${REGISTRY_CHARTS_URL:?empty - check op://GitHub-Actions/jlab-registry-access/charts-url}"
|
||||||
: "${JK_CR_USERNAME:?empty - check op://GitHub-Actions/jk-registry-access/username}"
|
: "${REGISTRY_USERNAME:?empty - check op://GitHub-Actions/jlab-registry-access/username}"
|
||||||
: "${JK_CR_TOKEN:?empty - check op://GitHub-Actions/jk-registry-access/password}"
|
: "${REGISTRY_PASSWORD:?empty - check op://GitHub-Actions/jlab-registry-access/password}"
|
||||||
printf '%s' "$JK_CR_TOKEN" | helm registry login "$JK_CR_HELM_REGISTRY" -u "$JK_CR_USERNAME" --password-stdin
|
: "${CHART_NAME:?empty - the chart-name input did not arrive}"
|
||||||
- name: "Push Helm Chart to JK-Container Registry"
|
: "${CHART_VERSION:?empty - could not read version: from ${CHART_NAME}/Chart.yaml}"
|
||||||
env:
|
# 1Password masks the full URL, but a curl connection error prints only
|
||||||
JK_CR_CHARTS_LOCATION: ${{ steps.load-jk-cr-creds.outputs.JK_CR_CHARTS_LOCATION }}
|
# "<host>:<port>", which that mask would not cover. Mask the host too.
|
||||||
run: |-
|
CHARTS_HOST="${REGISTRY_CHARTS_URL#*://}"
|
||||||
: "${JK_CR_CHARTS_LOCATION:?empty - check op://GitHub-Actions/jk-registry-access/charts-location}"
|
echo "::add-mask::${CHARTS_HOST%%/*}"
|
||||||
helm push "${{ inputs.chart-name }}"-*.tgz "$JK_CR_CHARTS_LOCATION"
|
case "${REGISTRY_USERNAME}${REGISTRY_PASSWORD}" in
|
||||||
|
*[[:space:]]*)
|
||||||
|
echo "::error::username/password in op://GitHub-Actions/jlab-registry-access must not contain whitespace - curl --config truncates an unquoted value at the first space"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
CHART_PACKAGE="${CHART_NAME}-${CHART_VERSION}.tgz"
|
||||||
|
test -f "$CHART_PACKAGE" || {
|
||||||
|
echo "::error::${CHART_PACKAGE} does not exist - helm package produced: $(ls -1 ./*.tgz 2>&1)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "uploading ${CHART_PACKAGE}"
|
||||||
|
printf 'user = %s:%s\nurl = %s\n' \
|
||||||
|
"$REGISTRY_USERNAME" "$REGISTRY_PASSWORD" "${REGISTRY_CHARTS_URL%/}/api/charts" |
|
||||||
|
curl --config - \
|
||||||
|
--fail-with-body \
|
||||||
|
--silent --show-error \
|
||||||
|
--request POST \
|
||||||
|
--upload-file "$CHART_PACKAGE" \
|
||||||
|
--write-out '\nHTTP %{http_code}\n'
|
||||||
working-directory: ${{ inputs.chart-location }}
|
working-directory: ${{ inputs.chart-location }}
|
||||||
- name: "Logout from JK-Registry"
|
|
||||||
env:
|
|
||||||
JK_CR_HELM_REGISTRY: ${{ steps.load-jk-cr-creds.outputs.JK_CR_HELM_REGISTRY }}
|
|
||||||
run: helm registry logout "$JK_CR_HELM_REGISTRY"
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
name: Publish YTDLP Web Player Helm Chart
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- ytdlp-web-player/**
|
||||||
|
jobs:
|
||||||
|
publish-ytdlp-web-player:
|
||||||
|
name: Publish YTDLP Web Player Helm Chart
|
||||||
|
uses: ./.github/workflows/publish-helm-chart.yml
|
||||||
|
with:
|
||||||
|
chart-name: 'ytdlp-web-player'
|
||||||
|
secrets: inherit
|
||||||
@@ -11,6 +11,8 @@ jobs:
|
|||||||
any-chart-changed: ${{ steps.detect.outputs.any-chart-changed }}
|
any-chart-changed: ${{ steps.detect.outputs.any-chart-changed }}
|
||||||
scan-charts: ${{ steps.detect.outputs.scan-charts }}
|
scan-charts: ${{ steps.detect.outputs.scan-charts }}
|
||||||
any-scan: ${{ steps.detect.outputs.any-scan }}
|
any-scan: ${{ steps.detect.outputs.any-scan }}
|
||||||
|
install-charts: ${{ steps.detect.outputs.install-charts }}
|
||||||
|
any-install: ${{ steps.detect.outputs.any-install }}
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout Repository"
|
- name: "Checkout Repository"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
@@ -54,6 +56,55 @@ jobs:
|
|||||||
echo "any-scan=true" >> "$GITHUB_OUTPUT"
|
echo "any-scan=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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:
|
build-chart:
|
||||||
name: "Build ${{ matrix.chart }}"
|
name: "Build ${{ matrix.chart }}"
|
||||||
needs: detect-changed-charts
|
needs: detect-changed-charts
|
||||||
@@ -70,6 +121,15 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: "Install Helm"
|
- name: "Install Helm"
|
||||||
uses: azure/setup-helm@v5
|
uses: azure/setup-helm@v5
|
||||||
|
# Both steps below refuse to run while a chart declares a dependency that
|
||||||
|
# is not in its charts/ directory - lint with "[WARNING] chart directory
|
||||||
|
# is missing these dependencies", package with "found in Chart.yaml, but
|
||||||
|
# missing in charts/ directory", both exiting 1. Charts without a
|
||||||
|
# dependencies: block are skipped without a word, so this costs nothing
|
||||||
|
# for the rest of the repo. See ci/helm-dependencies.sh for why it is
|
||||||
|
# "update" and not "build".
|
||||||
|
- name: "Fetch chart dependencies"
|
||||||
|
run: ci/helm-dependencies.sh "${{ matrix.chart }}"
|
||||||
- name: "Lint Helm Chart"
|
- name: "Lint Helm Chart"
|
||||||
run: helm lint --strict "${{ matrix.chart }}"
|
run: helm lint --strict "${{ matrix.chart }}"
|
||||||
- name: "Package Helm Chart"
|
- name: "Package Helm Chart"
|
||||||
@@ -97,8 +157,9 @@ jobs:
|
|||||||
# against - resource quantities formatted with printf "%v", where float64
|
# against - resource quantities formatted with printf "%v", where float64
|
||||||
# flips to exponential notation above ~1e6 - lives in the resources helper,
|
# flips to exponential notation above ~1e6 - lives in the resources helper,
|
||||||
# and that helper is copied into every chart's templates/_helpers.tpl,
|
# 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
|
# because sharing it would mean a library chart (README, guideline 14). So
|
||||||
# chart started from an older one, or from template-chart before the fix.
|
# 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
|
# 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 -
|
# 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
|
# adding a chart enrolls it. Two renders per chart, no cluster, a few
|
||||||
@@ -139,11 +200,27 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
- name: "Install Helm"
|
- name: "Install Helm"
|
||||||
uses: azure/setup-helm@v5
|
uses: azure/setup-helm@v5
|
||||||
|
# A plain release download rather than aquasecurity/setup-trivy, and the
|
||||||
|
# reason is measured on the Gitea probe PR of #225, not assumed. That
|
||||||
|
# action runs actions/checkout against aquasecurity/trivy on github.com
|
||||||
|
# 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"
|
- name: "Install Trivy"
|
||||||
uses: aquasecurity/setup-trivy@v0.3.1
|
run: |
|
||||||
with:
|
curl -sSfL -o /tmp/trivy.tar.gz \
|
||||||
version: ${{ env.TRIVY_VERSION }}
|
"https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${TRIVY_VERSION#v}_Linux-64bit.tar.gz"
|
||||||
cache: true
|
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
|
# Findings land in the job summary, not only in the log: exceptions with
|
||||||
# their reasons come from ci/trivy/trivyignore.yaml, the trusted-registry
|
# their reasons come from ci/trivy/trivyignore.yaml, the trusted-registry
|
||||||
# list from ci/trivy/data/ksv0125.yaml.
|
# list from ci/trivy/data/ksv0125.yaml.
|
||||||
@@ -151,83 +228,30 @@ jobs:
|
|||||||
run: ci/run-trivy-config.sh "${{ matrix.chart }}"
|
run: ci/run-trivy-config.sh "${{ matrix.chart }}"
|
||||||
|
|
||||||
install-test:
|
install-test:
|
||||||
name: "Install-test ${{ matrix.chart }}"
|
name: "Install-test"
|
||||||
needs: [detect-changed-charts, build-chart]
|
needs: [detect-changed-charts, build-chart]
|
||||||
if: needs.detect-changed-charts.outputs.any-chart-changed == 'true'
|
if: needs.detect-changed-charts.outputs.any-install == 'true'
|
||||||
runs-on: ubuntu-latest
|
# The job itself lives in .github/workflows/install-test.yml and is called
|
||||||
# Blocking since Renovate automerges patch/minor updates: a chart that no
|
# from here and from install-test-all-charts.yml (Gitea #52). Everything
|
||||||
# longer starts must stop the PR before it reaches main and publishes. This
|
# that decides HOW a chart is installed moved there unchanged - the runner
|
||||||
# ends the stabilization phase of issue #22 - the job reported failures
|
# label expression, the pinned kubectl with its skew check, envsubst, the
|
||||||
# without blocking while it proved itself across several chart PRs.
|
# containerised-job detection, the per-leg cluster name, the freesailarr VPN
|
||||||
timeout-minutes: 30
|
# credentials. What stayed here is WHICH charts are tested and whether the
|
||||||
strategy:
|
# job runs at all. A copied job would be a second chance to test
|
||||||
fail-fast: false
|
# differently, unnoticed, which is the same argument that gives
|
||||||
matrix:
|
# ci/excluded-charts.txt exactly one parser.
|
||||||
chart: ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }}
|
#
|
||||||
env:
|
# "install-charts", not "charts": the changed charts minus the ones in
|
||||||
K3D_VERSION: v5.9.0
|
# ci/excluded-charts.txt, filtered in detect-changed-charts, which also
|
||||||
K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1
|
# names every excluded chart of this pull request with its documented
|
||||||
steps:
|
# reason in its job summary (issue #231). The called workflow's matrix
|
||||||
- name: "Checkout Repository"
|
# comment carries the rest of that lesson, including why the decision
|
||||||
uses: actions/checkout@v7
|
# cannot be made per matrix leg instead.
|
||||||
- name: "Install Helm"
|
uses: ./.github/workflows/install-test.yml
|
||||||
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:
|
with:
|
||||||
connect-host: https://op-connect.jk-dev.de
|
charts: ${{ needs.detect-changed-charts.outputs.install-charts }}
|
||||||
- name: "Load freesailarr VPN credentials"
|
# As the 24 publish-<chart>.yml hulls do, and measured to work on this
|
||||||
id: load-freesailarr-vpn
|
# instance (README, "Where this repo lives"). The called workflow declares
|
||||||
if: matrix.chart == 'freesailarr'
|
# exactly one secret, OP_CONNECT_TOKEN, and only its freesailarr leg reads
|
||||||
uses: 1password/load-secrets-action@v5
|
# it.
|
||||||
env:
|
secrets: inherit
|
||||||
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
|
|
||||||
+23
@@ -1,6 +1,29 @@
|
|||||||
### Example user template template
|
### Example user template template
|
||||||
### Example user template
|
### Example user template
|
||||||
|
|
||||||
|
# Fetched Helm subchart archives and the lock file that records them (#214).
|
||||||
|
#
|
||||||
|
# Neither is committed, on purpose. The .tgz is a third-party binary that would
|
||||||
|
# land in every diff of a dependency update and be reviewed by nobody; CI
|
||||||
|
# fetches it with ci/helm-dependencies.sh before every lint, package, render
|
||||||
|
# and install.
|
||||||
|
#
|
||||||
|
# The lock is left out for a concrete reason rather than by omission: Renovate
|
||||||
|
# rewrites the version in Chart.yaml and cannot regenerate Chart.lock, so a
|
||||||
|
# committed lock would be stale on arrival - and `helm dependency build`, the
|
||||||
|
# only command that would benefit from it, fails outright when lock and
|
||||||
|
# Chart.yaml disagree. Every dependency PR would come in red. `helm dependency
|
||||||
|
# update` resolves from Chart.yaml instead and writes the lock itself.
|
||||||
|
#
|
||||||
|
# What is given up with it: the digest pinning a committed lock would provide.
|
||||||
|
# The version range in Chart.yaml is an exact version, so what moves is only
|
||||||
|
# what the upstream repo serves under that version.
|
||||||
|
*/charts/*.tgz
|
||||||
|
*/Chart.lock
|
||||||
|
|
||||||
|
# Local helm package output.
|
||||||
|
*.tgz
|
||||||
|
|
||||||
# IntelliJ project files
|
# IntelliJ project files
|
||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
@@ -7,26 +7,74 @@ 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:
|
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.
|
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.
|
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**.
|
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).
|
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. Only after the PR has been **merged by the user** may work on the next issue begin. The user merges; never merge yourself.
|
6. **Agent lifetime**: The development agent terminates itself once its PR has been merged (or closed).
|
||||||
7. **After the merge, verify the linked issue was closed** (auto-close via "Fixes #n"); close it manually if not.
|
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:
|
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.
|
- Do **not** add a `Co-Authored-By` trailer and do **not** append "Generated with Claude Code" footers.
|
||||||
|
- **Commits must be signed**, and in the Coder workspace they are: signing runs through its own SSH signer (`gpg.format = ssh`, `gpg.ssh.program = coder-git-ssh-sign`, `commit.gpgsign = true`), so no 1Password step is involved. Verify before opening the PR — `git log --format='%G?' -1` must print `G` — and if signing fails, stop and report rather than pushing unsigned work, because branch protection rejects it at merge time.
|
||||||
|
- **A PR that does not finish its issue writes `Part of #n`, never `Fixes #n`.** The keyword closes the issue on merge, so a `Fixes` on a PR that leaves work behind marks an open task as done — and reopening by hand is the only repair, which only happens if someone remembers to look. This is not hypothetical: Gitea PR #48 carried `Fixes #40` while `rss-bridge` had been deliberately left out of the sweep, and the merge closed Gitea #40 with one occurrence still open. Step 8 above is the other half of the same rule: the auto-close it verifies is only the right outcome when the keyword was the right one.
|
||||||
|
|
||||||
|
## The wiki
|
||||||
|
|
||||||
|
The knowledge base is the Gitea wiki at `https://git.jk-dev.de/jlab-cluster/helm-charts/wiki` — one page per chart (installation, required 1Password fields, every configurable value, the chart's traps) plus the long form of this workflow, the pipelines, the style guide and the incidents each rule was bought with. It is a **separate git repository**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.jk-dev.de:30022/jlab-cluster/helm-charts.wiki.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Its default branch is `main`, not `master`.
|
||||||
|
|
||||||
|
Flat layout, one Markdown file per page, page name = file name, links are relative page names without the extension (`[CI/CD](CI-CD)`). A new page must also be listed in `_Sidebar.md`, or it is reachable only by search.
|
||||||
|
|
||||||
|
**There is no PR flow here, so fetch and rebase before every push and never force-push** — two agents have already edited the same page minutes apart, and a force push deletes the other edit silently, with nothing left to report it.
|
||||||
|
|
||||||
|
**What obliges a wiki edit** (step 5 of the workflow above, same round of work, never a follow-up issue):
|
||||||
|
|
||||||
|
| Change | Page |
|
||||||
|
| --- | --- |
|
||||||
|
| a value added, removed, renamed, or its default changed | the chart's page — values table, and the operating notes if behaviour moved |
|
||||||
|
| runtime behaviour changed (probes, hardening, restart semantics, a value becoming required) | the chart's page, plus `Lessons-Learned` if the reason could be repeated elsewhere |
|
||||||
|
| a new chart | a new chart page, a row in `Charts`, an entry in `_Sidebar.md` |
|
||||||
|
| a convention added or changed | `Chart-Style-Guide` (and README, which stays the code-side copy) |
|
||||||
|
| pipeline, fixture or runner behaviour changed | `CI-CD` |
|
||||||
|
| this issue workflow changed | `Workflow` — **and** this file, which is the binding copy |
|
||||||
|
| a Renovate rule added or changed | `Renovate` |
|
||||||
|
| something broke in a way a rule now prevents | `Lessons-Learned` |
|
||||||
|
|
||||||
|
**What does not oblige an edit:** a plain app-version bump. Renovate automerges those continuously and never touches the wiki, which is why chart pages carry no version numbers at all and name `Chart.yaml` as the source of truth. An app update that *does* change values or behaviour is not a plain bump, and the table applies.
|
||||||
|
|
||||||
|
Wiki commits follow the same rules as repo commits: no `Co-Authored-By`, no "Generated with Claude Code" footer.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Cut every worktree from a freshly fetched `origin/main`.** `git fetch origin main` first, then branch from `origin/main` — never from the local `main` ref, and never assume the shared checkout is current: both can be many merges behind. The staleness is invisible where it matters most, and it outlives the fix: the project-instructions block a subagent receives is a **snapshot** of the spawning session's working directory, taken when **that session started** — not a live read. Fetching mid-session does not refresh it, so the file on disk can be correct while every agent started from then on is still handed the superseded rules. Measured on 2026-09-27: an agent whose own worktree was identical to `origin/main` (`git rev-list --left-right --count origin/main...HEAD` returned `0 0`) received a block from before Gitea #47 and reported a contradiction in this file that two PRs had already fixed, quoting the stale copy as binding. Two consequences, and only these two help: the spawning session's directory must be current **before** the session starts, and an agent checks a rule against the **file in its own worktree** plus a diff against `origin/main` — never against `main`, and never against its own context block, because the first consequence cannot be enforced from inside an agent and the second can.
|
||||||
|
- **Branch protection with a signature requirement is active**, and signatures verify on Gitea. A commit that is not signed does not reach `main` — check before opening a PR, do not discover it at merge time.
|
||||||
|
- **No merge queue.** Gitea has none, and its place is taken by **auto-merge on green checks**: the PR is armed once and merges itself when the required check passes **and the required review exists** — both are active on `main`, so an armed PR without a review sits there instead of merging. Rule 7 above is unchanged — the user merges, and an agent never merges its own PR or arms auto-merge on it.
|
||||||
|
- **A required check per chart is not possible.** Gitea runs a whole matrix as one job under one status context, so the install-test reports once for all charts. What is required on `main` is `validate-charts`, as that single context; per chart is the part that cannot be had. Relevant whenever branch protection is touched.
|
||||||
|
- **Issue numbers changed with the move, and `#n` in this repo's files means the GitHub tracker.** The issues open at the time are Gitea #5–#21 and #24; the mapping is a comment on Gitea #14. Closed ones were not migrated and exist only on GitHub. Beware that Gitea silently resolves a bare `#n` against its own counter, so an old reference can render as a link to something unrelated. When you write a *new* reference, use the Gitea number.
|
||||||
|
- **Renovate runs outside this repo**, as the shared `jlab-renovate-bot` instance; it reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed there. **Do not add a Renovate workflow here** — a second runner means duplicate PRs and two schedulers rewriting each other's branches. README, "Renovate on Gitea", has the evidence.
|
||||||
|
|
||||||
## Keep the docs current
|
## Keep the docs current
|
||||||
|
|
||||||
- Changes to this issue workflow always go into this CLAUDE.md file.
|
- 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.
|
- 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.
|
- 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
|
## What this repo is
|
||||||
|
|
||||||
A collection of self-made Helm charts, one directory per chart, published to a private OCI registry via GitHub Actions. 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
|
```bash
|
||||||
helm lint --strict <chart-name>
|
helm lint --strict <chart-name>
|
||||||
@@ -35,7 +83,7 @@ helm template <chart-name> --set ingress.domain=example.com --set ingress.cluste
|
|||||||
|
|
||||||
## Creating a new chart
|
## 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`.
|
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`.
|
2. Add the chart name to the `chart-name` choice list in `.github/workflows/change-app-version.yml`.
|
||||||
@@ -68,4 +116,5 @@ The binding style guide for all current and future charts lives in **README.md (
|
|||||||
- Probes: liveness/readiness/startup on every workload (exception: paperless consume CronJob), values-configurable with calibrated defaults.
|
- Probes: liveness/readiness/startup on every workload (exception: paperless consume CronJob), values-configurable with calibrated defaults.
|
||||||
- Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly).
|
- Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly).
|
||||||
- Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them.
|
- Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them.
|
||||||
- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci/<chart>/` or a justified exclusion; no chart dependencies (`charts/` directories are empty).
|
- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci/<chart>/` or a justified exclusion.
|
||||||
|
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` is the only chart using one). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
|
||||||
@@ -1,33 +1,45 @@
|
|||||||
# helm-charts
|
# helm-charts
|
||||||
|
|
||||||
Collection of some self-made helm-charts. Each top-level directory is a standalone Helm chart that gets packaged and pushed to a private OCI registry (JK-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
|
## 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 |
|
| Chart | Deploys | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `apache-tika` | Apache Tika | Deployment + Service only (no ingress) |
|
| `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 `{}`) |
|
| `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 |
|
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
|
||||||
|
| `convertx` | ConvertX | StatefulSet + PVC at `/app/data` (SQLite, uploads and converted output in one volume), no external database. **The only chart that overrides the image's `command` to bypass its entrypoint**: the image carries no `USER` and its entrypoint exists solely to drop to uid 1000 with `gosu`, so the chart starts the app directly and lets `runAsUser`/`fsGroup` do that work instead — the hardening baseline stays intact, at the price that any logic upstream later moves into that entrypoint is skipped silently (re-read it at every app-version update; the reasoning sits at the `command:` line). Pins uid 1000 rather than the repo's 20000, because the image owns `/home/convertx` with it and the uid owns PV data. `readOnlyRootFilesystem: true` with two emptyDirs, `/tmp` and `/home/convertx` — the latter is `HOME`, which the chart also sets explicitly, because LibreOffice, Calibre and fontconfig build their profiles there on the first conversion. Probes use `/healthcheck` (static 200, unauthenticated); `/` is never a 200, it 302s to `/setup` or `/login`. `JWT_SECRET` is a required `OnePasswordItem` — unset, the app mints a fresh one per process and every restart logs everyone out. The ingress `proxy-body-size` annotation is the **only** upload ceiling, since the app enforces none. ~1.4 GiB image, hence a raised `INSTALL_TIMEOUT` |
|
||||||
| `cyberchef` | CyberChef | Stateless Deployment |
|
| `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) |
|
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
|
||||||
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
|
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
|
||||||
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
|
| `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-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`, 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 |
|
| `outline` | Outline | StatefulSet, separate DB/Redis secrets |
|
||||||
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap |
|
| `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 |
|
| `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`) |
|
| `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 |
|
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
|
||||||
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
|
| `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 |
|
| `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 |
|
| `teamspeak-server` | TeamSpeak server | StatefulSet, no ingress |
|
||||||
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
|
| `template-chart` | — | Scaffold for new charts (placeholder `xxx`), never published |
|
||||||
| `urlaubsverwaltung` | Urlaubsverwaltung | Deployment, DB + OIDC secrets |
|
| `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 |
|
| `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 |
|
| `zipline` | Zipline | StatefulSet, DB secret |
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
@@ -37,12 +49,68 @@ 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.
|
- 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.
|
- **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-<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 `helm push` to the private JK-Registry. Registry credentials come from 1Password Connect (`OP_CONNECT_TOKEN` secret, items under `op://GitHub-Actions/`). The loaded values are read via **`steps.<id>.outputs.*`**, mapped into each 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.JK_CR_* }}` 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: `-u` consumed the following `--password-stdin`, helm fell back to an interactive prompt and the job died with `inappropriate ioctl for device` — a terminal error for what was really an unset variable.
|
- `publish-helm-chart.yml` — reusable workflow: `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry. The target is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (`OP_CONNECT_TOKEN` secret, item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`); the workflow appends `/api/charts` to `charts-url`. No registry address ever appears in this repository. The loaded values are read via **`steps.<id>.outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error. Three properties of the upload step are load-bearing:
|
||||||
|
- **Credentials stay out of the process list.** `curl --user "$U:$P"` would put them in `argv`; the step uses `curl --config -` and pipes the options in on stdin instead.
|
||||||
|
- **A failed upload fails the job.** Unlike `helm push`, `curl` exits **0** on HTTP 401/403/409 — a publish would go green with nothing uploaded. `--fail-with-body` turns those into a non-zero exit and still prints the response body, so the log names the cause.
|
||||||
|
- **The file name is built, not globbed.** `<chart>-<version>.tgz` is assembled from the chart name and the version already read from `Chart.yaml`; `--upload-file` takes exactly one file, and a glob matching two silently turns the second into curl's URL argument. `helm package` keeps the `+` of a `1.4.0+up1.9.2` version verbatim in the file name, so the built name is an exact match.
|
||||||
|
|
||||||
|
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.
|
- `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list.
|
||||||
- `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.
|
- `ci/helm-dependencies.sh <chart-dir>` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but one). It runs in **five** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in `publish-helm-chart.yml`, and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs.
|
||||||
|
- `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix, or run the all-charts install-test after the merge. The **resource-quantity render test** is the exception to that rule: it runs over every chart on every PR — see below.
|
||||||
|
- `install-test.yml` — reusable workflow (`workflow_call`) holding the install-test **job** itself: the runner-label expression, the pinned `kubectl` with its skew check, `envsubst`, the containerised-job detection, the per-leg k3d cluster and the `freesailarr` VPN credentials. Its one input is a JSON array of chart names; its callers decide *which* charts are tested and whether the job runs at all, never *how*. Two callers: `validate-charts.yml` and `install-test-all-charts.yml`. **It is not copied for the second caller** — a second copy is a second chance to test differently, unnoticed, the same argument that gives `ci/excluded-charts.txt` exactly one parser.
|
||||||
|
- `install-test-all-charts.yml` — manually dispatched **all-charts install-test**: the same job, with a matrix over every installable chart instead of the changed ones. Optional input for a subset of charts (empty = all). See below for when to run it.
|
||||||
|
|
||||||
|
### 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)
|
### Resource-quantity render test (all charts, every PR)
|
||||||
|
|
||||||
@@ -55,7 +123,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
|
|||||||
|
|
||||||
The two cases exist because the bug behind #157 — quantities formatted with `printf "%v"`, where the `float64` the null-safe fallback helpers produce flips to exponential notation from about `1e6` — has a loud and a silent half. Loud: the reformatted quantity carries the suffix `e+06`, the helper rejects it, `helm template` exits non-zero. Silent: the *product* of a fractional `limitFactor` and a bare number is reformatted **after** the suffix check, so the render succeeds and the manifest carries `memory: "1.4999985e+06"` — rejected by nothing until the API server sees it at apply time. A check on the exit code alone therefore proves only half the case; the second case searches the **rendered text** for a resource quantity in exponential notation. `999999` stays below `1e6` on purpose, so the fraction case isolates the second formatting site instead of tripping over the first.
|
The two cases exist because the bug behind #157 — quantities formatted with `printf "%v"`, where the `float64` the null-safe fallback helpers produce flips to exponential notation from about `1e6` — has a loud and a silent half. Loud: the reformatted quantity carries the suffix `e+06`, the helper rejects it, `helm template` exits non-zero. Silent: the *product* of a fractional `limitFactor` and a bare number is reformatted **after** the suffix check, so the render succeeds and the manifest carries `memory: "1.4999985e+06"` — rejected by nothing until the API server sees it at apply time. A check on the exit code alone therefore proves only half the case; the second case searches the **rendered text** for a resource quantity in exponential notation. `999999` stays below `1e6` on purpose, so the fraction case isolates the second formatting site instead of tripping over the first.
|
||||||
|
|
||||||
Why all charts rather than the changed ones: charts here have no dependencies, so the resources helper is copied into every chart's `templates/_helpers.tpl`. The regression arrives by copy — a new chart started from an older chart, or from `template-chart` before the fix — and in that PR the copied bug is in no diff anyone reads. Running over everything also removes the list nobody would maintain: adding a chart enrolls it automatically. Two renders per chart, no cluster, a few seconds for the whole repo; the job depends on nothing else and finishes well inside the `build-chart` runtime.
|
Why all charts rather than the changed ones: the resources helper is copied into every chart's `templates/_helpers.tpl` rather than shared through a library chart. The regression arrives by copy — a new chart started from an older chart, or from `template-chart` before the fix — and in that PR the copied bug is in no diff anyone reads. Running over everything also removes the list nobody would maintain: adding a chart enrolls it automatically. Two renders per chart, no cluster, a few seconds for the whole repo; the job depends on nothing else and finishes well inside the `build-chart` runtime.
|
||||||
|
|
||||||
Two details worth knowing before touching it:
|
Two details worth knowing before touching it:
|
||||||
|
|
||||||
@@ -66,6 +134,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.
|
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):
|
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`)
|
- `ci/common/` — fixtures applied for every chart (schema-less stub CRD for `OnePasswordItem`)
|
||||||
@@ -73,15 +143,32 @@ 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.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>/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/<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.
|
`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`.
|
Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to dodge runner rate limits. The job **blocks the PR** on failure — the stabilization phase of #22 ended when Renovate started automerging patch/minor updates, since a chart that no longer starts must not reach `main` and publish unattended. When adding a new chart, add its `ci/<chart>/` fixtures (or an entry in `ci/excluded-charts.txt`) — the install-test fails if both are missing. The script also runs locally against any existing cluster: `k3d cluster create t && ci/run-install-test.sh <chart> && k3d cluster delete t`.
|
||||||
|
|
||||||
|
### All-charts install-test (on demand)
|
||||||
|
|
||||||
|
`install-test-all-charts.yml` runs **the same job** as the PR pipeline — the same `install-test.yml`, the same pinned `kubectl`, the same fixtures — with a matrix over **every installable chart** instead of the charts a PR changed. `workflow_dispatch` only, **no schedule**: a daily run would spend compute on a question that changes rarely, and a red run nobody asked for is a red run nobody reads.
|
||||||
|
|
||||||
|
It answers a different question from the PR pipeline. The PR install-test guards against what a change breaks; this one asks whether the environment under an **unchanged** chart still holds. The install-test matrix follows the charts a PR touches, so a chart nobody has edited since the forge move carries a green history from a *different runner* — and that green says nothing about today. **Run it after a change to the runner, to `K3S_IMAGE`, to the pinned `KUBECTL_VERSION`, to the fixtures under `ci/` — and after a forge move.** It is also the answer for a PR that only changes the harness and therefore tests nothing by itself: run it once the change is on `main`.
|
||||||
|
|
||||||
|
Why it exists at all: the answer arrived once by accident. Gitea PR #48 corrected a *comment* in 21 charts, which made all of them "changed" and turned the run into the first all-charts install-test after the move — **19 of 20 matrix charts came up in 12m41s** in one job, and exactly one did not (`rss-bridge`, whose last green run was from a GitHub runner; Gitea #51). Worth having, and nobody had planned it.
|
||||||
|
|
||||||
|
- **Input `charts`** — space- or comma-separated chart names, empty means all. That is what lets a single chart be tested against the runner, which is what Gitea #51 needs. A name that is not a chart directory, or one listed in `ci/excluded-charts.txt`, is a **loud error**: someone typed it, and a green run over zero charts would be the worst possible answer. An empty matrix fails the run for the same reason.
|
||||||
|
- **One throwaway cluster per chart, no reuse.** Reuse would save time and change what the test proves — leftovers from the previous install, a warm image cache, fixtures already applied. "Came up from nothing" is the statement.
|
||||||
|
- **Exclusions come from the one parser**, `ci/run-install-test.sh --list-excluded`, and the excluded charts appear in the job summary **with their justification**, exactly as in the PR pipeline.
|
||||||
|
- **The result is written down.** Every matrix leg records its own outcome next to the four facts that decide what the outcome means — date, runner label, `K3S_IMAGE`, `kubectl` version — and a final job records the verdict over the whole matrix. A green run nobody notes is unknown again next time. Per leg and not as one table, because a matrix leg only ever sees its own job summary.
|
||||||
|
- **Expected runtime: 20 charts in 12m41s** (Gitea PR #48, one job, all legs concurrently on one Docker daemon). The figure is there so a later deviation is visible as one; compare it against the chart count the run prints in its own summary — 24 at the time of writing, and one more with every new chart. The job timeout is 30 minutes per leg and lives in `install-test.yml`, i.e. it is the same for both callers on purpose.
|
||||||
|
- **A big image does not move that figure as much as it looks.** `convertx` was expected to, at ~1.4 GiB compressed — by a wide margin the largest image in the repo, and the reason its `settings.env` raises `INSTALL_TIMEOUT` to 900s. Measured on its first green run (Gitea PR #61) the whole leg took **2m35s**, cluster creation and rollout wait included, so it sits inside the existing envelope rather than setting a new one. Worth knowing before the next raised timeout is read as a prediction about the total: the legs run concurrently, so what the all-charts figure tracks is the **slowest** leg plus scheduling, not the sum of the pulls.
|
||||||
|
|
||||||
## Dependency updates (Renovate)
|
## 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:
|
What Renovate keeps current:
|
||||||
|
|
||||||
@@ -89,15 +176,15 @@ What Renovate keeps current:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository |
|
| 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 |
|
| 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 |
|
| GitHub Actions | Built-in `github-actions` manager |
|
||||||
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
|
| 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.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -123,21 +210,39 @@ 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.
|
> 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
|
### 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.
|
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.
|
||||||
- **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.
|
- **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.
|
- 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
|
## 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`).
|
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`.
|
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.
|
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.
|
||||||
|
5. Only if the chart needs a **dependency**: read "Chart dependencies" in the style guidelines first, it is a bar rather than a formality. Nothing has to be added to the pipelines — `ci/helm-dependencies.sh` already runs everywhere and keys off the `dependencies:` block — but the subchart's own updates then depend entirely on Renovate's built-in `helmv3` manager plus a chart-version bump, which is the part to write down at the pinned version.
|
||||||
|
|
||||||
Validate locally with:
|
Validate locally with:
|
||||||
|
|
||||||
@@ -152,7 +257,7 @@ For runtime verification (do the workloads actually start, do probes/security se
|
|||||||
|
|
||||||
## Chart style guidelines
|
## 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
|
### 1. Naming
|
||||||
|
|
||||||
@@ -173,7 +278,8 @@ These guidelines are **binding for every current and future chart** in this repo
|
|||||||
### 3. Images
|
### 3. Images
|
||||||
|
|
||||||
- The main app image tag comes from `{{ .Chart.AppVersion }}` — never hardcoded.
|
- 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.
|
- 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
|
### 4. Ingress
|
||||||
@@ -184,7 +290,7 @@ These guidelines are **binding for every current and future chart** in this repo
|
|||||||
- The TLS secret is named `tls-<release>-<chart>-ingress`.
|
- 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).
|
- **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.
|
- **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
|
### 5. Security hardening
|
||||||
|
|
||||||
@@ -201,7 +307,7 @@ These guidelines are **binding for every current and future chart** in this repo
|
|||||||
### 7. Resources (auto-limits)
|
### 7. Resources (auto-limits)
|
||||||
|
|
||||||
- Resource requests ship as chart defaults in `values.yaml` and are user-overridable.
|
- Resource requests ship as chart defaults in `values.yaml` and are user-overridable.
|
||||||
- Limits are computed by a per-chart `_helpers.tpl` helper (reference implementation in `template-chart/templates/_helpers.tpl`): an explicitly set limit always wins, missing memory and ephemeral-storage limits default to `resources.limitFactor` × the request (factor default `3`), and a CPU limit is only rendered when set explicitly — otherwise CPU stays unlimited (it is compressible, throttling instead of OOM-killing). The helper is duplicated into every chart because charts have no dependencies.
|
- Limits are computed by a per-chart `_helpers.tpl` helper (reference implementation in `template-chart/templates/_helpers.tpl`): an explicitly set limit always wins, missing memory and ephemeral-storage limits default to `resources.limitFactor` × the request (factor default `3`), and a CPU limit is only rendered when set explicitly — otherwise CPU stays unlimited (it is compressible, throttling instead of OOM-killing). The helper is copied into every chart rather than shared through a library chart — which would be a second artifact to publish, version and pull for a helper that changes about once a year; the resource-quantity render test above exists because a copy can carry an old bug into a diff nobody reads.
|
||||||
|
|
||||||
### 8. Secrets
|
### 8. Secrets
|
||||||
|
|
||||||
@@ -237,6 +343,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.
|
- 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`).
|
- 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
|
### 11. Scaling
|
||||||
|
|
||||||
@@ -244,7 +351,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.
|
- 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`.
|
- 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.
|
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.
|
||||||
|
|
||||||
@@ -260,21 +367,56 @@ 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.
|
**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.
|
**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).
|
**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`).
|
**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:**
|
**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.
|
- **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
|
### 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.
|
- `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.
|
- **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.
|
- 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.
|
||||||
- Charts have no dependencies (`charts/` directories are empty).
|
### 14. Chart dependencies
|
||||||
|
|
||||||
|
Until `ytdlp-web-player` (#214) this section read "charts have no dependencies (`charts/` directories are empty)". That rule is gone, replaced by a bar rather than a ban — a dependency is **admissible, not free**, and the reason it stopped being a ban is worth keeping: the chart needed an oauth2-proxy in the data path in front of an application that ships no authentication at all, and proxy and app are only useful wired to each other.
|
||||||
|
|
||||||
|
**When a dependency earns its place.** All three, not two of three:
|
||||||
|
|
||||||
|
1. The dependent piece would otherwise be a second Helm release that only exists to serve this one, with the wiring between them living in neither chart.
|
||||||
|
2. Upstream is the right owner of it. We consume their chart as it comes: **we do not patch it, do not re-harden it, do not re-implement its values.** Where their defaults fall short of the guidelines above, that is **documented in our `values.yaml`**, not fixed by us — and a values-level fix stays available to whoever installs it.
|
||||||
|
3. It is **optional and off by default** (`condition:` in `Chart.yaml`), so the chart is complete without it and a default install renders none of its objects.
|
||||||
|
|
||||||
|
**What it costs, all of it visible before the next one is added:**
|
||||||
|
|
||||||
|
- **The subchart is invisible to everything downstream.** Its version is inside our packaged `.tgz`, so anything that watches Helm release versions — Fleet's `fleet.yaml` in `cluster-config` included — sees only *our* chart version. A new upstream release, security fix included, reaches a cluster **only** when we publish a new version of the outer chart. Nothing reports the gap. Renovate's built-in `helmv3` manager opens the PR (no `customManagers` entry needed — the repository URL and version sit together in one file) and `ci/renovate-bump-chart.sh` bumps the chart patch that ships it; that chain is the entire safety net. State this in a comment at the pinned version, not only in a review.
|
||||||
|
- **Every pipeline that touches the chart needs a network fetch first.** `helm lint`, `helm package`, `helm template` and `helm install` all exit 1 while a declared dependency is missing from `charts/`, so `ci/helm-dependencies.sh` runs before each of them (see CI/CD above). One of those call sites is the resource-quantity render test, which runs over **every** chart on **every** PR — a dependency-carrying chart can therefore turn a third-party Helm repository's bad day into a red pipeline on a PR that touched nothing related.
|
||||||
|
- **`charts/*.tgz` and `Chart.lock` are not committed** (`.gitignore` says why): the archive is a third-party binary nobody reviews in a diff, and a committed lock would arrive stale because Renovate rewrites `Chart.yaml` and cannot regenerate it — which is also why the pipelines run `helm dependency update` and not `helm dependency build`.
|
||||||
|
- **The repo's own mechanisms stop at the subchart boundary.** `checksum/config`, the 1Password auto-restart annotation and the `scaleDown` flag reach our templates only. A chart that renders a `OnePasswordItem` for its subchart documents the manual `kubectl rollout restart` that replaces the missing restart-on-change.
|
||||||
|
|
||||||
|
Two implementation notes that cost time otherwise: a values key containing a dash is unreachable as `.Values.oauth2-proxy` (that parses as a subtraction) and needs `index .Values "oauth2-proxy"`, while `condition:` in `Chart.yaml` splits on dots only and needs no escaping. And inside a subchart `.Chart.Name` is the **subchart's** name — a value templated into it (`config.configFile` in this case) must spell the parent chart out literally; only `.Release.Name` is shared.
|
||||||
|
|
||||||
|
Charts that need no dependency still have none, and `charts/` stays empty for all of them. `template-chart` remains dependency-free: the scaffold should not carry a `dependencies:` block a new chart has to remember to delete.
|
||||||
@@ -5,5 +5,5 @@ icon: https://www.apache.org/logos/res/tika/default.png
|
|||||||
|
|
||||||
type: application
|
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"
|
appVersion: "3.3.1.0-full"
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "apache-tika.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "apache-tika.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "apache-tika.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "apache-tika.defaultedDict" -}}
|
{{- define "apache-tika.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/alam00000/bentopdf/main/public/images/fa
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 1.0.2+up2.8.8
|
version: 1.0.3+up2.8.8
|
||||||
appVersion: "2.8.8"
|
appVersion: "2.8.8"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "bentopdf.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "bentopdf.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "bentopdf.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "bentopdf.defaultedDict" -}}
|
{{- define "bentopdf.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ name: calibre-web-automated
|
|||||||
description: A Helm chart to deploy calibere-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
|
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
|
appVersion: v4.0.6
|
||||||
|
|
||||||
maintainers:
|
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
|
kubectl -n {{ .Release.Namespace }} logs {{ $pod }} -f
|
||||||
|
|
||||||
SECRETS — THE FIELDS THE 1PASSWORD ITEM MUST CARRY
|
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
|
→ Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
hardcover-api-token becomes the HARDCOVER_TOKEN environment variable
|
hardcover-api-token becomes the HARDCOVER_TOKEN environment variable
|
||||||
calibre-plugins mounted as /config/.config/calibre/customize.py.json,
|
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
|
hardcover-api-token parks the pod in CreateContainerConfigError; a missing
|
||||||
calibre-plugins fails the mount instead ("couldn't find key calibre-plugins"),
|
calibre-plugins fails the mount instead ("couldn't find key calibre-plugins"),
|
||||||
and the container is never created at all.
|
and the container is never created at all.
|
||||||
{{- if not .Values.secrets.cwaSecretRef }}
|
|
||||||
|
|
||||||
secrets.cwaSecretRef IS UNSET. The OnePasswordItem has been created with an
|
secrets.cwaSecretRef itself is required: the template guards it with
|
||||||
empty itemPath, the operator has nothing to resolve, and the Secret above will
|
required(), so a release without it does not render at all rather than
|
||||||
never appear — the pod cannot start. Set it to the item's op:// path and
|
creating a OnePasswordItem with an empty itemPath.
|
||||||
upgrade.
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
CHANGING THE ITEM'S CONTENT RESTARTS NOTHING
|
CHANGING THE ITEM'S CONTENT RESTARTS NOTHING
|
||||||
Helm never sees what is inside a OnePasswordItem, so it cannot hash it into the
|
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:
|
resources:
|
||||||
{{- include "calibre-web-automated.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "calibre-web-automated.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "calibre-web-automated.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -167,8 +169,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "calibre-web-automated.defaultedDict" -}}
|
{{- define "calibre-web-automated.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
spec:
|
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,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"cwaSecretRef": {
|
"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"]
|
"type": ["string", "null"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
scaleDown: false
|
scaleDown: false
|
||||||
|
|
||||||
secrets:
|
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
|
cwaSecretRef: null
|
||||||
|
|
||||||
cwa:
|
cwa:
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Fixtures for the convertx install-test: the Secret the 1Password operator
|
||||||
|
# would otherwise materialize from the OnePasswordItem (release name: ci).
|
||||||
|
#
|
||||||
|
# Without it the pod stays in CreateContainerConfigError - the StatefulSet
|
||||||
|
# reads JWT_SECRET from this Secret's "jwt-secret" key, and the stub
|
||||||
|
# OnePasswordItem CRD in ci/common/ has no controller behind it. The key name
|
||||||
|
# here is therefore load-bearing and has to match the chart's secretKeyRef.
|
||||||
|
#
|
||||||
|
# ConvertX needs no external database, cache or storage service: its SQLite
|
||||||
|
# file, uploads and output all live on the chart's own PVC, so this is the
|
||||||
|
# whole fixture set.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ci-convertx-secret
|
||||||
|
stringData:
|
||||||
|
jwt-secret: convertx-ci-jwt-secret-0123456789abcdef0123456789abcdef
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# The image is ~1.4 GiB compressed - by a wide margin the largest in this
|
||||||
|
# repo, because LibreOffice, Calibre, ImageMagick, ffmpeg, Inkscape and a TeX
|
||||||
|
# Live installation are all inside it. One layer alone is 1354 MiB.
|
||||||
|
#
|
||||||
|
# The rollout wait therefore has to cover the PULL, not just the start: the
|
||||||
|
# runner reference point is 96 MB in 17.5s, which puts this image in the
|
||||||
|
# region of five minutes of pulling before the container is even created. On
|
||||||
|
# top of that comes the startup probe budget (30 x 10s = 300s), which is
|
||||||
|
# generous because ConvertX shells out to ~20 converter binaries at import
|
||||||
|
# just to log their versions.
|
||||||
|
#
|
||||||
|
# MEASURED, and the estimate was wrong in the safe direction: the first green
|
||||||
|
# install-test of this chart (Gitea PR #61) took 2m35s for the WHOLE job -
|
||||||
|
# cluster creation, fixtures, pull, install and rollout wait together. The
|
||||||
|
# 96 MB/17.5s reference does not transfer to ghcr.io on this runner.
|
||||||
|
#
|
||||||
|
# 900s stays anyway, and deliberately. A rollout-wait timeout is a ceiling,
|
||||||
|
# not a cost: kubectl waits exactly as long as the rollout needs, so a
|
||||||
|
# generous number is free on a good day and only spends time on a chart that
|
||||||
|
# is already failing. What it buys is the bad day - a cold registry, or a node
|
||||||
|
# that has to extract the 1354 MiB layer onto slow disk. The default of 300s
|
||||||
|
# would have no headroom at all for either.
|
||||||
|
INSTALL_TIMEOUT=900
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# The OnePasswordItem CR only needs a non-null item path; the actual
|
||||||
|
# Kubernetes Secret comes from ci/convertx/fixtures.yaml.
|
||||||
|
secrets:
|
||||||
|
jwtSecretRef: op://ci/convertx-jwt
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
domain: convertx.ci.local
|
||||||
|
# clusterIssuer is strictly required (no default); the Ingress just has to
|
||||||
|
# apply - there is no cert-manager in the CI cluster.
|
||||||
|
clusterIssuer: ci
|
||||||
|
|
||||||
|
# The chart's default is 10Gi; the install-test converts nothing, so 1Gi is
|
||||||
|
# enough and provisions faster on k3d's local-path class.
|
||||||
|
storage:
|
||||||
|
amount: 1Gi
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
# (REQUIRED_ENV), and pipes the result straight into kubectl.
|
# (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 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
|
# 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
|
# endpoint it saw last, so a shared key would let every pull request pull the
|
||||||
# tunnel out from under the running stack.
|
# 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 -
|
# 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
|
# 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.
|
# first-boot migrations). mediathekarr adds ~850 MB of image and waits for
|
||||||
INSTALL_TIMEOUT=900
|
# 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
|
# The gluetun credentials for ci/freesailarr/fixtures.env.yaml. Missing or empty
|
||||||
# values fail the install-test loudly instead of skipping it - see
|
# 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
|
# 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
|
# 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
|
# 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.
|
# in the CI cluster, and the install-test checks readiness, not routing.
|
||||||
host: freesailarr.example.com
|
host: freesailarr.example.com
|
||||||
clusterIssuer: ci
|
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
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fetch a chart's declared dependencies into its charts/ directory.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS (issue #214)
|
||||||
|
#
|
||||||
|
# Until ytdlp-web-player every chart here was dependency-free, so no pipeline
|
||||||
|
# ever ran "helm dependency update" - and none of them tolerates a chart that
|
||||||
|
# needs it. All three of the commands this repo builds on refuse to work while
|
||||||
|
# a declared dependency is missing from charts/, each with its own wording:
|
||||||
|
#
|
||||||
|
# helm lint --strict [WARNING] chart directory is missing these dependencies: <name>
|
||||||
|
# helm package Error: found in Chart.yaml, but missing in charts/ directory: <name>
|
||||||
|
# helm template Error: An error occurred while checking for chart dependencies. ...
|
||||||
|
#
|
||||||
|
# All three exit 1, which is why this call sits in FIVE places rather than the
|
||||||
|
# three the plan expected: the two build steps of validate-charts.yml, the
|
||||||
|
# publish workflow - and the three ci/ scripts that render or install a chart
|
||||||
|
# (install-test, quantity-render, trivy-config). The quantity-render one is the
|
||||||
|
# easiest to overlook and the one that would have gone red first: it runs over
|
||||||
|
# EVERY chart on EVERY pull request, so a dependency-carrying chart breaks it
|
||||||
|
# from a PR that does not touch that chart at all.
|
||||||
|
#
|
||||||
|
# WHY "update" AND NOT "build"
|
||||||
|
#
|
||||||
|
# "helm dependency build" installs what Chart.lock names and fails when the
|
||||||
|
# lock disagrees with Chart.yaml. Renovate rewrites the version in Chart.yaml
|
||||||
|
# and cannot regenerate the lock, so every dependency PR would arrive red.
|
||||||
|
# "update" resolves from Chart.yaml and writes the lock itself, which is also
|
||||||
|
# why neither the lock nor the fetched .tgz is committed (see .gitignore).
|
||||||
|
#
|
||||||
|
# WHAT IT COSTS, NAMED HERE SO NOBODY HAS TO REDISCOVER IT
|
||||||
|
#
|
||||||
|
# A network fetch from a third-party Helm repository in every job that renders
|
||||||
|
# the chart. That repository going away, or serving a version that no longer
|
||||||
|
# exists, turns into a red pipeline on a PR that changed nothing related. This
|
||||||
|
# is the price of the dependency, and it is the reason the README asks for one
|
||||||
|
# to be justified rather than assumed.
|
||||||
|
#
|
||||||
|
# Charts without a "dependencies:" block are skipped without a word, so this is
|
||||||
|
# safe to call unconditionally for every chart.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CHART_DIR="${1:?usage: ci/helm-dependencies.sh <chart-directory>}"
|
||||||
|
|
||||||
|
if [ ! -f "$CHART_DIR/Chart.yaml" ]; then
|
||||||
|
echo "ci/helm-dependencies.sh: $CHART_DIR/Chart.yaml not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deliberately a grep for the top-level key rather than a YAML parse: the
|
||||||
|
# runners have no yq and no pyyaml (the same constraint ci/run-trivy-config.sh
|
||||||
|
# and ci/run-quantity-render-test.sh work under). A commented-out block does
|
||||||
|
# not match, because the anchor requires the key in column one.
|
||||||
|
if ! grep -q '^dependencies:' "$CHART_DIR/Chart.yaml"; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> helm dependency update $CHART_DIR"
|
||||||
|
helm dependency update "$CHART_DIR"
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
# minecraft.version is deliberately NOT set here any more. Since chart 1.0.0 the
|
# minecraft.version is deliberately NOT set here any more. Since chart 1.0.0 the
|
||||||
# chart ships a pinned default that belongs to the Java line it publishes (the
|
# chart ships a pinned default that belongs to the Java line it publishes (the
|
||||||
# image tag comes from .Chart.AppVersion, and Java 8 runs Minecraft only up to
|
# image tag comes from .Chart.AppVersion, and Java 8 runs Minecraft only up to
|
||||||
# 1.16.5, 1.18-1.20.4 need Java 17, 1.20.5+ need Java 21). Leaving it unset
|
# 1.16.5, 1.18-1.20.4 need Java 17, 1.20.5-1.21.11 need Java 21 and 26.1+ needs
|
||||||
# means the install-test exercises exactly the default a user would get, so a
|
# Java 25). Leaving it unset means the install-test exercises exactly the
|
||||||
# Java line published with a mismatched default version fails CI instead of
|
# default a user would get, so a Java line published with a mismatched default
|
||||||
# passing on a CI-only override.
|
# version fails CI instead of passing on a CI-only override.
|
||||||
#
|
#
|
||||||
# server.service.type is not set either: the default is ClusterIP, which is what
|
# server.service.type is not set either: the default is ClusterIP, which is what
|
||||||
# all three real deployments run (routing is done by the static mappings of the
|
# all three real deployments run (routing is done by the static mappings of the
|
||||||
|
|||||||
+53
-5
@@ -4,6 +4,7 @@
|
|||||||
# fixtures under ci/<chart>/ and requires every workload to become Ready.
|
# fixtures under ci/<chart>/ and requires every workload to become Ready.
|
||||||
#
|
#
|
||||||
# Usage: ci/run-install-test.sh <chart-name>
|
# Usage: ci/run-install-test.sh <chart-name>
|
||||||
|
# ci/run-install-test.sh --list-excluded
|
||||||
#
|
#
|
||||||
# Layout (repo root):
|
# Layout (repo root):
|
||||||
# ci/common/ fixtures applied for every chart (OnePasswordItem stub CRD)
|
# 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
|
# ci/excluded-charts.txt charts that cannot run in CI, with reasons
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
CHART="${1:?usage: $0 <chart-name>}"
|
|
||||||
NAMESPACE="default"
|
NAMESPACE="default"
|
||||||
RELEASE="ci"
|
RELEASE="ci"
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
CI_DIR="$ROOT/ci"
|
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).
|
# 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
|
# REQUIRED_ENV is a space-separated list of environment variables the chart's
|
||||||
# fixtures need; see the check below.
|
# fixtures need; see the check below.
|
||||||
@@ -32,13 +72,17 @@ if [ -f "$CI_DIR/$CHART/settings.env" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Exclusion list ---------------------------------------------------------
|
# --- Exclusion list ---------------------------------------------------------
|
||||||
if [ -f "$CI_DIR/excluded-charts.txt" ]; then
|
# Through excluded_reason(), i.e. the same parser --list-excluded uses, so a
|
||||||
REASON="$(grep -E "^$CHART([[:space:]]|$)" "$CI_DIR/excluded-charts.txt" | sed -E "s/^$CHART[[:space:]]*//" || true)"
|
# 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
|
if [ -n "$REASON" ]; then
|
||||||
echo "SKIP: chart '$CHART' is excluded from the install-test: $REASON"
|
echo "SKIP: chart '$CHART' is excluded from the install-test: $REASON"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -d "$ROOT/$CHART" ] || [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
|
if [ ! -d "$ROOT/$CHART" ] || [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
|
||||||
echo "ERROR: '$CHART' is not a chart directory" >&2
|
echo "ERROR: '$CHART' is not a chart directory" >&2
|
||||||
@@ -59,7 +103,7 @@ fi
|
|||||||
# REQUIRED_ENV="FREESAILARR_WG_KEY FREESAILARR_WG_ADDRESSES"
|
# REQUIRED_ENV="FREESAILARR_WG_KEY FREESAILARR_WG_ADDRESSES"
|
||||||
#
|
#
|
||||||
# The values come from the environment - in CI from 1Password Connect, see the
|
# 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
|
# 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
|
# instead would restore exactly the green-but-meaningless signal this mechanism
|
||||||
@@ -151,6 +195,10 @@ done
|
|||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
# --- 3. Install the chart ---------------------------------------------------
|
# --- 3. Install the chart ---------------------------------------------------
|
||||||
|
# Same reason as in the lint and package steps: helm install refuses to touch a
|
||||||
|
# chart whose declared dependencies are not in charts/. No-op for a chart
|
||||||
|
# without dependencies; see ci/helm-dependencies.sh.
|
||||||
|
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
|
||||||
echo "::group::helm install"
|
echo "::group::helm install"
|
||||||
helm install "$RELEASE" "$ROOT/$CHART" \
|
helm install "$RELEASE" "$ROOT/$CHART" \
|
||||||
--namespace "$NAMESPACE" \
|
--namespace "$NAMESPACE" \
|
||||||
|
|||||||
@@ -26,9 +26,9 @@
|
|||||||
# case is not - it needs the rendered text to be searched for a quantity in
|
# case is not - it needs the rendered text to be searched for a quantity in
|
||||||
# exponential notation. Both are checked here, per chart.
|
# exponential notation. Both are checked here, per chart.
|
||||||
#
|
#
|
||||||
# Why over *all* charts instead of the changed ones: charts in this repo have
|
# Why over *all* charts instead of the changed ones: the helper is copied into
|
||||||
# no dependencies, so the helper is copied into every chart's
|
# every chart's templates/_helpers.tpl rather than shared through a library
|
||||||
# templates/_helpers.tpl. The regression this guards against arrives by copy -
|
# chart. The regression this guards against arrives by copy -
|
||||||
# a new chart started from an older chart, or from template-chart before the
|
# a new chart started from an older chart, or from template-chart before the
|
||||||
# fix. That chart is new to the repo, but its copy of the bug is not something
|
# fix. That chart is new to the repo, but its copy of the bug is not something
|
||||||
# any diff of *this* PR would point at. The test is cheap enough (two renders
|
# any diff of *this* PR would point at. The test is cheap enough (two renders
|
||||||
@@ -111,6 +111,15 @@ for CHART in "${CHARTS[@]}"; do
|
|||||||
echo "ERROR: '$CHART' is not a chart directory" >&2
|
echo "ERROR: '$CHART' is not a chart directory" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# A chart with a declared dependency cannot be rendered at all while its
|
||||||
|
# charts/ directory is empty - helm template exits 1 before it reaches a
|
||||||
|
# single template. This job is the one that would notice first, because it
|
||||||
|
# runs over EVERY chart on EVERY pull request: without this line, a PR
|
||||||
|
# touching nothing but a README would go red on ytdlp-web-player. The script
|
||||||
|
# skips charts that declare none, which is still all but one of them.
|
||||||
|
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
|
||||||
|
|
||||||
SCHEMA="$ROOT/$CHART/values.schema.json"
|
SCHEMA="$ROOT/$CHART/values.schema.json"
|
||||||
if [ ! -f "$SCHEMA" ]; then
|
if [ ! -f "$SCHEMA" ]; then
|
||||||
echo "::error title=quantity-render $CHART::$CHART has no values.schema.json - every chart in this repo ships one (README, \"Values that must not lie\"), and this test reads the resources paths from it."
|
echo "::error title=quantity-render $CHART::$CHART has no values.schema.json - every chart in this repo ships one (README, \"Values that must not lie\"), and this test reads the resources paths from it."
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ if bad:
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
# --- 3. Render ---------------------------------------------------------------
|
# --- 3. Render ---------------------------------------------------------------
|
||||||
|
# helm template refuses to run at all while a declared dependency is missing
|
||||||
|
# from charts/, so this has to happen before the render. No-op for a chart
|
||||||
|
# without dependencies; see ci/helm-dependencies.sh.
|
||||||
|
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
|
||||||
echo "::group::helm template $CHART ($VALUES_LABEL)"
|
echo "::group::helm template $CHART ($VALUES_LABEL)"
|
||||||
# ${a[@]+"${a[@]}"} - an empty array under `set -u` is an error on bash 3.2
|
# ${a[@]+"${a[@]}"} - an empty array under `set -u` is an error on bash 3.2
|
||||||
# (the macOS default), where this script also runs locally.
|
# (the macOS default), where this script also runs locally.
|
||||||
|
|||||||
@@ -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."
|
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
|
- 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
|
- id: AVD-KSV-0021
|
||||||
statement: "GIDs follow the pinned UIDs above; the same image expectation and the same PVC ownership risk apply."
|
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"]
|
paths: ["**/homeassistant.yaml"]
|
||||||
statement: "NET_RAW is the capability in question and is added on purpose for device discovery; see AVD-KSV-0022."
|
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 ----------------------------
|
# --- rss-bridge: port 80 is fixed by the image ----------------------------
|
||||||
- id: AVD-KSV-0117
|
- id: AVD-KSV-0117
|
||||||
paths: ["**/rss-bridge.yaml"]
|
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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Install-test values for ytdlp-web-player.
|
||||||
|
#
|
||||||
|
# The chart needs exactly these two values for a default install: the app
|
||||||
|
# itself has no secrets, no PVC and no external dependency to fake, so there is
|
||||||
|
# no fixtures.yaml next to this file. example.com is a placeholder - nothing in
|
||||||
|
# this repo may carry a production domain.
|
||||||
|
ingress:
|
||||||
|
domain: ytdlp-web-player.example.com
|
||||||
|
clusterIssuer: letsencrypt
|
||||||
|
|
||||||
|
# THE PROXY STAYS OFF HERE. DO NOT "FIX" THIS BY ENABLING IT.
|
||||||
|
#
|
||||||
|
# Two independent reasons, either one sufficient:
|
||||||
|
#
|
||||||
|
# 1. It would test somebody else's chart. That the oauth2-proxy chart works
|
||||||
|
# is a precondition of using it, and providing it is the upstream's job.
|
||||||
|
# What this repo owns is the WIRING - the upstreams line that has to carry
|
||||||
|
# the rendered Service name of this release - and wiring is a render-time
|
||||||
|
# property, checked by rendering, not by installing. See the PR for #214
|
||||||
|
# for that check.
|
||||||
|
#
|
||||||
|
# 2. It could not pass even if we wanted it to. oauth2-proxy fetches the OIDC
|
||||||
|
# discovery document of its provider at startup and never becomes Ready
|
||||||
|
# without one. There is no Authentik in the CI cluster, and standing one
|
||||||
|
# up to watch a third-party chart start is a test of the fixture, not of
|
||||||
|
# the chart.
|
||||||
|
#
|
||||||
|
# Switching it on therefore does not produce a stricter test - it produces a
|
||||||
|
# permanently red install-test whose cause is outside this repository.
|
||||||
|
oauth2-proxy:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: convertx
|
||||||
|
description: A Helm chart to deploy ConvertX on Kubernetes
|
||||||
|
icon: https://raw.githubusercontent.com/C4illin/ConvertX/main/public/android-chrome-512x512.png
|
||||||
|
|
||||||
|
type: application
|
||||||
|
|
||||||
|
version: 1.0.0+upv0.19.0
|
||||||
|
appVersion: "v0.19.0"
|
||||||
|
|
||||||
|
maintainers:
|
||||||
|
- name: jklein
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
{{- $replicas := include "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.replicas) -}}
|
||||||
|
{{- $startup := fromYaml (include "convertx.startupProbe" .Values.convertx.startupProbe) -}}
|
||||||
|
{{- $autoDelete := include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours -}}
|
||||||
|
{{- $registration := include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") -}}
|
||||||
|
{{- $unauthenticated := include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") -}}
|
||||||
|
{{- $httpAllowed := include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") -}}
|
||||||
|
ConvertX {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||||
|
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
|
||||||
|
ACCESS
|
||||||
|
https://{{ .Values.ingress.domain }}
|
||||||
|
|
||||||
|
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
|
||||||
|
cluster issuer. Until it has been issued the browser shows a warning; follow it with
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||||
|
{{- else }}
|
||||||
|
|
||||||
|
ACCESS — NO INGRESS
|
||||||
|
ingress.enabled is false, so this release has no Ingress and no certificate,
|
||||||
|
and neither ingress.clusterIssuer nor ingress.domain is needed. ConvertX has
|
||||||
|
no base-URL setting of its own — it builds no absolute links — so nothing
|
||||||
|
outside the Ingress reads the domain in this chart.
|
||||||
|
|
||||||
|
Reach the release inside the cluster with
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||||
|
|
||||||
|
and open http://localhost:8080. Note that the session cookie is marked
|
||||||
|
"secure"{{ if eq $httpAllowed "true" }} — except that convertx.httpAllowed is true in this release, which
|
||||||
|
clears that flag, so logging in over plain http works{{ else }}, so logging in over plain http will NOT work: the browser
|
||||||
|
refuses to store the cookie. convertx.httpAllowed: true clears that flag{{ end }}.
|
||||||
|
{{- end }}
|
||||||
|
{{ if eq $replicas "0" }}
|
||||||
|
SCALED TO ZERO
|
||||||
|
{{ if .Values.scaleDown }}scaleDown: true{{ else }}convertx.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so the endpoint
|
||||||
|
above does not answer until you set it back and upgrade again. PVC, Secret{{ if .Values.ingress.enabled }},
|
||||||
|
Service and Ingress stay in place, so neither the conversions nor the
|
||||||
|
certificate are touched.{{ else }} and
|
||||||
|
Service stay in place, so the conversions on the PVC are untouched.{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
WHOEVER OPENS THE PAGE FIRST BECOMES THE ADMINISTRATOR
|
||||||
|
A fresh ConvertX has no users, and with no users it serves a SETUP FORM to
|
||||||
|
every visitor — no password, no invitation, nothing to bypass. The first
|
||||||
|
account created that way owns the instance, and convertx.accountRegistration
|
||||||
|
does not gate it: that value decides whether accounts TWO and onwards can be
|
||||||
|
created, not the first one.
|
||||||
|
|
||||||
|
So on a fresh install, register yourself before the DNS name is reachable by
|
||||||
|
anyone else. There is no second chance and no "reset admin" command; the
|
||||||
|
repair is deleting the users row from the SQLite database on the PVC.
|
||||||
|
{{- if eq $unauthenticated "true" }}
|
||||||
|
|
||||||
|
This release has convertx.allowUnauthenticated: true, so the point above is
|
||||||
|
moot in the worst way — see the warning further down.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
THE 1PASSWORD ITEM MUST CARRY THE FIELD THE CHART READS
|
||||||
|
secrets.jwtSecretRef points at
|
||||||
|
{{ .Values.secrets.jwtSecretRef }}
|
||||||
|
The 1Password operator materializes that item as the Kubernetes Secret
|
||||||
|
{{ .Release.Name }}-{{ .Chart.Name }}-secret, with one Secret key per FIELD of the item. So the field
|
||||||
|
name is not free-form: this chart reads exactly one field, and it must be
|
||||||
|
spelled
|
||||||
|
|
||||||
|
jwt-secret a long random string — openssl rand -hex 32
|
||||||
|
|
||||||
|
An item without it does not fail the install. The release goes through and
|
||||||
|
the pod then hangs in CreateContainerConfigError naming the key it could not
|
||||||
|
find. Watch the materialization with
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} get onepassworditem {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
|
kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
|
|
||||||
|
WHY THIS VALUE IS REQUIRED RATHER THAN OPTIONAL: ConvertX signs its session
|
||||||
|
cookies with JWT_SECRET, and when the variable is unset it quietly generates
|
||||||
|
a random one PER PROCESS. Every restart — a node drain, an image update, an
|
||||||
|
OOM kill — would then invalidate every session, with no log line and no
|
||||||
|
error. Rotating the field has the same effect on purpose; after a rotation:
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
|
||||||
|
Editing the value INSIDE the item does not roll the pod by itself (Helm
|
||||||
|
never sees a secret's content). Pointing secrets.jwtSecretRef at a DIFFERENT
|
||||||
|
item does — the pod template carries a checksum over the rendered
|
||||||
|
OnePasswordItem.
|
||||||
|
|
||||||
|
TWO SWITCHES THAT OPEN THE INSTANCE UP
|
||||||
|
convertx.accountRegistration {{ $registration }}
|
||||||
|
convertx.allowUnauthenticated {{ $unauthenticated }}
|
||||||
|
|
||||||
|
Upstream warns explicitly against enabling either on an instance reachable
|
||||||
|
from the internet, and this chart puts a public DNS name in front by
|
||||||
|
default. accountRegistration lets anyone with the URL create an account;
|
||||||
|
allowUnauthenticated removes the login entirely, so anyone who reaches the
|
||||||
|
Service can upload files and read every conversion on the volume.
|
||||||
|
{{- if or (eq $registration "true") (eq $unauthenticated "true") }}
|
||||||
|
|
||||||
|
*** {{ if and (eq $registration "true") (eq $unauthenticated "true") }}BOTH OF THEM ARE ON{{ else }}ONE OF THEM IS ON{{ end }} IN THIS RELEASE. ***
|
||||||
|
{{- if eq $unauthenticated "true" }}
|
||||||
|
convertx.allowUnauthenticated: true — there is no login. Anyone who can
|
||||||
|
reach {{ if .Values.ingress.enabled }}https://{{ .Values.ingress.domain }}{{ else }}the Service{{ end }} can use it and read other people's files.
|
||||||
|
{{- end }}
|
||||||
|
{{- if eq $registration "true" }}
|
||||||
|
convertx.accountRegistration: true — anyone with the URL can sign up.
|
||||||
|
{{- end }}
|
||||||
|
If this instance is reachable from the internet, that is almost certainly
|
||||||
|
not what you wanted.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
RETENTION IS A VALUE, AND IT IS THE ONLY THING THAT EMPTIES THE VOLUME
|
||||||
|
{{- if eq $autoDelete "0" }}
|
||||||
|
convertx.autoDeleteEveryNHours is 0 — THE CLEANUP IS OFF. Uploads and
|
||||||
|
converted output accumulate on the PVC for as long as the release lives and
|
||||||
|
nothing reclaims them. Watch the volume, or set a non-zero number of hours.
|
||||||
|
{{- else }}
|
||||||
|
convertx.autoDeleteEveryNHours is {{ $autoDelete }} — ConvertX deletes the uploads and the
|
||||||
|
output of any job older than {{ $autoDelete }} hours. That is a hard delete, not an
|
||||||
|
archive: a conversion nobody downloaded in time is gone. Set it to 0 to
|
||||||
|
switch the cleanup off, and then size storage.amount for unbounded growth.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
STORAGE
|
||||||
|
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, {{ .Values.storage.amount }}, {{ if .Values.storage.storageClass }}storage class "{{ .Values.storage.storageClass }}"{{ else }}on the cluster's default storage class
|
||||||
|
(storage.storageClass is unset){{ end }}, mounted at /app/data.
|
||||||
|
|
||||||
|
Everything this release persists is on that one volume: the SQLite database
|
||||||
|
(users, jobs, file names), every uploaded file and every converted result.
|
||||||
|
There is no external database and no object storage, so one snapshot of the
|
||||||
|
PVC is a complete backup — and losing it loses the accounts along with the
|
||||||
|
files.
|
||||||
|
|
||||||
|
Because the PVC is rendered by the chart, helm uninstall deletes it and the
|
||||||
|
data with it, unless the storage class reclaim policy says otherwise.
|
||||||
|
{{- if gt (int $replicas) 1 }}
|
||||||
|
|
||||||
|
convertx.replicas is {{ $replicas }}, and that does not work. The PVC is ReadWriteOnce, so
|
||||||
|
the extra pods normally stay Pending on "Multi-Attach error for volume"; on
|
||||||
|
a genuinely RWX storage class they would start and then corrupt the SQLite
|
||||||
|
database two processes are now writing. Set it back to 1.
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
|
||||||
|
THE INGRESS IS THE ONLY UPLOAD CEILING
|
||||||
|
ingress.maxUploadFileSize {{ .Values.ingress.maxUploadFileSize }}
|
||||||
|
|
||||||
|
ConvertX enforces no size limit of its own — none, at any setting — so this
|
||||||
|
annotation is the whole story. A file above it never reaches the
|
||||||
|
application: the user gets the ingress controller's 413 page and nothing
|
||||||
|
appears in the pod log, which makes it look like a broken upload button
|
||||||
|
rather than a limit.
|
||||||
|
|
||||||
|
Raising it has a second cost: the upload lands on the PVC above, so a
|
||||||
|
ceiling far beyond storage.amount just moves the failure from a 413 to a
|
||||||
|
full volume.
|
||||||
|
{{- else }}
|
||||||
|
|
||||||
|
UPLOAD CEILING — NONE FROM THIS CHART
|
||||||
|
ingress.enabled is false, so the chart sets no body-size limit anywhere, and
|
||||||
|
ConvertX enforces none of its own. Whatever proxy you put in front brings
|
||||||
|
its ceiling with it — ingress-nginx defaults to 1m, small enough to look
|
||||||
|
like a broken upload button.
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
WHAT A GREEN POD PROVES
|
||||||
|
All three probes are HTTP GETs against /healthcheck, which ConvertX answers
|
||||||
|
with a static 200 and no authentication. It touches neither the database nor
|
||||||
|
the volume, so a green pod proves the process is serving — not that a
|
||||||
|
conversion works and not that the volume is writable.
|
||||||
|
|
||||||
|
It is deliberately NOT "/": with the defaults of this chart "/" answers 302,
|
||||||
|
to /setup while no account exists and to /login afterwards. A probe would
|
||||||
|
pass on that redirect, and pass on a different one from day two onwards.
|
||||||
|
|
||||||
|
The startup probe grants {{ $startup.failureThreshold }} × {{ $startup.periodSeconds }}s = {{ mul $startup.failureThreshold $startup.periodSeconds }}s. That budget is generous because
|
||||||
|
ConvertX shells out to about twenty converter binaries at startup just to
|
||||||
|
log their versions.
|
||||||
|
|
||||||
|
THE FIRST PULL IS SLOW, AND THAT IS NORMAL
|
||||||
|
The image is roughly 1.4 GiB compressed — LibreOffice, Calibre, ImageMagick,
|
||||||
|
ffmpeg, Inkscape and a TeX Live installation are all inside it. On a node
|
||||||
|
that has not seen it before, expect several minutes in ContainerCreating
|
||||||
|
before the startup probe budget above even begins. A pod that looks stuck
|
||||||
|
there is usually still pulling:
|
||||||
|
|
||||||
|
kubectl -n {{ .Release.Namespace }} describe pod {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0
|
||||||
|
|
||||||
|
THIS CHART BYPASSES THE IMAGE ENTRYPOINT
|
||||||
|
The image runs as root and drops privileges itself; this chart starts the
|
||||||
|
application directly (command: bun run dist/src/index.js) as uid 1000, with
|
||||||
|
fsGroup doing what the entrypoint's chown would have done. PUID, PGID and
|
||||||
|
UMASK therefore configure NOTHING here — setting them through convertx.env
|
||||||
|
has no effect at all, and the uid belongs in convertx.securityContext.pod.
|
||||||
|
|
||||||
|
The cost to know about: if upstream moves real work into that entrypoint,
|
||||||
|
this chart skips it silently. Re-read the upstream entrypoint.sh at every
|
||||||
|
app-version update.
|
||||||
|
{{- if .Values.convertx.env }}
|
||||||
|
|
||||||
|
EXTRA ENVIRONMENT VARIABLES ARE ACTIVE
|
||||||
|
convertx.env adds {{ len .Values.convertx.env }} variable(s), rendered after the ones the chart sets
|
||||||
|
itself. Two warnings that go with that block:
|
||||||
|
|
||||||
|
- WEBROOT moves every route of the application, /healthcheck included, so
|
||||||
|
it breaks all three probes while the app is perfectly healthy — and this
|
||||||
|
chart's Ingress still maps "/". Do not set it here.
|
||||||
|
- An entry repeating a name the chart already sets is additive, not a
|
||||||
|
replacement. At install yours wins; on an EXISTING release Kubernetes
|
||||||
|
merges env by name and the chart's own value survives instead, while helm
|
||||||
|
upgrade still reports success. Read it back off the StatefulSet.
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
|
||||||
|
EXTRA INGRESS ANNOTATIONS ARE ACTIVE
|
||||||
|
{{- range $key, $value := . }}
|
||||||
|
{{ $key }}: {{ $value }}
|
||||||
|
{{- end }}
|
||||||
|
These are merged with the cert-manager and proxy-body-size annotations the
|
||||||
|
chart always sets. Annotations are interpreted by the ingress controller, so
|
||||||
|
a misspelled key is silently ignored — it does not fail the render.
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
{{/*
|
||||||
|
Render the body of a container "resources:" block from a values dict of the form
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limitFactor: 3 # optional, default 3
|
||||||
|
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
|
||||||
|
limits: { ... } # optional, every entry optional
|
||||||
|
|
||||||
|
Rules (variant B, see issue #19):
|
||||||
|
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
|
||||||
|
to the Kubernetes key "ephemeral-storage").
|
||||||
|
- an explicitly set limit always wins.
|
||||||
|
- a missing memory / ephemeral-storage limit is computed as
|
||||||
|
limitFactor x the corresponding request.
|
||||||
|
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
|
||||||
|
unlimited; CPU is compressible, so this cannot kill the workload).
|
||||||
|
|
||||||
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
|
chart. template-chart/templates/_helpers.tpl is the reference implementation.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.resources" -}}
|
||||||
|
{{- $resources := . | default dict -}}
|
||||||
|
{{- $factor := 3.0 -}}
|
||||||
|
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
|
||||||
|
{{- $factor = float64 $resources.limitFactor -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $requests := dict -}}
|
||||||
|
{{- range $key, $value := ($resources.requests | default dict) -}}
|
||||||
|
{{- if not (kindIs "invalid" $value) -}}
|
||||||
|
{{- $_ := set $requests (include "convertx.resourceKey" $key) $value -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $limits := dict -}}
|
||||||
|
{{- range $key, $value := ($resources.limits | default dict) -}}
|
||||||
|
{{- if not (kindIs "invalid" $value) -}}
|
||||||
|
{{- $_ := set $limits (include "convertx.resourceKey" $key) $value -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- range $key, $request := $requests -}}
|
||||||
|
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
|
||||||
|
{{- $_ := set $limits $key (include "convertx.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $out := dict -}}
|
||||||
|
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
|
||||||
|
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
|
||||||
|
{{- toYaml $out -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Normalize a resource name from values to its Kubernetes key.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.resourceKey" -}}
|
||||||
|
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Multiply a Kubernetes quantity by a factor, keeping its suffix.
|
||||||
|
Input: dict "quantity" <string|number> "factor" <float64>.
|
||||||
|
Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
|
||||||
|
("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer
|
||||||
|
results are printed without a decimal point.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.multiplyQuantity" -}}
|
||||||
|
{{- $quantity := include "convertx.formatNumber" .quantity -}}
|
||||||
|
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
|
||||||
|
{{- if not $number -}}
|
||||||
|
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $suffix := trimPrefix $number $quantity -}}
|
||||||
|
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
|
||||||
|
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $product := mulf (float64 $number) .factor -}}
|
||||||
|
{{- if eq $product (floor $product) -}}
|
||||||
|
{{- printf "%d%s" (int64 $product) $suffix -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- printf "%s%s" (include "convertx.formatNumber" $product) $suffix -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Render a number as a plain decimal string, never in exponential notation.
|
||||||
|
|
||||||
|
Why this exists (issue #157): the null-safe fallback helpers pass values
|
||||||
|
through fromYaml, which parses every number as float64. Go's "%v" switches
|
||||||
|
float64 to exponential notation from about 1e6 upwards, so a bare byte count
|
||||||
|
like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the
|
||||||
|
render aborted. Strings ("512Mi") are passed through untouched.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.formatNumber" -}}
|
||||||
|
{{- if kindIs "float64" . -}}
|
||||||
|
{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- printf "%v" . -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Render the replica count for the StatefulSet.
|
||||||
|
|
||||||
|
Rules (issue #25):
|
||||||
|
- scaleDown: true takes precedence and always yields 0.
|
||||||
|
- otherwise an explicitly set replicas value is used as given, including an
|
||||||
|
explicit 0 - Helm's "default" would swallow 0, hence the explicit
|
||||||
|
kindIs "invalid" nil check.
|
||||||
|
- a missing replicas value defaults to 1.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.replicas" -}}
|
||||||
|
{{- if .scaleDown -}}
|
||||||
|
0
|
||||||
|
{{- else if not (kindIs "invalid" .replicas) -}}
|
||||||
|
{{- .replicas -}}
|
||||||
|
{{- else -}}
|
||||||
|
1
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Merge a user-supplied values sub-tree onto a dict of chart defaults so that a
|
||||||
|
null / empty override falls back to the defaults instead of erasing them.
|
||||||
|
|
||||||
|
Input: dict "defaults" <dict> "given" <dict|nil> "path" <string, optional
|
||||||
|
values path used in error messages>. Output: YAML of the merged dict (consume
|
||||||
|
it with fromYaml).
|
||||||
|
|
||||||
|
Why this exists (issues #61 and #99): Helm merges values maps recursively, but
|
||||||
|
an override key written without children - e.g. a leftover
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
|
||||||
|
after deleting its last remaining sub-key - is YAML null. Where the chart
|
||||||
|
defines a default, Helm treats that null as a deletion of the default; the
|
||||||
|
template then renders "securityContext: null": no readOnlyRootFilesystem, no
|
||||||
|
dropped capabilities, no allowPrivilegeEscalation: false. The workload starts
|
||||||
|
and reports healthy while being unhardened, so nothing points at the mistake.
|
||||||
|
This helper defines the opposite semantics: an empty block means "chart
|
||||||
|
defaults apply".
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.defaultedDict" -}}
|
||||||
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
{{- $given := .given -}}
|
||||||
|
{{- $path := .path | default "value" -}}
|
||||||
|
{{- if kindIs "invalid" $given -}}
|
||||||
|
{{- $given = dict -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if not (kindIs "map" $given) -}}
|
||||||
|
{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $out := deepCopy $defaults -}}
|
||||||
|
{{- range $key, $value := $given -}}
|
||||||
|
{{- if not (kindIs "invalid" $value) -}}
|
||||||
|
{{- $default := index $defaults $key -}}
|
||||||
|
{{- if and (kindIs "map" $value) (kindIs "map" $default) -}}
|
||||||
|
{{- $_ := set $out $key (fromYaml (include "convertx.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}}
|
||||||
|
{{- else if kindIs "map" $default -}}
|
||||||
|
{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $_ := set $out $key $value -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- toYaml $out -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Pick one sub-block out of a values block that may itself be null.
|
||||||
|
|
||||||
|
Input: dict "block" <dict|nil> "key" <string> "path" <string>.
|
||||||
|
Returns YAML of dict "value" <the sub-block, may be nil>, after rejecting a
|
||||||
|
non-mapping block with a message that names the values path. Needed because
|
||||||
|
".Values.convertx.securityContext" can itself be erased, and indexing into nil
|
||||||
|
inside a template is not an error but silently yields nothing.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.subBlock" -}}
|
||||||
|
{{- $block := .block -}}
|
||||||
|
{{- if kindIs "invalid" $block -}}
|
||||||
|
{{- $block = dict -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if not (kindIs "map" $block) -}}
|
||||||
|
{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- toYaml (dict "value" (index $block .key)) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The effective security contexts, probes and resources (issue #99).
|
||||||
|
|
||||||
|
These defaults MUST stay in sync with the corresponding blocks in values.yaml,
|
||||||
|
which stays the documented, user-facing place for them; this copy is only the
|
||||||
|
fallback for the case where an override has erased the block.
|
||||||
|
|
||||||
|
TWO OF THEM REPRODUCE A DELIBERATE DEVIATION AND MUST NOT BE "TIDIED":
|
||||||
|
|
||||||
|
- runAsUser / runAsGroup are 1000, not the repo's arbitrary 20000. The image
|
||||||
|
creates its convertx user at uid 1000 and owns /home/convertx with it, and
|
||||||
|
that uid owns the data on the PersistentVolume. Restoring the repo default
|
||||||
|
here would move the runtime uid off the volume's data.
|
||||||
|
- the probes point at /healthcheck, not at "/". "/" answers 302 in every
|
||||||
|
default configuration of this chart, and the redirect target changes once
|
||||||
|
the first account exists. The port name "http" is part of the context and
|
||||||
|
belongs here too: a default that loses it leaves the workload with a probe
|
||||||
|
that cannot succeed.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.podSecurityContext" -}}
|
||||||
|
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "pod" "path" "convertx.securityContext"))).value -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"runAsNonRoot" true
|
||||||
|
"runAsUser" 1000
|
||||||
|
"runAsGroup" 1000
|
||||||
|
"fsGroup" 1000
|
||||||
|
"fsGroupChangePolicy" "Always"
|
||||||
|
"seccompProfile" (dict "type" "RuntimeDefault") -}}
|
||||||
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.pod") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "convertx.containerSecurityContext" -}}
|
||||||
|
{{- $given := (fromYaml (include "convertx.subBlock" (dict "block" . "key" "container" "path" "convertx.securityContext"))).value -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"allowPrivilegeEscalation" false
|
||||||
|
"readOnlyRootFilesystem" true
|
||||||
|
"runAsNonRoot" true
|
||||||
|
"capabilities" (dict "drop" (list "ALL") "add" (list)) -}}
|
||||||
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" $given "path" "convertx.securityContext.container") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "convertx.livenessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 3 -}}
|
||||||
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.livenessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "convertx.readinessProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 3 -}}
|
||||||
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.readinessProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "convertx.startupProbe" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"httpGet" (dict "path" "/healthcheck" "port" "http")
|
||||||
|
"periodSeconds" 10
|
||||||
|
"timeoutSeconds" 5
|
||||||
|
"failureThreshold" 30 -}}
|
||||||
|
{{- include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.startupProbe") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
The effective resources block, fed into the resources helper above so that an
|
||||||
|
erased "resources:" still yields the chart's requests and computed limits
|
||||||
|
instead of a container without any resource accounting.
|
||||||
|
|
||||||
|
The numbers reproduce values.yaml, which carries the reasoning: they are sized
|
||||||
|
for converter subprocesses (LibreOffice, Calibre, ImageMagick, ffmpeg), not
|
||||||
|
for an idle web server, and the ephemeral-storage figure has to cover the two
|
||||||
|
emptyDirs this chart mounts.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.effectiveResources" -}}
|
||||||
|
{{- $defaults := dict
|
||||||
|
"limitFactor" 3
|
||||||
|
"requests" (dict "cpu" 0.25 "memory" "1Gi" "ephemeralStorage" "1Gi") -}}
|
||||||
|
{{- $merged := fromYaml (include "convertx.defaultedDict" (dict "defaults" $defaults "given" . "path" "convertx.resources")) -}}
|
||||||
|
{{- include "convertx.resources" $merged -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Render a values boolean as the literal string the application accepts.
|
||||||
|
|
||||||
|
Input: dict "value" <bool|string|nil> "path" <values path, for the error>
|
||||||
|
"default" <bool, used when the value is nil>
|
||||||
|
|
||||||
|
Why this is not just "{{ .Values.x }}" (README, "Values that must not lie",
|
||||||
|
point 6): ConvertX parses each of these variables as
|
||||||
|
|
||||||
|
process.env.X?.toLowerCase() === "true"
|
||||||
|
|
||||||
|
so ANYTHING that is not the literal "true" means false - "yes", "1", "on",
|
||||||
|
"TRUE " with a trailing space. Two of the three variables this helper renders
|
||||||
|
open the instance up (ACCOUNT_REGISTRATION, ALLOW_UNAUTHENTICATED), and a
|
||||||
|
value written to switch one of them ON that silently lands on OFF is the kind
|
||||||
|
of failure nobody goes looking for, because the symptom is the safe state.
|
||||||
|
|
||||||
|
So: a bool renders as "true"/"false", the strings "true"/"false" render
|
||||||
|
normalized to lower case, nil takes the given default, and everything else
|
||||||
|
ABORTS THE RENDER naming the values path. Go's "not" is deliberately unused
|
||||||
|
here - it treats every non-empty string as true, so "not \"false\"" is false
|
||||||
|
and the string form would flip the wrong way.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.boolString" -}}
|
||||||
|
{{- $v := .value -}}
|
||||||
|
{{- if kindIs "invalid" $v -}}
|
||||||
|
{{- $v = .default -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if kindIs "bool" $v -}}
|
||||||
|
{{- ternary "true" "false" $v -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $s := lower (printf "%v" $v) -}}
|
||||||
|
{{- if or (eq $s "true") (eq $s "false") -}}
|
||||||
|
{{- $s -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- fail (printf "%s must be true or false, got %q - ConvertX compares this variable against the literal string \"true\", so any other spelling would silently mean false" .path $v) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Render AUTO_DELETE_EVERY_N_HOURS.
|
||||||
|
|
||||||
|
An explicit 0 is the documented way to switch the cleanup off, so this cannot
|
||||||
|
go through Helm's "default": that swallows a 0 and would restore 24-hour
|
||||||
|
deletion on exactly the install that asked for none. Same nil-check shape as
|
||||||
|
the replicas helper, and for the same reason.
|
||||||
|
*/}}
|
||||||
|
{{- define "convertx.autoDeleteEveryNHours" -}}
|
||||||
|
{{- if kindIs "invalid" . -}}
|
||||||
|
24
|
||||||
|
{{- else -}}
|
||||||
|
{{- include "convertx.formatNumber" . -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }}
|
||||||
|
# ConvertX enforces no upload limit of its own (it starts its HTTP server
|
||||||
|
# with maxRequestBodySize: Number.MAX_SAFE_INTEGER), so these two
|
||||||
|
# annotations are the ONLY ceiling on an upload. Both spellings are set
|
||||||
|
# because the two nginx ingress controllers read different keys.
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.ingress.maxUploadFileSize }}
|
||||||
|
nginx.org/client-max-body-size: {{ .Values.ingress.maxUploadFileSize }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
rules:
|
||||||
|
- host: {{ required "A Domain/Host must be provided" .Values.ingress.domain }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- backend:
|
||||||
|
service:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ required "A Domain/Host must be provided" .Values.ingress.domain }}
|
||||||
|
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.storage.amount }}
|
||||||
|
{{- if .Values.storage.storageClass }}
|
||||||
|
storageClassName: {{ .Values.storage.storageClass }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: onepassword.com/v1
|
||||||
|
kind: OnePasswordItem
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
|
spec:
|
||||||
|
itemPath: {{ required "A Secret-Reference for the JWT signing key must be provided (secrets.jwtSecretRef)" .Values.secrets.jwtSecretRef }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: http
|
||||||
|
name: http
|
||||||
|
type: ClusterIP
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
labels:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
spec:
|
||||||
|
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||||
|
replicas: {{ include "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.replicas) }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
labels:
|
||||||
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
annotations:
|
||||||
|
# Hash of the rendered OnePasswordItem, so that repointing
|
||||||
|
# secrets.jwtSecretRef at a DIFFERENT vault item really rolls the pod
|
||||||
|
# instead of leaving the old signing key in the running environment.
|
||||||
|
# A change of the item's CONTENT inside the vault is not covered -
|
||||||
|
# Helm never sees it; see the comment on secrets in values.yaml.
|
||||||
|
# The name differs from checksum/config on purpose: that one promises
|
||||||
|
# "the config changed", this one only "the config source changed".
|
||||||
|
#
|
||||||
|
# operator.1password.io/auto-restart is deliberately NOT set: the
|
||||||
|
# 1Password operator only restarts Deployments, so on a StatefulSet it
|
||||||
|
# is a silent no-op that advertises a guarantee it does not give.
|
||||||
|
checksum/secret-ref: {{ include (print $.Template.BasePath "/convertx.secret.yaml") . | sha256sum }}
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
{{- include "convertx.podSecurityContext" .Values.convertx.securityContext | nindent 8 }}
|
||||||
|
containers:
|
||||||
|
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
|
image: "ghcr.io/c4illin/convertx:{{ .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# THE IMAGE ENTRYPOINT IS BYPASSED ON PURPOSE. READ THIS BEFORE THE
|
||||||
|
# NEXT APP-VERSION BUMP - IT IS THE ONE BET THIS CHART MAKES.
|
||||||
|
#
|
||||||
|
# ghcr.io/c4illin/convertx declares
|
||||||
|
# ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
# CMD ["bun", "run", "dist/src/index.js"]
|
||||||
|
# and carries no USER, so the image runs as root by default and
|
||||||
|
# /entrypoint.sh is what drops it to uid 1000 with gosu. Setting
|
||||||
|
# "command" here replaces the ENTRYPOINT (and discards the CMD), so
|
||||||
|
# the application is started directly and Kubernetes does the
|
||||||
|
# privilege work instead: runAsUser/runAsGroup 1000 in the pod
|
||||||
|
# security context, and fsGroup 1000 in place of the entrypoint's
|
||||||
|
# recursive chown of /app/data. The hardening baseline of this repo
|
||||||
|
# stays intact - no root, no capabilities, read-only root fs.
|
||||||
|
#
|
||||||
|
# What the entrypoint of v0.19.0 actually does, read rather than
|
||||||
|
# assumed: nothing but uid/gid plumbing. No migration, no config
|
||||||
|
# generation, no secret generation. ConvertX creates and migrates
|
||||||
|
# its SQLite schema in-process at import time, so starting it
|
||||||
|
# directly is migration-safe. Its very first branch is
|
||||||
|
# if [ -z "$PUID" ] || [ "$PUID" -eq 0 ]; then ... exec "$@"
|
||||||
|
# i.e. with PUID unset it is already a pure pass-through - which is
|
||||||
|
# why bypassing it costs nothing TODAY and is exactly why the risk
|
||||||
|
# below is about tomorrow. The one line we do lose is
|
||||||
|
# "export HOME=/home/convertx", and this chart sets HOME itself
|
||||||
|
# below rather than relying on the runtime's passwd lookup.
|
||||||
|
#
|
||||||
|
# THE RISK: if upstream ever moves real work into the entrypoint - a
|
||||||
|
# data migration is the obvious candidate - this chart skips it
|
||||||
|
# silently. There is no error, no log line and no failed probe; the
|
||||||
|
# application simply starts against an unmigrated volume. So
|
||||||
|
# RE-READ entrypoint.sh AT EVERY APP-VERSION UPDATE, not only on
|
||||||
|
# major jumps, and if it has grown anything beyond uid/gid handling,
|
||||||
|
# stop and reconsider this override.
|
||||||
|
command:
|
||||||
|
- bun
|
||||||
|
- run
|
||||||
|
- dist/src/index.js
|
||||||
|
|
||||||
|
# Not decoration. The application resolves its data paths relative
|
||||||
|
# to the working directory - uploadsDir is "./data/uploads/" and
|
||||||
|
# outputDir is "./data/output/", hardcoded and configurable by no
|
||||||
|
# environment variable. The image sets WORKDIR /app, so this only
|
||||||
|
# restates it; it is here because a different working directory
|
||||||
|
# would put every upload somewhere other than the mounted PVC, and
|
||||||
|
# the application would report no error at all.
|
||||||
|
workingDir: /app
|
||||||
|
|
||||||
|
env:
|
||||||
|
# The entrypoint would have exported this; the image declares no
|
||||||
|
# ENV HOME, so without this line HOME comes from the container
|
||||||
|
# runtime's /etc/passwd lookup for uid 1000. That happens to
|
||||||
|
# resolve to /home/convertx in this image - and "happens to" is
|
||||||
|
# not a basis for the directory LibreOffice, Calibre, Inkscape and
|
||||||
|
# fontconfig write their profiles into on the first conversion.
|
||||||
|
# Set explicitly, and mounted writable below.
|
||||||
|
- name: HOME
|
||||||
|
value: "/home/convertx"
|
||||||
|
# The port the application binds. containerPort, the Service's
|
||||||
|
# targetPort and the three probes all name the same 3000; moving
|
||||||
|
# it means moving all four, which is why it is pinned here rather
|
||||||
|
# than exposed as a value.
|
||||||
|
- name: PORT
|
||||||
|
value: "3000"
|
||||||
|
# Signs the session cookies. Without it ConvertX falls back to
|
||||||
|
# randomUUID() per process, so every restart logs everyone out -
|
||||||
|
# see secrets.jwtSecretRef in values.yaml.
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
|
key: jwt-secret
|
||||||
|
# The three booleans go through convertx.boolString, which renders
|
||||||
|
# the literal "true"/"false" the application's strict comparison
|
||||||
|
# needs and aborts the render on anything else.
|
||||||
|
- name: ACCOUNT_REGISTRATION
|
||||||
|
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") }}"
|
||||||
|
- name: ALLOW_UNAUTHENTICATED
|
||||||
|
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") }}"
|
||||||
|
- name: HTTP_ALLOWED
|
||||||
|
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") }}"
|
||||||
|
- name: AUTO_DELETE_EVERY_N_HOURS
|
||||||
|
value: "{{ include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours }}"
|
||||||
|
{{- range $value := .Values.convertx.env }}
|
||||||
|
{{- if $value.value }}
|
||||||
|
- name: {{ $value.name | quote }}
|
||||||
|
# quote is required, not cosmetic: a value like "8080" or "true"
|
||||||
|
# renders unquoted as a YAML number/bool, and the API server then
|
||||||
|
# rejects the whole manifest.
|
||||||
|
value: {{ tpl $value.value $ | quote }}
|
||||||
|
{{- else if $value.valueFrom }}
|
||||||
|
{{- if $value.valueFrom.secretKeyRef }}
|
||||||
|
- name: {{ $value.name }}
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
|
||||||
|
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
|
||||||
|
{{- else if $value.valueFrom.configMapKeyRef }}
|
||||||
|
- name: {{ $value.name }}
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
|
||||||
|
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
# Probes, resources and both security contexts go through the
|
||||||
|
# null-safe helpers (#99): an erased block means "chart defaults
|
||||||
|
# apply" instead of a vanished probe or an unhardened container.
|
||||||
|
# Never wrap these in an "if" - a guard hides the erased case
|
||||||
|
# completely instead of rendering a visible null.
|
||||||
|
livenessProbe:
|
||||||
|
{{- include "convertx.livenessProbe" .Values.convertx.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- include "convertx.readinessProbe" .Values.convertx.readinessProbe | nindent 12 }}
|
||||||
|
startupProbe:
|
||||||
|
{{- include "convertx.startupProbe" .Values.convertx.startupProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- include "convertx.effectiveResources" .Values.convertx.resources | nindent 12 }}
|
||||||
|
securityContext:
|
||||||
|
{{- include "convertx.containerSecurityContext" .Values.convertx.securityContext | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
# SQLite database, uploads and converted output - everything the
|
||||||
|
# application persists lives under this one path.
|
||||||
|
- mountPath: /app/data
|
||||||
|
name: data
|
||||||
|
# Two writable emptyDirs so the root filesystem can stay
|
||||||
|
# read-only. Neither is a precaution: ConvertX itself writes only
|
||||||
|
# under /app/data, but it runs its conversions as subprocesses
|
||||||
|
# (soffice, ebook-convert, magick, gs, xelatex, ffmpeg...) that
|
||||||
|
# inherit its environment, and those need scratch space.
|
||||||
|
#
|
||||||
|
# /home/convertx is HOME. LibreOffice creates its user profile
|
||||||
|
# there on first use - it is invoked without
|
||||||
|
# -env:UserInstallation, so there is no way to point it elsewhere
|
||||||
|
# - and Calibre, Inkscape, ImageMagick and fontconfig do the same
|
||||||
|
# with their own config and cache directories. The directory
|
||||||
|
# exists in the image (useradd -m) and is owned by 1000:1000, but
|
||||||
|
# it sits on the read-only root filesystem, so it has to be
|
||||||
|
# overmounted to be usable.
|
||||||
|
- mountPath: /home/convertx
|
||||||
|
name: home
|
||||||
|
# /tmp is the scratch directory of the toolchain: Ghostscript,
|
||||||
|
# TeX Live and Calibre all use it during a conversion.
|
||||||
|
#
|
||||||
|
# Both of these count against the container's ephemeral-storage
|
||||||
|
# limit - see the resources comment in values.yaml.
|
||||||
|
- mountPath: /tmp
|
||||||
|
name: tmp
|
||||||
|
restartPolicy: Always
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||||
|
- name: home
|
||||||
|
emptyDir: {}
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "convertx values",
|
||||||
|
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key that the chart does not read is rejected at render time instead of being silently ignored. The only objects that stay open are the ones passed verbatim into the Kubernetes pod spec (probes, security contexts, global) and the free-form Ingress annotation map - the chart does not interpret their contents, so it cannot judge which keys are meaningful. The hardening, probe and resources blocks additionally accept null, because an erased block falls back to the chart defaults (issue #99).",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"global": {
|
||||||
|
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"scaleDown": {
|
||||||
|
"description": "true renders the StatefulSet with replicas: 0 and overrides convertx.replicas. PVC, Secret, Service and Ingress stay in place.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"secrets": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"jwtSecretRef": {
|
||||||
|
"description": "REQUIRED. op:// path of the 1Password item carrying the field 'jwt-secret', which becomes JWT_SECRET. Enforced by required() in the template rather than by a 'required' array here: the key ships as null, so JSON Schema's required - which tests presence, not null-ness - could never fire on it.",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"convertx": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"replicas": {
|
||||||
|
"description": "0 is allowed and scales the workload down. Values above 1 are accepted by the schema but do not work: the SQLite database and the uploads share one ReadWriteOnce PVC.",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"accountRegistration": {
|
||||||
|
"description": "ACCOUNT_REGISTRATION - lets anyone with the URL create an account. Defaults to false; upstream warns against enabling it on an internet-facing instance. A string is accepted for the 'true'/'false' spellings only; anything else aborts the render with a message naming this path, because the application would silently read it as false.",
|
||||||
|
"type": ["boolean", "string", "null"]
|
||||||
|
},
|
||||||
|
"allowUnauthenticated": {
|
||||||
|
"description": "ALLOW_UNAUTHENTICATED - drops authentication entirely. Defaults to false; upstream describes this as local-only. Same accepted spellings as accountRegistration.",
|
||||||
|
"type": ["boolean", "string", "null"]
|
||||||
|
},
|
||||||
|
"httpAllowed": {
|
||||||
|
"description": "HTTP_ALLOWED - clears the 'secure' flag on the session cookies. Only needed when ConvertX is served without TLS in front; behind this chart's Ingress the browser speaks https, so the default false is correct. Same accepted spellings as accountRegistration.",
|
||||||
|
"type": ["boolean", "string", "null"]
|
||||||
|
},
|
||||||
|
"autoDeleteEveryNHours": {
|
||||||
|
"description": "AUTO_DELETE_EVERY_N_HOURS - how old a job may get before ConvertX deletes its uploads and output. 0 disables the cleanup and is honoured as such (the chart uses a nil check, not Helm's 'default', which would swallow the 0). This is the retention policy for the PVC.",
|
||||||
|
"type": ["integer", "number", "null"],
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"securityContext": {
|
||||||
|
"$ref": "#/definitions/securityContext"
|
||||||
|
},
|
||||||
|
"livenessProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"readinessProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"startupProbe": {
|
||||||
|
"$ref": "#/definitions/probe"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"$ref": "#/definitions/env"
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"$ref": "#/definitions/resources"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"description": "The data PVC: SQLite database, uploads and converted output. Not null-safe on purpose - an erased block yields a PVC without a size, which the API server rejects at apply time rather than the chart sizing the volume silently.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"storageClass": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ingress": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"description": "Gates the whole Ingress template. false frees BOTH clusterIssuer and domain: nothing outside the Ingress reads the domain in this chart, because ConvertX has no base-URL or redirect-URI variable.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"maxUploadFileSize": {
|
||||||
|
"description": "Rendered into the ingress-nginx proxy-body-size annotation, so it only takes effect while the Ingress is enabled. It is the only upload ceiling that exists - ConvertX enforces none of its own.",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"clusterIssuer": {
|
||||||
|
"description": "REQUIRED while ingress.enabled is true (enforced by required() in the template).",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"description": "REQUIRED while ingress.enabled is true (enforced by required() in the template).",
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"className": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"description": "Extra Ingress annotations, merged with the cert-manager and proxy-body-size annotations the chart always sets. Free-form keys by nature.",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"resources": {
|
||||||
|
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"limitFactor": {
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0
|
||||||
|
},
|
||||||
|
"requests": {
|
||||||
|
"$ref": "#/definitions/resourceList"
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"$ref": "#/definitions/resourceList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resourceList": {
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"cpu": {
|
||||||
|
"type": ["string", "number", "null"]
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"type": ["string", "number", "null"]
|
||||||
|
},
|
||||||
|
"ephemeral-storage": {
|
||||||
|
"type": ["string", "number", "null"]
|
||||||
|
},
|
||||||
|
"ephemeralStorage": {
|
||||||
|
"type": ["string", "number", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"valueFrom": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"secretKeyRef": {
|
||||||
|
"$ref": "#/definitions/keyRef"
|
||||||
|
},
|
||||||
|
"configMapKeyRef": {
|
||||||
|
"$ref": "#/definitions/keyRef"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keyRef": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "key"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securityContext": {
|
||||||
|
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99) instead of rendering an unhardened workload. Those defaults pin runAsUser/runAsGroup 1000 - the uid the image creates and the uid that owns the data on the PersistentVolume - rather than the repo's arbitrary 20000.",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"pod": {
|
||||||
|
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||||
|
"type": ["object", "null"]
|
||||||
|
},
|
||||||
|
"container": {
|
||||||
|
"description": "Merged onto the chart's container hardening defaults and rendered as the container's securityContext; kept open because the chart does not interpret the individual fields.",
|
||||||
|
"type": ["object", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"probe": {
|
||||||
|
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The defaults target /healthcheck on the port named http - overriding the path to '/' would probe a 302 whose target changes once the first account exists.",
|
||||||
|
"type": ["object", "null"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Scale the whole chart down without uninstalling it: scaleDown: true renders
|
||||||
|
# every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and
|
||||||
|
# takes precedence over any per-workload replicas value. PVC, Secret, Service
|
||||||
|
# and Ingress stay in place; scaleDown: false brings the workload back up.
|
||||||
|
scaleDown: false
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
# op:// item path for the JWT signing key. REQUIRED - the chart aborts the
|
||||||
|
# render without it, and that is deliberate rather than convenient.
|
||||||
|
#
|
||||||
|
# ConvertX signs its session cookies with JWT_SECRET and falls back to
|
||||||
|
# randomUUID() when the variable is unset (src/services/user.ts:
|
||||||
|
# "secret: process.env.JWT_SECRET ?? randomUUID()"). That fallback is
|
||||||
|
# per PROCESS, not per install: every restart - a node drain, an image
|
||||||
|
# update, an OOM kill - mints a fresh secret and silently invalidates every
|
||||||
|
# session. Nothing logs it; users simply find themselves logged out. A value
|
||||||
|
# that has to survive a restart is a secret, so it is one here.
|
||||||
|
#
|
||||||
|
# The item must carry this field, spelled exactly like this, because the
|
||||||
|
# 1Password operator copies an item's FIELD NAMES into the Secret's keys:
|
||||||
|
#
|
||||||
|
# jwt-secret a long random string (openssl rand -hex 32)
|
||||||
|
#
|
||||||
|
# A field spelled differently does not fail the install: the operator writes
|
||||||
|
# a Secret without that key and the pod stays in CreateContainerConfigError,
|
||||||
|
# which looks like a broken chart rather than a typo in a vault item.
|
||||||
|
jwtSecretRef: null
|
||||||
|
|
||||||
|
convertx:
|
||||||
|
# Replica count for this workload; 0 is allowed and scales it down.
|
||||||
|
# The chart-global scaleDown flag takes precedence.
|
||||||
|
#
|
||||||
|
# Raising this above 1 does not work and the chart does not stop you, so the
|
||||||
|
# reason is here: the SQLite database, the uploads and the converted output
|
||||||
|
# all live on the single ReadWriteOnce PVC below, which is a plain chart
|
||||||
|
# object rather than a volumeClaimTemplate. A second pod normally never
|
||||||
|
# schedules ("Multi-Attach error for volume"); on a genuinely RWX storage
|
||||||
|
# class it would schedule and then corrupt the WAL-mode SQLite file that two
|
||||||
|
# processes are now writing. On top of that each pod would sign cookies with
|
||||||
|
# its own JWT_SECRET only if the secret were unset - which is exactly why
|
||||||
|
# secrets.jwtSecretRef above is required.
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
# --- Two switches that open the instance up. Both default to false. ---
|
||||||
|
#
|
||||||
|
# UPSTREAM WARNS EXPLICITLY AGAINST ENABLING EITHER ON AN INTERNET-FACING
|
||||||
|
# INSTANCE, and this chart puts an Ingress with a public DNS name in front
|
||||||
|
# by default, so that warning applies to the standard install here.
|
||||||
|
#
|
||||||
|
# Both are parsed strictly by the application: it compares the lowercased
|
||||||
|
# string against "true" and treats everything else as false. The chart
|
||||||
|
# therefore accepts only a boolean or the strings "true"/"false" (any case)
|
||||||
|
# and ABORTS THE RENDER on anything else - "yes" or "1" would otherwise be
|
||||||
|
# filed under "off" without a word, which is the wrong direction to fail in
|
||||||
|
# for a value whose whole job is to open the service up.
|
||||||
|
|
||||||
|
# Lets anyone with the URL create an account. Note what it does NOT gate:
|
||||||
|
# the FIRST account is always creatable, because a ConvertX with no users
|
||||||
|
# serves a setup form to whoever opens the page first. Keeping this false
|
||||||
|
# protects accounts two and onwards, not account one.
|
||||||
|
accountRegistration: false
|
||||||
|
|
||||||
|
# Drops authentication entirely - anyone reaching the Service can upload
|
||||||
|
# files and read every conversion. Upstream describes this as local-only.
|
||||||
|
allowUnauthenticated: false
|
||||||
|
|
||||||
|
# Clears the "secure" flag on the session cookies (the app computes
|
||||||
|
# "secure: !HTTP_ALLOWED"). Only needed when ConvertX is served over plain
|
||||||
|
# http, and that is NOT our case: the Ingress terminates TLS, so the browser
|
||||||
|
# speaks https and sets the cookie happily while only the in-cluster hop
|
||||||
|
# between ingress controller and pod is http - which the browser never sees.
|
||||||
|
# Turning this on behind our Ingress weakens the cookies for no gain.
|
||||||
|
# (Same shape as outline's forceHttps, and the same reason to leave it off.)
|
||||||
|
httpAllowed: false
|
||||||
|
|
||||||
|
# How often ConvertX deletes jobs - uploads and converted output - older
|
||||||
|
# than this many hours. 0 disables the cleanup entirely.
|
||||||
|
#
|
||||||
|
# This is the retention policy for the PVC below, which is why it is a value
|
||||||
|
# and not left at the upstream default. With cleanup disabled the volume
|
||||||
|
# grows with every conversion and nothing reclaims it; size storage.amount
|
||||||
|
# for that case, or accept that the pod eventually fills its volume.
|
||||||
|
#
|
||||||
|
# An explicit 0 is honoured (the chart uses a nil check, not Helm's
|
||||||
|
# "default", which would swallow the 0 and silently restore 24-hour
|
||||||
|
# deletion on a value set precisely to switch it off).
|
||||||
|
autoDeleteEveryNHours: 24
|
||||||
|
|
||||||
|
# DOCUMENTED DEVIATION FROM THE HARDENING BASELINE - runAsUser/runAsGroup.
|
||||||
|
#
|
||||||
|
# The deviation is about the NUMBER only: nothing in the baseline is
|
||||||
|
# relaxed. The repo default is an arbitrary uid (20000); this chart pins
|
||||||
|
# 1000, and the reason is the image, not a preference.
|
||||||
|
#
|
||||||
|
# ghcr.io/c4illin/convertx carries NO USER instruction - verified in the
|
||||||
|
# published v0.19.0 image config, where "User" is empty - so without a
|
||||||
|
# securityContext the pod would run as root. Its Dockerfile creates
|
||||||
|
# "convertx" with uid 1000, gid 1000 and home /home/convertx (useradd -u
|
||||||
|
# 1000 -g convertx -d /home/convertx -m), and the image's entrypoint exists
|
||||||
|
# only to drop to that user with gosu.
|
||||||
|
#
|
||||||
|
# 1000 rather than 20000 for two reasons that both have to hold:
|
||||||
|
# - /home/convertx belongs to 1000:1000 in the image, and HOME is where
|
||||||
|
# LibreOffice, Calibre and fontconfig put their profiles. An arbitrary
|
||||||
|
# uid gets a HOME of "/" from the runtime's passwd lookup instead.
|
||||||
|
# - the uid owns data on a PersistentVolume, so changing it later makes
|
||||||
|
# existing conversions inaccessible. This number is not free to tidy.
|
||||||
|
securityContext:
|
||||||
|
pod:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
# fsGroup is what replaces the entrypoint's recursive chown of
|
||||||
|
# /app/data: the kubelet applies the group to the mounted volume, so
|
||||||
|
# the container can write it as uid 1000 without any privileged step.
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: Always
|
||||||
|
# RuntimeDefault applies the container runtime's default seccomp filter,
|
||||||
|
# which blocks the syscalls a normal workload never makes. It sits in the
|
||||||
|
# pod context on purpose: from here it covers every container in the pod
|
||||||
|
# including init containers, while a container-level seccompProfile would
|
||||||
|
# have to be repeated for each one (and silently missed on the next one
|
||||||
|
# added). A container that genuinely needs a different filter can still
|
||||||
|
# override it under securityContext.container, which wins over this value.
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
container:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
add: []
|
||||||
|
|
||||||
|
# ConvertX serves GET /healthcheck: a static 200 with {"status":"ok"},
|
||||||
|
# explicitly exempted from authentication (src/pages/healthcheck.tsx,
|
||||||
|
# "auth: false"). All three probes use it.
|
||||||
|
#
|
||||||
|
# Why NOT "/", the obvious default: with the chart defaults "/" is never a
|
||||||
|
# 200. It answers 302 to /setup while no user exists and 302 to /login
|
||||||
|
# afterwards, so a probe against it would pass on a redirect whose target
|
||||||
|
# changes the moment somebody registers - a green signal that means
|
||||||
|
# something different on day two than on day one. httpGet does count 3xx as
|
||||||
|
# success, which is what makes that the quiet kind of wrong.
|
||||||
|
#
|
||||||
|
# What the endpoint proves, and what it does not: the handler touches
|
||||||
|
# neither the database nor the volume, so it cannot report a broken PVC.
|
||||||
|
# It is still the right readiness target here, because the SQLite database
|
||||||
|
# is opened at module import - before app.listen() is reached - so a process
|
||||||
|
# that answers /healthcheck at all has already opened its database. There is
|
||||||
|
# no external dependency in this chart to hang readiness on; the "database"
|
||||||
|
# is a file on the pod's own volume.
|
||||||
|
#
|
||||||
|
# WEBROOT SHIFTS THIS PATH. The application mounts every route under the
|
||||||
|
# WEBROOT prefix, /healthcheck included. This chart does not offer WEBROOT
|
||||||
|
# as a value (see the env block below), and setting it through env without
|
||||||
|
# also moving these three paths leaves the pod probing a 404 forever.
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthcheck
|
||||||
|
port: http
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthcheck
|
||||||
|
port: http
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
# The startup probe gates liveness/readiness, so those need no
|
||||||
|
# initialDelaySeconds. Budget = periodSeconds * failureThreshold = 300s.
|
||||||
|
#
|
||||||
|
# Generous on purpose. At import time - under NODE_ENV=production, which
|
||||||
|
# the image sets - ConvertX shells out to roughly twenty converter binaries
|
||||||
|
# to log their versions (soffice, ebook-convert, xelatex, magick, ffmpeg,
|
||||||
|
# markitdown and the rest). Those are fire-and-forget and do not block
|
||||||
|
# app.listen(), but they all contend for the pod's CPU at exactly the moment
|
||||||
|
# the first probe arrives, and the first soffice/ebook-convert run also
|
||||||
|
# builds its profile under HOME. A tight budget turns a slow node into a
|
||||||
|
# crash loop.
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthcheck
|
||||||
|
port: http
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 30
|
||||||
|
|
||||||
|
# Extra environment variables, appended to the ones the chart always sets.
|
||||||
|
# Values are rendered through tpl; value, secretKeyRef and configMapKeyRef
|
||||||
|
# forms are supported.
|
||||||
|
#
|
||||||
|
# This is where the upstream variables this chart does not expose as values
|
||||||
|
# belong - BRANDING, LANGUAGE, HIDE_HISTORY, MAX_CONVERT_PROCESS,
|
||||||
|
# UNAUTHENTICATED_USER_SHARING, TZ, FFMPEG_ARGS, FFMPEG_OUTPUT_ARGS. They
|
||||||
|
# change the UI or a converter's behaviour and nothing this chart renders,
|
||||||
|
# so they need no value of their own.
|
||||||
|
#
|
||||||
|
# THREE VARIABLES DO NOT BELONG HERE, and they fail in three different ways:
|
||||||
|
#
|
||||||
|
# WEBROOT moves EVERY route, /healthcheck included, so the three
|
||||||
|
# probes above start failing against a 404 while the app is
|
||||||
|
# perfectly healthy - and this chart's Ingress still maps "/".
|
||||||
|
# Serving under a sub-path needs the probe paths and the
|
||||||
|
# Ingress path changed together; the chart does not do that
|
||||||
|
# for you.
|
||||||
|
# PUID/PGID are read by the image entrypoint, which this chart bypasses
|
||||||
|
# /UMASK (see the command: override in the StatefulSet). They reach
|
||||||
|
# nothing and configure nothing here. Set the uid through
|
||||||
|
# securityContext.pod above, which is what actually applies.
|
||||||
|
# PORT the app would listen on it, but containerPort, the Service
|
||||||
|
# and the probes all still name 3000. Moving the port means
|
||||||
|
# moving all four, so the chart pins it instead.
|
||||||
|
#
|
||||||
|
# Additive means additive: an entry repeating a name the chart already sets
|
||||||
|
# does not replace it. At install the later entry - yours - wins; on an
|
||||||
|
# EXISTING release Kubernetes merges env by name, the pair collapses to the
|
||||||
|
# chart's own value, and helm upgrade still reports success. Read the value
|
||||||
|
# back off the StatefulSet rather than trusting a green upgrade.
|
||||||
|
env: []
|
||||||
|
|
||||||
|
resources:
|
||||||
|
# Limits are optional: memory and ephemeral-storage limits default to
|
||||||
|
# limitFactor x the request, an explicitly set limit always wins, and a
|
||||||
|
# CPU limit is only set when configured explicitly under "limits".
|
||||||
|
#
|
||||||
|
# The numbers are sized for conversions, not for an idle web server, and
|
||||||
|
# each one has a reason:
|
||||||
|
#
|
||||||
|
# cpu 0.25 An idle Bun process uses almost nothing, but a
|
||||||
|
# conversion saturates whatever it can get and the startup
|
||||||
|
# fires ~20 version probes at once. Per repo convention no
|
||||||
|
# CPU LIMIT is computed, so this is the scheduling floor
|
||||||
|
# and the throttling share under contention, not a cap -
|
||||||
|
# a conversion may burst well past it on an idle node.
|
||||||
|
#
|
||||||
|
# memory 1Gi soffice --headless is a few hundred MB on its own,
|
||||||
|
# ebook-convert pulls in Qt WebEngine, and ImageMagick on
|
||||||
|
# a large raster can want more than either. 1Gi requested,
|
||||||
|
# 3Gi limit (limitFactor 3). Memory is NOT compressible:
|
||||||
|
# a conversion that crosses the limit is an OOM kill of
|
||||||
|
# the whole pod, so raise the request before feeding the
|
||||||
|
# instance large video or raw-image work.
|
||||||
|
#
|
||||||
|
# ephemeral- 1Gi, and this one is easy to get wrong: EMPTYDIR VOLUMES
|
||||||
|
# storage COUNT AGAINST IT. The chart mounts two (/tmp and
|
||||||
|
# /home/convertx) for the converter toolchain, and
|
||||||
|
# Ghostscript, TeX and Calibre all scratch to /tmp. The
|
||||||
|
# uploads and the converted output do NOT count - those
|
||||||
|
# live on the PVC - so the figure covers scratch space
|
||||||
|
# only. Crossing the 3Gi limit evicts the pod.
|
||||||
|
limitFactor: 3
|
||||||
|
requests:
|
||||||
|
cpu: 0.25
|
||||||
|
memory: 1Gi
|
||||||
|
ephemeralStorage: 1Gi
|
||||||
|
|
||||||
|
# The volume behind /app/data: the SQLite database, every uploaded file and
|
||||||
|
# every converted result. There is no second store and no object storage.
|
||||||
|
#
|
||||||
|
# Not null-safe on purpose, following the repo's documented exception: an
|
||||||
|
# erased block yields a PVC without a size, which the API server rejects at
|
||||||
|
# apply time. That is louder than a volume quietly sized by the chart.
|
||||||
|
storage:
|
||||||
|
amount: 10Gi
|
||||||
|
storageClass: null
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
# Gates the whole Ingress template. Checked in both directions as the style
|
||||||
|
# guide requires: NOTHING outside the Ingress reads ingress.domain in this
|
||||||
|
# chart - ConvertX has no base-URL, public-URL or redirect-URI variable at
|
||||||
|
# all (verified against the complete env list in src/helpers/env.ts) - so
|
||||||
|
# switching the Ingress off frees BOTH domain and clusterIssuer here.
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# THE ONLY UPLOAD CEILING THERE IS.
|
||||||
|
#
|
||||||
|
# ConvertX sets no size limit of its own: it starts Elysia with
|
||||||
|
# maxRequestBodySize: Number.MAX_SAFE_INTEGER, and v0.19.0 has no upload
|
||||||
|
# variable of any kind. So unlike outline - where an app limit and an
|
||||||
|
# ingress limit sat a factor of ten apart and the smaller one won
|
||||||
|
# confusingly - there is exactly one number here, and it is this one.
|
||||||
|
#
|
||||||
|
# Two consequences worth knowing before changing it:
|
||||||
|
# - A file above this limit never reaches ConvertX. The user gets the
|
||||||
|
# ingress controller's 413 page, not a message from the application,
|
||||||
|
# and nothing appears in the pod log.
|
||||||
|
# - With ingress.enabled: false there is NO ceiling from this chart at
|
||||||
|
# all, and whatever proxy you put in front brings its own - usually
|
||||||
|
# ingress-nginx's 1m default, which is small enough to look like a
|
||||||
|
# broken upload button.
|
||||||
|
#
|
||||||
|
# Defaulted generously because the point of the application is converting
|
||||||
|
# video and large documents. What it costs: the body transits the ingress
|
||||||
|
# controller and then lands on the PVC, so raising it means checking
|
||||||
|
# storage.amount as well.
|
||||||
|
maxUploadFileSize: 1024m
|
||||||
|
|
||||||
|
clusterIssuer: null
|
||||||
|
domain: null
|
||||||
|
className: nginx
|
||||||
|
# Extra annotations for the Ingress; merged with the cert-manager
|
||||||
|
# cluster-issuer and proxy-body-size annotations the chart always sets.
|
||||||
|
annotations: {}
|
||||||
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/gchq/CyberChef/master/src/web/static/ima
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 9.0.1+up11.4.0
|
version: 9.0.2+up11.4.0
|
||||||
appVersion: "11.4.0"
|
appVersion: "11.4.0"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "cyberchef.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "cyberchef.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "cyberchef.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "cyberchef.defaultedDict" -}}
|
{{- define "cyberchef.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
+12
-10
@@ -1,23 +1,25 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: freesailarr
|
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
|
# 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
|
# the same false "main app" the versioning deliberately avoids. The field is
|
||||||
# optional, so it is omitted rather than filled with an arbitrary choice.
|
# optional, so it is omitted rather than filled with an arbitrary choice.
|
||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 3.0.3+up1.0.0
|
version: 3.1.1+up1.0.0
|
||||||
# The chart's own version marker, freely chosen and tied to no upstream. The
|
# 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
|
# stack bundles ten independently versioned projects (nine of them on by
|
||||||
# "app version" to track here.
|
# 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
|
# 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. Nothing
|
# values.yaml, where Renovate's helm-values manager proposes them - except
|
||||||
# rewrites this field automatically - the chart has no app-version block in
|
# mediathekarr, which is pinned by digest and has its own customManagers block
|
||||||
# renovate.json5 and is not in the change-app-version.yml choice list. Raise it
|
# in renovate.json5, because its tags carry no version. Nothing rewrites this
|
||||||
# by hand when you want to mark a new edition of the chart as a whole.
|
# 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"
|
appVersion: "1.0.0"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{{- $sharedNames := list -}}
|
{{- $sharedNames := list -}}
|
||||||
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
|
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
|
||||||
freesailarr — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
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
|
NOTHING IS READY UNTIL THE TUNNEL IS UP
|
||||||
gluetun's readiness probe is its health server, which answers 200 only while
|
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.
|
WebUI before starting any download.
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- 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
|
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
|
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:
|
resources:
|
||||||
{{- include "freesailarr.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "freesailarr.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. The reference implementation lives in template-chart.
|
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" -}}
|
{{- define "freesailarr.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -135,7 +136,7 @@ implementation lives in template-chart.
|
|||||||
{{/*
|
{{/*
|
||||||
Render additive env entries (the repo-wide <app>.env convention: tpl-rendered,
|
Render additive env entries (the repo-wide <app>.env convention: tpl-rendered,
|
||||||
supporting the value, secretKeyRef and configMapKeyRef forms). Shared as a
|
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:
|
Usage:
|
||||||
{{- include "freesailarr.env" (dict "root" $ "env" .Values.<app>.env) | nindent 12 }}
|
{{- 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
|
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
|
listens on 8191 and therefore gets a service - but it has no ingress block in
|
||||||
values and is never exposed from outside the cluster.
|
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" -}}
|
{{- define "freesailarr.portApps" -}}
|
||||||
{{- $apps := list -}}
|
{{- $apps := list -}}
|
||||||
@@ -186,7 +195,8 @@ values and is never exposed from outside the cluster.
|
|||||||
(dict "name" "radarr" "port" 7878)
|
(dict "name" "radarr" "port" 7878)
|
||||||
(dict "name" "sonarr" "port" 8989)
|
(dict "name" "sonarr" "port" 8989)
|
||||||
(dict "name" "seerr" "port" 5055)
|
(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 -}}
|
{{- if (index $.Values $app.name).enabled -}}
|
||||||
{{- $apps = append $apps $app -}}
|
{{- $apps = append $apps $app -}}
|
||||||
{{- end -}}
|
{{- 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".
|
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
|
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
|
"/<app>", and to "/" for the two apps that can only live at the root - seerr,
|
||||||
therefore only live at the root.
|
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>.
|
Input: dict "root" $ "app" <app name>.
|
||||||
*/}}
|
*/}}
|
||||||
@@ -226,7 +237,7 @@ Input: dict "root" $ "app" <app name>.
|
|||||||
{{- $path = $config.path -}}
|
{{- $path = $config.path -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- if or (kindIs "invalid" $path) (eq (toString $path) "") -}}
|
{{- if or (kindIs "invalid" $path) (eq (toString $path) "") -}}
|
||||||
{{- if eq .app "seerr" -}}
|
{{- if has .app (list "seerr" "mediathekarr") -}}
|
||||||
{{- $path = "/" -}}
|
{{- $path = "/" -}}
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
{{- $path = printf "/%s" .app -}}
|
{{- $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
|
consumer - the ingress objects, the UrlBase env vars and NOTES.txt - sees the
|
||||||
same validated list:
|
same validated list:
|
||||||
|
|
||||||
1. seerr under any path other than "/". seerr has no base-path support
|
1. seerr or mediathekarr under any path other than "/". seerr has no base-path
|
||||||
(upstream request open since May 2022), so a chart that rendered it under
|
support (upstream request open since May 2022) and MediathekArr's UI links
|
||||||
a sub-path would produce something that provably cannot work.
|
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
|
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
|
shared host can collide; apps with their own host are on a different host
|
||||||
and are therefore not part of this check.
|
and are therefore not part of this check.
|
||||||
@@ -269,11 +283,14 @@ same validated list:
|
|||||||
{{- $config := (index $root.Values $app.name).ingress -}}
|
{{- $config := (index $root.Values $app.name).ingress -}}
|
||||||
{{- if not ($config.host | default "") -}}
|
{{- if not ($config.host | default "") -}}
|
||||||
{{- $path := include "freesailarr.appPath" (dict "root" $root "app" $app.name) -}}
|
{{- $path := include "freesailarr.appPath" (dict "root" $root "app" $app.name) -}}
|
||||||
{{- if and (eq $app.name "seerr") (ne $path "/") -}}
|
{{- $rootOnly := dict
|
||||||
{{- 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) -}}
|
"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 -}}
|
{{- end -}}
|
||||||
{{- if hasKey $seen $path -}}
|
{{- 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 -}}
|
{{- end -}}
|
||||||
{{- $_ := set $seen $path $app.name -}}
|
{{- $_ := set $seen $path $app.name -}}
|
||||||
{{- $apps = append $apps (dict "name" $app.name "port" $app.port "path" $path) -}}
|
{{- $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 -}}
|
{{- $ports := list -}}
|
||||||
{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
|
{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
|
||||||
{{- $ports = append $ports (int $app.port) -}}
|
{{- $ports = append $ports (int $app.port) -}}
|
||||||
|
{{- range $extra := $app.extraPorts | default list -}}
|
||||||
|
{{- $ports = append $ports (int $extra.port) -}}
|
||||||
|
{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $ports = append $ports 9999 -}}
|
{{- $ports = append $ports 9999 -}}
|
||||||
{{- join "," $ports -}}
|
{{- 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
|
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).
|
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
|
movies: radarr, bazarr
|
||||||
tv: sonarr, bazarr
|
tv: sonarr, bazarr
|
||||||
|
|
||||||
@@ -375,7 +395,7 @@ Input: dict "root" $ "category" <"downloads"|"movies"|"tv">.
|
|||||||
{{- define "freesailarr.mediaCategoryEnabled" -}}
|
{{- define "freesailarr.mediaCategoryEnabled" -}}
|
||||||
{{- $v := .root.Values -}}
|
{{- $v := .root.Values -}}
|
||||||
{{- if eq .category "downloads" -}}
|
{{- 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" -}}
|
{{- else if eq .category "movies" -}}
|
||||||
{{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}}
|
{{- if or $v.radarr.enabled $v.bazarr.enabled -}}true{{- end -}}
|
||||||
{{- else if eq .category "tv" -}}
|
{{- else if eq .category "tv" -}}
|
||||||
@@ -885,6 +905,47 @@ bazarr:
|
|||||||
- FOWNER
|
- FOWNER
|
||||||
- SETGID
|
- SETGID
|
||||||
- SETUID
|
- 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:
|
configInit:
|
||||||
resources:
|
resources:
|
||||||
limitFactor: 3
|
limitFactor: 3
|
||||||
@@ -905,7 +966,7 @@ configInit:
|
|||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
The effective pod security context. Separate from the per-container table
|
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" -}}
|
{{- define "freesailarr.podSecurityContext" -}}
|
||||||
{{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}}
|
{{- $given := (fromYaml (include "freesailarr.subBlock" (dict "block" .Values.securityContext "key" "pod" "path" "securityContext"))).value -}}
|
||||||
|
|||||||
@@ -21,5 +21,16 @@ spec:
|
|||||||
port: {{ int $app.port }}
|
port: {{ int $app.port }}
|
||||||
targetPort: {{ $app.name }}
|
targetPort: {{ $app.name }}
|
||||||
name: {{ $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
|
type: ClusterIP
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -521,6 +521,109 @@ spec:
|
|||||||
subPath: {{ . | quote }}
|
subPath: {{ . | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- 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
|
restartPolicy: Always
|
||||||
volumes:
|
volumes:
|
||||||
# The tun device gluetun needs to create the Wireguard tunnel.
|
# 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
|
setup with three separate claims still renders byte-identically to
|
||||||
2.0.1 and the diff itself proves the change is inert there.
|
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") }}
|
{{- if include "freesailarr.mediaVolumeOwned" (dict "root" $ "category" "downloads") }}
|
||||||
- name: media-downloads
|
- name: media-downloads
|
||||||
persistentVolumeClaim:
|
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 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
|
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
|
||||||
@@ -579,12 +682,16 @@ spec:
|
|||||||
- name: seerr-tmp
|
- name: seerr-tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.mediathekarr.enabled }}
|
||||||
|
- name: mediathekarr-tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
|
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
|
||||||
# are immutable - toggling an app's enabled flag later requires recreating
|
# are immutable - toggling an app's enabled flag later requires recreating
|
||||||
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
|
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
|
||||||
# before upgrading; existing PVCs and their data survive).
|
# before upgrading; existing PVCs and their data survive).
|
||||||
volumeClaimTemplates:
|
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 }}
|
{{- $config := index $.Values $app }}
|
||||||
{{- if or (eq $app "gluetun") $config.enabled }}
|
{{- if or (eq $app "gluetun") $config.enabled }}
|
||||||
- metadata:
|
- metadata:
|
||||||
|
|||||||
@@ -535,6 +535,76 @@
|
|||||||
"$ref": "#/definitions/containerSecurityContext"
|
"$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": {
|
"definitions": {
|
||||||
@@ -746,6 +816,27 @@
|
|||||||
"object",
|
"object",
|
||||||
"null"
|
"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}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+267
-10
@@ -4,10 +4,11 @@
|
|||||||
# the Wireguard tunnel.
|
# the Wireguard tunnel.
|
||||||
#
|
#
|
||||||
# Image pinning (documented deviation from the Chart.AppVersion standard):
|
# 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
|
# pinned here via <app>.image.repository / <app>.image.tag, because the stack
|
||||||
# combines nine independently versioned upstream projects and none of them is
|
# combines ten independently versioned upstream projects and none of them is
|
||||||
# "the" one app version.
|
# "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
|
# 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
|
# 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
|
# Media libraries as pre-existing PVCs (shared with the separately deployed
|
||||||
# Jellyfin) - the chart mounts but never creates them. Each claim is required
|
# Jellyfin) - the chart mounts but never creates them. Each claim is required
|
||||||
# as soon as an app that mounts it is enabled:
|
# as soon as an app that mounts it is enabled:
|
||||||
# downloads: qbittorrent, radarr, sonarr
|
# downloads: qbittorrent, radarr, sonarr, mediathekarr
|
||||||
# movies: radarr, bazarr
|
# movies: radarr, bazarr
|
||||||
# tv: sonarr, bazarr
|
# tv: sonarr, bazarr
|
||||||
#
|
#
|
||||||
@@ -480,7 +481,7 @@ prowlarr:
|
|||||||
enabled: true
|
enabled: true
|
||||||
image:
|
image:
|
||||||
repository: docker.io/linuxserver/prowlarr
|
repository: docker.io/linuxserver/prowlarr
|
||||||
tag: 2.3.5
|
tag: 2.5.2
|
||||||
# See the qbittorrent ingress comment.
|
# See the qbittorrent ingress comment.
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -594,7 +595,7 @@ radarr:
|
|||||||
enabled: true
|
enabled: true
|
||||||
image:
|
image:
|
||||||
repository: docker.io/linuxserver/radarr
|
repository: docker.io/linuxserver/radarr
|
||||||
tag: 6.1.1
|
tag: 6.3.0
|
||||||
# See the qbittorrent ingress comment.
|
# See the qbittorrent ingress comment.
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -652,7 +653,7 @@ recyclarr:
|
|||||||
enabled: true
|
enabled: true
|
||||||
image:
|
image:
|
||||||
repository: ghcr.io/recyclarr/recyclarr
|
repository: ghcr.io/recyclarr/recyclarr
|
||||||
tag: 8.6.0
|
tag: 8.7.1
|
||||||
# No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and
|
# No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and
|
||||||
# sonarr on a schedule (daemon mode).
|
# sonarr on a schedule (daemon mode).
|
||||||
storage:
|
storage:
|
||||||
@@ -705,7 +706,7 @@ sonarr:
|
|||||||
enabled: true
|
enabled: true
|
||||||
image:
|
image:
|
||||||
repository: docker.io/linuxserver/sonarr
|
repository: docker.io/linuxserver/sonarr
|
||||||
tag: 4.0.17
|
tag: 4.0.19
|
||||||
# See the qbittorrent ingress comment.
|
# See the qbittorrent ingress comment.
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -763,7 +764,7 @@ seerr:
|
|||||||
enabled: true
|
enabled: true
|
||||||
image:
|
image:
|
||||||
repository: ghcr.io/seerr-team/seerr
|
repository: ghcr.io/seerr-team/seerr
|
||||||
tag: v3.3.0
|
tag: v3.4.1
|
||||||
# See the qbittorrent ingress comment.
|
# See the qbittorrent ingress comment.
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -820,7 +821,7 @@ bazarr:
|
|||||||
enabled: true
|
enabled: true
|
||||||
image:
|
image:
|
||||||
repository: docker.io/linuxserver/bazarr
|
repository: docker.io/linuxserver/bazarr
|
||||||
tag: 1.5.6
|
tag: 1.6.0
|
||||||
# See the qbittorrent ingress comment.
|
# See the qbittorrent ingress comment.
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -874,3 +875,259 @@ bazarr:
|
|||||||
- FOWNER
|
- FOWNER
|
||||||
- SETGID
|
- SETGID
|
||||||
- SETUID
|
- 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
|
type: application
|
||||||
|
|
||||||
version: 8.0.1+up8.36.0
|
version: 8.0.3+up8.37.0
|
||||||
appVersion: "8.36.0"
|
appVersion: "8.37.0"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "gotenberg.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "gotenberg.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "gotenberg.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "gotenberg.defaultedDict" -}}
|
{{- define "gotenberg.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $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
|
icon: https://www.home-assistant.io/images/home-assistant-logo.svg
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 6.1.0+up2026.8.3
|
version: 6.1.1+up2026.8.3
|
||||||
|
|
||||||
appVersion: "2026.8.3"
|
appVersion: "2026.8.3"
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "homeassistant.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "homeassistant.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "homeassistant.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -167,8 +169,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "homeassistant.defaultedDict" -}}
|
{{- define "homeassistant.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: matrix-synapse
|
|||||||
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
|
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
|
||||||
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
|
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
|
||||||
type: application
|
type: application
|
||||||
version: 7.1.0+upv1.159.0
|
version: 7.1.1+upv1.159.0
|
||||||
appVersion: "v1.159.0"
|
appVersion: "v1.159.0"
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "matrix-synapse.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "matrix-synapse.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "matrix-synapse.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "matrix-synapse.defaultedDict" -}}
|
{{- define "matrix-synapse.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ icon: https://avatars.githubusercontent.com/u/988985
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 4.1.0+up1.46.4
|
version: 4.1.3+up1.47.1
|
||||||
appVersion: "1.46.4"
|
appVersion: "1.47.1"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "minecraft-router.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "minecraft-router.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "minecraft-router.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "minecraft-router.defaultedDict" -}}
|
{{- define "minecraft-router.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -5,16 +5,26 @@ icon: https://avatars.githubusercontent.com/u/988985
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
# The Java line of this chart version is chosen through appVersion, which is
|
# ONLY THE NEWEST JAVA LINE IS MAINTAINED (chart 5.0.0, issue #158).
|
||||||
# the image tag: the chart is published once per line (java8 / java17 / java21)
|
# appVersion is the image tag and therefore fixes the JVM. Up to chart 4.1.0 the
|
||||||
# and minecraft.version in values.yaml must match the line published here.
|
# 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>")
|
# 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
|
# 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
|
# 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
|
# 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.
|
# image is now pinned by the chart version like every other chart in this repo.
|
||||||
version: 4.1.0+up2026.8.2.java21
|
version: 6.0.1+up2026.8.2.java25
|
||||||
appVersion: "2026.8.2-java21"
|
appVersion: "2026.8.2-java25"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
||||||
{{- $budget = printf "%.0f seconds" (mulf $startup.periodSeconds $startup.failureThreshold) -}}
|
{{- $budget = printf "%.0f seconds" (mulf $startup.periodSeconds $startup.failureThreshold) -}}
|
||||||
{{- end -}}
|
{{- 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 -}}
|
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||||
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $memPinned := not (kindIs "invalid" (index $givenReq "memory")) -}}
|
{{- $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
|
ACCESS — A MINECRAFT SERVER, NOT A WEB SERVICE
|
||||||
This release has no Ingress, no certificate and no URL. Clients speak the
|
This release has no Ingress, no certificate and no URL. Clients speak the
|
||||||
@@ -76,23 +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 }}
|
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 JAVA LINE OF THIS RELEASE IS {{ $line | default "NOT RECOGNIZABLE FROM ITS TAG" | upper }}
|
||||||
The JVM is fixed by the image tag, which comes from the chart's appVersion
|
The JVM is fixed by the image tag, not by a value of the game. This release
|
||||||
({{ .Chart.AppVersion }}), not from a value. This build runs
|
runs
|
||||||
minecraft.version {{ .Values.minecraft.version }}.
|
|
||||||
|
|
||||||
Java 8 runs Minecraft up to 1.16.5, 1.18-1.20.4 need Java 17 and 1.20.5+ need
|
itzg/minecraft-server:{{ $tag }}
|
||||||
Java 21. Nothing in this chart checks that minecraft.version fits the line:
|
|
||||||
a mismatch renders and installs without a word, and only the container fails
|
with minecraft.version {{ .Values.minecraft.version }}.
|
||||||
afterwards — CrashLoopBackOff once the startup budget{{ if $budget }} ({{ $budget }}){{ end }} is spent, with
|
{{- if $tagOverridden }}
|
||||||
the reason in the log and nowhere else:
|
|
||||||
|
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 }}
|
kubectl -n {{ .Release.Namespace }} logs {{ $pod }}
|
||||||
|
|
||||||
So moving the server across a line boundary is a change of CHART VERSION, not
|
The boundaries are Mojang's (javaVersion.majorVersion in the piston-meta
|
||||||
a change of minecraft.version: pick the published chart of the target line and
|
version manifest, per release):
|
||||||
set minecraft.version to match it. Changing only minecraft.version leaves the
|
|
||||||
old JVM in place.
|
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" }}
|
{{- if eq $replicas "0" }}
|
||||||
|
|
||||||
SCALED TO ZERO
|
SCALED TO ZERO
|
||||||
@@ -172,9 +204,11 @@ OPERATORS ARE SYNCHRONIZED FROM VALUES, NOT FROM THE GAME
|
|||||||
SECRETS — WHICH FIELDS THE 1PASSWORD ITEM MUST CARRY
|
SECRETS — WHICH FIELDS THE 1PASSWORD ITEM MUST CARRY
|
||||||
The OnePasswordItem CR {{ $secret }} is rendered unconditionally, so
|
The OnePasswordItem CR {{ $secret }} is rendered unconditionally, so
|
||||||
secrets.minecraftServerRef has to be set even when nothing consumes a key from
|
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
|
The operator copies the item's FIELDS into the Kubernetes Secret, one key per
|
||||||
field name. This release consumes:
|
field name. This release consumes:
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "minecraft-server.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "minecraft-server.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. The reference implementation lives in template-chart.
|
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" -}}
|
{{- define "minecraft-server.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -183,6 +184,133 @@ last one silently won. Fail loudly instead.
|
|||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- 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
|
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.
|
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.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies. The reference implementation
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
lives in template-chart.
|
guideline 14). The reference implementation lives in template-chart.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "minecraft-server.defaultedDict" -}}
|
{{- define "minecraft-server.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ kind: OnePasswordItem
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||||
spec:
|
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.validateModpackSources" . }}
|
||||||
|
{{- include "minecraft-server.validateJavaLine" . }}
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: StatefulSet
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
@@ -58,7 +59,11 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
- 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
|
imagePullPolicy: IfNotPresent
|
||||||
securityContext:
|
securityContext:
|
||||||
{{- include "minecraft-server.containerSecurityContext" .Values.securityContext | nindent 12 }}
|
{{- include "minecraft-server.containerSecurityContext" .Values.securityContext | nindent 12 }}
|
||||||
|
|||||||
@@ -12,11 +12,23 @@
|
|||||||
"scaleDown": {
|
"scaleDown": {
|
||||||
"type": "boolean"
|
"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": {
|
"secrets": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"minecraftServerRef": {
|
"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"]
|
"type": ["string", "null"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,48 @@
|
|||||||
# Services stay in place; scaleDown: false brings the workloads back up.
|
# Services stay in place; scaleDown: false brings the workloads back up.
|
||||||
scaleDown: false
|
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:
|
secrets:
|
||||||
# op:// item path for the 1Password operator. Only needs to carry a
|
# REQUIRED - op:// item path for the 1Password operator. The OnePasswordItem
|
||||||
# "curseforge-api-key" key when curseforge.enabled is true and an
|
# CR is rendered unconditionally, so this reference has to be set even when
|
||||||
# "rcon-password" key when server.rcon.enabled is true.
|
# 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
|
# Documented limit (chart 3.0.0): changing this path rolls the pod (the
|
||||||
# StatefulSet hashes the rendered OnePasswordItem into checksum/secret-ref),
|
# StatefulSet hashes the rendered OnePasswordItem into checksum/secret-ref),
|
||||||
@@ -223,11 +261,32 @@ minecraft:
|
|||||||
memory: "4"
|
memory: "4"
|
||||||
# Minecraft version to run. Pinned rather than "LATEST", which would silently
|
# Minecraft version to run. Pinned rather than "LATEST", which would silently
|
||||||
# drift to a new release on every restart.
|
# 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
|
# This value must match the Java line of the EFFECTIVE image tag - appVersion,
|
||||||
# version. Java 8 runs Minecraft up to 1.16.5, 1.18-1.20.4 need Java 17 and
|
# or image.tagOverride when that is set. Since chart 5.0.0 the chart checks it
|
||||||
# 1.20.5+ need Java 21. This chart publishes appVersion "2026.8.2-java21".
|
# and FAILS THE RENDER on a mismatch, naming both values; before 5.0.0 a
|
||||||
version: "1.21.1"
|
# 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
|
# curseforge, ftb and curseforgeServerpack are mutually exclusive - enabling
|
||||||
# more than one is rejected by the chart (before 1.0.0 it silently rendered a
|
# more than one is rejected by the chart (before 1.0.0 it silently rendered a
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon-
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 7.1.0+up1.9.2
|
version: 7.1.3+up1.10.1
|
||||||
appVersion: 1.9.2
|
appVersion: 1.10.1
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "outline.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "outline.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "outline.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "outline.defaultedDict" -}}
|
{{- define "outline.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s
|
|||||||
|
|
||||||
type: application
|
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:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "paperless-ngx.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "paperless-ngx.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "paperless-ngx.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/PatchMon/PatchMon/main/frontend/public/a
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 7.0.1+up2.1.3
|
version: 7.0.2+up2.1.3
|
||||||
appVersion: "2.1.3"
|
appVersion: "2.1.3"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "patchmon.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "patchmon.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "patchmon.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "patchmon.defaultedDict" -}}
|
{{- define "patchmon.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 8.1.1+up4.13.1
|
version: 8.1.6+up4.15.2
|
||||||
appVersion: "4.13.1"
|
appVersion: "4.15.2"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "rallly.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "rallly.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "rallly.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "rallly.defaultedDict" -}}
|
{{- define "rallly.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
+250
-26
@@ -16,15 +16,45 @@
|
|||||||
// Commit subjects in this repo are plain sentences, not conventional
|
// Commit subjects in this repo are plain sentences, not conventional
|
||||||
// commits. Renovate's own merged PRs already look like that.
|
// commits. Renovate's own merged PRs already look like that.
|
||||||
":semanticCommitsDisabled",
|
":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",
|
timezone: "Europe/Berlin",
|
||||||
labels: ["dependencies"],
|
labels: ["renovate"],
|
||||||
|
dependencyDashboard: true,
|
||||||
|
|
||||||
// 21 charts plus the workflows: without a cap the first run after a quiet
|
// No caps, as in cluster-config. The first run after a quiet week can open
|
||||||
// week opens a wall of PRs, and every chart PR spins up a k3d install-test.
|
// many PRs at once and every chart PR starts a k3d install-test; on Gitea a
|
||||||
prConcurrentLimit: 10,
|
// whole matrix runs inside one job on one runner, so those queue instead of
|
||||||
prHourlyLimit: 4,
|
// 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
|
// 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
|
// 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
|
// Without that entry Renovate skips the task silently and every chart PR
|
||||||
// stays red on the version-bump check.
|
// 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: {
|
postUpgradeTasks: {
|
||||||
commands: ["ci/renovate-bump-chart.sh {{{packageFileDir}}}"],
|
commands: ["ci/renovate-bump-chart.sh {{{packageFileDir}}}"],
|
||||||
fileFilters: ["**/Chart.yaml"],
|
fileFilters: ["**/Chart.yaml"],
|
||||||
@@ -72,23 +110,43 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: [
|
description: [
|
||||||
"Patch, minor and digest updates merge themselves once the PR pipeline is green.",
|
"Routine updates wait three days before Renovate even opens the PR. Automerge is",
|
||||||
"Minor is included because the sidecar images barely move in patch steps —",
|
"global now (top of this file), so this rule no longer decides WHAT merges by itself —",
|
||||||
"alpine 3.22 -> 3.24 and nginx 1.30.4 -> 1.31.4 are both minor, so a patch-only",
|
"it decides how fresh a release may be when it does. A merge here is never just a",
|
||||||
"rule would practically never fire. Majors stay manual.",
|
"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"],
|
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",
|
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: [
|
description: [
|
||||||
"Chart app versions. Kept out of the automerge grouping decisions above only by label,",
|
"Chart app versions. Kept out of the automerge grouping decisions above only by label,",
|
||||||
@@ -97,6 +155,27 @@
|
|||||||
matchDepTypes: ["app-version"],
|
matchDepTypes: ["app-version"],
|
||||||
addLabels: ["app-version"],
|
addLabels: ["app-version"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: [
|
||||||
|
"Helm subchart dependencies (the `dependencies:` block of a Chart.yaml). Handled by",
|
||||||
|
"the BUILT-IN helmv3 manager - no customManager needed, and none would help: the",
|
||||||
|
"repository URL and the version sit next to each other in one file, which is the",
|
||||||
|
"case the built-in manager exists for. It is enabled by config:recommended and this",
|
||||||
|
"config sets no enabledManagers, so nothing has to switch it on. Verified by dry-run",
|
||||||
|
"for #214: with ytdlp-web-player pinned one release back, the lookup returns",
|
||||||
|
"oauth2-proxy 10.6.2 -> 10.7.0 as a manager=helmv3 minor update.",
|
||||||
|
"",
|
||||||
|
"This rule adds only a label. It exists because the update matters more here than",
|
||||||
|
"its size suggests: a subchart is invisible to everything downstream that watches",
|
||||||
|
"Helm release versions, so this PR is the ONLY way a new proxy version - a security",
|
||||||
|
"fix included - ever reaches a cluster. Automerge carries that PR to main once it is",
|
||||||
|
"reviewed and the check is green, and ci/renovate-bump-chart.sh bumps the chart patch",
|
||||||
|
"that publishes it (appVersion is unchanged, so it takes the patch branch). A major",
|
||||||
|
"goes the same way - it is the review, not a separate policy, that gives it a look.",
|
||||||
|
],
|
||||||
|
matchManagers: ["helmv3"],
|
||||||
|
addLabels: ["subchart"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
description: [
|
description: [
|
||||||
"nginx numbers its release branches by minor: even is the stable branch, odd is",
|
"nginx numbers its release branches by minor: even is the stable branch, odd is",
|
||||||
@@ -180,14 +259,49 @@
|
|||||||
//
|
//
|
||||||
// When adding a chart, add a block here — see README "Creating a new chart".
|
// When adding a chart, add a block here — see README "Creating a new chart".
|
||||||
//
|
//
|
||||||
// Exception, deliberately no block: `freesailarr`. It bundles nine
|
// Exception, deliberately no APP-VERSION block: `freesailarr`. It bundles
|
||||||
// independently versioned upstreams, so none of them is "the" app version
|
// ten independently versioned upstreams, so none of them is "the" app
|
||||||
// and no image tag comes from appVersion — all nine are pinned as
|
// version and no image tag comes from appVersion — they are pinned as
|
||||||
// image.repository/image.tag in its values.yaml, where the built-in
|
// image.repository/image.tag in its values.yaml, where the built-in
|
||||||
// helm-values manager already finds them and proposes one update per
|
// helm-values manager already finds them and proposes one update per
|
||||||
// image. Adding a block here would either duplicate those updates or
|
// image. Adding an appVersion block here would either duplicate those
|
||||||
// rewrite an appVersion that selects nothing. Its appVersion is a
|
// updates or rewrite an appVersion that selects nothing. Its appVersion is
|
||||||
// hand-maintained marker; see freesailarr/Chart.yaml.
|
// 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.",
|
description: "apache-tika: four-part version plus flavour suffix (3.3.1.0-full); the compatibility group keeps the chart on -full.",
|
||||||
@@ -220,6 +334,29 @@
|
|||||||
datasourceTemplate: "docker",
|
datasourceTemplate: "docker",
|
||||||
depTypeTemplate: "app-version",
|
depTypeTemplate: "app-version",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: [
|
||||||
|
"convertx: the registry carries versioned tags (v0.19.0 and its predecessors) next",
|
||||||
|
"to THREE moving ones - 'latest', 'main' and a 'pr-<n>' per open pull request.",
|
||||||
|
"v0.19.0 and 'latest' currently share the same digest (sha256:8590a21a…), which is",
|
||||||
|
"exactly the trap: an appVersion of 'latest' would look correct today and change the",
|
||||||
|
"running image on the next upstream push, without a commit here. 'main' and 'pr-<n>'",
|
||||||
|
"are worse still - they carry unreviewed code.",
|
||||||
|
"The anchored regex versioning below is what keeps all three out: it demands the v",
|
||||||
|
"prefix and three numeric parts, which none of them can satisfy. Precedent and the",
|
||||||
|
"full argument: the ytdlp-web-player block near the end of this file.",
|
||||||
|
],
|
||||||
|
customType: "regex",
|
||||||
|
managerFilePatterns: ["convertx/Chart.yaml"],
|
||||||
|
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||||
|
depNameTemplate: "ghcr.io/c4illin/convertx",
|
||||||
|
datasourceTemplate: "docker",
|
||||||
|
depTypeTemplate: "app-version",
|
||||||
|
// The "v" stays outside every capture group: it is part of the tag and
|
||||||
|
// carries no ordering. A group is a claim about how the value is
|
||||||
|
// compared, and the anchors are what exclude latest/main/pr-<n>.
|
||||||
|
versioningTemplate: "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)$",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
customType: "regex",
|
customType: "regex",
|
||||||
managerFilePatterns: ["cyberchef/Chart.yaml"],
|
managerFilePatterns: ["cyberchef/Chart.yaml"],
|
||||||
@@ -341,8 +478,9 @@
|
|||||||
// isGreaterThan("2026.8.22-777ba8fa4", current) -> false, equals -> true
|
// isGreaterThan("2026.8.22-777ba8fa4", current) -> false, equals -> true
|
||||||
// 777ba8fa4 was pushed at 03:06 and 9fea41204 at 05:38 the SAME day, so
|
// 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 -
|
// the build group makes Renovate offer the older image as an update -
|
||||||
// a downgrade, unattended, because date updates land in the patch/minor
|
// a downgrade, and one that automerge would carry to main as soon as the
|
||||||
// automerge rule. With NaN the pair is neither equal nor ordered at all.
|
// 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),
|
// 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
|
// it just takes no part in the comparison: two builds of one day are
|
||||||
@@ -370,6 +508,33 @@
|
|||||||
datasourceTemplate: "docker",
|
datasourceTemplate: "docker",
|
||||||
depTypeTemplate: "app-version",
|
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.",
|
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",
|
customType: "regex",
|
||||||
@@ -396,6 +561,65 @@
|
|||||||
datasourceTemplate: "docker",
|
datasourceTemplate: "docker",
|
||||||
depTypeTemplate: "app-version",
|
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",
|
||||||
|
"ones (v1.0.0, v1.0.1, v1.1.0, v1.2.0) and the two floating ones, 'latest' and",
|
||||||
|
"'stable'. Both floating tags MOVE: 'stable' was republished at the same minute as",
|
||||||
|
"v1.2.0 and 'latest' days later, so either of them as an appVersion would pin",
|
||||||
|
"nothing and change the running image without a single commit.",
|
||||||
|
"The anchored regex versioning below is what keeps them out - it requires the v",
|
||||||
|
"prefix and three numeric parts, which neither 'latest' nor 'stable' can satisfy.",
|
||||||
|
"docker versioning alone would probably have dropped them too, but 'probably' is",
|
||||||
|
"the wrong basis for the value that selects the image.",
|
||||||
|
],
|
||||||
|
customType: "regex",
|
||||||
|
managerFilePatterns: ["ytdlp-web-player/Chart.yaml"],
|
||||||
|
matchStrings: ["(?m)^appVersion:\\s*\"?(?<currentValue>[^\"\\s]+)\"?"],
|
||||||
|
depNameTemplate: "docker.io/matszwe02/ytdlp_web_player",
|
||||||
|
datasourceTemplate: "docker",
|
||||||
|
depTypeTemplate: "app-version",
|
||||||
|
// The "v" stays outside every capture group: it is part of the tag and
|
||||||
|
// carries no ordering, and the same rule the searxng block documents
|
||||||
|
// applies - a group is a claim about how the value is compared. The
|
||||||
|
// anchors are what exclude latest/stable. Precedent for keeping the v in
|
||||||
|
// appVersion at all: calibre-web-automated (+upv4.0.6) and matrix-synapse
|
||||||
|
// (+upv1.159.0) - it is part of the tag, so it is part of appVersion.
|
||||||
|
versioningTemplate: "regex:^v(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)$",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
customType: "regex",
|
customType: "regex",
|
||||||
managerFilePatterns: ["zipline/Chart.yaml"],
|
managerFilePatterns: ["zipline/Chart.yaml"],
|
||||||
|
|||||||
+1
-1
@@ -5,6 +5,6 @@ icon: https://avatars.githubusercontent.com/u/13281359
|
|||||||
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
version: 7.1.0+up4.23.10
|
version: 7.1.1+up4.23.10
|
||||||
|
|
||||||
appVersion: 4.23.10
|
appVersion: 4.23.10
|
||||||
@@ -19,9 +19,11 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "samba.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "samba.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "samba.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -167,8 +169,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "samba.defaultedDict" -}}
|
{{- define "samba.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: satisfactory
|
|||||||
description: A Helm chart to deploy a Satisfactory Gameserver on Kubernetes
|
description: A Helm chart to deploy a Satisfactory Gameserver on Kubernetes
|
||||||
icon: https://raw.githubusercontent.com/wolveix/satisfactory-server/main/.github/logo.png
|
icon: https://raw.githubusercontent.com/wolveix/satisfactory-server/main/.github/logo.png
|
||||||
type: application
|
type: application
|
||||||
version: 6.1.0+upv1.9.10
|
version: 7.0.2+upv1.9.10
|
||||||
appVersion: "v1.9.10"
|
appVersion: "v1.9.10"
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -1,26 +1,29 @@
|
|||||||
{{- $replicas := include "satisfactory.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.server.replicas) -}}
|
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||||
{{- $gamePort := int .Values.server.gamePort -}}
|
{{- $dev := fromYaml (include "satisfactory.dev" .Values.dev) -}}
|
||||||
{{- $msgPort := int .Values.server.messagingPort -}}
|
{{- $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 -}}
|
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
|
||||||
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
|
||||||
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
||||||
{{- $pvc := printf "%s-%s-vc" .Release.Name .Chart.Name -}}
|
{{- $pvc := printf "%s-%s-vc" .Release.Name .Chart.Name -}}
|
||||||
{{- $storage := .Values.server.storage | default "unset" -}}
|
{{- $storage := $server.storage | default "unset" -}}
|
||||||
{{- $res := fromYaml (include "satisfactory.effectiveResources" .Values.server.resources) -}}
|
{{- $res := fromYaml (include "satisfactory.effectiveResources" $server.resources) -}}
|
||||||
{{- $lim := $res.limits | default dict -}}
|
{{- $lim := $res.limits | default dict -}}
|
||||||
{{- $req := $res.requests | default dict -}}
|
{{- $req := $res.requests | default dict -}}
|
||||||
{{- $eph := index $lim "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
|
{{- $eph := index $lim "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
|
||||||
{{- $mem := index $lim "memory" | default "the computed memory 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) -}}
|
{{- $startup := fromYaml (include "satisfactory.startupProbe" .Values.startupProbe) -}}
|
||||||
{{- $budget := "" -}}
|
{{- $budget := "" -}}
|
||||||
{{- if and (kindIs "float64" $startup.periodSeconds) (kindIs "float64" $startup.failureThreshold) -}}
|
{{- 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) -}}
|
{{- $budget = printf "%.0fs x %.0f = %.0f minutes" $startup.periodSeconds $startup.failureThreshold (divf (mulf $startup.periodSeconds $startup.failureThreshold) 60.0) -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $seasonalRaw := printf "%v" .Values.game.seasonalEvents -}}
|
{{- $game := fromYaml (include "satisfactory.game" .Values.game) -}}
|
||||||
{{- $seasonalOff := eq (lower $seasonalRaw) "true" -}}
|
{{- $seasonalEnv := include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents -}}
|
||||||
{{- $beta := eq (lower (printf "%v" .Values.game.experimental)) "true" -}}
|
{{- $seasonalOff := eq $seasonalEnv "true" -}}
|
||||||
{{- $debug := eq (lower (printf "%v" .Values.dev.debug)) "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) -}}
|
{{- $outOfRange := or (lt $gamePort 30000) (gt $gamePort 32767) (lt $msgPort 30000) (gt $msgPort 32767) -}}
|
||||||
{{- $skipRaw := "" -}}
|
{{- $skipRaw := "" -}}
|
||||||
{{- range $entry := .Values.env -}}
|
{{- 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
|
unlimited container gets throttled rather than killed — a memory limit can
|
||||||
end a session, a missing CPU limit only slows it down.
|
end a session, a missing CPU limit only slows it down.
|
||||||
|
|
||||||
game.seasonalEvents IS PASSED THROUGH, NOT INVERTED
|
SEASONAL EVENTS — THE KEY WAS RENAMED IN 7.0.0, AND IT IS BREAKING
|
||||||
The chart hands game.seasonalEvents to the container as
|
Seasonal (holiday) events are {{ if $seasonalOff }}OFF{{ else }}ON{{ end }} in this release: game.enableSeasonalEvents is
|
||||||
DISABLESEASONALEVENTS unchanged. It is currently {{ $seasonalRaw }}, so the container
|
{{ $game.enableSeasonalEvents }}, which the chart negates into DISABLESEASONALEVENTS={{ $seasonalEnv }}.
|
||||||
runs with DISABLESEASONALEVENTS={{ $seasonalRaw }} — seasonal events are {{ if $seasonalOff }}OFF{{ else }}ON{{ end }}.
|
{{ if $seasonalOff }}To turn them ON, set game.enableSeasonalEvents: true.{{ else }}To turn them OFF, set game.enableSeasonalEvents: false.{{ 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
|
UPGRADING FROM 6.x — WHAT YOU HAVE TO DO
|
||||||
and of what values.schema.json claims. Believe this paragraph and the
|
The old key game.seasonalEvents IS GONE. It is not deprecated and not
|
||||||
rendered env var, not the key name.
|
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 USER ID IS PINNED, AND PUID/PGID DO NOTHING
|
||||||
The pod runs as 1000:1000 because the image supports rootless operation only
|
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:
|
resources:
|
||||||
{{- include "satisfactory.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "satisfactory.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. This file in template-chart is the reference implementation.
|
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" -}}
|
{{- define "satisfactory.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,8 +170,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies.
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
|
guideline 14).
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "satisfactory.defaultedDict" -}}
|
{{- define "satisfactory.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $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") -}}
|
{{- include "satisfactory.defaultedDict" (dict "defaults" $defaults "given" . "path" "startupProbe") -}}
|
||||||
{{- end -}}
|
{{- 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
|
The effective resources block, fed into the resources helper above so that an
|
||||||
erased "resources:" still yields the chart's requests and computed limits.
|
erased "resources:" still yields the chart's requests and computed limits.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{{- $server := fromYaml (include "satisfactory.server" .Values.server) -}}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
@@ -7,5 +8,5 @@ spec:
|
|||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.server.storage }}
|
storage: {{ $server.storage }}
|
||||||
storageClassName: longhorn
|
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
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
@@ -8,19 +11,19 @@ spec:
|
|||||||
type: NodePort
|
type: NodePort
|
||||||
ports:
|
ports:
|
||||||
- name: "api"
|
- name: "api"
|
||||||
port: {{ .Values.server.gamePort }}
|
port: {{ $gamePort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
nodePort: {{ .Values.server.gamePort }}
|
nodePort: {{ $gamePort }}
|
||||||
targetPort: {{ .Values.server.gamePort }}
|
targetPort: {{ $gamePort }}
|
||||||
- name: "game"
|
- name: "game"
|
||||||
port: {{ .Values.server.gamePort }}
|
port: {{ $gamePort }}
|
||||||
protocol: UDP
|
protocol: UDP
|
||||||
nodePort: {{ .Values.server.gamePort }}
|
nodePort: {{ $gamePort }}
|
||||||
targetPort: {{ .Values.server.gamePort }}
|
targetPort: {{ $gamePort }}
|
||||||
- name: "messaging"
|
- name: "messaging"
|
||||||
port: {{ .Values.server.messagingPort }}
|
port: {{ $msgPort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
nodePort: {{ .Values.server.messagingPort }}
|
nodePort: {{ $msgPort }}
|
||||||
targetPort: {{ .Values.server.messagingPort }}
|
targetPort: {{ $msgPort }}
|
||||||
selector:
|
selector:
|
||||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
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
|
apiVersion: apps/v1
|
||||||
kind: StatefulSet
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
@@ -9,7 +12,7 @@ spec:
|
|||||||
matchLabels:
|
matchLabels:
|
||||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
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:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -23,9 +26,9 @@ spec:
|
|||||||
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
|
image: "ghcr.io/wolveix/satisfactory-server:{{ .Chart.AppVersion }}"
|
||||||
env:
|
env:
|
||||||
- name: DEBUG
|
- name: DEBUG
|
||||||
value: "{{ .Values.dev.debug }}"
|
value: "{{ $dev.debug }}"
|
||||||
- name: MAXPLAYERS
|
- name: MAXPLAYERS
|
||||||
value: "{{ .Values.game.maxPlayers }}"
|
value: "{{ include "satisfactory.formatNumber" $game.maxPlayers }}"
|
||||||
# PUID/PGID are only used by the image's root entrypoint path;
|
# PUID/PGID are only used by the image's root entrypoint path;
|
||||||
# with the default non-root security context they are ignored.
|
# with the default non-root security context they are ignored.
|
||||||
- name: PGID
|
- name: PGID
|
||||||
@@ -35,13 +38,15 @@ spec:
|
|||||||
- name: SKIPUPDATE
|
- name: SKIPUPDATE
|
||||||
value: "false"
|
value: "false"
|
||||||
- name: STEAMBETA
|
- 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
|
- name: DISABLESEASONALEVENTS
|
||||||
value: "{{ .Values.game.seasonalEvents }}"
|
value: "{{ include "satisfactory.disableSeasonalEvents" $game.enableSeasonalEvents }}"
|
||||||
- name: SERVERGAMEPORT
|
- name: SERVERGAMEPORT
|
||||||
value: "{{ .Values.server.gamePort }}"
|
value: "{{ include "satisfactory.formatNumber" $server.gamePort }}"
|
||||||
- name: SERVERMESSAGINGPORT
|
- name: SERVERMESSAGINGPORT
|
||||||
value: "{{ .Values.server.messagingPort }}"
|
value: "{{ include "satisfactory.formatNumber" $server.messagingPort }}"
|
||||||
{{- range $value := .Values.env }}
|
{{- range $value := .Values.env }}
|
||||||
{{- if $value.value }}
|
{{- if $value.value }}
|
||||||
- name: {{ $value.name }}
|
- name: {{ $value.name }}
|
||||||
@@ -64,13 +69,13 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
ports:
|
ports:
|
||||||
- name: "api"
|
- name: "api"
|
||||||
containerPort: {{ .Values.server.gamePort }}
|
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
- name: "game"
|
- name: "game"
|
||||||
containerPort: {{ .Values.server.gamePort }}
|
containerPort: {{ include "satisfactory.formatNumber" $server.gamePort }}
|
||||||
protocol: UDP
|
protocol: UDP
|
||||||
- name: "messaging"
|
- name: "messaging"
|
||||||
containerPort: {{ .Values.server.messagingPort }}
|
containerPort: {{ include "satisfactory.formatNumber" $server.messagingPort }}
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
|
{{- include "satisfactory.livenessProbe" .Values.livenessProbe | nindent 12 }}
|
||||||
@@ -79,7 +84,7 @@ spec:
|
|||||||
startupProbe:
|
startupProbe:
|
||||||
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
|
{{- include "satisfactory.startupProbe" .Values.startupProbe | nindent 12 }}
|
||||||
resources:
|
resources:
|
||||||
{{- include "satisfactory.effectiveResources" .Values.server.resources | nindent 12 }}
|
{{- include "satisfactory.effectiveResources" $server.resources | nindent 12 }}
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: satisfactory-volume
|
- name: satisfactory-volume
|
||||||
mountPath: /config
|
mountPath: /config
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft-07/schema#",
|
"$schema": "https://json-schema.org/draft-07/schema#",
|
||||||
"title": "satisfactory values",
|
"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",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13,17 +13,19 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"dev": {
|
"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,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"debug": {
|
"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"]
|
"type": ["boolean", "string", "null"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"game": {
|
"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,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"experimental": {
|
"experimental": {
|
||||||
@@ -33,32 +35,36 @@
|
|||||||
"maxPlayers": {
|
"maxPlayers": {
|
||||||
"type": ["integer", "string", "null"]
|
"type": ["integer", "string", "null"]
|
||||||
},
|
},
|
||||||
"seasonalEvents": {
|
"enableSeasonalEvents": {
|
||||||
"description": "Inverted by the chart into DISABLESEASONALEVENTS, so true means events are enabled.",
|
"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"]
|
"type": ["boolean", "string", "null"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"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,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"replicas": {
|
"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
|
"minimum": 0
|
||||||
},
|
},
|
||||||
"gamePort": {
|
"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,
|
"minimum": 1,
|
||||||
"maximum": 65535
|
"maximum": 65535
|
||||||
},
|
},
|
||||||
"messagingPort": {
|
"messagingPort": {
|
||||||
"type": "integer",
|
"description": "Null means the chart default (30888). Same erased-port trap as gamePort above.",
|
||||||
|
"type": ["integer", "null"],
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 65535
|
"maximum": 65535
|
||||||
},
|
},
|
||||||
"storage": {
|
"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"]
|
"type": ["string", "null"]
|
||||||
},
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
|
|||||||
@@ -4,20 +4,50 @@
|
|||||||
# Services stay in place; scaleDown: false brings the workloads back up.
|
# Services stay in place; scaleDown: false brings the workloads back up.
|
||||||
scaleDown: false
|
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:
|
dev:
|
||||||
debug: false
|
debug: false
|
||||||
|
|
||||||
game:
|
game:
|
||||||
experimental: false
|
experimental: false
|
||||||
maxPlayers: 4
|
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:
|
server:
|
||||||
# Replica count for this workload; 0 is allowed and scales it down.
|
# Replica count for this workload; 0 is allowed and scales it down.
|
||||||
# The chart-global scaleDown flag takes precedence.
|
# The chart-global scaleDown flag takes precedence.
|
||||||
replicas: 1
|
replicas: 1
|
||||||
gamePort: 30777
|
gamePort: 30777
|
||||||
messagingPort: 30888
|
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
|
storage: 20Gi
|
||||||
resources:
|
resources:
|
||||||
# Limits are optional: memory and ephemeral-storage limits default to
|
# 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
|
# (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
|
# 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.
|
# scheme requires - the same shape rss-bridge and minecraft-server use.
|
||||||
version: 1.0.1+up2026.8.22.9fea41204
|
version: 1.0.6+up2026.9.13.3b8009065
|
||||||
appVersion: "2026.8.22-9fea41204"
|
appVersion: "2026.9.13-3b8009065"
|
||||||
|
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: jklein
|
- name: jklein
|
||||||
@@ -19,9 +19,10 @@ Usage (the values path to the resources dict varies per chart):
|
|||||||
resources:
|
resources:
|
||||||
{{- include "searxng.resources" .Values.<app>.resources | nindent 12 }}
|
{{- include "searxng.resources" .Values.<app>.resources | nindent 12 }}
|
||||||
|
|
||||||
NOTE: charts in this repo deliberately have no dependencies, so this helper is
|
NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with
|
||||||
duplicated into every chart's templates/_helpers.tpl with the chart's name as
|
the chart's name as define prefix, because sharing it would mean a library
|
||||||
define prefix. template-chart holds the reference implementation.
|
chart, and a dependency is admissible but expensive (README, guideline 14) -
|
||||||
|
this chart takes none. template-chart holds the reference implementation.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "searxng.resources" -}}
|
{{- define "searxng.resources" -}}
|
||||||
{{- $resources := . | default dict -}}
|
{{- $resources := . | default dict -}}
|
||||||
@@ -168,9 +169,9 @@ Rules:
|
|||||||
- keys only present in the override are passed through unchanged.
|
- keys only present in the override are passed through unchanged.
|
||||||
|
|
||||||
NOTE: like the helpers above, this is duplicated into every chart's
|
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
|
templates/_helpers.tpl with the chart's name as define prefix, because sharing
|
||||||
in this repo deliberately have no dependencies. template-chart holds the
|
it would mean a library chart and this chart takes no dependency (README,
|
||||||
reference implementation.
|
guideline 14). template-chart holds the reference implementation.
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "searxng.defaultedDict" -}}
|
{{- define "searxng.defaultedDict" -}}
|
||||||
{{- $defaults := .defaults | default dict -}}
|
{{- $defaults := .defaults | default dict -}}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
# Optional, and only meaningful with settings.limiter: true. The item must
|
# Optional, and only meaningful with settings.limiter: true. The item must
|
||||||
# carry a "valkey-url" field pointing at an EXTERNAL Valkey/Redis; the chart
|
# 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 }}
|
itemPath: {{ .Values.secrets.valkeySecretRef }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
+4
-4
@@ -32,10 +32,10 @@ secrets:
|
|||||||
# unless the bot detection below is switched on.
|
# unless the bot detection below is switched on.
|
||||||
#
|
#
|
||||||
# Setting it renders a second OnePasswordItem and passes the URL to the
|
# 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 -
|
# 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
|
# Chart dependencies are admissible since #214 (README, guideline 14), but
|
||||||
# external connection through a secret is the same shape outline and
|
# this chart declares none, so an external connection through a secret is the
|
||||||
# paperless-ngx use for their databases.
|
# same shape outline and paperless-ngx use for their databases.
|
||||||
valkeySecretRef: null
|
valkeySecretRef: null
|
||||||
#
|
#
|
||||||
# Documented limitation (README, guideline 9): the CONTENT of these items is
|
# 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 }}
|
||||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user