Commit Graph
114 Commits
Author SHA1 Message Date
jlab-renovate-bot b868e1c12b Update dependency kubernetes/kubernetes to v1.37.1
Validate Helm Charts / Detect changed charts (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ matrix.chart }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ matrix.chart }} (pull_request) Skipped
renovate/stability-days Updates have met minimum release age requirement
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 19s
Validate Helm Charts / Selector match (pull_request) Successful in 13s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 16s
2026-10-13 11:06:01 +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 / Resource quantities (pull_request) Successful in 14s
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 / Build template-chart (pull_request) Successful in 7s
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
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
jklein 6ade74db80 feat(stalwart): spread each node group over nodes and give each one a PDB
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Stalwart Helm Chart / Publish Stalwart Helm Chart (push) Successful in 7s
SonarQube Analysis / SonarQube Analysis (push) Successful in 27s
A two-replica node group was redundancy on paper: nothing kept both pods off
one node, and no PodDisruptionBudget kept a drain from taking both at once.
Both objects are now rendered per node group, gated on that group's COMPUTED
replica count being >= 2.

The distribution is a promise, not an effort: maxSkew 1 on
kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule, selecting only
this group's own pods, so an "mx" and a "full" pod may share a node. The
selector comes from the chart (stalwart.groupLabels), never from the operator -
a spread constraint whose selector matches no pod is trivially satisfied,
distributes nothing and reports nothing.

nodeAffinityPolicy: Ignore is set explicitly, and that is the load-bearing
detail. Its API default is Honor, under which a narrowing nodeSelector shrinks
the set of counted domains until both replicas fit into one: skew 0, both pods
co-located, no Pending, no event, and a selector that is correct - so no check
can see it. With Ignore the same narrowing gives skew 2 and the second pod is
Pending. A render abort was the alternative and would also have forbidden a
nodeSelector spanning five nodes, which is legal.

A user entry colliding with the chart's own on (topologyKey,
whenUnsatisfiable) aborts the render naming both values, because the API
rejects the duplicate with a message about a list index and an apply-based path
merges the two over patchMergeKey: topologyKey silently, dropping one selector.

Minor rather than major, proved with a diff rather than argued: with the
shipped defaults (full: 1, mx: 0) and with ci/stalwart/test-values.yaml,
helm template renders byte-for-byte what 1.0.1+upv0.16.24 rendered - both
diffs empty.

New in CI, over every chart on every PR: ci/run-selector-match-test.sh holds
every rendered PodDisruptionBudget selector and every topologySpreadConstraints
labelSelector against the labels of the pod templates in the same render. Helm
itself is the YAML parser (ci/yaml2json), because the runners have neither
pyyaml nor yq and a hand-written parser could read nothing and report nothing.
podAffinity/podAntiAffinity are out of scope; matchExpressions-only selectors
are reported as not checked rather than counted as passing. Two anti-vacuum
guards: a chart with an overlay under ci/selector-match/ that renders no
selector fails, and a run that checks no selector at all fails.

