feat: Gateway-API as an alternative to the Ingress in eight charts
Validate Helm Charts / Detect changed charts (pull_request) Successful in 5s
Validate Helm Charts / Resource quantities (pull_request) Successful in 15s
Validate Helm Charts / Selector match (pull_request) Successful in 14s
Validate Helm Charts / Build bentopdf (pull_request) Successful in 9s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 17s
Validate Helm Charts / Trivy config bentopdf (pull_request) Successful in 19s
Validate Helm Charts / Build garage (pull_request) Successful in 8s
Validate Helm Charts / Build patchmon (pull_request) Successful in 7s
Validate Helm Charts / Build rallly (pull_request) Successful in 7s
Validate Helm Charts / Build searxng (pull_request) Successful in 6s
Validate Helm Charts / Build sure (pull_request) Successful in 7s
Validate Helm Charts / Build urlaubsverwaltung (pull_request) Successful in 6s
Validate Helm Charts / Trivy config garage (pull_request) Successful in 18s
Validate Helm Charts / Build voucher-vault (pull_request) Successful in 7s
Validate Helm Charts / Trivy config patchmon (pull_request) Successful in 16s
Validate Helm Charts / Trivy config rallly (pull_request) Successful in 18s
Install-test / Install-test bentopdf (pull_request) Successful in 1m10s
Validate Helm Charts / Trivy config searxng (pull_request) Successful in 22s
Validate Helm Charts / Trivy config sure (pull_request) Successful in 18s
Validate Helm Charts / Trivy config urlaubsverwaltung (pull_request) Successful in 17s
Validate Helm Charts / Trivy config voucher-vault (pull_request) Successful in 19s
Install-test / Install-test garage (pull_request) Successful in 1m17s
Install-test / Install-test patchmon (pull_request) Successful in 1m24s
Install-test / Install-test rallly (pull_request) Successful in 1m39s
Install-test / Install-test searxng (pull_request) Successful in 1m7s
Install-test / Install-test sure (pull_request) Successful in 2m39s
Install-test / Install-test voucher-vault (pull_request) Successful in 2m9s
Validate Helm Charts / Install-test (pull_request) Successful in 13m15s
Install-test / Install-test urlaubsverwaltung (pull_request) Successful in 1m50s
Publish BentoPDF Helm Chart / Publish BentoPDF Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 9s
Publish Garage Helm Chart / Publish Garage Helm Chart (push) Successful in 9s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 9s
Publish Rallly Helm Chart / Publish Rallly Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish SearXNG Helm Chart / Publish SearXNG Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 9s
Publish Sure Helm Chart / Publish Sure Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish Urlaubsverwaltung Helm Chart / Publish Urlaubsverwaltung Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish Patchmon Helm Chart / Publish Patchmon Helm Chart (push) Successful in 9s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
Publish VoucherVault Helm Chart / Publish VoucherVault Helm Chart (push) Successful in 10s
Publish Helm Chart with patched Application Version / publish-helm-chart (push) Successful in 10s
SonarQube Analysis / SonarQube Analysis (push) Successful in 43s

Group 1 of the #114 sweep: the charts that render exactly one Ingress object
and set no behaviour-bearing nginx annotation - bentopdf, garage, patchmon,
rallly, searxng, sure, urlaubsverwaltung, voucher-vault.

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

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

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

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

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

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

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

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

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

Part of #114
This commit was merged in pull request #116.
This commit is contained in:
jklein committed 2026-10-12 16:06:18 +00:00
1 parent de92e59bb0
commit 33d7cedbab
51 files changed
+2303 -107

No files matched your search

