stalwart
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
Clone

Wiki Page Revisions

9 Commits

Author SHA1 Message Date
jklein 81980279c4 NetworkPolicies in every chart (Gitea #5): the convention, the gate, and what it does not do
29 chart pages plus Chart-Style-Guide, CI-CD, Charts and Lessons-Learned.

Chart-Style-Guide gains section 15, and its core is the FOUR PORT CLASSES -
the part that is actually hard to get right. A port a network probe addresses
stays open until probeSources is set, because the kubelet probes from the
worker node's IP and no chart can know it. A NodePort-published port stays open
because the source is already masqueraded. Everything else can be and is
restricted by source, which is why the charts with a real inbound restriction
are exactly those whose probes use a different port. A replica-to-replica bus
gets a bare podSelector - and, since policyTypes lists Egress, its outbound
side needs a rule of its own or the cluster never forms while every node looks
healthy.

The exec-probe distinction is written down as load-bearing rather than
pedantic: it is the whole reason valkey and minecraft-server are the tightest
policies in the repository and every HTTP-probed web app is looser.

CI-CD gains the networkpolicy job as section 8 (the later two renumbered), and
it ends with the measurement rather than a claim. Two experiments ran on the PR
and were reverted: cyberchef's probe port restricted to an address no probe can
come from stayed GREEN at 1m9s, and outline with its DNS egress rule removed
went RED at 5m59s. Together those say k3s exempts the kubelet's probe traffic
while enforcing everything else - so the install-test's net is an EGRESS net,
and a wrong ingress port is caught by the render assertion or not at all.

Every chart page gets a networkPolicy values table and an operating note naming
that chart's own trap, not a boilerplate one:

- keycloak carries the blast radius: it is the cluster's identity provider, its
  http rule is the only source-restricted inbound rule in the repo, and nothing
  in CI can catch a wrong controller - so the page asks for a real login and a
  look at consumers' issuer URLs after the first upgrade.
- garage asks the reader to check their S3 consumers, because anything in
  another namespace is blocked until it is in allowedNamespaces.
- bulwark names the quietest failure in the sweep: its probes test its own port,
  so a jmapServerUrl on a blocked port yields a Ready pod serving an empty
  webmail.
- gotenberg explains what its DNS-only cap COSTS - HTML conversion silently
  loses remote assets - rather than only what it buys.
- satisfactory and homeassistant each argue their own Ingress-only policy;
  satisfactory's deciding half is that it is the one chart with no install-test
  at all, so an egress cap there would be the only one with no empirical check
  behind it.
- paperless-ngx names the two pods of its release that the object does NOT
  cover, including the consume CronJob whose pod template carries no labels.
- freesailarr explains why ten containers behind a VPN need only three egress
  rules, and that extraPorts cannot help with an alternative WireGuard port
  because it is TCP-only.

valkey and stalwart are corrected rather than extended. valkey's page now says
why having no probeSources is what makes it the tightest policy here and not an
incomplete one. stalwart's carries a dated correction: this page and the chart
both advised putting the ingress controller's namespace into allowedNamespaces
with ["ingress-nginx"] as the example, and both halves are wrong - ingress-nginx
is gone from the cluster entirely, and allowedNamespaces would have opened http
to every pod in that namespace anyway.

Charts drops the claim that valkey is "the first chart with a NetworkPolicy on
by default" and says instead that every chart has one, with the two shapes a
reader needs before opening any chart page.

Lessons-Learned gains four, all transferable:

- the net that existed for a different failure than the one it was built for,
  and why both experiments were needed before either could be read;
- a comment that could not describe its own syntax (a template comment must not
  spell out its own closing delimiter; the render then fails pointing at the
  template, not at the sentence);
- three values that reached no rule and would have read as controls - a
  convention about value NAMES is not a convention about which values exist;
- the subchart whose comments the parent could not fix, and the rule it
  produced: before making a check a gate, ask which failures the author of this
  diff can actually fix.
2026-10-14 14:57:00 +00:00
jklein 6c1c01453e The chart bump follows the app bump (Gitea #128)
The decision from issue #128: app major -> chart major, app minor ->
chart MINOR, patch/digest -> chart patch, as a maximum against the
chart's own classification rather than a replacement. The level comes
from Renovate's update type, since an appVersion comparison is blind to
the update that prompted it - freesailarr's appVersion never moves.

Versioning carries the rule, Renovate the hook and its measurements,
Workflow the wiki exception where "plain" is now mechanically defined,
CI-CD the new bump-level job, and Lessons-Learned the decision together
with its limit: the reading duty was rejected, so the incident that
lesson is about stays repeatable and the honest number is the whole of
the remedy.

Chart-Style-Guide, ytdlp-web-player, bulwark and stalwart each carried
a sentence calling the hook's result a chart patch, which it no longer
always is.
2026-10-13 18:48:54 +00:00
jklein 7159e15564 Style guide: repair the index-naming rule's reason, and align stalwart on index
The rule read "acceptable only because a route holds no state and no
certificate". That was true of homeassistant, the chart it was written for,
and false of every chart it was later applied to: keycloak's realm
Ingresses (#122) and stalwart's MTA-STS Ingresses (#123) both own
cert-manager Certificates, so the rule's own precondition excluded the
cases it was being cited for.

It surfaced only because the two rounds ran in parallel and answered the
same sentence differently - keycloak by index citing the rule, stalwart by
issuer citing the rule's precondition. The maintainer settled it on index
(keycloak was already published) and the REASON is what gets corrected
here, not the naming of two charts.

The reason that holds, in three steps kept apart because their standing
differs: cert-manager here runs without --enable-certificate-owner-ref
(MEASURED - absent from the Deployment args and from the
ControllerConfiguration, which carries only kind and enableGatewayAPI);
by that default the Certificate is collected with its Ingress but the
SECRET IS NOT (DOCUMENTED behaviour); and no rename was ever performed
(NOT APPLIED). So a rename recreates the Certificate against a surviving
secret and issues nothing - and the promise hangs on a cluster flag this
repository does not control.

Also: stalwart added to that rule's examples; the per-entry-issuer
paragraph corrected from "name after the issuer" to "name by index" with
the obligation that follows (NOTES.txt must print index -> issuer -> hosts,
since the name no longer carries it); the sharper reorder hazard recorded
(the index runs over groups derived from the list, so a reorder can
renumber objects that did not move); and the note that the index question
only arises where a mode renders one object per entry - stalwart's Gateway
mode renders a single route, so it has the question on the Ingress side
only. homeassistant stays named as the certificate-free original case.

Lessons learned: my own point 3 said the opposite and is replaced by what
the round actually taught - a rule can be right while its written reason is
wrong, a justification is load-bearing because it is what someone reaches
for when the rule meets an unforeseen case, and parallel rounds are a
conformance test nobody wrote (sequentially the second would have followed
the first and the broken reason would have survived).

stalwart: one half-sentence that was missing from "TLS belongs to the
platform" - cert-manager runs here with enableGatewayAPI: true, so the
listener certificates can be issued by it from an annotation on the
Gateway. The work moves to the Gateway object rather than disappearing.
2026-10-13 11:04:01 +00:00
jklein 28db5e2ba4 stalwart: MTA-STS routes as a maintainable list, in both routing modes
Gitea #123. The policy route for mta-sts.<domain> moves out of
jk-cluster-config into the chart as mtaSts.hosts.

stalwart: a new mtaSts values section - the entry form, the two object
shapes and why they differ, the hardwired RFC 8461 path, the rejected
certificate.extraDnsNames alternative, and the migration order. Three new
traps: the chart registers a route and does not create a domain (404 at a
correctly routed endpoint, silent on the sender's side); a parent wildcard
does not cover mta-sts.sub.<domain>; and why one Ingress per cluster issuer
cannot be one object. CI section: 2 -> 4 assertions, plus the install-test
covering the Ingress half the overlay cannot reach.

CI-CD: 58 assertions over 21 charts, why the chart count does not move, and
the note that an overlay never covers the Ingress half of a both-modes
feature.

Chart-Style-Guide: section 4 gains the rule for a host list with a
per-entry secret AND a per-entry issuer, and the rule that a both-modes
feature must render in both - inert and documented where one mode cannot
carry a part of it, never required.

Lessons-Learned: the two fields cert-manager reads at different scopes.
The annotation is object-scoped and the secret is tls-entry-scoped, so a
per-entry optional issuer cannot live on one object - and ingress-shim
names the Certificate after the secret and owns it from the Ingress, so a
shared secret must sit in exactly one object. Generalises past this chart;
2026-10-13 10:21:46 +00:00
jklein ac259f3f53 stalwart: the Gateway API as an alternative to the Ingress (#114)
One route, not one per node group - #114 says 'per node group', and the
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, so ingress.nodeGroup is now documented as shared by both
modes.

Two sections that are specific to this chart:

THE MAIL CERTIFICATE STAYS. This is the only chart in the sweep where
anything of the TLS path stays with the chart: certificate.enabled keeps
rendering the mail Certificate in Gateway mode, because STARTTLS on 25 and
implicit TLS on 465/993 pass through no HTTP proxy. So ingress.domain stays
required, ingress.clusterIssuer is not freed, and the object must not be
cleaned up during a migration - deleting it drops SMTP and IMAP back to a
self-signed certificate while every Kubernetes object stays green.

CORS, AND IT BREAKS A SECOND CHART. The live Ingress carries enable-cors with
an allow-origin pointing at bulwark, the webmail. Measured against the pinned
CRDs rather than assumed, 'no counterpart' would have been wrong: HTTPRoute
filters DO carry a cors field (GEP-1767), and responseHeaderModifier is there
too - both Support: Extended. The chart still renders neither, and the three
reasons are on the page: 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. With the operational
consequence: configure CORS at the Gateway first, and test from a browser
because curl sends no preflight.

The ban trap and the NetworkPolicy paragraph now say that Gateway mode neither
avoids nor resets the auto-ban - one proxy address instead of another - and
that the namespace to allow is the Gateway's data plane.
2026-10-12 21:33:56 +00:00
jklein aa25dced2b stalwart: per-group distribution and PDB, and the selector-match CI assurance
stalwart: the six new per-group values in the nodes table, a "High availability,
per group" section, and four traps - distribution is a promise and the price is
a Pending pod (including the delete-the-Node-object inversion and the ceiling as
a rule rather than a number), the opposite defaults of the two node-inclusion
policies, nodeAffinityPolicy being pruned silently below Kubernetes 1.26, and
the collision abort. Also: the install-test renders neither object, and the
render-only overlay that covers them instead.

CI-CD: the new selector-match job as step 4, install-test renumbered to 6, and
helm-dependencies.sh now in six places - with the 503 that took both render
tests red while this was written.

Chart-Style-Guide: §11 gains the per-group PDB, the distribution-rule
convention, the chart-filled labelSelector, the explicit nodeAffinityPolicy with
its two separately-argued consequences, the collision abort, and the reminder
not to add updateStrategy or podManagementPolicy for this.

Lessons-Learned: two sections - the two defaults that pointed in opposite
directions (where a review's own authoritative table was the thing that was
wrong), and the duplicate the API rejected while the apply path merged it
silently - plus four entries in the recurring shape.
2026-10-07 11:39:25 +00:00
jklein 996f8c0dde bulwark: new chart page, index, sidebar, stalwart cross-reference, and four rules it bought
The chart page leads with the two things that are not visible from outside and
that govern everything else: Bulwark is a pure CLIENT, so with the mail server
gone the release is Ready and green and nothing in it can tell; and the VOLUME
BEATS THE CHART for every value, silently, because the application resolves
"admin override > env var > default" and its admin dashboard may write any key.
Upstream documents the second only for the server URL, which is what makes it a
trap rather than a footnote.

Also on the page, each measured against the source at tag 1.12.0 rather than
taken from the documentation: the real way out of an OIDC lockout (remove the
override in the dashboard - a helm upgrade with OAUTH_ONLY=false goes through the
same precedence chain and can do nothing), the three-part price of
adminConfigReadOnly, the redirect URI that carries the UI language across 27
locales, the two-part backup statement (the volume alone is not a backup, and it
may hold the OAuth secret in cleartext), why rotation needs no restart and which
mount detail that depends on, and what the probes do and do not carry.

Charts: new row, and the counter is now measured against */Chart.yaml instead of
carried forward - it had drifted. The table also still lists convertx, for which
there is no chart directory in the repository; flagged in a note rather than
deleted, because removing it is a decision about that chart and not a
documentation fix.

Lessons learned, four entries, all of them transferable beyond this chart:
a recovery instruction that ran through the fault it was meant to repair; a
read-only switch whose write barrier also blocked the one-time bootstrap write
that creates the admin password; a probe method the platform does not offer at
all (an HTTP probe is always GET); and a 0400 secret file that only works
because the kubelet silently widens it.
2026-10-05 21:27:25 +00:00
jklein 1eb6831a58 stalwart: the subchart is our own valkey chart, from the published index
Follows the decision on #65 to depend on our published valkey chart rather
than upstream's. Four pages, and the choice of pages is deliberate.

stalwart.md: the subchart section rewritten - our chart, not upstream's, so
"consumed as it comes" no longer applies; the condition is
valkeySubchart.enabled with the subchart's own error message quoted and the
reason it only appears once the dependency is switched on; the auth-conf field
is a complete "requirepass <pw>" line, not a password; and the cache-vs-queue
choice argued with its cost (allkeys-lru makes the rate limiters and the
auto-ban fail open under memory pressure - size memory, do not switch to
noeviction). Plus a section on the PR-ordering rule.

valkey.md: the same rule, stated FROM THIS SIDE. This is the page someone
editing valkey/ will actually be reading, and this repo's own lesson is to put
the warning where the next change will be made. It also warns against the
tempting repair of adding an "enabled" property here for a key this chart does
not read.

Chart-Style-Guide.md, section 14: two charts carry a dependency now, and point
2 ("we consume their chart as it comes") explicitly does not apply when the
subchart is ours. Adds the PR-ordering rule, the condition-key trap, and the
two measurements that back the published-version form.

Lessons-Learned.md: two entries - the green check that tested the wrong
artifact, and the condition key the subchart rejected (including the part that
makes it dangerous: Helm skips a disabled subchart's validation, so CI stays
green and the error waits for the first operator who enables it). Three lines
in "The recurring shape".
2026-10-02 20:40:43 +00:00
jklein 079639225c stalwart: new chart page, index, sidebar and four lessons
The chart page carries the full values reference, the eight-step first
install (eight, not seven: the bootstrap wizard appears only when
config.json is ABSENT, and the chart always renders one, so a first
install goes through recovery mode) and a Traps section.

Four entries on Lessons-Learned, because none of them is specific to mail:

- The source address every NodePort quietly replaces. externalTrafficPolicy
  Cluster masquerades the client address onto the node IP, and every
  application that evaluates the client address meets this - bans, rate
  limits, geo rules, audit logs. Includes the three traps of the PROXY
  header remedy and the reason the trusted set and the probe set have to be
  disjoint in Kubernetes.
- The rate limit that counted a node instead of a client. A per-IP limit in
  an application is a per-node limit for probes; the arithmetic that picks
  the probe intervals, and why a tcpSocket probe is the wrong escape.
- The hardening that stopped the container from starting. setcap +ep makes
  the binary capability-dumb, so drop: [ALL] fails the execve itself. The
  lesson is that a deviation has to be documented with its mechanism, or the
  next hardening pass removes it as unused.
- The missing file that opened a database nobody meant to use. An absent
  config.json opens an ephemeral in-memory store with green probes; an
  unparsable one aborts. The lenient path is the one a broken mount takes.

Also on the fail-open: it covers the TASKS as well as the listeners, with
all ten enabled by default, and the error is logged after the listeners are
already bound.
2026-10-02 19:24:15 +00:00