34 Commits
Author SHA1 Message Date
jlab-renovate-bot 0a8ec3a835 Update docker.io/searxng/searxng Docker tag to v2026.10.13-22c64b8d3
renovate/stability-days Updates have not met minimum release age requirement
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 13s
Validate Helm Charts / Build searxng (pull_request) Successful in 7s
Validate Helm Charts / Trivy config searxng (pull_request) Successful in 24s
Install-test / Install-test searxng (pull_request) Successful in 2m28s
Validate Helm Charts / Install-test (pull_request) Successful in 2m28s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Bump level (pull_request) Successful in 7s
Validate Helm Charts / YAML scalars (pull_request) Successful in 13s
2026-10-13 19:35:58 +00:00
jklein 39844fc92c ci: hold every scalar that must be a string, and the required podAffinity
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 15s
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / Bump level (pull_request) Successful in 7s
Validate Helm Charts / Trivy config paperless-ngx (pull_request) Successful in 29s
Validate Helm Charts / Install-test (pull_request) Successful in 5m11s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 16s
Publish Paperless-NGX Helm Chart / Publish Paperless-NGX Helm Chart (push) Successful in 16s
SonarQube Analysis / SonarQube Analysis (push) Successful in 47s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Build paperless-ngx (pull_request) Successful in 9s
Validate Helm Charts / YAML scalars (pull_request) Successful in 15s
Install-test / Install-test paperless-ngx (pull_request) Successful in 5m11s
The test Gitea #11 asked for ("whoever fixes this ships a test case that would
have caught it"), plus the guard for the fix #101 introduces.

ci/run-yaml-scalar-render-test.sh renders every chart with the fixtures the
install-test already uses and asserts three properties Kubernetes itself
requires, so no per-chart expectations are needed: a CronJob schedule is a
non-empty string, every env[].value of every container and initContainer is a
string, and every label and annotation value is a string. The first catches the
loud failure - the render aborting with a YAML error that names the template
and not the value - and the other two catch the quiet one, where the render
succeeds and the API server rejects the object at apply time.

WHY A NEW JOB AND NOT THE INSTALL-TEST. The object that carried the defect is
rendered by no fixture at all: paperless-ngx' consume CronJob sits behind
smbConnection.enabled, false in the chart defaults and false in its install-test
fixture, since the CI cluster has no SMB CSI driver. Nothing in this repository
ever produced it. So the overlays under ci/yaml-scalar/ are the test, not the
scaffolding: they switch such objects on AND feed them hostile values, and the
script fails when an overlay stops reaching its object or when the run asserted
nothing at all.

The fail path was exercised in all three shapes against a throwaway copy of the
chart as it stands on main, not reasoned about: the bare schedule aborted with
"yaml: line 6: did not find expected alphabetic or numeric character"; with only
the schedule quoted, the star-valued env entry aborted on the StatefulSet
instead; and with only the bool-looking value left - a render that succeeds -
the type assertion named the variable, the container and the API server's
message. Green repo-wide otherwise: 30 charts, 408 scalars, 0 failed.

ci/run-selector-match-test.sh additionally holds every
requiredDuringSchedulingIgnoredDuringExecution podAffinity term against all pod
templates of the same render (Gitea #101). That case is not the gap the script's
header describes: a preference that cannot be met is dropped and an
anti-affinity over zero pods is satisfied, but a REQUIRED term matching no pod
can never be satisfied and the pod stays Pending for good. paperless-ngx' mover
selects a label written in another template, which is exactly the copy this test
holds - renaming it would leave both templates rendering perfectly while the
mover silently stopped moving. Exercised by doing that rename in a throwaway
copy: one FAIL naming the selector, the pod templates it missed and the labels
actually present.

Six charts still render their env block bare (matrix-synapse, minecraft-router,
minecraft-server, rallly, satisfactory, teamspeak-server). They are green only
because no fixture of theirs sets a hostile value; README now says so instead of
repeating template-chart's claim that every chart quotes.

Part of #11
Part of #101
2026-10-13 19:10:26 +00:00
jklein 4b2bde505d feat(paperless-ngx)!: quote every operator scalar, pin the consume mover, drop oidcSecretRef
Three issues on one chart, bundled because they share values.yaml,
values.schema.json and the version line, and because the first half of #11
lives in the same template as #101.

Gitea #11, first half: the consume CronJob's schedule was rendered bare, so a
schedule in the commonest cron notation there is - a star, a slash, an interval
- aborted the render with a YAML parse error pointing at the template while the
operator's value appeared nowhere in the message. Quoting it in the values file
does not help; Helm passes the content through. Reproduced before the fix, at
the line the issue names, and fixed with | quote.

Gitea #11, second half: secrets.oidcSecretRef is removed, values and schema
both. No template ever read it, so no OnePasswordItem was created and nothing
reached the pod - the quiet kind of defect that looks like successful
configuration. It is not reimplemented, and the reason is in paperless: the
whole OIDC configuration travels in one json environment variable with no _FILE
variant, so a secretKeyRef cannot substitute a fragment into it. The
operational question is answered by #83 - Keycloak marks a client public per
client, so nothing forces a confidential one here.

In the same breath, oidc.provider.tokenAuthMethod is restricted to "none". Its
comment named client_secret_basic as the example, which asks the provider for
an authentication this chart cannot perform: it configures no client secret and
cannot. The two values read together looked like a working secret setup, and the
symptom was a login dying at the callback with invalid_client. No live release
sets it (measured against all three instances).

Gitea #112 asked for an extraEnv and the chart already had the standard
additive env block - the issue was measured against a deployed 12.2.0 and read
only the top-level keys. What was actually broken is the block itself: it
rendered its operand bare, so a "*"-leading value aborted the render and "true"
or "0755" arrived as a bool or an integer, which the API server rejects after a
green render. It is brought to the repository's documented quoted form
(template-chart carries the rule), which is what makes an arbitrary PAPERLESS_*
variable settable in practice. PAPERLESS_LOGGING_DIR is documented as the
worked example, with the cross-reference it needs: the root cause is the
NFS-exported RWX data volume in jk-cluster-config #360 and this does not fix it
- it only moves the log off that volume. The two Ingress upload-size
annotations had the same bare render and are quoted too.

Gitea #101: the consume mover now runs on the node of the StatefulSet pod, via
a required podAffinity on its label. It mounts the RWX consume volume, and
Longhorn's CSI driver refuses that mount unless it finds a workload pod in
Pending, so a mover on another node left the StatefulSet node's kubelet
retrying every two minutes - 291 events in 10 hours in both releases with SMB
on, none in the release without the CronJob. A static nodeSelector would be
wrong because the StatefulSet pod is not pinned either, and preferred would
reproduce the bug more rarely and harder to find.

The cost of a required term is handled rather than left open:
concurrencyPolicy: Forbid, a startingDeadlineSeconds that also keeps the
controller away from its 100-missed-schedules stop, and an activeDeadlineSeconds
that fails a job which cannot be placed instead of leaving one Pending pod per
schedule. scaleDown already suspends the CronJob; what remains is
paperless.replicas: 0 with SMB on, which now reports a bounded, visible failure.
successfulJobsHistoryLimit is deliberately untouched: Longhorn's check asks for
the existence of a Pending pod, not the absence of Succeeded ones, so those
three pods are the error message's evidence and not its cause.

Side defect fixed on the way: every read of smbConnection.cron dereferenced the
block directly, so an override that had erased it aborted with a nil-pointer
error naming an internal template path instead of falling back to the defaults
this chart promises everywhere else. Measured on origin/main; all reads now go
through a merged-defaults helper that preserves an explicit false.

Major: a value is removed and fails the strict schema afterwards, and runtime
placement changes.

Fixes #11
Fixes #101
Fixes #112
2026-10-13 19:09:45 +00:00
jklein 59d2614384 feat(ci): the chart bump follows the app bump
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Build ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Bump level (pull_request) Successful in 7s
Validate Helm Charts / Trivy config cyberchef (pull_request) Successful in 20s
Validate Helm Charts / Trivy config matrix-synapse (pull_request) Successful in 22s
SonarQube Analysis / SonarQube Analysis (push) Successful in 48s
Validate Helm Charts / Resource quantities (pull_request) Successful in 18s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / Trivy config samba (pull_request) Successful in 21s
The version-bump hook classified every app update that was not a major
as a chart patch, so an app minor arrived as a patch - the mechanism
behind the sonarr incident, where a security default was switched off
inside a bump the conventions told the reviewer not to read (Gitea #128).

ci/renovate-bump-chart.sh now takes the level from Renovate's update
type, passed in as a second argument: major -> major, minor -> MINOR,
patch/digest/pin -> patch. The level comes from Renovate rather than
from comparing appVersion because an appVersion comparison is blind to
the case that prompted the rule - freesailarr keeps a hand-maintained
appVersion for ten upstreams, and prowlarr's 2.5.2 -> 2.6.5 moved an
image tag in values.yaml while appVersion stood still.

Three properties come with it:

- Maximum, not replacement. The chart jump is the greater of the
  chart's own classification and the app jump, so a value that becomes
  required stays a major under an app patch. Mechanically it is also
  what makes a grouped branch deterministic: the hook runs once per
  update from the same base commit, so without it the last call would
  win.
- Non-semver app versions stay at patch for a minor (seven charts). In
  CalVer and date tags the second position is a month; in a four-part
  or suffixed version the positions are not semver positions. An app
  major is not subject to it.
- A missing or unknown update type falls back to the pre-#128
  behaviour. postUpgradeTasks only runs where the command is
  allow-listed on the shared instance, which is not visible from here
  and fails silently, so the worst case is the old classification
  rather than every chart PR red on the version check.

ci/run-bump-level-test.sh holds 49 cases against the version string the
script writes, wired up as the bump-level job: a wrong level is a valid
version string, so the PR would go green and the only symptom would be
a number that understates what it publishes.

The convention texts follow in CLAUDE.md and README, including the
limit the decision deliberately keeps: the reading duty was rejected,
so the incident stays repeatable and the honest number is the whole of
the remedy.

Fixes #128
2026-10-13 18:47:44 +00:00
jklein 978e6fbaa8 refactor(stalwart): name the MTA-STS Ingress objects by index, per the corrected rule
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Install-test / Install-test stalwart (pull_request) Successful in 1m42s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 13s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 13s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 29s
Validate Helm Charts / Install-test (pull_request) Successful in 1m42s
SonarQube Analysis / SonarQube Analysis (push) Successful in 50s
Maintainer's decision, and it reverses this chart's naming rather than the
style guide's rule. The objects become

  <release>-stalwart-mta-sts-ingress, -1, -2, ...

in this repo's idiom ({{ if $i }}-{{ $i }}{{ end }}), the same as
homeassistant's routes and keycloak's realm Ingresses, inlined in the
template instead of behind a helper of its own.

WHY IT GOES THIS WAY. This chart named by issuer, reading the style guide's
index rule as resting on "a route holds no state and no certificate" and
observing that these objects hold one. That reading was right and the
rule's STATED REASON was wrong: the parallel #122 round named by index,
cited the same rule as binding, and entered keycloak into it - whose realm
Ingresses hold certificates too. Two charts did opposite things for one
shape, and the rule had been extended onto cases its own precondition
excluded. #125 is merged and published, so the reason is repaired and the
naming unified on index; keycloak is not touched.

What actually carries the index rule, in three steps that have different
standing and are written as such in the template header and the style guide:
cert-manager here runs WITHOUT --enable-certificate-owner-ref (MEASURED by
the maintainer - absent from the Deployment's args and from the
ControllerConfiguration its --config names, which carries only
kind: ControllerConfiguration and enableGatewayAPI: true); by that default
the Certificate is collected with its Ingress but the SECRET IS NOT
(DOCUMENTED behaviour, not measured here), so a rename recreates the
Certificate object against a surviving secret and issues nothing; and NO
RENAME WAS APPLIED against a cluster. The promise therefore hangs on a
cluster-level flag this repository does not control - with it on, a reorder
would cost a fresh ACME request per renumbered object.

UNCHANGED: the structure. One Ingress per distinct clusterIssuer, one rule
per host, tls grouped by secret, and the abort when one secret spans two
issuers - grouping is a cert-manager question and naming is a style
question, and they are independent.

TWO THINGS THE RENAME OBLIGES.

1. NOTES.txt now prints the mapping - index, object name, issuer, secrets
   and hosts per object, the way keycloak's does for its realms - because
   the name no longer carries the issuer and nothing else in the cluster
   says which object holds which.
2. The reordering hazard is documented as SHARPER than homeassistant's:
   there an index names a host, here it names an issuer GROUP derived from
   the list, so moving one entry can change the grouping and renumber
   objects that were not themselves moved.

AN ASYMMETRY WITH keycloak, named in the template header: there both modes
render one object per entry, so the index question arises twice. Here it
arises on the Ingress side ONLY - Gateway mode renders one HTTPRoute
carrying every hostname, because TLS is none of the route's business, so
there is no index and no name to go stale.

Version stays 1.3.0+upv0.16.25: nothing is published yet, so nothing is
being renamed in a release.

Re-measured after the rename, not assumed: three empty helm template diffs
against origin/main (defaults, CI fixture, overlay), helm lint --strict
clean three ways, every abort path still firing with the structural one
still winning over the per-entry one, the schema still rejecting
mtaSts.enabled, and the HTTPRoute render test still at 64 assertions over
22 charts - object names are not assertions.
2026-10-13 11:03:36 +00:00
jklein 71b757bf9f docs(README): the MTA-STS delta on top of both landed rounds, measured at 64/22
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Install-test / Install-test stalwart (pull_request) Successful in 1m36s
Validate Helm Charts / Install-test (pull_request) Successful in 1m36s
Validate Helm Charts / Build stalwart (pull_request) Successful in 9s
Validate Helm Charts / Resource quantities (pull_request) Successful in 16s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 24s
Second rebase of this branch, and the last one the parallel rounds force.
Both have now merged - rss-bridge plus its documentation corrections, and
the realm hostnames of #122 - so line 184 carries the chain
56/21 -> 58/22 -> 62/22 with the delta convention #124 introduced.

That chain is kept WORD FOR WORD and this round's delta is appended to it;
the only change to the line is an append, verified line by line against
origin/main rather than asserted.

THE NUMBER IS READ, NOT COMPUTED, and that distinction is this paragraph's
own rule. 62 + 2 = 64 is arithmetic over a branch that had a different
substructure when its 2 were measured; the figure committed here comes from
ci/run-httproute-match-test.sh on THIS branch, rebased onto the main that
already holds both other rounds: 64 assertions over 22 charts, 0 failed,
with stalwart contributing 4. It is therefore the first figure in that
sentence that is a genuine running total rather than a delta - the first run
that contains all three rounds, which is exactly where the convention says
to read it.

The earlier 58/21 and 60/22 in this branch's history were each correct for
the substructure they were measured on and are both superseded. The commit
that set 60/22 was dropped in this rebase rather than carried, since its
only content was a number that no longer holds.
2026-10-13 10:42:18 +00:00
jklein 771c26500c fix(stalwart): let the mode conflict, not the MTA-STS entry, name the defect
With BOTH routing modes on and an mtaSts entry missing its tlsSecretName,
the render reported

  mtaSts.hosts[0] (mta-sts.example.com) has no tlsSecretName, and in
  Ingress mode every entry needs one

which is true and is the wrong diagnosis: the values file's defect is that
ingress.enabled and httpRoute.enabled are both true, and "in Ingress mode"
is a claim about a configuration that is not coherent at all. Whoever met
it would add a tlsSecretName, re-run, and reach the real message one round
later - or conclude the MTA-STS feature was the problem.

Measured on this chart, not reasoned about. And it is the second occurrence
of a defect the parallel keycloak round of Gitea #122 found on its own
realm list, independently and in the same round, which is what makes it a
rule rather than one chart's story: a specific validation must not be
reachable in a state that a structural validation already rejects.

Ordering is not the fix and could not be - Helm renders templates in an
order a chart cannot pin, so this template may not assume the httproute
template's abort runs first. Scope is: the Ingress-mode MTA-STS block is
now gated on ingress.enabled AND NOT httpRoute.enabled, so in the
incoherent combination it validates nothing and the only fail left is the
one that names the real problem.

The per-entry guard is NOT dead by this - verified reachable with
ingress.enabled alone. Defaults, the unchanged CI fixture and the overlay
all still render byte-identically against origin/main, and the HTTPRoute
render test still holds 58 assertions over 21 charts.
2026-10-13 10:41:10 +00:00
jklein b4c9823e01 feat(stalwart): MTA-STS routes as a maintainable list, in both routing modes
The policy route for mta-sts.<domain> belonged to another repository
(jk-cluster-config) while pointing at this chart's own Service. It moves
here as mtaSts.hosts, a list, so several domains - subdomains included -
are maintainable in one place and in whichever routing mode is active.

THE LIST IS THE SWITCH. No mtaSts.enabled: "enabled with an empty list"
would be a state with no meaning that the chart would then have to catch.
The schema rejects the key outright, so the rule is enforced rather than
documented. Default [] renders nothing in either mode - the defaults and
the unchanged CI fixture both produce a byte-identical helm template
against origin/main.

Entry form {host, tlsSecretName, clusterIssuer?}. The two TLS fields are
read ONLY in Ingress mode; in Gateway mode TLS sits on the listener, so
requiring them there would demand a value that does nothing.

THE PATH IS HARDWIRED. RFC 8461 fixes /.well-known/mta-sts.txt, rendered
with pathType Exact (type: Exact in the route). A values key for a
protocol-mandated path can only ever be set wrong, and wrong is invisible
from this side. Exact also matters in itself: a prefix on these hostnames
would publish the mail server's whole management surface under them.

ONE INGRESS PER CLUSTER ISSUER, NOT ONE OBJECT - and the plan's own three
requirements forced that. cert-manager reads the two fields at different
scopes: cluster-issuer is an ANNOTATION (object-scoped, and absent means
nothing is issued for that object at all), while secretName is
tls-entry-scoped and yields one Certificate per entry. So an object cannot
both request a certificate and leave another host's secret alone, which is
what an empty per-entry issuer has to mean. Measured in jk-cluster:
ingress-shim names the Certificate AFTER THE SECRET and owns it from the
Ingress, so a shared secret must sit in exactly one object (hence grouping
the tls list by secret, reusing rss-bridge.tlsGroups' shape) and a secret
may not span two issuer groups (hence the abort). Objects are named by
issuer, not by index: an index is only free where the object holds no
certificate, which is the precondition homeassistant's route naming states
for itself.

Gateway mode renders ONE HTTPRoute with every hostname - nothing differs
per host there - attached to the same Gateway(s) as the WebUI route.

WHAT THE CHART CANNOT PROMISE, AND THEREFORE NAMES: it registers a route,
it does not create a domain. The domains live in Stalwart's data store, so
an entry whose domain is missing there answers 404 at a correctly routed
endpoint with every object green - and MTA-STS failures are silent on the
SENDER's side, which delivers in the clear and logs nothing here. Same for
the _mta-sts.<domain> TXT record and the policy's content. The subdomain
trap is documented too: mta-sts.sub.<domain> is one label below
*.<domain>, so a parent wildcard certificate does not cover it.

Not folded into certificate.extraDnsNames deliberately - that would hang
the MAIL certificate on this list, letting a bad policy entry take STARTTLS
and IMAPS with it.

Minor, additive, empty default: 1.2.0+upv0.16.25 -> 1.3.0+upv0.16.25.

CI: ci/httproute/stalwart.yaml enters all three shapes of the entry form
(with issuer, without, and a subdomain), taking the HTTPRoute render test
from 56 assertions over 21 charts to 58 over 21 - stalwart from 2 to 4.
The Ingress half cannot be covered there, since an overlay's whole job is
to switch the mode off, so ci/stalwart/test-values.yaml carries two
entries and the install-test has a real API server accept them.

Fixes #123
2026-10-13 10:41:10 +00:00
jklein 649417b428 Merge pull request 'feat(keycloak): one hostname per realm, in both routing modes' (#125) from feat/keycloak-realm-hosts into main
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 11s
SonarQube Analysis / SonarQube Analysis (push) Successful in 47s
Publish Keycloak Helm Chart / Publish Keycloak Helm Chart (push) Successful in 11s
2026-10-13 10:38:09 +00:00
jklein 5cdb2a6227 Merge origin/main into feat/keycloak-realm-hosts, and settle README line 184
Validate Helm Charts / Resource quantities (pull_request) Successful in 17s
Validate Helm Charts / Selector match (pull_request) Successful in 21s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 26s
Validate Helm Charts / Install-test (pull_request) Successful in 3m43s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Build keycloak (pull_request) Successful in 8s
Install-test / Install-test keycloak (pull_request) Successful in 3m43s
The rss-bridge Gateway-API round landed while this branch was open, and the
two rounds shared exactly one line: the sentence in README.md that carries
the whole history of the httproute-match assertion count.

Merged rather than rebased on purpose: the branch is already pushed and its
history is not rewritten here, so nothing of the other round's text can be
lost to a force push.

Their half is kept verbatim, including the delta rule they added - that
58/22 and 60/21 were measured on branches which did not contain each other,
so the delta is what either number can be trusted for, and the running total
belongs to the first branch that holds both.

This branch IS that first branch, so the open question is now answered with
a measurement instead of an addition: 62 assertions over 22 charts, 0 failed,
read off ci/run-httproute-match-test.sh on this merge. keycloak contributes
8 of them, plus the two "not checked" lines for its realm routes'
backend-less redirect rules.
2026-10-13 10:29:55 +00:00
jklein f530f58b1d docs(rss-bridge): #51 was closed, and the cause was never identified (2.1.1)
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 8s
Install-test / Install-test rss-bridge (pull_request) Successful in 1m6s
Validate Helm Charts / Install-test (pull_request) Successful in 1m6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Publish RSS-Bridge Helm Chart / Publish RSS-Bridge Helm Chart (push) Successful in 12s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 23s
SonarQube Analysis / SonarQube Analysis (push) Successful in 45s
PR #124 merged at 12:25 and the maintainer closed #51 one second later, on
the strength of that round's green install-test. Three statements that
merged with #124 are stale as a result, and they are stale in the
direction that matters - they describe a failing test and an open issue:

  - rss-bridge/templates/rss-bridge.httproute.yaml and values.yaml said
    the install-test "fails on the Gitea runner (#51)", in the present
    tense, and values.yaml added "and that test is still red".
  - ci/httproute/rss-bridge.yaml said "#51 is not closed on the strength
    of a colour", which was true when written and is now the opposite of
    what happened.

Replaced with what is actually known, and the emphasis moved to the part
that outlives the colour: the test went green on a render byte-identical
to main, which rules the chart out and leaves the runner - and THE CAUSE
WAS NEVER IDENTIFIED. The diagnostic programme in #51 (docker info for
userns and the dind configuration, the same image in a local k3d without
remapping) was not executed. So a recurrence is a fresh finding rather
than a known quirk, and the overlay comment now says so where whoever
sees the red will be reading.

Also carries the wording change that was already on this branch when
#124 merged: the reproduced green is phrased as "every run of that branch
since" rather than pinned to a count, because each commit recording the
count invalidated it - a third green arrived with the push after "two".

Patch bump, per the rule #40 was argued on: a comment in a chart
directory is still a change to that chart, so it is a patch plus a
publish. No template output changes - the render for the fixture values
is byte-identical to the pre-#124 main, which the tests re-confirm.
2026-10-13 10:28:09 +00:00
jklein a411ec3c90 docs(rss-bridge): phrase the reproduced green without a run count
The count was pinned at "two consecutive runs" and a third green arrived
with the next push, which is a regress: every commit that records the
number invalidates it. Phrased as "every run of that branch since"
instead, which is stable, still establishes that it is not a fluke, and
cannot go stale in main.

The caution it carries is unchanged and is the one that matters: it is a
colour, not a cause. Nobody has identified what changed on the runner, and
#51's diagnostic programme is still unexecuted.
2026-10-13 10:25:55 +00:00
jklein 8896fc8d7d docs(README): the 58/22 figure is a delta, not a running total
Validate Helm Charts / Detect changed charts (pull_request) Successful in 7s
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 18s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 16s
Install-test / Install-test rss-bridge (pull_request) Successful in 1m4s
Publish RSS-Bridge Helm Chart / Publish RSS-Bridge Helm Chart (push) Successful in 12s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 18s
Validate Helm Charts / Install-test (pull_request) Successful in 1m4s
SonarQube Analysis / SonarQube Analysis (push) Successful in 45s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Gitea #122 (one hostname per keycloak realm) is in flight concurrently and
raises keycloak's share from 4 assertions to 8. Its round was measured at
60 over 21 on a branch that does not contain rss-bridge, while the 58 over
22 here was read on a branch that does not contain the realm hostnames.

Neither total includes the other, so stating 58/22 as the figure after the
sweep would be stale in main the moment the second of the two PRs merges.
Says so now: the delta is what either round can claim, and the running
total is whatever the first run on main prints once both have landed.

Same rule, and the same wording, as the wiki's CI-CD page carries for the
two rounds.
2026-10-13 10:20:48 +00:00
jklein 23ac537e24 docs(rss-bridge): the green install-test reproduced, so say "twice" rather than "one run"
Validate Helm Charts / Detect changed charts (pull_request) Successful in 7s
Validate Helm Charts / Resource quantities (pull_request) Successful in 19s
Validate Helm Charts / Selector match (pull_request) Successful in 16s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 17s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 9s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 20s
Install-test / Install-test rss-bridge (pull_request) Successful in 1m8s
Validate Helm Charts / Install-test (pull_request) Successful in 1m8s
The previous commit recorded the install-test going green and hedged it
with "it is one run". Pushing that commit produced a second run, on a
second commit, and it was green too - run 792 job 2623 (1m10s) and run 794
job 2638 (1m14s), both with the chart genuinely in the matrix.

So the hedge was understated in one direction and the real caution lies
elsewhere. Corrected to say what is actually true: it reproduced over two
consecutive runs, so it is not a fluke - and nobody has identified what
changed on the runner, which is the reason #51 stays open. A green test
does not explain itself, and the diagnostic programme in that issue's body
(docker info on the runner for userns and the dind configuration, the same
image in a local k3d without remapping) remains unexecuted.

"Close an issue on a cause, not on a colour" is unaffected by the count.
2026-10-13 10:17:42 +00:00
jklein 7a2eb29e9e feat(keycloak): one hostname per realm, in both routing modes
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 14s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 24s
Validate Helm Charts / Install-test (pull_request) Successful in 3m29s
Validate Helm Charts / Build keycloak (pull_request) Successful in 8s
Install-test / Install-test keycloak (pull_request) Successful in 3m29s
keycloak.realmHosts gives a realm a hostname of its own: this release's
Service published under a further name, with that name's bare / sent to
/realms/<realm>/account/. It renders in BOTH modes - one Ingress per entry
carrying nginx.ingress.kubernetes.io/app-root, or one HTTPRoute per entry
whose Exact match on / carries a RequestRedirect filter - because a routing value in
this repository is mode-independent by design - ingress.domain is read in
Gateway mode too - so a feature rendering in one mode only would silently
disappear when an installation switched.

Default []: nothing renders, and the render is byte-identical to 1.2.0 for
the fixture's values in both modes. Minor, additive, no value becomes
required.

ONE OBJECT PER ENTRY, not one with several hostnames. spec.hostnames applies
to the whole object and a rule's matches are path-based, so inside a shared
route every entry would match Exact: / and the realm could not be told
apart. Names therefore carry the LIST INDEX, as homeassistant's per-host
routes do and for the same reason - a realm name is not a dependable
object-name fragment. The consequence is written down rather than left to be
discovered: reordering the list renames and recreates the objects.

THE WORK IS IN THE INGRESS HALF, NOT THE ROUTE. Two convention rules do not
survive these objects, and both deviations are required rather than
tolerated: tlsSecretName is a per-entry value with NO default, because these
hostnames already have certificates and several entries typically share one,
so a derived name would ask cert-manager for a second certificate for a name
a wildcard already covers; and the cert-manager annotation is OPTIONAL per
entry, because cert-manager keys a Certificate by the secret it writes and
only one object per secret may request it - the same deliberate omission the
qBittorrent object in freesailarr makes. Annotations are per entry too: of
the four hostnames this was written for, three carry the proxy-buffer pair
and one does not, which a chart-global block could not express.

statusCode: 302 is written out at the field's own default. 301 and 302 are
both Support: Core, so the portability trap of #119 - an implementation
declining an Extended code sets Accepted: False on the WHOLE route - does
not apply here; ingress-nginx emits 302 for app-root, and a value that is
only accidentally right documents nothing.

Five render aborts, each against a failure that reports nothing: an entry
without a host (ingress-nginx reads an empty host as a catch-all for the
whole controller), without a realm (/realms//account/, a 404 behind a
healthy hostname and certificate), without a tlsSecretName in Ingress mode
(the controller's default certificate, a browser-only failure), a repeated
host or one the release already publishes (webhook rejection in Ingress
mode, two merged routes decided by object age in Gateway mode), and a filled
list with both modes off (no object at all, and nothing in the release that
reveals the list was ignored). The entry-shape checks sit in a shared helper
so they exist in both modes, and the Ingress template skips them while
httpRoute.enabled is also true so that the specific message cannot hide the
mutual-exclusion one.

CI: the two entries live in ci/keycloak/test-values.yaml, which
ci/httproute/keycloak.yaml is applied on top of, so one definition covers
both modes - two Ingress objects in the install-test, two HTTPRoutes in the
render test. They cover both Ingress forms, with and without a clusterIssuer,
and the render test holds their inertness in Gateway mode. The httproute
match test goes from 56 assertions over 21 charts to 60, keycloak's share
from 4 to 8 plus two "not checked" lines for the backend-less redirect rules.
The #118 prefix/redirect pairing correctly does not fire: it is scoped to
rules that strip a prefix, and a realm route strips nothing.

NOT IN THIS CHANGE: the migration. Nothing in jk-cluster-config is touched.
With the default [] no running system changes, so the order a takeover has to
run in is documented instead - remove the objects in the owning repository
first, then install with the list filled, because the reverse or both at once
is rejected by ingress-nginx's admission webhook and leaves a Fleet bundle in
ErrApplied until forceSyncGeneration is raised. Whether anything there
depends on those objects BY NAME is unchecked and named as unchecked.

Fixes #122
2026-10-13 10:13:08 +00:00
jklein 7d365c912c docs(rss-bridge): the install-test went green, and the chart is ruled out as the cause
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 8s
Install-test / Install-test rss-bridge (pull_request) Successful in 1m14s
Validate Helm Charts / Install-test (pull_request) Successful in 1m14s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Selector match (pull_request) Successful in 15s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 23s
This chart's install-test had failed on the Gitea runner since the move
(#51): php-fpm as uid 33 unable to read /app/index.php, the startup probe
403ing through its whole 150s budget, the pod never Ready. The chart was
deliberately kept OUT of ci/excluded-charts.txt so that a pass would be
noticed rather than silenced.

It passed, in this PR. Measured from the run: k3d cluster up, helm install
in Ingress mode, pod 1/1 Ready in 22s with 0 restarts, no "Permission
denied" anywhere in the job, and all eight check contexts green.

The chart is ruled out as the cause by a diff rather than by an argument:
the rendered output for the fixture values is BYTE-IDENTICAL to
origin/main - proven in the previous commit to justify the Minor bump - so
the pod spec that succeeded is, line for line, the pod spec that had been
failing. The implicated layer is the runner, which is where #51 had
already placed the burden of proof. The control case arrived for free,
from evidence collected for an unrelated reason.

What this does NOT establish, and the docs say so: that it is repaired.
It is one run, nobody has identified what changed on the runner, and an
intermittent effect needs several runs per side. #51 therefore stays open
and is to be closed on a cause, not on a colour - which was the standing
instruction there before the green arrived.

Corrected accordingly, because shipping documentation that says the test
fails while it demonstrably passed is the half-finished state this repo
tries not to leave behind:

  - README: the httproute-test paragraph, guideline 4's closing passage on
    the last chart of the sweep, and the chart table row.
  - rss-bridge/templates/NOTES.txt: the Gateway branch no longer claims
    the chart has no successful install-test. What remains true, and is
    what it now says, is that the install-test installs the INGRESS mode,
    so the Gateway mode is render-only - the same position as all 21 other
    charts of the sweep rather than a worse one.
  - ci/httproute/rss-bridge.yaml: same correction, plus why the overlay
    stays load-bearing either way.

The wiki carries the same correction in the same round (rss-bridge, CI-CD)
plus a Lessons-Learned entry on the decision that produced the signal: an
excluded test is not a quiet test but a blind one, because you also lose
the moment the fault stops existing.
2026-10-13 10:10:28 +00:00
jklein 45b2d2ea4e feat(rss-bridge): Gateway-API as an alternative to the Ingress (last chart)
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 17s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 8s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 24s
Install-test / Install-test rss-bridge (pull_request) Successful in 1m10s
Validate Helm Charts / Install-test (pull_request) Successful in 1m10s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 15s
rss-bridge was the twenty-second and last chart of the Gateway-API sweep:
22 charts carried an Ingress template, 21 an HTTPRoute template, and the
difference was exactly this one. It had been held back as frozen by #51 -
a runner defect, not a property of the chart - and that freeze is lifted.

One HTTPRoute carrying ALL THREE hostnames, because nothing differs per
host: every name serves the same Service at "/", measured on the live
Ingress. spec.hostnames is a list, so one object is the whole answer and a
second would repeat one rule. (Unlike homeassistant, whose paths differ per
host, or matrix-synapse, whose two routes want different backends.)

httpRoute.hostnames is its own MANDATORY value, which is a third shape for
this repository and follows from the values rather than from taste. Most
charts fall back to ingress.domain; freesailarr and homeassistant reject a
hostnames key outright, because a chart-global list could not say which app
or host entry it belonged to. This chart has no ingress.domain either, but
its names are unambiguous - and the ingress.hosts list it would otherwise
reuse carries per-host tlsSecretName values that address nothing once TLS
sits on the Gateway's listener. Reusing it would drag a structure whose
other half is meaningless into the new mode.

rss-bridge.tlsGroups is therefore INERT in Gateway mode, and that is named
as a simplification rather than quietly translated: the helper exists only
to group hosts by TLS secret name, and there is nothing left to group. The
required() on ingress.hosts needed no change to become conditional - it
already sits inside the Ingress template behind ingress.enabled - so what
is new is that values.yaml and the schema now say so.

The ingress.enabled-derived-value screen that caught sure, freesailarr and
ytdlp-web-player was run here and came back CLEAN: outside the Ingress
template the only readers of an ingress value are NOTES.txt, which knows
its own mode, and tlsGroups, called only from behind the switch. The app
takes no base URL in its environment, so no "published" helper is needed.

First chart of the sweep whose Gateway branch has NO loss to explain, and
it was measured rather than assumed (read-only kubectl against the live
object): the running Ingress carries the cert-manager annotation plus Helm
and Rancher/Fleet bookkeeping, and not one nginx.ingress.kubernetes.io
annotation. The group-3 screen came back empty, so NOTES.txt says so
positively instead of copying a loss paragraph from another chart.

Also corrects the last occurrence in the repository of the outdated
sentence "charts in this repo deliberately have no dependencies"
(rss-bridge/templates/_helpers.tpl) - dependencies are admissible but
expensive since #214, and ytdlp-web-player, stalwart and paperless-ngx use
subcharts. The reason the helper is duplicated is unchanged, only its
content: sharing it would mean a library chart, a second artefact to
publish, version and pull for about one change a year.

Minor, not major: additive, defaults unchanged, and relaxing a required is
not a tightening. Proven with an EMPTY helm template diff against
origin/main for the defaults (229 lines, byte-identical); the Gateway mode
with its overlay renders the HTTPRoute in place of the Ingress and nothing
else changes. appVersion untouched - 2026-09-30 is the newest date tag the
registry has, everything newer is sha-* and latest.

ci/run-httproute-match-test.sh goes from 56 assertions over 21 charts to
58 over 22. The overlay at ci/httproute/rss-bridge.yaml has to SUPPLY
hostnames, the second in the repository that does (homeassistant's was the
first, for a different reason), because this chart has no fallback.

NOT PROVEN, and stated in the PR as well: that traffic flows. No Gateway
controller runs in CI and no HTTPRoute reaches "Accepted: True" there. For
this chart there is additionally NO successful install-test at all - it
fails on the Gitea runner in either mode (#51, reproduced on two images
while the same chart and image run healthy in the cluster). The chart is
deliberately NOT added to ci/excluded-charts.txt: an exclusion would
silence the signal, and the day the runner is fixed the test goes green by
itself. So the only red contexts expected here are
"Install-test / Install-test rss-bridge" and the collective
"Validate Helm Charts / Install-test".

Fixes #114
Fixes #40
2026-10-13 09:58:11 +00:00
jlab-renovate-bot 6517643cd6 Update urlaubsverwaltung/urlaubsverwaltung Docker tag to v6.17.0
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 11s
SonarQube Analysis / SonarQube Analysis (push) Successful in 43s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 11s
2026-10-13 09:38:25 +00:00
jlab-renovate-bot 96dd1adbda Update l4rm4nd/vouchervault Docker tag to v1.30.3
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Resource quantities (pull_request) Successful in 19s
Validate Helm Charts / Selector match (pull_request) Successful in 18s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 17s
Install-test / Install-test voucher-vault (pull_request) Successful in 2m25s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 23s
Publish VoucherVault Helm Chart / Publish VoucherVault Helm Chart (push) Successful in 23s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 10s
Validate Helm Charts / Build voucher-vault (pull_request) Successful in 6s
Validate Helm Charts / Trivy config voucher-vault (pull_request) Successful in 19s
Validate Helm Charts / Install-test (pull_request) Successful in 2m25s
SonarQube Analysis / SonarQube Analysis (push) Successful in 1m4s
2026-10-13 09:06:04 +00:00
jklein 6d79d8f270 docs(README): group 3 of the Gateway-API sweep, and four corrections it forced
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 15s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 13s
Publish Homeassistant Helm Chart / Publish Homeassistant Helm Chart (push) Successful in 13s
Publish Keycloak Helm Chart / Publish Keycloak Helm Chart (push) Successful in 12s
Publish Matrix Synapse Helm Chart / Publish Matrix Synapse Helm Chart (push) Successful in 13s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 13s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 13s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 14s
Publish YTDLP Web Player Helm Chart / Publish YTDLP Web Player Helm Chart (push) Successful in 14s
Publish Bulwark Helm Chart / Publish Bulwark Helm Chart (push) Successful in 15s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 13s
SonarQube Analysis / SonarQube Analysis (push) Successful in 53s
The figures: the HTTPRoute render test goes from 37 assertions over 15 charts
to **56 over 21**, with the per-chart breakdown, and the sweep is finished
except for the frozen rss-bridge - so #114 still cannot be closed while #51 is
open.

FOUR CORRECTIONS, because three of #114's descriptions of the group-3 shapes
and one of this file's own forward-looking claims did not survive contact with
the charts. The corrected list is a table in section 4, since that is what
somebody will copy from:

  - bulwark has ONE Ingress, not two. keycloak has two.
  - bulwark has NO rewrite-target and cannot have one: NEXT_PUBLIC_BASE_PATH is
    compiled into its bundle. #114's table reached "bulwark, freesailarr" by
    grepping for the string, and every occurrence in that chart is an
    explanation of why the annotation does not help. freesailarr is therefore
    still the only chart the #118 prefix/redirect pairing applies to - the
    HTTPRoute-test section said bulwark would meet it "when group 3 lands",
    and now says why it does not.
  - matrix-synapse does NOT render in a loop over several apps (that is
    freesailarr): one Ingress object with two HOST rules, which has to become
    two HTTPRoute objects because spec.hostnames applies to the whole object.
  - stalwart does not render a routing object per node group - its Services and
    StatefulSets do; one route targets ingress.nodeGroup's own http Service.

THREE NEW CONVENTIONS, written where the next sweep will read them:

  - SCREEN THE LIVE OBJECT, not only the chart. Four of group 3's six charts
    carried an annotation the chart never sets and the Gateway mode drops -
    keycloak's proxy buffers on BOTH its Ingresses, matrix-synapse's body size
    and snippet, stalwart's whole CORS block, bulwark's body size. None of them
    appears in a git grep of this repo. At #118 this was found after shipping;
    here it was measured beforehand with read-only kubectl, and that
    measurement is now part of the procedure.
  - READ THE DIRECTIVE before calling an annotation load-bearing.
    matrix-synapse's configuration-snippet uses more_set_headers, which is the
    headers-more module's OUTPUT-header directive - so those headers go on the
    response and Synapse never sees them. The scariest-looking loss in the
    group is not a loss, and its NOTES.txt says not to re-create it.
  - "NO COUNTERPART" IS A CLAIM TO CHECK AGAINST THE PINNED CRDs, and for CORS
    it is false. Grepped out of gateway-api v1.6.3 standard channel: HTTPRoute
    filters carry a full cors field (GEP-1767), Support: Extended, and
    responseHeaderModifier is Extended too rather than Core. stalwart still
    renders neither, and the three reasons are recorded - no CORS values to
    render from, Extended support is not support (Cilium accepts the filter and
    forwards the preflight anyway), and the preflight is the half a bare header
    filter cannot do while Authorization in the allowed headers makes every
    request preflighted.

Also: pathType ImplementationSpecific has no counterpart and is a chart DEFAULT
in homeassistant - PathPrefix on "/", render abort on any other path, and
RegularExpression deliberately not offered. The ingress.enabled-derived-value
screen is closed: ytdlp-web-player was the third and last find, and its guard
is the one that is needed MORE in Gateway mode, because the ingress-nginx
admission webhook never sees an HTTPRoute and the silent outcome is an
authentication bypass that depends on DNS. A subchart does not come along:
oauth2-proxy has no Gateway support, so in the recommended configuration the
subchart's Ingress publishes the release in both of the chart's modes. And
stalwart is the only chart in the sweep still rendering a Certificate in
Gateway mode, for its mail listeners.

The homeassistant documented-exception bullet now covers its Gateway mode: no
httpRoute.hostnames key (the second chart without it), one route per
ingress.hosts entry, index-based route names and what reordering the list
costs.

Part of #114
2026-10-13 08:59:09 +00:00
jklein e4b41577ae feat(ytdlp-web-player): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep, last chart - and the one that carried the group-2
pattern this sweep was told to look for. It reads ingress.enabled OUTSIDE its
Ingress template, in ytdlp-web-player.validate, and in Gateway mode
ingress.enabled is false by construction, so that guard would have gone
silent. Fixed with ytdlp-web-player.published = ingress.enabled OR
httpRoute.enabled - the freesailarr.published / sure.publishedOverTls shape,
identical to ingress.enabled while httpRoute.enabled is false, which is why it
changes no existing render.

AND THE GUARD IS NEEDED MORE IN GATEWAY MODE, NOT LESS. It refuses to publish
the application and the oauth2-proxy on the same host:

  - In INGRESS mode the consequence of ignoring it is loud: ingress-nginx's
    admission webhook rejects the second Ingress and the Fleet bundle goes to
    ErrApplied.
  - In GATEWAY mode THE WEBHOOK NEVER FIRES. It compares Ingress objects with
    Ingress objects, and an HTTPRoute is a different kind - so nothing rejects
    anything, both objects stay valid, and one hostname is served over two
    data paths that are NOT equivalent: the proxy's Ingress goes through
    AUTHENTICATION and this chart's HTTPRoute goes straight to the
    application. Which one a client reaches depends on what DNS resolves to.
    That is an authentication bypass that comes and goes with a DNS record and
    that no Kubernetes object reports.

So the Gateway-mode abort has its own message saying exactly that. Both
messages exercised, plus the collision reached through httpRoute.hostnames
rather than through the ingress.domain fallback, and the guard still firing
when only the Deployment is rendered.

THE SUBCHART HAS NO GATEWAY SUPPORT, and this chart cannot give it one -
upstream's chart is consumed as it comes (README, "Chart dependencies"). It
renders an Ingress or nothing. So in the RECOMMENDED configuration, where the
proxy sits in the data path and owns the host, the publishing object is the
SUBCHART'S INGRESS in both of this chart's modes, and turning httpRoute on
buys nothing. The route is for the proxy-disabled case, or for the application
on a host of its own - which is the documented unauthenticated bypass. Said in
the template header, in values.yaml, in the schema and in the proxy branch of
NOTES.txt, because someone migrating hosts will otherwise set the value and
wonder why nothing moved.

The 16k proxy-buffer-size this chart recommends is NOT lost by this switch,
and that needed stating rather than assuming: it belongs on the PROXY's
Ingress (oauth2-proxy.ingress.annotations) as the second half of the 4kB
session-cookie trap, and the subchart keeps rendering an Ingress. This chart's
own ingress.annotations default is {} and it sets nothing but the cert-manager
annotation, so there is no chart-side annotation loss to name.

CORRECTED CLAIM: values.yaml and the schema both said that everything the
Ingress needs is read only inside its template, so switching it off freed BOTH
ingress.domain and ingress.clusterIssuer. clusterIssuer is still freed; domain
is now read by the HTTPRoute, so it is freed only with both modes off. The
reason the claim was made still holds and is kept - the application has no
base-URL setting and builds no absolute links.

NOTHING WAS MEASURED ON A LIVE INGRESS OF THIS CHART, because there is none.
Measured 2026-10-12: the release runs in namespace jlab-player with both
Deployments Ready, and NOT ONE of the cluster's 23 Ingress objects is in that
namespace - neither this chart's nor the subchart's. What publishes it instead
could not be determined: this workspace's identity may not list httproutes or
secrets, so neither a hand-written HTTPRoute nor the release's Helm values are
readable. Recorded as the open question it is, in values.yaml and in the
overlay, rather than guessed at.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/ytdlp-web-player/test-values.yaml.

Part of #114
2026-10-13 08:59:09 +00:00
jklein b10800da2a feat(homeassistant): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep, and the one chart where the convention had to bend
rather than the chart. homeassistant is the DOCUMENTED INGRESS EXCEPTION
(README): upstream's flexible values, a list of hosts each with its own list
of paths, instead of the repo's required domain/clusterIssuer pair. The
exception stays - nothing here unifies it.

NO httpRoute.hostnames KEY, which is the freesailarr shape from group 2 and
for the same reason: there is no ingress.domain to fall back to, and a
chart-global hostname list could not say which host entry - and therefore
which set of paths - it belonged to. So httpRoute carries only enabled and
parentRefs, the hostnames come from ingress.hosts, and the chart renders ONE
HTTPRoute PER ENTRY with one rule per path of that entry. One object with
several hostnames would be wrong, not merely different: an HTTPRoute's rules
apply to every hostname of the object, and per-host paths are this chart's
whole point. The schema rejects httpRoute.hostnames outright, so the absence
is enforced rather than merely intended.

ingress.hosts is therefore read in BOTH modes and is not an Ingress-only
value. ingress.className, ingress.tls and ingress.annotations are not read in
Gateway mode: class and certificate belong to the Gateway's listener, and
there is no annotation channel.

pathType IS THE SHARP EDGE, AND THE CHART'S DEFAULT IS THE AWKWARD VALUE.
Ingress has Exact/Prefix/ImplementationSpecific; the Gateway API has
Exact/PathPrefix/RegularExpression. Exact and Prefix map cleanly.
ImplementationSpecific - the default in values.yaml - means "ask the
controller" and has NO counterpart, so:

  - on "/" it renders as PathPrefix. Every controller treats a root
    ImplementationSpecific path as a prefix match, so the chart's own default
    works in this mode unchanged (verified: the untouched default renders a
    valid route).
  - on ANY OTHER path the render ABORTS, naming the value and asking for
    Prefix or Exact. In ingress-nginx - this chart's default class - an
    ImplementationSpecific sub-path is a REGEX, and PathPrefix is not one.
    Mapping it silently would change how the path matches while the render
    stayed green, which is exactly the class of defect #118 was.

RegularExpression is deliberately not offered as an escape hatch: its support
level is Implementation-specific, which decision 1 of #114 forbids.

NOTHING WAS MEASURED ON THE LIVE CLUSTER FOR THIS CHART, because there is
nothing to measure: homeassistant is NOT DEPLOYED in jk-cluster (checked
2026-10-12 - 23 Ingress objects cluster-wide, none this chart's, and no
workload of it in any namespace). So it is the one chart of the six with no
operator-supplied annotation to lose. values.yaml and NOTES.txt say that as
an absence of evidence about this installation rather than as a property of
the chart.

Route NAMES carry the entry's INDEX, not its hostname - a hostname is not a
dependable object-name fragment. Consequence named in values.yaml and in the
template: reordering ingress.hosts renames routes and therefore recreates
rather than edits them. Harmless, since a route holds no state and no
certificate.

ci/httproute/homeassistant.yaml has to SUPPLY HOSTS, which no other overlay in
the sweep does, and it adds coverage the install-test never had: this chart's
ingress.enabled defaults to FALSE and the fixture does not switch it on, so
no routing object was exercised at all before. Two entries on purpose - a
plain one, and one with two paths including an Exact - because that second
entry is what proves the chart renders one object per host rather than one
object with several hostnames. 5 assertions over 2 routes and 3 rules.

NOTES.txt asks $published where it asked ingress.enabled, and the
trusted_proxies section now warns that moving from an Ingress to a Gateway
CHANGES the proxy's pod network: a list carried over unchanged turns every
request into a 400 if the CIDR differs, or silently keeps attributing every
client to one address if it does not.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main, both with the fixture as it stands and with ingress.enabled=true.

Part of #114
2026-10-13 08:59:09 +00:00
jklein 3e969c5659 feat(stalwart): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. ONE route - not one per node group. #114 describes
this chart as rendering "per node group"; its SERVICES and StatefulSets are,
the routing object is not and must not be. The route targets exactly
ingress.nodeGroup's own http Service and keeps both of the Ingress's guards,
because the WebUI's setup and management screens are not safe to spread over a
group that may not run the management tasks. ingress.nodeGroup is therefore
shared by both modes: it names where the management surface is published from,
which is not a property of the Ingress.

THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
anything of the TLS path does. stalwart.certificate.yaml covers STARTTLS on 25
and implicit TLS on 465/993; those connections pass through neither an ingress
controller nor a Gateway. So in Gateway mode ingress.domain is still required
(the certificate's dnsNames read it), ingress.clusterIssuer is still required
unless certificate.clusterIssuer is set, and the Certificate must NOT be
removed as part of a migration - deleting it drops SMTP and IMAP back to
Stalwart's self-signed certificate, with clients seeing a name mismatch while
every Kubernetes object stays green. Verified in the render: Gateway mode emits
Certificate + HTTPRoute and no Ingress.

WHAT THIS MODE DROPS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
all of it operator-supplied through ingress.annotations (this chart sets none
of it):

    enable-cors: true
    cors-allow-origin:  https://webmail.bekemeyer-klein.de
    cors-allow-methods: GET, POST, OPTIONS
    cors-allow-headers: Authorization, Content-Type

That origin is BULWARK, the webmail chart in this same group. Without the CORS
configuration the browser-side webmail cannot call this server's JMAP endpoint
at all, and the failure is entirely client-side: a console error, nothing in
the server's logs.

AND "no counterpart" WOULD HAVE BEEN WRONG. Measured by grepping the CRDs this
repo pins (gateway-api v1.6.3 standard channel, ci/gateway-api-crds.sh) rather
than inferred: HTTPRoute's filters carry a full 'cors' field (GEP-1767) with
allowOrigins/allowMethods/allowHeaders/allowCredentials/maxAge, and its
documented level - verbatim from that CRD - is "Support: Extended".
responseHeaderModifier is in the same CRD and is also Extended, not Core.

The chart still does not render it, for three reasons in order:

  1. The chart has NO CORS values. The live configuration is the operator's,
     and the chart sets no CORS annotations in Ingress mode either - so
     rendering a filter means inventing a values surface that would exist in
     Gateway mode only. That asymmetry is its own defect and its own work.
  2. Extended support is not support. A rejected route is rejected WHOLE,
     which would take the WebUI and JMAP down together (the reasoning #119
     reached for statusCode); and the observed failure is quieter - Cilium
     accepts filters.type: CORS and forwards the preflight upstream anyway
     (cilium/cilium#45272), i.e. Accepted: True with the behaviour absent.
  3. Half a CORS is worse than none, and the translatable half is the useless
     one HERE. A responseHeaderModifier can add the Access-Control-Allow-*
     headers; the PREFLIGHT has to be answered BY THE GATEWAY, which the CORS
     filter's own contract states and a bare header filter cannot do. Because
     the allowed headers include Authorization, EVERY webmail request is
     preflighted - so the expressible half is exactly the half that does not
     help.

Named, not rebuilt (decision 2 of #114), in values.yaml, in the schema and in
the Gateway branch of NOTES.txt, with the operational instruction: configure
CORS at the Gateway with that implementation's own support BEFORE moving this
host, and test it from the webmail in a browser - curl sends no preflight, so
it passes while the webmail fails.

NOTES.txt now asks $published (ingress.enabled OR httpRoute.enabled) where it
asked ingress.enabled, and two paragraphs that named the ingress controller
specifically now name the Gateway in that mode: the auto-ban step (switching
API does not avoid or reset it - one proxy address instead of another) and the
NetworkPolicy paragraph, where the namespace to allow is the Gateway's data
plane rather than an ingress controller's.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/stalwart/test-values.yaml.

Part of #114
2026-10-13 08:59:09 +00:00
jklein e54cfe5fdd feat(matrix-synapse): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. TWO HTTPRoutes - and the shape is not the one the
issue described.

#114 says this chart renders its Ingresses in a LOOP OVER SEVERAL APPS. It
does not; that is freesailarr. It renders ONE Ingress object with TWO HOST
rules: the homeserver on ingress.domain at "/", and the delegation nginx on
matrix.delegation.serverUrl at "/.well-known/matrix". An HTTPRoute's
spec.hostnames applies to the WHOLE object, so two hostnames that want
different paths and different backends have to be two objects. That is the
only faithful translation, not a stylistic choice.

httpRoute.delegation.hostnames deliberately does NOT fall back to
httpRoute.hostnames. The server name is what REMOTE homeservers resolve and is
a different thing from the homeserver's own hostname; the migration order -
homeserver host first, server name last - depends on telling them apart, and a
chart-global hostname list covering both would erase the distinction.
httpRoute.delegation.parentRefs falls back to httpRoute.parentRefs, which
matters because the server name is usually an apex domain behind a different
Gateway.

ONE THING THIS MODE IMPROVES, and it is the awkward part of Matrix delegation.
Under ingress-nginx two Ingress objects cannot both claim one hostname - the
admission webhook rejects the second - which is why the delegation rule has to
live inside this chart's single Ingress object even though the apex domain
usually belongs to a website. HTTPRoutes attached to the same listener for the
same hostname are merged by the implementation, longest match winning, so the
/.well-known/matrix route can sit beside whatever owns that apex.

WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT ON 2026-10-12 - and ONE OF
THE TWO IS NOT WHAT IT LOOKS LIKE. The live Ingress carried, all of it
operator-supplied through ingress.annotations (this chart sets none of it):

    nginx.ingress.kubernetes.io/proxy-body-size:    50m
    nginx.org/client-max-body-size:                 50m
    nginx.ingress.kubernetes.io/configuration-snippet: |
      more_set_headers "X-Forwarded-For $remote_addr";
      more_set_headers "X-Forwarded-Proto $scheme";
      more_set_headers "Host $host:$server_port";

1. THE BODY SIZE IS THE REAL LOSS. Synapse's media repository takes uploads;
   its own ceiling is max_upload_size in homeserver.yaml, which this chart
   cannot see because the whole config arrives from 1Password. The effective
   limit is the smaller of the two, and in this mode the proxy half becomes
   the GATEWAY'S default - neither number anyone can point at is necessarily
   the one that decides. No counterpart in the Gateway API, so per decision 2
   of #114 it is documented, not rebuilt. nginx.org/client-max-body-size is
   the same limit spelled for NGINX Inc's controller, a hedge across both
   implementations, and it goes for the same reason. (#114's own measured
   table did not list matrix-synapse under proxy-body-size at all; the live
   object has it.)

2. THE SNIPPET IS NOT LOAD-BEARING, which took checking rather than assuming.
   It looks like what Synapse's x_forwarded setting depends on - client IP for
   rate limiting, and federation. It is not: more_set_headers is the
   headers-more module's OUTPUT-header directive ("Replaces (if any) or adds
   (if not any) the specified output headers"), the request-side directive
   being more_set_input_headers. So those three headers are added to the
   RESPONSE TO THE CLIENT; Synapse never sees them, and what it reads with
   x_forwarded: true is the REQUEST's X-Forwarded-For, which ingress-nginx
   supplies itself. The Gateway API's ResponseHeaderModifier could not rebuild
   it anyway (it knows no nginx variables), and should not: NOTES.txt says in
   as many words not to re-create it and not to read its absence as the cause
   of a rate-limiting or federation problem.

   Stated as what it is - a reading of the directive's documented semantics.
   Nothing here observed a Synapse instance with and without the snippet.

NOTES.txt now asks $published (ingress.enabled OR httpRoute.enabled) wherever
it used to ask ingress.enabled - the server-name/serverUrl mismatch warning,
the "clients work fine" aside and the scaled-to-zero paragraph were all really
asking whether anything publishes the release.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/matrix-synapse/test-values.yaml.

Part of #114
2026-10-13 08:59:09 +00:00
jklein 9625d8b182 feat(bulwark): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. One route, one rule, at the root - and THREE
EXPECTATIONS ABOUT THIS CHART DID NOT SURVIVE CONTACT WITH IT:

  - #114 says bulwark has TWO Ingress objects. It has ONE. keycloak has two.
  - #114's measured table lists bulwark under rewrite-target. It has no
    rewrite-target annotation and CANNOT have one: NEXT_PUBLIC_BASE_PATH is
    compiled into the bundle, so the application cannot be served under a
    sub-path, and stripping a prefix at the proxy would yield a page that
    loads and then fetches everything from the wrong place. Every occurrence
    of the string "rewrite-target" in this chart - values.yaml, the schema,
    the Ingress template - is an explanation of exactly that. A grep for the
    string finds the refutations and counts them as occurrences.
  - ci/run-httproute-match-test.sh said in its header that bulwark was the
    second rewrite-target chart and would meet the #118 prefix/redirect
    assurance when group 3 landed. Group 3 landed and it does not: with no
    URLRewrite on a prefix other than "/", the assurance's precondition is
    never met here. The header is corrected rather than left standing, and it
    now says freesailarr is the only chart the pairing applies to.

So no redirect rule is added, and none is needed.

WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT rather than inferred. On
2026-10-12 the live Ingress on webmail.bekemeyer-klein.de carried

    nginx.ingress.kubernetes.io/proxy-body-size: 25m

supplied by the OPERATOR through ingress.annotations - values.yaml recommends
exactly that value and explains why the chart cannot pick it, so the chart
sets nothing and a reader of this repository alone would not find it. It is
the single annotation this installation depends on, and the Gateway API has no
body-size counterpart: implementation policy, not routing. Per decisions 1 and
2 of #114 it is NAMED, not rebuilt - in values.yaml, in the schema and in the
Gateway branch of NOTES.txt, in this application's own terms: an attachment
above the Gateway's default is rejected before Bulwark sees a byte, the 413
comes from the proxy, and it arrives after the user has written the mail and
attached the file.

CORRECTED CLAIM, the same one garage and calibre-web-automated needed: the
Ingress template, the bulwark.publicUrl helper and the schema all stated that
nothing outside the Ingress template FUNCTIONALLY reads ingress.domain. The
HTTPRoute reads it, so the claim held only while both modes were off. All
three now say so, and the route carries a required() of its own.

bulwark.publicUrl follows the ACTIVE mode instead of always printing
ingress.domain. The two differ exactly when httpRoute.hostnames is set, which
is how a migration off the Ingress runs - and printing the old host there
would hand the operator an OIDC redirect URI for the hostname being migrated
away from, a failure that surfaces as a rejected login at the identity
provider and says nothing about this chart.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/bulwark/test-values.yaml.

Part of #114
2026-10-13 08:59:09 +00:00
jklein d0f0d49a93 feat(keycloak): Gateway-API as an alternative to the two Ingresses
Group 3 of the #114 sweep, first chart. keycloak has TWO Ingress objects -
the public hostname and the admin hostname - so it gets TWO HTTPRoutes, with
httpRoute.admin.parentRefs falling back to httpRoute.parentRefs exactly as
ingress.admin.className falls back to ingress.className: "which data path" is
a property of the Gateway in this API, not of the route, so an internal
Gateway here is what an internal ingress class is in the other mode.

The admin route stays gated on ingress.admin.enabled, because that value is
not an Ingress switch - it also decides whether KC_HOSTNAME_ADMIN is set on
the Deployment, i.e. whether the admin hostname exists at all. Switching to
Gateway mode therefore must not require turning it off.

FIXED ALONG THE WAY: the admin Ingress did not hang off ingress.enabled.
Measured on origin/main, `helm template -f ci/keycloak/test-values.yaml --set
ingress.enabled=false` emitted kc-keycloak-admin-ingress - so
"ingress.enabled: false" did not mean "no Ingress", and ingress.clusterIssuer
stayed required for an object nobody asked for. It also made Gateway mode
impossible: the admin Ingress would have gone on claiming the admin host next
to the admin HTTPRoute, one host over two data paths, which is the exact
conflict the mutual exclusion exists to prevent. The default render is
unchanged; what changed is a combination that could not be coherent.

WHAT THIS MODE DROPS, MEASURED ON THE LIVE CLUSTER rather than inferred. On
2026-10-12 BOTH of this chart's Ingress objects in jk-cluster carried

    nginx.ingress.kubernetes.io/proxy-buffer-size:    16k
    nginx.ingress.kubernetes.io/proxy-buffers-number: 4

set by the OPERATOR through ingress.annotations and ingress.admin.annotations
- the chart sets neither, which is why reading the chart alone does not reveal
them. The Gateway API has no annotation channel and no counterpart for proxy
buffer sizing; it is implementation policy, not routing. Per decision 1 of
#114 the chart renders no policy object, so this is NAMED and not rebuilt, in
values.yaml, in the schema and in the Gateway branch of NOTES.txt, with the
consequence spelled out in Keycloak's own terms: large headers (JWTs, SAML
assertions, long Set-Cookie chains on the login flow) make a too-small buffer
answer 502 at the PROXY while Keycloak logs the request as successful.

Named and deliberately not touched: four further Ingress objects in the same
namespace (keycloak-realm-*-ingress, with app-root) belong to the Helm release
jk-cluster-config-fleet-cd-keycloak-realm-ingress, i.e. to another repository.
They point at this release's Service under four further hostnames. This chart
renders exactly the two routes its own two Ingresses cover.

Minor: additive, defaults unchanged. Empty helm template diff against
origin/main with ci/keycloak/test-values.yaml.

Part of #114
2026-10-13 08:59:09 +00:00
jklein d36112aaf6 chore(renovate): group the freesailarr stack into one PR per cycle
Validate Helm Charts / Detect changed charts (pull_request) Successful in 6s
Validate Helm Charts / Resource quantities (pull_request) Successful in 30s
Validate Helm Charts / Selector match (pull_request) Successful in 17s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 7s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 24s
Install-test / Install-test freesailarr (pull_request) Successful in 4m9s
Validate Helm Charts / Install-test (pull_request) Successful in 4m9s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
SonarQube Analysis / SonarQube Analysis (push) Successful in 50s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 12s
The first groupName in this config, and a deliberate reversal for one chart
of the stance the file records above its app-version managers: that the
helm-values manager finds freesailarr's ten images and proposes one update
per image. That described what Renovate does, not what the chart wants. For
every other chart here one PR per image is already one PR per chart.

What it fixes, measured against the Gitea API on 2026-10-12: eight of the
ten images had an open PR at the same moment (#102, #103, #104, #105, #106,
#107, #108, #111). Each edits freesailarr/values.yaml and, through the
postUpgradeTasks hook, freesailarr/Chart.yaml - so the API reported
mergeable: false for all eight, since they rewrite the same version: line.
One can merge; the other seven then need a rebase each, and every rebase
restarts the most expensive install-test in this repo.

The second symptom is sharper: all eight carried
version: 3.1.1+up1.0.0 -> 3.1.2+up1.0.0 while main already stood at
3.3.0+up1.0.0, because the hook derives everything from the base commit and
those branches predated the Gateway-API rounds. A rebase repairs it, which
is the point - nothing fails if the rebase does not happen first.

Two rules rather than one, and not for tidiness: renovate rejects the
combination outright with "packageRules cannot combine both
matchUpdateTypes and separateMinorPatch", a config-validation error. That
is consistent, since separateMinorPatch decides the split a rule would then
be selecting on. Without the second rule the group still arrives as two
PRs - verified by dry-run against a checkout holding those eight updates,
they split onto renovate/freesailarr-stack (the three minors) and
renovate/patch-freesailarr-stack (the five patches); note that the setting
prefixes the branch rather than suffixing it. With both rules, one branch
carries all eight.

Decisions, with their reasons in the rules themselves: matched by file name
so that no second list of image names has to be kept current (an image
added later would silently never join the group); mediathekarr included,
since its digest PR blocks the others identically - checked with a stale
digest, because a current pin makes a working and a broken rule look the
same; gluetun and busybox included, as neither's role has anything to do
with the blocking; and majors kept out via matchUpdateTypes, so the
stepwise app-major procedure and separateMultipleMajor keep working.

What it cannot show from here: Renovate runs as the shared instance outside
this repository, so the evidence available is a config that validates and a
local lookup that groups as intended - not the next real run.
2026-10-12 22:00:42 +00:00
jklein 7003f3674c feat(freesailarr)!: eight image updates and a trustedNetworks value for the *arr apps
Collapses the eight open Renovate PRs for this stack into one change, and
answers what two of them turned out to carry.

The eight tags: qbittorrent 5.2.3 -> 5.2.4, bazarr 1.6.0 -> 1.6.2,
sonarr 4.0.19 -> 4.0.20, flaresolverr v3.5.0 -> v3.5.2,
recyclarr 8.7.1 -> 8.7.3, prowlarr 2.5.2 -> 2.6.5, radarr 6.3.0 -> 6.4.4,
seerr v3.4.1 -> v3.5.0. gluetun, the busybox init image and the
digest-pinned mediathekarr have no update pending and are untouched.

Major rather than patch, because prowlarr 2.6.3 and radarr 6.4.3 are not
plain bumps: they removed the hard-coded trusted proxy networks
(10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, fe80::/10, read in
Startup.cs at the previously pinned tags) and replaced them with a
TrustedNetworks setting whose own default is string.Empty -
ForwardedHeadersConfigurator returns before registering a single
KnownNetwork while it is empty. Behind an in-cluster ingress controller
that means the forwarded headers are ignored: the client address the app
reports is the controller's pod IP, the scheme reads as http behind TLS
termination, and under "Disabled for Local Addresses" a proxied request
stops counting as local.

So a <app>.trustedNetworks value is added for prowlarr, radarr AND sonarr,
rendered as <APP>__SERVER__TRUSTEDNETWORKS and only when non-empty - an
empty variable cannot be told apart from an unset one by the app, but looks
handled to a reader of the manifest. The env form was verified per app
rather than inferred: all three bind ServerOptions to <App>:Server next to
a plain AddEnvironmentVariables(). A helper suppresses the chart's entry
when the app's own env block already carries the variable, for the reason
the UrlBase helper states - two identically named variables in one
container are not a defined state.

sonarr is included because it is already in the empty state: 4.0.19, the
version this chart shipped before, has no hard-coded networks either. It
arrived in what the convention classes as a plain app-version bump, so
nothing reviewed or documented it. Repairing two and knowingly leaving the
third would be the same silent pattern.

The default is empty, and this therefore does NOT restore the behaviour
from before the upgrade. That is deliberate and follows upstream's reason -
trusting X-Forwarded-For from any private network is a spoofing vector -
and it is made visible instead of reinstated: NOTES.txt warns in exactly
the combination that costs something, the app published through an Ingress
or an HTTPRoute and the value empty, and stays quiet otherwise.

AllowedHosts, added by the same upstream releases, is deliberately not
exposed. Its default is string.Empty as well and the app then accepts any
host, so there is nothing to repair; the trap is documented instead,
because if it is ever set the app appends only localhost, 127.0.0.1, [::1]
and its own hostname - neither the pod IP nor the Service DNS name - and
the kubelet probes the pod by IP, which an incomplete list answers with
HTTP 400.

At default values the rendered manifest changes in exactly eight lines, the
image tags; the new value renders nothing until it is set.
2026-10-12 22:00:23 +00:00
jklein f202fdc3fc fix(freesailarr): redirect the bare /qbittorrent prefix in Gateway mode
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Install-test / Install-test freesailarr (pull_request) Successful in 4m19s
Validate Helm Charts / Install-test (pull_request) Successful in 4m19s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 12s
SonarQube Analysis / SonarQube Analysis (push) Successful in 48s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 8s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 22s
qBittorrent has no base-path setting, so the chart serves it under a prefix
and strips that prefix before the request reaches it. For the slashed form
that is correct. For the BARE form - /qbittorrent, no trailing slash - the
request is rewritten to "/" and qBittorrent answers 200 with its login page,
while the browser's document URL keeps the prefixless form. Every relative
link in that page therefore resolves against the host root, and on the shared
host the root is seerr: the login form comes up unstyled and does nothing
while a different application answers its assets and XHRs. Nothing reports an
error at any level.

The shared-domain HTTPRoute now carries a second rule for qBittorrent: an
Exact match on qbittorrent.ingress.path with a RequestRedirect filter that
replaces the full path with the slashed form, statusCode 301. The Gateway API
ranks an Exact path match above every prefix match, so it wins over both
qBittorrent's own PathPrefix rule and seerr's "/" wherever it is emitted - the
rules' length-descending order stays a rendering convention. 301 rather than
308 because only 301 and 302 are Core in that API; an Extended value an
implementation declines makes it set Accepted=False on the WHOLE route, which
would take the four neighbouring apps' routing down with it, and that would
also contradict decision 1 of Gitea #114. statusCode is written out because it
defaults to 302. The rule is guarded on the app being qbittorrent and its path
not being "/", and it reads the path from the value, never a literal.

The INGRESS BRANCH IS UNCHANGED, deliberately. The remedy there is an
nginx configuration-snippet annotation, and the chart cannot ship it: snippets
are ignored unless the controller runs with allow-snippet-annotations=true,
off by default since ingress-nginx 1.9 and only switched on by accepting
annotation risk level "Critical". A shipped annotation would be a silent no-op
on every cluster keeping the default. The working snippet, that price, and why
a third Ingress object with pathType: Exact is not a substitute - buildLocation
in ingress-nginx controller-v1.14.5 returns a regex location in its
enforceRegex branch before reaching the PathTypeExact branch, and enforceRegex
is computed once per nginx server over every location of the hostname, one of
which carries rewrite-target - are all written out in values.yaml instead.

ci/run-httproute-match-test.sh gains a third assertion, per HTTPRoute object:
a rule stripping a prefix with ReplacePrefixMatch requires a rule in the same
route matching that path Exactly and carrying a RequestRedirect filter. This
is what stops the gap reappearing; bulwark is the second rewrite-target chart
and meets the same check when group 3 of Gitea #114 lands. 37 assertions over
15 charts, up from 36. Both fail paths were exercised: the rule deleted, and
the redirect pointed at a path other than the one stripped.

Minor and not major: with the Ingress branch untouched and httpRoute.enabled
defaulting to false, no existing release's render changes by a byte and no
value becomes required. Shown by an empty helm template diff against
origin/main in both Ingress shapes, shared-domain and per-app hosts.

Fixes #118
2026-10-12 20:06:09 +00:00
jklein 471ea5ba5d docs(README): the remaining-charts count, without double-counting rss-bridge
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 13s
Validate Helm Charts / Build calibre-web-automated (pull_request) Successful in 7s
Validate Helm Charts / Build zipline (pull_request) Successful in 8s
Validate Helm Charts / Trivy config paperless-ngx (pull_request) Successful in 20s
Validate Helm Charts / Trivy config zipline (pull_request) Successful in 22s
Validate Helm Charts / Build outline (pull_request) Successful in 8s
Validate Helm Charts / Build paperless-ngx (pull_request) Successful in 8s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 23s
Validate Helm Charts / Trivy config outline (pull_request) Successful in 16s
Install-test / Install-test freesailarr (pull_request) Successful in 4m2s
Install-test / Install-test outline (pull_request) Successful in 2m50s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 14s
Publish Calibre Web Automated Helm Chart / Publish Calibre Web Automated Helm Chart (push) Successful in 14s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 14s
Publish Outline Helm Chart / Publish Outline Helm Chart (push) Successful in 12s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 11s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Trivy config calibre-web-automated (pull_request) Successful in 20s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 8s
Install-test / Install-test calibre-web-automated (pull_request) Successful in 2m22s
Install-test / Install-test paperless-ngx (pull_request) Successful in 3m45s
Install-test / Install-test zipline (pull_request) Successful in 2m23s
Validate Helm Charts / Install-test (pull_request) Successful in 15m22s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 14s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 12s
Publish Paperless-NGX Helm Chart / Publish Paperless-NGX Helm Chart (push) Successful in 11s
Publish Zipline Helm Chart / Publish Zipline Helm Chart (push) Successful in 10s
SonarQube Analysis / SonarQube Analysis (push) Successful in 44s
Group 2's sweep paragraph said "seven charts plus rss-bridge" while rss-bridge
is one of those seven. Six charts in group 3 plus the frozen one is seven, which
is also what the twelve before this PR was counting.

Part of #114
2026-10-12 18:36:58 +00:00
jklein bd1fdc09a0 feat: Gateway-API as an alternative to the Ingress in five annotation charts
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 14s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 7s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 24s
Validate Helm Charts / Trivy config outline (pull_request) Successful in 17s
Validate Helm Charts / Trivy config zipline (pull_request) Successful in 19s
Install-test / Install-test calibre-web-automated (pull_request) Successful in 2m11s
Install-test / Install-test freesailarr (pull_request) Successful in 3m55s
Install-test / Install-test outline (pull_request) Successful in 2m44s
Install-test / Install-test paperless-ngx (pull_request) Successful in 3m52s
Install-test / Install-test zipline (pull_request) Successful in 2m6s
Validate Helm Charts / Install-test (pull_request) Successful in 14m50s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 14s
Validate Helm Charts / Build outline (pull_request) Successful in 7s
Validate Helm Charts / Build paperless-ngx (pull_request) Successful in 8s
Validate Helm Charts / Build zipline (pull_request) Successful in 8s
Validate Helm Charts / Build calibre-web-automated (pull_request) Successful in 7s
Validate Helm Charts / Trivy config calibre-web-automated (pull_request) Successful in 19s
Validate Helm Charts / Trivy config paperless-ngx (pull_request) Successful in 19s
Group 2 of the #114 sweep: the charts whose behaviour hangs off an nginx
annotation - calibre-web-automated, outline, paperless-ngx, zipline
(proxy-body-size) and freesailarr (rewrite-target).

Each gets the form the pilot (cyberchef, PR #115) fixed and group 1 (PR #116)
repeated: an httpRoute block off by default, a template rendering ONLY an
HTTPRoute with parentRefs - no Gateway, no gatewayClassName, no Certificate, no
implementation-specific policy object - render aborts on both modes at once
(naming both value paths), on an empty parentRefs and on a missing hostname,
empty hostnames meaning [ingress.domain], a schema entry closed against unknown
keys with an open parentRef definition, and a NOTES.txt that names the active
mode and what the mode costs.

Minor, additive, defaults unchanged: `helm template` with each chart's CI values
is byte-identical to origin/main for all five.

The upload limit is DOCUMENTED, not rebuilt (decision 2 of the issue). The
Gateway API knows no body size; a policy object would bind the chart to one
implementation. So the Gateway branch of NOTES.txt says, per chart and in the
chart's own terms, that the value is NOT ENFORCED and names where to set it: an
e-book library (calibre), an upload service (zipline), the web-UI half of the
document intake while the consume directory keeps no limit at all (paperless),
and the open #12 pair (outline).

Four findings that were not in the plan, each argued in the file it changes:

  freesailarr gated freesailarr.sharedIngressApps on ingress.enabled, and that
  list is read by the <APP>__SERVER__URLBASE variables as well as by the routing
  objects. In Gateway mode it would have come out EMPTY while the route served
  radarr, sonarr and prowlarr under their prefixes: UrlBase unset, assets and API
  calls linked at the host root, broken UI behind a Ready pod. The helper's two
  guards (seerr/mediathekarr only at "/", no duplicate paths) would have gone
  unenforced there too. Fixed with freesailarr.published (ingress.enabled OR
  httpRoute.enabled) - the same shape as sure.publishedOverTls in group 1, and
  identical to ingress.enabled while httpRoute.enabled is false. Measured: on
  origin/main with ingress.enabled: false the render emits ZERO URLBASE
  variables; with this change in Gateway mode it emits three.

  paperless-ngx was the last chart in the repository rendering its Ingress
  UNCONDITIONALLY. Without an ingress.enabled switch there is no second value
  path for the mutual-exclusion abort to name, and the Ingress plus its
  unconditional required() on clusterIssuer would have kept rendering in Gateway
  mode - a value demanded for nothing, which is issue #114's "Folge 1" and
  README guideline 12. The switch was added, defaulting to true, so the render is
  unchanged.

  paperless-ngx PAPERLESS_URL had no required() of its own: it relied on the
  Ingress template's, which stops firing the moment the Ingress is switchable.
  With httpRoute.hostnames set and ingress.domain empty it would have rendered
  "https://" and refused every login POST as a CSRF failure. It now carries its
  own required(), naming what the value is for.

  calibre-web-automated claimed, in values.yaml and in its schema, that
  ingress.domain is read nowhere but in the Ingress template. The HTTPRoute reads
  it, so the claim now holds only while both modes are off. Corrected rather than
  left standing - the same correction garage needed in group 1.

freesailarr is where the Gateway API is LESS machinery than what it replaces,
not a translation of it. rewrite-target applies per Ingress OBJECT, so
qBittorrent (no base path, prefix must be stripped) needed a second Ingress on
the shared host, which then had to repeat the first object's TLS secret name,
omit the cert-manager annotation conditionally so only one Certificate is
requested, and match an ImplementationSpecific regex path for the rewrite's
capture group. A URLRewrite filter applies per RULE: one route, one rule per
app, the filter on qBittorrent's rule, a plain PathPrefix. All four crutches
gone. It is also the only chart without httpRoute.hostnames - it has no
ingress.domain either, and a chart-global hostname list could not say which of
the ten apps it belonged to, so the hostnames stay in ingress.host and
<app>.ingress.host.

CI: one render overlay per chart under ci/httproute/, so the assertion is not
vacuum-green - ci/run-httproute-match-test.sh goes from 20 held assertions over
10 charts to 36 over 15. freesailarr's overlay moves bazarr onto a host of its
own, so the shared-host route AND the per-app route are both rendered; without
that the per-app branch would be exercised by nothing. The install-test fixtures
stay in Ingress mode: the install runs one configuration per chart, cyberchef
already pays that price against the real Gateway-API CRDs, and for paperless-ngx
that fixture is the only install of the valkey-subchart path anywhere.

README also carries a stale figure fixed here: the HTTPRoute test was still
documented at the pilot's 4 assertions over 2 charts, although group 1 had taken
it to 20 over 10.

Not proven, by construction: that traffic flows. No Gateway controller runs in
CI. Not run locally either: k3d, Docker, Trivy. The Gateway branch of NOTES.txt
is EXECUTED by the render test (a template error in it fails the job, verified)
but its text is printed by no pipeline stage - helm template does not emit
notes, and the install-test installs in Ingress mode.

Part of #114
2026-10-12 18:15:29 +00:00
jklein 33d7cedbab feat: Gateway-API as an alternative to the Ingress in eight charts
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / Build bentopdf (pull_request) Successful in 9s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 17s
Validate Helm Charts / Trivy config bentopdf (pull_request) Successful in 19s
Validate Helm Charts / Build garage (pull_request) Successful in 8s
Validate Helm Charts / Build rallly (pull_request) Successful in 7s
Validate Helm Charts / Build searxng (pull_request) Successful in 6s
Validate Helm Charts / Build sure (pull_request) Successful in 7s
Validate Helm Charts / Build urlaubsverwaltung (pull_request) Successful in 6s
Validate Helm Charts / Trivy config garage (pull_request) Successful in 18s
Validate Helm Charts / Trivy config patchmon (pull_request) Successful in 16s
Validate Helm Charts / Trivy config rallly (pull_request) Successful in 18s
Install-test / Install-test bentopdf (pull_request) Successful in 1m10s
Validate Helm Charts / Trivy config sure (pull_request) Successful in 18s
Validate Helm Charts / Trivy config voucher-vault (pull_request) Successful in 19s
Install-test / Install-test rallly (pull_request) Successful in 1m39s
Install-test / Install-test sure (pull_request) Successful in 2m39s
Install-test / Install-test voucher-vault (pull_request) Successful in 2m9s
Validate Helm Charts / Install-test (pull_request) Successful in 13m15s
Install-test / Install-test urlaubsverwaltung (pull_request) Successful in 1m50s
Publish BentoPDF Helm Chart / Publish BentoPDF Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 9s
Publish Rallly Helm Chart / Publish Rallly Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish SearXNG Helm Chart / Publish SearXNG Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 9s
Publish Sure Helm Chart / Publish Sure Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish Patchmon Helm Chart / Publish Patchmon Helm Chart (push) Successful in 9s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
SonarQube Analysis / SonarQube Analysis (push) Successful in 43s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Build patchmon (pull_request) Successful in 7s
Validate Helm Charts / Build voucher-vault (pull_request) Successful in 7s
Validate Helm Charts / Trivy config searxng (pull_request) Successful in 22s
Validate Helm Charts / Trivy config urlaubsverwaltung (pull_request) Successful in 17s
Install-test / Install-test garage (pull_request) Successful in 1m17s
Install-test / Install-test patchmon (pull_request) Successful in 1m24s
Install-test / Install-test searxng (pull_request) Successful in 1m7s
Publish Garage Helm Chart / Publish Garage Helm Chart (push) Successful in 9s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 9s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 10s
Publish VoucherVault Helm Chart / Publish VoucherVault Helm Chart (push) Successful in 10s
Group 1 of the #114 sweep: the charts that render exactly one Ingress object
and set no behaviour-bearing nginx annotation - bentopdf, garage, patchmon,
rallly, searxng, sure, urlaubsverwaltung, voucher-vault.

Each gets the form the pilot (cyberchef, PR #115) fixed and template-chart
carries: an httpRoute block (enabled/parentRefs/hostnames, off by default), a
template rendering ONLY an HTTPRoute with parentRefs - no Gateway, no
gatewayClassName, no Certificate, no implementation-specific policy object -
render aborts on both modes at once (naming both value paths), on an empty
parentRefs and on a missing hostname, empty hostnames meaning [ingress.domain],
a schema entry closed against unknown keys with an open parentRef definition,
and a NOTES.txt that names the active mode and what the mode costs.

Minor, additive, defaults unchanged: `helm template` with each chart's CI
values is byte-identical to origin/main for seven of the eight, and for sure
identical in every rendered value (the diff is two YAML comment lines).

Three findings that were not in the plan, each argued in the file it changes:

  sure derived RAILS_ASSUME_SSL/RAILS_FORCE_SSL from ingress.enabled alone.
  Gateway mode sets that false by construction while TLS is still terminated at
  the Gateway, so both flags would have gone false: insecure cookies, no HSTS,
  http:// links, with a Ready pod and a working UI as the only symptom. They now
  read sure.publishedOverTls (ingress.enabled OR httpRoute.enabled), which is
  ingress.enabled while httpRoute.enabled is false - hence still Minor.

  garage claimed, in its Ingress template and its schema, that nothing outside
  that template reads ingress.domain. The HTTPRoute reads it, so the claim now
  holds only while both modes are off. Corrected rather than left standing.

  garage also depends on an upload limit it does not set: its annotations
  comment tells the operator to set proxy-body-size: "0" because ingress-nginx
  truncates S3 uploads at 1 MB. proxy-body-size has no Gateway-API counterpart,
  so its Gateway-mode NOTES prints that the limit is the Gateway's. README's
  list of affected charts named five and now names garage as a sixth.

clusterIssuer needed no change in any of the eight: its only required() sits
inside the Ingress template behind ingress.enabled, the same no-op the pilot
found. Annotation guidance that stops applying in Gateway mode moved into the
Gateway branch of NOTES.txt for bentopdf (X-Robots-Tag) and searxng
(forward-auth).

CI: one render overlay per chart under ci/httproute/, so the assertion is not
vacuum-green - ci/run-httproute-match-test.sh goes from 4 held assertions over
2 charts to 20 over 10. The install-test fixtures stay in Ingress mode: the
install runs one configuration per chart, cyberchef already pays that price
against the real Gateway-API CRDs, and for garage the Ingress is the only place
its Service port is referenced by NAME.

Not proven, by construction: that traffic flows. No Gateway controller runs in
CI. Not run locally either: k3d, Docker, Trivy.

Part of #114
2026-10-12 16:06:18 +00:00
jklein de92e59bb0 docs(README): the HTTPRoute test's figure from the CI run, not from the local subset
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 14s
Validate Helm Charts / Selector match (pull_request) Successful in 13s
Validate Helm Charts / Build cyberchef (pull_request) Successful in 7s
Validate Helm Charts / Trivy config cyberchef (pull_request) Successful in 15s
Validate Helm Charts / Trivy config template-chart (pull_request) Successful in 17s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 13s
Validate Helm Charts / Build template-chart (pull_request) Successful in 5s
Install-test / Install-test cyberchef (pull_request) Successful in 58s
Validate Helm Charts / Install-test (pull_request) Successful in 58s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 19s
SonarQube Analysis / SonarQube Analysis (push) Successful in 43s
Publish Cyberchef Helm Chart / Publish Cyberchef Helm Chart (push) Successful in 19s
The authoring environment cannot reach the private Helm registry, so the
three charts with a dependency were missing from the local run and the
number quoted 27 charts. CI run 719 renders all 30 and holds the same 4
assertions - that is the figure a later deviation should be compared
against, so it is the one written down.
2026-10-12 15:25:23 +00:00
jklein 50f683776e feat(cyberchef): Gateway-API as an alternative to the Ingress (pilot)
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 13s
Validate Helm Charts / Build cyberchef (pull_request) Successful in 6s
Validate Helm Charts / Trivy config cyberchef (pull_request) Successful in 18s
Validate Helm Charts / Trivy config template-chart (pull_request) Successful in 17s
Install-test / Install-test cyberchef (pull_request) Successful in 1m0s
Validate Helm Charts / Install-test (pull_request) Successful in 1m0s
Validate Helm Charts / Selector match (pull_request) Successful in 12s
Validate Helm Charts / Resource quantities (pull_request) Successful in 14s
Validate Helm Charts / Build template-chart (pull_request) Successful in 7s
The Gateway-API pilot of Gitea #114: one chart, the convention, the CI that
holds it. The remaining 20 charts follow in their own PRs.

The form, additive and off by default:

    httpRoute:
      enabled: false     # counterpart to ingress.enabled
      parentRefs: []     # the Gateway - a platform object
      hostnames: []      # empty = [ingress.domain]

Only HTTPRoute with parentRefs is rendered: no Gateway, no gatewayClassName,
no Certificate, and no implementation-specific policy object. That subset is
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
what lets the implementation be chosen later without touching the charts
again. Class and certificate move to the platform.

Four guards, all against silent failures:

  - mutually exclusive with ingress.enabled, with a render abort naming BOTH
    value paths. A migration runs over a second hostname, not in parallel.
  - parentRefs mandatory in Gateway mode, render abort on the empty list: a
    route without a parent is created, is schema-valid, and routes nothing.
  - hostnames empty means [ingress.domain], so the hostname has one source.
  - the mode is a VALUE, not auto-detection: .Capabilities.APIVersions is
    unreliable at template time and a missing CRD fails at install time.

ingress.clusterIssuer was already only read inside the Ingress template, so
the "conditionally required" half of #114 needed no change in this chart -
checked rather than assumed, and it has to be re-checked per chart in the
sweep.

CI, both halves, because neither covers the other:

  - the install-test installs cyberchef in GATEWAY mode against the REAL
    Gateway-API CRDs (standard channel v1.6.3, pinned and Renovate-tracked,
    applied --server-side). A schema-less stub would accept a malformed route
    and prove only that something was created. The Ingress path of this one
    chart is no longer installed; it is a built-in type that never routed in
    k3d anyway, and helm lint renders this chart's defaults - Ingress mode -
    on every PR.
  - ci/run-httproute-match-test.sh, over every chart on every PR: every
    backendRefs entry must name a Service, on a port, that the SAME render
    creates, and parentRefs must not be empty. The CRDs accept both of those
    mistakes, so the schema cannot catch them, and the only report would be a
    route status no controller writes in CI.

What stays unprovable, and is written down instead of implied: that traffic
flows. No Gateway controller runs in k3d.

Minor: additive with unchanged defaults, proven by an empty helm template
diff against origin/main for the default values.

Part of #114
2026-10-12 15:16:26 +00:00
163 changed files with 12363 additions and 475 deletions

No files matched your search

+100
View File
@@ -203,6 +203,106 @@ jobs:
- name: "Check every rendered selector against the rendered pod labels"
run: ci/run-selector-match-test.sh
httproute-match:
name: "HTTPRoute match"
runs-on: ubuntu-latest
# The third job that ignores the changed-chart matrix and runs over *every*
# chart (Gitea #114), and for the same reason as the two above: the mistake
# arrives by copy into a chart whose own diff nobody reads for it - and this
# convention is about to be copied into 20 more charts when the sweep runs.
#
# What it asserts: every HTTPRoute a chart renders names a Service, on a
# port, that the SAME render creates, and its parentRefs is not empty. Both
# failures are SILENT and neither is reachable from the other checks:
# parentRefs: [] attaches the route to no Gateway (created, schema-valid,
# routes nothing, no event and no condition), and a backendRef is resolved
# by name with nothing validating that the name exists. The real Gateway-API
# CRDs in the install-test prove the object is WELL-FORMED; they accept both
# of these, because an empty list and a dangling name are valid YAML
# against that schema.
#
# What is NOT provable anywhere, and is written down rather than implied: no
# Gateway controller runs in CI, so no route is ever reconciled. That
# traffic flows is not part of this pipeline's claims.
#
# No cluster and no Trivy; ~3 seconds for the whole repository, depends on
# no other job. The YAML is parsed by Helm through ci/yaml2json, because the
# runners have neither pyyaml nor yq and a hand-written parser could read
# nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered HTTPRoute against the rendered Services"
run: ci/run-httproute-match-test.sh
bump-level:
name: "Bump level"
runs-on: ubuntu-latest
# The fourth job that ignores the changed-chart matrix, and the only one
# that touches no chart at all: it tests ci/renovate-bump-chart.sh, the
# postUpgradeTasks hook that computes a chart's `version:` after Renovate
# moved an app version (Gitea #128).
#
# Why it is a job rather than a review: since #128 the hook takes the bump
# level from Renovate's update type, so an app minor becomes a chart minor.
# That makes it deciding logic, and its failure mode is silence - a wrong
# level is a VALID version string, so the PR goes green, the chart
# publishes, and the only symptom is a number that understates what it
# ships. Nothing else in this pipeline reads that number.
#
# It needs no Helm, no cluster and no Python: each case is a throwaway
# `git init` under mktemp with one commit plus a dirty Chart.yaml - the
# state the hook really runs in, since it reads the base commit - and the
# assertion is one `version:` line read back with sed. About a second for
# all 49 cases, and it depends on no other job.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Check every bump level of the version-bump hook"
run: ci/run-bump-level-test.sh
yaml-scalar:
name: "YAML scalars"
runs-on: ubuntu-latest
# The fifth job that ignores the changed-chart matrix, and the fourth that
# runs over *every* chart (Gitea #11), for the same reason as the three
# render tests above: the unquoted
# form arrives by copy into a chart whose own diff nobody reads for it, and
# it is in seven charts right now.
#
# What it asserts: every CronJob schedule, every env[].value and every label
# and annotation value a chart renders is a STRING, which is what Kubernetes
# types all three as. A value rendered without | quote fails in one of two
# ways and the quiet one is the dangerous one: the render aborts with a YAML
# error naming the TEMPLATE while the operator's value appears nowhere in
# it, or - when the content is a legal plain scalar that simply is not a
# string - the render succeeds and the API server rejects the object at
# apply time with "cannot unmarshal bool into Go struct field
# EnvVar.value of type string".
#
# WHY THIS JOB EXISTS AT ALL, rather than the install-test covering it: the
# object that carried the defect is rendered by no fixture. paperless-ngx'
# consume CronJob sits behind smbConnection.enabled, false in the defaults
# and in every committed fixture, so nothing in this repository ever
# produced it - which is how an unquoted schedule shipped for months. The
# overlays under ci/yaml-scalar/ switch such objects on and feed them
# hostile values, and the script fails when an overlay stops reaching its
# object.
#
# No cluster and no Trivy; the whole repository in well under a minute,
# depends on no other job. The YAML is parsed by Helm through ci/yaml2json,
# because the runners have neither pyyaml nor yq and a hand-written parser
# could read nothing and report nothing - see the script's header.
steps:
- name: "Checkout Repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: "Install Helm"
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5
- name: "Check every rendered scalar that must be a string"
run: ci/run-yaml-scalar-render-test.sh
trivy-config:
name: "Trivy config ${{ matrix.chart }}"
needs: detect-changed-charts
+8 -6
View File
@@ -48,7 +48,7 @@ Flat layout, one Markdown file per page, page name = file name, links are relati
| 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.
**What does not oblige an edit:** an app-version bump, at any level. 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. Since Gitea #128 **"plain" is mechanically defined instead of being a judgement**: the chart version now states the size of the jump, because `ci/renovate-bump-chart.sh` takes that size from Renovate's update type (app minor → chart minor). An app update that *does* change values or behaviour is not a plain bump and the table applies — but **nothing obliges anyone to go and find out**. That is the deliberate limit of #128, not an oversight: the reading duty was rejected, so the `sonarr` incident in `Lessons-Learned` stays repeatable and a bigger number is the only warning that is given.
Wiki commits follow the same rules as repo commits: no `Co-Authored-By`, no "Generated with Claude Code" footer.
@@ -94,11 +94,13 @@ Copy `template-chart/` and replace every `xxx` placeholder — in file names (`t
`Chart.yaml` versions follow `version: <chart-semver>+up<appVersion>` (e.g. `1.4.0+up1.9.2`), with `appVersion` matching the suffix (dashes in the app version are normalized to dots in the suffix). Any push to `main` touching a chart directory publishes that chart, so the version must be bumped in the same change.
Semver conventions for the chart part:
Semver conventions for the chart part. **The chart jump is the greater of the chart's own classification and the jump of what it ships** (Gitea #128) — a maximum, never a replacement, so a value that becomes required stays a major even under an app patch:
- **Patch**: bug fixes without behavior or values-schema change; plain app-version updates.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults).
- **Major**: runtime behavior changes or breaking values changes (e.g. probes, security hardening, computed limits, values that become required).
- **Patch**: bug fixes without behavior or values-schema change; an app **patch** or digest update.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults); an app **minor** update.
- **Major**: runtime behavior changes or breaking values changes (e.g. probes, security hardening, computed limits, values that become required); an app **major** update.
The app half of that is mechanical: `ci/renovate-bump-chart.sh` takes the level from **Renovate's update type**, passed in as `{{{updateType}}}`, and not from comparing `appVersion` — `freesailarr` pins every tag in `values.yaml` and its `appVersion` never moves, so a comparison is blind to exactly the case the rule exists for. Non-semver app versions (CalVer, date tags, four-part and suffixed tags — seven charts) stay at **patch** for a minor, because their second position is a month or a flavour and says nothing about the jump; an app **major** still produces a chart major. **It obliges nobody to read the release notes** — that variant was rejected in #128 — so an app minor that changes a default can still reach `main` unremarked; the number stops understating, that is all it does. See the wiki, `Lessons-Learned`.
App-version updates go through PRs (so the validate and install-test pipelines run), not through the `change-app-version.yml` dispatch workflow (which commits directly to main and bypasses both). 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.
@@ -117,4 +119,4 @@ The binding style guide for all current and future charts lives in **README.md (
- Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly).
- Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them.
- `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci/<chart>/` or a justified exclusion.
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and it belongs in a comment at the pinned version); and every helm command exits 1 while `charts/` is unfilled, which is why `ci/helm-dependencies.sh` runs before lint, package, render and install — including the render test that covers **all** charts on **every** PR. Full rules and the rest of the cost: README, "Chart dependencies".
- **Chart dependencies are admissible but expensive** (the former "no dependencies" rule fell with #214; `ytdlp-web-player` was the first to use one, `stalwart` the second, `paperless-ngx` the third). A dependency has to be optional and off by default via `condition:`, and upstream's chart is consumed as it comes — where its defaults fall short of these conventions, that is **documented in our `values.yaml`, never patched**. The price to know before adding the next one: a subchart is invisible to everything downstream that watches Helm release versions, so a new upstream version — a security fix included — reaches a cluster only when the outer chart is republished (Renovate's built-in `helmv3` manager plus `ci/renovate-bump-chart.sh` is the whole safety net, and since #128 that republish carries the **subchart's own level** — an upstream minor is a chart minor, which is the only place that jump becomes visible at all; 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".
+165 -24
View File
@@ -18,12 +18,12 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
| `bentopdf` | BentoPDF | Stateless Deployment + `config.json` ConfigMap, `subPath`-mounted into the web root and always rendered (empty renders as `{}`) |
| `bulwark` | Bulwark Webmail (JMAP client for Stalwart) | **The companion to `stalwart`, and a pure client**: no mail, no database, no cache, no broker — the messages belong to the Stalwart release named by `bulwark.jmapServerUrl`. StatefulSet with **one** PVC at `/app/data`, carrying all four of Bulwark's directories (admin config, admin state with the audit log, encrypted user settings, telemetry) as subdirectories — one volume because snapshots are atomic per volume and never across them (the `garage` lesson, #81). No ConfigMap, so no `checksum/config` (§9 has nothing to attach to); the `op://` paths are covered by `checksum/secret-ref`. **The defining property, and it governs every value: the VOLUME BEATS THE CHART.** `configManager.get` resolves `admin override > env var > default`, the admin dashboard may write *any* key, and nothing says it just disconnected the chart — so a `helm upgrade` with a changed value can succeed and change nothing. Upstream documents this only for the server URL; it is true of everything. Exactly three values escape it (`SESSION_SECRET(_FILE)`, `ADMIN_PASSWORD`, `BULWARK_TELEMETRY`), and `bulwark.adminConfigReadOnly` closes it at a price that is **threefold and measured**: it freezes the settings policy (and with it the only way to move Web Push off the vendor relay), it freezes an override that *already* exists rather than removing it, and on a **fresh** install it stops `admin.json` from being written at all — so the admin dashboard never comes up and answers HTTP 500 while the pod stays Ready. Hence the default `false`. Sign-in is **OIDC-only** (`oauth.only`), and the way back out of a lockout is **not** `helm upgrade` with it `false` — `oauthOnly` is read through the same precedence chain, so the repair is removing the override in the **admin dashboard**, which is why `ADMIN_PASSWORD` is set at all (it generates no random password and logs nothing — the `#65` analogy does not hold here). Its **redirect URI carries the UI language** (`/<locale>/auth/callback`, 27 locales, and the language is not in the visible URL), so a single registered URI is the likeliest cause of a failed first sign-in. Both `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` exist and are used — the better path `garage` and `keycloak` did not have — mounted **without `subPath`** so that a rotation takes effect without a restart, since both are re-read on every use. Two backup statements, both sharp: without the session secret the synchronized settings are unreadable **out of an intact volume**, and if the dashboard's OAuth setup was ever used the client secret sits in `config.json` on that volume in **cleartext**. All three probes are a plain GET on `/api/health` — **not** HEAD, which a `HTTPGetAction` cannot send — and the limit is written at the value: it carries process, HTTP acceptance, a free event loop and Next.js routing, and **nothing** about Stalwart, so with the mail server gone the pod is green. Served only at a host **root** (`NEXT_PUBLIC_BASE_PATH` is compiled in), one replica (single RWO PVC), two emptyDirs for `readOnlyRootFilesystem` (`/tmp`, `/app/.next/cache` — image optimization is active because `images.unoptimized` is in the lite branch only) |
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
| `cyberchef` | CyberChef | Stateless Deployment |
| `cyberchef` | CyberChef | Stateless Deployment. **The Gateway-API pilot (#114)**: the first chart offering `httpRoute.enabled` as the alternative to `ingress.enabled` — `HTTPRoute` with `parentRefs` only, no Gateway and no certificate, the two modes mutually exclusive. It is also the only chart whose install-test runs in Gateway mode, because that test installs one configuration per chart |
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr, and mediathekarr off by default) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service per app (mediathekarr's carries both of its ports); no global `ingress.domain`. Ingress in two shapes that mix: one host per app (default, each app's host required), or one shared `ingress.host` with the apps as sub-paths (documented ingress exception below) |
| `garage` | Garage (S3-compatible object store) | **One** node, `replication_factor = 1` — a deliberate trade with its price named (Gitea #81): the volume layer underneath already replicates and encrypts, so Garage replication on top would be nine times the raw data across two layers that know nothing about each other. What that costs is that the factor applies to the **metadata** too, where a corrupted database is upstream-documented as "generally not recoverable" and a replicated volume replicates the corruption. Hence three settings that are mandatory rather than optional: `metadata_fsync = true` (**off** in Garage's defaults), the automatic metadata snapshots, and those snapshots on a **second PVC** — a file-system snapshot of a live metadata database is not a restore point, so the volume backup covers the blocks and not the index. The first PVC carries `metadata_dir` **and** `data_dir` as two subdirectories, because volume snapshots are atomic per volume: that is what keeps index and blocks consistent with each other. Two further properties shape the whole chart: the image is **`FROM scratch`** and contains exactly one file, `/garage` — no shell, so **exec probes are impossible here** and an init container would need a second image — and the RPC secret has to arrive as an **environment variable**, because `rpc_secret_file` rejects any file with a group bit set while the kubelet sets one (the reasoning sits at the value). Started with `garage server --single-node`, which creates the cluster layout in-process before any listener opens and is idempotent on the layout version, and with `--default-access-key`/`--default-bucket`, which **import** key and bucket from the 1Password item — so the vault is the source of truth instead of a copy of what the server generated. Readiness is the admin API's `/health`, liveness and startup deliberately are **not**: `/health` answers 503 until a layout exists, so a node waiting for one would be restarted forever |
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
| `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) |
| `keycloak` | Keycloak (OpenID Connect / SAML identity provider) | **Two** replicas with `cache=ispn`, clustered through Keycloak's default `jdbc-ping` stack — discovery runs over the PostgreSQL that is there anyway, so no headless Service and no JGroups wiring. Deployment, **no PVC** (Keycloak 26 keeps everything in the database, user sessions included). **The chart has two init containers and needs both** (Gitea #83): `db`, `health-enabled` and `metrics-enabled` are Keycloak *build* options, the published image is built with their defaults, and a runtime change makes Keycloak rewrite `/opt/keycloak/lib/quarkus` at start — which `readOnlyRootFilesystem: true` forbids and which upstream describes as a start that "creates an image that is mutable". An empty `emptyDir` over that path does not work either, because `QuarkusEntryPoint` loads `quarkus/quarkus-application.dat` before it dispatches any command, `build` included. So one init container **seeds** the directory into a volume, the next runs `kc.sh build` into it, and the serving container runs `start --optimized` — every container keeping the full hardening baseline. Build options therefore exist in exactly **one** place and a build option in `keycloak.env` fails the render. First chart with a `PodDisruptionBudget` (§11) and first `quay.io` image (hence the entry in `ci/trivy/data/ksv0125.yaml`). Two Services (8080, and the management port 9000 **never** published — it serves `/health/*` and optionally `/metrics` unauthenticated) and two Ingresses. **The admin hostname is not an access control**: `hostname-admin` only decides which URLs the server generates, and upstream states that it "does not prevent accessing the Administration REST API endpoints via the frontend URL" — restricting that API is reverse-proxy work, and the chart renders no `/admin` deny rule. `ingress.domain` is read as `KC_HOSTNAME` (as a full URL, which upstream requires alongside `hostname-admin`), so it stays required with the Ingress off. Liveness is `/health/live` and deliberately not `/health` or `/health/ready` — those include the database, and a liveness probe on them restarts the whole cluster for as long as the database is away. **`keycloak.metrics.enabled` defaults to `true` for the readiness probe, not for the metrics**: upstream's table of health checks carries a "Requires Metrics" column and the *database* check is the row that says yes, so with it off `/health/ready` answers 200 while the database is gone — the `urlaubsverwaltung` shape (#218) with the dependency hidden behind an option whose name says observability. Two traps at the values: upstream's `db-pool-max-size` default of 100 would ask a default PostgreSQL for 200 connections across two replicas (the chart ships 20), and two releases sharing one database **merge into one Infinispan cluster** unless the cluster names differ |
| `keycloak` | Keycloak (OpenID Connect / SAML identity provider) | **Two** replicas with `cache=ispn`, clustered through Keycloak's default `jdbc-ping` stack — discovery runs over the PostgreSQL that is there anyway, so no headless Service and no JGroups wiring. Deployment, **no PVC** (Keycloak 26 keeps everything in the database, user sessions included). **The chart has two init containers and needs both** (Gitea #83): `db`, `health-enabled` and `metrics-enabled` are Keycloak *build* options, the published image is built with their defaults, and a runtime change makes Keycloak rewrite `/opt/keycloak/lib/quarkus` at start — which `readOnlyRootFilesystem: true` forbids and which upstream describes as a start that "creates an image that is mutable". An empty `emptyDir` over that path does not work either, because `QuarkusEntryPoint` loads `quarkus/quarkus-application.dat` before it dispatches any command, `build` included. So one init container **seeds** the directory into a volume, the next runs `kc.sh build` into it, and the serving container runs `start --optimized` — every container keeping the full hardening baseline. Build options therefore exist in exactly **one** place and a build option in `keycloak.env` fails the render. First chart with a `PodDisruptionBudget` (§11) and first `quay.io` image (hence the entry in `ci/trivy/data/ksv0125.yaml`). Two Services (8080, and the management port 9000 **never** published — it serves `/health/*` and optionally `/metrics` unauthenticated) and two Ingresses — plus one routing object per `keycloak.realmHosts` entry (Gitea #122), empty by default, which gives a realm a hostname of its own by sending that hostname's bare `/` to `/realms/<realm>/account/`: `app-root` in Ingress mode, an `Exact` match with a `RequestRedirect` filter in Gateway mode. Those entries carry a **required** per-entry TLS secret name and an **optional** per-entry issuer, the documented deviation in §4, because the hostnames they publish already have certificates and often share one. **The admin hostname is not an access control**: `hostname-admin` only decides which URLs the server generates, and upstream states that it "does not prevent accessing the Administration REST API endpoints via the frontend URL" — restricting that API is reverse-proxy work, and the chart renders no `/admin` deny rule. `ingress.domain` is read as `KC_HOSTNAME` (as a full URL, which upstream requires alongside `hostname-admin`), so it stays required with the Ingress off. Liveness is `/health/live` and deliberately not `/health` or `/health/ready` — those include the database, and a liveness probe on them restarts the whole cluster for as long as the database is away. **`keycloak.metrics.enabled` defaults to `true` for the readiness probe, not for the metrics**: upstream's table of health checks carries a "Requires Metrics" column and the *database* check is the row that says yes, so with it off `/health/ready` answers 200 while the database is gone — the `urlaubsverwaltung` shape (#218) with the dependency hidden behind an option whose name says observability. Two traps at the values: upstream's `db-pool-max-size` default of 100 would ask a default PostgreSQL for 200 connections across two replicas (the chart ships 20), and two releases sharing one database **merge into one Infinispan cluster** unless the cluster names differ |
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
| `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value |
| `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). **Only the newest Java line is maintained** (chart 5.0.0, #158): the directory carries one line, older ones stay published as frozen artifacts (java8/java17 at `4.0.0`, java21 at `4.1.0`) and receive no further chart changes — publishing once per line out of one directory is what made the lines drift. To run an older JVM with the current chart, set `image.tagOverride`; the chart then checks that `minecraft.version` fits the Java line of the **effective** tag and fails the render with both values named. Two cases pass unchecked on purpose: a tag without a `java<N>` part and a `minecraft.version` that is not a plain numeric `x[.y[.z]]`. `image.tagOverride` is deliberately outside Renovate's reach — `renovate.json5` tracks `appVersion` only. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: <release>-java8` / `-java17` / `-java21` / `-java25`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. Pilot for the strict `values.schema.json` (#68), which is now binding for every chart |
@@ -31,7 +31,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
| `paperless-ngx` | Paperless-ngx | Largest chart: consume CronJob, SMB-connector PV/PVC, pre/post-consumption scripts ConfigMap. Optional `valkey` subchart — **this repo's own chart, from the published index**, off by default and gated on `valkeySubchart.enabled` (not `valkey.enabled`, which the subchart's strict schema rejects). Configured as a **queue** (PVC + `noeviction`), unlike `stalwart`'s cache: `PAPERLESS_REDIS` is the Celery broker, and `allkeys-lru` evicts across the whole keyspace, where a backlog of waiting consume tasks is exactly what LRU reads as cold. The password comes from the **same 1Password item** as the Redis connection, in a third field `auth-conf` holding the complete line `requirepass <password>` — so the password sits in that item **twice** and a rotation has to touch both fields. Two more traps: with the subchart on, `redis-connection-url` in the vault **must** be repointed at `<release>-valkey-service:6379` or the bundled Valkey runs unused, and `valkeySubchart:` must not be erased — a null override deletes the key, Helm reads an unresolvable `condition:` as *enabled*, so erasing the switch turns the subchart **on** (rejected by `values.schema.json`). Never change `valkey/` and this chart in the same PR, see "Chart dependencies" |
| `patchmon` | PatchMon | One Deployment (the 2.x server serves frontend and `/api/*` from one process) plus an optional guacd sidecar; OIDC-only sign-in |
| `rallly` | Rallly | Deployment, DB/OIDC/SMTP secrets |
| `rss-bridge` | RSS-Bridge | Deployment + whitelist ConfigMap; `ingress.hosts` is a required list of host + optional `tlsSecretName`, so several hosts can keep their own existing certificates (documented deviation from the one-TLS-secret rule, see its `values.yaml`) |
| `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`) — required only while `ingress.enabled`, since in Gateway mode the names come from `httpRoute.hostnames` and the secret grouping has nothing to group. **The last chart of the Gateway-API sweep (#114)**. Its install-test had failed on the Gitea runner since the move (#51) and the chart was deliberately **not** excluded so that a pass would be noticed — it passed in the conversion PR, on a render byte-identical to `main`, which rules the chart out and leaves the runner |
| `samba` | Samba | StatefulSet, users secret + config ConfigMap |
| `satisfactory` | Satisfactory game server | StatefulSet, no ingress |
| `searxng` | SearXNG | Stateless Deployment + settings ConfigMap (`use_default_settings: true` plus a free-form `settings.extra` passthrough); the signing key is a required `OnePasswordItem`, an external Valkey an optional second one. `ingress.domain` is read twice — Ingress host and `SEARXNG_BASE_URL` — so it stays required with the Ingress switched off |
@@ -68,8 +68,8 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h
Note: the registry **accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact, so a republish of an existing version is still silent — see #160. That is also what makes a manual `workflow_dispatch` of any `publish-<chart>.yml` a harmless way to prove the shared block works.
- `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list.
- `ci/helm-dependencies.sh <chart-dir>` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but three: `ytdlp-web-player`, `stalwart`, `paperless-ngx`). It runs in **six** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in the shared publish block (which is why that block names this script as a prerequisite of its callers — see above), and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh`, `ci/run-selector-match-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** and the **selector-match render test** are the two exceptions to that rule: both run 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 three: `ytdlp-web-player`, `stalwart`, `paperless-ngx`). It runs in **eight** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in the shared publish block (which is why that block names this script as a prerequisite of its callers — see above), and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh`, `ci/run-selector-match-test.sh`, `ci/run-httproute-match-test.sh`, `ci/run-yaml-scalar-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**, the **selector-match render test**, the **HTTPRoute render test** and the **YAML-scalar render test** are the four exceptions to that rule: all of them run over every chart on every PR — see below. A fourth job, the **bump-level test**, ignores the matrix for a different reason: it tests no chart at all, but the `postUpgradeTasks` hook that computes their versions.
- `install-test.yml` — reusable workflow (`workflow_call`) holding the install-test **job** itself: the runner-label expression, the pinned `kubectl` with its skew check, `envsubst`, the containerised-job detection, the per-leg k3d cluster and the `freesailarr` VPN credentials. Its one input is a JSON array of chart names; its callers decide *which* charts are tested and whether the job runs at all, never *how*. Two callers: `validate-charts.yml` and `install-test-all-charts.yml`. **It is not copied for the second caller** — a second copy is a second chance to test differently, unnoticed, the same argument that gives `ci/excluded-charts.txt` exactly one parser.
**It stays in this repository, and that is a decision, not an oversight** (Gitea #66, decision 4). It is reusable in form but not in substance: it is welded to the `ci/` fixtures, to the `ci/excluded-charts.txt` parser and to this repository's chart matrix. A building block that only one repository can use is not shared, it is merely moved somewhere else — and moving it would buy nothing while putting a tag between us and a file we change often. `publish-helm-chart.yml` was the opposite case: it takes a chart name and a registry owner and knows nothing about this repository, which is exactly why it could go. This note exists so the question is not asked again in half a year.
@@ -156,10 +156,69 @@ Neither is a render error, neither fails the install-test, and neither shows up
Three things to know before touching it:
- **Helm is the YAML parser, through the one-template chart `ci/yaml2json`.** The runners have neither `pyyaml` nor `yq` (measured; the same constraint `ci/run-trivy-config.sh` and `ci/run-quantity-render-test.sh` work under), and a YAML reader written in `awk` was the obvious alternative and the wrong one: its failure mode is a parser that quietly reads nothing, finds no selector, reports no mismatch and leaves the test green for a chart it never looked at. So the rendered manifest stream is handed to that chart as a **values file** and comes back as JSON, which `python3` reads from the standard library. The one textual step is the document split on a line that is exactly `---` in column one, and getting *that* wrong does not produce a false pass — the fragment stops being valid YAML and `helm` exits 1.
- **What is deliberately not checked, named so the gap is not mistaken for coverage.** `podAffinity`/`podAntiAffinity` are out of scope: their semantics are relative to pods that exist, so a term matching nothing is a legitimate configuration rather than a mistake. And a selector expressed **only** through `matchExpressions` is **reported as "not checked", never counted as passing** — evaluating set-based requirements means reimplementing label-selector semantics, which is a second parser by another name.
- **Two anti-vacuum guards, because this test can be green over nothing.** Both objects are rendered only above a replica threshold, and most charts' CI fixtures sit below it. `ci/selector-match/<chart>.yaml` is a render-only overlay applied **last**, whose only job is to push a chart over that threshold — and a chart that *has* one and still renders no selector **fails**, since that overlay existing is the whole claim that something was checked. On top of that the run fails if **no selector at all** was checked anywhere. Today `stalwart` is exercised through its overlay (two replicas in both node groups plus an `externalServices.valkey.host`, without which the chart's own "cluster with no in-memory store" abort fires) and `keycloak` through its ordinary install-test fixture, which already runs two replicas.
- **What is deliberately not checked, named so the gap is not mistaken for coverage.** *Preferred* `podAffinity` and `podAntiAffinity` of either strength are out of scope: their semantics are relative to pods that exist, so a term matching nothing is a legitimate configuration rather than a mistake — a preference that cannot be met is dropped, and an anti-affinity over zero pods is satisfied. And a selector expressed **only** through `matchExpressions` is **reported as "not checked", never counted as passing** — evaluating set-based requirements means reimplementing label-selector semantics, which is a second parser by another name.
- **`requiredDuringSchedulingIgnoredDuringExecution` `podAffinity` *is* checked** (Gitea #101), and it is the one affinity case that is not a preference: a required term whose selector matches no pod in the release can never be satisfied, so the pod stays `Pending` for good. It is held against **all** pod templates of the render rather than its own, because selecting a *different* workload's pod is the entire purpose of such a term. First and so far only user: `paperless-ngx`' consume CronJob, pinned onto the node of its StatefulSet pod so Longhorn will mount the shared RWX consume volume — where the silent failure would be a mover that stops moving, one `Pending` pod per schedule, with both templates still rendering perfectly.
- **Two anti-vacuum guards, because this test can be green over nothing.** Both objects are rendered only above a replica threshold, and most charts' CI fixtures sit below it. `ci/selector-match/<chart>.yaml` is a render-only overlay applied **last**, whose only job is to push a chart over that threshold — and a chart that *has* one and still renders no selector **fails**, since that overlay existing is the whole claim that something was checked. On top of that the run fails if **no selector at all** was checked anywhere. Today `stalwart` is exercised through its overlay (two replicas in both node groups plus an `externalServices.valkey.host`, without which the chart's own "cluster with no in-memory store" abort fires), `keycloak` through its ordinary install-test fixture, which already runs two replicas, and `paperless-ngx` through `ci/selector-match/paperless-ngx.yaml`, whose only job is to switch `smbConnection` on so the consume CronJob — and with it the required `podAffinity` — renders at all.
Measured: 30 charts, **5 selectors verified in ~14 s**, no cluster. The fail path was exercised rather than assumed — renaming `node-group` to `nodegroup` in a hand-written copy of the PDB selector produced two `FAIL` lines naming both the selector and the labels actually present, and a non-zero exit; the same was done for the spread constraint's selector.
Measured: 30 charts, **6 selectors verified**, no cluster. The fail path was exercised rather than assumed — renaming `node-group` to `nodegroup` in a hand-written copy of the PDB selector produced two `FAIL` lines naming both the selector and the labels actually present, and a non-zero exit; the same was done for the spread constraint's selector. For the `podAffinity` check the same was done by renaming the StatefulSet's `app` label in a throwaway copy of `paperless-ngx` while leaving the CronJob's term alone: one `FAIL` naming the selector, the three pod templates it missed and the labels actually present, and a non-zero exit.
### HTTPRoute render test (all charts, every PR)
`ci/run-httproute-match-test.sh [chart …]` (no argument = all charts) renders every chart with the same fixtures the install-test uses and checks every `HTTPRoute` in the result against the `Service` objects of that **same** render. Introduced with the Gateway-API pilot (Gitea #114); three failure modes, all silent:
| Finding | What it does | Who notices today |
|---|---|---|
| `parentRefs` empty | the route attaches to **no** Gateway: created, schema-valid, rejected by nobody, and it routes nothing | nobody — no event, no condition, the only symptom is a hostname that does not answer |
| `backendRefs` naming a Service or a port that does not exist | the reference is resolved **by name** at runtime and validated by nothing | nobody in CI — `ResolvedRefs: False` lands in a route status that only a Gateway controller writes, and none runs here |
| a prefix stripped with `ReplacePrefixMatch` while the same route carries no `Exact` match on that prefix with a `RequestRedirect` filter (Gitea #118) | the **slashless** form of the path is rewritten to `/` and answered **200** by the app, under a document URL that keeps the prefix off — so every relative link in that page resolves against the host root, which on a shared host is a *different* application | nobody — the app's own assets and XHRs are served by its neighbour, and the symptom is a half-working UI, not an error |
**The real CRDs in the install-test close none of these gaps**, which is the whole reason this job exists next to them: the Gateway-API schema happily accepts an empty `parentRefs` list, a `backendRefs` entry pointing at a Service that was never created, and a prefix-stripping rewrite with no redirect beside it. The CRDs prove the object is *well-formed*; this test proves it *points somewhere*, and *somewhere right*. The port half is a genuine new exposure rather than a hypothetical: an Ingress backend references its Service port **by name**, while `backendRefs` takes a `PortNumber` and cannot, so the number in the route and the port in the Service are kept in step by hand from now on.
The third check is a **pairing within one HTTPRoute object**, and the scope is deliberate: Gateway-API matching precedence is a property of a single route, so a redirect in a *different* route (a different hostname) says nothing about this one. Precedence also makes the pairing a question of existence rather than order — an `Exact` path match outranks every prefix match, so the redirect rule wins wherever it is emitted, and the rules' length-descending order stays a rendering convention. `freesailarr` is the first chart it applies to — **and, after group 3, still the only one.** The line that used to stand here said `bulwark` was the second `rewrite-target` chart and would meet the same check when group 3 landed. Group 3 landed and it does not: **`bulwark` has one Ingress object, no `rewrite-target`, and cannot have one** — `NEXT_PUBLIC_BASE_PATH` is compiled into its bundle, so it cannot be served under a sub-path at all and its route is one rule at `/` with no `URLRewrite` filter. Every occurrence of the string `rewrite-target` in that chart is an explanation of *why it does not help*, and #114's measured table arrived at "`bulwark`, `freesailarr`" by grepping for the string rather than for the annotation. The check still runs over all charts for the reason below — the mistake arrives by copy into a chart whose own diff nobody reads for it — not because a second chart is known to need it.
Three things to know before touching it:
- **Helm is the YAML parser**, through `ci/yaml2json`, for the same reason as the selector-match test above — the runners have neither `pyyaml` nor `yq`, and a parser written in `awk` fails by reading nothing and reporting nothing.
- **What is deliberately not checked**, named so the gaps are not mistaken for coverage. *That traffic flows* — no Gateway controller runs in CI and none will, so `Accepted: True` is not obtainable; that claim is simply not made anywhere in this pipeline. *That any redirect is ever served* — the pairing check proves the rendered **object** carries a `RequestRedirect` rule on the bare prefix and nothing more: whether an implementation honours the `statusCode` or declines it and fails the whole route is outside what a render can see. *Mutual exclusion with the Ingress* — each chart aborts its own render when `ingress.enabled` and `httpRoute.enabled` are both true, which is where it belongs: this test renders one configuration per chart, and a rule here would have to assume the switch is chart-global, which may not survive the multi-app charts of the sweep. *Cross-namespace and non-Service backends*, and a rule with no `backendRefs` at all (legal for a filter-only rule) — all three are **reported as "not checked", never counted as passing**. The redirect rule the pairing check demands is itself backend-less, so a chart that satisfies that check also earns one "not checked" line; both verdicts are correct at once.
- **Two anti-vacuum guards**, because the convention ships `httpRoute.enabled: false` and this test would otherwise be green over nothing. `ci/httproute/<chart>.yaml` is a render-only overlay applied **last** whose one job is to put a chart into Gateway mode — and a chart that *has* one and still renders no route **fails**. On top of that the run fails if **no assertion at all** held anywhere. `cyberchef` is exercised through its ordinary install-test fixture (which installs in Gateway mode, see below), every other converted chart through its overlay. For a multi-app chart the overlay also has to enter every shape the template has: `ci/httproute/freesailarr.yaml` moves one app onto a host of its own so that the shared-host route *and* the per-app route are both rendered.
Measured in CI (run 719, the pilot): **30 charts, 4 assertions held**, whole job 13 s including checkout and the Helm setup, no cluster. Group 1 took that to 20 assertions over 10 charts and group 2 to 36 over 15; the prefix/redirect pairing of Gitea #118 adds the first check since the pilot rather than a chart, which brought it to 37 over 15 — one assertion for `freesailarr`'s `/qbittorrent`, plus one more "not checked" line for the backend-less redirect rule it demands. Group 3 took it to `56` assertions over `21` charts, from six charts of which three contribute more than one route: `keycloak` 4 (public and admin routes), `matrix-synapse` 4 (homeserver and delegation routes), `homeassistant` 5 (two routes, three rules — its overlay was the first in the repo that has to *supply* hostnames, since the chart has no `ingress.domain`), `bulwark` 2, `stalwart` 2, `ytdlp-web-player` 2. **`rss-bridge` finishes the sweep at `58` assertions over `22` charts** — read on this branch, which does **not** contain the realm-hostname round of Gitea #122 (measured separately at `60` over `21`, on a branch that did not contain `rss-bridge`). **Neither total includes the other, so the figure to trust from either is the delta**; the running total was therefore read off the first branch that contained both — the realm-hostname branch rebased onto this one — and it is **`62` assertions over `22` charts**, `0` failed. `keycloak` contributes `8` of them: one `parentRefs` and one `backendRefs` assertion for each of its four routes, the two realm routes adding one "not checked" line each for their backend-less redirect rule (`keycloak: ok (8 assertion(s) held, 2 not checked)`). The #118 prefix/redirect pairing deliberately does **not** fire on those two: it is scoped to rules that strip a prefix with a `ReplacePrefixMatch` rewrite, and a realm route strips nothing — it redirects the bare root of a hostname that serves one realm. The delta here is one route, so two assertions, and its overlay is the **second** that has to supply hostnames — for a different reason than `homeassistant`'s, which is worth keeping apart. `homeassistant` has no `httpRoute.hostnames` key at all and its overlay supplies the names through `ingress.hosts`; `rss-bridge` *has* the key and its overlay fills it, because this chart has no `ingress.domain` to fall back to either but its hostnames are unambiguous (one Service, one path). **The MTA-STS routes of Gitea #123 add `2`**, and they are the entry that shows what this figure means once the sweep is over: `stalwart` goes from `2` assertions to `4` — one `parentRefs` and one `backendRefs` assertion for its new MTA-STS route — and **the chart count does not move at all**. Every earlier entry in this history grew because a chart *joined* the list; this is the first that grew because a chart already in it gained an **object**, which is the ordinary shape of the next one too. Following the same delta rule, and read off the first run that contains all three rounds — this branch, rebased onto a `main` that already held `rss-bridge` and the realm hostnames — the figure is **`64` assertions over `22` charts**, `0` failed. The delta counts only because the overlay was extended to enter the new shape: `ci/httproute/stalwart.yaml` carries an entry with a `clusterIssuer`, one without, and one host on a **subdomain**. An overlay left alone would have added a second route this test never rendered — the anti-vacuum rule biting on a *second* object inside a chart that already passes, which no per-chart "has an overlay" check can see. **The Ingress half of that feature is not covered here at all**, because an overlay's one job is to switch Gateway mode on; it sits in `ci/stalwart/test-values.yaml`, where the install-test has a real API server accept it.
**And in the same PR that chart's install-test went green, which is the event Gitea #51 was waiting for.** It had failed on the Gitea runner for months (uid 33 unable to read `/app/index.php`, startup probe 403 through its whole budget), and the chart was deliberately kept *out* of `ci/excluded-charts.txt` precisely so that the day it passed would be noticed rather than silenced. It passed: k3d cluster up, `helm install` in Ingress mode, pod `1/1 Ready` in 22 s with 0 restarts, and no `Permission denied` anywhere in the job. **The chart cannot be the reason, and that is measured rather than argued** — the rendered output for the fixture values is byte-identical to `origin/main`, so the pod spec that installed is exactly the one `main` produces. The implicated layer is the runner, which is where #51 had already put the burden of proof. Two cautions belong with the result: **nobody has identified what changed on the runner**, and a green test does not explain itself — it has reproduced on every run of that branch since, across several commits, so it is not a fluke - but that is still a colour and not a cause; and the install covers **Ingress** mode, since that is what the fixture installs — so this chart's Gateway mode stays render-only, exactly like its 21 siblings rather than worse than them.
The fail path was exercised rather than assumed: a Service renamed in the route, a port the Service does not expose, a port left out entirely, an emptied `parentRefs` with the chart's own guard disabled, an overlay that renders no route, a run with zero assertions, and — for the pairing — both the redirect rule deleted outright and the redirect pointed at a path other than the one stripped; each produced a `FAIL` naming both sides and a non-zero exit.
### Bump-level test (the version-bump hook, every PR)
`ci/run-bump-level-test.sh` (no arguments) holds every case of `ci/renovate-bump-chart.sh` against the version string it writes. Added with Gitea #128, which is what made it necessary: until then the hook was a calculator with one branch, and with the update type coming from Renovate it became the thing that *says how big a change a cluster is being offered* — while the chart version is the only signal anything downstream sees (see "Chart dependencies": Fleet reads our chart version and nothing inside the package).
Its failure mode is the silent kind this section's two rules are about: **a wrong level is a valid version string.** The PR goes green, the chart publishes, and the only symptom is a number that understates what it ships. Nothing in the pipeline reads that number, so nothing but a test can.
A case is not a function call, because the script reads the **base commit** (`git show HEAD:<chart>/Chart.yaml`) *and* the working tree: each of the 49 cases gets a throwaway `git init` under `mktemp -d`, one commit carrying the base `Chart.yaml`, and the new one written over it uncommitted — the state Renovate's `postUpgradeTasks` hook actually runs in. Signing is switched off per command, since this workspace signs through an SSH signer and a throwaway repo has no business invoking it. No Helm, no cluster, no `pyyaml`, no `yq`, no Docker: the fixtures are six-line `Chart.yaml` files written with `printf`, and the assertion is one `version:` line read back with `sed`. The job therefore carries no Helm step and takes about a second.
What the cases cover: each update type, the `freesailarr` shape (an app minor while `appVersion` stands still), all seven non-semver app versions plus the majors that must *not* fall back, the maximum rule in both directions including a stale branch version that must **not** be kept, a missing and an empty and an unknown second argument, and the inputs the script must leave alone — a chart new on the branch, `template-chart`, a `.github/` or `ci/` argument, no argument at all. The full `version:` line is asserted in every case, so the `+up` suffix and its dash-to-dot normalization are covered throughout.
**What it cannot assert, and does not pretend to:** that Renovate passes the update type at all. The hook runs only when the command is allow-listed on the shared instance, which is outside this repository — which is why a *missing* second argument is itself a case here, and why the change that introduced it carries a dry run instead of a claim (see "The version-bump hook").
### YAML-scalar render test (all charts, every PR)
`ci/run-yaml-scalar-render-test.sh [chart …]` (no argument = all charts) renders every chart with the same fixtures the install-test uses and checks that **every scalar Kubernetes types as a string really arrived as one**. Introduced with Gitea #11, where a value rendered without `| quote` had been shipping in a chart for months; #112 turned out to be the same defect in the same chart's `env` block. Three assertions, and all three are properties the API itself requires — there is no chart for which the opposite is a legitimate configuration, which is why this test needs no per-chart expectations:
| Assertion | What a bare render does | Who notices today |
|---|---|---|
| `CronJob.spec.schedule` is a non-empty string | a schedule beginning with `*` (the commonest cron notation there is) or with `@` **aborts the render** — YAML reads the leading character as an alias or a reserved indicator | the operator, eventually: the error names the **template** and their own value appears nowhere in it, and **quoting it in the values file does not help**, because Helm passes the string's content through |
| every `env[].value` of every container and initContainer is a string | `"true"` and `"0755"` are legal plain scalars that are **not strings**: the chart renders perfectly and the API server rejects the object with `cannot unmarshal bool into Go struct field EnvVar.value of type string` | nobody until apply time — which in this repo means the install-test or a cluster, never the render |
| every `metadata.labels` and `metadata.annotations` value is a string | both are typed `map[string]string`, so a unit-less `100` in an upload-size annotation is rejected whole | nobody in the render |
**Why this job exists next to the render tests that already run:** the object that carried the original defect is rendered by **no fixture at all**. `paperless-ngx`' consume CronJob sits behind `smbConnection.enabled`, which is `false` in the chart defaults and `false` in its install-test fixture — deliberately, since the CI cluster has no SMB CSI driver. So nothing in this repository ever produced that object: not lint, not the other render tests, not the install-test. A test that renders only what the fixtures already render would have been just as blind, which is the whole argument for the overlays below.
Three things to know before touching it:
- **Helm is the YAML parser**, through `ci/yaml2json`, for the same reason as the two tests above — the runners have neither `pyyaml` nor `yq`, and a parser written in `awk` fails by reading nothing and reporting nothing.
- **Two anti-vacuum guards.** `ci/yaml-scalar/<chart>.yaml` is a render-only overlay applied **last**, whose job is to switch such objects on *and to feed them the hostile values* — a schedule beginning with a star, an `env` value that is a bare `true`. A chart that *has* one and still renders none of the three kinds **fails**, and the run fails if **nothing at all** was asserted anywhere.
- **What is deliberately not checked**, named so the gaps are not mistaken for coverage. A value rendered into a scalar that is **hand-quoted in the template** (`"{{ .Values.x }}"`) is a string in the output by construction, so this test is blind to it — what it can still do is break the YAML when the value itself contains a quote or a backslash, and only `| quote` fixes that. Everything that is **not** a label, an annotation, an `env` value or a schedule: a bare `storage:` or `className:` is just as unquoted, but its value space is DNS labels and quantities, where no legal value is YAML-ambiguous. And **whether a cron schedule is a *valid* schedule** — that is the controller's judgement, and narrowing the schema to a regex was considered and rejected, because the defect was never a wrong schedule but a correct one rendered bare.
Measured locally: **30 charts, 408 scalars verified, 0 failed**, no cluster. The fail path was exercised rather than assumed, in all three of its shapes, against a throwaway copy of the chart as it stands on `main`: the unquoted schedule aborted the render with `yaml: line 6: did not find expected alphabetic or numeric character` and the test reported it; with only the schedule quoted, the star-valued `env` entry aborted the render on the StatefulSet instead; and with only the bool-looking value left — a render that **succeeds** — the type assertion produced a `FAIL` naming the variable, the container and the exact API-server message. **Seven charts still render their `env` block bare** (`matrix-synapse`, `minecraft-router`, `minecraft-server`, `rallly`, `satisfactory`, `teamspeak-server`, and `paperless-ngx` until #11); the test is green for them only because their fixtures set no hostile `env` value, and the overlay is what makes the difference between covered and merely green.
### Install-test (PR pipeline stage 2)
@@ -170,6 +229,7 @@ The job itself is the reusable workflow **`install-test.yml`**; `validate-charts
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/gateway-api-crds.sh` — the **real** Gateway-API CRDs (standard channel, pinned, Renovate-tracked), applied only for a chart whose render actually contains an `HTTPRoute`. **Deliberately not a stub and deliberately not in `ci/common/`.** A schema-less stub would make the exercise worthless here: the API server would accept a malformed `parentRefs`, a port written as a string, a path type that does not exist, and the install-test would prove only that *something was created* — for `OnePasswordItem` that is the right trade (nobody is testing the operator's API), for an `HTTPRoute` the schema **is** the point. Applied with `kubectl apply --server-side`, and the reason is measured: client-side apply stores the whole object in the `last-applied-configuration` annotation, annotations total at most 262144 bytes, and the HTTPRoute CRD of v1.6.3 is 244792 bytes as JSON — 93 % of the limit, with every release adding fields. Client-side happens to fit today and is one upstream release away from failing with `metadata.annotations: Too long` on a Renovate PR that has nothing to do with it. Downloaded rather than vendored, like Trivy's tarball and `kubectl`: 1.1 MB of generated YAML in the repo would go stale invisibly, and the price of the fetch is named — if the asset disappears, an unrelated PR goes red, loudly
- `ci/<chart>/test-values.yaml` — values for the CI install (required for every installable chart)
- `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
@@ -232,7 +292,7 @@ What Renovate keeps current:
| --- | --- |
| Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository |
| Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager |
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block |
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block. One exception: `mediathekarr` is pinned by **digest** (its tags carry no version) and has its own `customManagers` block. All ten arrive **grouped into one PR**, see "The one grouped chart" below |
| GitHub Actions | Built-in `github-actions` manager |
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
@@ -240,23 +300,51 @@ Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — reposi
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 ten images alike, the digest-pinned one included — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
A consequence of pinning every tag in `values.yaml`, and the measurement that forced the change in Gitea #128: `ci/renovate-bump-chart.sh` used to see an unchanged `appVersion` and bump the chart **patch** — for all ten images alike, the digest-pinned one included. `appVersion` here is the chart's own marker rather than a tracked upstream version, so a comparison had nothing to read: prowlarr's `2.5.2 → 2.6.5` was an upstream **minor** that arrived as a chart patch. Since #128 the level comes from Renovate's classification instead (`{{{updateType}}}`, the hook's second argument), so a minor among the ten makes a chart minor and a mixed group takes the highest level in it. The `+up` suffix still follows `appVersion` and therefore still stands still.
**That open point is now decided (Gitea #128), and the decision is deliberately smaller than the problem.** The gap was real: the automatic patch was also a *classification*, and the convention exempts a "plain app-version bump" from the wiki obligation — so an image update that quietly changed a **default** was the one case where nothing reviewed it and nothing documented it. It had already happened: the `sonarr` 4.0.19 bump carried upstream's removal of the hard-coded trusted proxy networks and reached `main` unremarked (see [Lessons learned](https://git.jk-dev.de/jlab-cluster/helm-charts/wiki/Lessons-Learned)). What was decided is **the classification and not a reading duty**: the chart jump follows the app jump, so the number stops understating what it ships. The variant that would have caught that incident at its origin — *a chart minor or major obliges reading the upstream release notes, with the result stated in the PR even when it is "nothing found"* — was **rejected**, and its price is the part that stays: nobody is obliged to look behind the number, so an app minor that changes a default can still reach `main` unremarked. What the bigger number does buy is visibility where a patch has none — Fleet's `fleet.yaml` in `cluster-config` sees only our chart version (see "Chart dependencies"), so a minor is a number other gates can act on. Whether `cluster-config` actually distinguishes update types is **not** visible from this repository; that is the mechanism which makes the number effective, not a claim that it is in use.
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.
**linuxserver/qbittorrent** (freesailarr) excludes two tags by name, `14.3.9` and `20.04.1`. They are leftovers from earlier linuxserver tagging schemes, and docker versioning sorts both above every real release, so Renovate offers them as major updates — PRs #152 and #153 were exactly that. They are named rather than cut off with an upper bound like `<10`, because a bound would eventually reject a genuine major, while two finished artifacts cannot grow and therefore cannot hide a future release. The same guard as for nginx applies and was run: a lookup from `currentValue 4.6.4` still offers `5.2.3` as a **major**, so the filter does not silently empty the update list. That fixture also shows the cost of leaving it alone — without the rule the very same lookup offers `20.04.1` and never mentions the real `5.2.3`, since Renovate fills the major bucket only once.
### The one grouped chart
`freesailarr` is the only chart whose updates are **grouped**: all ten of its images arrive as one PR per cycle (`groupName: "freesailarr stack"`), where every other chart in this repo gets one PR per image. For the others that is already the same thing — one chart, one image — so this is a rule about a ten-image chart rather than a change of policy. It is also the only `groupName` in `renovate.json5`, and it reverses what the comment above the app-version managers describes: the `helm-values` manager finding those ten images and proposing "one update per image". That sentence described what Renovate does, not what the chart wants.
**What it fixes, measured on 2026-10-12.** Eight of the ten images had an open Renovate PR simultaneously (#102 bazarr, #103 qbittorrent, #104 sonarr, #105 flaresolverr, #106 recyclarr, #107 prowlarr, #108 radarr, #111 seerr). Each edits `freesailarr/values.yaml` and — through the `postUpgradeTasks` hook — `freesailarr/Chart.yaml`, so the API reported `mergeable: false` for all eight: they rewrite the same `version:` line. One could merge; the other seven then needed a rebase each, and every rebase restarts the most expensive install-test here (a real Wireguard tunnel, ten containers). A sharper second symptom came with it: all eight carried `version: 3.1.1+up1.0.0 → 3.1.2+up1.0.0` while `main` already stood at `3.3.0+up1.0.0`, because the hook derives everything from the **base commit** and those branches predated the Gateway-API rounds. A rebase repairs that, which is precisely the problem — nothing *fails* if the rebase does not happen first, so the repair depends on the bot reaching the branch before a human reaches the merge button. Grouped, there is one branch, one `version:` computed from one base, one publish and one install-test run.
**Four decisions worth keeping** (the reasoning sits next to the rules in `renovate.json5`):
- **Matched by file name, not by a list of images.** What causes the collision is "touches `freesailarr/Chart.yaml`", true of every dependency Renovate finds in that `values.yaml` regardless of manager or datasource. A `matchPackageNames` list would be a second place to keep the chart's image set current, and its failure mode is silence — an image added later never joins the group, with no error (#158: a valid rule is not a matching rule).
- **`mediathekarr` is in the group** although it comes from its own `customManagers` block: its digest PR edits the same two files and blocks the others identically. `digest` is in `matchUpdateTypes` for exactly that, and its own block is untouched — a group decides which *branch* an update lands on, not how it is found.
- **`gluetun` and the `busybox` init image are in too.** Neither "gluetun is the tunnel" nor "busybox is only an init container" has anything to do with the blocking, and a grouped PR hides nothing: the review still sees every changed line, just not eight times over.
- **Majors stay out**, via `matchUpdateTypes: ["patch", "minor", "digest"]`. An app major must be taken stepwise in its own PR, and `separateMultipleMajor` splits a multi-major jump to produce that sequence; a group throwing a major in with seven patches would undo both.
**It takes two `packageRules`, and that is not cosmetic.** Renovate refuses `matchUpdateTypes` and `separateMinorPatch` in one rule — `packageRules cannot combine both matchUpdateTypes and separateMinorPatch`, a config-validation error — which is consistent, since `separateMinorPatch` is what *decides* the split a rule would then be selecting on. So one rule groups the non-major update types and a second sets `separateMinorPatch: false` for the same file. Without that second rule the group still arrives as **two** PRs: verified by dry-run (`--platform=local --dry-run=full`, real registries) against a checkout holding those eight updates, they landed on `renovate/freesailarr-stack` (the three minors) and `renovate/patch-freesailarr-stack` (the five patches) — note that `separateMinorPatch` **prefixes** the branch rather than suffixing it, which makes the second name easy to miss in a branch list. With both rules, one branch carries all eight. The global `separateMinorPatch: true` keeps its reason for every other chart; "one red install-test must not block unrelated updates" simply does not apply within a single chart, where the updates share a pod, a chart version and a publish.
What this cannot show from inside the repo: Renovate runs as the shared instance outside it, so a valid config and a correct local lookup are the evidence available — not the behaviour of the next real run.
Not managed: `ci/**` (install-test fixtures pin throwaway infrastructure to major lines on purpose) and `template-chart/**` (placeholder image). A consequence worth knowing: `ci/matrix-synapse/fixtures.yaml` pins a Synapse image that Renovate will not follow — bump it by hand when the chart moves a major.
### The version-bump hook
Renovate only rewrites `appVersion:` or an image tag; it knows nothing about `version: <chart-semver>+up<app-version>`. Left alone, every chart PR would fail the `build-chart` version check. `ci/renovate-bump-chart.sh <chart-dir>` closes that gap and is wired up as a `postUpgradeTasks` command:
Renovate only rewrites `appVersion:` or an image tag; it knows nothing about `version: <chart-semver>+up<app-version>`. Left alone, every chart PR would fail the `build-chart` version check. `ci/renovate-bump-chart.sh <chart-dir> [update-type]` closes that gap and is wired up as a `postUpgradeTasks` command. **Since Gitea #128 the chart jump follows the app jump**, which turned the script from a calculator into deciding logic:
- **app major changed** → chart major (`6.0.0` → `7.0.0`), matching "runtime behavior changes" in the semver conventions
- **anything else** (plain app update, sidecar image) → chart patch
- **update type `major`** → chart major (`6.0.0` → `7.0.0`), matching "runtime behavior changes" in the semver conventions
- **update type `minor`** → chart **minor** (`6.0.0` → `6.1.0`) — the part that was missing, and where the incident sat
- **`patch`, `digest`, `pin`, `pinDigest`** → chart patch
- **no or unknown update type** → the pre-#128 behaviour: app major changed → chart major, anything else → chart patch
- **never below what the branch already carries** — the maximum rule below
- the `+up` suffix is recomputed from the new `appVersion`, dashes normalized to dots
Everything is derived from the base commit, so the script is idempotent across the multiple updates a single Renovate branch can carry.
**The level comes from Renovate, not from `appVersion`,** and that is the whole point: `freesailarr` keeps a hand-maintained `appVersion` for ten upstreams, so the update that prompted the rule moved an image tag in `values.yaml` while `appVersion` stood still (see "The one grouped chart" above). Verified rather than assumed — Renovate 44.155.7, keycloak pinned one minor back: `--platform=local --dry-run=full` classifies the update (`"updateType": "minor"`) but **never reaches the branch worker**, so it cannot show the hook at all; driving Renovate's own post-upgrade executor against a throwaway checkout does, and logs `cmd: "ci/renovate-bump-chart.sh keycloak minor"` with the script answering `1.3.0+up26.7.0 -> 1.4.0+up26.8.1 (update type minor)`.
**Non-semver app versions stay at patch for a minor**, with the reason in the convention rather than in a footnote: in CalVer and date tags the second position is a *month*, so following it would mint a chart minor every month that says nothing about the risk, and in a four-part or suffixed version the positions are not semver positions at all. Seven charts on `main` are in that class — `rss-bridge` (`2026-09-30`), `homeassistant` (`2026.8.3`), `searxng` (`2026.9.13-3b8009065`), `minecraft-server` (`2026.8.2-java25`), `apache-tika` (`3.3.1.0-full`), `teamspeak-server` (`6.0.0-beta13.1`), `valkey` (`9.1.2-alpine`) — and an app **major** is not subject to it, so a year rolling over in a date tag is a chart major exactly as it was before. The honest edge: for `6.0.0-beta13.1` and `9.1.2-alpine` the numeric part *is* a semver line, so the fallback understates a real minor there. That is the accepted trade — conservative by default; a chart that wants the minor earns a documented exception rather than a widened pattern.
**Maximum, not replacement.** The chart jump is the *greater* of the chart's own classification and the app jump, so a value that becomes required stays a chart major under an app patch. Mechanically it is also what makes a grouped branch deterministic: the hook runs once per update and every call recomputes from the same base commit, so without the maximum the last call would win and `freesailarr`'s minor-among-patches group would take whatever level it happened to end on. The script only ever keeps a *higher* version than it computes, which is why repeated runs stay idempotent, and everything else is derived from the base commit for the same reason.
`ci/run-bump-level-test.sh` holds every one of those cases (49 of them) against the version string the script writes — a wrong level is a valid version string, so the PR would go green and the only symptom would be a number that understates what it publishes.
> **One-time setup outside this repo:** `postUpgradeTasks` only runs on self-hosted Renovate **and** only when the command is allow-listed in the *instance* config:
>
@@ -265,6 +353,8 @@ 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.
>
> **The second argument depends on that pattern's shape, and the shape is not visible from here.** Renovate matches the allow-list against the **compiled** command (`allowedCommands.some((p) => regEx(p).test(compiledCmd))`), so the front-anchored form above still matches with an argument appended — measured, see the dry run noted in `renovate.json5`. A pattern anchored at the end on the instance (`…\.sh$`) would stop matching and produce the same silent skip a missing entry does. Only the maintainer can check that at the instance. It is why the script treats a **missing** update type as the pre-#128 behaviour instead of an error: the worst case of a narrower allow-list, an older Renovate or an unpopulated template field is the old classification, not red chart PRs.
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.
@@ -285,7 +375,7 @@ The config is aligned with the one in `cluster-config`, because the same `jlab-r
- **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.
- **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. **It has one exception**, `freesailarr`, where it is overridden to `false` — see "The one grouped chart" below for why that separation is wrong inside a single chart.
- **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.
@@ -325,10 +415,11 @@ These guidelines are **binding for every current and future chart** in this repo
### 2. Versioning
- `Chart.yaml` uses `version: <chart-semver>+up<appVersion>`; the `+up` suffix matches `appVersion`, with dashes normalized to dots (e.g. `0.0.36-beta` → `+up0.0.36.beta`). `appVersion` is the **exact image tag** of the main app image.
- Semver rules for the chart part:
- **Patch**: bug fixes without behavior or values-schema change; plain app-version updates.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults).
- **Major**: runtime behavior changes or breaking values changes (e.g. resource renames, probes, security hardening, values that become required).
- Semver rules for the chart part. **The chart jump is the greater of the chart's own classification and the jump of what it ships** (Gitea #128) — a maximum, never a replacement, so a value that becomes required is a chart major even under an app patch:
- **Patch**: bug fixes without behavior or values-schema change; an app **patch** or digest update.
- **Minor**: additive, backward-compatible changes (new optional values with unchanged defaults); an app **minor** update.
- **Major**: runtime behavior changes or breaking values changes (e.g. resource renames, probes, security hardening, values that become required); an app **major** update.
- The app half of that is mechanical and belongs to the hook rather than to a reviewer's judgement: `ci/renovate-bump-chart.sh` takes the level from Renovate's update type, with non-semver app versions staying at patch for a minor and a missing update type falling back to the old behaviour. The full rule, the seven affected charts and what it deliberately does **not** oblige anybody to read are in "The version-bump hook" above.
- Any push to `main` touching a chart directory publishes that chart, so the version must be bumped in the same change.
### 3. Images
@@ -338,15 +429,64 @@ These guidelines are **binding for every current and future chart** in this repo
- **Documented exception — minecraft-server**: the only chart whose main image tag can be overridden, through `image.tagOverride` (#158, empty by default — the tag still comes from `appVersion`). `appVersion` is the tag and therefore fixes the JVM, and since chart 5.0.0 only the **newest** Java line is maintained out of this one directory; the older lines stay published as frozen chart versions (java8 and java17 at 4.0.0, java21 at 4.1.0) and never receive another change. Without the override, running an older JVM would mean installing a frozen chart and giving up every later fix — so the line is a decision at rollout time rather than a property of the chart. The override is named `tagOverride`, not `tag`, because it decouples the running image from the chart version, and it is deliberately outside Renovate's reach (`renovate.json5` tracks the `appVersion` in `Chart.yaml` and nothing else). The chart checks the pairing itself: the Java line of the **effective** tag against `minecraft.version`, failing the render with both values named — a mismatch used to install silently and only break the container afterwards.
- Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round.
### 4. Ingress
### 4. Ingress and Gateway API
- `ingress.domain` and `ingress.clusterIssuer` (cert-manager) are `required` values **without defaults** — every installation sets both explicitly; they are the only values needed for a default install.
- `ingress.domain` and `ingress.clusterIssuer` (cert-manager) are `required` values **without defaults** — every installation sets both explicitly; they are the only values needed for a default install. `clusterIssuer` is required **only while `ingress.enabled`**: in Gateway mode the chart renders no certificate, and a value that is demanded and does nothing is precisely the defect guideline 12 exists to remove.
- `ingress.className` selects the ingress class and defaults to `nginx`.
- `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets.
- The TLS secret is named `tls-<release>-<chart>-ingress`.
- **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field).
- **A set of objects publishing ONE backend under hostnames the chart does not own is the third admissible shape, and it buys its secret name and its issuer per entry (Gitea #122).** Where a chart's app serves several tenants from one deployment and each wants a hostname of its own — `keycloak`'s realms are the case this was written for — the hostnames come as a values list and the chart renders one object per entry. Two convention rules do not survive that, and both deviations are **required**, not merely tolerated: the TLS secret name is a per-entry value with **no** default, because these hostnames already have certificates and several entries typically share one (a wildcard for a zone), so a derived `tls-<release>-<chart>-...` name per entry would ask cert-manager for a second certificate for a name that is already covered — one ACME order per hostname, for nothing; and the cert-manager annotation becomes **optional per entry**, because cert-manager keys a Certificate by the secret it writes, so of several objects naming one secret exactly one may carry it. That is the same deliberate omission the qBittorrent object in `freesailarr` makes, for the same reason, and it is why the secret name is required rather than defaulted: a chart that invents the name cannot also leave the issuing to somebody else. Annotations are per entry too, since the set is not uniform across the hostnames. Object names carry the **list index**, for the reason spelled out in the homeassistant bullet below — a hostname is not a dependable object-name fragment, so reordering the list renames and recreates the objects — and the entries render in **both** routing modes: a routing value in this repository is mode-independent by design, so a feature rendering in one mode only would silently disappear when an installation switched. That is also why the per-entry `parentRefs` exists, as the counterpart of the per-entry class.
- **Documented exception — homeassistant, and it extends to the Gateway mode**: 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). Because it has **no `ingress.domain`**, it has **no `httpRoute.hostnames`** either — the second chart without that key after `freesailarr`, and the schema rejects it outright so the absence is enforced rather than merely intended. The hostnames and the paths come from `ingress.hosts`, which is therefore read in **both** modes and is not an Ingress-only value, and the chart renders **one `HTTPRoute` per entry** of that list with one rule per path of the entry. One object carrying several hostnames would be wrong rather than merely different: an `HTTPRoute`'s rules apply to *every* hostname of the object, and per-host paths are this chart's whole point. `ingress.className`, `ingress.tls` and `ingress.annotations` are not read in Gateway mode. Route names carry the entry's **index**, not its hostname (a hostname is not a dependable object-name fragment), so reordering `ingress.hosts` renames routes and therefore recreates rather than edits them — harmless, since a route holds no state and no certificate, but worth knowing before it looks like a bug. Its `pathType` default is the awkward one; see the `ImplementationSpecific` rule in the Gateway-API bullets below.
- **A shared domain with sub-paths is the second admissible ingress shape.** Where a chart exposes several apps, they may either each take a host of their own or share one host and be told apart by path (`domain.tld/sonarr`, `domain.tld/radarr`, …) — one DNS name and one certificate for the whole bundle. The rules for the shared shape: the domain is a single value (`ingress.host`), each app's path is its own value defaulting to `/<app>`, the paths render in one Ingress object `<release>-<chart>-ingress` with the standard TLS secret `tls-<release>-<chart>-ingress`, and an app that carries a host of its own always wins and keeps its own object — so both shapes mix without extra values. An app that needs a per-path proxy annotation (`rewrite-target` is per **object**, not per path) gets its own object on the same host, repeating the same TLS secret name and **omitting** the cert-manager annotation, so that exactly one certificate is requested. Apps that cannot serve a base path must be rendered at `/` or refused with a `fail` — never rendered somewhere they provably cannot work.
- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. It offers both shapes. By default each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled and it is not on the shared domain) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. Setting the global `ingress.host` switches every app without a host of its own to the shared-domain shape under `<app>.ingress.path`; qBittorrent (no base path, needs a prefix-stripping rewrite) gets the extra object described above, and seerr as well as mediathekarr are pinned to `/` — mediathekarr because its UI links assets with root-absolute paths, which a rewrite cannot fix; with both of them on one shared host the duplicate-path guard fires and one of them needs a host of its own (its ingress is therefore off by default). `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
- **The Gateway API is the second routing mode, and it is implementation-neutral by construction (Gitea #114).** A chart offers it through one values block, off by default:
```yaml
httpRoute:
enabled: false # counterpart to ingress.enabled
parentRefs: [] # the Gateway (name, namespace, sectionName) — a platform object
hostnames: [] # empty = [ingress.domain]
```
The rules, each of which is load-bearing:
- **Only `HTTPRoute` with `parentRefs`.** No `Gateway`, no `gatewayClassName`, no certificate, and **no implementation-specific policy object** (`ClientSettingsPolicy`, `ClientTrafficPolicy` and their equivalents). That subset looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to it is the whole reason the cluster's implementation can be chosen — or replaced — without touching the charts again. A policy object binds a chart to one implementation *and* to a CRD whose presence it cannot check. Class and certificate therefore move to the **platform**: they sit on the Gateway and its listeners, in `cluster-config`.
- **A value, not auto-detection.** `.Capabilities.APIVersions` is unreliable at template time, and an `HTTPRoute` without the Gateway-API CRDs fails at install time — so a chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing.
- **Mutually exclusive with `ingress.enabled`**, enforced by a `fail` that names **both** value paths. Two paths onto the same Service under one hostname is a conflict at DNS level already; a migration runs over a **second hostname** (`httpRoute.hostnames` set explicitly while the Ingress keeps `ingress.domain`), never over parallel operation.
- **`parentRefs` is mandatory in Gateway mode**, enforced by a `fail` on the empty list. A route without a parent attaches to nothing and is simply inert — created, schema-valid, rejected by nobody, reported by nothing.
- **One source of truth for the hostname.** Empty `hostnames` means `[ingress.domain]`; two values that have to agree drift apart. `ingress.domain` is therefore read in Gateway mode too — it names the release's public hostname and is not an Ingress switch. After the sweep there are **three** shapes here, and which one a chart gets follows from its values rather than from taste: a chart with an `ingress.domain` falls back to it (the default, most charts); a chart with no `ingress.domain` whose names could not be attributed — `freesailarr` to one of ten apps, `homeassistant` to one host entry and therefore one set of paths — has **no `hostnames` key at all** and reads the names where they already live; and `rss-bridge` has the key as a **mandatory** value with no fallback, because it has no `ingress.domain` either but its names *are* unambiguous (one Service at `/`), while the `ingress.hosts` list it would otherwise reuse carries per-host `tlsSecretName` values that address nothing once TLS sits on the listener. The rule behind all three: a hostname list is admissible exactly where it can say what it belongs to, and reusing an Ingress structure whose other half is meaningless in this mode is not a saving.
- **`NOTES.txt` says which mode is active**, as `keycloak` already does for its cases, and names what the mode costs.
- **What has no counterpart, and must be said rather than rebuilt.** The Gateway API has no annotation channel for controller behaviour. `rewrite-target` has a real equivalent (the `URLRewrite` filter in a rule); **`proxy-body-size` has none** — an upload limit is implementation-specific policy, so a chart that depends on one must print in its Gateway-mode `NOTES.txt` that the limit is **not enforced** and has to be set at the Gateway. Charts that *set or recommend* one carry that disclosure: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` since group 2, `bulwark` and `matrix-synapse` since group 3. `garage` *depends* on one without setting it: its `ingress.annotations` comment tells the operator to set `proxy-body-size: "0"` because ingress-nginx's 1 MB default truncates S3 uploads. Measured while sweeping group 1 — "sets no behaviour-bearing annotation" is not the same question as "needs none", and the sweep screens on the second one. `freesailarr` is the mirror image of `garage` and was measured in group 2: it sets no body-size annotation and needs none itself, but `ingress.annotations` is the only lever an operator has for one (qBittorrent takes torrent-file uploads), so its Gateway-mode `NOTES.txt` says that an annotation set there is gone. The same applies to auth annotations in front of an app with no login of its own: in Gateway mode that escape hatch does not exist in the chart at all (`cyberchef`, `searxng`), and so does any annotation a chart documents as its only lever for a response header (`bentopdf`: `X-Robots-Tag`).
- **Screen the LIVE object, not only the chart — group 3's lesson, and the generalisation of Gitea #118.** A chart can read as correct while the running system fills a gap of the chart with an annotation the **operator** set, and the Gateway mode drops `ingress.annotations`, so it drops the compensation with it. At #118 that was found *after* shipping, on `freesailarr`, where the annotation was a redirect nobody would have looked for in the chart. Group 3 measured it beforehand with read-only `kubectl` against the live cluster, and **four of its six charts carried such an annotation that the chart never sets**: `keycloak` (`proxy-buffer-size: 16k` and `proxy-buffers-number: 4`, on *both* of its Ingress objects), `matrix-synapse` (`proxy-body-size: 50m`, plus the `nginx.org/client-max-body-size` twin for the other nginx controller, plus a `configuration-snippet`), `stalwart` (the whole `enable-cors` block), `bulwark` (`proxy-body-size: 25m`). None of them appears in a `git grep` of this repository, because no template sets any of them — the only hits are the *documentation* that recommends two of them. **The measurement at the live object is part of the procedure, not a flourish**, and reading the chart alone is not a substitute for it.
- **And read the annotation's directive before calling it load-bearing.** `matrix-synapse`'s live `configuration-snippet` looks like exactly what Synapse's `x_forwarded` setting depends on — client IP for rate limiting, and federation. It is not: `more_set_headers` is the headers-more module's **output**-header directive ("Replaces (if any) or adds (if not any) the specified output headers"), the request-side one being `more_set_input_headers`. Those headers therefore go on the **response to the client**; Synapse never sees them, and what it reads with `x_forwarded: true` is the *request's* `X-Forwarded-For`, which ingress-nginx supplies itself. So the scariest-looking loss in group 3 is not a loss at all, and its `NOTES.txt` says in as many words **not** to re-create it at the Gateway and not to read its absence as the cause of a rate-limiting problem. The real loss on that chart is the body size. A reading of documented semantics, stated as such — nothing A/B-tested a running Synapse.
- **"No counterpart" is a claim to check against the pinned CRDs, and for CORS it is false.** `stalwart`'s live Ingress carries `enable-cors` with an allow-origin pointing at `bulwark`, the webmail chart in the *same group* — so losing it breaks a second chart, with a browser console error and nothing at all in the server's log. Measured by grepping the CRD set this repo pins (`ci/gateway-api-crds.sh`, gateway-api v1.6.3 standard channel): HTTPRoute's filters **do** carry a full `cors` field (GEP-1767) with `allowOrigins`/`allowMethods`/`allowHeaders`/`allowCredentials`/`maxAge`, and its documented level, verbatim from that CRD, is `Support: Extended`. `responseHeaderModifier` is in the same CRD and is **also Extended, not Core**. The chart still renders neither, for three reasons in order: it has **no CORS values** to render one from (the origin and the method list are the operator's, and the chart sets no CORS annotation in Ingress mode either, so a filter would exist in Gateway mode only — an asymmetry that is its own defect and its own piece of work); **Extended support is not support**, since a rejected route is rejected *whole* (the reasoning #119 reached for `statusCode`) and the observed failure is quieter still — Cilium accepts `filters.type: CORS` and forwards the preflight upstream anyway, i.e. `Accepted: True` with the behaviour absent; and **half a CORS is worse than none, with the translatable half being the useless one here** — the browser's preflight must be answered *by the gateway* (the CORS filter's own contract forbids passing it upstream) and a bare header filter cannot do that, while this installation's allowed headers include `Authorization`, which makes **every** request preflighted. Hence: configure CORS at the Gateway with that implementation's own support *before* moving the host, and test from the browser — `curl` sends no preflight, so it passes while the webmail fails.
- **`rewrite-target` is the one place where the Gateway API is less machinery than what it replaces, not a translation of it.** `rewrite-target` applies per **Ingress object**; a `URLRewrite` filter applies per **rule**. A chart serving several apps under one host, one of which needs its prefix stripped, therefore needs a *second* Ingress object for that app — which then has to repeat the other object's TLS secret name, omit the cert-manager annotation so only one certificate is requested (conditionally, for the case where no other object renders), and match on an `ImplementationSpecific` regex path whose capture group the rewrite refers to. In Gateway mode all four disappear: one route, one rule per app, the filter on one rule, a plain `PathPrefix`. Measured on `freesailarr` in group 2, and after group 3 it is still the **only** chart here with a `rewrite-target`: `bulwark` was listed as the second one by #114 and by this file, and it has none — see the HTTPRoute-test section for why the string occurs in that chart four times without the annotation ever being set.
- **`pathType: ImplementationSpecific` has no counterpart either, and it is a chart *default* in one place.** Ingress offers `Exact`, `Prefix` and `ImplementationSpecific`; the Gateway API offers `Exact`, `PathPrefix` and `RegularExpression`. The first two map cleanly. `ImplementationSpecific` means "ask the controller", so nothing preserves it — and it is the default in `homeassistant`'s `ingress.hosts`, the one chart keeping upstream's flexible host list. The rule group 3 settled on, and the split is not tidiness: on the path `/` it is rendered as `PathPrefix`, because every controller treats a root `ImplementationSpecific` path as a prefix match and the chart's own default therefore works in Gateway mode unchanged; on **any other path the render aborts**, because in ingress-nginx an `ImplementationSpecific` sub-path is a **regex** and `PathPrefix` is not one, so mapping it silently would change how the path matches while the render stayed green — the #118 class of defect exactly. `RegularExpression` is deliberately not offered as an escape hatch: its support level is Implementation-specific, which is what the first rule above forbids.
- **A value derived from `ingress.enabled` is a second thing the sweep has to look for, and it is not visible in the Ingress template.** Gateway mode sets `ingress.enabled: false` *by construction*, so any value computed from it flips — and where it stood for "is there a TLS-terminating proxy in front" or "is this app published at all", the flipped value is wrong while nothing fails. Two finds so far, both fixed by one helper reading `ingress.enabled OR httpRoute.enabled` rather than by a new value:
- `sure` (group 1), whose `RAILS_ASSUME_SSL`/`RAILS_FORCE_SSL` would have gone `false` with TLS terminated at the Gateway: insecure cookies, no HSTS, `http://` links, green pod. Helper `sure.publishedOverTls`.
- `freesailarr` (group 2), whose `freesailarr.sharedIngressApps` was gated on `ingress.enabled` and is read by the `<APP>__SERVER__URLBASE` environment variables as well as by the routing objects. In Gateway mode that list would have come out **empty**, so Radarr, Sonarr and Prowlarr would have been told no base path while their routes served them under `/radarr`, `/sonarr`, `/prowlarr` — a UI whose assets and API calls are linked at the host root, behind a Ready pod. The two guards living in that helper (seerr/mediathekarr only at `/`, no duplicate paths on the shared host) would have gone unenforced in that mode for the same reason. Helper `freesailarr.published`.
- `ytdlp-web-player` (group 3), the one this file had listed as still open, and the find with the sharpest consequence. Its `ytdlp-web-player.validate` guard — which refuses to publish the application and the bundled oauth2-proxy on the **same host** — was gated on `ingress.enabled`, so in Gateway mode it went silent. **And that guard is needed more in Gateway mode, not less.** In Ingress mode ignoring it fails loudly: ingress-nginx's admission webhook rejects the second Ingress and the Fleet bundle lands in `ErrApplied`. In Gateway mode **the webhook never fires** — it compares Ingress objects with Ingress objects, and an `HTTPRoute` is a different kind — so nothing rejects anything, both objects stay valid, and one hostname is served over two data paths that are *not* equivalent: the proxy's Ingress goes through **authentication**, the chart's `HTTPRoute` goes straight to the application. Which one a client reaches depends on what DNS resolves to, so it is an authentication bypass that comes and goes with a record, reported by nothing. Helper `ytdlp-web-player.published`, and the Gateway-mode abort carries its own message saying exactly that.
All three are now closed, and the screen was run over all six charts of group 3 rather than only the suspected one — and over `rss-bridge` as the last chart, where it came back **clean**: outside its Ingress template the only readers of an ingress value are `NOTES.txt`, which knows its own mode, and the `rss-bridge.tlsGroups` helper, which is called only from behind `ingress.enabled`. The application takes no base URL and no hostname in its environment, so there is nothing to compute from the switch and no `published` helper is needed. **A clean result is worth recording too**: the screen's value is that it is run, not that it always finds something, and "this chart needed no helper" is a measurement rather than an omission.
- **A subchart does not come along, and that has to be said where somebody will look for it.** `ytdlp-web-player`'s oauth2-proxy dependency has **no Gateway-API support** and this repo does not patch upstream charts (see "Chart dependencies"), so it renders an Ingress or nothing. In the *recommended* configuration — the proxy in the data path, owning the host, the chart's own routing object off — the object that publishes the release is therefore **the subchart's Ingress in both of the chart's modes**, and setting `httpRoute.enabled` there changes nothing about how the host is served. The chart's own route is for the proxy-disabled case or for the application on a host of its own, which is the documented unauthenticated bypass. Stated in the template header, in `values.yaml`, in the schema and in the proxy branch of `NOTES.txt`, because an operator migrating hosts would otherwise set the value and wonder why nothing moved.
- **CI**: either the chart's install-test fixture installs in Gateway mode, or it gets a render-only overlay at `ci/httproute/<chart>.yaml` — otherwise `ci/run-httproute-match-test.sh` is green over nothing for it, which that test fails on. An overlay for a multi-app chart has a second job: it must enter **every** shape the template has, or the branch nobody exercised is the one that breaks. `ci/httproute/freesailarr.yaml` gives one app a host of its own so both the shared-host route and the per-app route render.
Reference implementation: `template-chart/templates/xxx.httproute.yaml`. Pilot: `cyberchef`. Group 1 — the charts rendering one Ingress with no behaviour-bearing annotation — followed: `bentopdf`, `garage`, `patchmon`, `rallly`, `searxng`, `sure`, `urlaubsverwaltung`, `voucher-vault`. Group 2 — the charts whose behaviour hangs off an nginx annotation — followed: `calibre-web-automated`, `outline`, `paperless-ngx`, `zipline` (all four `proxy-body-size`) and `freesailarr` (`rewrite-target`). Group 3 — the special shapes: `homeassistant`, `keycloak`, `bulwark`, `matrix-synapse`, `stalwart`, `ytdlp-web-player`. **`rss-bridge` finishes the sweep**, as the twenty-second and last chart: 22 charts carried an Ingress template and 21 an HTTPRoute template, and the difference was exactly this one. It had been held back as frozen for changes by #51 — a runner defect, not a property of the chart — and the earlier claim that **#114 could not be closed while #51 was open no longer holds**: the maintainer lifted that freeze, so #114 closes with the chart converted while **#51 stays open**, since the runner defect is worked around for this purpose rather than fixed. Its shape: one `HTTPRoute` carrying **all three** of its hostnames, because nothing differs per host (every name serves the same Service at `/`, measured on the live Ingress), so `spec.hostnames` as a list is the whole answer and a second object would repeat one rule. It is also the only chart of the sweep whose Gateway branch has **no loss to document** — its live Ingress carries no `nginx.ingress.kubernetes.io` annotation at all, so the group-3 screen came back empty. Its install-test, red since the move (#51), **went green in the conversion PR** with a pod `Ready` in 22 s and no `Permission denied`; since that PR's rendered defaults are byte-identical to `origin/main`, the chart is ruled out as the cause and the runner is the implicated layer. That green covers Ingress mode, which is what the fixture installs, so the Gateway mode of this chart is render-only like every other chart's.
**Three of #114's descriptions of the group-3 shapes did not survive contact with the charts**, and the corrected list is worth having here because it is the one somebody will copy from:
| #114 said | Measured |
|---|---|
| `keycloak` and `bulwark` have **two** Ingresses each | `keycloak` has two (public and admin hostname) and gets two routes. **`bulwark` has one.** |
| `bulwark` is the second `rewrite-target` chart | It has none and cannot have one — `NEXT_PUBLIC_BASE_PATH` is compiled into its bundle, so it cannot be served under a sub-path and its route is one rule at `/`. The grep that produced the claim hit the chart's *refutations* of the idea. |
| `matrix-synapse` renders its Ingresses **in a loop over several apps** | That is `freesailarr`. `matrix-synapse` renders **one** Ingress object with **two host rules** — the homeserver at `/` and the delegation nginx at `/.well-known/matrix` — which has to become **two** `HTTPRoute` objects, because `spec.hostnames` applies to the whole object and these two want different paths and different backends. |
| `stalwart` renders **per node group** | Its Services and StatefulSets do; the routing object does not and must not. One Ingress targets exactly `ingress.nodeGroup`'s own http Service, and the route keeps both of that template's guards — the management surface is not safe to spread over a group that may not run the management tasks. |
Three further group-3 specifics: **`keycloak`'s admin Ingress did not hang off `ingress.enabled`** before the sweep (`ingress.admin.enabled` gates it, and that value is not an Ingress switch — it also sets `KC_HOSTNAME_ADMIN`), so `ingress.enabled: false` did not mean "no Ingress" and the object would have gone on claiming the admin host next to the admin route; it is gated on both now, which leaves the default render unchanged and only alters a combination that could not be coherent. `httpRoute.admin.parentRefs` falls back to `httpRoute.parentRefs` exactly as `ingress.admin.className` falls back to `ingress.className`, because **"which data path" is a property of the Gateway in this API, not of the route** — an internal Gateway there is what an internal ingress class is in the other mode. And **`stalwart` is the only chart in the sweep that still renders a `Certificate` in Gateway mode**: its mail certificate covers STARTTLS on 25 and implicit TLS on 465/993, which pass through neither an ingress controller nor a Gateway, so `ingress.domain` stays required there and the object must not be removed as part of a migration — deleting it drops SMTP and IMAP back to a self-signed certificate while every Kubernetes object stays green.
`garage` was one of #114's named special cases and was done in group 1: it ships `ingress.enabled: false`, so both of its modes are off by default and its route is exercised by a render overlay rather than by the install-test, which keeps its Ingress. `homeassistant` is the same case and the same treatment — its `ingress.enabled` default is `false` too, so group 3's overlay for it **adds** coverage the install-test never had rather than trading one object for another. `paperless-ngx` was the one chart in the repository that still rendered its Ingress **unconditionally**; group 2 gave it an `ingress.enabled` switch defaulting to `true`, because without a second value path there is nothing for the mutual-exclusion abort to name and the `required()` on `clusterIssuer` would have kept demanding a value the Gateway mode does not use.
**One place where the Gateway API is the better fit, found in group 3**: Matrix delegation. The server name is normally an apex domain that already serves a website, and under ingress-nginx two Ingress objects cannot both claim one hostname — the admission webhook rejects the second — which is why `matrix-synapse` has to carry the `/.well-known/matrix` rule *inside* its own single Ingress object. HTTPRoutes attached to the same listener for the same hostname are **merged** by the implementation, longest match winning, so in Gateway mode that route can sit beside whatever owns the apex without either side knowing about the other.
- **Documented exception — freesailarr, and it extends to the Gateway mode**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain` — and therefore **no `httpRoute.hostnames`** either, the only chart without that key. A chart-global list of hostnames could not say which of the ten apps it belonged to, so the hostnames stay where they already are, in `ingress.host` and `<app>.ingress.host`; both are read by both modes and neither is an Ingress switch. In Gateway mode the per-app shape renders one `HTTPRoute` per app (`<release>-<chart>-<app>-httproute`) and the shared shape exactly one (`<release>-<chart>-httproute`) with one rule per app — qBittorrent included, since its prefix-stripping rewrite is a per-rule filter there, and it is the one app contributing **two** rules: the rewrite plus a 301 from the bare, slashless prefix to the slashed form (Gitea #118). Without that redirect the slashless path is stripped to `/`, answered 200, and the page's relative links resolve to the root of the shared host — a different app. **The Ingress mode does not render it and cannot**: nginx offers only a `configuration-snippet` annotation, which is ignored unless the controller runs with `allow-snippet-annotations=true` (off by default since ingress-nginx 1.9, and it means accepting annotation risk level `Critical`), so the chart documents the snippet in `values.yaml` instead of shipping a no-op. It is the one place where the Gateway mode closes a gap rather than merely replacing machinery. 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
@@ -399,6 +539,7 @@ These guidelines are **binding for every current and future chart** in this repo
- Every workload container offers an additive `env` value block, appended to the env entries the chart always sets.
- Entries are rendered through `tpl` (so values may contain Helm templating) and support the `value`, `secretKeyRef` and `configMapKeyRef` forms (pattern: `template-chart/templates/xxx.deployment.yaml`).
- **`| quote` on the rendered `value`, and on the `name`/`key` of the ref forms, is part of the pattern and not cosmetic.** An env value is typed `string` by Kubernetes, so a value of `"true"` or `"0755"` rendered bare becomes a YAML bool or integer and the API server rejects the whole manifest with `cannot unmarshal bool into Go struct field EnvVar.value of type string` — after a perfectly green render. A value beginning with `*` is worse in the other direction: it aborts the render with a YAML error naming the template, while the operator's own value appears nowhere in the message, and quoting it in the values file does not help. **The rule is held by `ci/run-yaml-scalar-render-test.sh` on every PR** (Gitea #11/#112), which is also how the count below is known rather than assumed: `template-chart` carries the rule in a comment claiming "every chart in this repo quotes here", and **six still do not** — `matrix-synapse`, `minecraft-router`, `minecraft-server`, `rallly`, `satisfactory`, `teamspeak-server`. They are green only because no fixture of theirs sets a hostile value; bringing a chart to the quoted form is a **major** bump for it, since it changes rendered output for values that currently work.
- **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
@@ -489,7 +630,7 @@ One more trap, and it is the reason `stalwart` gates its subchart on `valkeySubc
**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.
- **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 version that ships it — since Gitea #128 at the **subchart's own level**, so an upstream minor is a chart minor even though our `appVersion` did not move. That chain is the entire safety net, and the number it writes is the only place the subchart's jump is visible at all. 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.
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/alam00000/bentopdf/main/public/images/fa
type: application
version: 1.0.3+up2.8.8
version: 1.1.0+up2.8.8
appVersion: "2.8.8"
maintainers:
+86 -12
View File
@@ -1,9 +1,14 @@
{{- $replicas := include "bentopdf.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.bentopdf.replicas) -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
BentoPDF {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }}
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
@@ -13,24 +18,74 @@ ACCESS
This chart has no secrets and no volumes — the two ingress values above are
everything an installation needs.
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING else
of the routing path — no Gateway, no class, no Certificate. Three things
therefore belong to the cluster and not to this chart, and if any of them is
missing the hostname above simply does not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is
visible only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required —
the chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
on behalf of this release.
AND TWO THINGS THIS MODE TAKES AWAY FROM YOU, both because the Gateway API
has no annotation channel and this chart renders no implementation-specific
policy object on purpose:
* THE X-Robots-Tag HEADER. A publicly reachable instance gets indexed, and
its pages carry a canonical link to the vendor's site. In Ingress mode
ingress.annotations is the lever (see values.yaml); here there is none in
the chart at all — the header has to be added at the Gateway, or the
instance has to not be public.
* THE REPAIR FOR A PROXY THAT MANGLES RESPONSES. See the two notes below on
the cross-origin headers and on .mjs; in this mode neither can be fixed
from this chart.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate;
ingress.domain and ingress.clusterIssuer are not needed. The app is only
reachable inside the cluster, as
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release has no
Ingress, no HTTPRoute and no certificate; ingress.domain and
ingress.clusterIssuer are not needed. The app is only reachable inside the
cluster, as
{{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
Then browse to http://localhost:8080. Set ingress.enabled=true together with
ingress.domain and ingress.clusterIssuer to publish it instead.
Then browse to http://localhost:8080. To publish it instead, set either
ingress.enabled=true with ingress.domain and ingress.clusterIssuer, or
httpRoute.enabled=true with ingress.domain and httpRoute.parentRefs.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}bentopdf.replicas: 0{{ end }} — the deployment runs with replicas: 0, so the endpoint
above does not answer until you set it back and upgrade again. Service{{ if .Values.ingress.enabled }} and
Ingress stay in place, so the certificate is not re-issued.{{ else }} stays in
Ingress stay in place, so the certificate is not re-issued.{{ else if $httpRoute.enabled }} and
HTTPRoute stay in place, so the route keeps pointing at a Service with no
endpoints — the Gateway answers with an error of its own rather than with
nothing.{{ else }} stays in
place.{{ end }}
{{ end }}
A GREEN POD ONLY PROVES DELIVERY
@@ -40,21 +95,40 @@ A GREEN POD ONLY PROVES DELIVERY
handing out index.html — no more. Whether PDF processing works is a
question about the browser, not about this release.
TWO HEADERS HAVE TO SURVIVE YOUR INGRESS CONTROLLER
TWO HEADERS HAVE TO SURVIVE YOUR PROXY
The image sends Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: credentialless. Without both arriving at the
browser there is no SharedArrayBuffer, and the LibreOffice-backed
conversions (Word, Excel, PowerPoint) fail while everything else keeps
working. Check them through the ingress, not at the pod:
working. Check them through the published URL, not at the pod:
curl -sI https://{{ if .Values.ingress.enabled }}{{ .Values.ingress.domain }}{{ else }}<host>{{ end }}/ | grep -i cross-origin
curl -sI https://{{ if .Values.ingress.enabled }}{{ .Values.ingress.domain }}{{ else if $httpRoute.enabled }}{{ first $hostnames }}{{ else }}<host>{{ end }}/ | grep -i cross-origin
The image sends them itself, so they arrive in both modes unless something
in the path strips or rewrites them. The difference is the repair: in Ingress
mode ingress.annotations can put them back, in Gateway mode that channel does
not exist and it is the Gateway's business. Same for the .mjs MIME type (see
values.yaml).
ANYONE WHO REACHES THE URL CAN USE IT
{{- if $httpRoute.enabled }}
BentoPDF has no login, and this chart adds none. That is defensible here —
the server takes nothing in, stores nothing and has no session — but a
public instance also gets indexed, and its pages carry a canonical link to
the vendor's site. In GATEWAY MODE there is no place in this chart for either
fix: the Gateway API has no annotation channel, so the X-Robots-Tag snippet
documented in values.yaml has NO counterpart here, and the chart renders no
implementation-specific policy object on purpose (it would bind the chart to
one Gateway implementation). Both have to happen at the Gateway — or the
instance must not be publicly reachable.
{{- else }}
BentoPDF has no login, and this chart adds none. That is defensible here —
the server takes nothing in, stores nothing and has no session — but a
public instance also gets indexed, and its pages carry a canonical link to
the vendor's site. Both are addressed through ingress.annotations; see
values.yaml.
values.yaml. Note that this escape hatch exists in Ingress mode only:
httpRoute.enabled has no annotation channel at all.
{{- end }}
NOTHING TO BACK UP, NOTHING TO MIGRATE
No database, no volumes, no uploads: documents never leave the visitor's
@@ -0,0 +1,90 @@
{{/*
The Gateway-API alternative to bentopdf.ingress.yaml (Gitea #114). Form and
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
Gateway API".
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners.
WHAT THIS CHART IN PARTICULAR LOSES IN THIS MODE, AND IT IS NOT NOTHING
ingress.annotations is BentoPDF's only lever for two things it cannot do
itself, and the Gateway API has no annotation channel at all:
* the X-Robots-Tag header that keeps a public instance out of search
indexes (ROBOTS_NOINDEX cannot run on a read-only root filesystem, and
every page carries a canonical link to the vendor's domain), and
* the repair for controllers that override the MIME type of .mjs files or
strip Cross-Origin-Opener-Policy / Cross-Origin-Embedder-Policy.
None of that is rebuilt here - a policy object would bind the chart to one
implementation. NOTES.txt says so in this mode instead of leaving it to be
discovered on a search-results page. The two cross-origin headers are sent by
the image itself and still arrive unless something strips them, so they are a
thing to CHECK (see NOTES.txt) rather than a thing to configure.
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
in this mode - and it is not demanded either: its only required() sits inside
bentopdf.ingress.yaml, behind ingress.enabled.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and all three defaults
are empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
even though no Ingress is rendered in this mode - the value names the public
hostname of this release, which both modes need; it is not an Ingress switch.
Checked for THIS chart rather than copied: nothing outside bentopdf.ingress.yaml
and this file reads ingress.domain - BentoPDF is a static bundle and is handed
no base URL - so with both routing modes off, domain is genuinely free. In
Gateway mode this required() is the one that fires.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
# backend Service - BentoPDF is served at the root and has no second path.
# The port is the Service's port NUMBER and not its name ("http"):
# backendRefs takes a PortNumber, so unlike the Ingress this reference
# cannot be made by name and the 80 below has to track
# bentopdf.service.yaml by hand. ci/run-httproute-match-test.sh is what
# holds that pair - it fails when a backendRef names a Service, or a port,
# the same render does not create.
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- end }}
+31 -1
View File
@@ -49,6 +49,7 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. It does not free ingress.domain by itself: the HTTPRoute reads that value too, so domain is only unnecessary while BOTH routing modes are off. clusterIssuer is read by the Ingress alone.",
"type": "boolean"
},
"clusterIssuer": {
@@ -61,13 +62,42 @@
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.",
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead - including the X-Robots-Tag snippet that is this chart's only lever against being indexed.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": {
"$ref": "#/definitions/parentRef"
}
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth.",
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"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"],
+59
View File
@@ -174,13 +174,30 @@ ingress:
# read inside the Ingress template - unlike searxng, this chart has no
# second reader of ingress.domain: the upstream counterpart BASE_URL is a
# build-time argument and empty in the published image).
#
# Mutually exclusive with httpRoute.enabled below (Gitea #114): with both
# true the render aborts, naming both values. In Gateway mode ingress.domain
# is read again - by the HTTPRoute, as its single hostname - so "both modes
# off" is what frees it, not "ingress off".
enabled: true
clusterIssuer: null
# The public hostname of this release. Read by the Ingress AND - when
# httpRoute.hostnames is empty - by the HTTPRoute, so it stays the single
# source of truth for the hostname in both modes.
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
#
# THESE HAVE NO COUNTERPART IN GATEWAY MODE, AND FOR THIS CHART THAT COSTS
# SOMETHING CONCRETE. The Gateway API has no annotation channel for
# controller behaviour, so both uses documented below - the X-Robots-Tag
# header and any repair for a controller that mangles the headers or the
# .mjs MIME type - are simply gone when httpRoute.enabled is used instead.
# The chart does not rebuild them: a policy object would bind it to one
# Gateway implementation. It is then the Gateway's business. NOTES.txt says
# so in that mode.
#
# TWO HEADERS MUST SURVIVE THE PROXY, OR OFFICE CONVERSION BREAKS. The image
# sends Cross-Origin-Opener-Policy: same-origin and
# Cross-Origin-Embedder-Policy: credentialless. Together they make the page
@@ -212,6 +229,48 @@ ingress:
# add-headers ConfigMap is the alternative.
annotations: {}
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
# an existing release is unaffected; exactly one of ingress.enabled and
# httpRoute.enabled may be true.
#
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
# gatewayClassName, no Certificate and no implementation-specific policy
# object. Class and certificate belong to the Gateway and therefore to the
# platform, which is what keeps this chart independent of whether the cluster
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
#
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
# cannot detect them (.Capabilities.APIVersions is unreliable at template
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
#
# WHAT THIS MODE COSTS HERE: the whole ingress.annotations escape hatch above,
# including the X-Robots-Tag header - a public instance in Gateway mode gets
# indexed unless the Gateway adds the header. The two cross-origin headers are
# a different case: the image sends them itself, so they still arrive in this
# mode unless something in the path strips them (NOTES.txt has the curl).
httpRoute:
enabled: false
# The Gateway(s) this route attaches to - a platform object, usually in
# another namespace, which must allow this namespace in its listener's
# allowedRoutes. One entry per parent, the fields are the Gateway API's
# ParentReference (name, and optionally namespace, sectionName, port, group,
# kind), passed through unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
hostnames: []
# WHAT THIS CHART CANNOT DO
#
# Almost everything else about BentoPDF is a BUILD-TIME argument and therefore
+1 -1
View File
@@ -21,7 +21,7 @@ type: application
# duplicated here. The short version: eight tag shapes, six of them things
# nobody should pin, and plain `docker` versioning excludes all six - so that
# block deliberately carries NO versioningTemplate.
version: 1.0.0+up1.12.0
version: 1.1.0+up1.12.0
appVersion: "1.12.0"
maintainers:
+87 -10
View File
@@ -1,4 +1,16 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $replicas := int (include "bulwark.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $settings.replicas)) -}}
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
@@ -11,17 +23,75 @@ The messages and the accounts belong to the Stalwart server at
If that server is away, this release is still Ready and still green - see
"WHAT THE PROBES DO NOT TELL YOU" below.
ENDPOINTS
{{ if .Values.ingress.enabled }} Web: {{ $url }}
{{ else }} Web: no Ingress (ingress.enabled: false) - in-cluster only
ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Web: {{ $url }}
{{ else }} Web: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
both false) - in-cluster only
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.port | int64 }}
{{ if .Values.ingress.enabled }} Admin: {{ $url }}/admin/login
{{ else }} Admin: /admin/login on the in-cluster address above. With no Ingress, reach it with
{{ if or .Values.ingress.enabled $httpRoute.enabled }} Admin: {{ $url }}/admin/login
{{ else }} Admin: /admin/login on the in-cluster address above. With nothing published, reach it with
kubectl -n {{ .Release.Namespace }} port-forward statefulset/{{ $sfs }} 3000:{{ $settings.port | int64 }}
and open http://localhost:3000/admin/login
{{ end }}{{ if .Values.ingress.enabled }}
{{ end }}{{ if or .Values.ingress.enabled $httpRoute.enabled }}
Bulwark is served at the ROOT of that host and cannot be moved under a path:
NEXT_PUBLIC_BASE_PATH is compiled into the image.
NEXT_PUBLIC_BASE_PATH is compiled into the image.{{ if $httpRoute.enabled }} That is also why the
HTTPRoute carries no URLRewrite filter - there is no prefix to strip.{{ end }}
{{ end }}{{ if $httpRoute.enabled }}
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
This release renders ONE HTTPRoute and NOTHING else of the routing path - no
Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
Three things therefore belong to the cluster, and if any is missing the
hostname above simply does not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present - nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions - "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
for this release.
THE UPLOAD LIMIT IS NOT ENFORCED BY ANYTHING THIS CHART RENDERS.
In Ingress mode an operator sets nginx.ingress.kubernetes.io/proxy-body-size
through ingress.annotations - this installation runs with 25m - because
nginx's default of 1m rejects almost every attachment. IN THIS MODE THAT IS
GONE: the Gateway API has no body-size field, it is implementation policy
rather than routing, and this chart renders no policy object on purpose (it
would bind the chart to one Gateway implementation).
So the limit in force is THE GATEWAY'S, whatever it happens to be, and no
value of this chart has any say in it. The failure mode on a webmail client
is worth picturing before it happens: a user has written the mail, attached
the file and pressed send, and the 413 comes back FROM THE PROXY. It reads
like Bulwark being broken, nothing mentions a limit, and the draft is the
thing at risk.
Set the equivalent next to the Gateway, in your implementation's own object,
BEFORE you point DNS at it - and size it to what the MAIL SERVER accepts
(the Stalwart release above), not to what looks generous here. A webmail
client that accepts a 40 MB attachment the mail server will refuse has only
moved the failure later.
ingress.annotations is inert in this mode in general: the Gateway API has no
annotation channel for controller behaviour.
{{ end }}
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
The operator materializes one Secret key per item FIELD. A missing or misnamed
@@ -244,9 +314,16 @@ WHAT THIS CHART DOES NOT RENDER
- No PodDisruptionBudget: with one replica a minAvailable of 1 would forbid
every voluntary eviction and make `kubectl drain` hang forever on a pod
nothing may move (README, guideline 11).
- No sub-path Ingress, and no value that could add one - the base path is
compiled into the image.
- No sub-path Ingress and no sub-path route, and no value that could add
one - the base path is compiled into the image. Hence also no URLRewrite
filter in Gateway mode.
- No proxy-body-size annotation. Attachment uploads go through the Ingress and
nginx's default of 1m rejects larger ones with a 413 that reads like an
application fault; the right number is the mail server's limit, which this
chart does not know. Set it in ingress.annotations.
chart does not know. Set it in ingress.annotations{{ if $httpRoute.enabled }} - except that in GATEWAY
MODE there is no annotation to set and no counterpart at all, which is the
section above{{ end }}.
- No Gateway, no GatewayClass and no listener, in either mode. Those are
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.
+26 -9
View File
@@ -447,17 +447,34 @@ for the first time during a real `helm install`. A guard that only runs in
production is worse than no guard.
The honest position for THIS chart, checked in both directions as guideline 12.1
asks: nothing outside the Ingress template FUNCTIONALLY reads ingress.domain.
Bulwark derives its own public URL from the request origin and takes no base-URL
variable, so with ingress.enabled: false the release is complete without a
domain - the calibre-web-automated case, where the gate frees both values. The
enforcement therefore sits entirely in bulwark.ingress.yaml, where it is reached
by every render that has the Ingress on (the default), and this helper renders a
visible placeholder instead of failing.
asks, AND CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): it used to say
that nothing outside the Ingress template FUNCTIONALLY reads ingress.domain.
That was true and is no longer - bulwark.httproute.yaml reads it as the route's
hostname, with a required() of its own. What still holds is the half that made
the claim worth making: Bulwark derives its own public URL from the request
origin and takes no base-URL variable, so with BOTH modes off the release is
complete without a domain - the calibre-web-automated case, where the gate
frees both values. The enforcement sits in whichever routing template is
active; this helper still renders a visible placeholder instead of failing,
because `helm template` does not render NOTES.txt.
IN GATEWAY MODE THE PUBLIC URL IS THE ROUTE'S HOSTNAME, not ingress.domain, and
the two differ exactly when httpRoute.hostnames is set explicitly - which is
how a migration off the Ingress runs. Printing ingress.domain there would hand
the operator an OIDC redirect URI for the host being migrated AWAY from, and
that failure surfaces as a rejected login at the identity provider rather than
as anything about this chart. So the helper follows the active mode.
*/}}
{{- define "bulwark.publicUrl" -}}
{{- if .Values.ingress.domain -}}
https://{{ .Values.ingress.domain }}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $host := .Values.ingress.domain -}}
{{- if $httpRoute.enabled -}}
{{- $host = (first ($httpRoute.hostnames | default list)) | default .Values.ingress.domain -}}
{{- end -}}
{{- if $host -}}
https://{{ $host }}
{{- else if $httpRoute.enabled -}}
https://<neither httpRoute.hostnames nor ingress.domain SET>
{{- else -}}
https://<ingress.domain NOT SET>
{{- end -}}
+115
View File
@@ -0,0 +1,115 @@
{{/*
HTTPRoute - the Gateway-API alternative to bulwark.ingress.yaml (Gitea #114).
ONE ROUTE, ONE RULE, AT THE ROOT - and the reason is the same one that makes
the Ingress rule permanently "/": NEXT_PUBLIC_BASE_PATH is compiled into
Bulwark's bundle, so the application cannot be served under a sub-path at all.
Which is also why THIS CHART RENDERS NO URLRewrite FILTER and needs none. That
differs from what #114's measured table and the header of
ci/run-httproute-match-test.sh both expected of this chart, so it is worth
writing down where it will be read:
- This chart has ONE Ingress object, not two (keycloak has two).
- This chart has NO rewrite-target annotation and CANNOT have one. Every
occurrence of the string "rewrite-target" in this chart - values.yaml, the
schema, the Ingress template - is an explanation of why it does not help:
the bundle's own asset and API URLs are built for the root, so stripping a
prefix at the proxy yields a page that loads and then fetches everything
from the wrong place. A `git grep rewrite-target bulwark/` finds those
refutations and counts them as occurrences, which is how the expectation
arose.
- Consequently the #118 prefix/redirect assurance in
ci/run-httproute-match-test.sh does not apply here: with no URLRewrite on a
prefix other than "/", its precondition is never met. The bare-prefix
redirect that freesailarr needs has nothing to redirect here.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object - the subset that looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio (decision 1
of #114). Class and certificate belong to the PLATFORM, on the Gateway and its
listeners, in cluster-config.
WHAT THIS MODE DROPS FOR THIS CHART, MEASURED ON THE LIVE OBJECT
On 2026-10-12 the live Ingress (jk-cluster, namespace bulwark,
webmail.bekemeyer-klein.de) carried
nginx.ingress.kubernetes.io/proxy-body-size: 25m
The CHART does not set it - values.yaml recommends exactly that value and
explains why the chart cannot pick it (the right number is the mail server's,
which this chart does not know), so the 25m is the operator's, supplied through
ingress.annotations. It is therefore invisible to anyone reading this
repository alone, and it is the single annotation this installation depends on.
In this mode it is gone. The Gateway API has no body-size counterpart: it is
implementation policy, not routing, and decision 2 of #114 says such a limit is
DOCUMENTED rather than rebuilt. So the Gateway branch of NOTES.txt names it in
this chart's own terms - an attachment above the Gateway's default is rejected
before Bulwark sees a byte, with a 413 from the proxy that reads like an
application fault, on the one application whose whole purpose is sending files
to other people.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $settings := fromYaml (include "bulwark.settings" .) -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Bulwark it also breaks the login: the OIDC redirect URI registered with the identity provider names ONE origin, and a session that starts on one data path and returns on the other fails on the state cookie. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain) with that second origin registered as a redirect URI too, never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart.
AND THIS READ CHANGES THE CHART'S OWN ACCOUNT OF ITSELF. Before this mode
existed, bulwark.ingress.yaml and the bulwark.publicUrl helper both stated that
nothing outside the Ingress template FUNCTIONALLY reads ingress.domain - true
then, false now, because this template reads it. Both have been corrected
rather than left standing (the same correction garage and calibre-web-automated
needed in groups 1 and 2). The required() below is what makes it true again:
ingress.domain is mandatory in Gateway mode too, unless hostnames are given
explicitly.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix`, and "/" here is
as permanent as it is there - see the header. The port is the Service's
port NUMBER and not its name "http": backendRefs takes a PortNumber, so
unlike the Ingress this reference cannot be made by name. It comes from
the same settings helper the Service renders its port from, so
bulwark.port and this file still cannot drift apart;
ci/run-httproute-match-test.sh holds the pair for the case where they do.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.port | int64 }}
{{- end }}
+14 -4
View File
@@ -14,12 +14,22 @@ Prefix, deliberately and permanently.
GUIDELINE 1, CHECKED IN BOTH DIRECTIONS (README, "Values that must not lie"),
AND FOR THIS CHART THE GATE FREES BOTH VALUES. Checked rather than assumed:
nothing outside this template FUNCTIONALLY reads ingress.domain. Bulwark derives
its own public URL from the request origin and takes no base-URL environment
variable - there is no KC_HOSTNAME equivalent here - so a release with
ingress.enabled: false is complete without a domain. That is the
Bulwark derives its own public URL from the request origin and takes no
base-URL environment variable - there is no KC_HOSTNAME equivalent here - so a
release with no routing object at all is complete without a domain. That is the
calibre-web-automated case rather than the matrix-synapse one.
CORRECTED WITH THE GATEWAY-API SUPPORT (Gitea #114): this paragraph used to say
that nothing outside this template FUNCTIONALLY reads ingress.domain.
bulwark.httproute.yaml now does - as the route's hostname, with a required() of
its own - so the value is mandatory in Gateway mode too unless
httpRoute.hostnames is given explicitly. "The gate frees both values" means
BOTH gates off, not this one alone.
THE TWO MODES ARE MUTUALLY EXCLUSIVE and the HTTPRoute template aborts the
render when ingress.enabled and httpRoute.enabled are both true, naming both
value paths.
The one other READER is NOTES.txt, which prints the access URL and the OIDC
redirect URI. That read is deliberately NOT guarded with a required(): `helm
template` does not render NOTES.txt, so a guard there would fire for the first
+26 -3
View File
@@ -166,27 +166,50 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template (issue #93). Checked in both directions for this chart: nothing outside that template FUNCTIONALLY reads ingress.domain - Bulwark derives its own public URL from the request origin and takes no base-URL variable - so switching the Ingress off frees domain as well as clusterIssuer. The only other reader is NOTES.txt, which renders a visible placeholder rather than carrying a required() that helm template could never reach.",
"description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Checked in both directions for this chart, and CORRECTED with Gitea #114: it used to say that nothing outside that template functionally reads ingress.domain, which the HTTPRoute now does. Switching the INGRESS off frees clusterIssuer; it frees domain only if httpRoute is off as well. What still holds is the reason - Bulwark derives its own public URL from the request origin and takes no base-URL variable - so a release with no routing object at all is complete without a domain. NOTES.txt renders a visible placeholder rather than carrying a required() that helm template could never reach.",
"type": "boolean"
},
"clusterIssuer": {
"type": ["string", "null"]
},
"domain": {
"description": "The public hostname. Bulwark is served at the ROOT of it and cannot be served under a path: NEXT_PUBLIC_BASE_PATH is compiled into the bundle, and a rewrite-target annotation does not help because the bundle's own asset URLs are built for the root.",
"description": "The public hostname. Bulwark is served at the ROOT of it and cannot be served under a path: NEXT_PUBLIC_BASE_PATH is compiled into the bundle, and a rewrite-target annotation does not help because the bundle's own asset URLs are built for the root - which is also why the HTTPRoute renders no URLRewrite filter. Read by the Ingress, by the HTTPRoute (when httpRoute.hostnames is empty) and by NOTES.txt for the OIDC redirect URI.",
"type": ["string", "null"]
},
"className": {
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. The one worth adding for this application is nginx.ingress.kubernetes.io/proxy-body-size: attachment uploads go through this Ingress and nginx's default of 1m rejects anything larger with a 413 that reads like an application fault.",
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. The one worth adding for this application is nginx.ingress.kubernetes.io/proxy-body-size: attachment uploads go through this Ingress and nginx's default of 1m rejects anything larger with a 413 that reads like an application fault - and that is what this installation's live Ingress actually carries (25m, measured 2026-10-12). INERT IN GATEWAY MODE: the Gateway API has no annotation channel and no body-size counterpart at all.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. ONE rule, at the root, with no URLRewrite filter - Bulwark cannot be served under a sub-path, so there is nothing to strip and the #118 prefix/redirect assurance has no precondition to meet here. NOT COVERED IN THIS MODE: proxy-body-size, which this installation's live Ingress carries at 25m and which has no counterpart in the specification - an oversized attachment is then rejected by the Gateway with a 413 that reads like a webmail fault. See values.yaml, httpRoute block. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. Setting it means a second origin, which has to be registered as an OIDC redirect URI with the identity provider before a login can succeed on it.",
"type": "array",
"items": { "type": "string" }
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"port": {
"type": ["integer", "null"],
"minimum": 1,
+81
View File
@@ -711,6 +711,11 @@ ingress:
# away would produce a release that renders cleanly and whose operator is told
# to register `https:///auth/callback`.
#
# AND A THIRD TIME SINCE Gitea #114: the HTTPRoute reads it as its hostname
# when httpRoute.hostnames is empty, with a required() of its own. So this
# value is mandatory in GATEWAY mode as well, and only a release with both
# modes off can do without it.
#
# BULWARK IS SERVED AT THE ROOT OF THIS HOST AND CANNOT BE SERVED UNDER A
# PATH. NEXT_PUBLIC_BASE_PATH is compiled into the bundle (point 4 at the top
# of this file), so a sub-path deployment would need a rebuilt image. The
@@ -734,4 +739,80 @@ ingress:
# (or whatever the mail server accepts) to match stalwart's own limit. The
# chart does not set it, because the right number is the mail server's and
# this chart does not know it.
#
# AND THAT RECOMMENDATION IS NOT HYPOTHETICAL - IT IS WHAT THIS INSTALLATION
# RUNS ON. Measured on 2026-10-12: the live Ingress on
# webmail.bekemeyer-klein.de carries exactly
# nginx.ingress.kubernetes.io/proxy-body-size: 25m, supplied here by the
# operator. In GATEWAY MODE this whole block is INERT - the Gateway API has
# no annotation channel for controller behaviour, and body size has no
# counterpart in the specification at all. Read the httpRoute block below
# before switching modes.
annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY an
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate, and no
# implementation-specific policy object. Class and certificate sit on the
# Gateway and its listeners and belong to the platform (cluster-config), which
# is what keeps the Gateway implementation replaceable without touching this
# chart again.
#
# ONE ROUTE, ONE RULE, AT THE ROOT. "/" is as permanent here as it is on the
# Ingress, for the same reason: NEXT_PUBLIC_BASE_PATH is compiled into the
# bundle, so Bulwark cannot be served under a sub-path and there is nothing for
# a URLRewrite filter to strip. This chart renders no such filter and needs
# none - which also means the #118 prefix/redirect assurance in
# ci/run-httproute-match-test.sh has no precondition to meet here.
#
# WHAT THIS MODE COSTS, AND IT IS THE ONE ANNOTATION THIS INSTALLATION USES.
# proxy-body-size: 25m is set on the live Ingress by the operator (see the
# annotations block above). It is GONE in this mode and cannot be rebuilt here:
# the Gateway API knows no body size, that is implementation policy, and
# decision 2 of #114 says the limit is documented rather than reconstructed
# with a policy object that would bind this chart to one implementation.
#
# What that looks like in practice, for THIS application: an attachment above
# the GATEWAY's default is rejected before Bulwark sees a byte, and the 413
# comes from the proxy. The user is composing mail with a file attached, so the
# failure arrives at the least recoverable moment and reads like a webmail
# fault. Set the equivalent next to the Gateway - in that implementation's own
# object - BEFORE pointing DNS at it, and size it to what the MAIL SERVER
# accepts (stalwart), not to what feels generous. NOTES.txt prints this in
# Gateway mode.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
#
# AND IF YOU SET IT, REGISTER THAT ORIGIN WITH THE IDENTITY PROVIDER FIRST.
# NOTES.txt derives the OIDC redirect URI from the ACTIVE mode's hostname, so
# a second hostname means a second redirect URI - a login that returns to an
# unregistered origin is rejected at the provider, which says nothing about
# this chart.
hostnames: []
+1 -1
View File
@@ -5,7 +5,7 @@ name: calibre-web-automated
description: A Helm chart to deploy calibere-web-automated
icon: https://raw.githubusercontent.com/crocodilestick/Calibre-Web-Automated/main/README_images/cwa-logo-round-dark.png
version: 7.0.1+upv4.0.6
version: 7.1.0+upv4.0.6
appVersion: v4.0.6
maintainers:
+60 -6
View File
@@ -2,11 +2,16 @@
{{- $startup := fromYaml (include "calibre-web-automated.startupProbe" .Values.cwa.startupProbe) -}}
{{- $sfs := printf "%s-%s-sfs" .Release.Name .Chart.Name -}}
{{- $pod := printf "%s-0" $sfs -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
Calibre-Web Automated {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }}
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
@@ -20,12 +25,59 @@ ACCESS
controller before CWA ever sees it, so nothing about it reaches the app log.
Both annotations are understood by the nginx-family controllers only, and this
release uses ingressClassName "{{ .Values.ingress.className }}".
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING
else of the routing path — no Gateway, no class, no Certificate. Three things
therefore belong to the cluster and not to this chart, and if any is missing
the address above does not answer:
1. The Gateway-API CRDs. Without them this install would have failed.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
up only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment. An empty status means no
controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
YOUR UPLOAD LIMIT IS NOT cwa.maxUploadFileSize ANY MORE — IT IS THE GATEWAY'S.
cwa.maxUploadFileSize ({{ .Values.cwa.maxUploadFileSize }}) is NOT ENFORCED in this mode. The chart
renders it into nginx.ingress.kubernetes.io/proxy-body-size, and the Gateway
API has NO counterpart — it knows no body size at all, that is
implementation-specific policy — so this chart deliberately renders no policy
object, which would bind it to one implementation. Whatever limit the Gateway
enforces is the limit your uploads get, and this is an e-book library: a book
above the Gateway's default is rejected before CWA sees a byte, the user gets
the proxy's error instead of a message from the application, and nothing says
a chart value was ignored. Set the limit at the Gateway, or through its
implementation's own policy object next to the Gateway — not here.
ingress.annotations is inert in this mode as well, for the same reason: the
Gateway API has no annotation channel.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate.
Neither ingress.domain nor ingress.clusterIssuer is needed: this chart reads
ingress.domain in the Ingress template and nowhere else. The app is reachable
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release is not
published: no Ingress, no HTTPRoute and no certificate. Neither
ingress.domain nor ingress.clusterIssuer is needed — this chart reads
ingress.domain in the two routing templates and nowhere else, and with both
of them off nothing reads it. The app is reachable
inside the cluster as {{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8083:80
@@ -39,7 +91,9 @@ SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}cwa.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing
answers until you set it back and upgrade again. Both PVCs, the Secret and the
Service{{ if .Values.ingress.enabled }} and Ingress stay in place, so no data is lost and the certificate
is not re-issued.{{ else }} stay in place, so no data is lost.{{ end }}
is not re-issued.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so no data is lost — the route then
resolves to a Service with no endpoints and the Gateway answers its own
error.{{ else }} stay in place, so no data is lost.{{ end }}
{{ end }}
FIRST LOGIN — THE DEFAULT PASSWORD IS PUBLIC
Username "admin", password "admin123". Those are the upstream defaults, they
@@ -0,0 +1,101 @@
{{/*
The Gateway-API alternative to calibre-web-automated.ingress.yaml (Gitea #114).
Form and reasoning: template-chart/templates/xxx.httproute.yaml and README,
"Ingress and Gateway API".
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners.
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
in this mode - and it is not demanded either: its only required() sits inside
calibre-web-automated.ingress.yaml, behind ingress.enabled.
THE UPLOAD LIMIT IS THE ONE THING THIS MODE LOSES, AND IT IS NOT REBUILT
cwa.maxUploadFileSize is rendered into nginx.ingress.kubernetes.io/proxy-body-size
and nginx.org/client-max-body-size by the Ingress template. The Gateway API has
NO counterpart: it knows no body size at all, because a request-body limit is
implementation-specific policy and lives in an implementation's own object
(ClientSettingsPolicy on nginx-gateway-fabric, ClientTrafficPolicy on Istio,
and so on). Rendering one here would bind this chart to one implementation and
to a CRD whose presence it cannot check, so the limit is DOCUMENTED instead -
NOTES.txt says in this mode that the value is not enforced and names where to
set it. The price is named and deliberate: whoever overlooks that output gets
the Gateway's default limit, and an oversized book fails as a proxy error
rather than as a message from CWA. This chart is an e-book library, so the
value is not decoration - a book above the Gateway's default is rejected, and
the rejection carries no hint that a chart value was ignored.
ingress.domain, AND WHY THIS FILE CHANGES WHAT CAN BE CLAIMED ABOUT IT
Checked for this chart rather than copied: ingress.domain is read in the
Ingress template and - until this file existed - NOWHERE else. No environment
variable, no probe Host header, no URL the app builds itself; CWA is told its
address by nothing. That is why values.yaml could say that switching the
Ingress off makes domain unnecessary along with clusterIssuer.
This file is now a second reader (when httpRoute.hostnames is empty), so that
sentence holds only while BOTH modes are off. It is corrected in values.yaml
and in values.schema.json rather than left standing. In Gateway mode with
hostnames empty, domain is required again - by the required() below.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and all three defaults
are empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
even though no Ingress is rendered in this mode - the value names the public
hostname of this release, which both modes need; it is not an Ingress switch.
Nothing inside the pod follows it, so a hostname set explicitly here needs no
second value changed with it - which is what makes a migration over a second
hostname cheap in THIS chart and expensive in others.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
# backend Service - CWA serves its whole UI, including the OPDS feed and
# the ingest views, from one process on one port, so a single path covers
# it. The port is the Service's port NUMBER and not its name ("http"):
# backendRefs takes a PortNumber, so unlike the Ingress this reference
# cannot be made by name and the 80 below has to track
# calibre-web-automated.service.yaml by hand.
# ci/run-httproute-match-test.sh is what holds that pair - it fails when a
# backendRef names a Service, or a port, the same render does not create.
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- end }}
+32 -3
View File
@@ -42,7 +42,7 @@
"type": ["string", "null"]
},
"maxUploadFileSize": {
"description": "Rendered into the ingress-nginx proxy-body-size annotation, so it only takes effect while the Ingress is enabled.",
"description": "Rendered into the ingress-nginx proxy-body-size and nginx.org/client-max-body-size annotations, so it is enforced by the ingress controller and by nothing inside CWA. It does nothing with ingress.enabled: false, and it is NOT ENFORCED in Gateway mode either (Gitea #114): the Gateway API has no body-size field, the effective limit is the Gateway's, and this chart deliberately renders no implementation-specific policy object to set it. NOTES.txt says so in that mode.",
"type": ["string", "null"]
},
"livenessProbe": {
@@ -70,7 +70,7 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template (issue #93). ingress.domain is read nowhere else in this chart, so switching the Ingress off makes both domain and clusterIssuer unnecessary.",
"description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Nothing inside the pod reads ingress.domain, so switching the Ingress off makes both domain and clusterIssuer unnecessary - but only while the Gateway mode is off as well: the HTTPRoute is a second reader of domain while httpRoute.hostnames is empty.",
"type": "boolean"
},
"clusterIssuer": {
@@ -83,13 +83,42 @@
"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.",
"description": "Extra Ingress annotations, merged with the cert-manager and proxy-body-size annotations the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead - including the upload cap the chart itself sets from cwa.maxUploadFileSize.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them. cwa.maxUploadFileSize is not enforced in this mode.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": {
"$ref": "#/definitions/parentRef"
}
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. Nothing inside the pod follows the hostname in this chart, so a name set here needs no second value changed with it.",
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"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"],
+75 -3
View File
@@ -23,6 +23,15 @@ cwa:
storage: 10Gi
storageClass: longhorn
timezone: "UTC"
# The upload cap, and it lives entirely in the INGRESS: the chart renders it
# into nginx.ingress.kubernetes.io/proxy-body-size and
# nginx.org/client-max-body-size, so it is enforced by the nginx-family
# controllers and by nothing inside CWA. Consequences worth knowing before
# trusting it: with ingress.enabled: false it does nothing at all, and in
# Gateway mode (httpRoute.enabled) it is NOT ENFORCED either - the Gateway
# API has no body-size field, the limit is the Gateway's own, and this chart
# deliberately renders no implementation-specific policy object to set it
# (Gitea #114). NOTES.txt says so in both modes.
maxUploadFileSize: "10m"
livenessProbe:
httpGet:
@@ -107,13 +116,76 @@ cwa:
env: []
ingress:
# Set to false to omit the Ingress entirely. Unlike some other charts here,
# ingress.domain is read nowhere but in the Ingress template, so switching
# the Ingress off makes both domain and clusterIssuer unnecessary.
# Set to false to omit the Ingress entirely. Nothing inside the pod reads
# ingress.domain - no environment variable, no probe Host header, no URL the
# app builds itself - so with the Ingress off, clusterIssuer and domain are
# both unnecessary. That is still true, but no longer the whole sentence:
# the HTTPRoute below is a SECOND reader of ingress.domain (while
# httpRoute.hostnames is empty), so "domain is not needed" holds only while
# BOTH routing modes are off.
#
# Mutually exclusive with httpRoute.enabled below (Gitea #114): with both
# true the render aborts, naming both values.
enabled: true
clusterIssuer: null
# The public hostname of this release. Read by the Ingress and - when
# httpRoute.hostnames is empty - by the HTTPRoute, and by nothing else.
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.
#
# THESE HAVE NO COUNTERPART IN GATEWAY MODE. The Gateway API has no
# annotation channel for controller behaviour, so anything configured here is
# simply gone when httpRoute.enabled is used instead - it is then the
# Gateway's business, or that of an implementation-specific policy object
# this chart deliberately does not render. For THIS chart that includes the
# upload cap: see cwa.maxUploadFileSize above.
annotations: {}
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
# an existing release is unaffected; exactly one of ingress.enabled and
# httpRoute.enabled may be true.
#
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
# gatewayClassName, no Certificate and no implementation-specific policy
# object. Class and certificate belong to the Gateway and therefore to the
# platform, which is what keeps this chart independent of whether the cluster
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
#
# WHAT THIS MODE COSTS HERE: cwa.maxUploadFileSize is not enforced. It exists
# only as an ingress-nginx annotation and the Gateway API has no body-size
# field, so the limit becomes whatever the Gateway enforces. For an e-book
# library that is the one value worth checking before switching - a book above
# the Gateway's default is rejected by the proxy, and nothing in CWA's log says
# a chart value was ignored. NOTES.txt prints this in Gateway mode.
#
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
# cannot detect them (.Capabilities.APIVersions is unreliable at template
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
httpRoute:
enabled: false
# The Gateway(s) this route attaches to - a platform object, usually in
# another namespace, which must allow this namespace in its listener's
# allowedRoutes. One entry per parent, the fields are the Gateway API's
# ParentReference (name, and optionally namespace, sectionName, port, group,
# kind), passed through unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
#
# Cheap in this chart, because nothing inside the pod follows the hostname:
# CWA is told its address by nothing, so a second name needs no second value
# changed with it.
hostnames: []
+38 -4
View File
@@ -1,6 +1,40 @@
# cyberchef: stateless, no secrets. Only the required ingress values are set.
# The Ingress just has to apply - there is no ingress-nginx/cert-manager in
# the CI cluster.
# cyberchef: stateless, no secrets.
#
# THIS CHART IS INSTALLED IN GATEWAY MODE, NOT IN INGRESS MODE (Gitea #114),
# and that is a deliberate trade, because the install-test runs exactly ONE
# configuration per chart and the two modes are mutually exclusive.
#
# What is given up: the Ingress of this one chart is no longer installed
# anywhere. That costs little. An Ingress is a built-in, GA type whose schema
# the API server has validated for a decade, k3d's own ingress controller is
# traefik while this chart asks for the class "nginx", so nothing ever routed
# through it here either - the install only ever proved "the API server
# accepted an Ingress". And it keeps being proved, twenty times over, by every
# other chart in the repo plus `helm lint --strict`, which renders this chart's
# DEFAULTS, i.e. Ingress mode, on every pull request.
#
# What is gained: a real HTTPRoute, applied against the real Gateway-API CRDs
# (see ci/gateway-api-crds.sh), which is a CRD whose schema nothing else in
# this repository has ever exercised. That is where a malformed parentRefs or
# backendRefs is actually caught.
#
# Still NOT proved by this, and it cannot be: that traffic flows. No Gateway
# controller runs in k3d, so nothing routes. The install-test proves the object
# is schema-valid and the pod becomes Ready; ci/run-httproute-match-test.sh
# proves the route points at a Service the same render creates.
ingress:
# The hostname is read by the HTTPRoute too (httpRoute.hostnames is empty, so
# it falls back to ingress.domain) - it is the release's public name, not an
# Ingress switch.
domain: cyberchef.ci.local
clusterIssuer: ci
enabled: false
httpRoute:
enabled: true
# The Gateway does not exist in the CI cluster, and it does not need to: a
# parentRef is resolved by the Gateway controller, of which there is none
# here, so the reference is stored as written. What the real CRDs DO validate
# is its shape.
parentRefs:
- name: ci-gateway
namespace: default
sectionName: https
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Install the REAL Gateway-API CRDs (standard channel) into the cluster of the
# current kubectl context, for the install-test of a chart that renders an
# HTTPRoute (Gitea #114).
#
# Usage: ci/gateway-api-crds.sh (called by ci/run-install-test.sh)
#
# WHY NOT A STUB CRD, WHICH IS WHAT ci/common/ DOES FOR OnePasswordItem
#
# Because a stub would make the install-test prove nothing. ci/common's
# OnePasswordItem CRD is schema-less on purpose (x-kubernetes-preserve-unknown-
# fields): nobody is testing the 1Password operator's API, only that the chart's
# CR applies. For an HTTPRoute the situation is the opposite - the SCHEMA is the
# entire value of the exercise. With a schema-less stub the API server accepts a
# malformed parentRefs, a backendRef with a port written as a string, a
# path type that does not exist; the install goes green and the only thing
# demonstrated is "something was created". With the real CRDs the same apply is
# validated field by field, and a malformed route fails the install-test loudly.
#
# This is the half that CANNOT be moved into a render test: a render cannot know
# the Gateway API's schema. And the half the CRDs cannot cover is held by
# ci/run-httproute-match-test.sh - schema validation accepts an empty
# parentRefs list and a backendRef naming a Service that does not exist.
#
# What neither proves, and nothing here can: that traffic flows. No Gateway
# controller runs in k3d, so no route is ever reconciled and no status is ever
# written. Provable: the object is schema-valid and the pod becomes Ready.
#
# WHY --server-side, AND THE MEASUREMENT BEHIND IT
#
# Client-side `kubectl apply` stores the whole applied object in the
# kubectl.kubernetes.io/last-applied-configuration annotation, and an object's
# annotations may total at most 262144 bytes. The HTTPRoute CRD of the standard
# channel is 429367 bytes of YAML, which comes to 244792 bytes as JSON
# (measured on v1.6.3, re-read through ci/yaml2json) - i.e. 93% of the limit,
# with every release adding fields. So client-side apply happens to fit today
# and is one upstream release away from failing with "metadata.annotations: Too
# long", on a Renovate PR that has nothing to do with it. Server-side apply
# writes no such annotation and has no such limit.
#
# This is also why the CRDs are NOT in ci/common/, which ci/run-install-test.sh
# applies client-side for EVERY chart: putting them there would hand that
# landmine to all 20-odd charts and spend the download on charts that render no
# route at all.
#
# WHY DOWNLOADED AND NOT VENDORED
#
# 1.1 MB of generated YAML in the repository would go stale invisibly and would
# bury every diff that touches ci/. Downloading a PINNED artifact is what this
# repo already does for every other piece of CI tooling - Trivy's tarball, k3d's
# install script, kubectl - each with a `# renovate:` comment keeping the pin
# current. The cost is named rather than hidden: one more network fetch, and if
# the release asset ever disappears this goes red on a pull request that changed
# nothing related. It fails loudly, which is the acceptable half.
set -euo pipefail
# The pinned standard channel. Renovate keeps this current through the
# customManager for ci/*.sh in renovate.json5; prereleases are excluded there,
# so the -rc.N tags upstream publishes are never offered.
# renovate: datasource=github-releases depName=kubernetes-sigs/gateway-api
GATEWAY_API_VERSION=v1.6.3
# The standard channel, as published - the whole set and not a hand-picked
# subset. The charts render only HTTPRoute today, so HTTPRoute's CRD is the only
# one strictly needed; taking the published set instead means the sweep of #114
# needs no change here, and a fixture that wants a real Gateway object later
# finds its CRD already installed.
URL="https://github.com/kubernetes-sigs/gateway-api/releases/download/${GATEWAY_API_VERSION}/standard-install.yaml"
MANIFEST="$(mktemp)"
trap 'rm -f "$MANIFEST"' EXIT
echo "==> Gateway API ${GATEWAY_API_VERSION} (standard channel): $URL"
curl -sSfL -o "$MANIFEST" "$URL"
# A truncated or redirected download would otherwise be applied as whatever it
# happens to contain. Asserting the one object the charts actually need is
# cheaper than a checksum nobody can regenerate, and it names what is wrong.
if ! grep -q '^ name: httproutes.gateway.networking.k8s.io$' "$MANIFEST"; then
echo "ERROR: the downloaded manifest does not contain the httproutes CRD." >&2
echo "That is not a chart failure: either the release asset changed shape or the" >&2
echo "download was truncated. Got $(wc -c <"$MANIFEST") bytes from $URL" >&2
exit 1
fi
kubectl apply --server-side -f "$MANIFEST"
# Established, not just created. `kubectl apply` returns as soon as the CRD
# object exists, while the API server needs a moment to serve the new endpoint -
# and a `helm install` in that window fails with "no matches for kind
# HTTPRoute", which reads exactly like a missing CRD rather than like a race.
# Same lesson as the Ready-node loop in install-test.yml: wait for the
# condition that means usable, not for the one that means present.
for CRD in $(kubectl get crd -o name | grep '\.gateway\.networking\.k8s\.io$'); do
kubectl wait --for=condition=Established "$CRD" --timeout=60s
done
kubectl get crd -o name | grep '\.gateway\.networking\.k8s\.io$' | sed 's/^/ /'
echo "==> Gateway API CRDs established."
+31
View File
@@ -0,0 +1,31 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# WHY AN OVERLAY AND NOT A CHANGE TO ci/bentopdf/test-values.yaml, which is
# what the pilot did for cyberchef: the install-test runs exactly ONE
# configuration per chart, and the two modes are mutually exclusive. cyberchef
# already pays that price and gives the install-test its HTTPRoute against the
# real Gateway-API CRDs. Spending it a second time here would drop this chart's
# Ingress from the install-test for a schema this repository now exercises
# anyway. So the install keeps testing the Ingress; the route is held at render
# level, which is where its two silent failure modes live.
#
# ingress.enabled: false is not optional here. The two modes are mutually
# exclusive and the chart aborts its own render when both are true, so an
# overlay that only switched httpRoute on would turn this test red.
#
# ingress.domain comes from ci/bentopdf/test-values.yaml, which this overlay
# sits on top of - httpRoute.hostnames is left empty on purpose, so the
# fallback to ingress.domain is what the test exercises.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+54
View File
@@ -0,0 +1,54 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/bulwark/test-values.yaml: the install-test
# runs exactly ONE configuration per chart, and this chart's fixture installs
# the StatefulSet with its PVC and both 1Password items. Trading its Ingress
# for a CRD schema check that cyberchef's fixture already performs against the
# real Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the
# route is held here, at render level, which is where its silent failure modes
# live.
#
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
# routing switch and defaults to TRUE, so without this line the chart aborts
# its own render with both modes on.
#
# ONE ROUTING OBJECT, WHICH IS ALL THIS CHART HAS. #114's measured table and
# the header of ci/run-httproute-match-test.sh both expected two Ingresses and
# a rewrite-target here; neither is the case, and the reason is in
# bulwark.httproute.yaml. In short: Bulwark cannot be served under a sub-path
# (NEXT_PUBLIC_BASE_PATH is compiled into the bundle), so there is one object,
# one rule, at "/", and no URLRewrite filter for the #118 prefix/redirect
# assurance to pair up with. Every "rewrite-target" string in this chart is an
# explanation of why it does not work, and a grep counted those as
# occurrences.
#
# WHAT THIS OVERLAY COVERS:
#
# - ingress.domain comes from the fixture, so the route renders with its
# hostname from that single source of truth - httpRoute.hostnames stays
# empty on purpose, because the fallback is the path every installation
# will actually use.
# - the route's backendRef is checked against the serving Service of the same
# render, by PORT NUMBER (bulwark.port) rather than by the name "http" the
# Ingress uses, because backendRefs takes a PortNumber. This overlay is
# what holds that number against the Service's.
#
# NOT covered here, and it cannot be: that the hostname answers. No Gateway
# controller runs in CI and no route reaches "Accepted: True". Nor the thing
# this chart actually loses in this mode - the operator's
# proxy-body-size: 25m, which has no counterpart in the specification. That
# loss is documented in values.yaml and printed by NOTES.txt, and documentation
# is not something a test can assert.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+28
View File
@@ -0,0 +1,28 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/calibre-web-automated/test-values.yaml: the
# install-test runs exactly ONE configuration per chart and the two modes are
# mutually exclusive, so switching the fixture would drop this chart's Ingress
# from the install for a CRD schema cyberchef's fixture already exercises
# against the real Gateway-API CRDs. The install keeps the Ingress; the route is
# held here, at render level, which is where its two silent failure modes live.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/calibre-web-automated/test-values.yaml;
# httpRoute.hostnames is left empty on purpose, so the fallback to
# ingress.domain is what the test exercises and the two cannot disagree here.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+55
View File
@@ -0,0 +1,55 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/freesailarr/test-values.yaml: the
# install-test runs exactly ONE configuration per chart, and this chart's
# fixture is the expensive one - it brings up a real Wireguard tunnel so that
# ten containers can become Ready. Switching it into Gateway mode would trade
# its Ingress objects for a CRD schema cyberchef's fixture already exercises
# against the real Gateway-API CRDs. The install keeps the Ingresses; the
# routes are held here, at render level, which is where their two silent
# failure modes live.
#
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
# MASTER switch and defaults to true, so without this line the chart aborts its
# own render with both modes on.
#
# WHAT THIS OVERLAY ACTUALLY COVERS, which is more than one route:
#
# - ingress.host comes from the fixture (freesailarr.example.com), so the
# SHARED-domain shape is what renders: one HTTPRoute with one rule per app.
# - qbittorrent is enabled in the fixture, so its rule is in that route WITH
# the URLRewrite filter - the per-rule equivalent of the rewrite-target
# annotation, and the reason this chart needs no second routing object in
# this mode.
# - mediathekarr is enabled in the fixture with its ingress off, so the
# per-app selector is exercised in the negative direction too.
# - every rule's backendRef is checked against the per-app Services of the
# same render, which is where this chart differs from the rest of the
# sweep: it has one Service per app and the route carries the port NUMBER,
# not the name the Ingress uses.
#
# There is no httpRoute.hostnames key in this chart (see values.yaml): the
# hostnames come from ingress.host and <app>.ingress.host, which the fixture
# already sets.
#
# A SECOND APP IS GIVEN A HOST OF ITS OWN so the MIXED shape is covered as
# well: bazarr then renders its own route (ci-freesailarr-bazarr-httproute)
# while the rest stay on the shared host. Without it the per-app branch of the
# template would never be entered by any test. Bazarr is the right one to move
# because its sub-path mode needs a manual step inside the application anyway.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
bazarr:
ingress:
host: bazarr.ci.local
+35
View File
@@ -0,0 +1,35 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# THIS CHART IS THE ONE WHERE THE OVERLAY EARNS ITS KEEP TWICE OVER, and the
# reason is in ci/garage/test-values.yaml: that fixture keeps the Ingress ON
# deliberately, so the install-test proves the Service port it names BY NAME
# ("s3") exists. An HTTPRoute cannot make that reference - backendRefs takes a
# PortNumber - so garage is the one chart in the sweep whose route renders a
# port NUMBER out of a VALUE (garage.ports.s3), not a literal. Switching the
# install fixture into Gateway mode would have traded a check that catches a
# renamed port for one that does not, and would have given up garage's only
# Ingress coverage at the same time. Here the number is held instead by this
# test, which fails when a backendRef names a port the same render's Service
# does not expose - exactly the drift the value introduces.
#
# ingress.enabled: false is not optional here. The two modes are mutually
# exclusive and the chart aborts its own render when both are true, and this
# chart's fixture has the Ingress on, so the overlay must switch it off.
#
# ingress.domain comes from ci/garage/test-values.yaml. Note that garage ships
# ingress.enabled: false AND httpRoute.enabled: false, so without this overlay
# the chart renders no route at all - which is correct and is why the overlay
# exists rather than a changed default.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+79
View File
@@ -0,0 +1,79 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/homeassistant/test-values.yaml, and here
# that is more than the usual reason. This chart's ingress.enabled DEFAULT IS
# false and the install-test fixture does not switch it on, so the install
# exercises no routing object at all - as with garage. There is nothing to
# trade away: this overlay ADDS coverage that the install-test never had,
# rather than swapping one object for another.
#
# ingress.enabled: false is restated anyway. It is already the default, but the
# two modes are mutually exclusive and the chart aborts its own render when
# both are true, so writing it down here keeps this overlay correct if that
# default ever changes.
#
# AND THIS OVERLAY HAS TO SUPPLY HOSTS, which no other overlay in the sweep
# does. This chart is the DOCUMENTED INGRESS EXCEPTION: it has no
# ingress.domain, so there is no httpRoute.hostnames either - the hostnames and
# the paths come from ingress.hosts, and the chart renders ONE HTTPRoute PER
# ENTRY there. The chart's default entry is the placeholder
# "chart-example.local" with pathType ImplementationSpecific, which WOULD
# render (the chart maps ImplementationSpecific to PathPrefix on "/"), but it
# would exercise exactly one route with one rule and would leave the
# interesting half of this chart's flexible style untested.
#
# So the list below is deliberate, and each entry earns its place:
#
# 1. ha.ci.local with "/" and pathType Prefix - the ordinary case, and the
# clean Prefix -> PathPrefix mapping.
# 2. ha-alt.ci.local with TWO paths, one Prefix and one Exact - this is the
# part that cannot be expressed in a single HTTPRoute together with entry
# 1, because an HTTPRoute's rules apply to EVERY hostname of the object.
# It is therefore the test that the chart really renders one object per
# host entry rather than one object with several hostnames, and it
# exercises the Exact mapping and the multi-rule path at the same time.
#
# Both entries' rules are checked against the Service of the same render. The
# reference is by PORT NUMBER (service.port) rather than by the name "http" the
# Ingress uses, because backendRefs takes a PortNumber - so this overlay is
# what holds that number against the Service's.
#
# NOT covered here, and it cannot be:
#
# - that either hostname answers. No Gateway controller runs in CI and no
# route reaches "Accepted: True".
# - the pathType ImplementationSpecific ABORT on a non-root path. That is a
# render-failure path, and this test only runs renders it expects to
# succeed; it was exercised by hand instead (see the PR).
# - that Home Assistant accepts the Host header or reads X-Forwarded-For.
# Both live in the application's own configuration, which arrives from
# 1Password, and changing the data path changes the proxy address the
# trusted_proxies list has to name. NOTES.txt says so; nothing can assert
# it here.
#
# No URLRewrite filter is rendered by this chart in either mode, so the #118
# prefix/redirect assurance has no precondition to meet here.
ingress:
enabled: false
hosts:
- host: ha.ci.local
paths:
- path: /
pathType: Prefix
- host: ha-alt.ci.local
paths:
- path: /
pathType: Prefix
- path: /api/websocket
pathType: Exact
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+70
View File
@@ -0,0 +1,70 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/keycloak/test-values.yaml: the install-test
# runs exactly ONE configuration per chart, and this chart's fixture is the
# expensive and informative one - two replicas against a throwaway PostgreSQL,
# two init containers, `kc.sh build` under readOnlyRootFilesystem, and the
# Infinispan cluster forming over jdbc-ping (see that file for the four things
# only a real install can prove). Trading all of it for a CRD schema check that
# cyberchef's fixture already performs against the real Gateway-API CRDs would
# be a bad swap. The install keeps the two Ingresses; the two routes are held
# here, at render level, which is where their silent failure modes live.
#
# ingress.enabled: false is not optional here. The two modes are mutually
# exclusive and the chart aborts its own render when both are true, so an
# overlay that only switched httpRoute on would turn this test red.
#
# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS:
#
# - ingress.domain comes from the fixture (keycloak.ci.example.com), so the
# PUBLIC route renders with its hostname from that single source of truth
# (httpRoute.hostnames stays empty on purpose - the fallback is the path
# that every installation will actually use).
# - ingress.admin.enabled is true in the fixture with
# ingress.admin.domain set, so the ADMIN route renders too, and its
# parentRefs fall back to httpRoute.parentRefs below - the empty-admin
# branch, which is the one an installation hits unless it wants an
# internal Gateway.
# - every route's backendRef is checked against the serving Service of the
# same render. They all point at the SAME Service, by PORT NUMBER
# (keycloak.ports.http) rather than by the name "http" the Ingress uses,
# because backendRefs takes a PortNumber - so this overlay is what holds
# that number against the Service's.
# - THE TWO REALM ROUTES (Gitea #122), which come from keycloak.realmHosts in
# the fixture rather than from this file. Nothing is repeated here on
# purpose: the fixture installs them as Ingress objects and this overlay
# turns the same two entries into HTTPRoutes, so one definition covers both
# modes and the two halves cannot drift into describing different hostnames.
# What the routes add to the two above is a SECOND RULE SHAPE - an Exact
# match on / carrying a RequestRedirect filter and no backendRefs at all.
# That rule is reported as "not checked" by this test, correctly and by
# design: a filter-only rule has no backend to resolve. The assertion it
# does earn is on its sibling rule, the PathPrefix one that points at the
# Service. The prefix/redirect pairing check of #118 does NOT fire for these
# routes and must not: it is scoped to rules that STRIP a prefix with a
# ReplacePrefixMatch rewrite, and these strip nothing - they redirect the
# bare root of a hostname that serves one realm.
# The entries also cover the two Ingress-mode forms (one with a
# clusterIssuer, one without) and what this test holds about them is their
# INERTNESS: neither tlsSecretName nor clusterIssuer nor the per-entry
# annotation may appear anywhere in a rendered route.
#
# NOT covered here, and it cannot be: that either hostname answers. No Gateway
# controller runs in CI, no route reaches "Accepted: True", and the
# proxy-buffer-size/proxy-buffers-number annotations this chart's live
# installation relies on have no counterpart in this mode at all - that loss is
# documented in values.yaml and printed by NOTES.txt, and documentation is not
# something a test can assert.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+59
View File
@@ -0,0 +1,59 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/matrix-synapse/test-values.yaml: the
# install-test runs exactly ONE configuration per chart, and this chart's
# fixture installs the StatefulSet with its PVC and the delegation Deployment.
# Trading its Ingress for a CRD schema check that cyberchef's fixture already
# performs against the real Gateway-API CRDs would be a bad swap. The install
# keeps the Ingress; the routes are held here, at render level, which is where
# their silent failure modes live.
#
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
# routing switch and defaults to TRUE, so without this line the chart aborts
# its own render with both modes on.
#
# WHAT THIS OVERLAY COVERS, WHICH IS BOTH ROUTING OBJECTS - and this chart is
# the one in group 3 where "both" means two different hostnames, two different
# paths and two different backends:
#
# - ingress.domain comes from the fixture (matrix.ci.local), so the
# HOMESERVER route renders with its hostname from that single source of
# truth. httpRoute.hostnames stays empty on purpose: the fallback is the
# path every installation will actually use.
# - matrix.delegation.enabled is TRUE in the fixture with
# matrix.delegation.serverUrl: ci.local, so the DELEGATION route renders
# too - on its own hostname, matching PathPrefix /.well-known/matrix, and
# pointing at the delegation Service rather than at Synapse. Its
# parentRefs fall back to httpRoute.parentRefs below, which is the branch
# an installation hits unless the server name sits behind a different
# Gateway.
# - both routes' backendRefs are checked against the Services of the same
# render, and they are DIFFERENT Services. Both references are by PORT
# NUMBER rather than by the name "http" the Ingress uses, because
# backendRefs takes a PortNumber - so this overlay is what holds those
# numbers against the Services'.
#
# No URLRewrite filter is rendered by this chart in either mode: the delegation
# nginx answers on the full /.well-known/matrix/* path, so nothing is stripped
# and the #118 prefix/redirect assurance has no precondition to meet here.
#
# NOT covered here, and it cannot be: that either hostname answers, which for
# Matrix is also whether FEDERATION works. No Gateway controller runs in CI, no
# route reaches "Accepted: True", and Matrix federation is HTTPS-only while
# nothing in this mode renders a certificate. Nor is the body-size limit
# covered - proxy-body-size: 50m is on the live Ingress and has no counterpart
# in the specification; that loss is documented in values.yaml and printed by
# NOTES.txt, and documentation is not something a test can assert.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+30
View File
@@ -0,0 +1,30 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/outline/test-values.yaml: the install-test
# runs exactly ONE configuration per chart and the two modes are mutually
# exclusive, so switching the fixture would drop this chart's Ingress from the
# install for a CRD schema cyberchef's fixture already exercises against the
# real Gateway-API CRDs. The install keeps the Ingress; the route is held here,
# at render level, which is where its two silent failure modes live.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/outline/test-values.yaml and keeps feeding
# Outline's URL variable in this mode too; httpRoute.hostnames is left empty on
# purpose, so the fallback to ingress.domain is what the test exercises - and
# the two therefore cannot disagree here, which is the state this chart needs
# for its OIDC redirect URI to match.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+32
View File
@@ -0,0 +1,32 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/paperless-ngx/test-values.yaml: that
# fixture is the ONLY install of the valkey-subchart path anywhere in this
# repository (see its header), and the install-test runs exactly one
# configuration per chart. Switching it into Gateway mode would trade that
# coverage, plus this chart's Ingress, for a CRD schema cyberchef's fixture
# already exercises against the real Gateway-API CRDs. The install keeps the
# Ingress and the subchart; the route is held here, at render level, which is
# where its two silent failure modes live.
#
# ingress.enabled: false is not optional here, and in THIS chart it is also
# new: the switch was added with the Gateway mode (#114) and defaults to true,
# so without this line the chart aborts its own render with both modes on.
#
# ingress.domain comes from ci/paperless-ngx/test-values.yaml and keeps feeding
# PAPERLESS_URL in this mode too; httpRoute.hostnames is left empty on purpose,
# so the fallback to ingress.domain is what the test exercises - and the two
# therefore cannot disagree here, which is the state Django's CSRF check needs.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+29
View File
@@ -0,0 +1,29 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/patchmon/test-values.yaml: the install-test
# runs exactly ONE configuration per chart and the two modes are mutually
# exclusive, so switching the fixture would drop this chart's Ingress from the
# install for a CRD schema cyberchef's fixture already exercises against the
# real Gateway-API CRDs. The install keeps the Ingress; the route is held here,
# at render level, which is where its two silent failure modes live.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/patchmon/test-values.yaml and keeps feeding
# CORS_ORIGIN and the probes' Host header in this mode too; httpRoute.hostnames
# is left empty on purpose, so the fallback to ingress.domain is what the test
# exercises - and the two therefore cannot disagree here.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+27
View File
@@ -0,0 +1,27 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/rallly/test-values.yaml: the install-test
# runs exactly ONE configuration per chart and the two modes are mutually
# exclusive, so switching the fixture would drop this chart's Ingress from the
# install for a CRD schema cyberchef's fixture already exercises against the
# real Gateway-API CRDs.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/rallly/test-values.yaml and keeps feeding
# NEXT_PUBLIC_BASE_URL in this mode too; httpRoute.hostnames is left empty on
# purpose, so the fallback to ingress.domain is what the test exercises.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+75
View File
@@ -0,0 +1,75 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/rss-bridge/test-values.yaml, and for this
# chart the reason is sharper than for its siblings. The install-test runs
# exactly ONE configuration per chart and the two modes are mutually exclusive,
# so switching the fixture would trade this chart's Ingress away - and that
# fixture's Ingress is the only thing that exercises rss-bridge.tlsGroups, the
# secret-grouping helper that keeps the three grown certificates of the real
# installation on their existing names. That helper is inert in Gateway mode
# (nothing to group once TLS sits on the listener), so a fixture switched to
# this mode would stop covering it entirely. The CRD schema check the pilot
# paid for is already performed by cyberchef's fixture against the real
# Gateway-API CRDs; what is missing without this file is the render-level
# assurance, and that is what this overlay buys.
#
# A note on this chart's install-test, because it has a history. It had failed
# on the Gitea runner since the move - php-fpm unable to read /app/index.php,
# the startup probe 403ing through its whole budget, the pod never Ready (Gitea
# #51), reproduced on two images while the same chart and image ran healthy in
# the cluster. The chart was deliberately kept OUT of ci/excluded-charts.txt so
# that a pass would be noticed rather than silenced, and in the PR that added
# this overlay it PASSED: pod 1/1 Ready in 22s, 0 restarts, no permission error
# in the job. Since that PR's rendered defaults are byte-identical to
# origin/main, the chart is ruled out as the cause and the runner is the
# implicated layer - which is where #51 had already placed it. It reproduced
# on every run of that branch since, so it is not a fluke, and #51 was closed
# on that round. What was NEVER identified is the cause: nobody established
# what changed on the runner, and the diagnostic programme in that issue
# (docker info for userns and the dind configuration, the same image in a
# local k3d without remapping) was not executed. So if this chart's
# install-test goes red again, that is a fresh finding and not a known quirk -
# and this comment is the context it should be read against.
#
# Either way this overlay stays load-bearing: the install-test installs the
# INGRESS mode (that is what the fixture sets), so the Gateway mode of this
# chart is covered here at render level and nowhere else - the same situation
# as every other chart in the sweep.
#
# ingress.enabled: false is not optional here - the chart ships it as TRUE and
# aborts its own render when both modes are on.
#
# httpRoute.hostnames MUST be supplied, and this overlay is the second in the
# repository that has to (homeassistant's was the first). The other charts let
# an empty hostnames list fall back to ingress.domain; this chart has no
# ingress.domain, and it deliberately does not reuse ingress.hosts from the
# fixture, because those entries carry tlsSecretName values that address
# nothing once the certificate sits on the Gateway's listener. The render
# aborts on an empty list rather than publishing a route that matches every
# request reaching its listener.
#
# THREE hostnames on ONE object, which is the shape this chart's production
# installation has (three hosts, all serving the same Service at "/"). It is
# also the shape the test should see: one HTTPRoute, one rule, one backendRef,
# several hostnames - as opposed to homeassistant's one-object-per-host or
# matrix-synapse's two objects with different paths and backends. The three
# names are kept recognisably parallel to ci/rss-bridge/test-values.yaml so the
# two modes can be read side by side, but they are NOT the same values: these
# are the route's hostnames, and the fixture's are the Ingress's hosts.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
hostnames:
- rss-bridge.ci.local
- rssbridge.ci.local
- rss-bridge.ci.example
+27
View File
@@ -0,0 +1,27 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/searxng/test-values.yaml: the install-test
# runs exactly ONE configuration per chart and the two modes are mutually
# exclusive, so switching the fixture would drop this chart's Ingress from the
# install for a CRD schema cyberchef's fixture already exercises against the
# real Gateway-API CRDs.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/searxng/test-values.yaml and keeps feeding
# SEARXNG_BASE_URL in this mode too; httpRoute.hostnames is left empty on
# purpose, so the fallback to ingress.domain is what the test exercises.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+97
View File
@@ -0,0 +1,97 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/stalwart/test-values.yaml: the install-test
# runs exactly ONE configuration per chart, and this chart's fixture brings up
# the node groups against a throwaway Postgres. Trading its Ingress for a CRD
# schema check that cyberchef's fixture already performs against the real
# Gateway-API CRDs would be a bad swap. The install keeps the Ingress; the
# route is held here, at render level, which is where its silent failure modes
# live.
#
# ingress.enabled: false is not optional here - ingress.enabled is this chart's
# HTTP routing switch and defaults to TRUE, so without this line the chart
# aborts its own render with both modes on.
#
# WHAT THIS OVERLAY COVERS:
#
# - ingress.domain comes from the fixture, so the route renders with its
# hostname from that single source of truth. httpRoute.hostnames stays
# empty on purpose: the fallback is the path every installation will
# actually use.
# - ingress.nodeGroup comes from the fixture too, so the route is checked to
# point at THAT GROUP'S http Service and no other. This chart is the one in
# group 3 where the backend is group-scoped, and the ONE-route-not-one-
# per-group property is exactly what would be easy to get wrong on a copy.
# - the backendRef is checked against the per-group http Service of the same
# render, by PORT NUMBER (ports.http.containerPort) rather than by the name
# "http" the Ingress uses, because backendRefs takes a PortNumber. Note
# that this chart's NodePort Services publish a DIFFERENT number
# (ports.<name>.nodePort) than the container binds, so naming the wrong one
# here would be a plausible mistake - this overlay is what catches it.
#
# No URLRewrite filter is rendered by this chart in either mode, so the #118
# prefix/redirect assurance has no precondition to meet here.
#
# NOT covered here, and none of it can be:
#
# - that the hostname answers. No Gateway controller runs in CI and no route
# reaches "Accepted: True".
# - THE CORS CONFIGURATION, which is the thing this chart actually loses in
# this mode and the one that breaks the bulwark webmail. It lives in the
# operator's ingress.annotations, the chart has no values for it, and the
# Gateway API's own CORS filter is Extended-support with observed
# implementation variance - all of which is documented in values.yaml and
# printed by NOTES.txt. Documentation is not something a test can assert.
# - that SMTP/IMAP still work. They never went through the Ingress and do not
# go through the route; the mail Certificate that serves them keeps being
# rendered in this mode, which is this chart's distinguishing feature in
# the sweep, and no render test looks at whether it was left alone.
# - THE MTA-STS ROUTE (Gitea #123), which is the second HTTPRoute this chart
# renders. Its backendRef is held against the same per-group http Service
# by the same port number, so the one place information travels from the
# Service object into a route template is checked twice rather than once.
# What the list below is shaped for is the three cases the entry form has:
# an entry WITH a clusterIssuer, an entry WITHOUT one, and a host on a
# SUBDOMAIN - the last because mta-sts.sub.<domain> is one label deeper
# than *.<domain> and a parent wildcard does not cover it.
#
# Both TLS fields are INERT IN THIS MODE: TLS sits on the Gateway's
# listener and this chart renders no certificate for an HTTP hostname
# here. They are set anyway, and that is the assertion - the render has to
# ACCEPT them in Gateway mode without requiring them, because the mirror
# image (tlsSecretName required in Ingress mode) is what makes them
# mode-specific rather than optional-everywhere.
#
# The Ingress half of this feature is NOT exercised here - this overlay's
# whole job is to switch the mode off. It is exercised by
# ci/stalwart/test-values.yaml instead, which installs in Ingress mode and
# therefore has the MTA-STS Ingress objects accepted by a real API server.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
mtaSts:
hosts:
- host: mta-sts.ci.example.com
tlsSecretName: tls-mta-sts-ci-example-com-ingress
clusterIssuer: ci-selfsigned
# One label deeper: its own secret, because the parent's wildcard would not
# reach it. In this mode that is the Gateway listener's problem, which is
# exactly why the entry is here and why the values are inert.
- host: mta-sts.sub.ci.example.com
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
clusterIssuer: ci-selfsigned
# No clusterIssuer: "the certificate comes from somewhere else". Nothing in
# this mode reads it either way; the point is that the render accepts the
# entry without it.
- host: mta-sts.other.ci.example
tlsSecretName: tls-some-existing-wildcard-ingress
+33
View File
@@ -0,0 +1,33 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/sure/test-values.yaml: the install-test
# runs exactly ONE configuration per chart and the two modes are mutually
# exclusive, so switching the fixture would drop this chart's Ingress from the
# install for a CRD schema cyberchef's fixture already exercises against the
# real Gateway-API CRDs.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true. For THIS chart the combination also exercises
# sure.publishedOverTls: with the Ingress off and the route on, the render must
# still produce RAILS_ASSUME_SSL/RAILS_FORCE_SSL as "true", because TLS is
# terminated at the Gateway. That is not asserted by this test - it only looks
# at HTTPRoutes - but it is rendered here, so a regression shows up in the
# manifest this job parses rather than only in a cluster.
#
# ingress.domain comes from ci/sure/test-values.yaml and keeps feeding
# APP_DOMAIN, WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS in this mode too;
# httpRoute.hostnames is left empty on purpose, so the fallback to
# ingress.domain is what the test exercises.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+23
View File
@@ -0,0 +1,23 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# template-chart has no ci/<chart>/test-values.yaml (it is excluded from the
# install-test for its placeholder image), so the test reaches it through
# ci/trivy/values/template-chart.yaml, which this overlay sits on top of.
#
# ingress.enabled: false is not optional here. The two modes are mutually
# exclusive and the chart aborts its own render when both are true, so an
# overlay that only switched httpRoute on would turn this test red on the
# charts that implement the convention correctly.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+28
View File
@@ -0,0 +1,28 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/urlaubsverwaltung/test-values.yaml: the
# install-test runs exactly ONE configuration per chart and the two modes are
# mutually exclusive, so switching the fixture would drop this chart's Ingress
# from the install for a CRD schema cyberchef's fixture already exercises
# against the real Gateway-API CRDs.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/urlaubsverwaltung/test-values.yaml. This is the
# one chart in the group where nothing but the routing templates reads it, so
# with the overlay applied the HTTPRoute's required() is the only thing
# demanding it - which is exactly the path worth rendering.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+29
View File
@@ -0,0 +1,29 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/voucher-vault/test-values.yaml: the
# install-test runs exactly ONE configuration per chart and the two modes are
# mutually exclusive, so switching the fixture would drop this chart's Ingress
# from the install for a CRD schema cyberchef's fixture already exercises
# against the real Gateway-API CRDs.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/voucher-vault/test-values.yaml and keeps feeding
# Django's DOMAIN (and through it ALLOWED_HOSTS) in this mode too;
# httpRoute.hostnames is left empty on purpose, so the fallback to
# ingress.domain is what the test exercises - and the two therefore cannot
# disagree here, which is the one way this chart fails invisibly.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+64
View File
@@ -0,0 +1,64 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/ytdlp-web-player/test-values.yaml: the
# install-test runs exactly ONE configuration per chart, and this chart's
# fixture is the one that resolves and installs the oauth2-proxy SUBCHART.
# Trading its Ingress for a CRD schema check that cyberchef's fixture already
# performs against the real Gateway-API CRDs would be a bad swap. The install
# keeps the Ingress; the route is held here, at render level.
#
# ingress.enabled: false is not optional here - ingress.enabled defaults to
# TRUE in this chart, so without this line the chart aborts its own render with
# both modes on.
#
# THE SUBCHART IS LEFT DISABLED, as the fixture has it, and that is deliberate
# rather than lazy. The oauth2-proxy chart HAS NO GATEWAY-API SUPPORT - it
# renders an Ingress or nothing - so switching it on here would add a second
# object this test does not look at, while the thing worth exercising in
# Gateway mode is this chart's own route. The interesting interaction between
# the two (one hostname claimed by the proxy's Ingress AND by this chart's
# HTTPRoute, which the ingress-nginx admission webhook cannot catch because the
# kinds differ) is a RENDER-FAILURE path: the chart refuses it. This test only
# runs renders it expects to succeed, so that guard was exercised by hand
# instead - see the PR.
#
# WHAT THIS OVERLAY COVERS:
#
# - ingress.domain comes from the fixture (ytdlp-web-player.example.com), so
# the route renders with its hostname from that single source of truth.
# httpRoute.hostnames stays empty on purpose: the fallback is the path an
# installation will actually use.
# - the backendRef is checked against the application Service of the same
# render, by PORT NUMBER rather than by the name "http" the Ingress uses,
# because backendRefs takes a PortNumber. That number also has to agree
# with ytdlp-web-player.appServiceAddress, which is what the proxy gets as
# its upstream - three places, and this overlay holds two of them together.
#
# No URLRewrite filter is rendered by this chart in either mode, so the #118
# prefix/redirect assurance has no precondition to meet here.
#
# NOT covered here, and it cannot be:
#
# - that the hostname answers. No Gateway controller runs in CI and no route
# reaches "Accepted: True".
# - that authentication is in front of the application. With the proxy off
# this route IS the unauthenticated path, which is correct for a fixture
# and is exactly what NOTES.txt warns about for a real installation.
# - anything about how this release is published in the real cluster. There
# is no live Ingress of this chart to compare against: measured 2026-10-12,
# the release runs in namespace jlab-player with both Deployments Ready and
# not one of the cluster's 23 Ingress objects is in that namespace. What
# publishes it could not be determined from the workspace.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+29
View File
@@ -0,0 +1,29 @@
# Render-only overlay for ci/run-httproute-match-test.sh, applied LAST.
#
# Its one job is to put this chart into Gateway mode, because the convention
# ships with httpRoute.enabled: false and the test would otherwise be green
# over nothing. A chart that has an overlay here and still renders no
# HTTPRoute FAILS the test - that is the whole point of the file existing.
#
# An overlay and not a change to ci/zipline/test-values.yaml: the install-test
# runs exactly ONE configuration per chart and the two modes are mutually
# exclusive, so switching the fixture would drop this chart's Ingress from the
# install for a CRD schema cyberchef's fixture already exercises against the
# real Gateway-API CRDs. The install keeps the Ingress; the route is held here,
# at render level, which is where its two silent failure modes live.
#
# ingress.enabled: false is not optional here - the chart aborts its own render
# when both modes are true.
#
# ingress.domain comes from ci/zipline/test-values.yaml and keeps feeding
# CORE_DEFAULT_DOMAIN in this mode too; httpRoute.hostnames is left empty on
# purpose, so the fallback to ingress.domain is what the test exercises - and
# the two therefore cannot disagree here.
ingress:
enabled: false
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
+37
View File
@@ -203,6 +203,43 @@ keycloak:
mountPath: /opt/keycloak/themes/ci-theme
readOnly: true
# TWO REALM HOSTNAMES (Gitea #122), and they are set HERE rather than in the
# Gateway overlay on purpose: ci/httproute/keycloak.yaml is applied on top of
# this file, so one definition covers BOTH modes - two Ingress objects in this
# install, two HTTPRoutes in the render-only test, from the same two entries.
# Defining them in the overlay instead would leave the Ingress half of the
# feature with no coverage at all, and it is the half that carries the
# required value and the convention deviation.
#
# WHAT THIS INSTALL ADDS TO THE RENDER TEST, which is the part a cluster-less
# check cannot reach: the API server accepts both objects, including the
# Service port named BY NAME ("http") - a typo there is invisible until
# somebody opens the URL - and the second entry's cert-manager annotation does
# not make the apply fail in a cluster without cert-manager. No ingress
# controller answers in CI, so neither app-root nor the proxy buffers are
# exercised; that is the same gap the two objects above have.
#
# THE TWO ENTRIES ARE THE TWO FORMS, deliberately:
# [0] NO clusterIssuer - the normal case, where the certificate comes from
# elsewhere (a wildcard, another release) and this object only consumes
# the secret. It also carries a per-entry annotation, which is what
# makes the per-entry (rather than chart-global) annotations block a
# tested shape rather than an intended one.
# [1] WITH clusterIssuer - the case where the certificate is issued for this
# entry's own secret.
# Both are inert in Gateway mode, and the render test is what holds that: the
# routes it renders from these entries must show no trace of either field.
realmHosts:
- host: realm-one.ci.example.com
realm: ci-realm-one
tlsSecretName: tls-ci-realm-shared-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
- host: realm-two.ci.example.com
realm: ci-realm-two
tlsSecretName: tls-ci-realm-two-ingress
clusterIssuer: ci-selfsigned
ingress:
# Left ON although no ingress controller answers in CI: the point is that
# both objects are accepted and that the Service port they name BY NAME
+189 -33
View File
@@ -11,23 +11,56 @@
# failing the `build-chart` version-bump check. This script closes that gap and
# is wired up as a `postUpgradeTasks` command in renovate.json5.
#
# Usage: ci/renovate-bump-chart.sh <chart-directory>
# Usage: ci/renovate-bump-chart.sh <chart-directory> [update-type]
#
# Bump level (README "Versioning"):
# - app major changed -> chart major, minor/patch reset to 0
# - anything else -> chart patch (plain app updates, sidecar images)
# Bump level (README "Versioning", decided in Gitea #128):
# - update type major -> chart major, minor/patch reset to 0
# - update type minor -> chart MINOR, patch reset to 0
# - patch / digest / pin -> chart patch
# - no or unknown update type -> the appVersion comparison below, i.e. the
# behaviour this script had before #128
# - and never BELOW what the branch already carries (see the maximum rule
# further down)
#
# THE UPDATE TYPE COMES FROM RENOVATE, AND THAT IS THE POINT (Gitea #128).
# Renovate has already classified the jump; passing `{{{updateType}}}` in is
# what makes a chart minor possible at all. Deriving it here from appVersion
# would be blind to exactly the case that prompted the rule: `freesailarr`
# carries a hand-maintained `appVersion: "1.0.0"` for ten upstreams and it never
# moves, so prowlarr's 2.5.2 -> 2.6.5 — an image tag in values.yaml — is a
# minor with an unchanged appVersion. README, "Dependency updates (Renovate)".
#
# WHAT A CHART MINOR BUYS, AND WHAT IT DOES NOT. It makes the number honest: an
# app minor stops arriving as a chart patch. It does NOT oblige anyone to read
# the release notes — that variant was decided against — so an app minor that
# changes a default can still reach `main` unremarked. What the number does is
# make the jump VISIBLE to everything downstream that sees only our chart
# version (Fleet's `fleet.yaml` in `cluster-config` among them), where a patch
# is invisible. See the wiki, Lessons-Learned.
#
# THE SECOND ARGUMENT IS OPTIONAL ON PURPOSE. `postUpgradeTasks` only runs when
# the command is allow-listed in the *instance* config, which is not visible
# from this repository, and a command that does not match the allow-list is
# skipped SILENTLY. So a missing update type — a narrower allow-list pattern, an
# older Renovate, a template field that is not populated — must not be a
# failure: without it the script falls back to what it did before #128 and every
# chart PR still gets its version bump.
#
# Everything is derived from the base commit (HEAD, which during
# postUpgradeTasks still points at the base branch), never from the working
# tree's `version:`. That makes repeated runs on the same branch idempotent —
# Renovate calls the script once per dependency update, and a branch may carry
# more than one update for the same chart.
# more than one update for the same chart. The one place the working tree IS
# read is the maximum rule below, and only to keep a higher version.
#
# Runs inside the Renovate container, so: POSIX sh, no bashisms, no jq/yq.
#
# Tested by ci/run-bump-level-test.sh, which is where the cases live.
set -eu
dir=${1:-}
update_type=${2:-}
[ -n "$dir" ] || exit 0
# Strip a trailing slash so "outline/" and "outline" behave the same.
@@ -49,6 +82,72 @@ field() {
sed -n "s/^$1:[[:space:]]*//p" | head -n 1 | tr -d "\"' "
}
# Splits "<major>.<minor>.<patch>" into p_major/p_minor/p_patch. Returns 1 if
# the string is not exactly three numeric parts, which is also how a four-part
# version (3.3.1.0) and a suffixed one (9.1.2-alpine) are rejected: the last
# part then is not numeric.
parse_semver() {
_sv=$1
case "$_sv" in
*.*.*) ;;
*) return 1 ;;
esac
p_major=${_sv%%.*}
_rest=${_sv#*.}
p_minor=${_rest%%.*}
p_patch=${_rest#*.}
for _part in "$p_major" "$p_minor" "$p_patch"; do
case "$_part" in
'' | *[!0-9]*) return 1 ;;
esac
done
return 0
}
# Is <a.b.c> strictly greater than <d.e.f>? Six arguments, componentwise.
semver_gt() {
if [ "$1" -ne "$4" ]; then
if [ "$1" -gt "$4" ]; then return 0; else return 1; fi
fi
if [ "$2" -ne "$5" ]; then
if [ "$2" -gt "$5" ]; then return 0; else return 1; fi
fi
if [ "$3" -gt "$6" ]; then return 0; else return 1; fi
}
# Does this app version's MINOR POSITION carry a statement about jump width?
# Only a plain [v]X.Y.Z does. Everything else keeps the pre-#128 behaviour, i.e.
# a patch, and the reason is in the convention rather than in a footnote: in
# CalVer and in date tags the second position is a MONTH, so following it would
# produce a chart minor every month that says nothing about the risk, and in a
# four-part or suffixed version the positions are not semver positions at all.
#
# Measured on origin/main, this covers seven charts and only those seven:
# rss-bridge 2026-09-30 (no dots at all)
# homeassistant 2026.8.3 (CalVer — caught by the year guard)
# searxng 2026.9.13-3b8009065 (CalVer plus a commit hash)
# minecraft-server 2026.8.2-java25 (CalVer plus a JRE suffix)
# apache-tika 3.3.1.0-full (four parts plus a flavour)
# teamspeak-server 6.0.0-beta13.1 (prerelease line)
# valkey 9.1.2-alpine (flavour suffix)
#
# The last two are the honest edge of this rule: their numeric part IS a semver
# line, so the fallback understates a real minor there. That is the decided
# trade — conservative by default, and a chart that wants the minor gets a
# documented exception rather than a widened pattern.
#
# A `major` update type is NOT subject to this: a year rolling over in a date
# tag arrives as a major, and that was a chart major before #128 too.
app_minor_is_meaningful() {
_av=${1#v}
_av=${_av#V}
parse_semver "$_av" || return 1
# A four-digit leading component from 2000 on is a calendar year, not a
# semver major.
if [ "$p_major" -ge 2000 ]; then return 1; fi
return 0
}
base=$(git show "HEAD:$chart" 2>/dev/null) || {
echo "renovate-bump: $chart is new on this branch, leaving its version alone"
exit 0
@@ -57,6 +156,7 @@ base=$(git show "HEAD:$chart" 2>/dev/null) || {
base_version=$(printf '%s\n' "$base" | field version)
base_app=$(printf '%s\n' "$base" | field appVersion)
new_app=$(field appVersion <"$chart")
tree_version=$(field version <"$chart")
if [ -z "$base_version" ] || [ -z "$new_app" ]; then
echo "renovate-bump: $chart has no version/appVersion to work with" >&2
@@ -65,27 +165,13 @@ fi
# <chart-semver> is everything before the +up build suffix.
semver=${base_version%%+*}
case "$semver" in
*.*.*) ;;
*)
echo "renovate-bump: cannot parse chart semver '$semver' in $chart" >&2
exit 1
;;
esac
major=${semver%%.*}
rest=${semver#*.}
minor=${rest%%.*}
patch=${rest#*.}
for part in "$major" "$minor" "$patch"; do
case "$part" in
'' | *[!0-9]*)
echo "renovate-bump: non-numeric component in chart semver '$semver'" >&2
exit 1
;;
esac
done
parse_semver "$semver" || {
echo "renovate-bump: cannot parse chart semver '$semver' in $chart" >&2
exit 1
}
major=$p_major
minor=$p_minor
patch=$p_patch
# Leading major of an app version: v1.159.0 -> 1, 3.3.1.0-full -> 3,
# 2025-06-03 -> 2025.
@@ -93,17 +179,87 @@ app_major() {
printf '%s' "$1" | sed -e 's/^[vV]//' -e 's/[^0-9].*$//'
}
if [ "$new_app" != "$base_app" ] && [ "$(app_major "$new_app")" != "$(app_major "$base_app")" ]; then
reason="app major $base_app -> $new_app"
new_semver="$((major + 1)).0.0"
else
reason="patch-level change"
new_semver="$major.$minor.$((patch + 1))"
# The pre-#128 level: app major changed -> major, anything else -> patch. Also
# the fallback whenever no usable update type arrives.
app_version_level() {
if [ "$new_app" != "$base_app" ] && [ "$(app_major "$new_app")" != "$(app_major "$base_app")" ]; then
echo major
else
echo patch
fi
}
case "$update_type" in
major)
level=major
reason="update type major"
;;
minor)
if app_minor_is_meaningful "$new_app"; then
level=minor
reason="update type minor"
else
level=$(app_version_level)
reason="update type minor, but appVersion '$new_app' is not a plain semver line -> $level"
fi
;;
patch | digest | pin | pinDigest)
level=patch
reason="update type $update_type"
;;
'')
level=$(app_version_level)
reason="no update type given, derived from appVersion -> $level"
;;
*)
level=$(app_version_level)
reason="unknown update type '$update_type', derived from appVersion -> $level"
;;
esac
case "$level" in
major)
new_major=$((major + 1))
new_minor=0
new_patch=0
;;
minor)
new_major=$major
new_minor=$((minor + 1))
new_patch=0
;;
*)
new_major=$major
new_minor=$minor
new_patch=$((patch + 1))
;;
esac
# THE MAXIMUM RULE (Gitea #128): the chart level is the GREATER of the chart's
# own classification and the app jump — never a replacement. Two cases, and the
# second is the mechanical one:
#
# - a chart-own bump already in the working tree stands. A value that becomes
# required is a chart major even when the app jump is a patch, and the chart
# version must not stop reporting the chart's own breaks.
# - a branch carrying SEVERAL updates. Renovate calls this script once per
# update and every call recomputes from the same base commit, so without
# this the last call would win: `freesailarr`'s grouped PR has arrived with
# three minors among five patches, and the chart level would then depend on
# the order the hook happened to run in.
#
# It only ever keeps a HIGHER version, so repeated runs stay idempotent.
if parse_semver "${tree_version%%+*}" &&
semver_gt "$p_major" "$p_minor" "$p_patch" "$new_major" "$new_minor" "$new_patch"; then
reason="$reason, kept the higher $p_major.$p_minor.$p_patch already on the branch"
new_major=$p_major
new_minor=$p_minor
new_patch=$p_patch
fi
# Dashes in the app version become dots in the +up suffix.
suffix=$(printf '%s' "$new_app" | tr '-' '.')
new_version="$new_semver+up$suffix"
new_version="$new_major.$new_minor.$new_patch+up$suffix"
sed -i.bak "s|^version:.*|version: $new_version|" "$chart"
rm -f "$chart.bak"
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# Bump-level test: every case of ci/renovate-bump-chart.sh, held against the
# version string it writes into a throwaway chart.
#
# Usage: ci/run-bump-level-test.sh
#
# WHY THIS TEST EXISTS (Gitea #128)
#
# Until #128 the hook was a calculator with one branch: app major -> chart
# major, everything else -> chart patch. With the update type coming from
# Renovate it became DECIDING LOGIC — it is now the thing that says how big a
# change the cluster is being offered, and the chart version is the only signal
# anything downstream sees (README, "Chart dependencies": Fleet's `fleet.yaml`
# in `cluster-config` sees our chart version and nothing inside the package).
#
# And its failure mode is silence. A wrong level is a valid version string: the
# PR goes green, the chart publishes, and the only symptom is a number that
# understates what it ships. Nothing in the pipeline reads it, so nothing but a
# test can.
#
# HOW A CASE IS BUILT, AND WHY A REAL GIT REPO
#
# The script reads the BASE commit (`git show HEAD:<chart>/Chart.yaml`) and the
# working tree, so a case is not a function call — it is a repository with a
# commit and a dirty file. Each case therefore gets a throwaway `git init` in a
# unique temp directory, one commit carrying the base Chart.yaml, and the new
# Chart.yaml written over it uncommitted. That is exactly the state Renovate's
# `postUpgradeTasks` hook runs in.
#
# Signing is switched off per command (`-c commit.gpgsign=false`): this
# workspace signs every commit through an SSH signer, and a throwaway repo has
# no business invoking it. The temp root comes from `mktemp -d`, because /tmp is
# shared with whatever else runs on this machine.
#
# The runners have no pyyaml, no yq and no Docker — the same constraint
# ci/run-selector-match-test.sh and ci/run-quantity-render-test.sh work under.
# None is needed: the fixtures are six-line Chart.yaml files written by printf,
# and the assertion is one `version:` line read back with sed. Helm is not
# needed either, which is why this job carries no Helm step.
#
# WHAT IS ASSERTED, AND WHAT IS NOT
#
# Asserted: the full `version:` line (so the `+up` suffix and its dash-to-dot
# normalization are covered by every case), the exit code, and for the cases
# that must leave the chart alone, that the line is untouched.
#
# NOT asserted, and not assertable from here: that Renovate passes the update
# type at all. The hook only runs when the command is allow-listed in the
# *instance* config, which lives outside this repository — and a pattern that
# does not match is skipped silently. That is why a missing second argument is
# itself a case here (it must fall back, not fail), and why the PR that
# introduced this carries a dry-run instead of a claim.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT/ci/renovate-bump-chart.sh"
[ -x "$SCRIPT" ] || {
echo "FAIL: $SCRIPT is not executable" >&2
exit 1
}
TMP="$(mktemp -d "${TMPDIR:-/tmp}/bump-level-test.XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
GIT_OPTS=(-c user.name=ci -c user.email=ci@example.invalid -c commit.gpgsign=false -c tag.gpgsign=false)
passed=0
failed=0
chart_yaml() { # <name> <version> <appVersion>
printf 'apiVersion: v2\nname: %s\ndescription: test fixture\ntype: application\nversion: %s\nappVersion: "%s"\nicon: https://example.com/icon.png\n' \
"$1" "$2" "$3"
}
# Builds a repository whose HEAD carries <base-content> for <dir>/Chart.yaml
# (skipped when empty, which is the "new chart on this branch" case) and whose
# working tree carries <tree-content>. Prints the repository path.
scenario() { # <dir> <base-content> <tree-content>
local dir=$1 base=$2 tree=$3 repo
repo="$(mktemp -d "$TMP/case.XXXXXX")"
git -C "$repo" init -q
printf 'root commit so HEAD exists\n' >"$repo/README.md"
git -C "$repo" add README.md
git -C "$repo" "${GIT_OPTS[@]}" commit -qm "root"
mkdir -p "$repo/$dir"
if [ -n "$base" ]; then
printf '%s' "$base" >"$repo/$dir/Chart.yaml"
git -C "$repo" add "$dir/Chart.yaml"
git -C "$repo" "${GIT_OPTS[@]}" commit -qm "base chart"
fi
[ -z "$tree" ] || printf '%s' "$tree" >"$repo/$dir/Chart.yaml"
printf '%s' "$repo"
}
version_of() { sed -n 's/^version:[[:space:]]*//p' "$1" | head -n 1; }
report() { # <name> <expected> <got> <expected-rc> <got-rc> <output>
if [ "$2" = "$3" ] && [ "$4" = "$5" ]; then
passed=$((passed + 1))
printf ' ok %s\n' "$1"
else
failed=$((failed + 1))
printf ' FAIL %s\n expected version %-24s rc %s\n got version %-24s rc %s\n output: %s\n' \
"$1" "$2" "$4" "$3" "$5" "$6"
fi
}
# The ordinary case: one chart, a base version, an app version that may or may
# not have moved, and an update type. UPDATE_TYPE of "-" calls the script with
# ONE argument (the allow-list / older-Renovate case), "" passes an empty
# second argument (a template field that resolved to nothing).
bump() { # <name> <base-version> <base-app> <tree-version> <tree-app> <update-type> <expected-version> [expected-rc]
local name=$1 base_version=$2 base_app=$3 tree_version=$4 tree_app=$5 utype=$6 expected=$7 rc_expected=${8:-0}
local repo out rc got
repo="$(scenario demo "$(chart_yaml demo "$base_version" "$base_app")" "$(chart_yaml demo "$tree_version" "$tree_app")")"
rc=0
if [ "$utype" = "-" ]; then
out="$(cd "$repo" && "$SCRIPT" demo 2>&1)" || rc=$?
else
out="$(cd "$repo" && "$SCRIPT" demo "$utype" 2>&1)" || rc=$?
fi
got="$(version_of "$repo/demo/Chart.yaml")"
report "$name" "$expected" "$got" "$rc_expected" "$rc" "$out"
}
echo "== update type decides the level (plain semver appVersion) =="
bump "major -> chart major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 major 2.0.0+up2.0.0
bump "minor -> chart minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 minor 1.3.0+up1.5.0
bump "patch -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.1 patch 1.2.4+up1.4.1
bump "digest -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 digest 1.2.4+up1.4.0
bump "pin -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 pin 1.2.4+up1.4.0
bump "pinDigest -> chart patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.0 pinDigest 1.2.4+up1.4.0
bump "minor resets the patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 minor 1.3.0+up1.5.0
bump "major resets minor and patch" 4.5.6+up1.4.0 1.4.0 4.5.6+up1.4.0 2.0.0 major 5.0.0+up2.0.0
echo "== the case that prompted the rule: appVersion never moves (freesailarr) =="
# prowlarr 2.5.2 -> 2.6.5 is an image tag in values.yaml; freesailarr's
# appVersion is a hand-maintained marker and stays at 1.0.0. An appVersion
# comparison sees nothing and would bump the patch.
bump "sidecar/bundle minor, appVersion unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 minor 3.2.0+up1.0.0
bump "sidecar/bundle patch, appVersion unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 patch 3.1.2+up1.0.0
echo "== non-semver app versions stay at patch (all seven on origin/main) =="
bump "rss-bridge date tag, month roll" 2.1.1+up2026.09.30 2026-09-30 2.1.1+up2026.09.30 2026-10-05 minor 2.1.2+up2026.10.05
bump "homeassistant CalVer" 6.2.0+up2026.8.3 2026.8.3 6.2.0+up2026.8.3 2026.9.0 minor 6.2.1+up2026.9.0
bump "searxng CalVer plus hash" 1.1.0+up2026.9.13.3b8009065 2026.9.13-3b8009065 1.1.0+up2026.9.13.3b8009065 2026.10.1-aa12bb34c minor 1.1.1+up2026.10.1.aa12bb34c
bump "minecraft-server CalVer plus JRE" 6.0.1+up2026.8.2.java25 2026.8.2-java25 6.0.1+up2026.8.2.java25 2026.9.1-java25 minor 6.0.2+up2026.9.1.java25
bump "apache-tika four parts" 10.0.2+up3.3.1.0.full 3.3.1.0-full 10.0.2+up3.3.1.0.full 3.4.0.0-full minor 10.0.3+up3.4.0.0.full
bump "teamspeak prerelease line" 5.1.3+up6.0.0.beta13.1 6.0.0-beta13.1 5.1.3+up6.0.0.beta13.1 6.0.0-beta14.1 minor 5.1.4+up6.0.0.beta14.1
bump "valkey flavour suffix" 1.0.0+up9.1.2.alpine 9.1.2-alpine 1.0.0+up9.1.2.alpine 9.2.0-alpine minor 1.0.1+up9.2.0.alpine
# A major is NOT subject to the fallback - a year rolling over was a chart major
# before #128 as well.
bump "date tag, year roll, major" 2.1.1+up2026.09.30 2026-09-30 2.1.1+up2026.09.30 2027-01-05 major 3.0.0+up2027.01.05
bump "four parts, app major, major" 10.0.2+up3.3.1.0.full 3.3.1.0-full 10.0.2+up3.3.1.0.full 4.0.0.0-full major 11.0.0+up4.0.0.0.full
# The year guard is a guard, not a blanket: a v-prefixed plain line still counts.
bump "v-prefixed plain semver, minor" 7.2.0+upv1.159.0 v1.159.0 7.2.0+upv1.159.0 v1.160.0 minor 7.3.0+upv1.160.0
echo "== maximum, not replacement =="
# A chart-own bump already in the working tree stands, whatever the app jump is.
bump "chart major already on branch, app patch" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.4.1 patch 2.0.0+up1.4.1
bump "chart minor already on branch, app patch" 1.2.3+up1.4.0 1.4.0 1.3.0+up1.4.0 1.4.1 patch 1.3.0+up1.4.1
# A grouped branch: the hook runs once per update from the same base, so the
# minor must survive a patch call that follows it.
bump "minor on branch, later patch call" 3.1.1+up1.0.0 1.0.0 3.2.0+up1.0.0 1.0.0 patch 3.2.0+up1.0.0
bump "patch on branch, later minor call" 3.1.1+up1.0.0 1.0.0 3.1.2+up1.0.0 1.0.0 minor 3.2.0+up1.0.0
bump "repeated minor call is idempotent" 3.1.1+up1.0.0 1.0.0 3.2.0+up1.0.0 1.0.0 minor 3.2.0+up1.0.0
bump "repeated major call is idempotent" 1.2.3+up1.4.0 1.4.0 2.0.0+up2.0.0 2.0.0 major 2.0.0+up2.0.0
bump "major on branch wins over a later minor" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.5.0 minor 2.0.0+up1.5.0
# Stale branch after a rebase: the base moved ahead of what the branch carries,
# so the recomputed version wins and nothing is kept.
bump "stale branch version is not kept" 3.3.0+up1.0.0 1.0.0 3.1.2+up1.0.0 1.0.0 patch 3.3.1+up1.0.0
echo "== missing and unknown update type fall back to the pre-#128 behaviour =="
bump "no second argument, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 - 2.0.0+up2.0.0
bump "no second argument, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 - 1.2.4+up1.5.0
bump "no second argument, app patch" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.4.1 - 1.2.4+up1.4.1
bump "no second argument, unchanged" 3.1.1+up1.0.0 1.0.0 3.1.1+up1.0.0 1.0.0 - 3.1.2+up1.0.0
bump "empty second argument, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 "" 2.0.0+up2.0.0
bump "empty second argument, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 "" 1.2.4+up1.5.0
bump "unknown type, app minor" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 1.5.0 replacement 1.2.4+up1.5.0
bump "unknown type, app major" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 2.0.0 lockFileMaintenance 2.0.0+up2.0.0
bump "unknown type, maximum still holds" 1.2.3+up1.4.0 1.4.0 2.0.0+up1.4.0 1.4.1 nonsense 2.0.0+up1.4.1
echo "== charts the script must leave alone, and the hard failures =="
bump "unparsable chart semver fails loudly" 1.2+up1.4.0 1.4.0 1.2+up1.4.0 1.5.0 minor 1.2+up1.4.0 1
bump "missing appVersion fails loudly" 1.2.3+up1.4.0 1.4.0 1.2.3+up1.4.0 "" minor 1.2.3+up1.4.0 1
new_chart_repo="$(scenario demo "" "$(chart_yaml demo 0.1.0+up1.0.0 1.0.0)")"
rc=0
out="$(cd "$new_chart_repo" && "$SCRIPT" demo minor 2>&1)" || rc=$?
report "a chart new on the branch keeps its version" \
"0.1.0+up1.0.0" "$(version_of "$new_chart_repo/demo/Chart.yaml")" 0 "$rc" "$out"
case "$out" in
*"new on this branch"*) passed=$((passed + 1)); printf ' ok and says so\n' ;;
*) failed=$((failed + 1)); printf ' FAIL new chart: no explanation in the output: %s\n' "$out" ;;
esac
tpl_repo="$(scenario template-chart "$(chart_yaml template-chart 4.2.0+upxxx xxx)" "$(chart_yaml template-chart 4.2.0+upxxx xxx)")"
rc=0
out="$(cd "$tpl_repo" && "$SCRIPT" template-chart minor 2>&1)" || rc=$?
report "template-chart is never touched" \
"4.2.0+upxxx" "$(version_of "$tpl_repo/template-chart/Chart.yaml")" 0 "$rc" "$out"
# Renovate calls the hook for every update it makes, chart or not.
other_repo="$(scenario demo "$(chart_yaml demo 1.2.3+up1.4.0 1.4.0)" "$(chart_yaml demo 1.2.3+up1.4.0 1.4.0)")"
for arg in ".github/workflows" "ci/gateway-api-crds.sh" "." "" "not-a-chart"; do
rc=0
out="$(cd "$other_repo" && "$SCRIPT" "$arg" major 2>&1)" || rc=$?
report "non-chart argument '$arg' is a no-op" \
"1.2.3+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out"
done
rc=0
out="$(cd "$other_repo" && "$SCRIPT" 2>&1)" || rc=$?
report "no arguments at all is a no-op" \
"1.2.3+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out"
rc=0
out="$(cd "$other_repo" && "$SCRIPT" demo/ patch 2>&1)" || rc=$?
report "a trailing slash is accepted" \
"1.2.4+up1.4.0" "$(version_of "$other_repo/demo/Chart.yaml")" 0 "$rc" "$out"
echo
if [ "$failed" -gt 0 ]; then
printf 'bump-level test: %d passed, %d FAILED\n' "$passed" "$failed"
exit 1
fi
printf 'bump-level test: %d cases passed\n' "$passed"
+587
View File
@@ -0,0 +1,587 @@
#!/usr/bin/env bash
# HTTPRoute render test: every HTTPRoute a chart renders is held against the
# Services of the SAME render, and against the one condition without which the
# route attaches to nothing.
#
# Usage: ci/run-httproute-match-test.sh [chart ...] (no argument = all charts)
#
# WHAT IT GUARDS AGAINST, AND WHY NEITHER A RENDER NOR AN INSTALL DOES (#114)
#
# An HTTPRoute is the Gateway-API alternative to the Ingress (README, "Ingress
# and Gateway API"), and it has two silent failure modes that look exactly like
# a working route from the outside:
#
# parentRefs EMPTY - a route without a parent attaches to no Gateway. The
# object is created, it is schema-valid, no controller rejects it, and it
# routes nothing. There is no event and no condition; the only symptom is a
# hostname that does not answer. The charts abort the render over this, and
# this test is the half that catches a chart which forgot to.
# backendRefs pointing at NOTHING - the reference is by name, in the release
# namespace, and nothing validates that the name exists. A typo, a renamed
# Service, or a port number that no longer matches the Service's port
# yields a route that resolves to no endpoint. ResolvedRefs goes False in
# the route's STATUS, which only exists once a controller has looked at it
# - so in CI, where no Gateway controller runs, nothing reports it at all.
# A PREFIX STRIPPED WITHOUT THE BARE PREFIX REDIRECTED (#118) - the third
# finding, and the one that is not about an object pointing nowhere but
# about an object pointing somewhere WRONG. A rule that matches
# PathPrefix "<path>" (anything but "/") and carries a URLRewrite filter
# with ReplacePrefixMatch strips the prefix before the app sees the
# request. For the slashed form that is correct. For the BARE form -
# "<path>", no trailing slash - the request becomes "/" and the app
# answers 200, while the browser's document URL keeps the prefixless form:
# every relative link in that page then resolves against the PARENT of
# that path, which is the host root. On a shared host the root belongs to
# a different application, so the app's own assets and XHRs are answered
# by its neighbour. Nothing in the chart, the render, the schema or the
# install reports any of it; the symptom is a half-working UI and a
# neighbour in the access log, which points nowhere useful.
#
# So: wherever a route strips a prefix, the SAME route must also carry a
# rule matching that path with type Exact and a RequestRedirect filter.
# Per HTTPRoute object, because Gateway-API matching precedence is a
# property of one route - a different route is a different hostname and
# says nothing about this one. Exact outranks every prefix match in that
# precedence, so the pairing is about existence and not about order.
# freesailarr is the FIRST AND, AS OF GROUP 3 OF #114, THE ONLY chart this
# applies to. The line that used to stand here said bulwark was the second
# rewrite-target chart and would meet the same check when group 3 landed.
# Group 3 landed and it does not: bulwark has ONE Ingress object, no
# rewrite-target annotation, and cannot have one - NEXT_PUBLIC_BASE_PATH is
# compiled into its bundle, so it cannot be served under a sub-path at all
# and its route is one rule at "/" with no URLRewrite filter. Every
# occurrence of the string "rewrite-target" in that chart is an explanation
# of why it does not help, and #114's measured table arrived at "bulwark,
# freesailarr" by grepping for the string rather than for the annotation.
# The check stays over all charts for the reason below - the mistake
# arrives by copy - not because a second chart is known to need it.
#
# And the install-test cannot close either gap, even with the real CRDs in the
# cluster: schema validation accepts an empty parentRefs list and accepts a
# backendRef to a Service that does not exist. The CRDs prove the object is
# WELL-FORMED; this test is what proves it POINTS SOMEWHERE.
#
# Over ALL charts and not the changed ones, for the same reason as
# ci/run-quantity-render-test.sh and ci/run-selector-match-test.sh: the mistake
# arrives by copy into a chart whose own diff nobody reads for it - and this
# convention is going to be copied 20 more times when the sweep of #114 runs.
# Running over everything also means there is no list to maintain: adding a
# chart enrols it.
#
# HOW THE YAML IS PARSED
#
# By Helm, through ci/yaml2json, exactly as ci/run-selector-match-test.sh does
# it - the runners have neither pyyaml nor yq, and a parser written in awk
# would be able to read nothing, find no route and report no finding, leaving
# this test green for a chart it never looked at. The only textual step is the
# document split on a line that is exactly "---" in column one, and getting
# that wrong is not a false pass: the fragment stops being valid YAML and helm
# exits 1. See that script's header for the long form.
#
# WHAT IS DELIBERATELY NOT CHECKED, so the gaps are not mistaken for coverage
#
# - THAT TRAFFIC FLOWS. No Gateway controller runs in CI and none will; a
# route's status is written by the implementation, so "Accepted: True" is
# not obtainable here. What is obtainable: the object is schema-valid
# (install-test, against the real CRDs) and it names a Service and a port
# that exist (this test). Routing itself is unproven, by construction.
# - MUTUAL EXCLUSION with the Ingress. Every chart aborts its own render when
# ingress.enabled and httpRoute.enabled are both true, which is where that
# check belongs: this test renders ONE configuration per chart, and a
# render-level rule here would have to assume the switch is chart-global.
# That holds for the pilot and may not hold for the multi-app charts of the
# sweep (matrix-synapse renders in a loop, keycloak and bulwark have two
# Ingresses each), so the rule is not written down here where it would have
# to be undone.
# - CROSS-NAMESPACE backendRefs and non-Service backends. A backendRef into
# another namespace needs a ReferenceGrant on the other side and its target
# is by definition not in this render; a backend that is not a Service is
# some implementation's own kind. Both are REPORTED as "not checked" and
# are never counted as passing, because a green tick for a code path nobody
# entered is worse than an honest gap (README, "Two rules for every check
# added here").
# - A RULE WITHOUT backendRefs. Legal - a rule carrying only a
# RequestRedirect filter needs no backend - so it is reported, not failed.
# The redirect rule the prefix check below demands is exactly such a rule,
# so a chart that satisfies that check also earns one "not checked" line
# here. Both are correct: the pairing is an assertion, the missing backend
# is an honest gap.
# - THAT ANY REDIRECT IS EVER SERVED. The prefix/redirect pairing proves the
# rendered OBJECT carries a RequestRedirect rule on the bare prefix. It
# does not prove that a 301 ever reaches a client, and it cannot: no
# Gateway controller runs in CI, nothing evaluates matching precedence,
# and no route reaches "Accepted: True" - so whether the implementation
# honours the statusCode, or declines it and fails the whole route, is
# outside what this test can see. What is proven is that the rule exists,
# that it matches the right path, and that the chart did not silently stop
# rendering it.
#
# AND THE ANTI-VACUUM GUARDS, because this test can be green over nothing. The
# convention ships with httpRoute.enabled: false, so a chart renders no route
# at all unless its CI values or an overlay switch the mode on. Two rules:
#
# 1. ci/httproute/<chart>.yaml is an overlay applied last, whose only job is
# to put a chart into Gateway mode for this test. A chart that HAS one and
# still renders no HTTPRoute FAILS - the overlay exists for exactly that.
# 2. If no HTTPRoute at all was checked across the whole run, the run fails.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="$ROOT/ci"
YAML2JSON="$CI_DIR/yaml2json"
OVERLAY_DIR="$CI_DIR/httproute"
RELEASE="httproute"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# --- Which charts -----------------------------------------------------------
CHARTS=()
if [ "$#" -gt 0 ]; then
CHARTS=("$@")
else
for DIR in "$ROOT"/*/; do
[ -f "$DIR/Chart.yaml" ] || continue
CHARTS+=("$(basename "$DIR")")
done
fi
if [ "${#CHARTS[@]}" -eq 0 ]; then
echo "ERROR: no chart directories found under $ROOT" >&2
exit 1
fi
FAILED=""
CHECKED=""
NOT_EXERCISED=""
WITH_GAPS=""
TOTAL_ASSERTIONS=0
SUMMARY_ROWS=""
for CHART in "${CHARTS[@]}"; do
if [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
echo "ERROR: '$CHART' is not a chart directory" >&2
exit 1
fi
# Same reason as in the other two render tests: a chart with a declared
# dependency cannot be rendered at all while charts/ is empty, and this job
# runs over EVERY chart on EVERY pull request - so without this line a PR
# touching nothing but a README would go red on the charts that have one.
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
# Values: the same precedence the other render tests use, so a chart's CI
# values are maintained in one place and satisfy its required() values.
VALUES_ARGS=()
VALUES_LABEL="chart defaults"
if [ -f "$CI_DIR/$CHART/test-values.yaml" ]; then
VALUES_ARGS=(-f "$CI_DIR/$CHART/test-values.yaml")
VALUES_LABEL="ci/$CHART/test-values.yaml"
elif [ -f "$CI_DIR/trivy/values/$CHART.yaml" ]; then
VALUES_ARGS=(-f "$CI_DIR/trivy/values/$CHART.yaml")
VALUES_LABEL="ci/trivy/values/$CHART.yaml"
fi
HAS_OVERLAY=""
if [ -f "$OVERLAY_DIR/$CHART.yaml" ]; then
# Last, so it wins over the install-test values.
VALUES_ARGS+=(-f "$OVERLAY_DIR/$CHART.yaml")
VALUES_LABEL="$VALUES_LABEL + ci/httproute/$CHART.yaml"
HAS_OVERLAY=1
fi
echo "::group::$CHART ($VALUES_LABEL)"
RENDER="$WORK/$CHART.manifest.yaml"
if ! helm template "$RELEASE" "$ROOT/$CHART" \
${VALUES_ARGS[@]+"${VALUES_ARGS[@]}"} >"$RENDER" 2>"$WORK/err"; then
sed 's/^/ /' "$WORK/err"
echo "::endgroup::"
echo "::error title=httproute $CHART::$CHART does not render with its CI values, so no HTTPRoute could be checked. Reproduce: helm template $RELEASE $CHART $(printf '%s ' "${VALUES_ARGS[@]+${VALUES_ARGS[@]}}")"
FAILED="$FAILED $CHART"
continue
fi
# --- The document split, and the only textual step in this script ----------
# One YAML sequence item per rendered document, content indented by four and
# the item marker on a line of its own - so a document whose first line is a
# comment ("# Source: ...") needs no special case. A wrong split does not
# produce a false pass: the result stops being parsable and helm exits 1.
DOCS="$WORK/$CHART.docs.yaml"
awk '
BEGIN { print "docs:"; started = 0 }
/^---[ \t]*$/ { print " -"; started = 1; next }
{
if (!started) { print " -"; started = 1 }
if (length($0) == 0) print ""; else print " " $0
}
' "$RENDER" >"$DOCS"
JSON="$WORK/$CHART.json"
if ! helm template y2j "$YAML2JSON" -f "$DOCS" 2>"$WORK/err" \
| awk '/^\{/ { print; exit }' >"$JSON"; then
sed 's/^/ /' "$WORK/err"
echo "::endgroup::"
echo "::error title=httproute $CHART::the rendered manifests of $CHART could not be re-read as YAML by ci/yaml2json. That is a parse failure, not an HTTPRoute finding: either the render contains a document this script's '---' split cut in the wrong place, or the manifest is not valid YAML. Helm's own message is in the job log."
FAILED="$FAILED $CHART"
continue
fi
if [ ! -s "$JSON" ]; then
echo "::endgroup::"
echo "::error title=httproute $CHART::ci/yaml2json produced no JSON for $CHART - the manifests were not re-read, so nothing was checked."
FAILED="$FAILED $CHART"
continue
fi
# --- The comparison --------------------------------------------------------
# Inline and on the standard library only, as in the two sibling tests. It
# writes one "VERDICT|detail" line per assertion and never decides the exit
# code itself: the loop below does, so a verdict can never be lost to a
# swallowed status.
set +e
python3 - "$CHART" "$JSON" >"$WORK/$CHART.out" 2>"$WORK/$CHART.pyerr" <<'PY'
import json, sys
chart, path = sys.argv[1], sys.argv[2]
docs = json.load(open(path)).get("docs") or []
docs = [d for d in docs if isinstance(d, dict)]
def name_of(doc):
meta = doc.get("metadata")
if isinstance(meta, dict) and meta.get("name"):
return str(meta["name"])
return "<unnamed %s>" % doc.get("kind", "object")
# Every Service of the render, as name -> the set of port NUMBERS it exposes.
# Port numbers and not names on purpose: an HTTPRoute backendRef carries a
# PortNumber and cannot reference a port by name, which is exactly where it
# differs from the Ingress backend this convention replaces - and exactly why
# a chart has to keep the number in the route in step with its Service by
# hand.
services = {}
for doc in docs:
if doc.get("kind") != "Service":
continue
ports = set()
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
for port in (spec.get("ports") or []):
if isinstance(port, dict) and port.get("port") is not None:
ports.add(str(port["port"]))
services[name_of(doc)] = ports
routes = [d for d in docs if d.get("kind") == "HTTPRoute"]
lines = []
for doc in routes:
route = "HTTPRoute %s" % name_of(doc)
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
# 1. parentRefs must be a non-empty list. The whole point: a route without
# a parent is valid, created, and inert.
parents = spec.get("parentRefs")
if not isinstance(parents, list) or not parents:
lines.append("FAIL|%s: %s has %s parentRefs. A route without a parent "
"attaches to NO Gateway: the object is created, it is "
"schema-valid, no controller rejects it and it routes "
"nothing - no event, no condition, the only symptom is a "
"hostname that does not answer. The chart must abort its "
"own render on an empty httpRoute.parentRefs"
% (chart, route,
"no" if parents is None else "an empty list of"))
else:
bad = [p for p in parents
if not isinstance(p, dict) or not str(p.get("name") or "").strip()]
if bad:
lines.append("FAIL|%s: %s has %d parentRef(s) without a usable "
"'name' (%r). ParentReference.name is required by the "
"API; a reference without it attaches to nothing"
% (chart, route, len(bad), bad))
else:
lines.append("OK|%s: %s has %d parentRef(s): %s"
% (chart, route, len(parents),
", ".join("%s%s" % (p["name"],
"/%s" % p["namespace"] if p.get("namespace") else "")
for p in parents)))
# 2. Every backendRefs entry, against the Services of the SAME render.
rules = spec.get("rules")
if not isinstance(rules, list) or not rules:
lines.append("FAIL|%s: %s renders no rules at all, so it matches no "
"request and forwards nothing" % (chart, route))
continue
for i, rule in enumerate(rules):
tag = "%s rules[%d]" % (route, i)
if not isinstance(rule, dict):
lines.append("FAIL|%s: %s is not a mapping (%r)" % (chart, tag, rule))
continue
backends = rule.get("backendRefs")
if not backends:
# Legal: a rule carrying only a RequestRedirect or a
# RequestMirror filter needs no backend. Reported, never counted.
lines.append("SKIP|%s: %s has no backendRefs. That is legal for a "
"filter-only rule (a redirect, for instance) and is "
"not evaluated here - but a rule meant to forward "
"traffic and carrying no backend answers 500 by "
"specification" % (chart, tag))
continue
if not isinstance(backends, list):
lines.append("FAIL|%s: %s backendRefs is not a list (%r)"
% (chart, tag, backends))
continue
for j, backend in enumerate(backends):
where = "%s.backendRefs[%d]" % (tag, j)
if not isinstance(backend, dict):
lines.append("FAIL|%s: %s is not a mapping (%r)"
% (chart, where, backend))
continue
# kind and group default to Service and the core group.
kind = str(backend.get("kind") or "Service")
group = str(backend.get("group") or "")
if kind != "Service" or group not in ("", "core"):
lines.append("SKIP|%s: %s references %s.%s, which is not a core "
"Service - some implementation's own backend kind. "
"This test cannot resolve it and does not count it "
"as passing" % (chart, where, group or "core", kind))
continue
namespace = backend.get("namespace")
if namespace:
lines.append("SKIP|%s: %s references Service %s in namespace %s. A "
"cross-namespace backend needs a ReferenceGrant on "
"the other side and its target is by definition not "
"in this render, so it is reported rather than "
"checked" % (chart, where, backend.get("name"), namespace))
continue
target = str(backend.get("name") or "")
if not target:
lines.append("FAIL|%s: %s has no 'name' - it references no "
"backend at all" % (chart, where))
continue
if target not in services:
lines.append("FAIL|%s: %s references Service %r, which the SAME "
"render does not create. A backendRef is resolved "
"by name at runtime and validated by nothing: the "
"route is accepted, ResolvedRefs goes False in a "
"status no controller writes in CI, and the "
"hostname answers with the Gateway's error instead "
"of the app. Services this render does create: %s"
% (chart, where, target,
", ".join(sorted(services)) or "none at all"))
continue
port = backend.get("port")
if port is None:
lines.append("FAIL|%s: %s references Service %r without a port. "
"A backendRef to a Service must carry one - the "
"CRD schema cannot require it conditionally, so an "
"omitted port is accepted at apply time and "
"resolves to nothing. The Service exposes: %s"
% (chart, where, target,
", ".join(sorted(services[target])) or "no ports"))
continue
if str(port) not in services[target]:
lines.append("FAIL|%s: %s references Service %r on port %s, which "
"that Service does not expose (it serves %s). "
"backendRefs takes a port NUMBER and cannot name a "
"port, so this pair is kept in step by hand and "
"drifts the moment one side is edited alone"
% (chart, where, target, port,
", ".join(sorted(services[target])) or "no ports"))
continue
lines.append("OK|%s: %s resolves to Service %s:%s, created by the same "
"render" % (chart, where, target, port))
# 3. A stripped prefix needs the BARE prefix redirected, in THIS route
# (#118). Collected over all rules of this one object first, because the
# pairing is a property of the route and not of a rule: the Gateway API
# ranks an Exact path match above every prefix match, so the redirect
# rule may sit anywhere in the list and still win.
stripped = {} # prefix -> index of the rule that strips it
redirected = {} # path -> index of the rule that redirects it exactly
for i, rule in enumerate(rules):
if not isinstance(rule, dict):
continue
filter_types = set()
strips_prefix = False
filters = rule.get("filters")
for f in (filters if isinstance(filters, list) else []):
if not isinstance(f, dict):
continue
ftype = str(f.get("type") or "")
filter_types.add(ftype)
if ftype != "URLRewrite":
continue
rewrite = f.get("urlRewrite")
rewrite = rewrite if isinstance(rewrite, dict) else {}
rpath = rewrite.get("path")
rpath = rpath if isinstance(rpath, dict) else {}
if str(rpath.get("type") or "") == "ReplacePrefixMatch":
strips_prefix = True
matches = rule.get("matches")
for m in (matches if isinstance(matches, list) else []):
if not isinstance(m, dict):
continue
mpath = m.get("path")
mpath = mpath if isinstance(mpath, dict) else {}
# The API's own defaults for HTTPPathMatch.
ptype = str(mpath.get("type") or "PathPrefix")
value = str(mpath.get("value") or "/")
if strips_prefix and ptype == "PathPrefix" and value != "/":
stripped.setdefault(value, i)
if ptype == "Exact" and "RequestRedirect" in filter_types:
redirected.setdefault(value, i)
for prefix in sorted(stripped):
if prefix in redirected:
lines.append("OK|%s: %s strips the prefix %r (rules[%d]) and "
"redirects the bare, slashless %r to the slashed form "
"(rules[%d]) - so the prefixless entry point cannot be "
"answered under a URL whose relative links leave the app"
% (chart, route, prefix, stripped[prefix], prefix,
redirected[prefix]))
else:
lines.append("FAIL|%s: %s rules[%d] strips the prefix %r with a "
"ReplacePrefixMatch rewrite, but NO rule of this route "
"redirects the bare prefix: there is no match of type "
"Exact on %r carrying a RequestRedirect filter. A "
"request for %r WITHOUT the trailing slash is then "
"rewritten to \"/\" and the app answers it 200, so "
"nothing anywhere reports a problem - but the browser's "
"document URL stays prefixless, every relative link in "
"that page resolves against the host ROOT, and on a "
"shared host the root is a DIFFERENT application, which "
"then answers the app's own assets and XHRs. The fix is "
"a rule matching %r exactly with a RequestRedirect to "
"'%s/' and statusCode 301 written out (302 is the API's "
"default, and 308 is Extended - an implementation that "
"declines it sets Accepted=False on the WHOLE route, "
"taking every other app on it down). Exact outranks "
"every prefix match, so where the rule sits in the list "
"does not matter. Gitea #118"
% (chart, route, stripped[prefix], prefix, prefix,
prefix, prefix, prefix))
for line in lines:
print(line)
PY
RC=$?
set -e
if [ "$RC" -gt 1 ]; then
sed 's/^/ /' "$WORK/$CHART.pyerr"
echo "::endgroup::"
echo "::error title=httproute $CHART::the HTTPRoute comparison itself failed for $CHART (python exit $RC). Treated as a failure, never as a pass."
FAILED="$FAILED $CHART"
continue
fi
CHART_FAIL=""
CHART_OK=0
CHART_SKIP=0
while IFS='|' read -r VERDICT DETAIL; do
case "$VERDICT" in
OK)
CHART_OK=$((CHART_OK + 1))
echo " ok $DETAIL"
;;
SKIP)
CHART_SKIP=$((CHART_SKIP + 1))
echo " gap $DETAIL"
;;
FAIL)
CHART_FAIL=1
echo " FAIL $DETAIL"
echo "::error title=httproute $CHART::$DETAIL"
;;
*)
[ -n "$VERDICT" ] && echo " $VERDICT $DETAIL"
;;
esac
done <"$WORK/$CHART.out"
echo "::endgroup::"
TOTAL_ASSERTIONS=$((TOTAL_ASSERTIONS + CHART_OK))
if [ -n "$CHART_FAIL" ]; then
FAILED="$FAILED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | $CHART_OK | $CHART_SKIP |"$'\n'
echo "$CHART: FAILED"
elif [ "$CHART_OK" -gt 0 ]; then
CHECKED="$CHECKED $CHART"
[ "$CHART_SKIP" -gt 0 ] && WITH_GAPS="$WITH_GAPS $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | ok | $CHART_OK | $CHART_SKIP |"$'\n'
echo "$CHART: ok ($CHART_OK assertion(s) held, $CHART_SKIP not checked)"
elif [ -n "$HAS_OVERLAY" ]; then
# The overlay's ONE job is to put this chart into Gateway mode. If no
# HTTPRoute came out, this test is vacuum-green for the chart the overlay
# was written for, which is the failure the overlay prevents.
FAILED="$FAILED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | 0 | $CHART_SKIP |"$'\n'
echo "::error title=httproute $CHART::$CHART has an overlay at ci/httproute/$CHART.yaml but rendered no HTTPRoute, so nothing was checked. That overlay exists only to switch the chart into Gateway mode - either it no longer does (a renamed value, a changed gate) or the chart stopped rendering a route. A green tick here would be a tick for a check that ran over nothing."
echo "$CHART: FAILED (overlay present, no HTTPRoute rendered)"
else
NOT_EXERCISED="$NOT_EXERCISED $CHART"
[ "$CHART_SKIP" -gt 0 ] && WITH_GAPS="$WITH_GAPS $CHART"
echo "$CHART: no HTTPRoute rendered - nothing to check."
fi
done
# --- Report ------------------------------------------------------------------
count() { set -- $1; echo "$#"; }
listing() { set -- $1; [ "$#" -eq 0 ] && { echo "–"; return; }; printf '`%s`' "$1"; shift; for C in "$@"; do printf ', `%s`' "$C"; done; echo; }
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
echo "### HTTPRoute render test (Gitea #114)"
echo
echo "Every \`HTTPRoute\` a chart renders, held against the \`Service\` objects of the"
echo "**same** render. An empty \`parentRefs\` attaches the route to no Gateway, and a"
echo "\`backendRefs\` entry naming a Service or a port that does not exist resolves to"
echo "nothing — both are schema-valid, so neither the real CRDs in the install-test nor"
echo "any render error catches them, and the only report would be a route status that no"
echo "controller writes in CI. A third finding is not about pointing nowhere but about"
echo "pointing somewhere wrong: a rule that strips a prefix with \`ReplacePrefixMatch\`"
echo "while the same route carries no \`Exact\` match on that prefix with a"
echo "\`RequestRedirect\` filter leaves the slashless form answered 200 under a URL whose"
echo "relative links resolve to the host root — a neighbouring app (Gitea #118)."
echo
echo "| Result | Count | Charts |"
echo "|---|---|---|"
echo "| HTTPRoutes checked | $(count "$CHECKED") | $(listing "$CHECKED") |"
echo "| No HTTPRoute rendered | $(count "$NOT_EXERCISED") | $(listing "$NOT_EXERCISED") |"
echo "| Failed | $(count "$FAILED") | $(listing "$FAILED") |"
echo
echo "$TOTAL_ASSERTIONS assertion(s) held in total."
echo
echo "**Not provable here, by construction:** that traffic flows. No Gateway controller"
echo "runs in CI, so no route ever reaches \`Accepted: True\`."
if [ -n "$WITH_GAPS" ]; then
echo
echo "Charts with references this test does **not** resolve (cross-namespace or non-Service backends, filter-only rules — reported, never counted as passing): $(listing "$WITH_GAPS")."
fi
if [ -n "$SUMMARY_ROWS" ]; then
echo
echo "| Chart | Result | Checked | Not checked |"
echo "| --- | --- | --- | --- |"
printf '%s' "$SUMMARY_ROWS"
fi
} >>"$SUMMARY"
echo "====================== httproute render test ======================"
echo "checked: $(count "$CHECKED")${CHECKED:+ -$CHECKED}"
echo "not exercised: $(count "$NOT_EXERCISED")${NOT_EXERCISED:+ -$NOT_EXERCISED}"
echo "failed: $(count "$FAILED")${FAILED:+ -$FAILED}"
echo "assertions held: $TOTAL_ASSERTIONS"
echo "=================================================================="
if [ -n "$FAILED" ]; then
echo "ERROR: HTTPRoute findings in:$FAILED" >&2
exit 1
fi
# The second anti-vacuum guard. Reaching zero means every chart fell into "no
# HTTPRoute rendered", which is indistinguishable from a broken harness - and
# it is the state this test would silently decay into if the overlays were ever
# dropped, or if the convention were reverted in the charts without anyone
# removing this job.
if [ "$TOTAL_ASSERTIONS" -eq 0 ]; then
echo "ERROR: not a single HTTPRoute assertion was checked in this run. Either no chart renders an HTTPRoute any more, or the overlays under ci/httproute/ no longer switch one into Gateway mode. A pass over zero assertions is not a pass." >&2
exit 1
fi
+24
View File
@@ -8,6 +8,8 @@
#
# Layout (repo root):
# ci/common/ fixtures applied for every chart (OnePasswordItem stub CRD)
# ci/gateway-api-crds.sh the REAL Gateway-API CRDs, applied only for a
# chart whose render contains an HTTPRoute
# ci/<chart>/test-values.yaml values for the CI install (required)
# ci/<chart>/fixtures.yaml dummy secrets / throwaway infra / static PVs (optional)
# ci/<chart>/fixtures.env.yaml fixtures with ${VARS} filled in from the environment (optional)
@@ -199,6 +201,28 @@ echo "::endgroup::"
# 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"
# --- 3a. Gateway-API CRDs, for a chart whose CI values render a route --------
# Detected from the render rather than declared per chart in settings.env, for
# the same reason the render tests run over every chart: there is then no list
# to maintain, and the sweep of Gitea #114 enrols a chart by switching its
# fixture into Gateway mode. The CRDs are REAL and not a stub - with a
# schema-less stub the API server would accept a malformed HTTPRoute and the
# install-test would prove only that something was created; see
# ci/gateway-api-crds.sh for the whole argument, and for why they are not in
# ci/common/ (which is applied client-side for every chart).
#
# A grep on the rendered kind, and a false negative here is loud rather than
# silent: without the CRDs the helm install below fails with "no matches for
# kind HTTPRoute in version gateway.networking.k8s.io/v1", which names exactly
# what is missing.
if helm template "$RELEASE" "$ROOT/$CHART" -f "$CI_DIR/$CHART/test-values.yaml" \
| grep -q '^kind: HTTPRoute$'; then
echo "::group::Gateway API CRDs"
"$ROOT/ci/gateway-api-crds.sh"
echo "::endgroup::"
fi
echo "::group::helm install"
helm install "$RELEASE" "$ROOT/$CHART" \
--namespace "$NAMESPACE" \
+65 -4
View File
@@ -52,10 +52,22 @@
#
# WHAT IS DELIBERATELY NOT CHECKED
#
# - podAffinity / podAntiAffinity. They carry labelSelectors too, but their
# semantics are "relative to pods that exist", and an anti-affinity term
# matching nothing is a legitimate configuration rather than a mistake.
# Out of scope, and named so that the gap is not mistaken for coverage.
# - preferred podAffinity and podAntiAffinity of either strength. They carry
# labelSelectors too, but their semantics are "relative to pods that exist",
# and a term matching nothing is a legitimate configuration rather than a
# mistake: a preference that cannot be met is dropped and an anti-affinity
# over zero pods is satisfied. Out of scope, and named so that the gap is
# not mistaken for coverage.
#
# requiredDuringSchedulingIgnoredDuringExecution podAffinity is NOT in that
# gap and is checked (Gitea #101). Its failure is not a preference quietly
# dropped: a required term whose selector matches no pod in the release can
# never be satisfied, so the pod stays Pending forever. For the workload
# that introduced one here - paperless-ngx' consume CronJob, pinned onto the
# node of its StatefulSet pod so that Longhorn will mount the shared RWX
# volume - that would mean a mover that silently stops moving, with one
# Pending pod per schedule and a green render behind it. The term names a
# label written in ANOTHER template, which is the copy this test holds.
# - selectors expressed ONLY through matchExpressions. Evaluating a set-based
# requirement means reimplementing label-selector semantics, which is a
# second parser by another name. Those are REPORTED as "not checked" and
@@ -343,6 +355,55 @@ for doc in pdbs:
", ".join("%s=%s" % kv for kv in sorted(match_labels.items())),
len(all_templates), "; ".join(known) or "none"))
# 3. Every REQUIRED podAffinity term, against ALL pod templates of the render
# (Gitea #101). Not against its own template: the whole point of such a term
# is to select a DIFFERENT workload's pod, so the correct question is whether
# anything in this release carries the labels it asks for. Preferred terms and
# anti-affinity of either strength are left alone - see the header.
for where, template in all_templates:
affinity = template.get("spec", {}).get("affinity")
if not isinstance(affinity, dict):
continue
pod_affinity = affinity.get("podAffinity")
if not isinstance(pod_affinity, dict):
continue
terms = pod_affinity.get("requiredDuringSchedulingIgnoredDuringExecution")
if not isinstance(terms, list):
continue
for i, term in enumerate(terms):
if not isinstance(term, dict):
lines.append("FAIL|%s: required podAffinity[%d] at %s is not a mapping"
% (chart, i, where))
continue
tag = "required podAffinity[%d] on %s at %s" % (
i, term.get("topologyKey", "<no topologyKey>"), where)
match_labels, reason = classify(term.get("labelSelector"), tag)
if match_labels is None:
if term.get("labelSelector") is None:
lines.append("FAIL|%s: %s" % (chart, reason))
else:
lines.append("SKIP|%s: %s" % (chart, reason))
continue
hits = [w for w, t in all_templates if matches(match_labels, labels_of(t))]
if hits:
lines.append("OK|%s: %s matches %d pod template(s): %s"
% (chart, tag, len(hits), ", ".join(hits)))
else:
known = sorted({"%s=%s" % kv for _, t in all_templates for kv in labels_of(t).items()})
lines.append("FAIL|%s: %s selects %s, which matches NONE of the %d "
"pod template(s) in the same render. A REQUIRED "
"podAffinity term that matches no pod can never be "
"satisfied, so the pod it is attached to stays Pending "
"forever - for a CronJob that means one Pending pod per "
"schedule and a workload that silently stopped running, "
"with nothing in the render to show for it. The usual "
"cause is a label renamed in the template that carries "
"the selected pod. Labels actually present on the pod "
"templates: %s"
% (chart, tag,
", ".join("%s=%s" % kv for kv in sorted(match_labels.items())),
len(all_templates), "; ".join(known) or "none"))
for line in lines:
print(line)
PY
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env bash
# YAML-scalar render test: an operator value that must arrive in the manifest as
# a STRING is held against the manifest it actually produced.
#
# Usage: ci/run-yaml-scalar-render-test.sh [chart ...] (no argument = all charts)
#
# WHAT IT GUARDS AGAINST (Gitea #11, and #112 turned out to be the same genus)
#
# A value is rendered into a YAML scalar without `| quote`. The operator quotes
# it correctly in their values file, Helm passes the string's CONTENT through,
# and the quotes are gone again in the manifest. Two outcomes, and the quiet one
# is the worse one:
#
# 1. LOUD, but misdirected - the content is not a legal plain scalar and the
# render aborts. Measured on paperless-ngx' consume CronJob, whose schedule
# was unquoted: a schedule in the commonest cron notation of all (a star, a
# slash, an interval) made `helm template` exit 1 with
#
# error converting YAML to JSON: yaml: line 6: did not find expected
# alphabetic or numeric character
#
# YAML read the leading "*" as an alias indicator. The message names the
# TEMPLATE and the operator's own value is nowhere in it, so the search
# starts in the wrong place. "@daily" fails the same way.
# 2. SILENT until the API server - the content IS a legal plain scalar, just
# not a string. "true" becomes a bool, "0755" becomes an integer, and the
# chart renders perfectly. Kubernetes then rejects the object with
# "cannot unmarshal number into Go struct field EnvVar.value of type
# string" - at apply time, which for this repository means in the
# install-test or in a cluster, never in the render.
#
# Neither is caught by "does helm template exit 0" alone: the first one is, but
# only if something actually renders the object, and the second one is not
# caught at all.
#
# WHAT IS ASSERTED, and all three are properties Kubernetes itself requires -
# there is no chart for which the opposite is a legitimate configuration, which
# is why this needs no per-chart expectations:
#
# 1. CronJob .spec.schedule is a non-empty string.
# 2. Every env[].value of every container and initContainer is a string.
# 3. Every metadata.labels and metadata.annotations value is a string
# (the API types both as map[string]string).
#
# WHY OVER ALL CHARTS, not the changed ones: the same reason as
# ci/run-quantity-render-test.sh and ci/run-selector-match-test.sh. The env
# block is copied into every chart rather than shared, so the unquoted form
# travels by copy into whatever chart is started from an older one - and that
# chart's own pull request is not where anyone would look for it. Running over
# everything also means there is no list to maintain: adding a chart enrols it.
#
# THE OVERLAYS, AND WHY THIS TEST IS USELESS WITHOUT THEM
#
# The defect above survived in a shipped chart for months for one reason: the
# object that carried it is rendered by NO fixture. paperless-ngx' CronJob sits
# behind smbConnection.enabled, which is false in the chart defaults and in
# every committed fixture, so neither lint, nor the render tests, nor the
# install-test ever produced it. A test that renders only what the fixtures
# already render would have been just as blind.
#
# So ci/yaml-scalar/<chart>.yaml is an overlay applied LAST whose job is to
# switch such objects on AND to feed them the hostile values - a schedule
# beginning with a star, an env value that is a bare "true". Two rules follow,
# the same ones ci/run-selector-match-test.sh works under:
#
# 1. A chart that HAS an overlay and still renders none of the three things
# above FAILS. The overlay exists for exactly that, so an overlay that
# stopped reaching its object is a finding and not a shrug.
# 2. If nothing at all was asserted across the whole run, the run fails. A
# pass over zero assertions is not a pass.
#
# WHAT IS DELIBERATELY NOT CHECKED, so the gap is not mistaken for coverage:
#
# - Values rendered into a scalar that is hand-quoted in the template
# ("{{ .Values.x }}"). Those are strings in the output, so this test is
# blind to them by construction; what they can still do is break the YAML
# when the value itself contains a quote or a backslash, and only `| quote`
# fixes that. Reported here rather than silently implied.
# - Everything that is not a label, an annotation, an env value or a schedule.
# A bare `storage:` or `className:` is just as unquoted, but its value space
# is DNS labels and quantities, where no legal value is YAML-ambiguous. The
# three above are where operator free text actually lands.
# - Whether a cron schedule is a VALID schedule. That is the cron
# controller's judgement, and narrowing the schema to a regex was considered
# and rejected: the defect was never a wrong schedule, it was a correct one
# rendered bare.
#
# How the YAML is parsed: with helm, via ci/yaml2json, exactly as in
# ci/run-selector-match-test.sh - the runners have neither pyyaml nor yq, and a
# YAML reader written in awk here would fail by quietly reading nothing and
# staying green. That script's header carries the full argument.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CI_DIR="$ROOT/ci"
YAML2JSON="$CI_DIR/yaml2json"
OVERLAY_DIR="$CI_DIR/yaml-scalar"
RELEASE="scalar"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# --- Which charts -----------------------------------------------------------
CHARTS=()
if [ "$#" -gt 0 ]; then
CHARTS=("$@")
else
for DIR in "$ROOT"/*/; do
[ -f "$DIR/Chart.yaml" ] || continue
CHARTS+=("$(basename "$DIR")")
done
fi
if [ "${#CHARTS[@]}" -eq 0 ]; then
echo "ERROR: no chart directories found under $ROOT" >&2
exit 1
fi
FAILED=""
CHECKED=""
NOT_EXERCISED=""
TOTAL_ASSERTIONS=0
SUMMARY_ROWS=""
for CHART in "${CHARTS[@]}"; do
if [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then
echo "ERROR: '$CHART' is not a chart directory" >&2
exit 1
fi
# A chart with a declared dependency cannot be rendered at all while charts/
# is empty, and this job runs over EVERY chart on EVERY pull request.
"$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART"
VALUES_ARGS=()
VALUES_LABEL="chart defaults"
if [ -f "$CI_DIR/$CHART/test-values.yaml" ]; then
VALUES_ARGS=(-f "$CI_DIR/$CHART/test-values.yaml")
VALUES_LABEL="ci/$CHART/test-values.yaml"
elif [ -f "$CI_DIR/trivy/values/$CHART.yaml" ]; then
VALUES_ARGS=(-f "$CI_DIR/trivy/values/$CHART.yaml")
VALUES_LABEL="ci/trivy/values/$CHART.yaml"
fi
HAS_OVERLAY=""
if [ -f "$OVERLAY_DIR/$CHART.yaml" ]; then
# Last, so it wins over the install-test values.
VALUES_ARGS+=(-f "$OVERLAY_DIR/$CHART.yaml")
VALUES_LABEL="$VALUES_LABEL + ci/yaml-scalar/$CHART.yaml"
HAS_OVERLAY=1
fi
echo "::group::$CHART ($VALUES_LABEL)"
RENDER="$WORK/$CHART.manifest.yaml"
if ! helm template "$RELEASE" "$ROOT/$CHART" \
${VALUES_ARGS[@]+"${VALUES_ARGS[@]}"} >"$RENDER" 2>"$WORK/err"; then
sed 's/^/ /' "$WORK/err"
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::$CHART does not render with these values. If the error is a YAML parse error naming a template line, that IS the finding this test exists for: a value reached a scalar unquoted. Reproduce: helm template $RELEASE $CHART $(printf '%s ' "${VALUES_ARGS[@]+${VALUES_ARGS[@]}}")"
FAILED="$FAILED $CHART"
continue
fi
# The document split, the only textual step. A wrong split cannot produce a
# false pass: the result stops being parsable and helm exits 1.
DOCS="$WORK/$CHART.docs.yaml"
awk '
BEGIN { print "docs:"; started = 0 }
/^---[ \t]*$/ { print " -"; started = 1; next }
{
if (!started) { print " -"; started = 1 }
if (length($0) == 0) print ""; else print " " $0
}
' "$RENDER" >"$DOCS"
JSON="$WORK/$CHART.json"
if ! helm template y2j "$YAML2JSON" -f "$DOCS" 2>"$WORK/err" \
| awk '/^\{/ { print; exit }' >"$JSON"; then
sed 's/^/ /' "$WORK/err"
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::the rendered manifests of $CHART could not be re-read as YAML by ci/yaml2json. That is a parse failure, not a scalar finding."
FAILED="$FAILED $CHART"
continue
fi
if [ ! -s "$JSON" ]; then
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::ci/yaml2json produced no JSON for $CHART - the manifests were not re-read, so nothing was checked."
FAILED="$FAILED $CHART"
continue
fi
# --- The comparison --------------------------------------------------------
# Standard library only, and it never decides the exit code itself: it writes
# "VERDICT|detail" lines and the loop below decides, so a verdict can never
# be lost to a swallowed status.
set +e
python3 - "$CHART" "$JSON" >"$WORK/$CHART.out" 2>"$WORK/$CHART.pyerr" <<'PY'
import json, sys
chart, path = sys.argv[1], sys.argv[2]
docs = json.load(open(path)).get("docs") or []
lines = []
def name_of(doc):
meta = doc.get("metadata")
if isinstance(meta, dict) and meta.get("name"):
return str(meta["name"])
return "<unnamed %s>" % doc.get("kind", "object")
def typename(value):
return {bool: "bool", int: "int", float: "float", type(None): "null",
dict: "mapping", list: "list"}.get(type(value), type(value).__name__)
def pod_templates(node, path="$"):
"""Any value of a key named "template" carrying a spec with containers.
Found by walking, so a CronJob's spec.jobTemplate.spec.template needs no
special case and neither does whatever workload kind a chart adds next."""
out = []
if isinstance(node, dict):
for key, value in node.items():
here = "%s.%s" % (path, key)
if (key == "template" and isinstance(value, dict)
and isinstance(value.get("spec"), dict)
and "containers" in value["spec"]):
out.append((here, value))
out.extend(pod_templates(value, here))
elif isinstance(node, list):
for i, value in enumerate(node):
out.extend(pod_templates(value, "%s[%d]" % (path, i)))
return out
def metadatas(node, path="$"):
"""Every metadata mapping anywhere in the render - top level and inside pod
templates both, because labels and annotations exist at both levels."""
out = []
if isinstance(node, dict):
for key, value in node.items():
here = "%s.%s" % (path, key)
if key == "metadata" and isinstance(value, dict):
out.append((here, value))
out.extend(metadatas(value, here))
elif isinstance(node, list):
for i, value in enumerate(node):
out.extend(metadatas(value, "%s[%d]" % (path, i)))
return out
# --- 1. CronJob schedules ---------------------------------------------------
for doc in docs:
if not isinstance(doc, dict) or doc.get("kind") != "CronJob":
continue
where = "CronJob %s .spec.schedule" % name_of(doc)
spec = doc.get("spec") if isinstance(doc.get("spec"), dict) else {}
schedule = spec.get("schedule", "<absent>")
if isinstance(schedule, str) and schedule.strip():
lines.append("OK|%s: %s is the string %r" % (chart, where, schedule))
elif schedule == "<absent>":
lines.append("FAIL|%s: %s is missing entirely - a CronJob without a "
"schedule is rejected by the API server" % (chart, where))
else:
lines.append("FAIL|%s: %s rendered as %s (%r), not a string. A schedule "
"is operator free text and has to leave the template "
"through | quote; without it YAML re-reads the content and "
"a schedule beginning with a digit survives by luck while "
"one beginning with * or @ does not"
% (chart, where, typename(schedule), schedule))
# --- 2. env values ----------------------------------------------------------
for where, template in pod_templates({"docs": docs}):
spec = template.get("spec") if isinstance(template.get("spec"), dict) else {}
for kind in ("initContainers", "containers"):
for container in spec.get(kind) or []:
if not isinstance(container, dict):
continue
cname = container.get("name", "<unnamed>")
for entry in container.get("env") or []:
if not isinstance(entry, dict) or "value" not in entry:
continue # valueFrom entries carry no literal to check
value = entry["value"]
tag = "env %s of container %s at %s" % (
entry.get("name", "<unnamed>"), cname, where)
if isinstance(value, str):
lines.append("OK|%s: %s is a string" % (chart, tag))
else:
lines.append("FAIL|%s: %s rendered as %s (%r), not a string. "
"Kubernetes types env[].value as a string and "
"the API server rejects the whole manifest with "
"\"cannot unmarshal %s into Go struct field "
"EnvVar.value of type string\" - at apply time, "
"not in the render. The fix is | quote at the "
"render site; template-chart carries the form"
% (chart, tag, typename(value), value,
"number" if not isinstance(value, bool) else "bool"))
# --- 3. labels and annotations ----------------------------------------------
for where, meta in metadatas({"docs": docs}):
for field in ("labels", "annotations"):
block = meta.get(field)
if not isinstance(block, dict):
continue
for key, value in block.items():
tag = "%s[%r] at %s" % (field, key, where)
if isinstance(value, str):
lines.append("OK|%s: %s is a string" % (chart, tag))
else:
lines.append("FAIL|%s: %s rendered as %s (%r), not a string. "
"Kubernetes types %s as map[string]string, so the "
"API server rejects the object - the render stays "
"green. The fix is | quote at the render site"
% (chart, tag, typename(value), value, field))
for line in lines:
print(line)
PY
RC=$?
set -e
if [ "$RC" -gt 1 ]; then
sed 's/^/ /' "$WORK/$CHART.pyerr"
echo "::endgroup::"
echo "::error title=yaml-scalar $CHART::the scalar comparison itself failed for $CHART (python exit $RC). Treated as a failure, never as a pass."
FAILED="$FAILED $CHART"
continue
fi
CHART_FAIL=""
CHART_OK=0
while IFS='|' read -r VERDICT DETAIL; do
case "$VERDICT" in
OK)
CHART_OK=$((CHART_OK + 1))
echo " ok $DETAIL"
;;
FAIL)
CHART_FAIL=1
echo " FAIL $DETAIL"
echo "::error title=yaml-scalar $CHART::$DETAIL"
;;
*)
[ -n "$VERDICT" ] && echo " $VERDICT $DETAIL"
;;
esac
done <"$WORK/$CHART.out"
echo "::endgroup::"
TOTAL_ASSERTIONS=$((TOTAL_ASSERTIONS + CHART_OK))
if [ -n "$CHART_FAIL" ]; then
FAILED="$FAILED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | $CHART_OK |"$'\n'
echo "$CHART: FAILED"
elif [ "$CHART_OK" -gt 0 ]; then
CHECKED="$CHECKED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | ok | $CHART_OK |"$'\n'
echo "$CHART: ok ($CHART_OK scalar(s) checked)"
elif [ -n "$HAS_OVERLAY" ]; then
FAILED="$FAILED $CHART"
SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | 0 |"$'\n'
echo "::error title=yaml-scalar $CHART::$CHART has an overlay at ci/yaml-scalar/$CHART.yaml but its render carried no schedule, no env value and no label or annotation at all, so nothing was checked. That overlay exists only to make such a scalar render; a green tick here would be a tick for a check that ran over nothing."
echo "$CHART: FAILED (overlay present, nothing asserted)"
else
NOT_EXERCISED="$NOT_EXERCISED $CHART"
echo "$CHART: nothing of the three kinds rendered - nothing to check."
fi
done
# --- Report ------------------------------------------------------------------
count() { set -- $1; echo "$#"; }
listing() { set -- $1; [ "$#" -eq 0 ] && { echo "–"; return; }; printf '`%s`' "$1"; shift; for C in "$@"; do printf ', `%s`' "$C"; done; echo; }
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
{
echo "### YAML-scalar render test (Gitea #11)"
echo
echo "Every \`CronJob.spec.schedule\`, every \`env[].value\` and every label and"
echo "annotation value in a chart's render, held against the type Kubernetes requires"
echo "for it. A value rendered without \`| quote\` either aborts the render with an"
echo "error pointing at the template instead of at the value, or arrives as a bool or"
echo "an integer and is rejected by the API server at apply time - never in the render."
echo
echo "| Result | Count | Charts |"
echo "|---|---|---|"
echo "| Scalars checked | $(count "$CHECKED") | $(listing "$CHECKED") |"
echo "| Nothing of the kind rendered | $(count "$NOT_EXERCISED") | $(listing "$NOT_EXERCISED") |"
echo "| Failed | $(count "$FAILED") | $(listing "$FAILED") |"
echo
echo "$TOTAL_ASSERTIONS scalar(s) verified in total."
if [ -n "$SUMMARY_ROWS" ]; then
echo
echo "| Chart | Result | Checked |"
echo "| --- | --- | --- |"
printf '%s' "$SUMMARY_ROWS"
fi
} >>"$SUMMARY"
echo "===================== yaml scalar render test ======================"
echo "checked: $(count "$CHECKED")${CHECKED:+ -$CHECKED}"
echo "not exercised: $(count "$NOT_EXERCISED")${NOT_EXERCISED:+ -$NOT_EXERCISED}"
echo "failed: $(count "$FAILED")${FAILED:+ -$FAILED}"
echo "scalars verified: $TOTAL_ASSERTIONS"
echo "==================================================================="
if [ -n "$FAILED" ]; then
echo "ERROR: a value that has to arrive as a string did not, in:$FAILED" >&2
exit 1
fi
if [ "$TOTAL_ASSERTIONS" -eq 0 ]; then
echo "ERROR: not a single scalar was checked in this run. Either no chart renders a schedule, an env value, a label or an annotation any more, or the overlays under ci/yaml-scalar/ no longer reach their objects. A pass over zero assertions is not a pass." >&2
exit 1
fi
+25
View File
@@ -0,0 +1,25 @@
# Selector-match overlay for paperless-ngx (Gitea #101).
#
# Its one job: make the consume CronJob render, so that the required podAffinity
# it carries is actually held against the StatefulSet's pod labels. The template
# sits behind smbConnection.enabled, false in the chart defaults and in
# ci/paperless-ngx/test-values.yaml, so without this overlay the affinity term
# exists in the repository and in no render - and the test would pass over it.
#
# What it guards: the term selects the StatefulSet pod by a label written in
# ANOTHER template (paperless-server.sfs.yaml). Renaming that label leaves both
# files rendering perfectly while the mover can never be scheduled again, one
# Pending pod per schedule, nothing in the render to show for it.
#
# Kept separate from ci/yaml-scalar/paperless-ngx.yaml although both switch SMB
# on: that one feeds the CronJob a hostile SCHEDULE and hostile env values for a
# different test, and folding the two would make each test's failure depend on
# the other's fixture.
smbConnection:
enabled: true
server: "//smb.ci.local/scans"
secrets:
# Satisfies the required() behind the SMB PersistentVolume's
# nodeStageSecretRef. Nothing is mounted in a render.
smbConnectionSecretRef: op://ci/paperless-smb
+31
View File
@@ -83,6 +83,37 @@ ingress:
clusterIssuer: ci-selfsigned
nodeGroup: full
# MTA-STS policy routes (Gitea #123). Set HERE and not only in the render
# overlay, because this is the ONLY stage that puts the INGRESS half of the
# feature in front of a real API server: ci/httproute/stalwart.yaml switches
# the chart into Gateway mode, so it never renders these objects at all.
#
# WHAT THIS ADDS OVER THE RENDER TEST: that two Ingress objects whose names
# differ only by the issuer suffix are both accepted, that a `pathType: Exact`
# on `/.well-known/mta-sts.txt` is valid, and that a tls: list grouping several
# hosts under one secret is accepted as written. None of that is a template
# question any more once the API server has taken it.
#
# THE TWO ENTRIES ARE TWO OBJECTS, and that is the point of the pair: the first
# carries a clusterIssuer and gets the cert-manager annotation, the second has
# none and gets no annotation - which cannot be one object, because the
# annotation is object-scoped. The second is also a SUBDOMAIN, one label deeper
# than the first, carrying the case a parent wildcard certificate would not
# cover.
#
# NOTHING ISSUES EITHER CERTIFICATE here: cert-manager is not installed in the
# k3d cluster, so the annotation is inert and the named Secrets never exist.
# That is the same deliberate trade as certificate: below - the objects are
# asserted, the issuance is not, and no Secret is needed for the install to
# reach Ready because nothing mounts these.
mtaSts:
hosts:
- host: mta-sts.ci.example.com
tlsSecretName: tls-mta-sts-ci-example-com-ingress
clusterIssuer: ci-selfsigned
- host: mta-sts.sub.ci.example.com
tlsSecretName: tls-mta-sts-sub-ci-example-com-ingress
certificate:
# On, against a STUB CRD (fixtures.yaml). Nothing issues the Secret it names,
# which is the interesting case: the volume is mounted optional, so the pods
+53
View File
@@ -0,0 +1,53 @@
# YAML-scalar overlay for paperless-ngx (Gitea #11).
#
# THIS FILE IS THE TEST CASE the issue asked for, and its first job is to make
# an object exist at all. templates/paperless-consume.cronjob.yaml sits behind
# smbConnection.enabled, which is false in the chart defaults and false in
# ci/paperless-ngx/test-values.yaml - deliberately, because the install-test has
# no SMB CSI driver. So before this overlay NOTHING in the repository rendered
# that CronJob: not lint, not the render tests, not the install-test. That is
# the whole reason an unquoted schedule survived in a shipped chart, and it is
# why the fix comes with a render test rather than with a fixture change.
#
# The values below are hostile on purpose. Against the chart before 13.0.0 this
# overlay does not produce a finding - it produces an aborted render, which this
# test reports as the finding it is:
#
# Error: YAML parse error on paperless-ngx/templates/paperless-consume.cronjob.yaml:
# error converting YAML to JSON: yaml: line 6: did not find expected
# alphabetic or numeric character
smbConnection:
enabled: true
server: "//smb.ci.local/scans"
# A schedule beginning with "*" - the commonest cron notation there is, and
# the one that YAML reads as an alias indicator. Written here as a quoted
# string, which is exactly how an operator would write it and exactly what
# does NOT help: Helm passes the content through, and without | quote in the
# template the star stands bare in the manifest again.
cronJob: "*/15 * * * *"
secrets:
# The CronJob's SMB volume has a required() secret ref behind it; the
# PersistentVolume names it as nodeStageSecretRef. Nothing is mounted in a
# render, so no fixture Secret is needed - only a value that satisfies the
# required().
smbConnectionSecretRef: op://ci/paperless-smb
paperless:
# The silent half of the genus, on the chart's additive env block: both of
# these are STRINGS in this file and both are schema-valid, and before 13.0.0
# the first one aborted the render while the second arrived in the manifest as
# a YAML bool - which the API server rejects at apply time with "cannot
# unmarshal bool into Go struct field EnvVar.value of type string", so the
# render test is the only place it can be caught cheaply.
env:
- name: PAPERLESS_STAR_VALUE
value: "*never-interpreted*"
- name: PAPERLESS_BOOLEAN_LOOKING_VALUE
value: "true"
# The variable the whole env block was asked for (Gitea #112): the log off
# the NFS-backed RWX data volume and onto the /tmp emptyDir this pod already
# mounts. Harmless in a render, and it keeps the worked example honest by
# being the one an operator will actually copy.
- name: PAPERLESS_LOGGING_DIR
value: "/tmp/paperless-log"
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/gchq/CyberChef/master/src/web/static/ima
type: application
version: 9.0.2+up11.4.0
version: 9.1.0+up11.4.0
appVersion: "11.4.0"
maintainers:
+67 -10
View File
@@ -1,9 +1,14 @@
{{- $replicas := include "cyberchef.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.cyberchef.replicas) -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
CyberChef {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }}
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
@@ -13,31 +18,83 @@ ACCESS
This chart has no secrets and no volumes — the two ingress values above are
everything an installation needs.
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING else
of the routing path — no Gateway, no class, no Certificate. Three things
therefore belong to the cluster and not to this chart, and if any of them is
missing the hostname above simply does not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is
visible only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required —
the chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
on behalf of this release.
ingress.annotations is also inert in this mode: the Gateway API has no
annotation channel for controller behaviour. See the note below on who can
use this instance.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate;
ingress.domain and ingress.clusterIssuer are not needed. The app is only
reachable inside the cluster, as
{{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release has no
Ingress, no HTTPRoute and no certificate; ingress.domain and
ingress.clusterIssuer are not needed. The app is only reachable inside the
cluster, as {{ .Release.Name }}-{{ .Chart.Name }}-service:80. To open it locally:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
Then browse to http://localhost:8080. Set ingress.enabled=true together with
ingress.domain and ingress.clusterIssuer to publish it instead.
Then browse to http://localhost:8080. To publish it instead, set either
ingress.enabled=true with ingress.domain and ingress.clusterIssuer, or
httpRoute.enabled=true with ingress.domain and httpRoute.parentRefs.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}cyberchef.replicas: 0{{ end }} — the deployment runs with replicas: 0, so the endpoint
above does not answer until you set it back and upgrade again. Service{{ if .Values.ingress.enabled }} and
Ingress stay in place, so the certificate is not re-issued.{{ else }} stays in
Ingress stay in place, so the certificate is not re-issued.{{ else if $httpRoute.enabled }} and
HTTPRoute stay in place, so the route keeps pointing at a Service with no
endpoints — the Gateway answers with an error of its own rather than with
nothing.{{ else }} stays in
place.{{ end }}
{{ end }}
ANYONE WHO REACHES THE URL CAN USE IT
{{- if $httpRoute.enabled }}
CyberChef has no login, and this chart adds none. In GATEWAY MODE there is no
place in this chart to put one either: the Gateway API has no annotation
channel, so the ingress-nginx external-auth or basic-auth annotations that
cover this in Ingress mode have NO counterpart here, and the chart renders no
implementation-specific policy object on purpose (it would bind the chart to
one Gateway implementation). Protection has to come from the Gateway itself —
a listener that is not publicly reachable, or that implementation's own auth
filter/policy, configured next to the Gateway and not here.
{{- else }}
CyberChef has no login, and this chart adds none. If it must not be open to
everyone, put authentication in front of it via ingress.annotations — the
chart merges them into the Ingress, e.g. ingress-nginx external auth or
basic auth annotations.
basic auth annotations. Note that this escape hatch exists in Ingress mode
only: httpRoute.enabled has no annotation channel at all.
{{- end }}
NOTHING TO BACK UP, NOTHING TO MIGRATE
CyberChef is a static web app: recipes and inputs are processed in the
@@ -0,0 +1,82 @@
{{/*
The Gateway-API alternative to cyberchef.ingress.yaml (Gitea #114).
WHAT THIS RENDERS, AND WHAT IT DELIBERATELY DOES NOT
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object (nginx-gateway-
fabric's ClientSettingsPolicy, Envoy Gateway's ClientTrafficPolicy or
whatever the next one calls it). That subset is the part which looks IDENTICAL
on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio - and keeping to it is
what lets the cluster's implementation be chosen, or changed, without touching
this chart again. A policy object here would bind the chart to one
implementation and to a CRD whose presence it cannot check.
So the class and the certificate move from this chart to the PLATFORM: they sit
on the Gateway and its listeners, which belong in cluster-config, not here.
ingress.clusterIssuer is therefore meaningless in this mode - the chart renders
no Certificate at all - and it is not required either, because both reads of it
live inside cyberchef.ingress.yaml, behind ingress.enabled.
WHY A VALUE AND NOT AUTO-DETECTION
.Capabilities.APIVersions is unreliable at template time (it answers from the
cluster helm happens to be pointed at, and not at all for `helm template`),
while an HTTPRoute without the Gateway-API CRDs fails at INSTALL time. A chart
that guesses would therefore sometimes render an object the cluster rejects and
sometimes silently render nothing. So the mode is a value.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and all three defaults
here are empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
even though no Ingress is rendered in this mode - the value names the public
hostname of this release, which is what both modes need; it is not an Ingress
switch. Setting httpRoute.hostnames explicitly is what a migration over a
second hostname needs, and it then wins over ingress.domain.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
# backend Service - CyberChef is a static bundle served at the root and has
# no second path. The port is the Service's port NUMBER and not its name:
# backendRefs takes a PortNumber, so unlike the Ingress this reference
# cannot be made by name and the 80 below has to track
# cyberchef.service.yaml by hand. ci/run-httproute-match-test.sh is what
# holds that pair - it fails when a backendRef names a Service, or a port,
# the same render does not create.
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- end }}
+30 -1
View File
@@ -57,13 +57,42 @@
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.",
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": {
"$ref": "#/definitions/parentRef"
}
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth.",
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"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"],
+50 -3
View File
@@ -68,13 +68,60 @@ cyberchef:
ephemeralStorage: 10Mi
ingress:
# Set to false to omit the Ingress entirely (the required domain and
# clusterIssuer values are then not needed either, because they are only
# read inside the Ingress template).
# Set to false to omit the Ingress entirely (the required clusterIssuer value
# is then not needed either, because it is only read inside the Ingress
# template). Mutually exclusive with httpRoute.enabled below: with both true
# the render aborts, naming both values.
enabled: true
clusterIssuer: null
# The public hostname of this release. Read by the Ingress AND - when
# httpRoute.hostnames is empty - by the HTTPRoute, so it stays the single
# source of truth for the hostname in both modes.
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
#
# THESE HAVE NO COUNTERPART IN GATEWAY MODE. The Gateway API has no
# annotation channel for controller behaviour, so everything configured here
# - external auth in front of an app that has no login of its own, for
# instance - is simply gone when httpRoute.enabled is used instead. It is
# then the Gateway's business, or that of an implementation-specific policy
# object this chart deliberately does not render.
annotations: {}
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
# an existing release is unaffected; exactly one of ingress.enabled and
# httpRoute.enabled may be true.
#
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
# gatewayClassName, no Certificate and no implementation-specific policy
# object. Class and certificate belong to the Gateway and therefore to the
# platform, which is what keeps this chart independent of whether the cluster
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
#
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
# cannot detect them (.Capabilities.APIVersions is unreliable at template
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
httpRoute:
enabled: false
# The Gateway(s) this route attaches to - a platform object, usually in
# another namespace, which must allow this namespace in its listener's
# allowedRoutes. One entry per parent, the fields are the Gateway API's
# ParentReference (name, and optionally namespace, sectionName, port, group,
# kind), passed through unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
hostnames: []
+1 -1
View File
@@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack
type: application
version: 3.1.1+up1.0.0
version: 4.0.0+up1.0.0
# The chart's own version marker, freely chosen and tied to no upstream. The
# stack bundles ten independently versioned projects (nine of them on by
# default), so there is no single "app version" to track here.
+138 -15
View File
@@ -10,6 +10,16 @@
{{- $shared := include "freesailarr.sharedIngressApps" . | fromJsonArray -}}
{{- $sharedNames := list -}}
{{- range $shared }}{{ $sharedNames = append $sharedNames .name }}{{ end -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $published := include "freesailarr.published" . -}}
{{- /* NOTHING IN CI READS THIS FILE, so every claim below is unverified by any
pipeline stage. `helm template` does not emit NOTES.txt at all, and no
workflow or script under ci/ reads it (measured for Gitea #118: the
three hits for "NOTES" under ci/ are comments in fixtures, not reads).
The render tests therefore cannot catch a statement here that has gone
stale, and the install-test never sees it either — it is prose checked
by review only. Treat an edit to it with the same care as the templates
it describes, because nothing else will. */ -}}
freesailarr — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{ if .Values.mediathekarr.enabled }}Ten{{ else }}Nine{{ end }} containers in ONE pod, sharing gluetun's network namespace.
@@ -42,9 +52,53 @@ SET gluetun.vpn.firewallOutboundSubnets — IT IS EMPTY RIGHT NOW
fail, in a way that looks like an application bug. Set it to the cluster pod
and service CIDRs, e.g. "10.42.0.0/16,10.43.0.0/16" on k3s.
{{- end }}
{{- /* The servarr apps that are published through a routing object AND have no
trusted proxy network set - the one combination in which the empty
default actually costs something. Gated on freesailarr.published plus
membership in freesailarr.ingressApps, the same pair the ACCESS section
below uses, so it holds in Gateway mode too: reading ingress.enabled
directly would silence this warning in exactly the mode whose routes
still publish the apps. */}}
{{- $noTrust := list }}
{{- if $published }}
{{- range $a := $ingressApps }}
{{- if and (has $a.name (list "prowlarr" "radarr" "sonarr")) (not (index $.Values $a.name).trustedNetworks) }}
{{- $noTrust = append $noTrust $a.name }}
{{- end }}
{{- end }}
{{- end }}
{{- if $noTrust }}
FORWARDED HEADERS ARE NOT TRUSTED — {{ join ", " $noTrust }}
These apps are published through a routing object and their trustedNetworks
value is empty, which is the chart's default. They therefore trust no proxy:
the client address each one logs and displays is your ingress controller's
pod IP rather than the real client, and each sees the scheme as http even
where TLS is terminated in front of it. If an app's authentication is set to
"Disabled for Local Addresses", a proxied request no longer counts as local
and is asked to authenticate.
This is not a regression introduced by your values. Upstream removed the
hard-coded private networks (prowlarr 2.6.3, radarr 6.4.3; sonarr before
4.0.19) because trusting X-Forwarded-For from any private network is a
spoofing vector, and this chart follows that default rather than reinstating
it behind your back.
To honour the headers, set the network your ingress controller's pods sit in
— the chart cannot know it, as it is a property of the cluster:
prowlarr.trustedNetworks: "10.42.0.0/16" # e.g. the k3s pod CIDR
Leaving it empty is a valid choice; it is only not a silent one.
{{- end }}
ACCESS
{{- if and .Values.ingress.enabled $ingressApps }}
{{- if and $published $ingressApps }}
{{- if .Values.ingress.enabled }}
INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
{{- else }}
GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- end }}
{{- range $shared }}
{{ .name }}: https://{{ $sharedHost }}{{ if ne .path "/" }}{{ .path }}{{ end }}
{{- end }}
@@ -56,7 +110,8 @@ ACCESS
{{- end }}
{{- if $shared }}
These apps share one host, one Ingress and one certificate.
These apps share one host{{ if .Values.ingress.enabled }}, one Ingress and one certificate{{ else }} and one HTTPRoute ({{ $name }}-httproute), one
rule each{{ end }}.
{{- if has "bazarr" $sharedNames }}
BAZARR NEEDS ONE MANUAL STEP UNDER A SUB-PATH
@@ -73,14 +128,45 @@ ACCESS
{{- if has "qbittorrent" $sharedNames }}
QBITTORRENT SITS BEHIND A PREFIX-STRIPPING REWRITE
It has no base path of its own, so it gets a second Ingress object on the
same host whose rewrite cuts the prefix off. Two things there look exactly
like a login failure and send you looking in the wrong place: its session
cookie is marked "Secure" (over plain HTTP the browser never sends it back
and every click bounces to the login form), and it bans the client IP for a
while after a few failed attempts. If it does not work at all, set
qbittorrent.ingress.host and it is back on a host of its own - no chart
change needed.
It has no base path of its own, so the prefix has to be cut off before the
request reaches it.
{{- if .Values.ingress.enabled }} Under an Ingress that costs a SECOND object on the
same host, because nginx applies rewrite-target per object and the other
apps must not be rewritten.
{{- else }} In Gateway mode that second object is gone: a
URLRewrite filter belongs to a RULE, so qBittorrent is a rule of
{{ $name }}-httproute with the filter on it and its neighbours are
untouched by construction — no extra object, no regex path.
{{- end }}
OPEN IT WITH THE TRAILING SLASH:
https://{{ $sharedHost }}{{ include "freesailarr.appPath" (dict "root" $ "app" "qbittorrent") }}/
{{ if .Values.ingress.enabled }} Without the slash the prefix is stripped to "/", qBittorrent answers 200,
and every RELATIVE link in that page resolves against the ROOT of this
host — which is {{ if has "seerr" $sharedNames }}seerr{{ else }}whatever serves "/" here{{ end }}, not qBittorrent. The login form then looks
unstyled and does nothing while another application answers its requests.
This chart does NOT render that redirect in Ingress mode: nginx offers only
a configuration-snippet annotation for it, and snippets are ignored unless
the controller runs with allow-snippet-annotations=true, which has been off
by default since ingress-nginx 1.9 and means accepting annotation risk
level "Critical". The working snippet, and its price, are in values.yaml at
qbittorrent.ingress (Gitea #118).
{{ else }} The bare, slashless path is redirected 301 to the slashed form by a rule of
{{ $name }}-httproute — an Exact path match, which the Gateway API ranks
above every prefix match, so it wins wherever it sits in the list. Without
it qBittorrent would answer 200 under a URL whose relative links resolve to
the root of this host, which is a different application (Gitea #118). This
is the one thing the Gateway mode does here that the Ingress mode cannot.
{{ end }} Two further things look exactly like a login failure and send you
looking in the wrong place: its session cookie is marked "Secure" (over
plain HTTP the browser never sends it back and every click bounces to the
login form), and it bans the client IP for a while after a few failed
attempts. If it does not work at all, set qbittorrent.ingress.host and it
is back on a host of its own - no chart change needed.
{{- end }}
{{- if not (has "seerr" $sharedNames) }}
@@ -90,12 +176,48 @@ ACCESS
{{- end }}
{{- end }}
{{- if .Values.ingress.enabled }}
Every app listed with a host of its own has its OWN Ingress and its own TLS
secret. While cert-manager is still issuing, the browser warns:
kubectl -n {{ .Release.Namespace }} get certificate
{{- else }}
No Ingress is rendered{{ if not .Values.ingress.enabled }} (ingress.enabled is false){{ end }}. Reach an app through its own service:
Every app listed with a host of its own has its OWN HTTPRoute
({{ $name }}-<app>-httproute).
THREE THINGS BELONG TO THE CLUSTER AND NOT TO THIS CHART IN THIS MODE, and if
any is missing the addresses above do not answer:
1. The Gateway-API CRDs. Without them this install would have failed.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. Every route of this release
attaches to that same parent. A rejected attachment shows up only in a
route's status:
kubectl -n {{ .Release.Namespace }} get httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment. An empty status means no
controller has looked at the route at all.
3. The TLS certificates for these hostnames, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required, and
neither are ingress.className or the per-app overrides of either: the
chart renders no Certificate and no class, so there are no
tls-{{ $name }}-* secrets and nothing for cert-manager to do.
ingress.annotations HAS NO COUNTERPART HERE, per-app overrides included: the
Gateway API has no annotation channel. The chart needs nothing from it —
qBittorrent's rewrite is a filter in this mode — but an annotation you added
yourself is gone without a word. A body-size cap for qBittorrent's
torrent-file uploads is the likely one; set it at the Gateway or through its
implementation's own policy object.
{{- end }}
{{- else }}
Nothing is published{{ if and (not .Values.ingress.enabled) (not $httpRoute.enabled) }} — ingress.enabled and httpRoute.enabled are both false{{ else if not $ingressApps }} — no enabled app carries <app>.ingress.enabled: true{{ end }}. Reach an app through its own service:
{{- range $portApps }}
kubectl -n {{ $.Release.Namespace }} port-forward svc/{{ $name }}-{{ .name }}-service {{ .port }}:{{ .port }}
@@ -105,7 +227,8 @@ ACCESS
qBittorrent's WebUI rejects a Host header whose port differs from its own
({{ "8080" }}) with a plain 401. A port-forward on a different local port therefore
looks like a login failure — forward 8080:8080, or go through the ingress.
looks like a login failure — forward 8080:8080, or go through the published
address above.
{{- if .Values.qbittorrent.savePath }}
QBITTORRENT DOWNLOADS GO TO {{ .Values.qbittorrent.savePath }}
@@ -158,8 +281,8 @@ MEDIATHEKARR: TWO PORTS, AND THE SABNZBD API IS NOT AT /api
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 }}:
The UI (setup wizard and download queue) is not published{{ if and .Values.mediathekarr.ingress.enabled .Values.mediathekarr.ingress.host }}, other than on the
host you gave it, {{ .Values.mediathekarr.ingress.host }}{{ end }}:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ $name }}-mediathekarr-service 5007:5007
{{- if $gate.enabled }}
@@ -286,7 +409,7 @@ THE MEDIA CLAIMS ARE YOURS, THE CONFIG VOLUMES ARE THE CHART'S
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0. PVCs, secrets,
services{{ if .Values.ingress.enabled }} and ingresses{{ end }} stay in place; set it back and upgrade to bring the
services{{ if .Values.ingress.enabled }} and ingresses{{ else if $httpRoute.enabled }} and HTTPRoutes{{ end }} stay in place; set it back and upgrade to bring the
stack up again.
{{- else if ne $replicas "1" }}
+104 -11
View File
@@ -205,8 +205,49 @@ in the ingress, which addresses a service port by name.
{{- end -}}
{{/*
The apps that render an Ingress: the enabled port apps whose values carry an
ingress block with enabled: true. Used by the ingress template.
Is this release published through a routing object at all? Returns the string
"true" or the empty string (falsy in a template "if").
Input: the root context.
ONE PLACE, BECAUSE THE WRONG ANSWER IS INVISIBLE (Gitea #114)
ingress.enabled is the chart's master switch, and until the Gateway-API mode
existed it was also the answer to "is anything published". It stopped being
that answer in exactly one configuration - and that configuration is the whole
point of the new mode: httpRoute.enabled: true forces ingress.enabled: false
(the two are mutually exclusive, the render aborts otherwise), while the apps
ARE published, now through HTTPRoutes.
The consumer that makes this load-bearing is NOT the routing templates - those
know their own mode - but freesailarr.sharedIngressApps below, which is also
read by the <APP>__SERVER__URLBASE environment variables (see
freesailarr.urlBase). Read from ingress.enabled alone, that list would come out
EMPTY in Gateway mode, so Radarr, Sonarr and Prowlarr would be told no base
path while their routes serve them under /radarr, /sonarr and /prowlarr. Each
app would then answer at the sub-path with a page whose assets and API calls
are linked at the host root, i.e. a broken UI behind a Ready pod - the same
silent class as sure's TLS flags, found in the same sweep. The duplicate-path
and root-only guards in that helper would also have gone unenforced in Gateway
mode, for the same reason.
With httpRoute.enabled at its default of false this expression IS
ingress.enabled, which is why adding it changes nothing for an existing
release: `helm template` with unchanged values renders byte-identically.
*/}}
{{- define "freesailarr.published" -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if or .Values.ingress.enabled $httpRoute.enabled -}}true{{- end -}}
{{- end -}}
{{/*
The apps that are published through a routing object: the enabled port apps
whose values carry an ingress block with enabled: true. Used by BOTH routing
templates - the per-app <app>.ingress.enabled toggle selects which apps are
exposed in Ingress mode and in Gateway mode alike, and only the Ingress-only
fields of that block (clusterIssuer, className, annotations) have no meaning in
the second one. The name is historical; renaming it would churn every call site
for no behaviour.
{{- $apps := include "freesailarr.ingressApps" . | fromJsonArray }}
*/}}
@@ -252,14 +293,19 @@ The apps served under the shared domain (ingress.host), as a JSON array of
is deterministic.
The shared-domain mode is off unless ingress.host is set; then it covers the
apps whose ingress renders at all (freesailarr.ingressApps, i.e. the enabled
port apps with ingress.enabled) and which do not carry a host of their own. A
per-app host always wins and keeps rendering its own object exactly as before,
which is what makes mixed operation fall out for free.
apps whose routing object renders at all (freesailarr.ingressApps, i.e. the
enabled port apps with ingress.enabled) and which do not carry a host of their
own. A per-app host always wins and keeps rendering its own object exactly as
before, which is what makes mixed operation fall out for free.
Two guards live here rather than in the ingress template, so that every
consumer - the ingress objects, the UrlBase env vars and NOTES.txt - sees the
same validated list:
Gated on freesailarr.published and NOT on ingress.enabled (Gitea #114): this
list is read by the UrlBase env vars too, and in Gateway mode ingress.enabled
is false by construction while the apps are published all the same. See that
helper for what reading the switch directly would have broken silently.
Two guards live here rather than in the routing templates, so that every
consumer - the Ingress objects, the HTTPRoutes, the UrlBase env vars and
NOTES.txt - sees the same validated list:
1. seerr or mediathekarr under any path other than "/". seerr has no base-path
support (upstream request open since May 2022) and MediathekArr's UI links
@@ -277,7 +323,7 @@ same validated list:
{{- $root := . -}}
{{- $apps := list -}}
{{- $host := $root.Values.ingress.host | default "" -}}
{{- if and $root.Values.ingress.enabled $host -}}
{{- if and (include "freesailarr.published" $root) $host -}}
{{- $seen := dict -}}
{{- range $app := include "freesailarr.ingressApps" $root | fromJsonArray -}}
{{- $config := (index $root.Values $app.name).ingress -}}
@@ -320,9 +366,16 @@ base path from this environment variable (schema
<APP>__CONFIGNAMESPACE__CONFIGITEM, https://wiki.servarr.com/sonarr/environment-variables),
so the chart can configure them itself as soon as they render under a sub-path.
Mode-independent by construction (Gitea #114): the sub-path an app is served
under is the same in Ingress and in Gateway mode, so this variable follows
freesailarr.sharedIngressApps and not the routing object. That is only true
because that helper asks freesailarr.published rather than ingress.enabled -
reading the switch directly would have emptied this variable in Gateway mode
while the route still served the app under its prefix.
Empty - i.e. the chart sets nothing - in three cases:
- the app does not render under the shared host, or renders at "/";
- the app is not served under the shared host, or is served at "/";
- the app's own env block already carries the variable. The env blocks are
documented as additive; emitting a second entry with the same name would
break that promise, and two identically named variables in one container
@@ -353,6 +406,46 @@ Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">.
{{- $path -}}
{{- end -}}
{{/*
The value for a servarr app's <APP>__SERVER__TRUSTEDNETWORKS variable, or the
empty string when the chart must not set it. Same env schema as the UrlBase
variable above (<APP>__CONFIGNAMESPACE__CONFIGITEM): verified for all three
apps at the tags this chart pins, where Bootstrap.cs binds
services.Configure<ServerOptions>(config.GetSection("<App>:Server")) next to a
plain .AddEnvironmentVariables().
The variable is emitted ONLY when the value is non-empty. An empty
TRUSTEDNETWORKS would be indistinguishable from an unset one for the app -
upstream reads string.Empty either way - but not for a reader of the manifest,
to whom it would look like a setting that had been taken care of.
Empty - i.e. the chart sets nothing - in two cases:
- the app's trustedNetworks value is empty, which is the DEFAULT. The apps
then trust no proxy at all; see the value's comment in values.yaml for what
that costs and why it is not a preserved behaviour;
- the app's own env block already carries the variable, for the same reason
the UrlBase helper above stays out of that case: the env blocks are
additive, and two identically named variables in one container are not a
defined state to build on.
Input: dict "root" $ "app" <"radarr"|"sonarr"|"prowlarr">.
*/}}
{{- define "freesailarr.trustedNetworks" -}}
{{- $root := .root -}}
{{- $app := .app -}}
{{- $var := printf "%s__SERVER__TRUSTEDNETWORKS" (upper $app) -}}
{{- $value := (index $root.Values $app).trustedNetworks | default "" -}}
{{- if $value -}}
{{- range $entry := (index $root.Values $app).env -}}
{{- if eq (toString $entry.name) $var -}}
{{- $value = "" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- $value -}}
{{- end -}}
{{/*
Comma-separated FIREWALL_INPUT_PORTS for gluetun: the ports of the enabled app
containers (ingress traffic and the kubelet probes both arrive on the pod IP)
@@ -0,0 +1,286 @@
{{/*
The Gateway-API alternative to freesailarr.ingress.yaml (Gitea #114). Form and
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
Gateway API".
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners. ingress.clusterIssuer, ingress.className,
ingress.annotations and their per-app overrides are all inert in this mode.
THE SAME TWO SHAPES AS THE INGRESS, AND THEY STILL MIX
1. One host per app (the default): every published app gets its own
HTTPRoute with its own hostname, pointing at its own app's Service.
2. One shared domain with sub-paths (ingress.host): the apps that carry no
host of their own move into ONE route with one rule each.
THIS CHART HAS NO ingress.domain, SO httpRoute HAS NO hostnames
It is the documented exception on that point (README, guideline 4): the chart
bundles ten independently exposed apps, so there is no single hostname to put
in a chart-global list - a hostnames entry could not say which app it belonged
to. The hostnames therefore come from the values that already hold them,
ingress.host and <app>.ingress.host, which keeps the single source of truth the
convention is actually about. Those two are not Ingress switches: they name the
public addresses of this release, and both modes read them.
Consequence for a migration: the "second hostname" the convention offers
elsewhere is done here by changing <app>.ingress.host, which in Gateway mode
feeds only the route - there is no Ingress rendered to disagree with it.
WHERE THE GATEWAY API IS BETTER THAN WHAT IT REPLACES, AND IT IS NOT A
TRANSLATION
qBittorrent has no base-path setting: it serves its WebUI at the root and the
proxy has to strip the prefix. nginx applies rewrite-target per INGRESS OBJECT
and not per path, and the other apps must NOT be rewritten - their UrlBase
contains the prefix, so they expect the full path. That is why
freesailarr.ingress.yaml splits qBittorrent into a SECOND Ingress object on the
same host, which then has to
- repeat the same TLS secret name as the main object, so that neither object
depends on an assumption about whether nginx applies the other one's TLS
configuration to its paths,
- OMIT the cert-manager annotation so only one Certificate is requested for
that one secret - except when no main object renders at all, which is a
conditional inside the annotation block, and
- match on an ImplementationSpecific path with a regex
("<path>(/|$)(.*)") whose capture group 2 the rewrite refers to.
The Gateway API's URLRewrite filter is per RULE. All of that disappears: one
route, one rule per app, and qBittorrent's rule carries the filter while its
neighbours do not. The path match goes back to a plain PathPrefix, there is no
second object, no duplicated TLS block, no conditional annotation and no
regex. The convention's own summary says rewrite-target "has a real
equivalent"; in this chart the equivalent is strictly less machinery than the
original, and that is the reason this chart was worth converting rather than
merely translating.
qBittorrent is the one app that contributes a SECOND rule, and it is not part
of the rewrite: a 301 from the bare, slashless prefix to the slashed form
(Gitea #118). Without it qBittorrent answers 200 under a URL whose relative
links resolve to the host root, which on the shared host is a DIFFERENT
application. In Ingress mode the chart does not render that redirect - it
cannot, as the only mechanism nginx offers for it is a snippet annotation that
most clusters refuse (values.yaml, qbittorrent.ingress, has the full price).
This is therefore the one place where the Gateway mode is not merely less
machinery but closes a gap the Ingress mode leaves open.
WHAT IS NOT BETTER: ANYTHING THAT WAS AN ANNOTATION AND IS NOT A FILTER. The
per-app and global ingress.annotations have no counterpart at all here. This
chart sets no body-size annotation of its own, so nothing it NEEDS is lost -
but an operator who added one through ingress.annotations (qBittorrent takes
torrent file uploads) loses it silently in this mode. NOTES.txt says so.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and both defaults are
empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingresses and the HTTPRoutes would publish the same Services under the same hostnames over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. ingress.enabled is this chart's MASTER switch and defaults to true, so turning the Gateway mode on means setting ingress.enabled: false in the same values file. A migration runs over different hostnames (change ingress.host or <app>.ingress.host), never over parallel operation of both modes." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- $sharedHost := .Values.ingress.host | default "" }}
{{- /* The same validated list the Ingress uses, and the same list the UrlBase
env vars read: it is gated on freesailarr.published, so it is populated
in this mode as well. Its two guards - seerr/mediathekarr only at "/",
no duplicate paths on the shared host - therefore apply here unchanged,
which is the whole reason they live in the helper. */}}
{{- $shared := include "freesailarr.sharedIngressApps" $ | fromJsonArray }}
{{- range $app := include "freesailarr.ingressApps" $ | fromJsonArray }}
{{- $config := (index $.Values $app.name).ingress }}
{{- /* Skip the apps that live on the shared domain; they are one rule each in
the single route below. */}}
{{- if or (not $sharedHost) ($config.host | default "") }}
{{- $host := required (printf "%s.ingress.host must be provided while %s.ingress.enabled is true (or set the shared ingress.host to serve the app under a sub-path instead). The value is the app's public hostname and is read in Gateway mode as well - it is not an Ingress switch." $app.name $app.name) $config.host }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
- {{ $host | quote }}
rules:
{{- /* The Ingress counterpart is `path: / , pathType: Prefix` on this
app's own Service. An app on a host of its own is served at the
root, so no rewrite is involved in either mode - that question only
arises under the shared host below.
The port is the Service's port NUMBER and not its name (the Ingress
addresses it by name, which is the app name): backendRefs takes a
PortNumber, so this reference cannot be made by name and the number
has to track freesailarr.service.yaml. Both come from
freesailarr.portApps, which is also what the Service renders from,
so they cannot drift - and
ci/run-httproute-match-test.sh fails when a backendRef names a
Service, or a port, the same render does not create. */}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
port: {{ int $app.port }}
{{- end }}
{{- end }}
{{- if $shared }}
{{/*
The shared-domain route: ONE object for every app under ingress.host, one rule
each, rendered only when at least one app actually contributes a path - so
switching every app off while leaving ingress.host set produces no object
rather than an empty one without rules.
qBittorrent is in here with the others, which is the difference to the Ingress:
a filter in the Gateway API belongs to a RULE, so it contributes TWO rules of
its own and the apps around it stay untouched by construction - a PathPrefix
rule with the URLRewrite filter that strips the prefix, and an Exact rule on
the bare, slashless prefix that redirects 301 to the slashed form (Gitea #118;
the long reasoning is on that rule). No second object, no repeated TLS secret,
no conditional cert-manager annotation, no regex path - see the header.
The rules come sorted by path length descending, as the Ingress paths do, and
qBittorrent's redirect rule follows the rule it corrects. Neither placement
carries meaning: the Gateway API specifies matching precedence by criteria
before order - an Exact match outranks every prefix match, and among prefix
matches the longest wins - and list order is consulted only when all of those
tie. So the ordering here is about a deterministic, reviewable rendering, not
about matching behaviour. That it is ALSO the order nginx would need is a
coincidence of this chart's history and not a property to rely on.
*/}}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
- {{ $sharedHost | quote }}
rules:
{{- range $app := $shared }}
- matches:
- path:
type: PathPrefix
value: {{ $app.path | quote }}
{{- if eq $app.name "qbittorrent" }}
{{- /* qBittorrent has no base-path setting - it serves its WebUI at the
root, so the prefix has to be stripped
(https://github.com/qbittorrent/wiki/blob/master/NGINX-Reverse-Proxy-for-Web-UI.md).
ReplacePrefixMatch replaces exactly the PathPrefix matched above,
which is why the match must be a PathPrefix and not a regex.
The apps around it must NOT be rewritten: their UrlBase carries
the prefix, so they expect the full path. Under an Ingress that
made a second object unavoidable, because rewrite-target applies
per object; here the filter is scoped to this rule and the others
are untouched by construction. */}}
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
{{- end }}
backendRefs:
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
port: {{ int $app.port }}
{{- if and (eq $app.name "qbittorrent") (ne $app.path "/") }}
{{- /* THE BARE PREFIX, WITHOUT THE TRAILING SLASH (Gitea #118)
A request for the bare qbittorrent.ingress.path - "/qbittorrent"
by default, no trailing slash - is matched by the PathPrefix rule
above and the URLRewrite turns it into "/". That is a valid
request to qBittorrent, which answers 200 with its login
page, so nothing anywhere reports a problem. But the browser's
document URL stays prefixless, and a relative link in that page
resolves against the PARENT of that path - which is the host root.
Every asset, every XHR and every form target therefore goes to "/",
and "/" on this shared host is SEERR. The symptom is qBittorrent's
login page that is unstyled and does nothing, while the requests it
makes are answered by a different application entirely - and seerr
answering a qBittorrent request is not a clue anyone follows
quickly. One redirect to the slashed form fixes all of it, because
relative links then resolve against the slashed prefix.
PRECEDENCE IS NOT POSITION. This rule is matched by the Gateway API
on an Exact path, and the specification ranks "Exact path match"
ABOVE "Prefix path match with largest number of characters", with
list order consulted only when every earlier criterion ties
(gateway-api v1.6.3, apis/v1/httproute_types.go). So this rule wins
over the PathPrefix rule above AND over seerr's PathPrefix "/" no
matter where it is emitted. The length-descending sort of the rules
in this route therefore stays what the header says it is - a
deterministic, reviewable rendering - and emitting this rule
directly after the one it corrects is a readability choice, not a
correctness one.
301 AND NOT 308, deliberately, and the reason is not the HTTP
semantics but which values an implementation is obliged to support.
The Gateway API marks 301 and 302 as CORE and everything else -
308 included - as EXTENDED. An implementation that does not support
the value given here must set the Accepted condition of the ROUTE to
status False with reason UnsupportedValue: not of this rule, of the
whole object. On the shared host that is one route carrying every
app under it, so a 308 that one implementation declines takes
prowlarr, radarr, sonarr and seerr down with qBittorrent - and it
does so on a route that is schema-valid, in a status field no
controller writes in CI. It would also contradict decision 1 of
Gitea #114, which is that this chart renders only the subset
that looks identical on nginx-gateway-fabric, Cilium, Envoy
Gateway and Istio, so that the cluster's implementation can be
swapped without touching the chart.
What 308 would buy is method and body preservation, and this rule
does not need it: it fires on exactly one path, the prefixless entry
to the WebUI, which a browser reaches with GET. qBittorrent's API
lives under "<path>/api/..." - slashed, longer, and therefore
served by the PathPrefix rule above, which this rule never sees. A
POST to the API is not redirected at all, so there is no body to
lose.
statusCode MUST BE WRITTEN OUT: it defaults to 302 in the API, and
a temporary redirect here would be re-fetched by every client on
every visit forever, for a prefix that is a permanent property of
this release's layout.
THE ONE ASYMMETRY TO THE nginx SNIPPET (values.yaml, qbittorrent):
HTTPRequestRedirectFilter has exactly five fields - scheme,
hostname, path, port, statusCode - and says nothing about the query
string, while ReplaceFullPath is specified as replacing the full
PATH. The nginx remedy in Ingress mode appends "$is_args$args" and
so carries the query over explicitly; here the query's fate is left
to the implementation and is not something this chart can state.
It costs nothing on the path this rule actually serves - the UI
entry point takes no query parameters - but it is a real difference
and not a translation. */}}
- matches:
- path:
type: Exact
value: {{ $app.path | quote }}
filters:
- type: RequestRedirect
requestRedirect:
path:
type: ReplaceFullPath
replaceFullPath: {{ printf "%s/" $app.path | quote }}
statusCode: 301
{{- end }}
{{- end }}
{{- end }}
{{- end }}
@@ -279,6 +279,14 @@ spec:
- name: PROWLARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- /* Proxy networks whose forwarded headers prowlarr honours.
Emitted only when set - empty is the default and means no
proxy is trusted. See the freesailarr.trustedNetworks
helper and the value's comment in values.yaml. */}}
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "prowlarr")) }}
- name: PROWLARR__SERVER__TRUSTEDNETWORKS
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }}
ports:
- containerPort: 9696
@@ -348,6 +356,11 @@ spec:
- name: RADARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. */}}
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "radarr")) }}
- name: RADARR__SERVER__TRUSTEDNETWORKS
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }}
ports:
- containerPort: 7878
@@ -424,6 +437,13 @@ spec:
- name: SONARR__SERVER__URLBASE
value: {{ . | quote }}
{{- end }}
{{- /* See the prowlarr TRUSTEDNETWORKS comment above. Sonarr did
not lose the trust with this chart version - 4.0.19 was
already in the empty state. */}}
{{- with (include "freesailarr.trustedNetworks" (dict "root" $ "app" "sonarr")) }}
- name: SONARR__SERVER__TRUSTEDNETWORKS
value: {{ . | quote }}
{{- end }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }}
ports:
- containerPort: 8989
+43 -3
View File
@@ -127,11 +127,11 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Master switch; false suppresses every app ingress regardless of the per-app toggles.",
"description": "Master switch; false suppresses every app ingress regardless of the per-app toggles. Mutually exclusive with httpRoute.enabled - the render aborts when both are true, and since this defaults to true it has to be switched off explicitly to use the Gateway mode.",
"type": "boolean"
},
"host": {
"description": "Shared domain for the sub-path mode. Empty or null means the default: every app on a host of its own.",
"description": "Shared domain for the sub-path mode. Empty or null means the default: every app on a host of its own. Read by BOTH routing modes - it names the public address of the stack and is not an Ingress switch. This chart has no ingress.domain, so this value and the per-app <app>.ingress.host are also where the HTTPRoutes take their hostnames from.",
"type": [
"string",
"null"
@@ -150,11 +150,28 @@
]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. No counterpart in Gateway mode, per-app overrides included: the Gateway API has no annotation channel, so anything set here is gone when httpRoute.enabled is used instead. The chart needs nothing from it - qBittorrent's prefix-stripping rewrite has a per-rule URLRewrite filter as its equivalent there - but an annotation you added yourself is lost silently.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, and ingress.enabled defaults to true in this chart. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. Both ingress shapes work unchanged, and the sub-path shape gets simpler: a URLRewrite filter is per rule, so qBittorrent needs no second routing object. There is deliberately no hostnames key - this chart has no ingress.domain either, and a chart-global hostname list could not say which of the ten apps it belonged to, so the hostnames stay in ingress.host and <app>.ingress.host. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"description": "The Gateway(s) every route of this release attaches to. Required while httpRoute.enabled is true, enforced in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": {
"$ref": "#/definitions/parentRef"
}
}
}
},
"gluetun": {
"type": "object",
"additionalProperties": false,
@@ -312,6 +329,9 @@
"env": {
"$ref": "#/definitions/env"
},
"trustedNetworks": {
"$ref": "#/definitions/trustedNetworks"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
@@ -378,6 +398,9 @@
"env": {
"$ref": "#/definitions/env"
},
"trustedNetworks": {
"$ref": "#/definitions/trustedNetworks"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
@@ -447,6 +470,9 @@
"env": {
"$ref": "#/definitions/env"
},
"trustedNetworks": {
"$ref": "#/definitions/trustedNetworks"
},
"livenessProbe": {
"$ref": "#/definitions/probe"
},
@@ -608,6 +634,13 @@
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": [
"name"
]
},
"image": {
"description": "Every image in this chart is pinned here, gluetun included; no tag comes from appVersion. This is also what puts them all under Renovate's helm-values manager, which reads exactly these two keys.",
"type": "object",
@@ -666,6 +699,13 @@
}
}
},
"trustedNetworks": {
"description": "Comma-separated CIDRs whose X-Forwarded-For/-Proto/-Host headers this servarr app honours, rendered as <APP>__SERVER__TRUSTEDNETWORKS and only when non-empty. Empty is the default and means no proxy is trusted - the app then reports the ingress controller's pod IP as the client and sees the scheme as http behind TLS termination. Kept null-safe deliberately: an erased value is the same permissive-to-nobody state as the default, so it must not fail the render.",
"type": [
"string",
"null"
]
},
"appStorage": {
"description": "Size of this app's config volumeClaimTemplate. Not null-safe on purpose: an erased size yields an invalid claim template and fails loudly.",
"type": "object",
+209 -10
View File
@@ -227,8 +227,17 @@ media:
# startupProbe accordingly (they are values, precisely for cases like this).
# - qbittorrent: it has no base path at all, so the chart gives it its own
# Ingress object on the same host with an nginx rewrite that strips the
# prefix. Two traps from the upstream recipe, both of which look like a
# login failure and therefore point in the wrong direction:
# prefix. (In GATEWAY MODE that second object is gone: the Gateway API's
# URLRewrite filter is per RULE, not per object, so qBittorrent is one rule
# in the shared route while its neighbours stay unrewritten. The traps
# below are the application's and apply in both modes.) CALL ITS PATH WITH
# A TRAILING SLASH: the bare /qbittorrent is stripped to "/", answered 200
# by qBittorrent, and its relative links then resolve to the root of the
# shared host - which is seerr. Gateway mode renders a 301 for this itself;
# Ingress mode cannot, and the nginx remedy plus its price is written out
# at qbittorrent.ingress below (Gitea #118). Two further traps from the
# upstream recipe, both of which look like a login failure and therefore
# point in the wrong direction:
# * qBittorrent marks its session cookie "Secure". Reaching the UI over
# plain HTTP means the browser never sends the cookie back and every
# click bounces to the login form. Use https.
@@ -247,16 +256,87 @@ media:
ingress:
# Master switch: false suppresses every app ingress regardless of the
# per-app toggles.
#
# Mutually exclusive with httpRoute.enabled below (Gitea #114): with both
# true the render aborts, naming both values. Mind that this defaults to
# TRUE, so turning the Gateway mode on means setting enabled: false in the
# same values file.
enabled: true
# The shared domain for the sub-path mode, e.g. "media.example.com". Empty
# means the default: each app on a host of its own.
#
# Read by BOTH routing modes: it names the public address of the stack and is
# not an Ingress switch. Same for the per-app <app>.ingress.host.
host: ""
clusterIssuer: null
className: nginx
# Extra annotations for the Ingress objects; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
#
# NO COUNTERPART IN GATEWAY MODE, and the same goes for the per-app
# overrides: the Gateway API has no annotation channel for controller
# behaviour, so anything set here is simply gone when httpRoute.enabled is
# used instead. This chart NEEDS nothing from it - the one behaviour-bearing
# annotation it sets itself, qBittorrent's prefix-stripping rewrite, has a
# real and better equivalent in that mode (a per-rule URLRewrite filter). But
# an annotation YOU added here is lost without a word; a body-size cap for
# qBittorrent's torrent-file uploads is the likely candidate, and it then has
# to be set at the Gateway or through its implementation's own policy object.
#
# ONE ANNOTATION IS WORTH ADDING HERE IN INGRESS MODE and the chart cannot
# ship it: the configuration-snippet that redirects the bare /qbittorrent to
# /qbittorrent/. It is written out, with its allow-snippet-annotations price,
# at qbittorrent.ingress below (Gitea #118). In Gateway mode it is neither
# needed nor possible - the chart renders that redirect as a rule of the
# route - so an operator who set it here does not lose a behaviour on
# migration, unlike the body-size case above.
annotations: {}
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
# an existing release is unaffected; exactly one of ingress.enabled and
# httpRoute.enabled may be true - and ingress.enabled defaults to true, so it
# has to be switched off explicitly.
#
# The chart renders ONLY HTTPRoutes with parentRefs - no Gateway, no
# gatewayClassName, no Certificate and no implementation-specific policy
# object. Class and certificate belong to the Gateway and therefore to the
# platform, which is what keeps this chart independent of whether the cluster
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
#
# Both shapes above work unchanged, and the sub-path mode gets SIMPLER: the
# Gateway API's URLRewrite filter is per rule, so qBittorrent no longer needs a
# second routing object of its own. One route, one rule per app, the filter on
# qBittorrent's rule only.
#
# There is deliberately NO httpRoute.hostnames here, unlike every other chart
# in the repository: this chart has no ingress.domain either (it bundles ten
# independently exposed apps - README, guideline 4, documented exception), so a
# chart-global list of hostnames could not say which app it belonged to. The
# hostnames come from ingress.host and <app>.ingress.host, which is where this
# chart's single source of truth for them already is.
#
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
# cannot detect them (.Capabilities.APIVersions is unreliable at template
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
httpRoute:
enabled: false
# The Gateway(s) every route of this release attaches to - a platform object,
# usually in another namespace, which must allow this namespace in its
# listener's allowedRoutes. One entry per parent, the fields are the Gateway
# API's ParentReference (name, and optionally namespace, sectionName, port,
# group, kind), passed through unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so. The same list is used for every route
# this chart renders; the hosts differ, the Gateway does not.
parentRefs: []
gluetun:
# Pinned here like every other image in this chart, so Renovate's helm-values
# manager proposes gluetun updates too. Docker Hub tags carry a v prefix.
@@ -343,7 +423,7 @@ qbittorrent:
enabled: true
image:
repository: docker.io/linuxserver/qbittorrent
tag: 5.2.3
tag: 5.2.4
# Every app gets its own Ingress object (named <release>-<chart>-<app>-ingress)
# with its own TLS secret tls-<release>-<chart>-<app>-ingress, so the apps can
# be exposed independently and under unrelated hosts.
@@ -354,6 +434,73 @@ qbittorrent:
# path is only read in the shared-domain mode (see the ingress block above);
# it defaults to /<app> and is ignored while this app has a host of its own.
# (Same block for prowlarr, radarr, sonarr, seerr and bazarr below.)
#
# CALL THIS PATH WITH A TRAILING SLASH IN INGRESS MODE (Gitea #118)
#
# qBittorrent has no base-path setting, so the prefix is stripped before the
# request reaches it. A request for the bare path - https://host/qbittorrent,
# no trailing slash - is therefore stripped to "/", which qBittorrent answers
# 200 with its login page. Nothing reports an error anywhere. But the
# browser's document URL stays prefixless, so every RELATIVE link in that
# page resolves against the host root - and "/" on the shared host is SEERR.
# The result is an unstyled login form whose assets, XHRs and form posts are
# all answered by a different application, which is a symptom that points
# nowhere useful. https://host/qbittorrent/ works correctly. The same applies
# to any custom path set here; it is a property of prefix stripping, not of
# the default value.
#
# IN GATEWAY MODE THE CHART RENDERS THE REDIRECT ITSELF. qBittorrent's rule
# in the shared HTTPRoute is joined by a second rule matching the bare path
# EXACTLY, whose RequestRedirect filter answers 301 to the slashed form. The
# Gateway API ranks an Exact match above every prefix match, so it wins over
# both qBittorrent's own PathPrefix rule and seerr's "/" regardless of
# ordering. 301 rather than 308 because only 301 and 302 are Core in that
# API - an Extended value an implementation declines makes it set
# Accepted=False on the WHOLE route, taking the neighbouring apps' routing
# with it. One asymmetry to the nginx remedy below: the Gateway API's
# redirect filter has no field for the query string (it replaces the full
# PATH), while the nginx snippet carries it over explicitly with
# $is_args$args. It costs nothing on the UI entry path, which takes no query
# parameters, but it is a real difference.
#
# IN INGRESS MODE THE CHART RENDERS NO REDIRECT, and that is a limitation,
# not an oversight. The remedy exists but cannot be shipped: put an nginx
# snippet into the global ingress.annotations (it is merged into both of this
# chart's Ingress objects on the shared host, which is what makes it work
# from there):
#
# ingress:
# annotations:
# nginx.ingress.kubernetes.io/configuration-snippet: |
# if ($uri = "/qbittorrent") { return 301 $scheme://$host/qbittorrent/$is_args$args; }
#
# ITS PRICE, which is why it is documented rather than added: snippet
# annotations are only honoured on a controller started with
# allow-snippet-annotations=true, and since ingress-nginx 1.9 that flag is
# OFF by default. Switching it on also means accepting
# annotations-risk-level: Critical, because a configuration snippet is
# arbitrary nginx configuration injected from a namespaced object. A chart
# that shipped this annotation would silently render a no-op on every cluster
# that keeps the default, which is worse than naming the gap. The decision
# belongs to whoever runs the ingress controller.
#
# AND A THIRD INGRESS OBJECT WITH pathType: Exact IS NOT A SUBSTITUTE here,
# which is the obvious-looking alternative and does not work. In
# ingress-nginx controller-v1.14.5, internal/ingress/controller/template/
# template.go, buildLocation() returns a plain regex location
# (~* "^<path>") in its enforceRegex branch and RETURNS THERE - before it
# ever reaches the PathTypeExact branch that would emit the = "<path>"
# location nginx gives precedence to. And enforceRegex is computed once per
# nginx SERVER, not per location: rootfs/etc/nginx/template/nginx.tmpl
# evaluates enforceRegexModifier over every location of a hostname, and it
# returns true as soon as one of them needs a rewrite. qBittorrent's own
# object carries rewrite-target on this very hostname, so enforceRegex is
# true for the whole server and an Exact path would render as an ordinary
# regex location. The exact-match precedence the idea depends on never comes
# into being. The unsuitability follows from that short-circuit alone; what
# an unanchored regex location would then do to ORDERING - shadow the app, or
# loop the redirect, depending on emission order - is inferred from the same
# sources and has NOT been observed on a running nginx.
ingress:
enabled: true
host: null
@@ -481,7 +628,43 @@ prowlarr:
enabled: true
image:
repository: docker.io/linuxserver/prowlarr
tag: 2.5.2
tag: 2.6.5
# Networks whose X-Forwarded-For / -Proto / -Host headers prowlarr honours,
# as comma-separated CIDRs (e.g. "10.42.0.0/16"). Rendered as
# PROWLARR__SERVER__TRUSTEDNETWORKS, and only when this value is non-empty.
#
# EMPTY IS THE DEFAULT, AND WITH IT PROWLARR TRUSTS NO PROXY AT ALL. That is
# a change in behaviour and not a preserved one. Up to 2.5.2 the app
# hard-coded 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 and
# fe80::/10 as trusted proxy networks in Startup.cs; 2.6.3 removed all five
# and replaced them with this setting, whose own upstream default is
# string.Empty - ForwardedHeadersConfigurator returns without registering a
# single KnownNetwork while it is empty. Upstream's reason for the change is
# that trusting X-Forwarded-For from every private network is a spoofing
# vector, and this chart follows that intent rather than papering over it.
#
# What it costs while this stays empty and the app is published through an
# Ingress or an HTTPRoute: the client address prowlarr logs and shows is the
# ingress controller's pod IP rather than the real client, and the app sees
# the scheme as http even behind TLS termination. If its authentication is
# set to "Disabled for Local Addresses", a proxied request no longer counts
# as local and is asked to authenticate.
#
# The chart cannot fill this in for you. The network to trust is the one your
# ingress controller's pods sit in - the cluster pod CIDR, or that single
# controller pod's IP - which is a property of the cluster and not of the
# release. Set it yourself if you want the forwarded headers honoured.
#
# AllowedHosts, the other key 2.6.3 added, is deliberately NOT exposed as a
# value here. Its upstream default is string.Empty as well, and while it is
# empty the app accepts requests for any host ("Allowed Hosts is not
# configured, accepting requests for any host"), so there is nothing to
# repair. One trap to know before anyone sets it through the env block
# anyway: the app only ever appends localhost, 127.0.0.1, [::1] and its own
# hostname to that list, so neither the pod IP nor the in-cluster Service DNS
# name is covered - and the kubelet addresses the probes to the pod IP, so an
# incomplete list answers them with HTTP 400.
trustedNetworks: ""
# See the qbittorrent ingress comment.
ingress:
enabled: true
@@ -540,7 +723,7 @@ flaresolverr:
enabled: true
image:
repository: ghcr.io/flaresolverr/flaresolverr
tag: v3.5.0
tag: v3.5.2
# No ingress and no config volume: flaresolverr is stateless and only
# consumed by prowlarr over localhost.
env: []
@@ -595,7 +778,13 @@ radarr:
enabled: true
image:
repository: docker.io/linuxserver/radarr
tag: 6.3.0
tag: 6.4.4
# The same setting, the same empty default and the same consequence as
# prowlarr's - read the trustedNetworks comment there for the whole picture.
# Radarr dropped the five hard-coded private networks in 6.4.3 (6.3.0, the
# version this chart shipped before, still had them) and reads this as
# RADARR__SERVER__TRUSTEDNETWORKS.
trustedNetworks: ""
# See the qbittorrent ingress comment.
ingress:
enabled: true
@@ -653,7 +842,7 @@ recyclarr:
enabled: true
image:
repository: ghcr.io/recyclarr/recyclarr
tag: 8.7.1
tag: 8.7.3
# No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and
# sonarr on a schedule (daemon mode).
storage:
@@ -706,7 +895,17 @@ sonarr:
enabled: true
image:
repository: docker.io/linuxserver/sonarr
tag: 4.0.19
tag: 4.0.20
# The same setting as prowlarr's - read the trustedNetworks comment there.
# Reads as SONARR__SERVER__TRUSTEDNETWORKS.
#
# One difference worth stating plainly: sonarr did NOT lose the forwarded
# header trust with this chart version. 4.0.19, the version this chart
# shipped before, already had no hard-coded networks and the same
# empty-default TrustedNetworks, so the value is being supplied here for a
# state that already existed rather than for a new one. It had arrived
# unnoticed in what the convention classes as a plain app-version bump.
trustedNetworks: ""
# See the qbittorrent ingress comment.
ingress:
enabled: true
@@ -764,7 +963,7 @@ seerr:
enabled: true
image:
repository: ghcr.io/seerr-team/seerr
tag: v3.4.1
tag: v3.5.0
# See the qbittorrent ingress comment.
ingress:
enabled: true
@@ -821,7 +1020,7 @@ bazarr:
enabled: true
image:
repository: docker.io/linuxserver/bazarr
tag: 1.6.0
tag: 1.6.2
# See the qbittorrent ingress comment.
ingress:
enabled: true
+1 -1
View File
@@ -19,7 +19,7 @@ type: application
# offer those as "updates", which is why the garage block in renovate.json5
# carries a versioningTemplate anchored to ^v<major>.<minor>.<patch>$ - same
# shape as the stalwart block, for the same reason.
version: 1.0.0+upv2.4.1
version: 1.1.0+upv2.4.1
appVersion: "v2.4.1"
maintainers:
+63 -1
View File
@@ -1,6 +1,11 @@
{{- $settings := fromYaml (include "garage.settings" .) -}}
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
Garage {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
ONE node, replication_factor = {{ $settings.replicationFactor | int64 }}, metadata engine {{ $settings.dbEngine }}.
@@ -9,7 +14,64 @@ S3 ENDPOINT (in-cluster, path-style addressing):
http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.s3 | int64 }}
region: {{ $settings.s3.region }}
{{- if .Values.ingress.enabled }}
published at: https://{{ .Values.ingress.domain }}
published at: https://{{ .Values.ingress.domain }} (INGRESS MODE)
{{- else if $httpRoute.enabled }}
{{- range $hostnames }}
published at: https://{{ . }} (GATEWAY MODE)
{{- end }}
{{- end }}
{{- if .Values.ingress.enabled }}
PUBLISHED THROUGH AN INGRESS (ingress.enabled: true, httpRoute.enabled: false)
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
cluster issuer:
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
The Ingress references the Service's s3 port BY NAME, so the port number
never has to be kept in step by hand here.
{{- else if $httpRoute.enabled }}
PUBLISHED THROUGH AN HTTPROUTE (httpRoute.enabled: true, ingress.enabled: false)
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) pointing at the
Service's s3 port ({{ $settings.ports.s3 | int64 }}) and NOTHING else of the routing path — no
Gateway, no class, no Certificate. So three things belong to the cluster and
not to this chart, and if any is missing the hostname above does not answer:
1. The Gateway-API CRDs. Without them this install would have failed.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
up only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment. An empty status means no
controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
YOUR UPLOAD LIMIT IS NOW THE GATEWAY'S, AND THIS IS AN OBJECT STORE.
In Ingress mode the advice is nginx.ingress.kubernetes.io/proxy-body-size: "0"
via ingress.annotations, because the controller's 1m default truncates
uploads. The Gateway API has NO counterpart — it knows no body size at all,
that is implementation-specific policy — and this chart deliberately renders
no policy object, which would bind it to one implementation. So whatever
limit the Gateway has is the limit your S3 PUTs get, and the symptom is
uploads failing by size rather than by permission. Raise it at the Gateway
or at its implementation's own policy object, next to the Gateway and not
here.
Mind also that the route carries the port NUMBER ({{ $settings.ports.s3 | int64 }}), not the name
"s3": an HTTPRoute backendRef takes a PortNumber. The chart renders it from
the same garage.ports.s3 value the Service uses, so the two cannot drift.
{{- end }}
{{- if $settings.web.enabled }}
static website endpoint on port {{ $settings.ports.web | int64 }}{{ with $settings.web.rootDomain }} (root domain {{ . }}){{ else }} - WITHOUT a rootDomain it serves nothing; set garage.web.rootDomain{{ end }}
+105
View File
@@ -0,0 +1,105 @@
{{/*
The Gateway-API alternative to garage.ingress.yaml (Gitea #114). Form and
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
Gateway API".
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners.
OFF BY DEFAULT, AND SO IS THE INGRESS. This chart publishes nothing unless
asked (ingress.enabled: false, httpRoute.enabled: false), because the consumer
it was built for speaks S3 inside the cluster. Both routing modes are therefore
a values change, and both expose THE S3 API - not a web UI. Garage's admin API
is JSON and its authenticated half is closed in this chart, and the project's
web interface is a separate program that is not in this image. Whoever turns
either mode on is putting an S3 endpoint on the internet; it authenticates
every request with a signature, and the credentials are the two fields of the
s3 vault item.
WHAT CHANGES FOR ingress.domain, AND IT IS THE ONE CLAIM THIS FILE INVALIDATES
garage.ingress.yaml used to be able to say that NOTHING outside it reads
ingress.domain, so that switching the Ingress off freed domain as well as
clusterIssuer. This file is now a second reader (when httpRoute.hostnames is
empty), so the sentence holds only while BOTH modes are off. That is corrected
in that template's header and in values.schema.json rather than left standing.
Unchanged: garage.s3.rootDomain is still its own value and still not coupled to
ingress.domain. A wildcard vhost suffix for bucket addressing and the single
host a route answers on are different things, and the Gateway API does not make
them the same.
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
in this mode - and it is not demanded either: its only required() sits inside
garage.ingress.yaml, behind ingress.enabled.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and all three defaults
are empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $settings := fromYaml (include "garage.settings" .) -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
even though no Ingress is rendered in this mode - the value names the public
hostname of this release, which both modes need; it is not an Ingress switch.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
# The Ingress counterpart is `path: / , pathType: Prefix` on the S3 port of
# the one Service.
#
# THE PORT IS WHERE THIS CHART DIFFERS FROM EVERY OTHER ONE IN THE SWEEP.
# backendRefs takes a PortNumber and cannot name a port, while
# garage.ingress.yaml references it BY NAME ("s3") and is therefore immune
# to the number changing. Here the number has to be rendered - and
# garage.ports.s3 is a value, so a literal 3900 would be a lie the moment
# anyone changes it. It comes out of the same garage.settings helper that
# garage.service.yaml renders its port from, which is what keeps the two
# in step by construction instead of by hand.
#
# The admin port is deliberately NOT routed: with this chart's config it
# serves /health and /metrics without authentication and answers everything
# else 401/403. That is a monitoring surface for the cluster, not something
# to publish.
#
# ci/run-httproute-match-test.sh holds the pair either way - it fails when
# a backendRef names a Service, or a port, the same render does not create.
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.s3 | int64 }}
{{- end }}
+11 -5
View File
@@ -5,11 +5,17 @@ values exist anyway, built to this repo's ingress convention, so that
publishing the endpoint later is a values change rather than a chart change.
GUIDELINE 1, CHECKED IN BOTH DIRECTIONS (README, "Values that must not lie"):
nothing outside this template reads ingress.domain. The S3 API's vhost-style
suffix is its own value, garage.s3.rootDomain, because the two are not the same
thing - a wildcard vhost domain and the single host an Ingress answers on. So
with the Ingress switched off, BOTH domain and clusterIssuer are free, and
their required() lives here where it can actually fire.
the S3 API's vhost-style suffix is its own value, garage.s3.rootDomain, because
the two are not the same thing - a wildcard vhost domain and the single host a
route answers on.
This template used to be able to add "and nothing outside it reads
ingress.domain", so that switching the Ingress off freed domain as well as
clusterIssuer. SINCE #114 THAT IS NO LONGER TRUE: garage.httproute.yaml reads
ingress.domain too, as its single hostname when httpRoute.hostnames is empty.
So domain is free only while BOTH routing modes are off. clusterIssuer is
unchanged - it is read nowhere but here, Gateway mode renders no Certificate,
and its required() below still lives where it can actually fire.
WHAT IT PUBLISHES: the S3 API. There is no web UI to expose - Garage's admin
API is JSON and the authenticated half is closed in this chart (no token), and
+32 -3
View File
@@ -114,7 +114,7 @@
"minLength": 1
},
"rootDomain": {
"description": "Optional suffix for vhost-style bucket access. Deliberately not coupled to ingress.domain - the two are different things.",
"description": "Optional suffix for vhost-style bucket access. Deliberately not coupled to ingress.domain or httpRoute.hostnames - a wildcard vhost domain and the single host a route answers on are different things.",
"type": ["string", "null"]
}
}
@@ -182,7 +182,7 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template (issue #93). Checked in both directions for this chart: nothing outside that template reads ingress.domain, so switching the Ingress off frees domain as well as clusterIssuer.",
"description": "Gates the whole Ingress template (issue #93), and ships false. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Checked in both directions for this chart, and the answer moved with Gitea #114: the HTTPRoute reads ingress.domain too (as its single hostname), so domain is free only while BOTH routing modes are off; clusterIssuer is read by the Ingress alone and ingress.enabled: false still frees it.",
"type": "boolean"
},
"clusterIssuer": {
@@ -195,13 +195,42 @@
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. No counterpart in Gateway mode: the Gateway API has no annotation channel and no notion of a body size, so the proxy-body-size an S3 endpoint needs cannot be set here when httpRoute.enabled is used - it has to be raised at the Gateway.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114), and off by default like the Ingress here. Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs, pointing at the Service's s3 port; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": {
"$ref": "#/definitions/parentRef"
}
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. A wildcard for vhost-style bucket access goes here, alongside garage.s3.rootDomain.",
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"port": {
"type": ["integer", "null"],
"minimum": 1,
+71 -5
View File
@@ -421,11 +421,21 @@ ingress:
# there is nothing to publish. The values exist, built to this repo's
# convention, so that publishing the endpoint later is a values change.
#
# Guideline 1, checked in both directions: nothing outside the Ingress
# template reads ingress.domain (the vhost suffix is its own value,
# garage.s3.rootDomain), so switching the Ingress off frees BOTH domain and
# clusterIssuer. With it on, both are required and the required() fires in
# the template.
# Mutually exclusive with httpRoute.enabled below (Gitea #114): with both
# true the render aborts, naming both values. Both ship off, so this chart
# publishes nothing until asked - in either mode.
#
# Guideline 1, checked in both directions, and the answer MOVED with #114:
# the Ingress template is no longer the only reader of ingress.domain - the
# HTTPRoute reads it too, as its single hostname, when httpRoute.hostnames is
# empty. So domain is free only while BOTH routing modes are off; with either
# one on it is required, and the required() fires in whichever template is
# rendering. clusterIssuer is unchanged: it is read only by the Ingress, so
# ingress.enabled: false frees it in every case - Gateway mode renders no
# Certificate at all.
#
# Unchanged as well: the vhost suffix is its own value, garage.s3.rootDomain,
# and is still deliberately not coupled to ingress.domain.
enabled: false
clusterIssuer: null
domain: null
@@ -434,4 +444,60 @@ ingress:
# cluster-issuer annotation the chart always sets. An S3 endpoint behind
# nginx usually wants nginx.ingress.kubernetes.io/proxy-body-size: "0" -
# without it the controller's 1m default truncates uploads.
#
# THAT ONE HAS NO COUNTERPART IN GATEWAY MODE. The Gateway API has no
# annotation channel and no notion of a body size at all - an upload limit is
# implementation-specific policy - so an S3 endpoint published through an
# HTTPRoute silently inherits the Gateway's limit, whatever it is. For an
# object store that is not a detail: it is where PUTs start failing by size.
# The chart does not rebuild it (a policy object would bind it to one
# implementation); NOTES.txt prints the warning in that mode and the limit
# has to be raised at the Gateway.
annotations: {}
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default -
# like the Ingress here, so this chart still publishes nothing unless asked.
# Exactly one of ingress.enabled and httpRoute.enabled may be true.
#
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
# gatewayClassName, no Certificate and no implementation-specific policy
# object. Class and certificate belong to the Gateway and therefore to the
# platform, which is what keeps this chart independent of whether the cluster
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
#
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
# cannot detect them (.Capabilities.APIVersions is unreliable at template
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
#
# WHAT GETS PUBLISHED IS THE S3 API, in this mode exactly as in Ingress mode:
# the route points at the Service's s3 port and nothing else. The admin port is
# deliberately not routed. Read the Ingress comment above before turning either
# mode on - and read the upload-limit note, which is the one real regression of
# this mode for an object store.
httpRoute:
enabled: false
# The Gateway(s) this route attaches to - a platform object, usually in
# another namespace, which must allow this namespace in its listener's
# allowedRoutes. One entry per parent, the fields are the Gateway API's
# ParentReference (name, and optionally namespace, sectionName, port, group,
# kind), passed through unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
#
# For vhost-style bucket access this is where the wildcard goes
# ("*.s3.example.com" alongside the plain host), together with
# garage.s3.rootDomain - the two still have to be set in step, as they do for
# the Ingress.
hostnames: []
+1 -1
View File
@@ -4,7 +4,7 @@ description: A Helm chart to deploy Home Assistant on Kubernetes
icon: https://www.home-assistant.io/images/home-assistant-logo.svg
type: application
version: 6.1.1+up2026.8.3
version: 6.2.0+up2026.8.3
appVersion: "2026.8.3"
+102 -19
View File
@@ -18,7 +18,20 @@ print as "1e+06" here just as it did in the rendered manifest.
{{- $ctr := fromYaml (include "homeassistant.containerSecurityContext" .Values.securityContext) -}}
{{- $caps := (index $ctr "capabilities") | default dict -}}
{{- $netRaw := has "NET_RAW" ((index $caps "add") | default list) -}}
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $ing := .Values.ingress -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
both read ingress.hosts (Gitea #114).
*/ -}}
{{- $published := or $ing.enabled $httpRoute.enabled -}}
{{- $hosts := $ing.hosts | default list -}}
{{- $tls := $ing.tls | default list -}}
{{- $ann := $ing.annotations | default dict -}}
@@ -33,7 +46,7 @@ Home Assistant {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namesp
{{- if $ing.enabled }}
ACCESS
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
{{- range $h := $hosts }}
{{- $scheme := "http" }}
{{- range $t := $tls }}
@@ -75,11 +88,73 @@ ACCESS
kubectl -n {{ .Release.Namespace }} get certificate {{ $certName | default "<your ingress.tls secretName>" }}
{{- end }}
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $h := $hosts }}
{{- range $p := ($h.paths | default list) }}
https://{{ $h.host }}{{ $p.path }}
{{- end }}
{{- end }}
ONE HTTPRoute PER ENTRY of ingress.hosts, and nothing else of the routing
path — no Gateway, no class, no Certificate, no policy object:
{{- range $i, $h := $hosts }}
{{ $.Release.Name }}-{{ $.Chart.Name }}-httproute{{ if $i }}-{{ $i }}{{ end }} {{ $h.host }}
{{- end }}
This chart has no ingress.domain — it is the documented ingress exception and
keeps upstream's flexible host list — so there is no httpRoute.hostnames
either: the hostnames and the paths above come from ingress.hosts, which is
read in BOTH modes. ingress.className, ingress.tls and ingress.annotations
are NOT read in this mode.
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificates for those hostnames, on the Gateway's listeners.
THE URLS ABOVE SAY https BECAUSE THAT IS WHAT A GATEWAY LISTENER
NORMALLY TERMINATES, not because this release arranged it: ingress.tls
is not read in this mode and this chart requests no certificate in
either mode (see the Ingress-mode text — it never did).
WHAT IS GONE IN THIS MODE, generally rather than specifically: the Gateway
API has no annotation channel for controller behaviour, so everything in
ingress.annotations is inert. For THIS chart there is no measured loss to
report — the installation this repository serves does not deploy
homeassistant at all, so unlike its five siblings in this sweep it had no
operator-supplied annotation to lose. That is an absence of evidence about
that installation, not a property of the chart: whatever YOU put in
ingress.annotations disappears here.
AND ONE THING TO CHECK IN HOME ASSISTANT ITSELF, because no Kubernetes object
reports it: Home Assistant refuses requests whose Host header is not in its
own configuration and builds absolute URLs from it, and it needs the proxy's
address in trusted_proxies to read X-Forwarded-For at all. Changing the data
path changes that address — the Gateway's data-plane pods are not the ingress
controller's. See the trusted-proxies section below; it applies to the
Gateway exactly as it did to ingress-nginx, with a different CIDR.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress. Nothing else in
this chart reads ingress.hosts, so no other setting silently depends on it.
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release
publishes nothing. Nothing else in this chart reads ingress.hosts, so no
other setting silently depends on it.
Reach the instance inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8123:{{ .Values.service.port }}
@@ -89,21 +164,25 @@ ACCESS — NO INGRESS
because a browser that has already talked to another Home Assistant on
localhost:8123 will otherwise mix up the stored tokens.
{{- end }}
{{- if and $ing.enabled $placeholder }}
{{- if and $published $placeholder }}
THE INGRESS IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
ingress.enabled is true while ingress.hosts still contains the default entry
"chart-example.local". That name resolves nowhere, so the Ingress exists,
ingress-nginx accepts it, and no request ever arrives. Set ingress.hosts to
THIS RELEASE IS PUBLISHED UNDER THE CHART'S PLACEHOLDER HOST
{{ if $ing.enabled }}ingress.enabled{{ else }}httpRoute.enabled{{ end }} is true while ingress.hosts still contains the default entry
"chart-example.local". That name resolves nowhere, so the {{ if $ing.enabled }}Ingress{{ else }}HTTPRoute{{ end }} exists,
{{ if $ing.enabled }}ingress-nginx accepts it{{ else }}the Gateway accepts it{{ end }}, and no request ever arrives. Set ingress.hosts to
the name you actually serve — for example example.com — before assuming the
release is unreachable for some deeper reason.
{{ if $httpRoute.enabled }}ingress.hosts is read in Gateway mode too; it is not an "Ingress-only"
value.{{ end }}
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so nothing answers
until you set it back and upgrade again. The PVC, the OnePasswordItem and
the Service{{ if $ing.enabled }} and Ingress stay in place, so no state is lost and no
certificate is re-issued.{{ else }} stay in place, so no state is lost.{{ end }}
certificate is re-issued.{{ else if $httpRoute.enabled }} and the HTTPRoutes stay in place, so no state is
lost — the routes keep pointing at a Service with no endpoints, and the
Gateway answers with an error of its own rather than with nothing.{{ else }} stay in place, so no state is lost.{{ end }}
Bringing this release back up is not a no-op: the pod re-reads the config
item from the vault as it starts, and it starts on whatever app version the
@@ -155,23 +234,27 @@ THE CONFIGURATION IS A 1PASSWORD ITEM, AND IT IS READ-ONLY IN THE CONTAINER
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ $sfs }}
BEHIND AN INGRESS, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
Every request reaching Home Assistant through ingress-nginx arrives from the
controller's pod IP, not from the browser. The http-config field is where
that is dealt with, and both ways of getting it wrong are quiet in their own
way:
BEHIND A PROXY, THE http BLOCK DECIDES BETWEEN 400 AND WRONG CLIENT IPs
Every request reaching Home Assistant through {{ if $httpRoute.enabled }}the Gateway{{ else }}ingress-nginx{{ end }} arrives from
{{ if $httpRoute.enabled }}its data-plane pod IP{{ else }}the controller's pod IP{{ end }}, not from the browser. The http-config field is
where that is dealt with, and both ways of getting it wrong are quiet in
their own way:
use_x_forwarded_for: true without the proxy's address in trusted_proxies
makes Home Assistant reject the request outright — the browser gets
400 Bad Request and the log names the untrusted address it saw.
Neither setting at all means every client looks like the ingress
controller. Nothing fails; you simply get one address in the logbook for
Neither setting at all means every client looks like {{ if $httpRoute.enabled }}the Gateway{{ else }}the ingress
controller{{ end }}. Nothing fails; you simply get one address in the logbook for
everyone, and Home Assistant's login-attempt ban then locks out all users
at once because they share that address.
The value belongs to the ingress controller's POD network, not to the nodes
and not to the clients:
The value belongs to the {{ if $httpRoute.enabled }}GATEWAY's{{ else }}ingress controller's{{ end }} POD network, not to the nodes
and not to the clients{{ if $httpRoute.enabled }} — and switching from an Ingress to a Gateway CHANGES
that network, because a Gateway's data plane normally runs in a namespace of
its own. A trusted_proxies list carried over unchanged from Ingress mode
turns every request into a 400 if the pod CIDR differs, or silently keeps
attributing all clients to one address if it does not{{ end }}:
use_x_forwarded_for: true
trusted_proxies:
@@ -0,0 +1,164 @@
{{/*
HTTPRoute - the Gateway-API alternative to homeassistant.ingress.yaml
(Gitea #114), AND THE ONE CHART IN THE SWEEP WHERE THE CONVENTION HAD TO BEND
RATHER THAN THE CHART.
This chart is the DOCUMENTED INGRESS EXCEPTION (README): it keeps upstream's
flexible ingress values - a LIST of hosts, each with its own LIST of paths,
plus a separate tls list - instead of the repository's required
domain/clusterIssuer pair. The exception stays. Nothing here unifies it, and
the Gateway support is built on top of it rather than next to it.
THIS CHART HAS NO ingress.domain, SO httpRoute HAS NO hostnames KEY
That is the freesailarr precedent from group 2, and the reason is the same: a
chart-global hostname list could not say which host entry - and therefore which
set of paths - it belonged to. So the hostnames come from the values that
already hold them, ingress.hosts[].host, and httpRoute carries only `enabled`
and `parentRefs`. One HTTPRoute PER HOST ENTRY, with one rule per path of that
entry, because an HTTPRoute's rules apply to every hostname of the object and
this chart's whole point is that the paths may differ per host.
ingress.hosts IS READ IN BOTH MODES, then, and it is not an "Ingress" value any
more than ingress.domain is in the other charts - it is where this release's
public names live. ingress.tls and ingress.className are NOT read here: the
certificate and the class sit on the Gateway and its listeners and belong to
the platform (decision 1 of #114). ingress.annotations is inert - the Gateway
API has no annotation channel.
Nothing was measured on the live cluster for this chart, because there is
nothing to measure: homeassistant is NOT DEPLOYED in jk-cluster (checked
2026-10-12 - 23 Ingress objects cluster-wide, none of them this chart's, and no
workload of it in any namespace). So unlike its five siblings in this group it
carries no operator-supplied annotation that this mode would drop. That is an
absence of evidence about THIS installation, not a demonstration that the chart
needs none: its ingress.annotations default is {} and the chart sets nothing
itself, so whatever a future installation puts there would be dropped in this
mode exactly as elsewhere.
pathType HAS NO CLEAN COUNTERPART, AND THE CHART'S DEFAULT IS THE AWKWARD ONE
Ingress pathType has three values; the Gateway API's path match types are
Exact, PathPrefix and RegularExpression. The mapping:
Exact -> Exact
Prefix -> PathPrefix
ImplementationSpecific -> NOTHING. It means "ask the controller", so there
is no value that preserves it.
And ImplementationSpecific is this chart's DEFAULT (values.yaml,
ingress.hosts[0].paths[0].pathType). Two rules therefore, and the split is not
tidiness:
- On the path "/", ImplementationSpecific is rendered as PathPrefix. Every
controller treats a root ImplementationSpecific path as a prefix match, so
the two coincide and the chart's default works in this mode out of the box.
- On ANY OTHER path, ImplementationSpecific ABORTS THE RENDER. In
ingress-nginx - the class this chart defaults to - an
ImplementationSpecific non-root path is a REGEX, and PathPrefix is not a
regex. Mapping it silently would change matching behaviour while the render
stayed green, which is precisely the class of defect #118 was: a chart that
looks translated and routes differently. So the operator is asked to say
which of Exact or Prefix was meant.
RegularExpression is deliberately NOT offered as an escape hatch: its support
level is Implementation-specific, which would bind this chart to one Gateway
implementation and is what decision 1 of #114 forbids.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoutes would publish the same Service under the same hostnames over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Home Assistant there is a second edge: it refuses requests whose Host header is not in its own trusted/allowed list and builds absolute URLs from it, so a client arriving over the other path can be rejected by the application rather than by the proxy. A migration runs over a DIFFERENT hostname - add a second entry to ingress.hosts and switch the modes once it answers - never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- $hosts := .Values.ingress.hosts | default list -}}
{{- if not $hosts -}}
{{- fail "httpRoute.enabled is true but ingress.hosts is empty - there is no hostname to publish and the release would render no route at all, silently. This chart has no ingress.domain (it is the documented ingress exception and keeps upstream's flexible host list), so ingress.hosts is where its public names live and it is read in BOTH modes. Set at least one entry, e.g. ingress.hosts: [{host: ha.example.com, paths: [{path: /, pathType: Prefix}]}]." -}}
{{- end -}}
{{- $first := true -}}
{{- range $i, $entry := $hosts }}
{{- $host := required "Every entry of ingress.hosts needs a host - an HTTPRoute with an empty hostname matches every request that reaches its listener, which on a shared Gateway means this release answers for hostnames belonging to other releases" $entry.host -}}
{{- $paths := $entry.paths | default list -}}
{{- if not $paths -}}
{{- fail (printf "ingress.hosts[%d] (%s) has no paths - the HTTPRoute would carry no rules, and a route with no rules matches nothing while being perfectly valid and Accepted. Give it at least one, e.g. paths: [{path: /, pathType: Prefix}]" $i $host) -}}
{{- end -}}
{{- if not $first }}
---
{{- end }}
{{- $first = false -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
{{- /*
ONE ROUTE PER HOST ENTRY, so the name has to carry the entry's identity. The
index and not the hostname: a hostname is not a legal object-name fragment
(dots are legal in a name but a 63-character label limit and the leading
"<release>-<chart>-" prefix are not something a hostname can be trusted to
fit), and a sanitised hostname would collide for two hosts differing only in
a character that sanitising removes. The index is stable for a given values
file, and REORDERING ingress.hosts THEREFORE RENAMES ROUTES - which deletes
and recreates them on the next upgrade rather than editing them. Harmless
here (a route carries no state and no certificate), and named so it is not a
surprise.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-httproute{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
- {{ $host | quote }}
rules:
{{- range $paths }}
{{- /*
pathType mapping, with the rule from the header: ImplementationSpecific -
this chart's default - becomes PathPrefix on "/" only, and aborts the
render on any other path rather than silently changing how it matches. An
absent pathType is treated the same way; note that pathType is a REQUIRED
field of networking.k8s.io/v1 Ingress, so an entry without it renders an
Ingress the API server rejects - a pre-existing gap of the Ingress
template, not something this one introduces.
*/}}
{{- $given := .pathType | default "ImplementationSpecific" -}}
{{- $path := required "Every entry of ingress.hosts[].paths needs a path" .path -}}
{{- $type := "" -}}
{{- if eq $given "Exact" -}}
{{- $type = "Exact" -}}
{{- else if eq $given "Prefix" -}}
{{- $type = "PathPrefix" -}}
{{- else if eq $given "ImplementationSpecific" -}}
{{- if eq $path "/" -}}
{{- $type = "PathPrefix" -}}
{{- else -}}
{{- fail (printf "ingress.hosts[%d] (%s) has a path %q with pathType ImplementationSpecific, which has NO counterpart in the Gateway API - it means \"ask the controller\", and the Gateway API offers only Exact, PathPrefix and RegularExpression. On \"/\" the chart maps it to PathPrefix because every controller agrees there, but on a sub-path ImplementationSpecific is a REGEX in ingress-nginx (this chart's default class) and PathPrefix is not one, so mapping it would change how the path matches while the render stayed green. Say which you mean: pathType: Prefix or pathType: Exact. RegularExpression is deliberately not offered - its support level is Implementation-specific and would bind this chart to one Gateway implementation (decision 1 of Gitea #114)" $i $host $path) -}}
{{- end -}}
{{- else -}}
{{- fail (printf "ingress.hosts[%d] (%s) has a path %q with an unknown pathType %q - Ingress knows Exact, Prefix and ImplementationSpecific" $i $host $path $given) -}}
{{- end }}
- matches:
- path:
type: {{ $type }}
value: {{ $path | quote }}
backendRefs:
{{- /*
The port is the Service's port NUMBER and not its name "http":
backendRefs takes a PortNumber, so unlike the Ingress this reference
cannot be made by name. It comes from service.port, the same value
the Service renders, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they
do.
*/}}
- name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-service"
port: {{ $.Values.service.port | int64 }}
{{- end }}
{{- end }}
{{- end }}
+23 -2
View File
@@ -31,7 +31,7 @@
}
},
"ingress": {
"description": "Documented exception (README): this chart keeps the upstream-style flexible ingress values - host and tls lists instead of the repo's required domain/clusterIssuer pair. The list entries go verbatim into the Ingress spec, so they stay open.",
"description": "Documented exception (README): this chart keeps the upstream-style flexible ingress values - host and tls lists instead of the repo's required domain/clusterIssuer pair. The list entries go verbatim into the Ingress spec, so they stay open. Mutually exclusive with httpRoute.enabled (Gitea #114) - the render aborts when both are true.",
"type": "object",
"additionalProperties": false,
"properties": {
@@ -39,20 +39,36 @@
"type": "boolean"
},
"className": {
"description": "Set to \"\" to omit ingressClassName from the rendered Ingress.",
"description": "Set to \"\" to omit ingressClassName from the rendered Ingress. Not read in Gateway mode: the class is a property of the Gateway there, and the Gateway belongs to the platform.",
"type": ["string", "null"]
},
"annotations": {
"description": "Not read in Gateway mode: the Gateway API has no annotation channel for controller behaviour, so anything set here is gone in that mode. The chart sets nothing itself.",
"type": "object"
},
"hosts": {
"description": "The public names of this release, READ IN BOTH MODES - this chart has no ingress.domain, so there is no httpRoute.hostnames either and the hostnames live here. In Gateway mode the chart renders one HTTPRoute per entry, with one rule per path of that entry. pathType maps Exact to Exact and Prefix to PathPrefix; ImplementationSpecific (the default below) has no Gateway-API counterpart and is rendered as PathPrefix on \"/\" only - on any other path the render aborts rather than silently changing how the path matches, because in ingress-nginx an ImplementationSpecific sub-path is a regex. RegularExpression is not offered: its support level is Implementation-specific.",
"type": ["array", "null"]
},
"tls": {
"description": "Not read in Gateway mode: the certificate sits on the Gateway's listener and belongs to the platform.",
"type": ["array", "null"]
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO KEYS ONLY, and the missing hostnames key is deliberate: this chart is the documented ingress exception and has no ingress.domain, so the hostnames and paths come from ingress.hosts and the chart renders ONE route per entry there (the freesailarr shape from group 2 of the sweep). Reordering ingress.hosts renames routes, which recreates rather than edits them - harmless, since a route holds no state. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) every route of this release attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged. The same list is used for every route; the hosts differ, the Gateway does not.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
}
}
},
"securityContext": {
"$ref": "#/definitions/securityContext"
},
@@ -125,6 +141,11 @@
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"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"],
+84
View File
@@ -17,22 +17,106 @@ service:
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
# MUTUALLY EXCLUSIVE WITH httpRoute.enabled - the render aborts when both are
# true, and the message names both value paths.
enabled: false
# Ingress class; set to "" to omit ingressClassName from the rendered Ingress.
# NOT READ IN GATEWAY MODE: the class is a property of the Gateway there, and
# the Gateway belongs to the platform.
className: nginx
# NOT READ IN GATEWAY MODE either, and this is the one to be aware of: the
# Gateway API has no annotation channel for controller behaviour, so
# everything put here is simply gone in that mode. This chart sets nothing
# itself, and the installation this repository serves does not deploy
# homeassistant at all (checked 2026-10-12), so there is no operator-supplied
# annotation here today that the switch would drop - unlike its siblings in
# the same sweep. That is an absence of evidence about this installation, not
# a guarantee about yours: whatever you put here disappears in Gateway mode.
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
# THE PUBLIC NAMES OF THIS RELEASE, AND READ IN BOTH MODES.
#
# This chart is the documented ingress exception (README): it keeps upstream's
# flexible host list rather than the repository's required
# domain/clusterIssuer pair, and that exception stays. The consequence for
# Gitea #114 is that httpRoute has NO hostnames key - a chart-global hostname
# list could not say which host entry, and therefore which set of paths, it
# belonged to. So this list is where the hostnames live in BOTH modes, and in
# Gateway mode the chart renders ONE HTTPRoute PER ENTRY, with one rule per
# path of that entry.
#
# pathType IN GATEWAY MODE, because the default below is the awkward value.
# Ingress has Exact, Prefix and ImplementationSpecific; the Gateway API has
# Exact, PathPrefix and RegularExpression. Exact and Prefix map cleanly.
# ImplementationSpecific means "ask the controller" and has NO counterpart,
# so:
# - on the path "/" it is rendered as PathPrefix (every controller treats a
# root ImplementationSpecific path as a prefix match, so the default
# below works in Gateway mode unchanged);
# - on ANY OTHER path the render ABORTS and asks you to say which you mean.
# In ingress-nginx an ImplementationSpecific sub-path is a REGEX and
# PathPrefix is not one, so mapping it silently would change how the path
# matches while the render stayed green.
# RegularExpression is deliberately not offered: its support level is
# Implementation-specific and would bind this chart to one Gateway
# implementation.
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
# NOT READ IN GATEWAY MODE: the certificate sits on the Gateway's listener.
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true. The chart renders ONLY HTTPRoutes with parentRefs: no Gateway, no
# class, no Certificate, and no implementation-specific policy object. Class
# and certificate sit on the Gateway and its listeners and belong to the
# platform (cluster-config), which is what keeps the Gateway implementation
# replaceable without touching this chart again.
#
# TWO KEYS ONLY, and the missing third one is deliberate: there is no
# httpRoute.hostnames here, because this chart has no ingress.domain either.
# The hostnames and the paths come from ingress.hosts above - see the comment
# there, including the pathType mapping. This is the freesailarr shape from
# group 2 of the sweep, for the same reason.
#
# ONE HTTPRoute PER ENTRY of ingress.hosts, named
# <release>-homeassistant-httproute for the first and ...-httproute-<index> for
# the rest. REORDERING ingress.hosts RENAMES ROUTES and so deletes and
# recreates them on the next upgrade instead of editing them - harmless,
# because a route holds no state and no certificate, but worth knowing before
# it looks like a bug.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) EVERY route of this release attaches to - a platform object,
# usually in another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so. The same list is used for every route
# this chart renders; the hosts differ, the Gateway does not.
parentRefs: []
# Documented deviation from the repo-wide hardening standard: the official
# Home Assistant container must start as root — it boots via the s6-overlay
# init system, which performs its setup as uid 0 (the image supports no
+1 -1
View File
@@ -43,7 +43,7 @@ type: application
# procedure lives in NOTES.txt and on the wiki page instead of in a
# packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before
# merging an app-version PR that changes the minor.
version: 1.1.0+up26.8.0
version: 1.3.0+up26.8.0
appVersion: "26.8.0"
maintainers:
+169 -8
View File
@@ -1,6 +1,24 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}}
{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $httpRouteAdmin := $httpRoute.admin | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $adminHostnames := $httpRouteAdmin.hostnames | default list -}}
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (.Values.ingress.admin | default dict).domain -}}
{{- end -}}
{{- $realmHosts := (fromYaml (include "keycloak.settings" .)).realmHosts | default list -}}
{{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}}
{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}}
Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
@@ -8,19 +26,154 @@ Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in nam
{{ $replicas }} replica(s), cache=ispn with jdbc-ping discovery over the database,
external PostgreSQL at {{ $settings.db.host | default "<keycloak.db.host NOT SET>" }}:{{ $settings.db.port | int64 }}/{{ $settings.db.database }}.
ENDPOINTS
ENDPOINTS - {{ if .Values.ingress.enabled }}INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false){{ else if $httpRoute.enabled }}GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false){{ else }}NEITHER INGRESS NOR HTTPROUTE{{ end }}
{{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }}
{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only
{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin
{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
{{ else if $httpRoute.enabled }}{{ range $hostnames }} Public: https://{{ . }}
{{ end }}{{ else }} Public: no Ingress and no HTTPRoute (ingress.enabled and httpRoute.enabled are
both false) - in-cluster only
{{ end }}{{ if not $adminHostname }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is
served on the public host and Keycloak generates its URLs for it
{{ else if $httpRoute.enabled }}{{ range $adminHostnames }} Admin: https://{{ . }}/admin
{{ end }}{{ else if .Values.ingress.enabled }} Admin: {{ include "keycloak.adminUrl" . }}/admin
{{ else }} Admin: {{ include "keycloak.adminUrl" . }}/admin is what KC_HOSTNAME_ADMIN says, and
NOTHING PUBLISHES IT. ingress.admin.enabled is true, so the console
still generates its URLs for that host, but with both modes off
there is no Ingress and no HTTPRoute to carry it. Use the
port-forward below, or switch a mode on.
{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.http | int64 }}
Management port {{ $settings.ports.management | int64 }} is NOT published and must never be: it serves
/health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }}
WITHOUT authentication. It has a ClusterIP Service
({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only.
{{ if $realmHosts }}
REALM HOSTNAMES - {{ len $realmHosts }} further name(s), each landing in ONE realm
The object names carry the LIST INDEX and not the realm, so this is the only
place that says which is which. Reordering keycloak.realmHosts therefore
renames these objects and recreates them.
{{ range $i, $entry := $realmHosts }}
https://{{ $entry.host }}
realm {{ $entry.realm }} (bare / -> /realms/{{ $entry.realm }}/account/)
object {{ $.Release.Name }}-{{ $.Chart.Name }}-realm-{{ if $httpRoute.enabled }}httproute{{ else }}ingress{{ end }}{{ if $i }}-{{ $i }}{{ end }}
{{- if not $httpRoute.enabled }}
cert {{ $entry.tlsSecretName }}{{ if $entry.clusterIssuer }}, requested by THIS object ({{ $entry.clusterIssuer }}){{ else }}, NOT requested by this object - it must already exist or be issued elsewhere{{ end }}
{{- end }}
{{ end }}
{{- /*
No "and nothing publishes them" branch here, deliberately: a filled
keycloak.realmHosts with both modes off ABORTS the render in
keycloak.ingress.yaml, so this block can only ever be reached with a mode on.
A branch for an impossible state reads like a state somebody has seen.
*/ -}}
{{- end }}
{{ if $httpRoute.enabled }}
GATEWAY MODE - WHAT THIS CHART DOES NOT RENDER, AND WHAT YOU MUST SET YOURSELF
This release renders {{ add (ternary 2 1 $adminHostname) (len $realmHosts) }} HTTPRoute(s) and NOTHING else of the routing path -
no Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
{{- if $adminHostname }}
{{ .Release.Name }}-{{ .Chart.Name }}-admin-httproute {{ join ", " $adminHostnames }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-httproute{{ if $i }}-{{ $i }}{{ end }} {{ $entry.host }} (realm {{ $entry.realm }})
{{- end }}
Three things therefore belong to the cluster, and if any is missing the
hostnames above simply do not answer:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present - nothing to check.
2. The Gateway(s) named in parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (public)
{{- end }}
{{- if $adminHostname }}
{{- range ($httpRouteAdmin.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (admin)
{{- end }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
{{- range ($entry.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (realm {{ $entry.realm }})
{{- end }}
{{- end }}
must exist AND their listeners must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions - "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. The TLS certificates for those hostnames, on the Gateway's listeners.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do
for this release.
{{- if $realmHosts }}
The realm hostnames above are part of that: their tlsSecretName values
are not read in this mode either. Where several of them share a DNS zone,
ONE wildcard listener for that zone replaces one certificate per name -
which is the one thing this mode makes cheaper here rather than dearer.
{{- end }}
AND THE ONE YOU WILL NOT NOTICE UNTIL A LOGIN FAILS: THE PROXY BUFFERS.
Keycloak's responses carry large headers - JWTs, SAML assertions, long
Set-Cookie chains during the login flow. In Ingress mode that is handled by
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
nginx.ingress.kubernetes.io/proxy-buffers-number: 4
which this chart has never set: they come from ingress.annotations and
ingress.admin.annotations, i.e. from whoever installs it. IN THIS MODE THEY
ARE GONE and there is NO counterpart in the Gateway API - buffer sizing is
implementation policy, not routing, and this chart renders no policy object
on purpose (it would bind the chart to one Gateway implementation).
The failure mode, so you recognise it: the PROXY answers 502 while Keycloak
logs the request as successful. Nothing says a chart value was ignored,
because no chart value was involved. Set the equivalent next to the Gateway,
in your implementation's own object, BEFORE you point DNS at it - and raise
it, do not just match 16k, if you use long-lived tokens or SAML.
ingress.annotations and ingress.admin.annotations are inert here in general:
the Gateway API has no annotation channel for controller behaviour. An IP
allow-list for the admin host is the other thing that has to move - and read
the first trap below before relying on it in either mode.
{{ if $realmHosts }}
AND THE SAME LOSS ONCE MORE, ON THE REALM HOSTNAMES - where it is likelier to
be met rather than merely possible.
Each entry of keycloak.realmHosts carries its own annotations block in Ingress
mode, and in this mode every one of them is gone: {{ len $realmHosts }} hostname(s) here, none
of which can configure a proxy buffer through this chart any more. What the
entries CAN still express is the routing - the redirect into the realm is a
rule of the route and survives the mode change intact.
Why these hostnames are the awkward ones: they exist to put somebody in an
ACCOUNT CONSOLE. That is a logged-in session from the first request after the
redirect, so the large Authorization and Set-Cookie headers are the normal
traffic on these names rather than an edge case the way they are on a login
host that mostly serves anonymous requests. A buffer sized for the default
therefore fails here FIRST, and it fails as a 502 from the Gateway on a
hostname whose Ingress never had a chart value to point at - so there is
nothing to grep for and nothing to un-set.
Set the buffer equivalent for these hostnames too, in the Gateway
implementation's own object, and set it before the names resolve there.
{{- end }}
THE ADMIN HOSTNAME'S "INTERNAL CLASS" MOVES TO THE PARENT. What
ingress.admin.className does in the other mode, httpRoute.admin.parentRefs
does here: point the admin route at an internal Gateway. It falls back to
httpRoute.parentRefs, so leaving it empty puts both hostnames on the same
Gateway.
{{ end }}
THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY
The operator materializes one Secret key per item FIELD, so a missing or
misnamed field leaves the pod in CreateContainerConfigError - with no message
@@ -61,7 +214,7 @@ FIRST INSTALL - AND STEP 4 IS MANDATORY, NOT ADVICE
jdbc-ping did not find the peer - check that both pods can reach each
other on port {{ $settings.ports.jgroups | int64 }} and that they really share one database.
3. Log in at {{ if $adminHostname }}{{ include "keycloak.adminUrl" . }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }}
3. Log in at {{ if and $adminHostname (not $httpRoute.enabled) }}{{ include "keycloak.adminUrl" . }}/admin{{ else if $adminHostname }}https://{{ first $adminHostnames }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else if $httpRoute.enabled }}https://{{ first $hostnames }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }}
with the bootstrap credentials from the vault item above.
4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one.
@@ -132,7 +285,10 @@ TRAPS WORTH KNOWING BEFORE YOU NEED THEM
on the reverse proxy level." So /admin/realms/... is reachable through the
public host whatever you put on the admin host, and an IP allow-list on the
admin Ingress restricts nothing on its own. This chart renders no deny rule
for /admin - see values.yaml for why not.
for /admin - see values.yaml for why not.{{ if $httpRoute.enabled }} In GATEWAY MODE there is not
even a place in this chart to put one: no annotation channel, and no
implementation-specific policy object by design. The restriction belongs
next to the Gateway, which is where it can also be tested.{{ end }}
- A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the
database, so - upstream - "clusters that share the same database but should
@@ -200,5 +356,10 @@ WHAT THIS CHART DOES NOT RENDER
- No NetworkPolicy. jdbc-ping opens direct pod-to-pod connections on port
{{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the
replicas, or the cluster silently becomes two islands.
- No deny rule for /admin on the public Ingress, and no restriction of the
administration REST API. That is reverse-proxy work - see the first trap.
- No deny rule for /admin on the public Ingress{{ if $httpRoute.enabled }} or route{{ end }}, and no restriction of
the administration REST API. That is reverse-proxy work - see the first
trap.
- No Gateway, no GatewayClass and no listener, in either mode. Those are
platform objects and live in cluster-config; this chart renders an
HTTPRoute at most, which is the part that looks the same on every Gateway
implementation.
+100 -1
View File
@@ -184,7 +184,8 @@ Usage: {{ $settings := fromYaml (include "keycloak.settings" .) }}
"features" (list)
"cache" (dict "clusterName" nil)
"ports" (dict "http" 8080 "management" 9000 "jgroups" 7800)
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1)
"realmHosts" (list) -}}
{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak") -}}
{{- end -}}
@@ -342,6 +343,104 @@ true
{{- end -}}
{{- end -}}
{{/*
THE REALM HOSTNAMES (Gitea #122) - validated here once, rendered twice.
WHAT THEY ARE. Keycloak serves every realm from one deployment, and a realm's
account console lives at /realms/<realm>/account/. A realm therefore gets a
hostname of its own by publishing THIS release's Service under that name and
sending the bare / to that path. The list is keycloak.realmHosts, it defaults
to empty, and an empty list renders nothing in either mode - the feature is
additive and the default render is byte-identical without it.
WHY THE VALIDATION IS SHARED AND NOT PER TEMPLATE. The same list feeds the
Ingress and the HTTPRoute template, so a check written in one of them is a check
that does not exist in the other mode - and the failure these checks are about
is a hostname that resolves, answers, and serves the wrong thing or nothing at
all. None of it is loud: an entry without a realm would render an Ingress whose
app-root annotation reads "/realms//account/", an entry without a host would
render an object with an empty host that ingress-nginx treats as a CATCH-ALL for
the whole controller, and an entry without a tlsSecretName would render a TLS
block with no secret, which serves the controller's default certificate and
fails in the browser only.
THE MODE DECIDES WHICH FIELDS ARE LOAD-BEARING, which is why it is a parameter
rather than read from the values here: tlsSecretName and annotations are Ingress
fields with no counterpart in the Gateway API (see keycloak.httproute.yaml), and
parentRefs is the other way round. A field demanded in a mode that ignores it is
the defect guideline 12 exists to remove, so each is required only where it is
read.
Input: dict "root" <the root context> "mode" "ingress"|"gateway"
Output: YAML of dict "entries" <the entries, as given, after validation>
*/}}
{{- define "keycloak.realmHosts" -}}
{{- $root := .root -}}
{{- $mode := .mode -}}
{{- $entries := (fromYaml (include "keycloak.settings" $root)).realmHosts | default list -}}
{{- if not (kindIs "slice" $entries) -}}
{{- fail (printf "keycloak.realmHosts must be a list or null, got %s (%v). One entry per realm hostname, each a mapping of host and realm plus the fields its routing mode reads - see values.yaml." (kindOf $entries) $entries) -}}
{{- end -}}
{{- $ingress := $root.Values.ingress | default dict -}}
{{- $ingressAdmin := $ingress.admin | default dict -}}
{{- $httpRoute := $root.Values.httpRoute | default dict -}}
{{- /*
THE HOSTNAMES THIS RELEASE ALREADY CLAIMS, collected so that a realm entry
cannot claim one of them a second time. Both modes punish it, differently and
neither of them clearly: under ingress-nginx the admission webhook rejects the
SECOND Ingress on the same host and path - which under Fleet leaves the bundle
in ErrApplied until forceSyncGeneration is raised - while two HTTPRoutes on one
listener and one hostname are MERGED by the implementation, so the duplicate is
accepted and which of the two identical rules serves a request is decided by
object age. The first is a deployment that stops, the second is a coin toss that
looks healthy.
*/ -}}
{{- $claimed := dict -}}
{{- if $ingress.domain -}}
{{- $_ := set $claimed (toString $ingress.domain) "ingress.domain, this release's public hostname" -}}
{{- end -}}
{{- if and (eq (include "keycloak.adminHostnameEnabled" $root) "true") $ingressAdmin.domain -}}
{{- $_ := set $claimed (toString $ingressAdmin.domain) "ingress.admin.domain, the administration console's hostname" -}}
{{- end -}}
{{- if eq $mode "gateway" -}}
{{- range ($httpRoute.hostnames | default list) -}}
{{- $_ := set $claimed (toString .) "httpRoute.hostnames, the public route's explicit hostname list" -}}
{{- end -}}
{{- range (($httpRoute.admin | default dict).hostnames | default list) -}}
{{- $_ := set $claimed (toString .) "httpRoute.admin.hostnames, the admin route's explicit hostname list" -}}
{{- end -}}
{{- end -}}
{{- $seen := dict -}}
{{- range $i, $entry := $entries -}}
{{- $at := printf "keycloak.realmHosts[%d]" $i -}}
{{- if not (kindIs "map" $entry) -}}
{{- fail (printf "%s must be a mapping with at least host and realm, got %s (%v). The list is a list of entries, not of hostnames - the realm cannot be derived from the hostname, and this installation is the proof: id.klein.pub serves the realm klein-family." $at (kindOf $entry) $entry) -}}
{{- end -}}
{{- $host := toString ($entry.host | default "") -}}
{{- $realm := toString ($entry.realm | default "") -}}
{{- if not $host -}}
{{- fail (printf "%s has no host (its realm is %q). An Ingress rule or an HTTPRoute without a hostname is not an error in either API: ingress-nginx treats the empty host as a CATCH-ALL and the object then answers every name the controller serves that nothing else claims, while an HTTPRoute with an empty hostnames list attaches to every hostname of its listener. Both publish this realm's account console across hostnames nobody chose. Set %s.host." $at $realm $at) -}}
{{- end -}}
{{- if not $realm -}}
{{- fail (printf "%s (host %s) has no realm. The realm is the whole point of the entry - it is what the bare / redirects to - and without it the redirect target would read /realms//account/, which Keycloak answers 404 while the hostname, the certificate and the route are all healthy. Set %s.realm, and read it off the realm in Keycloak rather than off the hostname: the two need not match, and in this installation they do not." $at $host $at) -}}
{{- end -}}
{{- if contains "/" $realm -}}
{{- fail (printf "%s has realm %q, which contains a slash. The value is interpolated into the URL path /realms/<realm>/account/, so a slash does not escape it - it silently retargets the redirect at a different path. Use the realm's name as Keycloak shows it." $at $realm) -}}
{{- end -}}
{{- if hasKey $seen $host -}}
{{- fail (printf "%s repeats the host %s, already used by keycloak.realmHosts[%v]. Two entries on one hostname render two objects with the same hostname and the same path: in Ingress mode the admission webhook rejects the second, in Gateway mode both are accepted and merged and the redirect a client gets - so the realm it lands in - depends on which object is older. Give each realm its own hostname, or drop the duplicate entry." $at $host (index $seen $host)) -}}
{{- end -}}
{{- $_ := set $seen $host $i -}}
{{- if hasKey $claimed $host -}}
{{- fail (printf "%s claims the host %s, which this release already publishes through %s. One hostname cannot carry both: a realm entry sends the bare / to /realms/%s/account/, and the hostname it collides with serves the login endpoints or the administration console at / instead. Pick a hostname of its own for the realm - that is what the feature is for - or publish the realm through the existing host without this entry." $at $host (index $claimed $host) $realm) -}}
{{- end -}}
{{- if and (eq $mode "ingress") (not (toString ($entry.tlsSecretName | default ""))) -}}
{{- fail (printf "%s (host %s) has no tlsSecretName, and in Ingress mode it is required. Unlike this chart's own two Ingresses the realm objects do NOT get the conventional tls-<release>-keycloak-ingress name: these hostnames live on certificates that already exist - a wildcard covering several of them, or one per name - and letting the chart invent a secret name per entry would ask cert-manager for a SECOND certificate for a name that is already covered, which costs an ACME order per host and buys nothing. So the name is operator data and has to be given. If the certificate is to be issued for this entry, name a secret of its own here and set %s.clusterIssuer as well." $at $host $at) -}}
{{- end -}}
{{- end -}}
{{- toYaml (dict "entries" $entries) -}}
{{- end -}}
{{/*
The effective security contexts, probes and resources (issue #99).
+273
View File
@@ -0,0 +1,273 @@
{{/*
HTTPRoute - the Gateway-API alternative to keycloak.ingress.yaml (Gitea #114).
TWO ROUTES, because this chart has TWO Ingress objects: the public hostname and
the admin hostname. Read keycloak.ingress.yaml first - everything it says about
what the admin hostname is NOT (it is not an access control) holds here
unchanged, and the Gateway API gives no new way to make it one.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object. That subset is identical
on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is the whole
point (decision 1 of #114). Class and certificate therefore belong to the
PLATFORM, on the Gateway and its listeners, in cluster-config.
WHAT ingress.admin.className BECOMES, AND WHY THERE IS AN admin BLOCK HERE
The admin Ingress exists so the console can sit on an INTERNAL ingress class or
in a different DNS zone (ingress.admin.className, falling back to
ingress.className). "Which data path" is a property of the Gateway in this API,
not of the route, so its counterpart is the PARENT: httpRoute.admin.parentRefs,
falling back to httpRoute.parentRefs exactly as the class does. Without that
fallback the admin route could only ever hang off the same Gateway as the public
one, and the one useful half of the second hostname would be gone in this mode.
THE ANNOTATIONS THIS MODE DROPS ARE NOT HYPOTHETICAL FOR THIS CHART
Measured on the live cluster on 2026-10-12, on BOTH of this chart's Ingress
objects (jk-cluster/keycloak):
nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
nginx.ingress.kubernetes.io/proxy-buffers-number: 4
The CHART sets neither - they are operator-supplied, through
ingress.annotations and ingress.admin.annotations, which is why a reader of
this repository alone would never find them. They are also not cosmetic:
Keycloak's responses carry large headers (JWTs, SAML assertions, long Set-Cookie
chains on the login flow), and a proxy whose buffer is too small answers 502
ITSELF. The failure then looks like Keycloak being broken while Keycloak logged
a perfectly successful request.
ingress.annotations and ingress.admin.annotations are INERT in this mode - the
Gateway API has no annotation channel for controller behaviour, and buffer
sizing has no counterpart in the specification at all; it is implementation
policy. So the Gateway branch of NOTES.txt NAMES it instead of rebuilding it
(decision 2 of #114). Whoever migrates this host sets the equivalent next to
the Gateway, in that implementation's own object.
THE REALM ROUTES, AND WHY THERE IS ONE OBJECT PER ENTRY (Gitea #122)
keycloak.realmHosts publishes further hostnames onto the SAME Service, each
sending the bare / to one realm's account console. Empty by default, so this
block renders nothing unless an installation fills it.
ONE OBJECT PER ENTRY, never one object with several hostnames. spec.hostnames
applies to the WHOLE object and a rule's matches are path-based, so the realm
could not be told apart inside a shared route: all entries would match
`Exact: /` and whichever rule the implementation picked would send every
hostname to the same realm. The count is therefore data-driven, which is also
why the object names are INDEX-based (style guide §4, as homeassistant does it)
- a realm name is not a dependable object-name fragment and a sanitised one can
collide. The consequence, written down rather than discovered: REORDERING THE
LIST RENAMES ROUTES, so they are recreated rather than edited. A route holds no
state and no certificate, which is what makes that acceptable; the TLS secret
the Ingress mode references is not the chart's and survives untouched either
way.
TWO RULES PER OBJECT, AND THE REDIRECT IS AN EXACT MATCH. The Ingress
counterpart is nginx.ingress.kubernetes.io/app-root, which ingress-nginx renders
as `if ($uri = /) { return 302 $scheme://$http_host<app-root>; }`
(rootfs/etc/nginx/template/nginx.tmpl:1000, controller-v1.14.5) - a 302 on
exactly /, and everything else proxied unchanged. So: an Exact match on / with a
RequestRedirect to /realms/<realm>/account/, plus the ordinary PathPrefix rule
on the Service. The Exact rule wins on / by the specification's precedence
("Exact" path match ahead of prefix matches) INDEPENDENTLY of its position in
the list, so the order below is a rendering convention and not a promise anyone
has to rely on.
statusCode IS WRITTEN OUT, at the value the field already defaults to. 301 and
302 are both Support: Core here, so the portability trap of #119 - an
implementation declining an Extended status code sets Accepted: False on the
WHOLE route, taking its other hostnames down with it - does not apply. The
reason to write it anyway: ingress-nginx emits 302 and a value that is merely
accidentally right documents nothing.
WHAT THE REALM ENTRIES LOSE IN THIS MODE is the per-entry annotations block,
and for three of the four hostnames this installation runs today that means the
proxy buffers - see the paragraph above, which is about the same loss on this
chart's own two objects. It is worse here than there in one respect only: these
hostnames serve ACCOUNT CONSOLE sessions, so the large-header case is the
normal case rather than the edge, and the 502 arrives from the proxy on a
hostname whose Ingress never had a chart value to point at.
ingress.clusterIssuer and the entries' tlsSecretName are not read in this mode:
the certificates for these hostnames sit on the Gateway's listeners, where a
wildcard listener covers whole zones at once.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $settings := fromYaml (include "keycloak.settings" .) -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Keycloak it is worse than for a stateless app, because the server derives KC_HOSTNAME from ingress.domain and generates absolute redirect and asset URLs for it - a login that starts on one path and finishes on the other fails on the state cookie, not with an error that names the cause. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $admin := $httpRoute.admin | default dict -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- $realmHosts := (fromYaml (include "keycloak.realmHosts" (dict "root" . "mode" "gateway"))).entries -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart. ingress.domain is
required in EVERY mode in this chart and its required() lives in the
keycloak.frontendUrl helper, because the Deployment reads it as KC_HOSTNAME -
guideline 12.1, and the reason this chart's value cannot be gated away. The
required() below is therefore a second, redundant guard rather than the only
one; it stays so the message names the route when the route is what is missing
a hostname.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the public HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the serving
Service. The port is the Service's port NUMBER and not its name "http":
backendRefs takes a PortNumber, so unlike the Ingress this reference
cannot be made by name. It is read from the same settings helper the
Service renders its port from, so the two cannot drift;
ci/run-httproute-match-test.sh holds the pair for the case where they do.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- if $adminEnabled }}
---
{{- /*
The admin route. Gated on ingress.admin.enabled, the same switch the admin
Ingress uses, because that value is not an Ingress switch: it also decides
whether KC_HOSTNAME_ADMIN is set on the Deployment, i.e. whether the admin
hostname is in play AT ALL. So it stays mode-independent and the ROUTING
OBJECT follows the mode - which is also why switching to Gateway mode must not
require setting it to false.
*/ -}}
{{- $adminHostnames := $admin.hostnames | default list -}}
{{- if not $adminHostnames -}}
{{- $adminHostnames = list (required "An admin Domain/Host must be provided while ingress.admin.enabled is true: set ingress.admin.domain (used as the admin HTTPRoute's single hostname when httpRoute.admin.hostnames is empty) or set httpRoute.admin.hostnames explicitly" ((.Values.ingress.admin | default dict).domain)) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-admin-httproute
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
{{- /*
Its own parent, falling back to the public one - the counterpart of
ingress.admin.className falling back to ingress.className. An internal
Gateway here is the useful half of the admin hostname, and still not the
access control keycloak.ingress.yaml is about.
*/}}
parentRefs:
{{- toYaml ($admin.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
{{- toYaml $adminHostnames | nindent 4 }}
rules:
{{- /*
The SAME backend and the same path as the public route. The
administration console is not a separate application and not a separate
path - only the hostname differs, and Keycloak generates its admin URLs
for that hostname because of KC_HOSTNAME_ADMIN.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- end }}
{{- range $i, $entry := $realmHosts }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
{{- /*
INDEX-BASED, like homeassistant's per-host routes and for the same reason
(style guide §4): the count is data-driven and a realm name is not a
dependable object-name fragment - the 63-character label limit plus the
release prefix, and a sanitised name can collide with another entry's. The
price is that reordering keycloak.realmHosts RENAMES these routes, so they are
recreated rather than edited. Which hostname and which realm each index
carries is printed by NOTES.txt, because the name does not say.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-httproute{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-deployment
spec:
{{- /*
Its own parent, falling back to the chart-global one - the same shape as
httpRoute.admin.parentRefs. It is not decoration here: these hostnames sit in
several DNS zones, so a Gateway that terminates them needs either a wildcard
listener per zone or a named listener per host, and a chart-global parentRef
pinning one sectionName would attach every realm route to one certificate's
listener. A route whose listener does not match its hostname is not an error
either - it is simply not accepted, and the hostname does not answer.
*/}}
parentRefs:
{{- toYaml ($entry.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
- {{ $entry.host | quote }}
rules:
{{- /*
RULE ONE, the counterpart of nginx.ingress.kubernetes.io/app-root. Exact
on / so that only the bare entry point is redirected and every other path
- the realm's own endpoints, its assets, the token endpoints - continues
to be proxied by rule two. Exact outranks every prefix match in the
specification's precedence, so this rule wins on / wherever it is emitted.
*/}}
- matches:
- path:
type: Exact
value: /
filters:
- type: RequestRedirect
requestRedirect:
path:
type: ReplaceFullPath
replaceFullPath: {{ printf "/realms/%s/account/" $entry.realm | quote }}
{{- /*
Written out at the field's own default. 301 and 302 are both
Support: Core, so the trap of #119 - an implementation declining an
Extended code sets Accepted: False on the WHOLE route - is not in
play; ingress-nginx emits 302 for app-root, and a value that is only
accidentally right documents nothing.
*/}}
statusCode: 302
{{- /*
RULE TWO: the same Service, the same port and the same prefix as the
public route. These hostnames are not a second application - they are the
same Keycloak under a name that lands in one realm.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port: {{ $settings.ports.http | int64 }}
{{- end }}
{{- end }}
+151 -3
View File
@@ -29,8 +29,58 @@ time outside this template - as KC_HOSTNAME on the Deployment - so it stays
required even with ingress.enabled: false, and its required() lives in the
keycloak.frontendUrl helper rather than here. clusterIssuer is read only in
this file, so switching both Ingresses off frees it.
BOTH OBJECTS HANG OFF ingress.enabled, AND THE ADMIN ONE DID NOT USED TO
(fixed with the Gateway-API support, Gitea #114). ingress.admin.enabled is NOT
an Ingress switch: it decides whether KC_HOSTNAME_ADMIN is set on the
Deployment, i.e. whether the admin hostname is in play at all - so the admin
Ingress used to render with ingress.enabled: false, which meant
"ingress.enabled: false" did not mean "no Ingress" and clusterIssuer was still
required for an object nobody asked for. Measured before the fix:
`helm template -f ci/keycloak/test-values.yaml --set ingress.enabled=false`
emitted kc-keycloak-admin-ingress and nothing else of the routing path. It also
made Gateway mode impossible: switching httpRoute on leaves ingress.enabled
false, and the admin Ingress would have gone on claiming the admin host next to
the admin HTTPRoute - one host, two data paths, the exact conflict the mutual
exclusion exists to prevent. The default render is unchanged (ingress.enabled
defaults to true); what changed is a combination that could not be coherent.
*/ -}}
{{- $adminEnabled := eq (include "keycloak.adminHostnameEnabled" .) "true" }}
{{- /*
THE REALM HOSTNAMES WITH NO MODE TO CARRY THEM (Gitea #122), checked here
because this is the template that owns ingress.enabled and because the check
has to run when NEITHER routing template emits anything. Both modes off is a
legitimate configuration - the chart then publishes nothing and NOTES.txt says
so - but a filled keycloak.realmHosts next to it is not: the list would be
accepted, validated, and rendered into no object at all, which is the one
outcome nobody can tell from a working install by looking at the release.
Emitting nothing is only correct when nothing was asked for.
*/ -}}
{{- if and (not .Values.ingress.enabled) (not (.Values.httpRoute | default dict).enabled) }}
{{- if (fromYaml (include "keycloak.settings" .)).realmHosts }}
{{- fail "keycloak.realmHosts is set while BOTH routing modes are off (ingress.enabled: false and httpRoute.enabled: false). The entries name hostnames and realms and there is no object to put them in: no Ingress, no HTTPRoute, no DNS answer, and nothing in the rendered release that reveals the list was ignored. Switch on the mode these hostnames are meant to be served by, or remove the entries - both are coherent, this combination is not." }}
{{- end }}
{{- end }}
{{- /*
Validated and bound OUTSIDE the ingress.enabled block, because a variable
declared inside an if-block does not exist after it and the realm objects below
need the same list. The helper is only CALLED in Ingress mode: it demands a
tlsSecretName per entry, which the Gateway mode neither reads nor should ask
for.
AND NOT WHEN httpRoute.enabled IS ALSO TRUE, which looks redundant next to
ingress.enabled and is not. That combination is the one keycloak.httproute.yaml
aborts over, with a message about the two data paths; template execution order
is not something a chart can rely on, so a realm check that fired here would
sometimes answer "this entry needs a tlsSecretName" to a values file whose
actual defect is that both modes are on. The specific message must not be able
to hide the structural one. Measured while writing this: without the second
condition, exactly that happened.
*/ -}}
{{- $realmHosts := list }}
{{- if and .Values.ingress.enabled (not (.Values.httpRoute | default dict).enabled) }}
{{- $realmHosts = (fromYaml (include "keycloak.realmHosts" (dict "root" . "mode" "ingress"))).entries }}
{{- end }}
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -61,10 +111,8 @@ spec:
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
{{- if $adminEnabled }}
{{- if .Values.ingress.enabled }}
{{- if and .Values.ingress.enabled $adminEnabled }}
---
{{- end }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -106,3 +154,103 @@ spec:
- {{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" ((.Values.ingress.admin | default dict).domain) }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-admin-ingress
{{- end }}
{{- range $i, $entry := $realmHosts }}
---
{{/*
A REALM HOSTNAME (Gitea #122), one Ingress object per keycloak.realmHosts entry.
Keycloak serves every realm from this one deployment, and a realm's account
console lives at /realms/<realm>/account/. An entry here gives a realm a
hostname of its own: the same Service as the two objects above, under a further
name, with the bare / sent to that realm's console by app-root. Gateway mode's
counterpart is an Exact match plus a RequestRedirect filter - see
keycloak.httproute.yaml.
THIS IS WHERE THE CHART DEVIATES FROM THE REPO'S INGRESS CONVENTION, and the
deviation is the expensive half of the feature rather than an oversight. The
convention (style guide §4) is one TLS secret per object named
tls-<release>-<chart>-ingress, with the cert-manager annotation ALWAYS set.
Neither holds here:
- THE SECRET NAME IS OPERATOR DATA, required per entry and never derived.
These hostnames live in zones whose certificates already exist, and several
of them typically share one: a wildcard for a zone covers every realm host
under it. A chart-derived name per entry would ask cert-manager for a fresh
certificate for a name a wildcard already covers - one ACME order per
hostname, for nothing.
- THE ISSUER IS OPTIONAL, and omitting it is the normal case rather than the
exception. cert-manager keys a Certificate by the secret it writes, so two
objects naming one secret and both carrying the annotation request the same
certificate twice. Exactly one object per secret may carry it - which is the
same deliberate omission the qBittorrent object in freesailarr makes, for
the same reason. Where an entry's certificate is to be issued FOR it, it
names a secret of its own and sets clusterIssuer; where the certificate
comes from elsewhere (a wildcard, a Certificate in cluster-config, another
release), clusterIssuer stays empty and this object only consumes the
secret.
The entry's annotations are merged last and per entry, not chart-globally,
because this is not a uniform set: of the four hostnames this installation runs
today, three carry the proxy-buffer pair and one does not. A chart-global
annotation block could not express that difference.
*/}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
{{- /*
INDEX-BASED, like homeassistant's per-host routes and for the same reason
(style guide §4): the count is data-driven, and a realm name is not a
dependable object-name fragment - the 63-character label limit plus the
release prefix, and a sanitised name can collide with another entry's. The
price, written down rather than discovered: reordering keycloak.realmHosts
RENAMES these objects, so they are recreated rather than edited, and the
hostname is briefly unserved while that happens. The TLS secret is not the
chart's and survives it. NOTES.txt prints which index carries which hostname
and realm, because the name does not say.
*/}}
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}-realm-ingress{{ if $i }}-{{ $i }}{{ end }}"
labels:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-deployment
annotations:
{{- /*
The counterpart of the Gateway mode's Exact-match RequestRedirect.
ingress-nginx renders this annotation as
if ($uri = /) { return 302 $scheme://$http_host<app-root>; }
(rootfs/etc/nginx/template/nginx.tmpl:1000, controller-v1.14.5) - a 302 on
exactly /, with every other path proxied unchanged.
*/}}
nginx.ingress.kubernetes.io/app-root: {{ printf "/realms/%s/account/" $entry.realm | quote }}
{{- with $entry.clusterIssuer }}
cert-manager.io/cluster-issuer: {{ . | quote }}
{{- end }}
{{- with $entry.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- /*
The entry's own class, falling back to ingress.className - the same shape as
ingress.admin.className. A realm hostname can perfectly well belong to a zone
served by a different controller than the login host.
*/}}
ingressClassName: {{ $entry.className | default $.Values.ingress.className }}
rules:
- host: {{ $entry.host | quote }}
http:
paths:
{{- /*
The SAME backend as the two objects above, by port NAME as they do.
Everything but the bare / is proxied straight through; app-root
handles /.
*/}}
- backend:
service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port:
name: http
path: /
pathType: Prefix
tls:
- hosts:
- {{ $entry.host | quote }}
secretName: {{ $entry.tlsSecretName | quote }}
{{- end }}
+83 -1
View File
@@ -151,6 +151,48 @@
"extraVolumeMounts": {
"description": "Additional volume mounts, rendered verbatim and TWICE - on the build init container as well as on the serving container (Gitea #88). That is deliberate: a login theme is read at runtime, but an SPI provider JAR must be present while `kc.sh build` writes the provider registry, or it is never registered and nothing reports it. Items loosely typed for the same reason as extraVolumes. Never mount on /opt/keycloak/themes itself - it would hide the image's base, keycloak and keycloak.v2 themes; use a subpath /opt/keycloak/themes/<name>.",
"type": ["array", "null"]
},
"realmHosts": {
"description": "One hostname per realm (Gitea #122), rendered in BOTH routing modes: one Ingress per entry with nginx.ingress.kubernetes.io/app-root, or one HTTPRoute per entry whose Exact match on / carries a RequestRedirect filter to /realms/<realm>/account/. Empty by default and then renders nothing at all. One object per entry, named by list INDEX - so reordering the list renames and recreates them. host and realm are required and the realm is NOT derived from the hostname; the render also aborts on a repeated host and on a host this release already publishes, because one hostname cannot serve two different things at /. tlsSecretName is required in Ingress mode and never derived, and clusterIssuer is optional there - the documented deviation from the one-secret/always-annotate convention, so that a certificate already covering these zones is consumed rather than requested a second time. See values.yaml for the full reasoning and for the order a takeover of existing hostnames has to run in.",
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["host", "realm"],
"properties": {
"host": {
"description": "The hostname serving this realm. Must differ from every other entry's and from the hostnames this release already publishes.",
"type": "string",
"minLength": 1
},
"realm": {
"description": "The realm as Keycloak names it, interpolated into the redirect target /realms/<realm>/account/. Not derivable from the hostname - the two need not correspond and in the installation this was written for they do not. A slash in it aborts the render, because it would retarget the redirect instead of being escaped.",
"type": "string",
"minLength": 1
},
"tlsSecretName": {
"description": "The TLS secret this hostname's certificate lives in. REQUIRED IN INGRESS MODE and deliberately not derived from release and chart name: these zones already have certificates, often one wildcard for several entries, and a derived name would mean one further ACME order per hostname for a name that is already covered. Not read in Gateway mode - there the certificate sits on the Gateway's listener.",
"type": ["string", "null"]
},
"clusterIssuer": {
"description": "Optional cert-manager issuer for THIS object. Empty or absent renders no cert-manager annotation, which is the normal case: cert-manager keys a Certificate by the secret it writes, so of several objects naming one secret exactly one may carry the annotation. Same deliberate omission as the qBittorrent object in freesailarr. Not read in Gateway mode.",
"type": ["string", "null"]
},
"className": {
"description": "Optional ingress class for this object, falling back to ingress.className - the same shape as ingress.admin.className, because a realm hostname may belong to a zone served by another controller. Not read in Gateway mode, where the class is a property of the Gateway.",
"type": ["string", "null"]
},
"annotations": {
"description": "Extra annotations for THIS entry's Ingress, merged after the app-root and cert-manager annotations. Per entry rather than chart-global on purpose: the set is not uniform across hostnames - of the four this was written for, three carry the proxy-buffer pair and one does not. Free-form keys by nature, so this object stays open. INERT in Gateway mode, and for an account console that loses the proxy buffers in the case where large headers are the norm rather than the edge.",
"type": "object"
},
"parentRefs": {
"description": "Optional Gateway(s) for this entry's HTTPRoute, falling back to httpRoute.parentRefs - the counterpart of className above, because \"which data path\" is a property of the Gateway in this API. Not decoration here: realm hostnames sit in several DNS zones, so a chart-global parentRef pinning one sectionName would attach every realm route to one certificate's listener, and a route whose listener does not match its hostname is not accepted and simply does not answer. Not read in Ingress mode.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
}
}
}
}
}
},
@@ -187,15 +229,55 @@
"type": ["string", "null"]
},
"annotations": {
"description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open.",
"description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open. Inert in Gateway mode.",
"type": "object"
}
}
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the TWO Ingresses (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO routes, the public hostname and the admin one; the second is gated on ingress.admin.enabled, because that value also sets KC_HOSTNAME_ADMIN on the Deployment and is therefore not an Ingress switch. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them. NOT COVERED IN THIS MODE: the proxy-buffer-size and proxy-buffers-number annotations this installation relies on - the Gateway API has no counterpart for proxy buffer sizing, and Keycloak's large headers make a too-small buffer answer 502 at the proxy while the application logs success. See values.yaml, httpRoute block.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the public route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the public hostname has a single source of truth.",
"type": "array",
"items": { "type": "string" }
},
"admin": {
"description": "The admin route. Rendered while ingress.admin.enabled is true - the same switch the admin Ingress uses, so the admin hostname is available in both modes.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"parentRefs": {
"description": "Empty falls back to httpRoute.parentRefs - the counterpart of ingress.admin.className falling back to ingress.className, because \"which data path\" is a property of the Gateway in this API and not of the route. An internal Gateway here is what an internal ingress class is in the other mode, and still not an access control.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.admin.domain].",
"type": "array",
"items": { "type": "string" }
}
}
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"port": {
"type": ["integer", "null"],
"minimum": 1,
+199
View File
@@ -614,6 +614,120 @@ keycloak:
extraVolumes: []
extraVolumeMounts: []
# ONE HOSTNAME PER REALM (Gitea #122). Empty by default, and an empty list
# renders NOTHING in either mode - the default render is byte-identical to a
# release without this feature.
#
# WHAT IT IS FOR. Keycloak serves every realm from this one deployment, and a
# realm's account console lives at /realms/<realm>/account/. An entry here
# gives one realm a hostname of its own: this release's Service published
# under a further name, with the bare / sent to that realm's console and
# everything else proxied through unchanged. It renders in BOTH routing modes
# - one Ingress per entry with nginx.ingress.kubernetes.io/app-root, or one
# HTTPRoute per entry whose Exact match on / carries a RequestRedirect filter.
#
# ONE OBJECT PER ENTRY, never one object with several hostnames: an
# HTTPRoute's hostnames apply to the whole object and a rule's matches are
# path-based, so inside a shared route every entry would match `Exact: /` and
# the realm could not be told apart. The objects are named by LIST INDEX
# (<release>-keycloak-realm-ingress, -realm-ingress-1, ... and the same for
# -realm-httproute), which means REORDERING THIS LIST RENAMES THEM and they
# are recreated rather than edited. NOTES.txt prints the index-to-hostname
# mapping, because the object name does not carry it.
#
# THE REALM IS NOT DERIVABLE FROM THE HOSTNAME and the chart does not try:
# both are given per entry. In the installation this feature was written for,
# one of the four hostnames serves a realm whose name shares nothing with it.
#
# Fields, with the mode that reads each one:
#
# host (both) REQUIRED. The hostname. Must not repeat
# another entry's, and must not be a hostname
# this release already publishes
# (ingress.domain, ingress.admin.domain, an
# httpRoute hostname) - the render aborts on
# either, because one hostname cannot serve two
# different things at /.
# realm (both) REQUIRED. The realm as Keycloak names it. It
# is interpolated into /realms/<realm>/account/.
# tlsSecretName (Ingress) REQUIRED in Ingress mode, see below.
# clusterIssuer (Ingress) OPTIONAL, see below. Empty or absent = no
# cert-manager annotation on this object.
# annotations (Ingress) OPTIONAL, per entry. Merged last.
# className (Ingress) OPTIONAL. Falls back to ingress.className.
# parentRefs (Gateway) OPTIONAL. Falls back to httpRoute.parentRefs.
#
# realmHosts:
# - host: realm-one.id.example.com
# realm: realm-one
# tlsSecretName: tls-id-wildcard-ingress # a certificate that exists
# clusterIssuer: "" # issued elsewhere, see below
# annotations:
# nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
# nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
# - host: id.other.example.com
# realm: realm-two
# tlsSecretName: tls-id-other-ingress
# clusterIssuer: letsencrypt-issuer # issued for THIS object
#
# (Neutral examples on purpose. Realm names, hostnames and secret names are
# operator data and are not in this chart - they belong in the values file of
# the installation.)
#
# WHY THIS DEVIATES FROM THE REPO'S INGRESS CONVENTION, which is one TLS
# secret named tls-<release>-<chart>-ingress and the cert-manager annotation
# ALWAYS set (style guide §4). Neither holds for these objects, and both
# deviations are deliberate:
#
# - tlsSecretName is REQUIRED AND NEVER DERIVED. These hostnames live in
# zones whose certificates already exist, and several entries typically
# share one - a wildcard for a zone covers every realm host under it. A
# chart-derived name per entry would ask cert-manager for a fresh
# certificate for a name a wildcard already covers: one ACME order per
# hostname, for nothing. So the name is operator data, and an entry
# without one aborts the render in Ingress mode rather than rendering a
# TLS block that silently serves the controller's default certificate.
# - clusterIssuer is OPTIONAL, and leaving it empty is the normal case.
# cert-manager keys a Certificate by the secret it writes, so two objects
# naming one secret and both carrying the annotation request the same
# certificate twice. Exactly one object per secret may carry it - the same
# deliberate omission the qBittorrent object in freesailarr makes, for the
# same reason. Set it only where the certificate is to be issued FOR this
# entry's own secret.
#
# ANNOTATIONS ARE PER ENTRY and not chart-global, because the set is not
# uniform: of the four hostnames the installation this was written for runs
# today, three carry the proxy-buffer pair and one does not. One global block
# could not express that difference. In GATEWAY MODE this field is inert, like
# ingress.annotations - and for these hostnames that loss bites harder than
# for the chart's own two, because an account console session is exactly the
# large-header case: see the httpRoute block below and NOTES.txt.
#
# TAKING OVER HOSTNAMES THAT ANOTHER RELEASE SERVES TODAY - THE ORDER MATTERS,
# AND IT IS NOT REVERSIBLE WITHIN A DEPLOY. These objects may already exist in
# the cluster, created by a different repository and owned by a different Helm
# release. Filling this list then does not edit them, it adds a SECOND object
# for the same hostname and path, and the two orders are not equally bad:
#
# 1. Remove the objects in the owning repository FIRST and let that removal
# reach the cluster.
# 2. THEN install this chart with the list filled.
#
# The window between the two is a short unreachability of those hostnames.
# The other order, or both at once, is worse: ingress-nginx's admission
# webhook rejects the second claim on a host-and-path pair, and under Fleet
# that leaves the bundle in ErrApplied until forceSyncGeneration is raised -
# measured, and written down in ytdlp-web-player's collision fail. In Gateway
# mode the webhook never fires (it compares Ingresses with Ingresses), and the
# duplicate is accepted instead: two merged routes on one hostname, with
# object age deciding which redirect a client gets.
#
# AND ONE QUESTION THIS CHART CANNOT ANSWER, UNCHECKED AS OF THIS WRITING:
# whether anything in the owning repository depends on those objects BY NAME -
# a Fleet bundle, a monitor, an objectset reference. Clear that before step 1,
# not after.
realmHosts: []
ingress:
# The PUBLIC ingress - the login endpoint, the token endpoints, the account
# console. On by default, because an identity provider nothing can reach is
@@ -642,6 +756,11 @@ ingress:
# Extra annotations for the public Ingress; merged with the cert-manager
# annotation the chart always sets.
#
# INERT IN GATEWAY MODE (httpRoute.enabled) - the Gateway API has no
# annotation channel for controller behaviour. That matters for THIS chart in
# particular: see the httpRoute block below for the proxy-buffer annotations
# this installation actually relies on and what their loss looks like.
annotations: {}
admin:
@@ -684,4 +803,84 @@ ingress:
# annotation. This is where an IP allow-list would go
# (nginx.ingress.kubernetes.io/whitelist-source-range) - with the caveat
# above firmly in mind.
#
# INERT IN GATEWAY MODE, like ingress.annotations - see the httpRoute block
# below for what this installation actually has in here today and what
# losing it costs.
annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE TWO INGRESSES (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true, and the message names both value paths. The chart renders ONLY
# HTTPRoutes with parentRefs: no Gateway, no class, no Certificate, and no
# implementation-specific policy object. Class and certificate sit on the
# Gateway and its listeners and belong to the platform (cluster-config), which
# is what makes the Gateway implementation replaceable without touching this
# chart again.
#
# TWO ROUTES, mirroring the two Ingresses: the public hostname and the admin
# hostname. ingress.admin.enabled keeps gating BOTH forms, because it is not an
# Ingress switch - it decides whether KC_HOSTNAME_ADMIN is set on the
# Deployment, i.e. whether the admin hostname exists at all.
#
# WHAT THIS MODE COSTS THIS CHART, MEASURED AND NOT HYPOTHETICAL. On
# 2026-10-12 both of this chart's live Ingress objects carried
#
# nginx.ingress.kubernetes.io/proxy-buffer-size: 16k
# nginx.ingress.kubernetes.io/proxy-buffers-number: 4
#
# set by the OPERATOR through the two annotations blocks above - this chart
# sets neither, which is why reading the chart alone does not reveal them. The
# Gateway API has no annotation channel and no counterpart for proxy buffer
# sizing: it is implementation policy, not routing, so there is nothing here to
# render and nothing this chart could promise. The consequence is specific:
# Keycloak's responses carry large headers (JWTs, SAML assertions, long
# Set-Cookie chains during the login flow), and a proxy whose buffer is too
# small answers 502 ITSELF, while Keycloak logs a successful request. The
# symptom therefore points at the application and the cause is at the proxy.
# Set the equivalent next to the Gateway, in that implementation's own object,
# BEFORE moving the host. NOTES.txt prints this in Gateway mode.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the PUBLIC route attaches to - a platform object, usually in
# another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the public hostname has one source of
# truth. ingress.domain is required in every mode in this chart anyway (the
# Deployment reads it as KC_HOSTNAME), so setting this list is only for
# publishing under a different or an ADDITIONAL name - which is how a
# migration from the Ingress runs: the HTTPRoute takes a second hostname
# while the Ingress keeps ingress.domain.
hostnames: []
admin:
# The admin route's own parent. null/empty falls back to
# httpRoute.parentRefs - the exact counterpart of ingress.admin.className
# falling back to ingress.className. "Which data path" is a property of the
# Gateway in this API rather than of the route, so an INTERNAL Gateway here
# is what an internal ingress class is in the other mode. Still not the
# access control the ingress.admin paragraph above is about.
parentRefs: []
# Empty means [ingress.admin.domain]. Required (via that value) while
# ingress.admin.enabled is true and httpRoute.enabled is true.
hostnames: []
+1 -1
View File
@@ -3,7 +3,7 @@ name: matrix-synapse
description: A Helm chart to deploy a Matrix Synapse server on Kubernetes
icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png
type: application
version: 7.1.1+upv1.159.0
version: 7.2.0+upv1.159.0
appVersion: "v1.159.0"
maintainers:
- name: jklein
+145 -14
View File
@@ -1,5 +1,29 @@
{{- /*
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
workflow or script under ci/ reads it, so every claim below carries
review-only assurance - it is checked by being read, not by a stage. A
rendering ERROR here does fail `helm template`, which is all the pipeline can
see of it.
*/ -}}
{{- $replicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.matrix.replicas) -}}
{{- $delegation := .Values.matrix.delegation | default dict -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $httpRouteDelegation := $httpRoute.delegation | default dict -}}
{{- /*
"Published" rather than "has an Ingress": the two modes are alternatives, and
every paragraph below that used to ask ingress.enabled was really asking
whether anything publishes this release. Gitea #114, and the sure.publishedOverTls
/ freesailarr.published shape.
*/ -}}
{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
{{- $delegationHostnames := $httpRouteDelegation.hostnames | default list -}}
{{- if not $delegationHostnames -}}
{{- $delegationHostnames = list $delegation.serverUrl -}}
{{- end -}}
{{- $delegationReplicas := include "matrix-synapse.replicas" (dict "scaleDown" .Values.scaleDown "replicas" $delegation.replicas) -}}
{{- $res := fromYaml (include "matrix-synapse.effectiveResources" .Values.matrix.resources) -}}
{{- $eph := index ($res.limits | default dict) "ephemeral-storage" | default "the computed ephemeral-storage limit" -}}
@@ -14,7 +38,7 @@ Matrix Synapse {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namesp
{{- if .Values.ingress.enabled }}
ACCESS
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }}
That host carries the whole client and federation API: the Ingress sends "/"
@@ -24,11 +48,96 @@ ACCESS
shows a warning and federation fails. Follow it with
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
That host carries the whole client and federation API: the HTTPRoute sends
"/" on it to the Synapse Service, which forwards port 80 to the container's
8008.
THIS RELEASE RENDERS {{ if $delegation.enabled }}TWO HTTPRoutes{{ else }}ONE HTTPRoute{{ end }} AND NOTHING ELSE of the routing
path — no Gateway, no class, no Certificate, no policy object.
{{ .Release.Name }}-{{ .Chart.Name }}-httproute {{ join ", " $hostnames }}
{{- if $delegation.enabled }}
{{ .Release.Name }}-{{ .Chart.Name }}-delegation-httproute {{ join ", " $delegationHostnames }}
{{- end }}
Three things therefore belong to the cluster, and if any is missing the
hostname does not answer — which for Matrix means federation is down, not
just the web client:
1. The Gateway-API CRDs. Without them this install would have failed, so
they are present — nothing to check.
2. The Gateway(s) named in parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (homeserver)
{{- end }}
{{- if $delegation.enabled }}
{{- range ($httpRouteDelegation.parentRefs | default $httpRoute.parentRefs) }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} (delegation)
{{- end }}
{{- end }}
must exist AND their listeners must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment is visible only
in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is what a working attachment looks like. An empty
status means no controller has looked at the route at all.
3. {{ if $delegation.enabled }}The TLS certificates — one per hostname, on the Gateway's listeners,
or one wildcard listener covering both{{ else }}The TLS certificate for the hostname, on the Gateway's listener{{ end }}.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate here, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret{{ if $delegation.enabled }} and no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-delegation secret{{ end }} and nothing for
cert-manager to do for this release. MATRIX FEDERATION IS HTTPS-ONLY,
so a listener without a valid certificate for these names is a
federation outage that no probe in this release can see.
THE UPLOAD LIMIT IS NOT ENFORCED BY ANYTHING THIS CHART RENDERS.
In Ingress mode an operator sets
nginx.ingress.kubernetes.io/proxy-body-size through ingress.annotations —
this installation runs with 50m — because Synapse's media repository takes
uploads and nginx's default of 1m rejects nearly all of them. IN THIS MODE
THAT IS GONE: the Gateway API has no body-size field, it is implementation
policy rather than routing, and this chart renders no policy object on
purpose (it would bind the chart to one Gateway implementation).
The ceiling in force is the smaller of max_upload_size in your
homeserver.yaml — which this chart cannot see, it comes from 1Password —
and whatever the GATEWAY enforces. Neither number anyone can point at is
necessarily the one that decides. Set the Gateway's half, in your
implementation's own object, to match max_upload_size, BEFORE you point DNS
at it.
AND ONE THING NOT TO RE-CREATE. The live Ingress also carries a
configuration-snippet of three more_set_headers directives
(X-Forwarded-For $remote_addr, X-Forwarded-Proto $scheme, Host). It LOOKS
like what Synapse's x_forwarded setting depends on. It is not:
more_set_headers is the headers-more module's OUTPUT-header directive, so
those three headers go on the RESPONSE TO THE CLIENT and Synapse never sees
them. What Synapse reads with x_forwarded: true is the REQUEST's
X-Forwarded-For, which the proxy supplies by itself. Do not spend effort
rebuilding this at the Gateway, and do not read its absence as the cause of
a rate-limiting or federation problem. (This is a reading of the directive's
documented semantics; nothing here A/B-tested a Synapse instance.)
ingress.annotations is inert in this mode in general: the Gateway API has no
annotation channel for controller behaviour.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate;
ingress.clusterIssuer is not needed.
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release has no
Ingress, no HTTPRoute and no certificate; ingress.clusterIssuer is not
needed.
{{- if $delegation.enabled }}
ingress.domain still is: the delegation nginx renders it into the
/.well-known/matrix/* documents, so it stays the host every other Matrix
@@ -56,10 +165,10 @@ DELEGATION — TWO HOSTNAMES THAT ARE NOT INTERCHANGEABLE
→ {"m.server": "{{ .Values.ingress.domain }}:443"}
/.well-known/matrix/client
→ {"m.homeserver": {"base_url": "https://{{ .Values.ingress.domain }}"}}
{{- if .Values.ingress.enabled }}
{{- if $published }}
The Ingress publishes them on a SECOND host. Read that split carefully,
because the chart cannot check it for you:
{{ if $httpRoute.enabled }}A SECOND HTTPRoute publishes{{ else }}The Ingress publishes{{ end }} them on a SECOND host. Read that split
carefully, because the chart cannot check it for you:
matrix.delegation.serverUrl ({{ $delegation.serverUrl }})
is the DELEGATING host — the domain in every user ID (@user:{{ $delegation.serverUrl }}).
@@ -69,18 +178,36 @@ DELEGATION — TWO HOSTNAMES THAT ARE NOT INTERCHANGEABLE
is where Synapse actually answers. It appears inside the documents
above and needs no relation to the server name.
Only the path /.well-known/matrix is routed to nginx on {{ $delegation.serverUrl }};
everything else on that host is not served by this release. Both hosts need
Only the path /.well-known/matrix is routed to nginx on {{ join ", " $delegationHostnames }};
everything else on that host is not served by this release.
{{- if $httpRoute.enabled }}
AND THAT CO-EXISTENCE IS EASIER HERE THAN IT WAS. The server name is usually
an apex domain that already serves a website, and under ingress-nginx two
Ingress objects cannot both claim one hostname — the admission webhook
rejects the second — which is why the delegation rule had to sit inside this
chart's single Ingress object. HTTPRoutes attached to the same listener for
the same hostname are MERGED by the implementation, longest match winning,
so this route may sit beside whatever owns that apex without either side
knowing about the other.
Both hosts need a certificate ON THE GATEWAY'S LISTENERS — this chart renders
none in this mode — and a listener without a valid certificate for the server
name is a federation outage, not a cosmetic problem.
{{- else }}
Both hosts need
a certificate — the second one is tls-{{ .Release.Name }}-{{ .Chart.Name }}-delegation — and
cert-manager only issues it once {{ $delegation.serverUrl }} resolves to this cluster.
A pending certificate there is a federation outage, not a cosmetic problem.
{{- end }}
Check what is actually being served, from outside the cluster:
curl -s https://{{ $delegation.serverUrl }}/.well-known/matrix/server
curl -s https://{{ first $delegationHostnames }}/.well-known/matrix/server
{{- else }}
Nothing publishes them, though: ingress.enabled is false, so the documents
Nothing publishes them, though: ingress.enabled and httpRoute.enabled are
both false, so the documents
exist only on the in-cluster Service {{ .Release.Name }}-{{ .Chart.Name }}-delegation-service, and
matrix.delegation.serverUrl ({{ $delegation.serverUrl }}) is rendered into no object of this
release. Whatever terminates TLS in front of the cluster has to route
@@ -101,7 +228,7 @@ NO DELEGATION IN THIS RELEASE — FEDERATION LIKELY DOES NOT WORK
8448 anywhere: the container publishes 8008 only, and the Service maps port
80 onto it. Unless the delegation is served by something else (a static
.well-known on the server-name host, or an SRV record), federation stays
broken{{ if .Values.ingress.enabled }} while clients on {{ .Values.ingress.domain }} work fine{{ end }}.
broken{{ if $published }} while clients on {{ first $hostnames }} work fine{{ end }}.
Setting matrix.delegation.enabled to true and matrix.delegation.serverUrl
to the server name lets this chart serve it.
{{- end }}
@@ -120,7 +247,11 @@ SCALED TO ZERO
{{- end }}
{{- end }}
The PVC, the Secret and the Services stay in place, so no data is lost{{ if .Values.ingress.enabled }} and
the certificates are not re-issued{{ end }}. Set it back and upgrade again to
the certificates are not re-issued{{ else if $httpRoute.enabled }} and the
HTTPRoutes stay in place, so the hostnames keep pointing at Services with no
endpoints — the Gateway answers with an error of its own rather than with
nothing, and remote homeservers see a failing .well-known instead of an
unresolvable name{{ end }}. Set it back and upgrade again to
return.
{{- end }}
@@ -190,7 +321,7 @@ THE SERVER NAME IS DECIDED ONCE, AND NOT BY matrix.serverName
a different name is a new server with a new database, not an upgrade. Decide
it before the first start — together with matrix.delegation.serverUrl, which
has to match it.
{{- if and .Values.ingress.enabled $delegation.enabled .Values.matrix.serverName $delegation.serverUrl }}
{{- if and $published $delegation.enabled .Values.matrix.serverName $delegation.serverUrl }}
{{- if ne (printf "%v" .Values.matrix.serverName) (printf "%v" $delegation.serverUrl) }}
MISMATCH IN THIS RELEASE: matrix.serverName is "{{ .Values.matrix.serverName }}" but the
@@ -0,0 +1,198 @@
{{/*
HTTPRoute - the Gateway-API alternative to matrix-synapse.ingress.yaml
(Gitea #114).
TWO ROUTES, AND THE SHAPE IS NOT WHAT #114 EXPECTED. The issue describes this
chart as rendering "its Ingresses in a loop over several apps". It does not -
that is freesailarr. This chart renders ONE Ingress object with TWO host rules:
ingress.domain / synapse service
matrix.delegation.serverUrl /.well-known/matrix delegation service
In the Gateway API that has to become TWO HTTPRoute objects, and not because of
style: spec.hostnames applies to the WHOLE object, so one route can carry
several hostnames only when they want the SAME rules. These two want different
paths and different backends, so one object per hostname is the only faithful
translation.
WHAT THIS MODE IMPROVES, WHICH IS WORTH KNOWING BEFORE THE MIGRATION
The delegation hostname is the Matrix SERVER NAME - the apex domain, not the
homeserver's own host - and it is almost always already serving something else
(a website at /). Under ingress-nginx two Ingress objects on one hostname are a
fight: the admission webhook rejects the second claim on the same host and
path, which is why the delegation rule has to live INSIDE this chart's single
Ingress object rather than beside it. HTTPRoutes do not work that way: several
routes may attach to the same listener for the same hostname and the
implementation merges them by match specificity (a longer path wins). So in
this mode the /.well-known/matrix route can sit next to whatever else owns that
apex, without either side knowing about the other.
WHAT THIS RENDERS, AND WHAT IT MUST NOT
Only HTTPRoutes with parentRefs. No Gateway, no gatewayClassName, no
Certificate, no implementation-specific policy object - the subset that looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio (decision 1
of #114). Class and certificate belong to the PLATFORM, on the Gateway and its
listeners, in cluster-config. Note that the two hostnames need certificates on
TWO listeners (or one wildcard listener covering both), which in Ingress mode
were two entries in the one object's tls block.
WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND ONE OF THE TWO IS NOT
WHAT IT LOOKS LIKE
On 2026-10-12 the live Ingress (jk-cluster, namespace matrix-synapse) carried,
all of it OPERATOR-supplied through ingress.annotations - this chart sets none
of it:
nginx.ingress.kubernetes.io/proxy-body-size: 50m
nginx.org/client-max-body-size: 50m
nginx.ingress.kubernetes.io/configuration-snippet: |
more_set_headers "X-Forwarded-For $remote_addr";
more_set_headers "X-Forwarded-Proto $scheme";
more_set_headers "Host $host:$server_port";
1. THE BODY SIZE IS THE REAL LOSS. Synapse's media repository takes uploads,
its own ceiling is max_upload_size in homeserver.yaml - which this chart
cannot see, because the whole config arrives from 1Password through
secrets.configSecretRef - and the effective limit is the smaller of the two.
In this mode the proxy half becomes THE GATEWAY'S default, so neither number
anyone can point at is necessarily the one that decides. There is no
counterpart in the Gateway API: body size is implementation policy, not
routing, and decision 2 of #114 says it is documented rather than rebuilt.
(nginx.org/client-max-body-size is the same limit spelled for the OTHER
nginx ingress controller, NGINX Inc's; it is a hedge across both
implementations and it disappears for the same reason.)
2. THE SNIPPET IS NOT LOAD-BEARING, AND THAT TOOK CHECKING RATHER THAN
ASSUMING. It looks like the thing Synapse's x_forwarded setting depends on -
client IP for rate limiting, and federation. It is not, because
more_set_headers is the module's OUTPUT-header directive:
headers-more-nginx-module, verbatim - more_set_headers "Replaces (if any) or
adds (if not any) the specified OUTPUT headers", while the request-side
directive is a different one, more_set_input_headers ("it operates on input
headers (or request headers)"). So these three headers are added to the
RESPONSE TO THE CLIENT. Synapse never sees them; what Synapse reads with
x_forwarded: true is the REQUEST's X-Forwarded-For, which ingress-nginx
populates itself.
The honest statement for the migration is therefore: not translatable, and
not needed. The Gateway API's ResponseHeaderModifier could set static
response headers but knows no nginx variables ($remote_addr, $scheme,
$host), so a rebuild is impossible anyway - and pointless, since dropping
them changes nothing Synapse depends on. What does change is that a client
stops receiving three headers echoing the proxy's view of its own request,
which is at best noise.
This is a claim about a directive's documented semantics, not an experiment:
nothing here observed a Synapse instance with and without the snippet.
Both are named in the Gateway branch of NOTES.txt, the first as a limit to
re-establish at the Gateway and the second as a thing NOT to re-establish.
*/}}
{{- /*
Null-safe like every other block a template here dereferences (#99): an
override written without children - "httpRoute:" with its keys deleted - is
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Services under the same hostnames over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Matrix it is worse than for a stateless app: the /.well-known/matrix delegation tells every OTHER homeserver in the federation where to find this one, and a server name that resolves to two data paths makes federation succeed or fail per remote server and per retry. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and the delegation host moves last, after the homeserver host answers." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain]. Two values that have to agree drift apart. ingress.domain is
read outside the routing templates in this chart - the delegation nginx serves
it inside /.well-known/matrix/client - so it already carries a required() of
its own there and stays mandatory with the Ingress off (README, guideline 12,
point 1). This required() is the route's own, so the message names the route.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the homeserver HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
{{- /*
The Ingress counterpart is `path: / , pathType: Prefix` on the Synapse
Service. The port is the Service's port NUMBER and not its name "http":
backendRefs takes a PortNumber, so unlike the Ingress this reference
cannot be made by name. ci/run-httproute-match-test.sh is what holds the
pair - it fails when a backendRef names a Service, or a port, that the
same render does not create.
*/}}
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- if .Values.matrix.delegation.enabled }}
---
{{- /*
The delegation route, on the SERVER NAME rather than on the homeserver's host.
Gated on matrix.delegation.enabled exactly as the second host rule of the
Ingress is, and for the same reason: without the delegation workload there is
nothing to route to.
Its hostname deliberately does NOT fall back to httpRoute.hostnames. The
delegation host is matrix.delegation.serverUrl - the Matrix server name, which
is a different thing from the homeserver's hostname and is what remote servers
resolve. Letting a chart-global hostname list cover it would make the two
indistinguishable, and the migration order (homeserver host first, server name
last) depends on telling them apart. httpRoute.delegation.hostnames exists for
the one case where the server name itself has to move.
*/ -}}
{{- $delegation := $httpRoute.delegation | default dict -}}
{{- $delegationHostnames := $delegation.hostnames | default list -}}
{{- if not $delegationHostnames -}}
{{- $delegationHostnames = list (required "If delegation is enabled you must provide a serverUrl: set matrix.delegation.serverUrl (used as the delegation HTTPRoute's single hostname when httpRoute.delegation.hostnames is empty) or set httpRoute.delegation.hostnames explicitly" .Values.matrix.delegation.serverUrl) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-httproute
spec:
{{- /*
Its own parent, falling back to the homeserver route's. The server name may
well be published by a different Gateway than the homeserver host - it is a
domain somebody else's website usually owns - and in that case this is where
it is said.
*/}}
parentRefs:
{{- toYaml ($delegation.parentRefs | default $parentRefs) | nindent 4 }}
hostnames:
{{- toYaml $delegationHostnames | nindent 4 }}
rules:
{{- /*
PathPrefix and not Exact, and the same path the Ingress uses: the
delegation nginx serves two documents below it
(/.well-known/matrix/server and /.well-known/matrix/client), so the
prefix has to carry both. NO URLRewrite filter - the delegation nginx's
own config answers on the full path, so nothing is stripped, which is
also why the #118 prefix/redirect assurance has no precondition to meet
here.
*/}}
- matches:
- path:
type: PathPrefix
value: /.well-known/matrix
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-service
port: 80
{{- end }}
{{- end }}
+43 -2
View File
@@ -119,10 +119,11 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template (issue #93). false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which the delegation nginx also serves in its /.well-known/matrix/* endpoints.",
"description": "Gates the whole Ingress template (issue #93). Mutually exclusive with httpRoute.enabled - the render aborts when both are true. false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which the delegation nginx also serves in its /.well-known/matrix/* endpoints and which the HTTPRoute reads as its hostname.",
"type": "boolean"
},
"domain": {
"description": "The homeserver hostname. Read by the Ingress, by the HTTPRoute (when httpRoute.hostnames is empty) and by the delegation nginx, which serves it inside /.well-known/matrix/client - so it is required in every mode while delegation is on.",
"type": ["string", "null"]
},
"clusterIssuer": {
@@ -132,13 +133,53 @@
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.",
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. INERT IN GATEWAY MODE: the Gateway API has no annotation channel. This installation's live Ingress carries proxy-body-size 50m (which has no counterpart and must be re-established at the Gateway) and a configuration-snippet of more_set_headers directives (which must NOT be re-established - see values.yaml, httpRoute block).",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only HTTPRoutes with parentRefs; class and certificate sit on the Gateway and belong to the platform. TWO routes, not one: the Ingress is a single object with two host rules (homeserver at /, delegation at /.well-known/matrix), and an HTTPRoute's hostnames apply to the whole object, so two hostnames wanting different paths and backends must be two objects. NOT COVERED IN THIS MODE: proxy-body-size, which this installation carries at 50m and which Synapse's own max_upload_size has to agree with - see values.yaml, httpRoute block, including why the configuration-snippet on the live object is NOT a loss. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"parentRefs": {
"description": "The Gateway(s) the homeserver route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [ingress.domain], so the homeserver hostname has a single source of truth. Does NOT cover the delegation host - see httpRoute.delegation.hostnames.",
"type": "array",
"items": { "type": "string" }
},
"delegation": {
"description": "The delegation route, rendered while matrix.delegation.enabled is true - the same switch the Ingress's second host rule uses.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"parentRefs": {
"description": "Empty falls back to httpRoute.parentRefs. Worth setting when the server name is published by a different Gateway than the homeserver host, which is the normal case - the server name is an apex domain somebody else's website usually owns.",
"type": "array",
"items": { "$ref": "#/definitions/parentRef" }
},
"hostnames": {
"description": "Empty means [matrix.delegation.serverUrl]. Deliberately does NOT fall back to httpRoute.hostnames: the server name is what remote homeservers resolve and is a different thing from the homeserver's hostname, and the migration order depends on telling them apart.",
"type": "array",
"items": { "type": "string" }
}
}
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"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"],
+123
View File
@@ -155,10 +155,133 @@ ingress:
# read by the delegation nginx, which serves it in the /.well-known/matrix/*
# endpoints, so it stays required whenever matrix.delegation.enabled is true.
# Only clusterIssuer becomes unnecessary.
#
# MUTUALLY EXCLUSIVE WITH httpRoute.enabled - the render aborts when both are
# true, and the message names both value paths.
enabled: true
domain: null
clusterIssuer: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
#
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
# controller behaviour. For THIS chart that is not a formality: read the
# httpRoute block below for what this installation actually has in here and
# which half of it matters.
annotations: {}
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114).
#
# Mutually exclusive with ingress.enabled - the render aborts when both are
# true. The chart renders ONLY HTTPRoutes with parentRefs: no Gateway, no
# class, no Certificate, and no implementation-specific policy object. Class
# and certificate sit on the Gateway and its listeners and belong to the
# platform (cluster-config), which is what keeps the Gateway implementation
# replaceable without touching this chart again.
#
# TWO ROUTES, not one. The Ingress is a single object with two HOST rules - the
# homeserver on ingress.domain at "/", and the delegation nginx on
# matrix.delegation.serverUrl at "/.well-known/matrix". An HTTPRoute's
# hostnames apply to the whole object, so two hostnames wanting different paths
# and different backends must be two objects. (#114 describes this chart as
# rendering its Ingresses in a loop over several apps; it does not - that is
# freesailarr.)
#
# ONE THING THIS MODE MAKES EASIER, and it is the awkward part of Matrix
# delegation: the server name is usually an apex domain that already serves a
# website. Under ingress-nginx two Ingress objects cannot both claim one
# hostname - the admission webhook rejects the second - which is why the
# delegation rule has to live inside this chart's single Ingress object.
# HTTPRoutes attached to the same listener for the same hostname are MERGED by
# the implementation, longest match winning, so here the /.well-known/matrix
# route can sit beside whatever owns that apex without either side knowing.
#
# WHAT THIS MODE COSTS, MEASURED ON THE LIVE OBJECT ON 2026-10-12, where the
# Ingress carried - all of it OPERATOR-supplied through ingress.annotations,
# since this chart sets none of it:
#
# nginx.ingress.kubernetes.io/proxy-body-size: 50m
# nginx.org/client-max-body-size: 50m
# nginx.ingress.kubernetes.io/configuration-snippet: |
# more_set_headers "X-Forwarded-For $remote_addr";
# more_set_headers "X-Forwarded-Proto $scheme";
# more_set_headers "Host $host:$server_port";
#
# 1. THE BODY SIZE IS THE REAL LOSS. Synapse's media repository takes uploads
# and its own ceiling is max_upload_size in homeserver.yaml - which this
# chart cannot see, because the whole config comes from 1Password through
# secrets.configSecretRef. The effective limit is the smaller of the two,
# and in this mode the proxy half becomes THE GATEWAY'S default: neither
# number anyone can point at is necessarily the one that decides. The
# Gateway API has no body-size field, so per decision 2 of #114 this is
# documented rather than rebuilt. Set it next to the Gateway, in that
# implementation's own object, and set it to match max_upload_size.
# (nginx.org/client-max-body-size is the same limit spelled for NGINX Inc's
# ingress controller rather than ingress-nginx - a hedge across both - and
# it goes for the same reason.)
#
# 2. THE SNIPPET IS NOT LOAD-BEARING, WHICH TOOK CHECKING. It looks like what
# Synapse's x_forwarded setting depends on - client IP for rate limiting,
# and federation. It is not: more_set_headers is the OUTPUT-header directive
# of headers-more-nginx-module ("Replaces (if any) or adds (if not any) the
# specified output headers"), while the request-side directive is
# more_set_input_headers. These three headers are therefore added to the
# RESPONSE TO THE CLIENT; Synapse never sees them, and what it reads with
# x_forwarded: true is the REQUEST's X-Forwarded-For, which ingress-nginx
# sets itself.
#
# So for the migration: not translatable, and not needed. The Gateway API's
# ResponseHeaderModifier can set static response headers but knows no nginx
# variables ($remote_addr, $scheme, $host), so rebuilding it is impossible
# anyway - and pointless. Do NOT re-create it at the Gateway in the belief
# that Synapse needs it.
#
# Stated as what it is: a reading of the directive's documented semantics.
# Nothing here observed a Synapse instance with and without the snippet.
#
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
# without them fails at install, so this is a value and not a guess.
httpRoute:
enabled: false
# The Gateway(s) the HOMESERVER route attaches to - a platform object,
# usually in another namespace, whose listener must allow this namespace in
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
# optionally namespace, sectionName, port, group, kind), passed through
# unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the homeserver hostname has one source of
# truth. Set explicitly only to publish under a different or an additional
# name - which is how a migration from the Ingress runs: the HTTPRoute takes
# a second hostname while the Ingress keeps ingress.domain.
#
# THE DELEGATION HOST IS NOT COVERED BY THIS LIST, deliberately - see below.
hostnames: []
delegation:
# The delegation route's own parent. Empty falls back to
# httpRoute.parentRefs. Worth setting when the SERVER NAME is published by
# a different Gateway than the homeserver host, which is the normal case:
# the server name is an apex domain somebody else's website usually owns.
parentRefs: []
# Empty means [matrix.delegation.serverUrl], and it deliberately does NOT
# fall back to httpRoute.hostnames. The server name is a different thing
# from the homeserver's hostname - it is what REMOTE homeservers resolve -
# and the migration order depends on telling them apart: move the
# homeserver host first, confirm it answers, move the server name last.
# A chart-global hostname list covering both would erase that distinction.
# Set this only when the server name itself has to move.
hostnames: []
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon-
type: application
version: 7.1.3+up1.10.1
version: 7.2.0+up1.10.1
appVersion: 1.10.1
maintainers:
+79 -6
View File
@@ -1,21 +1,70 @@
{{- $replicas := include "outline.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.outline.replicas) -}}
{{- $startup := fromYaml (include "outline.startupProbe" .Values.outline.startupProbe) -}}
{{- $upload := .Values.outline.maxUploadSize | toString -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
Outline {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
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 if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING
else of the routing path — no Gateway, no class, no Certificate. Three things
therefore belong to the cluster and not to this chart, and if any is missing
the address above does not answer:
1. The Gateway-API CRDs. Without them this install would have failed.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
up only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment. An empty status means no
controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
ingress.domain is still required in this mode and is {{ .Values.ingress.domain }}: it becomes
Outline's URL variable, which builds every generated link, every share URL
and the OIDC redirect URI. The route does not replace it. If you set
httpRoute.hostnames to a name ingress.domain does not carry, the route
publishes one host while the app hands out links for another — and an OIDC
login then fails on a redirect_uri the provider does not know. Set both
together.
ingress.annotations is inert in this mode: the Gateway API has no annotation
channel. WebSockets need none — Outline's realtime editing upgrades on the
same port and a plain HTTPRoute passes that through, exactly as the Ingress
did without an annotation.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate.
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release is not
published: no Ingress, no HTTPRoute and no certificate.
ingress.domain is still required: it becomes Outline's URL variable, which
builds every generated link, every share URL and the OIDC redirect URI.
Reach the instance inside the cluster with
@@ -31,7 +80,9 @@ SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}outline.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, Secrets,
Service{{ if .Values.ingress.enabled }} and Ingress stay in place, so neither the uploads nor the
certificate are touched.{{ else }} and the uploads on the PVC stay in place.{{ end }}
certificate are touched.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so the uploads are not touched — the
route then resolves to a Service with no endpoints and the Gateway answers
its own error.{{ else }} and the uploads on the PVC stay in place.{{ end }}
{{ end }}
WHAT THE THREE 1PASSWORD ITEMS MUST CONTAIN
The chart creates OnePasswordItem CRs; the operator turns each into a
@@ -135,13 +186,35 @@ TWO UPLOAD CEILINGS, THE SMALLER ONE WINS
ingress controller's 413 page instead of a message from the app. With the
chart defaults the Ingress is the tighter of the two by a factor of ten.
Raise both, or neither.
{{- else if $httpRoute.enabled }}
YOUR UPLOAD CEILING IS NOT ingress.maxUploadFileSize ANY MORE — IT IS THE GATEWAY'S
outline.maxUploadSize {{ $upload }}{{ if regexMatch "^[0-9]+$" $upload }} bytes (~{{ div (int64 $upload) 1048576 }} MiB){{ else }} — Outline reads this as a plain byte count{{ end }}
ingress.maxUploadFileSize {{ .Values.ingress.maxUploadFileSize }} — NOT ENFORCED in this mode
The chart renders ingress.maxUploadFileSize into
nginx.ingress.kubernetes.io/proxy-body-size, and the Gateway API has NO
counterpart — it knows no body size at all, that is implementation-specific
policy — so this chart deliberately renders no policy object, which would
bind it to one implementation. The ceiling that actually bites is therefore
the smaller of outline.maxUploadSize and WHATEVER THE GATEWAY ENFORCES, and
the chart can only name the first of those two. Set the limit at the Gateway
or through its implementation's own policy object, next to the Gateway and
not here.
Read that next to Gitea #12, which is about exactly this pair: with the chart
defaults the two documented numbers are a factor of ten apart. In this mode
the ingress half is gone, so neither of them is necessarily the one that
decides — which moves that defect rather than fixing it. Check the Gateway's
own limit before trusting either number.
{{- else }}
UPLOAD CEILING
outline.maxUploadSize {{ $upload }}{{ if regexMatch "^[0-9]+$" $upload }} bytes (~{{ div (int64 $upload) 1048576 }} MiB){{ else }} — Outline reads this as a plain byte count{{ end }}
Without an Ingress this is the only limit the chart sets — whatever proxy
you put in front adds its own, usually much smaller, body-size cap.
With neither routing mode on, this is the only limit the chart sets —
whatever proxy you put in front adds its own, usually much smaller,
body-size cap. ingress.maxUploadFileSize ({{ .Values.ingress.maxUploadFileSize }}) is rendered nowhere.
{{- end }}
THE CHART CONFIGURES NO MAIL
+110
View File
@@ -0,0 +1,110 @@
{{/*
The Gateway-API alternative to outline.ingress.yaml (Gitea #114). Form and
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
Gateway API".
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners.
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
in this mode - and it is not demanded either: its only required() sits inside
outline.ingress.yaml, behind ingress.enabled.
ingress.domain IS STILL REQUIRED, AND NOT BECAUSE OF THIS FILE
Checked for this chart rather than copied: ingress.domain is read once more
outside both routing templates, in outline.sfs.yaml, where it builds Outline's
URL variable and carries its own required(). URL is what every generated link,
every share URL and the OIDC redirect URI are built from, so domain is
mandatory in EVERY mode of this chart - the fallback below is never its only
reader. Setting httpRoute.hostnames to a name ingress.domain does not carry
therefore publishes the route under one name while the app hands out links
under another, and an OIDC login fails on a redirect_uri the provider does not
know. Set both together.
THE UPLOAD LIMIT, AND WHY THIS MODE MOVES Gitea #12 INSTEAD OF FIXING IT
ingress.maxUploadFileSize is rendered into proxy-body-size and
nginx.org/client-max-body-size by the Ingress template. The Gateway API has NO
counterpart: a request-body limit is implementation-specific policy and lives
in an implementation's own object (ClientSettingsPolicy, ClientTrafficPolicy
and their equivalents), which this chart deliberately does not render. So the
value is DOCUMENTED as unenforced in NOTES.txt rather than rebuilt.
That matters more here than anywhere else in this sweep, because this chart is
the one with the open defect: outline.maxUploadSize is 250 MB by default while
ingress.maxUploadFileSize is 25m - a factor of ten, and the smaller number
wins (Gitea #12). In Gateway mode the ingress half of that pair DISAPPEARS, so
the effective ceiling becomes the Gateway's, which is neither of the two values
the chart documents. That CHANGES the situation of #12; it does not resolve it,
and this file deliberately does not try to: the chart would have to render a
policy object to do so, which is exactly the portability the sweep is buying.
The honest statement is in NOTES.txt for this mode: whichever of
outline.maxUploadSize and the Gateway's limit is smaller decides, and the chart
can only name one of them.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and all three defaults
are empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
even though no Ingress is rendered in this mode - the value names the public
hostname of this release, which both modes need; it is not an Ingress switch.
A hostname set explicitly here does NOT move Outline's URL variable with it:
that keeps following ingress.domain, so the two have to be set together or the
app publishes links nobody can follow.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
# backend Service - Outline serves its UI, its API and its collaborative
# WebSocket from one process on one port, so a single path covers all
# three. The port is the Service's port NUMBER and not its name ("http"):
# backendRefs takes a PortNumber, so unlike the Ingress this reference
# cannot be made by name and the 80 below has to track
# outline.service.yaml by hand.
# ci/run-httproute-match-test.sh is what holds that pair - it fails when a
# backendRef names a Service, or a port, the same render does not create.
#
# WEBSOCKETS: nothing to configure, in either mode. Outline's realtime
# editing runs over a WebSocket upgrade on the same port, and the Gateway
# API passes an upgrade through on a plain HTTPRoute - there is no
# annotation involved in Ingress mode either.
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- end }}
+32 -3
View File
@@ -90,11 +90,11 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template. false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which is also Outline's public URL.",
"description": "Gates the whole Ingress template. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. false omits the Ingress and makes clusterIssuer unnecessary - but NOT domain, which is also Outline's public URL and carries its own required() in the StatefulSet.",
"type": "boolean"
},
"maxUploadFileSize": {
"description": "Rendered into the ingress-nginx proxy-body-size annotation, so it only takes effect while the Ingress is enabled.",
"description": "Rendered into the ingress-nginx proxy-body-size and nginx.org/client-max-body-size annotations, so it takes effect only while the Ingress is enabled. NOT ENFORCED in Gateway mode (Gitea #114): the Gateway API has no body-size field, the effective ceiling is the Gateway's, and this chart deliberately renders no implementation-specific policy object to set it. Together with outline.maxUploadSize this is the pair of Gitea #12 - the smaller of the two wins, and in Gateway mode the smaller one may be neither.",
"type": ["string", "null"]
},
"clusterIssuer": {
@@ -107,13 +107,42 @@
"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.",
"description": "Extra Ingress annotations, merged with the cert-manager and proxy-body-size annotations the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead - including the upload cap the chart itself sets from ingress.maxUploadFileSize.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them. ingress.maxUploadFileSize is not enforced in this mode.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": {
"$ref": "#/definitions/parentRef"
}
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. A hostname set here does not move Outline's URL variable, which keeps following ingress.domain - set both together or the app hands out links under the old name.",
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"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"],
+75
View File
@@ -88,11 +88,86 @@ outline:
ephemeralStorage: 10Mi
ingress:
# Set to false to omit the Ingress entirely. clusterIssuer is then not needed
# either, because it is only read inside the Ingress template. ingress.domain
# is a different case: it is also Outline's URL variable (see below) and
# stays mandatory in every mode.
#
# Mutually exclusive with httpRoute.enabled below (Gitea #114): with both
# true the render aborts, naming both values.
enabled: true
# The request-body cap, and it lives entirely in the INGRESS: the chart
# renders it into nginx.ingress.kubernetes.io/proxy-body-size and
# nginx.org/client-max-body-size, so it is enforced by the nginx-family
# controllers and by nothing inside Outline.
#
# TWO CEILINGS APPLY TO AN UPLOAD AND THE SMALLER ONE WINS: this value and
# outline.maxUploadSize above, which default to 25m and ~250 MB - a factor of
# ten apart, with the ingress limit the tighter one (Gitea #12, open). In
# GATEWAY MODE this half disappears: the Gateway API has no body-size field,
# the value is NOT ENFORCED, and the ceiling becomes whatever the Gateway
# enforces - which is neither of the two numbers this chart documents. That
# moves #12 rather than resolving it; NOTES.txt says so in that mode.
maxUploadFileSize: 25m
clusterIssuer: null
# The public hostname of this release. Read by the Ingress, by Outline's URL
# variable in the StatefulSet (with its own required()), and - when
# httpRoute.hostnames is empty - by the HTTPRoute. Required in every mode.
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.
#
# THESE HAVE NO COUNTERPART IN GATEWAY MODE. The Gateway API has no
# annotation channel for controller behaviour, so anything configured here is
# simply gone when httpRoute.enabled is used instead - it is then the
# Gateway's business, or that of an implementation-specific policy object
# this chart deliberately does not render. For THIS chart that includes the
# upload cap above.
annotations: {}
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
# an existing release is unaffected; exactly one of ingress.enabled and
# httpRoute.enabled may be true.
#
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
# gatewayClassName, no Certificate and no implementation-specific policy
# object. Class and certificate belong to the Gateway and therefore to the
# platform, which is what keeps this chart independent of whether the cluster
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
#
# WHAT THIS MODE COSTS HERE: ingress.maxUploadFileSize is not enforced, so the
# upload ceiling is the Gateway's. Check it before switching - this chart is
# the one with the open upload defect (#12), and in this mode neither of the
# two numbers the chart knows is necessarily the one that bites.
#
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
# cannot detect them (.Capabilities.APIVersions is unreliable at template
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
httpRoute:
enabled: false
# The Gateway(s) this route attaches to - a platform object, usually in
# another namespace, which must allow this namespace in its listener's
# allowedRoutes. One entry per parent, the fields are the Gateway API's
# ParentReference (name, and optionally namespace, sectionName, port, group,
# kind), passed through unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
#
# For THIS chart, mind that a second hostname does not move Outline's URL
# variable with it: it keeps following ingress.domain, so every generated
# link, every share URL and the OIDC redirect URI still name the old host
# until ingress.domain is changed too.
hostnames: []
+1 -1
View File
@@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s
type: application
version: 12.2.0+up3.2.1
version: 13.0.0+up3.2.1
appVersion: 3.2.1
+168 -21
View File
@@ -8,12 +8,21 @@
{{- $valkeySvc := printf "%s-valkey-service" .Release.Name -}}
{{- $pre := default (list) $scripts.preConsumptionScripts -}}
{{- $post := default (list) $scripts.postConsumptionScripts -}}
{{- $cron := fromYaml (include "paperless-ngx.cron.scheduling" .Values.smbConnection.cron) -}}
{{- $cronPinned := $cron.pinToServerNode -}}
{{- $cronDeadline := include "paperless-ngx.formatNumber" $cron.activeDeadlineSeconds -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
Paperless-ngx {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
ACCESS
{{- if .Values.ingress.enabled }}
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }}
This chart has no ingress.enabled switch — the Ingress is always rendered.
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
@@ -32,12 +41,87 @@ ACCESS
PAPERLESS_CSRF_TRUSTED_ORIGINS through paperless.env for as long as you need
it. Changing ingress.domain later changes PAPERLESS_URL with it, and the
pods have to restart before they notice.
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING
else of the routing path — no Gateway, no class, no Certificate. Three things
therefore belong to the cluster and not to this chart, and if any is missing
the address above does not answer:
1. The Gateway-API CRDs. Without them this install would have failed.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
up only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment. An empty status means no
controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
ingress.domain is still required in this mode and is {{ .Values.ingress.domain }}: it is also
PAPERLESS_URL ("https://{{ .Values.ingress.domain }}"), the only origin this release declares to
Django, and the route does not replace it. If you set httpRoute.hostnames to
a name ingress.domain does not carry, the route publishes one host while
Django trusts another — pages load and every login POST is refused as a CSRF
failure. Set both together, and remember the pods have to restart before they
notice a changed domain.
YOUR WEB-UI UPLOAD LIMIT IS NOT paperless.files.maxUploadFileSize ANY MORE.
That value ({{ .Values.paperless.files.maxUploadFileSize }}) is NOT ENFORCED in this mode. The chart renders it
into nginx.ingress.kubernetes.io/proxy-body-size, and the Gateway API has NO
counterpart — it knows no body size at all, that is implementation-specific
policy — so this chart deliberately renders no policy object, which would
bind it to one implementation. Whatever limit the Gateway enforces caps
uploads through the web UI, and the symptom is the proxy's error rather than a
message from paperless. Set it at the Gateway or through its
implementation's own policy object, next to the Gateway and not here.
The consume directory is unaffected: it passes no proxy and has no size limit
in any mode, so a document too large for the web UI still gets in that way.
ingress.annotations is inert in this mode for the same reason — the Gateway
API has no annotation channel.
{{- else }}
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release is not
published: no Ingress, no HTTPRoute and no certificate, and
ingress.clusterIssuer is not needed.
ingress.domain IS still required and is {{ .Values.ingress.domain }}: the chart hands it to the
container as PAPERLESS_URL ("https://{{ .Values.ingress.domain }}"), the only origin this
release declares. Reach the instance inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8000:80
Pages then load over http://localhost:8000, but expect the login POST to be
refused as a CSRF failure — that origin is not PAPERLESS_URL. Add
PAPERLESS_CSRF_TRUSTED_ORIGINS through paperless.env for as long as you need
it. paperless.files.maxUploadFileSize ({{ .Values.paperless.files.maxUploadFileSize }}) does nothing in this mode:
it exists only as an ingress-nginx annotation.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}paperless.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so the URL above
does not answer until you set it back and upgrade again. PVCs, Secrets,
Service and Ingress stay in place, so neither the documents nor the
certificate are touched.
Service{{ if .Values.ingress.enabled }} and Ingress stay in place, so neither the documents nor the
certificate are touched.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so the documents are not touched —
the route then resolves to a Service with no endpoints and the Gateway
answers its own error.{{ else }} stay in place, so the documents are not touched.{{ end }}
{{- if .Values.smbConnection.enabled }}
{{- if .Values.scaleDown }}
scaleDown also suspends the SMB consume CronJob, so nothing is moved off the
@@ -103,15 +187,14 @@ WHAT THE 1PASSWORD ITEMS MUST CONTAIN
here, so a wrong field name surfaces as a mount failure on the CronJob's
pod — a describe on that pod, never a missing variable.
{{- end }}
{{- if .Values.secrets.oidcSecretRef }}
secrets.oidcSecretRef IS SET AND IS READ BY NOTHING
"{{ .Values.secrets.oidcSecretRef }}" — no template in this chart references
that value. No OnePasswordItem is created for it and no Secret appears. The
OIDC client id travels as plain env from oidc.provider.clientId, and no
client secret is configured at all (see the OIDC section below). Whatever
you put in that item has no effect on this release.
{{- end }}
{{- /*
The warning that used to stand here - "secrets.oidcSecretRef IS SET AND IS READ
BY NOTHING" - is gone with the value itself (13.0.0, Gitea #11). It cannot be
printed any more and would not be reached: the key is out of values.schema.json
too, so a release that still sets it fails BEFORE any template renders, with
"additional properties 'oidcSecretRef' not allowed". The fix is to delete the
key; values.yaml says why it is not coming back.
*/}}
CHANGING A SECRET'S CONTENT RESTARTS NOTHING
Helm never sees the content of a OnePasswordItem, so it cannot hash it, and
@@ -227,11 +310,24 @@ HOW DOCUMENTS GET IN
paperless.files.storage ({{ .Values.paperless.files.storage }}) sizes only the main volume, which holds
data/, media/ and export/ as subPaths.
{{- if .Values.ingress.enabled }}
Uploads through the web UI take the second route and pass the Ingress, where
the chart sets a body-size cap of {{ .Values.paperless.files.maxUploadFileSize }} (paperless.files.maxUploadFileSize,
rendered into both the nginx.ingress and nginx.org annotations). A larger
file is rejected by the ingress controller with a 413 before paperless ever
sees it — the consume directory has no such limit.
{{- else if $httpRoute.enabled }}
Uploads through the web UI take the second route, and in GATEWAY MODE
paperless.files.maxUploadFileSize ({{ .Values.paperless.files.maxUploadFileSize }}) is NOT ENFORCED: it exists only as
an ingress-nginx annotation and the Gateway API has no body-size field, so
the cap is whatever the Gateway enforces. The consume directory has no limit
in either mode.
{{- else }}
Uploads through the web UI take the second route, but nothing publishes this
release and paperless.files.maxUploadFileSize ({{ .Values.paperless.files.maxUploadFileSize }}) is rendered nowhere:
it exists only as an ingress-nginx annotation. The consume directory has no
limit either way.
{{- end }}
{{- if .Values.smbConnection.enabled }}
SMB CONSUME IS ON — AND IT MOVES ONLY TOP-LEVEL PDFs
@@ -246,12 +342,44 @@ HOW DOCUMENTS GET IN
move, not a copy: once the job has run, the share no longer holds the file
and the only copy is on the consume volume.
One thing to know before you edit that schedule: it is rendered into the
manifest unquoted, so a schedule that STARTS with "*" ("*/15 * * * *")
aborts the render with a YAML parse error on this template — YAML reads the
leading * as an alias. Quoting it in your values file does not help, the
quotes are gone by then. Use an equivalent that starts with a digit
("5,20,35,50 * * * *"); a "*" anywhere but in first position is fine.
The schedule is quoted on the way into the manifest since 13.0.0, so any
cron notation works — one starting with "*" or an "@daily" macro included.
Before that such a schedule aborted the render with a YAML parse error
pointing at the template, and the advice here was to avoid it; if you are
carrying a workaround schedule from that era ("5,20,35,50 * * * *" instead
of every 15 minutes), you can write what you meant now.
{{- if $cronPinned }}
PLACEMENT: the mover runs on the NODE OF THE STATEFULSET POD, via a
required podAffinity on its label (Gitea #101). Without that it lands
anywhere, and because it mounts the consume volume — RWX, from Longhorn —
the kubelet of the StatefulSet's node then retries the mount every two
minutes with "no Pending workload pods for volume ... to be mounted". It
was measured at 291 such events in 10 hours.
THE PRICE, and it is the one thing to know before scaling down: with no
StatefulSet pod the mover CANNOT BE SCHEDULED. scaleDown: true suspends
this CronJob outright, so that case is covered — but paperless.replicas: 0
with SMB still on is not, and there the job fails once per schedule after
activeDeadlineSeconds ({{ $cronDeadline }}s) with reason DeadlineExceeded. That is
deliberate: a bounded, visible failure rather than a Pending pod per
schedule that nobody sees. Switch smbConnection.cron.pinToServerNode to
false to go back to unpinned placement — and to the mount errors.
What the pinning does NOT fix: the consume volume stays RWX with a
share-manager behind it. Co-locating the two consumers removes the
cross-node mount, not the second consumer.
{{- else }}
PLACEMENT IS OFF (smbConnection.cron.pinToServerNode: false). The mover
will be scheduled wherever there is room, and when that is not the node of
the StatefulSet pod, Longhorn refuses to mount the shared RWX consume
volume there: the kubelet of the StatefulSet's node retries every two
minutes with "no Pending workload pods for volume ... to be mounted"
(Gitea #101, measured at 291 events in 10 hours). The errors stop when the
StatefulSet pod is deleted and comes back Pending, and return with the next
firing of this job.
{{- end }}
The job has NO probes (the documented exception to the chart conventions),
and it cannot fail usefully either: with nothing to move it exits 0, which
@@ -338,8 +466,13 @@ OIDC IS ON, AND IT IS PKCE-ONLY
{{- if not .Values.oidc.provider.tokenAuthMethod }}
oidc.provider.tokenAuthMethod is unset. Since the allauth update in 3.0 some
providers need it spelled out (client_secret_basic); the symptom is a login
that dies at the callback with invalid_client.
providers need it spelled out, and the symptom when they do is a login that
dies at the callback with invalid_client. The only value this chart accepts is
"none" — it states the public client explicitly. A method that authenticates
WITH a client secret cannot work here and is rejected by the schema (13.0.0,
Gitea #11): this release configures no client secret and cannot, because
paperless reads the whole OIDC configuration out of one json environment
variable with no _FILE variant.
{{- end }}
{{- if or .Values.oidc.autoLoginWithProvider .Values.oidc.disableRegularLogin }}
@@ -359,6 +492,20 @@ paperless.scripts IS SET AND DOES NOTHING
PAPERLESS_PRE_CONSUME_SCRIPT nor PAPERLESS_POST_CONSUME_SCRIPT is set on the
container. These values render no object and change no behaviour.
{{- end }}
{{- if .Values.paperless.env }}
paperless.env ADDS {{ len .Values.paperless.env }} VARIABLE(S) — AND AN OVERRIDE ONLY BITES AT INSTALL
{{ range $i, $e := .Values.paperless.env }}{{ if $i }}, {{ end }}{{ $e.name }}{{ end }}
These are appended AFTER the variables the chart sets itself, so at INSTALL an
entry repeating one of those names wins. On an upgrade of 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. Read the value
back off the StatefulSet, never off the upgrade's exit code:
kubectl -n {{ .Release.Namespace }} get statefulset {{ .Release.Name }}-{{ .Chart.Name }}-sfs \
-o jsonpath='{.spec.template.spec.containers[0].env}'
{{- end }}
{{- if gt (int $replicas) 1 }}
paperless.replicas IS {{ $replicas }} — EXPECT ONE POD TO WORK AND THE REST TO WAIT
+38
View File
@@ -340,3 +340,41 @@ The same three fallbacks for the SMB consume-mover CronJob. It has no probes
{{- $merged := fromYaml (include "paperless-ngx.defaultedDict" (dict "defaults" $defaults "given" . "path" "smbConnection.cron.resources")) -}}
{{- include "paperless-ngx.resources" $merged -}}
{{- end -}}
{{/*
The whole smbConnection.cron block, merged onto its defaults (YAML; consume
with fromYaml).
Input: .Values.smbConnection.cron (may be nil, empty or partial).
WHY EVERY READ OF THAT BLOCK GOES THROUGH HERE. The CronJob template used to
dereference .Values.smbConnection.cron.<key> directly, so an override that had
erased the block - the leftover childless
cron:
that issue #61 is about - did not fall back to the defaults the way this chart
promises everywhere else. It aborted the render with
nil pointer evaluating interface {}.securityContext
naming an internal template path and neither the value nor the cause. Measured
on origin/main before 13.0.0, so this is a fix and not a precaution.
The scalar defaults below MUST stay in sync with the smbConnection.cron block
in values.yaml, which stays the documented, user-facing place for them; this
copy is only the fallback. The sub-blocks (securityContext, resources, env)
keep their own defaults in the helpers above and are only passed through here.
An explicit "pinToServerNode: false" survives this, which is the whole reason
it is not a "default true" pipe: sprig's default would read false as unset and
silently turn the pinning back on - the one value whose explicit false has to
be obeyed, since it exists to switch off a hard placement rule.
*/}}
{{- define "paperless-ngx.cron.scheduling" -}}
{{- $defaults := dict
"pinToServerNode" true
"startingDeadlineSeconds" 600
"activeDeadlineSeconds" 3600 -}}
{{- include "paperless-ngx.defaultedDict" (dict "defaults" $defaults "given" . "path" "smbConnection.cron") -}}
{{- end -}}
@@ -1,26 +1,165 @@
{{- if .Values.smbConnection.enabled }}
{{- /*
The cron block, merged onto its defaults, so that an override which erased it
falls back instead of aborting with a nil-pointer error naming an internal
template path - which is what every direct .Values.smbConnection.cron.<key>
read did before 13.0.0. The helper carries the measurement.
*/}}
{{- $cron := fromYaml (include "paperless-ngx.cron.scheduling" .Values.smbConnection.cron) -}}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-consume-cronjob
spec:
schedule: {{ .Values.smbConnection.cronJob }}
{{- /*
QUOTED, AND THAT IS THE WHOLE OF GITEA #11's FIRST HALF. The default
"0 0,12 * * *" renders safely only because it begins with a digit. The most
common cron notation of all - a star, a slash and an interval, every 15
minutes - begins with "*", which YAML reads as an alias indicator, and the
render ABORTS:
(and the notation cannot be written out in this comment, because the two
characters that start it would end a Go template comment right here)
Error: YAML parse error on paperless-ngx/templates/paperless-consume.cronjob.yaml:
error converting YAML to JSON: yaml: line 6: did not find expected
alphabetic or numeric character
The trap is that quoting it in the VALUES does not help: Helm passes the
string's content through, and the star stands bare in the manifest again. So
the operator writes a correctly quoted value and gets an error that points at
this template instead of at their value. "@daily" fails the same way ("@" is
a reserved YAML indicator).
ci/run-yaml-scalar-render-test.sh is the test that would have caught it, and
ci/yaml-scalar/paperless-ngx.yaml is the overlay that feeds it a star-leading
schedule - needed because this whole template sits behind
smbConnection.enabled, which is false in every other fixture, so nothing else
in the repository renders this object at all.
*/}}
schedule: {{ .Values.smbConnection.cronJob | quote }}
{{- /*
Forbid, hard-wired rather than a value: two movers running at once race on
the same files ("mv /mnt/smb/*.pdf" twice over one directory), and the loser
reports a file that is already gone. There is no configuration in which
overlapping runs of this job are wanted, so there is nothing to configure.
*/}}
concurrencyPolicy: Forbid
{{- /*
Bounds how late a missed run may still start, and it is here for a second
reason that has nothing to do with lateness: while the CronJob is suspended
(scaleDown) or its runs are being suppressed, the controller counts missed
schedules, and above 100 it STOPS SCHEDULING ALTOGETHER with only
"Cannot determine if job needs to be started: too many missed start times" in
the controller log. With this field set, the controller only counts misses
inside this window, so that state cannot be reached. The cost is stated in
values.yaml: a run that could not start inside the window is skipped, not
queued.
*/}}
startingDeadlineSeconds: {{ include "paperless-ngx.formatNumber" $cron.startingDeadlineSeconds }}
{{- if .Values.scaleDown }}
suspend: true
{{- end }}
jobTemplate:
spec:
{{- /*
THE SAFETY NET UNDER THE AFFINITY BELOW (Gitea #101). activeDeadlineSeconds
counts from the job's start, not from the pod's, so it also bounds a pod
that NEVER gets scheduled: when the StatefulSet pod is absent and the
required affinity cannot be satisfied, the job fails after this many
seconds with reason DeadlineExceeded instead of sitting Pending forever
and gaining a sibling every schedule. That turns the one new failure mode
this issue's fix could introduce into a visible, self-clearing one -
failedJobsHistoryLimit (Kubernetes' default, 1) keeps the evidence.
*/}}
activeDeadlineSeconds: {{ include "paperless-ngx.formatNumber" $cron.activeDeadlineSeconds }}
template:
spec:
{{- if $cron.pinToServerNode }}
{{- /*
GITEA #101: this pod mounts the consume volume, which is RWX and comes
from Longhorn. Without a placement rule the scheduler puts it on any
node, and Longhorn's CSI driver then refuses the mount on the node
where the StatefulSet pod runs:
MountVolume.SetUp failed ... code = Aborted desc = no Pending
workload pods for volume pvc-... to be mounted:
map[Running:[...-sfs-0] Succeeded:[...-consume-cronjob-...]]
Measured at every two minutes, 291 events in 10 hours, in both
releases that have SMB switched on; the release with
smbConnection.enabled: false and therefore no CronJob produced none of
them on identically built volumes.
WHY podAffinity AND NOT nodeSelector: the StatefulSet pod is not
pinned either - it sits wherever the scheduler put it and moves on
every restart. A static nodeSelector would be correct exactly until
then and silently wrong afterwards. The affinity follows the pod.
WHY required AND NOT preferred: preferred falls back to another node
in the very situation this exists for, which reproduces the bug more
rarely and therefore harder to find. The fallback is what went
unnoticed for ten hours.
The label is the one the StatefulSet puts on its pod template
(paperless-server.sfs.yaml, metadata.labels.app) and is read off the
running pod, not assumed.
WHAT THIS DOES NOT FIX: the volume stays RWX and keeps its
share-manager; co-locating the two consumers removes the CROSS-NODE
attach, not the second consumer. The design that removes it is moving
the mv into the StatefulSet pod, which cannot be done for the running
installations because RWX cannot be taken back off an existing PVC.
Gitea #101 names it as the alternative and not as the proposal.
*/}}
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
topologyKey: kubernetes.io/hostname
{{- end }}
automountServiceAccountToken: false
securityContext:
{{- include "paperless-ngx.cron.podSecurityContext" .Values.smbConnection.cron.securityContext | nindent 12 }}
{{- include "paperless-ngx.cron.podSecurityContext" $cron.securityContext | nindent 12 }}
containers:
- name: paperless-consume-cronjob
image: "{{ .Values.paperless.busybox.image.repository }}:{{ .Values.paperless.busybox.image.tag }}"
imagePullPolicy: IfNotPresent
securityContext:
{{- include "paperless-ngx.cron.containerSecurityContext" .Values.smbConnection.cron.securityContext | nindent 16 }}
{{- include "paperless-ngx.cron.containerSecurityContext" $cron.securityContext | nindent 16 }}
{{- if $cron.env }}
env:
{{- range $value := $cron.env }}
{{- if $value.value }}
{{- /*
quote is required, not cosmetic: a value like "8080" or "true"
renders unquoted as a YAML number/bool, and the API server then
rejects the whole manifest with "cannot unmarshal number into
Go struct field EnvVar.value of type string". A template
comment and not a "#" one, because a "#" line here is part of
the rendered manifest.
*/}}
- name: {{ $value.name | quote }}
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 }}
{{- end }}
command:
- /bin/sh
- -c
@@ -33,7 +172,7 @@ spec:
mountPath: "/mnt/consume"
readOnly: false
resources:
{{- include "paperless-ngx.cron.effectiveResources" .Values.smbConnection.cron.resources | nindent 16 }}
{{- include "paperless-ngx.cron.effectiveResources" $cron.resources | nindent 16 }}
restartPolicy: Never
volumes:
- name: smb-share
@@ -140,8 +140,21 @@ spec:
value: "{{ .Values.paperless.ocr.output_type }}"
- name: PAPERLESS_FILENAME_FORMAT
value: "{{ .Values.paperless.files.filenameFormat }}"
{{- /*
ingress.domain is not a pure Ingress value: it is also
PAPERLESS_URL, the ONLY origin this release declares to Django, so
it stays required when ingress.enabled is false and in Gateway mode
(#114). The required() is here and not only in the Ingress template
because this is the read that cannot be switched off - an empty
value would render "https://" and every login POST would be refused
as a CSRF failure, with nothing in the log naming a chart value.
A template comment and not a YAML one on purpose: a "#" line here
is part of the rendered manifest, and this change has to leave
`helm template` byte-identical for unchanged values.
*/}}
- name: PAPERLESS_URL
value: "https://{{ .Values.ingress.domain }}"
value: "https://{{ required "A Domain/Host must be provided (ingress.domain) - it is also PAPERLESS_URL, the only origin this release declares" .Values.ingress.domain }}"
- name: PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE
value: "{{ $barcodes.asn.enabled }}"
- name: PAPERLESS_CONSUMER_ENABLE_BARCODES
@@ -191,23 +204,41 @@ spec:
- name: PAPERLESS_DISABLE_REGULAR_LOGIN
value: "{{ .Values.oidc.disableRegularLogin }}"
{{- end }}
{{- /*
QUOTED SINCE 13.0.0, and it is a fix rather than a tidy-up (Gitea
#112, same genus as #11). This block is the chart's additive env
interface, and until 13.0.0 it rendered its operand bare. Two
measured consequences, both of them the operator's value arriving
as something else:
- a value beginning with "*" aborted the render, with the error
pointing at THIS template rather than at the value;
- "true" and "0755" rendered as a YAML bool and an integer, and
the API server rejects those with "cannot unmarshal number
into Go struct field EnvVar.value of type string" - the chart
renders, the install fails.
The quoted form is the repository's documented one
(template-chart/templates/xxx.deployment.yaml carries the rule);
this chart was one of seven that had not been brought to it.
*/}}
{{- range $value := .Values.paperless.env }}
{{- if $value.value }}
- name: {{ $value.name }}
value: {{ tpl $value.value $ }}
- name: {{ $value.name | quote }}
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ }}
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 $ }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }}
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,112 @@
{{/*
The Gateway-API alternative to paperless.ingress.yaml (Gitea #114). Form and
reasoning: template-chart/templates/xxx.httproute.yaml and README, "Ingress and
Gateway API".
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners.
THIS CHART NEEDED AN ingress.enabled SWITCH BEFORE THIS FILE COULD EXIST
It was the only chart here still rendering its Ingress unconditionally, so
there was no second value path for the mutual-exclusion abort to name - and
in Gateway mode the Ingress would have kept rendering, together with the
unconditional required() on ingress.clusterIssuer, i.e. a value demanded for
nothing. The switch was added with this file, defaulting to true so an existing
release renders byte-identically. See the header of paperless.ingress.yaml.
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
in this mode - and since the switch exists it is no longer demanded either: its
required() now sits behind ingress.enabled.
ingress.domain IS STILL REQUIRED, AND NOT BECAUSE OF THIS FILE
Checked for this chart rather than copied: ingress.domain is read once more
outside both routing templates, in paperless-server.sfs.yaml, where it becomes
PAPERLESS_URL - the ONLY origin this release declares to Django. That read used
to rely on the Ingress template's required() firing first, which stopped being
true the moment the Ingress became switchable, so it now carries its own
required(). Without it an empty domain would render "https://" and every login
POST would be refused as a CSRF failure, with nothing in the log naming a chart
value. Consequence in this mode: a hostname in httpRoute.hostnames that
ingress.domain does not carry publishes the route under one name while Django
trusts another, and the login breaks exactly that way. Set both together, and
remember the pods have to restart before they notice a changed domain.
THE UPLOAD LIMIT IS WHAT THIS MODE LOSES, AND IT IS NOT REBUILT
paperless.files.maxUploadFileSize is rendered into proxy-body-size and
nginx.org/client-max-body-size by the Ingress template. The Gateway API has NO
counterpart - a request-body limit is implementation-specific policy and lives
in an implementation's own object (ClientSettingsPolicy, ClientTrafficPolicy
and their equivalents), which this chart deliberately does not render. So the
value is DOCUMENTED as unenforced in NOTES.txt rather than rebuilt.
The second route documents is unaffected, and that is worth knowing here: the
consume directory (and the SMB CronJob that feeds it) has no size limit at all
and does not pass any proxy, so in Gateway mode a document too large for the
web UI still gets in that way.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and all three defaults
are empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both. Note that ingress.enabled defaults to TRUE in this chart, so setting httpRoute.enabled alone is not enough - switch the Ingress off explicitly." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
even though no Ingress is rendered in this mode - the value names the public
hostname of this release, which both modes need; it is not an Ingress switch.
A hostname set explicitly here does NOT move PAPERLESS_URL with it, which is
why the two have to be set together in this chart.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
# backend Service - paperless serves its UI, its API and the document
# downloads from one process on one port, so a single path covers them.
#
# The port is the Service's port NUMBER, not its name: the Ingress
# references it as "pless-s-port" and backendRefs takes a PortNumber, so
# this reference cannot be made by name and the 80 below has to track
# paperless.service.yaml by hand. Three different names describe the same
# hop in this chart (service port "pless-s-port", targetPort
# "paperless-port", container port), which is exactly why the number is
# kept under a test rather than under review:
# ci/run-httproute-match-test.sh fails when a backendRef names a Service,
# or a port, the same render does not create.
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- end }}
+30 -2
View File
@@ -1,11 +1,38 @@
{{/*
THIS CHART GAINED AN ingress.enabled SWITCH WITH THE GATEWAY-API SWEEP (#114),
AND IT IS THE ONLY CHART IN THE REPOSITORY THAT WAS STILL MISSING ONE.
It defaults to true, so an existing release renders byte-identically and the
addition is additive. It is not cosmetic uniformity: the Gateway mode is
defined as mutually exclusive with the Ingress, the render abort has to name
BOTH value paths, and there was no second path to name. Worse, without the
switch the Ingress would keep rendering in Gateway mode, and with it the
unconditional required() on ingress.clusterIssuer below - a value that demands
to be set and then does nothing, which is the defect README guideline 12 and
issue #114's "Folge 1" exist to remove. The switch is what makes that required()
conditional, exactly as every other chart here has it.
What does NOT become optional with the Ingress off: ingress.domain. It is also
PAPERLESS_URL (paperless-server.sfs.yaml), the only origin this release
declares to Django, and it therefore carries its own required() there.
*/}}
{{- 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 }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.paperless.files.maxUploadFileSize }}
nginx.org/client-max-body-size: {{ .Values.paperless.files.maxUploadFileSize }}
{{- /*
Quoted for the same reason as the env block in the StatefulSet and the
schedule in the CronJob (Gitea #11's genus): an annotation value must be a
string, maxUploadFileSize is free operator text, and a unit-less "100" -
schema-valid, since the schema types it as a string - renders bare as an
integer and the API server rejects the whole Ingress. The default "10m"
happens to be safe, which is exactly how this survived.
*/}}
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.paperless.files.maxUploadFileSize | quote }}
nginx.org/client-max-body-size: {{ .Values.paperless.files.maxUploadFileSize | quote }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
@@ -26,3 +53,4 @@ spec:
- hosts:
- {{ required "A Domain/Host must be provided" .Values.ingress.domain }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more