Fixes #96
2026-10-07 19:36:04 +00:00
jklein b39f36ff5c feat(bulwark): new chart for Bulwark Webmail (1.0.0+up1.12.0)
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Bulwark Helm Chart / Publish Bulwark Helm Chart (push) Successful in 7s
SonarQube Analysis / SonarQube Analysis (push) Successful in 27s
A JMAP webmail client for the stalwart chart (Gitea #65): StatefulSet, one PVC
at /app/data, Service plus headless Service, Ingress at a host root, two
OnePasswordItems, three probes, computed limits, replicas plus scaleDown, strict
values.schema.json.

Everything below was measured against the source at tag 1.12.0 rather than read
out of the documentation, which was wrong about three of them.

THE DEFINING PROPERTY: THE VOLUME BEATS THE CHART, FOR EVERY VALUE, SILENTLY.
configManager.get resolves "admin override > env var > default", the admin
dashboard may write any key of CONFIG_ENV_MAP, and nothing reports that it just
disconnected the chart - so a helm upgrade can succeed and change nothing.
Upstream documents this for the server URL only; it is true of everything.
Exactly three values escape it (SESSION_SECRET(_FILE), ADMIN_PASSWORD,
BULWARK_TELEMETRY). The value that closes it, bulwark.adminConfigReadOnly,
defaults to false because its price is threefold: 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 instead of removing it, and on a fresh install it
stops admin.json from being written at all - so the admin dashboard never comes
up and its login answers HTTP 500 while the pod stays Ready.

OIDC-only sign-in, with the real way back out of a lockout: not helm upgrade
with OAUTH_ONLY=false (oauthOnly goes through the same precedence chain) but
removing the override in the admin dashboard. That is why ADMIN_PASSWORD is set
- the dashboard is the door. It generates no random password and logs nothing,
so the #65 analogy does not apply.

SESSION_SECRET_FILE and OAUTH_CLIENT_SECRET_FILE are both used - the better path
garage (#81) and keycloak (#83) did not have - in one projected volume mounted
WITHOUT subPath, because both are re-read on every use and a subPath mount is
never refreshed, and at mode 0440 because secret files are owned by root and
fsGroup only supplies the group.

Two backup statements, both at the value: 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.

Probes: all three a plain GET on /api/health, with the limit written at the
readiness value - it carries process, HTTP acceptance, a free event loop and
Next.js routing, and nothing about the reported state or about Stalwart.

No ConfigMap, so the checksum/config rule (#82) has nothing to attach to, and
values.yaml says so explicitly; the op:// paths are covered by
checksum/secret-ref.

Renovate: no versioningTemplate, measured rather than assumed. 315 tags in eight
shapes (releases, a second v-prefixed family of the same images, an -always
flavour, floating minors and names, commit SHAs, betas, attestations) and plain
docker versioning excludes all six unwanted ones. Dry runs at 1.12.0, 1.11.0 and
1.10.0 are recorded in the block.

Part of #89 - deliberately not "Fixes", because one binding decision of the
issue was changed out of necessity and wants ratification rather than an
automatic close: the plan specifies HEAD /api/health for all three probes, and a
Kubernetes probe cannot send HEAD at all (HTTPGetAction has no method field; the
kubelet always issues GET). The alternative, an exec probe, would mean starting a
Node process every few seconds in an image that carries no curl and no wget. GET
is used instead and is marginally more capable than HEAD, so every limit the
issue argues is unchanged - but the decision is the maintainer's to confirm.
2026-10-06 10:15:27 +00:00
jklein 4c1cff1744 feat(keycloak): new chart for Keycloak (1.0.0+up26.8.0)
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 rather than StatefulSet - Keycloak 26
keeps everything in the database, user sessions included.

The chart's defining property is that it has TWO INIT CONTAINERS and needs
both. db, health-enabled and metrics-enabled are Keycloak BUILD options
(verified in the 26.8.0 option table), the published image is augmented with
their defaults, and supplying different ones at runtime 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". Not setting db is no way out: the probes alone force it, because
health-enabled is a build option too. An empty emptyDir over that path fails as
well, 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, with no documented deviation to carry.

Build options therefore exist in exactly one place, and the asymmetry is
enforced rather than trusted: the serving container passes no build option at
all, and a build option's variable name in keycloak.env fails the render with a
message naming it.

Hostname handling is the security-relevant part. KC_HOSTNAME is derived from
ingress.domain as a full URL (which upstream requires alongside hostname-admin),
hostname-strict stays at its default true, and proxy-headers is set rather than
optional. The admin hostname is NOT presented as an access control: upstream
states that hostname-admin "does not prevent accessing the Administration REST
API endpoints via the frontend URL specified by the hostname option", so the
chart renders no /admin deny rule and values.yaml, NOTES.txt and the wiki all
say in the same words that restricting that API is reverse-proxy work.

The management port 9000 is a containerPort and a ClusterIP Service for
in-cluster scraping, and is in no Ingress: it serves /health/* and optionally
/metrics without authentication. Liveness uses /health/live and deliberately
not /health or /health/ready - those include the database, and a liveness probe
on them would restart the whole cluster for as long as the database is away.

Also in this change:
- First PodDisruptionBudget in this repository, which makes it a convention
  (README section 11): minAvailable as a value, and rendered only while the
  computed replica count is >= 2, because minAvailable: 1 with one replica
  blocks every node drain forever with a message that names the PDB rather
  than the replica count.
- First quay.io image, so quay.io joins the trusted registries in
  ci/trivy/data/ksv0125.yaml - configured rather than suppressed, so
  AVD-KSV-0125 keeps being able to say something.
- Renovate block with NO version constraint, which is the counter-case to
  garage and stalwart and is measured rather than assumed: docker versioning
  already excludes the -N rebuild tags, the floating minors and latest/nightly.
  Three dry-run fixtures are recorded at the block, and against the committed
  config 26.7.4 offers exactly 26.7.5 (patch) and 26.8.0 (minor).
- No automerge exception, by decision (Gitea #83): the global automerge applies,
  and the minor-upgrade procedure plus `kc.sh update-compatibility` is
  documented in NOTES.txt instead.
- Two upstream defaults deviated from, each with its reason at the value:
  db-pool-max-size 20 instead of 100 (two replicas at 100 ask a default
  PostgreSQL for twice its max_connections), and uid 1000 / gid 0 instead of
  this repo's 20000/1000, because the image declares keycloak:x:1000:0 and
  chowns its install tree to that pair.

Part of #83
2026-10-04 18:00:20 +00:00
jlab-renovate-bot 236767070d Update dependency aquasecurity/trivy to v0.75.0
SonarQube Analysis / SonarQube Analysis (push) Successful in 28s
2026-10-04 15:11:02 +00:00
jklein 79b7e44fdc feat(garage): new chart for the Garage object store
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 11s
Validate Helm Charts / Build garage (pull_request) Successful in 6s
Validate Helm Charts / Trivy config garage (pull_request) Successful in 16s
Install-test / Install-test garage (pull_request) Successful in 1m15s
Validate Helm Charts / Install-test (pull_request) Successful in 1m15s
One Garage node with replication_factor = 1, built from template-chart to the
style guide rather than copied from upstream's in-tree chart (Gitea #81). The
single-node trade is deliberate and its price is named at the value: the factor
applies to the metadata as well, where upstream calls a corrupted database
"generally not recoverable" and a replicated volume replicates the corruption
faithfully. The three countermeasures that work at factor 1 are therefore
mandatory, not optional - metadata_fsync = true (off in Garage's defaults), the
automatic metadata snapshots, and those snapshots on a second PVC.

Storage: PVC 1 carries metadata_dir AND data_dir as two subdirectories, because
volume snapshots are atomic per volume and that is what keeps index and blocks
consistent with each other; PVC 2 carries metadata_snapshots_dir, because a
recovery point must not die with the volume whose corruption it should repair.
Garage creates all three directories itself, which matters because the image is
FROM scratch and has no mkdir.

Two corrections to the approved plan, both verified against the source at tag
v2.4.1 and decided on the issue before implementation:

- The RPC secret arrives as GARAGE_RPC_SECRET via secretKeyRef, not through
  rpc_secret_file. The file form cannot work in a non-root pod:
  read_secret_file rejects any mode with a group or other bit set
  (src/garage/secrets.rs:144, mask 0o077) while the kubelet OR-s the group-read
  bit onto every file of a secret volume once fsGroup is set
  (pkg/volume/volume_linux.go), and Kubernetes can set the group of such a file
  but never its owner. The mechanism is documented at the value so the next
  round does not "fix" it back.
- The first start is done by the server, not by hand: garage server
  --single-node creates the layout in-process before any listener opens, is
  idempotent on the layout version and has no --version argument that could be
  reused, and --default-access-key/--default-bucket import key and bucket FROM
  the vault item. Both premises of the original "manual first start" decision
  fell with that.

Probes follow from the same source reading: readiness is GET /health on the
admin port, which answers 503 while no layout exists; liveness and startup are
a TCP connect, because a probe that can only fail would restart a node waiting
for its layout forever (same lesson as zipline, README 12.3). The S3 listener
binds independently of the layout, so a TCP probe on 3900 would have been the
lie the plan warned about.

CI: fixtures under ci/garage/ rather than an exclusion - with --single-node the
pod reaches Ready on its own, which makes the install-test an assertion about
the layout, the one-PVC subdirectory layout and readOnlyRootFilesystem instead
of a formality. The Renovate block carries a regex versioning anchored to
^v<major>.<minor>.<patch>$, because the registry receives 40-character
commit-SHA tags almost daily (measured 2026-10-03: the newest 22 tags are all
of that kind) and the four v1.99.x-internal builds sort above the v1.3.x line.

Part of #81
2026-10-03 20:29:38 +00:00
jlab-renovate-bot a288ee231a Pin dependencies
SonarQube Analysis / SonarQube Analysis (push) Failing after 20s
2026-10-02 21:02:13 +00:00
jklein 780cb8eafd feat(stalwart): new chart for the Stalwart mail server
Cluster-capable from the start, with roles rather than symmetry: one
StatefulSet per entry of the "nodes" map, the key passed in STALWART_ROLE.

Since v0.16 Stalwart keeps its whole configuration inside the data store and
manages it over JMAP, so the chart's scope is deliberately narrow: it renders
config.json (exactly the DataStore object), the workloads, the Services, the
NetworkPolicies, the Ingress and a cert-manager Certificate. Listeners, blob
store, in-memory store, TLS object and cluster roles are operator work in the
WebUI, which is why NOTES.txt carries an eight-step first install.

config.json stays secret-free: authSecret uses the EnvironmentVariable variant
(verified against the serde definition of PostgreSqlStore at v0.16.24), so the
password arrives through secretKeyRef and checksum/config stays meaningful.
The username cannot follow it - authUsername is a plain String with no secret
form - so it is a public value and says so.

Services are per PORT, not per group: a NodePort is cluster-unique, so "full"
and "mx" can only accept SMTP behind one Service, selected through a per-port
pod label. The http port gets a per-group ClusterIP Service instead, so the
Ingress reaches exactly ingress.nodeGroup.

Four traps, documented rather than explained away:

- The role lookup FAILS OPEN. A node whose ClusterRole is missing from the
  database enables every listener and every task while staying Running with a
  green probe, and the log line arrives after the listeners are bound. Hence
  mx at replicas 0, per-port Services, and a NetworkPolicy that is a ceiling
  independent of what was bound.
- NET_BIND_SERVICE is a documented deviation needed to EXEC, not to bind. The
  image sets setcap cap_net_bind_service=+ep, which makes the kernel fail
  execve when the bounding set is empty. The values comment says both why it
  is there and that it is never used, because the second half is what stops
  the next hardening pass from removing it.
- externalTrafficPolicy must be Cluster - no pod runs on a control-plane node
  while the HA proxy delivers to them, so Local would drop every packet - and
  the source address that costs is restored only by the HA proxy's PROXY v2
  header.
- /healthz is rate-limited to 100 requests/60s per source IP, which is a node
  IP for kubelet probes, so all pods on a node share one bucket. The probe
  intervals are calculated (6 req/min/pod, ~16 pods per node) and the sum is a
  comment in values.yaml.

A first install needs bootstrap.enabled: true: the setup wizard appears only
when config.json is ABSENT, and this chart always renders one, so recovery
mode is upstream's documented path for a templated config.json.

Second chart in the repo with a dependency - the Valkey project's own chart,
off by default behind condition:, because Stalwart's cluster coordinator rides
on the in-memory store connection. The render aborts when more than one pod
runs with no in-memory store address anywhere.

Also wired up: publish-stalwart.yml through the shared block in
workflow-util/pipelines, the change-app-version.yml choice list, a Renovate
customManagers block anchored on the v prefix (which excludes the moving
latest / v0.16 / -alpine tags), CI fixtures, and the README and CLAUDE.md
lines that claimed ytdlp-web-player was the only chart with a dependency.

Verified locally: ci/helm-dependencies.sh, helm lint --strict (0 failed),
helm package, helm template against chart defaults and against
ci/stalwart/test-values.yaml, the rendered config.json parsed as JSON with the
field types the serde definition requires, ci/run-quantity-render-test.sh
(0 failed, both nodes.*.resources paths exercised), NOTES.txt rendered through
a scratch ConfigMap, and the null-safety and fail paths (securityContext=null
keeps NET_BIND_SERVICE; a cluster without an in-memory store, an unknown port
name, a group without http and an unknown ingress.nodeGroup all abort with a
named message; the schema rejects timeout, a string timeoutMs and a list-shaped
nodes). NO runtime verification was possible - there is no k3d or Docker daemon
in this workspace.

Part of #65
2026-10-02 20:59:17 +00:00
jlab-renovate-bot ee43c33ef4 Update dependency kubernetes/kubernetes to v1.35.9
renovate/stability-days Updates have met minimum release age requirement
validate-charts.yml / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Blocked by required conditions
validate-charts.yml / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Blocked by required conditions
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 10s
SonarQube Analysis / SonarQube Analysis (push) Failing after 24s
2026-10-02 16:13:30 +00:00
jklein 67d8c94fb3 ci: call the shared SonarQube block, and say what it does not do
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 9s
SonarQube Analysis / SonarQube Analysis (push) Failing after 1m0s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Adds the caller side of the analysis: .github/workflows/sonar.yml as a hull of
the same kind as the publish ones, and sonar-project.properties as the
project-specific half. The workflow itself is the shared block in
workflow-util/pipelines, bound to @v4.

No paths filter, unlike the publish hulls: the analysis looks at the whole tree
- the five shell scripts under ci/, every workflow file, the YAML and Helm
templates, the values.schema.json files, and the secret detection over
everything. A filter would leave the previous result standing after a change it
did not cover while still presenting it as current.

workflow_dispatch is on the hull on purpose. Three things about this path are
unmeasured - node24 in the runner image, the ~59 MiB scanner download, and
whether a repository variable reaches a workflow called from another repository
- and any of them may need more than one attempt. Without a dispatch every
attempt would cost a dummy commit on main.

No exclusions, including the Helm templates, which are not valid YAML because
of {{ }}. What the Helm and YAML analysers make of them is only learnable by
letting them run over them once, and an exclusion written on a guess hides
whatever else matches it. If the first run produces noise worth suppressing,
the exclusion belongs in sonar-project.properties with the finding it answers
named next to it.

The project key carries the owner (jlab-cluster_helm-charts) because the
instance serves two, and a key cannot be changed afterwards without losing the
project's history.

The analysis is not a gate. The Community Build analyses the main branch only,
so the trigger is a push to main and a finding appears after the merge - at
which point there is nothing left to block. README and the CI-CD wiki page both
say so, with the two conditions under which a gate would become right, so that
its absence is not read as an oversight.
2026-10-01 23:49:48 +00:00
jklein 8878329f31 ci: publish charts through the shared block in workflow-util/pipelines
Validate Helm Charts / Detect changed charts (pull_request) Successful in 6s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 13s
Validate Helm Charts / Install-test (pull_request) Skipped
The reusable publish workflow now lives in workflow-util/pipelines and is
called pinned to @v3. All 25 thin hulls switch over and our copy is deleted in
the same commit: a hull pointing at a deleted file, or an orphaned file nobody
calls, would both be worse than the state before.

Each hull gains one line, registry-owner: ${{ github.repository_owner }}. The
owner used to be derived inside the workflow (#63); as a required input without
a default it is evaluated here, in the calling repository, where the value is
measured. That is the house rule of pipelines - a shared block never guesses an
owner segment - and it keeps the block free of any context semantics. The
workflow_dispatch and push triggers of every hull are untouched; after the move
the dispatch is the only way to trigger a publish by hand.

The Connect host is no longer wired into the block. It reads the repository
variable OP_CONNECT_HOST, which the maintainer has set, and fails in its first
step naming the variable when it is empty.

Documentation follows the same split as the code. The README entry no longer
describes a workflow of this repository; it names where the block lives, what
each caller owes it, and points at the publish-helm-chart page of that
repository's wiki for the reasons behind the upload step, which are no longer
ours to keep current. Nothing of them was lost in the move - the upload step's
shell body went across unchanged apart from two error messages that name the
owner as an input.

Also corrected, because the move made them wrong rather than merely stale:

- the helm-dependencies.sh entry still counted the local publish workflow among
  the five places it runs; it is still five, one of them now the shared block,
  which is why that block names the script as a prerequisite of its callers;
- two comments in install-test.yml pointed at publish-helm-chart.yml as a
  sibling file, one of them for a trap it documented "at length" - that long
  form is now on the pipelines wiki, and the comments say so;
- the hull count read 24, and reusable workflows with secrets: inherit are now
  confirmed to work across repositories too;
- the chart-creation step in CLAUDE.md said the reusable workflow is ours.

install-test.yml stays here, with the reason written down (decision 4 on the
issue): it is reusable in form but welded to the ci/ fixtures, the exclusion
parser and this repository's chart matrix. A block only one repository can use
is not shared, only moved. The note is there so the question is not asked again.

Fixes #66
2026-10-01 23:01:47 +00:00
jklein b614b53602 ci: remove the temporary repository_owner probe, record the measurement
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 2s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
github.repository_owner is populated on this Gitea instance: measured as
"jlab-cluster" (12 characters) in run 282 of validate-charts.yml. The
value is now in the comment above the upload step, so the fact survives
without the step that produced it.
2026-09-30 18:51:55 +00:00
jklein a12983fbea ci(publish): compose the registry URL from a base and the repository owner
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 15s
charts-url in op://GitHub-Actions/jlab-registry-access held the full
registry URL and publish-helm-chart.yml appended /api/charts to it. It
now holds only the base up to /api/packages, and the owner segment comes
from github.repository_owner, so the target is composed as
<base>/<owner>/helm/api/charts.

Nothing in this repository points at the registry any more, not even a
path segment, and the second owner on the same instance needs no second
setting. The 26 thin publish-<chart>.yml callers are unchanged.

Two guards keep the switch from the old shape loud rather than a 404
against a masked URL: charts-url is rejected unless it ends in
/api/packages, naming /helm, /api/charts or an included owner
explicitly; and the derived owner is rejected when empty or when it is
not a usable URL path segment. Everything the upload step already had
is untouched - the per-field :? guards, the whitespace check on the
credentials, the host masking, the built file name, --fail-with-body.

The probe step in validate-charts.yml is temporary and measures whether
this forge populates github.repository_owner at all; it is removed again
once the value has been read off the run.
2026-09-30 18:49:56 +00:00
jklein 6a3b05503e Make the all-charts install-test repeatable, as one shared job
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 10s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Skipped
Validate Helm Charts / Install-test (pull_request) Skipped
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 21s
The install-test matrix follows the charts a pull request changes, so a
chart nobody has touched since the forge move carries a green history from
a different runner - and that green says nothing about today. Gitea PR #48
showed what the answer is worth: correcting a comment in 21 charts made
every one of them "changed" and turned the run into the first all-charts
install-test after the move, 19 of 20 matrix charts Ready in 12m41s and
exactly one not (rss-bridge, Gitea #51). By accident.

The install-test job moves into the reusable .github/workflows/
install-test.yml and is called twice: by validate-charts.yml with the
charts a pull request changed, and by the new install-test-all-charts.yml
with all of them. Not copied for the second caller - a second copy is a
second chance to test differently, unnoticed, which is the argument that
gives ci/excluded-charts.txt exactly one parser. The parts that would
drift are the ones that were paid for once already: the pinned kubectl
with its skew check, envsubst, the containerised-job detection, the
per-leg cluster name.

Nothing about how a chart is installed changed. The moved job differs from
its predecessor in the matrix source (inputs.charts instead of the detect
job's output), an id on the install step, and a step that records the
result; every other step is byte-identical.

The all-charts run is workflow_dispatch only - a schedule would spend
compute every day on a question that changes rarely - with an optional
subset input, which is what lets a single chart be tested against the
runner as Gitea #51 needs. One throwaway cluster per chart, no reuse:
reuse saves time and changes what the test proves. An unknown or excluded
chart name, and an empty matrix, are loud errors rather than a green run
over nothing.

Every leg writes its own outcome into the job summary next to the four
facts that decide what the outcome means - date, runner label, K3S_IMAGE,
kubectl version - and a final job records the verdict over the matrix. A
green run nobody notes is unknown again next time. The excluded charts
appear with their justification, from the one parser, as in the pull
request pipeline. 20 charts in 12m41s is written down as the expectation,
so a later deviation is visible as one.

Fixes #52
2026-09-27 14:30:39 +00:00
jklein 813c796a7b feat(valkey): one Valkey instance per application (1.0.0+up9.1.2.alpine)
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 4s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 11s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 1m22s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Valkey Helm Chart / Publish Valkey Helm Chart (push) Successful in 7s
A new chart for a single Valkey instance per application, replacing the
shared cluster-redis whose separation rests on one ACL file in one
1Password field - a construction in which every change touches six
applications and which cost a month of Paperless standstill.

Shape: StatefulSet with one replica, ClusterIP Service, no cluster mode,
no Ingress and no value to add one.

The password arrives as a finished configuration line ("requirepass
<secret>") in the 1Password field auth-conf, is mounted as a file and
included by the main configuration - never in argv, never in the
environment. The include is rendered last, so nothing in extraConfig can
override it. Because a misspelled or empty field would leave an open
server that looks perfectly healthy, an init container greps the mounted
file for the line and refuses to start the pod without it; it never
prints the file.

maxmemory is derived from the computed memory limit (maxmemoryFactor,
default 0.75) and a configured value above that limit aborts the render:
otherwise the kubelet's OOM kill decides what the eviction policy was
configured to decide. Persistence and eviction are documented as pairs -
queue (PVC + noeviction, the default) and cache (emptyDir +
allkeys-lru) - and the combination "no persistence + noeviction" carries
a warning in values.yaml and in NOTES.txt.

Probes: valkey-cli ping as an exec probe, authenticated through
VALKEYCLI_AUTH read out of the mounted file, with "-e" and an assertion
on the answer PONG. Without -e valkey-cli exits 0 on an error reply
(valkey 9.1.2, src/valkey-cli.c: the config.set_errcode branch in
cliReadReply), which would make the probe unable to fail.

Hardening: uid 999 / gid 1000 - the image's own valkey user - with
fsGroup matching, runAsNonRoot, readOnlyRootFilesystem, all capabilities
dropped and seccompProfile RuntimeDefault in the pod context so the init
container is covered too. /data is the only writable path.

NetworkPolicy on by default (first chart in this repo with one), with a
switch and a list of additional namespaces; values.yaml names that it is
only a boundary where the CNI enforces policies.

Wiring: publish-valkey.yml, the chart-name choice list, ci/valkey
fixtures for the persistent variant, a plain docker-versioning Renovate
block, README chart row.
2026-09-27 11:24:55 +00:00
jklein e0b552d93e Replace the obsolete no-dependencies rationale in every chart helper
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Resource quantities (pull_request) Successful in 8s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 31s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 1m34s
Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request) Successful in 15m49s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Apache Tika Helm Chart / Publish Apache Tika Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish BentoPDF Helm Chart / Publish BentoPDF Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Calibre Web Automated Helm Chart / Publish Calibre Web Automated Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Cyberchef Helm Chart / Publish Cyberchef Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Freesailarr Helm Chart / Publish Freesailarr Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Gotenberg Helm Chart / Publish Gotenberg Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Homeassistant Helm Chart / Publish Homeassistant Helm Chart (push) Successful in 8s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Matrix Synapse Helm Chart / Publish Matrix Synapse Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Minecraft-Router Helm Chart / Publish Minecraft-Router Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Minecraft-Server Helm Chart / Publish Minecraft-Server Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Outline Helm Chart / Publish Outline Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Patchmon Helm Chart / Publish Patchmon Helm Chart (push) Successful in 6s
Publish Paperless-NGX Helm Chart / Publish Paperless-NGX Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Rallly Helm Chart / Publish Rallly Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Samba Helm Chart / Publish Samba Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 7s
Publish Satisfactory Helm Chart / Publish Satisfactory Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish TeamSpeak Server Helm Chart / Publish TeamSpeak Server Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 7s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish VoucherVault Helm Chart / Publish VoucherVault Helm Chart (push) Successful in 6s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 6s
Publish Zipline Helm Chart / Publish Zipline Helm Chart (push) Successful in 6s
The doc comments on the resources and defaultedDict helpers justified the
copy-per-chart with "charts in this repo deliberately have no dependencies".
That rule fell with #214: a dependency is admissible now, just expensive, and
it sits in the README as guideline 14, not 13. The justification still holds,
but its reason is a different one - sharing the helper would mean a library
chart, a second artefact to publish, version and pull for something that
changes about once a year. searxng got that wording in PR #38; this carries it
to 21 further charts, 40 occurrences in all.

rss-bridge is deliberately left out. Its install-test fails on the Gitea
runner for a reason that predates this branch, and touching the chart would
keep it in the install-test matrix and the check red. Its copy of the sentence
is carried by #51, which has to open the chart anyway.

Comments only: every touched chart renders byte-identical against its
ci/<chart>/test-values.yaml. Each published chart takes a patch bump,
appVersion and +up suffix untouched; template-chart is never published and
takes none, but is corrected because every new chart inherits from it.

Two repo files carried the same claim at no version cost:
validate-charts.yml explained the all-charts quantity render test with it, and
change-app-version.yml still counted nine images for freesailarr - MediathekArr
made it ten.
2026-09-27 10:27:57 +00:00
jklein 93fdb3eaea sure: new chart for the we-promise Sure app (1.0.0+up0.7.4)
One StatefulSet with two containers - the Puma web process and a Sidekiq
worker - sharing one ReadWriteOnce PVC at /rails/storage. That shared
volume is the reason they live in one pod: splitting them would need an
RWX volume. Only the web container migrates, because the image entrypoint
runs db:prepare solely for the './bin/rails server' command; the worker is
started through args: so the entrypoint (and its jemalloc preload) stays
in place and the two can never race.

Probes are chosen for what they prove: liveness and startup use Rails'
/up, which is a boot check and touches no database, while readiness uses
'/', which runs User.count through authenticate_user! in self-hosted mode
and therefore fails while Postgres is unreachable. The worker speaks no
HTTP, so all three of its probes ask sidekiqmon whether this pod still
holds a Sidekiq heartbeat in Redis.

Three OnePasswordItem CRs (app, database, redis), ingress.domain kept
required outside the Ingress because APP_DOMAIN and the WebAuthn relying
party read it, strict values.schema.json, CI fixtures with throwaway
Postgres 16 and Redis, and a Renovate manager on semver so the
-alpha/-rc/-hotfix channel and the moving tags are never selected.
2026-09-26 21:08:55 +00:00
jklein fdd798e1d9 install-test: provide envsubst, and say why it is not pinned like kubectl
ci/run-install-test.sh renders ci/<chart>/fixtures.env.yaml with
envsubst. GitHub's hosted image ships gettext-base, the Gitea runner
image does not, and without it the fixtures collapse to 'error: no
objects passed to apply' - gluetun never gets its Wireguard key and
nothing behind its kill switch becomes Ready. The script is left
untouched; it does nothing wrong, a standard tool is simply absent.

Checked for rather than pinned, and the difference from kubectl is
written next to it: kubectl's version changes what the test MEANS,
because its distance to the API server is a real source of failure, so
it is pinned, tracked and guarded. envsubst substitutes text and the
release performing it does not change the result, so a pin would be
upkeep without value. Present means do nothing, which keeps the GitHub
path free of any package install; missing means fetch it, so the
difference between the two runners is stated instead of hidden behind
an unconditional apt-get.

Also rewrites the reachability comment into one block. It had grown into
two stacked halves whose first half still described the behaviour of the
version before last - both reasons (the loop against a discovery 503,
counting Ready nodes against an empty list that exits 0) now stand
together with the evidence that the second one is what made the inotify
failure visible at all.
2026-09-17 01:00:38 +02:00
jklein 417dff0f80 install-test: wait for a Ready node, not for an answer
Second defect in the same check, and the more serious one: kubectl get
nodes exits 0 on an EMPTY list. The cluster on Gitea answered while its
node had not registered, the step logged 'No resources found' and went
green, and the failure surfaced five minutes later as a pod stuck in
Pending with no node and no scheduling event - the cause nowhere near
the symptom. kubectl wait --for=condition=Ready nodes --all has the
same hole; with zero nodes it succeeds vacuously. It now counts Ready
nodes, and on timeout dumps the node conditions and the k3s server log
so the error picture is in the job that caused it.
2026-09-17 00:46:54 +02:00
jklein 4766126618 install-test: poll for the cluster instead of asking once
The reachability check added with the containerisation fix was itself
racy and went red on one of two legs of the same GitHub run: k3d's
--wait returns when the node container reports ready, but the API
server can still answer discovery with 503 for a moment after that, and
kubectl cluster-info asked exactly once. A check that produces sporadic
red is worse than no check - that is the one property #227 rules out
explicitly. It now polls kubectl get nodes, which is the thing that has
to work and exercises discovery on the way.
2026-09-17 00:38:41 +02:00
jklein 31e4260580 install-test: reach the k3d cluster when the job itself is a container
Closes the last gap from #227. k3d publishes the API server port on the
Docker HOST. On GitHub's hosted runners the job is that host, so the
https://0.0.0.0:<port> k3d writes into the kubeconfig works. Every Gitea
Actions job is itself a container, and there the host is the dind
container: 0.0.0.0 points at the job container, where nothing listens.
The cluster comes up cleanly and the first kubectl call is refused - as
measured, surfacing two steps later as a validation error on the
fixtures, which named nothing useful.

The case is decided on whether THIS job runs in a container, not on
which forge it runs on. github.server_url would pick the right branch
today and still be wrong: a self-hosted GitHub runner in a container has
the identical problem, and a condition that says GitHub while meaning
not-containerised is the kind of lie this repo exists to remove. The
detection is the same question the fix needs answered anyway - ask the
daemon about ourselves. In a container it returns our network, k3d joins
it, and Docker's DNS resolves the node names; on a host it returns
nothing and the previous path is taken unchanged.

kubectl cluster-info and get nodes now run inside the create step, so a
cluster that is up but unreachable fails where it is caused instead of
two steps downstream.
2026-09-17 00:29:08 +02:00
jklein 301fc0d4e5 install-test: one cluster per matrix leg, and a pinned kubectl
Two assumptions about the runner environment, both found by the Gitea
probe of #225 and both wrong on GitHub as well - they simply could not
show there.

CLUSTER NAME. Every leg created a cluster called "install-test". On
GitHub each matrix leg gets its own VM with its own Docker daemon, so
two legs never saw each other and the fixed name looked fine for two
years. Gitea runs all legs of a matrix CONCURRENTLY in ONE job on ONE
daemon, and there it collides immediately: one leg logs "Re-using
existing network 'k3d-install-test'", the other dies on "The container
name /k3d-install-test-server-0 is already in use", and then the first
leg's rollback deletes the shared network under the second. Two red
legs, and neither message names the cause. The cluster is now named
after the chart, in the create step and in the delete step - a blanket
cleanup would tear down the neighbour's cluster, which was the half of
the collision that turned one failure into two. ci/run-install-test.sh
never touches k3d, so nothing else had to move.

The bare chart name rather than a prefixed one, measured not assumed:
k3d v5.9.0 accepts a cluster name of 32 characters and rejects 33
("Cluster name must be <= 32 characters"), and the longest chart here is
calibre-web-automated at 21. "install-test-<chart>" would be 34 - it
would break that one chart and no other, which is worse than the fixed
name because it only sometimes breaks. A guard states the limit instead
of leaving it to be rediscovered.

KUBECTL. It was inherited from the runner image. The Gitea image has
none ("kubectl: command not found", 0 seconds into the chart install),
but that is how it was noticed, not why it is being changed: a client
version that arrives with the image drifts with every image rebuild, and
an install-test should run against a known one. GitHub's image carrying
one only makes the drift invisible there. Pinned to v1.35.5 to match the
K3S_IMAGE it talks to, tracked by Renovate the same way TRIVY_VERSION
is, and guarded against skew: kubectl supports one minor away from the
server, Renovate moves KUBECTL_VERSION on its own, and K3S_IMAGE is
tracked by nothing. Dry run proves both directions - the rule extracts
(kubernetes/kubernetes, github-releases) and it fires (from v1.34.0 the
lookup offers v1.37.0, minor). That same measurement shows the guard is
not theoretical: Renovate jumps several minors in one PR, so it will go
red on purpose, naming which pin to move.
2026-09-17 00:22:16 +02:00
jklein c3889a66b6 Drop the Renovate workflow: the Gitea side already has a shared instance
A measurement overtook this part of the change. A shared `jlab-renovate-bot`
instance already runs on the Gitea side and already has this repository
enrolled - its first run there opened PRs for searxng and gotenberg plus the
dependency dashboard. A second runner in this repo would produce duplicate
PRs and two schedulers rewriting each other's branches, so
.github/workflows/renovate.yml is removed again.

Two things fall out of that run, and both were open risks:

- The instance reads our renovate.json5. searxng and gotenberg are
  customManagers targets of ours - no built-in manager finds an appVersion
  in a Chart.yaml - so the config is demonstrably in effect.

- allowedCommands is already granted there. The searxng PR's diff carries
  `version: 1.0.4+up2026.9.11.ffe96f8a6 -> 1.0.5+up2026.9.13.96479fff6`
  next to the appVersion, and nothing but ci/renovate-bump-chart.sh writes
  `version:`. Had that allow-list been missing, postUpgradeTasks would have
  been skipped SILENTLY and every chart PR on Gitea would have been red on
  the version check with nothing naming the cause.

With the workflow gone there is no container image to pin either; verified
by dry run that the ghcr.io/renovatebot/renovate dependency is no longer
extracted, while TRIVY_VERSION still is - that one belongs to the Trivy
replacement and stays.

README and CLAUDE.md now say Renovate runs on Gitea as an existing shared
instance and that no workflow belongs here, so the next reader who finds
none knows why instead of building one.
2026-09-16 15:49:36 +02:00
jklein a6c27ebef3 Prepare the Gitea move: measured CI deviations, self-hosted Renovate, issue migration script
Groundwork for #225, built on a throwaway pull request against the Gitea
instance rather than on assumptions. The move itself is not part of this
change - no Actions are switched off, no mirror is enabled, no branch
protection is touched.

What the probe run measured, and what follows from it:

- Which jobs need a Docker socket: only the install-test. Measured per
  job - detect-changed-charts, build-chart, the quantity render and the
  Trivy config scan all pass on `ubuntu-latest`, which on that instance
  has no socket. The install-test dies there at "Create k3d cluster"
  with "Cannot connect to the Docker daemon", an error naming k3d and
  never the runner label. Its `runs-on` is now an expression: the label
  `ubuntu-docker` on Gitea, `ubuntu-latest` on GitHub, because the
  former does not exist there and a chart PR would otherwise queue
  forever during the handover. Gitea evaluates it - the probe job was
  taken by the docker runner.

- The install-test still cannot pass on Gitea. That daemon is rootless
  with "Cgroup Driver: none"; the k3s node restarts immediately with an
  empty log because k3s needs cgroup delegation. The label is necessary
  and not sufficient, and the fix is the maintainer's.

- aquasecurity/setup-trivy cannot be used on Gitea: it checks out
  aquasecurity/trivy from github.com with `github.token`, which there is
  a Gitea token, and ends on "could not read Username for
  https://github.com". Replaced by the release tarball - one request,
  same behaviour on both forges, green on the probe run.

Also in this change:

- .github/workflows/renovate.yml, Renovate in self-operation: a daily
  scheduled run in the official container, inert on github.com via a
  `github.server_url` guard. Every rule proved by dry run instead of
  assumed - the pinned Renovate image is tracked by the built-in
  github-actions manager (44.80.0 -> 44.87.1, minor), TRIVY_VERSION is
  still tracked after setup-trivy is gone (v0.72.0 -> v0.74.0, minor),
  and `ubuntu-docker` is not extracted by the github-runners manager at
  all, so nothing will quietly "correct" it.

- ci/migrate-issues-to-gitea.sh, runnable, with --dry-run, and NOT run.
  Its header names what it cannot do: comment authorship, the #<n>
  references between issues, and attachments.

- CLAUDE.md and README.md on the new flow: which forge and which tool,
  auto-merge on green checks in place of the merge queue Gitea does not
  have, and the measured differences of the two CIs.

Refs #225 - deliberately no "Fixes": the move is not finished with this.
2026-09-14 17:07:31 +02:00
jklein 622e4c3ff8 ci: drop excluded charts from the install-test matrix
A chart in ci/excluded-charts.txt got an install-test job that ran the script, printed SKIP and exited 0. GitHub knows only the exit code, so the check list showed "Install-test satisfactory OK" for a chart whose image was never pulled - indistinguishable from one that really came up in the cluster (#231). detect-changed-charts now removes those charts before the matrix is built, so no job exists for them and nothing claims they passed.

An absence says nothing by itself, so the detect job writes every excluded chart of the PR with its documented justification into the job summary. A "Skipped" would only say "not run"; the reason is the question a review actually asks.

The list is not parsed in YAML. The workflow calls ci/run-install-test.sh --list-excluded, the one parser, and compares whole strings with jq, so a chart name is never read as a pattern. The script's own exclusion branch now goes through the same excluded_reason() helper instead of a grep that used the chart name as a regex; it stays for local runs, where the CI filter does not apply.

Not viable, and recorded at the matrix as a comment: deciding per chart in an "if" on the install-test job, which would give a visible Skipped entry instead of an absent one. GitHub rejects it - jobs.<id>.if is evaluated before the matrix is expanded, so the matrix context does not exist there ("context \"matrix\" is not allowed here. available contexts are \"github\", \"inputs\", \"needs\", \"vars\"."). Run 33650724109 on the closed PR #233 ended in failure with zero jobs created.
2026-09-04 16:58:14 +02:00
jklein 01f804a43a ci: publish charts to a classic index-based Helm registry
The publish step no longer speaks OCI. "helm registry login", "helm push"
and "helm registry logout" are replaced by a single HTTP POST of the
packaged .tgz to <charts-url>/api/charts, with Basic auth per request.

Credentials and the target URL come from a new 1Password item
op://GitHub-Actions/jlab-registry-access (username, password, charts-url).
The old item is left untouched, so a revert of this commit restores the
previous pipeline without any further work. No registry address enters
the repository.

The three properties of the old step survive:

- values are read via steps.<id>.outputs.* into the step's own env block
  and used as quoted shell variables, each guarded with ":?"
- credentials stay out of argv: "curl --config -" instead of "--user"
- a failed upload fails the job: "--fail-with-body", because curl exits 0
  on HTTP 401/403/409 without it and the publish would go green with
  nothing uploaded

The "<chart>-*.tgz" glob is replaced by the file name built from the
chart name and the version already read from Chart.yaml; --upload-file
takes exactly one file. Two small additions fall out of measuring the
new step: the registry host is masked, because a curl connection error
prints only "<host>:<port>" and the mask on the full URL does not cover
that, and whitespace in the credentials is rejected up front, because an
unquoted curl config value ends at the first space.

No chart is touched and no chart version is bumped.
2026-09-01 18:07:08 +02:00
jklein 6b0d8c9232 ytdlp-web-player: new chart with an optional oauth2-proxy subchart (1.0.0+upv1.2.0)
Deploys matszwe02/ytdlp_web_player, a Flask app that fetches videos with
yt-dlp, transcodes them with ffmpeg and serves them as HLS. It ships no
authentication of any kind, so the chart carries a preconfigured oauth2-proxy
as an optional subchart, in the data path in front of the app.

This is the first chart in the repo with a dependency, so the rule "charts
have no dependencies" falls here. It is replaced in CLAUDE.md and README.md by
a bar: a dependency has to be optional and off by default, upstream's chart is
consumed as it comes (documented where it falls short of our conventions,
never patched), and the price is written down - above all that a subchart is
invisible to everything downstream watching Helm release versions, so a new
proxy release only reaches a cluster when this chart is republished.

CI learns the dependency in five places, not the three the plan expected:
helm lint, package, template and install all exit 1 while charts/ is unfilled,
so ci/helm-dependencies.sh runs before lint and package in validate-charts.yml,
before package in publish-helm-chart.yml, and inside run-install-test.sh,
run-trivy-config.sh and run-quantity-render-test.sh - the last of which renders
every chart on every PR and would have gone red first.

Notable chart decisions, each measured rather than assumed:

- The image has no USER and runs as root by default, but not by necessity: it
  runs under an arbitrary uid with readOnlyRootFilesystem and the full
  hardening baseline. Only the pinned uid is a deviation, and it is documented.
- Two emptyDirs, each justified separately. /data holds the video cache
  (DATA_PATH). /tmp is NOT mounted - measured across metadata extraction, HLS
  transcoding, sprite generation and audio extraction, the image never writes
  there. What it does write is $HOME/.cache, so /cache is mounted with HOME and
  XDG_CACHE_HOME pointed at it; without it yt-dlp re-downloads its JavaScript
  challenge-solver from github.com on every extraction.
- The proxy is wired through config.configFile, the only value the subchart
  passes through tpl - config.upstreams is emitted with toJson and would carry
  a literal {{ .Release.Name }} into the manifest.
- Rendering aborts when this chart's Ingress and the proxy's claim the same
  host: ingress-nginx rejects the second one and a Fleet bundle then sits in
  ErrApplied until forceSyncGeneration is bumped.

Fixes #214
2026-08-30 17:34:49 +02:00
jklein 148e15a1f6 ci: guard resource quantities against exponential notation
Adds ci/run-quantity-render-test.sh and a validate-charts job that runs it
over every chart on every PR.

The bug behind #157 has a loud and a silent half. Loud: a bare integer request
of 1048576 is reformatted by printf "%v" to "1.048576e+06", the helper rejects
the suffix it produced itself and the render aborts. Silent: with a fractional
limitFactor the *product* is reformatted after the suffix check, the render
succeeds, and the manifest carries memory: "1.4999985e+06" - rejected by
nothing until the API server sees it at apply time.

So the test renders each chart twice and, for the silent case, searches the
rendered text rather than the exit code. It runs over all charts, not the
changed ones: the resources helper is copied into every chart's _helpers.tpl,
so the regression arrives by copy, in a PR whose diff shows a new chart and not
the bug inside it. The resources paths come from values.schema.json (JSON, so
python3 reads it without pyyaml), which also means a new chart is enrolled
automatically and there is no list to maintain.

Charts that pin every computable limit in their fallback defaults never reach
the helper (gotenberg, rss-bridge). They are reported as "not exercised"
instead of counted as passing.
2026-08-29 01:25:48 +02:00
jklein 8df1913c5c bentopdf: new chart for the PDF toolbox (1.0.0+up2.8.7)
BentoPDF computes entirely in the visitor's browser; the container only serves
static files through nginx. The chart is therefore close to cyberchef - same
nginx-unprivileged base, uid 101, port 8080, probes on "/" - with four
differences that shaped the design.

Runtime config.json. The one setting reachable at runtime is the tool list.
It is a static file in the middle of the web root, so it is mounted by
subPath: a directory mount would hide the whole application. subPath mounts
are never refreshed by the kubelet, which makes the checksum/config annotation
(#82) load-bearing rather than decorative. bentopdf.config is passed through
verbatim, like searxng's settings, so a key upstream adds tomorrow is a values
change and not a chart change.

Three env switches that would kill the pod. PORT, DISABLE_IPV6 and
ROBOTS_NOINDEX are implemented as "sed -i" against files in the image, run
under "set -e" in /docker-entrypoint.d/, and the base image's entrypoint
aborts when one of them fails. With readOnlyRootFilesystem: true the pod
therefore never starts instead of ignoring the switch - measured in k3d as
CrashLoopBackOff, exit 1, "sed: can't create temp file
'/etc/nginx/nginx.confXXXXXX': Read-only file system". None of the three is
offered as a first-class value; values.yaml names the failure picture and the
escape hatch (env block plus readOnlyRootFilesystem: false).

Writable paths were measured, not copied from the upstream chart.
/etc/nginx/tmp holds the pid file and /var/cache/nginx is where nginx creates
its temp directories at startup - without it the master aborts with
'mkdir() "/var/cache/nginx/client_temp" failed'. /var/run is deliberately not
mounted: the running pod proves it is unnecessary.

Everything else about BentoPDF is a build-time argument and out of reach for
any chart; values.yaml says so explicitly rather than leaving it to be
discovered. Two accepted consequences are named there too: some tools fetch
WASM modules from a public CDN at runtime, and the served pages carry a
canonical link to the vendor's site.

Fixes #174
2026-08-28 18:22:30 +02:00
jklein 46942db53a searxng: new chart (1.0.0+up2026.8.22.9fea41204)
SearXNG as a stateless Deployment plus a settings ConfigMap, with the four CI
steps a new chart needs: publish workflow, change-app-version choice list,
ci/searxng/ fixtures and a renovate.json5 block.

Three points where the chart is not a cyberchef with another name:

* The image entrypoint writes settings.yml into /etc/searxng only when the
  file is missing. The chart always mounts one by subPath, so the copy and
  the random-secret sed never run and the container needs no write access
  there - which is what allows readOnlyRootFilesystem: true.
* The signing key is a required OnePasswordItem, not a ConfigMap key. It
  signs the settings cookie and the image-proxy URLs, and the entrypoint's
  self-generating fallback cannot fire here, so without it the instance would
  run on the literal "ultrasecretkey" - which webapp.py refuses with exit 1.
* All three probes use /healthz, the only path the bot detection lets through
  unconditionally. Measured: with the limiter active, "/" answers 429 while
  /healthz still answers 200.

ingress.domain is read a second time outside the Ingress template, for
SEARXNG_BASE_URL, and carries its own required() there - so it stays
mandatory with the Ingress switched off.

The Renovate versioning regex deviates from the plan on the issue and matches
the commit hash WITHOUT capturing it. Renovate parses a "build" group with
Number.parseInt, so a hash becomes the digits leading it; measured on
renovate 41.173.1 and 44.48.0, the planned form reports an image pushed
earlier the same day as an update to a newer one. Reasoning in the block and
in README.
2026-08-27 21:09:59 +02:00
jklein 69a280399c ci: run the freesailarr install-test for real instead of skipping it
freesailarr was excluded from the install-test because gluetun never becomes
healthy without a real Wireguard key and no app container behind its kill
switch can become Ready. The green "Install-test freesailarr pass" was a
25-second SKIP on the most complex chart in the repo - a signal that proved
nothing.

With a CI-only Mullvad key in 1Password the reason is gone:

- ci/run-install-test.sh learns a general rule: ci/<chart>/fixtures.env.yaml is
  rendered with envsubst before it is applied, restricted to the variable names
  a chart declares in REQUIRED_ENV. A missing or empty declared variable fails
  the run loudly and names the variable - it never skips the chart.
- The install-test job loads the key from 1Password Connect for freesailarr
  only, following the pattern publish-helm-chart.yml documents: values via
  steps.<id>.outputs.*, mapped into the step's own env, quoted, with ":?"
  guards that name the missing op:// field before a cluster is created.
- Fixtures for freesailarr: three media claims, the gluetun secret template
  with variable names only, firewallOutboundSubnets so the kill switch does not
  block cluster DNS, and INSTALL_TIMEOUT=900 for nine containers.

freesailarr/Chart.yaml goes 3.0.0+up1.0.0 -> 3.0.1+up1.0.0. That bump is
deliberately empty of content: the install-test matrix follows changed chart
directories, and without it this PR could not run the test it adds.
2026-08-26 14:30:42 +02:00
jklein 9c81061f92 Merge pull request #59 from jk-cluster/feat/freesailarr-chart
Add freesailarr chart: gluetun-tunneled media automation stack
2026-08-24 13:38:58 +00:00
jklein af5f9ed79b Pin gluetun in values too, so Renovate proposes it there
The chart bundles nine independently versioned upstreams, and eight of them
were already pinned in values.yaml while only gluetun took its tag from
appVersion. That split had no upside: it made gluetun the one image Renovate
could not reach through the built-in helm-values manager, and it implied a
"main app" the stack does not have.

gluetun now carries gluetun.image.repository/tag like every other image, and
the StatefulSet renders it from there. No tag comes from appVersion any more.
Renovate picks all nine up through helm-values - the manager the repo already
relies on for sidecar images - and ci/renovate-bump-chart.sh bumps the chart
version with each update.

Consequences, all documented where they bite:
- renovate.json5 deliberately gets no app-version block for this chart. One
  would either duplicate the helm-values updates or rewrite an appVersion that
  selects no image. The absence is commented so it does not read as an
  oversight.
- freesailarr leaves the change-app-version.yml choice list. That workflow
  rewrites appVersion and republishes, which for this chart would change no
  deployed image at all.
- appVersion becomes a hand-maintained marker for the gluetun line. Since
  renovate-bump-chart.sh bumps the patch level on an unchanged appVersion, a
  gluetun update has to be paired with a hand edit of appVersion, or the +up
  suffix stops describing what the chart deploys. Noted in Chart.yaml,
  values.yaml and README.
2026-08-24 15:01:34 +02:00
jklein 0fb6c30466 publish: read registry credentials from step outputs and validate them
Two defects kept the publish broken after the load-secrets-action v5 bump
in #140.

v5 no longer exports the loaded values as environment variables for later
steps the way v4 did; only the step outputs carry them. Every
"${{ env.JK_CR_* }}" reference therefore resolved to an empty string from
that bump on, and nothing reported it - the loading step logs "Populating
variable" and stays green either way. #149 moved three of the four
references to steps.<id>.outputs but left "-u ${{ env.JK_CR_USERNAME }}"
on the old form, and piped the username instead of the token into
--password-stdin.

The second defect is why this surfaced as a terminal error: an empty value
collapsed into a missing argument instead of failing. With an empty
username, "-u" swallowed the following "--password-stdin", helm stopped
reading the password from stdin, prompted interactively and died with
"inappropriate ioctl for device".

All four values now come from steps.<id>.outputs, are mapped into each
step's own env block and used as quoted shell variables guarded with ":?".
An empty or unset value aborts the job naming the op:// path to fix,
quoting keeps it from shifting arguments, and the token no longer appears
on the command line.
2026-08-24 14:03:09 +02:00
jklein f6f04d27fe fix(publish-action): fixed 1Password secret access 2026-08-24 13:47:01 +02:00
jklein 914c2d0205 Add freesailarr chart: gluetun-tunneled media automation stack
Single-pod StatefulSet running gluetun (appVersion 3.41.1) as Wireguard
VPN gateway with qbittorrent, prowlarr, flaresolverr, radarr, recyclarr,
sonarr, seerr and bazarr sharing its network namespace. Config volumes
via volumeClaimTemplates, media libraries as existing claims, per-app
enable/ingress toggles, computed FIREWALL_INPUT_PORTS, hardened
security contexts with documented per-image deviations. Wires up
publish workflow, change-app-version choice, install-test exclusion
and README overview.
2026-08-24 13:41:46 +02:00
jklein 2e0e37d838 renovate: automerge minor updates, make the install-test blocking
The two open Renovate PRs (#138 alpine 3.22 -> 3.24, #139 nginx 1.30.4 ->
1.31.4) never automerged because both are minor updates while the rule
only covered patch and digest. Sidecar images barely move in patch steps,
so patch-only would practically never fire.

Automerging a chart update publishes it, so the install-test can no
longer report failures without blocking: continue-on-error is gone and
issue #22's stabilization phase ends here. Majors stay manual.
2026-08-24 12:01:08 +02:00
jklein 6b25d082a7 renovate: chart-aware config with an automatic version bump
Renovate only ever rewrites appVersion or an image tag, so every PR it
opened against a chart directory failed the build-chart version check
(#138, #139). ci/renovate-bump-chart.sh recomputes
version: <semver>+up<appVersion> as a postUpgradeTasks hook: chart major
when the app major moves, chart patch otherwise.

Each chart renders its main image as <repo>:{{ .Chart.AppVersion }}, so
repository and version live in different files and Renovate cannot join
them. The mapping is one customManager per chart in renovate.json5,
deliberately not a comment inside each Chart.yaml - that would touch all
20 chart directories and force 20 publishes for a comment change.

Patch and digest updates automerge after a 3-day minimumReleaseAge once
the pipeline is green; majors arrive one line at a time to match the
stepwise app-major procedure. ci/** stays excluded: the install-test
fixtures pin throwaway infrastructure to major lines on purpose.
2026-08-24 11:42:43 +02:00
jklein e730a81b17 Merge pull request #140 from jk-cluster/renovate/1password-load-secrets-action-5.x
Update 1password/load-secrets-action action to v5
2026-08-24 09:20:12 +00:00
jklein a176c7576c Merge pull request #142 from jk-cluster/renovate/azure-setup-helm-5.x
Update azure/setup-helm action to v5
2026-08-24 09:20:01 +00:00
jlab-renovate[bot] 5026622763 Update azure/setup-helm action to v5 2026-08-24 00:01:19 +00:00
jlab-renovate[bot] 35d381432c Update actions/checkout action to v7 2026-08-23 23:01:23 +00:00
jlab-renovate[bot] f65f31bed8 Update 1password/load-secrets-action action to v5 2026-08-23 23:01:15 +00:00
jklein 3fa67d55ef rss-bridge: new chart
Takes over the four loose manifests in cluster-config/rss-bridge/ as a
proper chart, on the current template-chart standard: strict values
schema, null-safe helpers, computed limits, replicas/scaleDown,
checksum/config, NOTES.txt.

Two things deviate from the repo standard on purpose:

- The Ingress models host AND TLS secret name per entry instead of the
  single conventional tls-<release>-<chart>-ingress. The installation
  this replaces has grown three separate certificates, one per host.
  Renaming them would make cert-manager issue three new ones and leave
  each host unreachable until it did - a self-inflicted outage on a
  deployment that has been up for over a year. Entries sharing a secret
  name are grouped into one tls entry, and omitting the name falls back
  to the convention, so consolidating later is a values change.

- Requests and limits are decoupled. The old manifest requested 2Gi of
  ephemeral storage, identical to its limit, reserving two gigabytes for
  a workload measured at 2.2 MB after fetching all five whitelisted
  bridges. The request drops to 64Mi; the limit stays at 2Gi.

Hardening was verified rather than assumed. The image ships no USER and
runs as root by default, but runs fully hardened once its writable paths
are provided: uid/gid 33, readOnlyRootFilesystem, all capabilities
dropped, seccompProfile RuntimeDefault. The writable paths were found by
running the image with a writable rootfs, exercising every bridge and
reading its diff layer - /app/cache, /run, /run/php, /var/lib/nginx.
/tmp did not show up that way, because PHP's opcache creates its lock
file there and unlinks it immediately; it only surfaced when the
hardened container died at startup. Stopping at the first writable path
would have shipped a chart that cannot start.

Probes use the app's own /?action=health, which renders fixed JSON
without touching the cache or any remote site, so neither a scraped site
going down nor an unwritable cache can restart the pod.

Fixes #128
2026-08-23 23:33:23 +02:00
jklein 1ae5e1a7c3 CI: report Trivy misconfiguration findings for changed charts
Adds a trivy-config job to the PR pipeline that renders each changed chart
with Helm and runs `trivy config` over the rendered manifests. Reporting
only - TRIVY_CONFIG_MODE=report always exits 0, so the job never blocks a PR.

Rendered manifests rather than the chart directory: `trivy config <chart-dir>`
renders with default values, which fails on every chart here because of their
required() values - and Trivy does not fail with it, it logs "Skipping chart"
and prints an empty, green report.

Values come from the install-test fixtures (ci/<chart>/test-values.yaml), the
only committed per-chart values in the repo; none of them touches
securityContext, images or capabilities, so the scan sees the chart's own
hardening. Charts without fixtures get ci/trivy/values/<chart>.yaml.

Exceptions live in ci/trivy/trivyignore.yaml in Trivy's native ignore-file
schema, each with a statement - the script refuses to run without one and
reports suppressed findings with their reason instead of hiding them.
AVD-KSV-0125 is configured instead of excepted: ci/trivy/data/ksv0125.yaml
lists the trusted registries, so the check stays able to flag a genuinely
foreign source.

Refs #84.
2026-08-23 23:14:12 +02:00
jklein a68192e5fb Import minecraft-server chart for Fleet migration stage 1 (java8 line)
Imports the chart from cluster-config functionally unchanged, wires it into
the publish/validate/install-test pipelines and publishes it as the first of
three Java lines (0.4.0+upjava8).

The only deviation from the source chart is the chart-global scaleDown flag
plus a server.replicas value, following the satisfactory pattern: two of the
three servers stand at 0 replicas in the cluster, which the chart could not
express, and Fleet would otherwise start them on reconcile. The default
(scaleDown: false) still renders replicas: 1, so the rendering of the running
server is unchanged - verified byte-identical against the three real values
files for all three Java lines.

Known deficiencies (hardening, probes, naming scheme, hardcoded values,
missing icon, the misleading mc-router auto-discovery annotations) are left
untouched on purpose and are scheduled for stage 2.
2026-08-23 15:57:17 +02:00
jklein 3be5111630 Import minecraft-router chart unchanged (Fleet migration stage 1)
Takes the existing mc-router chart over from cluster-config as-is so the
deployment can move to Fleet without any change to the rolled-out state.
Convention alignment follows in stage 2, the app update in stage 3.

The only deviation from "unchanged": Chart.yaml carried name:
minecraft-server (copy-paste from the sister chart). The publish job pushes
by the glob <directory>-*.tgz while helm package names the archive after the
Chart.yaml name, so without this correction the push finds no file.

Version 0.1.1+up1.25.1 - chart semver stays at 0.1.1, the +up suffix is repo
policy and does not affect rendering.

Also wires up CI: publish workflow, change-app-version choice list,
install-test values (CI-only node-port override, since 25565 is outside the
k3d default range) and the README chart table.
2026-08-23 12:58:21 +02:00
jklein c668780fd3 Fix satisfactory publish workflow name 2026-08-22 15:15:21 +02:00
jklein cc6084ca58 Add install-test stage to the PR pipeline (k3d)
New install-test job in validate-charts.yml: for exactly the charts a PR
changes, install them into an ephemeral k3d cluster (pinned k3d v5.9.0,
k3s v1.35.5-k3s1) and require every Deployment/StatefulSet to become
Ready. Runs non-blocking (continue-on-error) during the stabilization
phase.

Fixtures live at the repo root under ci/ (not inside chart directories,
so they neither trigger publishes on merge nor get packaged): a stub
OnePasswordItem CRD, per-chart test values and fixtures (dummy secrets
with in-cluster DSNs, throwaway postgres/redis/dex, synapse-generate
seed job, static hostPath PVs where charts pin storage classes or need
RWX), plus a shared ci/run-install-test.sh harness that also runs
locally.

Excluded with documented reasons (ci/excluded-charts.txt): satisfactory
(multi-GB image), teamspeak-server (TS6 beta license expired, image
crashes on start), template-chart (placeholder image).

All 14 non-excluded charts verified locally in k3d using only the
committed fixtures.
2026-08-21 23:12:38 +02:00