+1 -1
View File
@@ -4,5 +4,5 @@ description: A Helm chart to deploy urlaubsverwaltung on Kubernetes
icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png
type: application
version: 10.0.3+up6.15.0
version: 10.1.0+up6.15.0
appVersion: 6.15.0
+65 -10
View File
@@ -44,11 +44,16 @@ force. Mirror the defaulting of the resources helper on the raw value instead.
{{- range $entry := .Values.urlaubsverwaltung.env }}
{{- $envNames = append $envNames $entry.name }}
{{- end }}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list .Values.ingress.domain -}}
{{- end -}}
Urlaubsverwaltung {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
https://{{ .Values.ingress.domain }}
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
@@ -64,15 +69,63 @@ ACCESS
API description, not of data. Turning them off is a
springdoc.api-docs.enabled / springdoc.swagger-ui.enabled property; the chart
sets neither, so it can be added through urlaubsverwaltung.env.
{{- else if $httpRoute.enabled }}
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
{{- range $hostnames }}
https://{{ . }}
{{- end }}
The route publishes the application at "/" with no path restriction, exactly
as the Ingress does — including /swagger-ui.html and /v3/api-docs, which this
app version enables by default and this chart does not turn off. Both are
behind the login, so this is exposure of an API description, not of data;
springdoc.api-docs.enabled / springdoc.swagger-ui.enabled through
urlaubsverwaltung.env is how to turn them off.
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING else
of the routing path — no Gateway, no class, no Certificate. Three things
therefore belong to the cluster and not to this chart:
1. The Gateway-API CRDs. Without them this install would have failed.
2. The Gateway named in httpRoute.parentRefs
{{- range $httpRoute.parentRefs }}
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
{{- end }}
must exist AND its listener must allow routes from this namespace
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
up only in the route's status:
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
Read status.parents[].conditions — "Accepted: True" and
"ResolvedRefs: True" is a working attachment. An empty status means no
controller has looked at the route at all.
3. The TLS certificate for the hostname, on the Gateway's listener.
ingress.clusterIssuer is NOT used in this mode and is not required: the
chart renders no Certificate, so there is no
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
THE HOSTNAME AND THE APPLICATION'S IDEA OF ITSELF STILL DO NOT AGREE BY
THEMSELVES. No environment variable of the container derives from
ingress.domain or from httpRoute.hostnames; the address the application
considers its own is the OIDC redirect URI, out of the 1Password item below.
The two can drift apart without any complaint — the route answers, the login
bounces.
ingress.annotations is inert in this mode: the Gateway API has no annotation
channel. Nothing this chart needs lives there.
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate;
ingress.clusterIssuer is not needed. Unlike other charts in this repo,
ingress.domain is read by NOTHING else here — no environment variable of the
container derives from it. The address the application considers its own is
the OIDC redirect URI, and that comes out of the 1Password item below, not
out of this value. The two can drift apart without any complaint.
ACCESS — NEITHER INGRESS NOR HTTPROUTE
ingress.enabled and httpRoute.enabled are both false, so this release has no
Ingress, no HTTPRoute and no certificate;
ingress.clusterIssuer is not needed, and neither is ingress.domain. Unlike
other charts in this repo, ingress.domain is read by NOTHING here except the
two routing templates — no environment variable of the container derives from
it. The address the application considers its own is the OIDC redirect URI,
and that comes out of the 1Password item below, not out of this value. The
two can drift apart without any complaint.
Reach the release inside the cluster with
@@ -87,7 +140,9 @@ SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}urlaubsverwaltung.replicas: 0{{ end }} — the Deployment runs with replicas: 0, so the endpoint
above does not answer until you set it back and upgrade again. Both
OnePasswordItem CRs and the Service{{ if .Values.ingress.enabled }} and Ingress stay in place, so the
certificate is not re-issued.{{ else }} stay in place.{{ end }} This chart owns no volume: all
certificate is not re-issued.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so the route
keeps pointing at a Service with no endpoints — the Gateway answers with an
error of its own rather than with nothing.{{ else }} stay in place.{{ end }} This chart owns no volume: all
state lives in the external database, so scaling to zero loses nothing.
{{ end }}
SECRETS — THE FIELDS EACH 1PASSWORD ITEM MUST CARRY
@@ -114,7 +169,7 @@ SECRETS — THE FIELDS EACH 1PASSWORD ITEM MUST CARRY
redirect-uri the full callback URL. This chart registers the client
under the id "default", so the path is
/login/oauth2/code/default — for example
https://{{ if .Values.ingress.enabled }}{{ .Values.ingress.domain }}{{ else }}example.com{{ end }}/login/oauth2/code/default
https://{{ if or .Values.ingress.enabled $httpRoute.enabled }}{{ first $hostnames }}{{ else }}example.com{{ end }}/login/oauth2/code/default
and the same URL has to be registered at the provider.
This chart deploys NO database and NO identity provider. Both have to exist
@@ -0,0 +1,84 @@
{{/*
The Gateway-API alternative to urlaubsverwaltung.ingress.yaml (Gitea #114).
Form and reasoning: template-chart/templates/xxx.httproute.yaml and README,
"Ingress and Gateway API".
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
Certificate, and no implementation-specific policy object - that subset looks
identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, which is
the whole reason the cluster's implementation can be swapped without touching
this chart. Class and certificate therefore belong to the PLATFORM, on the
Gateway and its listeners.
ingress.clusterIssuer is meaningless here - this chart renders no Certificate
in this mode - and it is not demanded either: its only required() sits inside
urlaubsverwaltung.ingress.yaml, behind ingress.enabled.
ingress.domain, CHECKED FOR THIS CHART AND NOT COPIED
Unusually for this repository, nothing outside the routing templates reads
ingress.domain here: no environment variable of the application is derived from
it. UV_MAIL_APPLICATIONURL - the host the app puts into the links inside its
notification mails - is a hardcoded placeholder (http://localhost:8080) and is
deliberately NOT derived from ingress.domain, which NOTES.txt already says and
which this mode does not change. So with both routing modes off, domain is
genuinely free, and in Gateway mode the required() below is the one that fires.
What does NOT follow the hostname automatically is the OIDC redirect URI: it is
a field of the 1Password item and is registered at the identity provider.
Publishing under a different name - including via httpRoute.hostnames - means
updating both, or login fails with a redirect-uri mismatch while the pod stays
green. NOTES.txt says this for both modes.
*/}}
{{- /*
Null-safe like every other block this chart dereferences (#99): an override
written without children - "httpRoute:" with the keys below it deleted - is
YAML null, and `.Values.httpRoute.enabled` would abort the render with a nil
pointer. An empty block means "chart defaults apply", and all three defaults
are empty or false, so the whole block collapses to "no HTTPRoute".
*/ -}}
{{- $httpRoute := .Values.httpRoute | default dict -}}
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both." -}}
{{- end -}}
{{- if $httpRoute.enabled -}}
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
{{- if not $parentRefs -}}
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
{{- end -}}
{{- /*
One source of truth for the hostname: an empty httpRoute.hostnames means
[ingress.domain], so the two cannot drift apart. ingress.domain is read here
even though no Ingress is rendered in this mode - the value names the public
hostname of this release, which both modes need; it is not an Ingress switch.
*/ -}}
{{- $hostnames := $httpRoute.hostnames | default list -}}
{{- if not $hostnames -}}
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-httproute
spec:
parentRefs:
{{- toYaml $parentRefs | nindent 4 }}
hostnames:
{{- toYaml $hostnames | nindent 4 }}
rules:
# The Ingress counterpart is `path: / , pathType: Prefix` on the one
# backend Service - the Spring Boot application serves the UI, /login and
# /actuator from one port. The port is the Service's port NUMBER and not
# its name ("http"): backendRefs takes a PortNumber, so unlike the Ingress
# this reference cannot be made by name and the 80 below has to track
# urlaubsverwaltung.service.yaml by hand.
# ci/run-httproute-match-test.sh is what holds that pair - it fails when a
# backendRef names a Service, or a port, the same render does not create.
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-service
port: 80
{{- end }}
+31 -1
View File
@@ -31,6 +31,7 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the whole Ingress template. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. Checked in both directions for this chart: nothing outside the two routing templates reads ingress.domain (UV_MAIL_APPLICATIONURL is a hardcoded placeholder and deliberately not derived from it), so domain is unnecessary while BOTH routing modes are off; clusterIssuer is read by the Ingress alone.",
"type": "boolean"
},
"clusterIssuer": {
@@ -43,11 +44,35 @@
"type": ["string", "null"]
},
"annotations": {
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature.",
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone when httpRoute.enabled is used instead.",
"type": "object"
}
}
},
"httpRoute": {
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true. The chart renders only an HTTPRoute with parentRefs; class and certificate sit on the Gateway and belong to the platform. The Gateway-API CRDs must be present in the cluster, because the chart cannot detect them.",
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"parentRefs": {
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
"type": "array",
"items": {
"$ref": "#/definitions/parentRef"
}
},
"hostnames": {
"description": "Empty means [ingress.domain], so the hostname has a single source of truth. The OIDC redirect URI lives in the 1Password item and does not follow it - publishing under a new name means updating both.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"urlaubsverwaltung": {
"type": "object",
"additionalProperties": false,
@@ -78,6 +103,11 @@
}
},
"definitions": {
"parentRef": {
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
"type": "object",
"required": ["name"]
},
"resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
"type": ["object", "null"],
+62
View File
@@ -23,14 +23,76 @@ secrets:
oidcSecretRef: null
ingress:
# Gates the whole Ingress template. Mutually exclusive with
# httpRoute.enabled below (Gitea #114): with both true the render aborts,
# naming both values.
#
# Guideline 1, checked in both directions for this chart: NOTHING outside the
# two routing templates reads ingress.domain - no environment variable of the
# application is derived from it, and UV_MAIL_APPLICATIONURL (the host in the
# links inside notification mails) is a hardcoded placeholder, deliberately
# not derived from it. So clusterIssuer AND domain are both free while both
# routing modes are off; with either one on, domain is required and its
# required() fires in whichever template renders. clusterIssuer is only ever
# read by the Ingress - Gateway mode renders no Certificate.
enabled: true
clusterIssuer: null
# The public hostname of this release. Read by the Ingress and - when
# httpRoute.hostnames is empty - by the HTTPRoute.
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
#
# THESE HAVE NO COUNTERPART IN GATEWAY MODE. The Gateway API has no
# annotation channel for controller behaviour, so anything configured here is
# simply gone when httpRoute.enabled is used instead - it is then the
# Gateway's business, or that of an implementation-specific policy object
# this chart deliberately does not render. Nothing this chart needs lives
# here by default.
annotations: {}
# Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so
# an existing release is unaffected; exactly one of ingress.enabled and
# httpRoute.enabled may be true.
#
# The chart renders ONLY an HTTPRoute with parentRefs - no Gateway, no
# gatewayClassName, no Certificate and no implementation-specific policy
# object. Class and certificate belong to the Gateway and therefore to the
# platform, which is what keeps this chart independent of whether the cluster
# runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio.
#
# The Gateway-API CRDs must be installed in the cluster: the chart does not and
# cannot detect them (.Capabilities.APIVersions is unreliable at template
# time), so with httpRoute.enabled: true and no CRDs the INSTALL fails.
httpRoute:
enabled: false
# The Gateway(s) this route attaches to - a platform object, usually in
# another namespace, which must allow this namespace in its listener's
# allowedRoutes. One entry per parent, the fields are the Gateway API's
# ParentReference (name, and optionally namespace, sectionName, port, group,
# kind), passed through unchanged:
#
# parentRefs:
# - name: public
# namespace: gateway
# sectionName: https
#
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
# list, because a route without a parent is created, is valid and routes
# nothing, with no event to say so.
parentRefs: []
# Empty means [ingress.domain], so the hostname has one source of truth. Set
# explicitly only to publish under a different or an additional name - which
# is how a migration from the Ingress runs: the HTTPRoute takes a second
# hostname while the Ingress keeps ingress.domain.
#
# For THIS chart, mind that the OIDC redirect URI is a field of the 1Password
# item and is registered at the identity provider - it does not follow the
# hostname by itself, so publishing under a new name means updating both or
# login fails on a redirect-uri mismatch while the pod stays green.
hostnames: []
urlaubsverwaltung:
# Replica count for this workload; 0 is allowed and scales it down.
# The chart-global scaleDown flag takes precedence.