6
urlaubsverwaltung
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

urlaubsverwaltung

Deploys Urlaubsverwaltung, a leave and absence manager for teams, as a single-replica Deployment. It is a Spring Boot application: the database and the OpenID Connect provider are not part of this chart, and the release owns no volume at all.

Image docker.io/urlaubsverwaltung/urlaubsverwaltung
Versions not listed on purpose — read the current pair off urlaubsverwaltung/Chart.yaml, see Chart index
Workloads Deployment (1 replica)
Storage none — /tmp is an emptyDir, all state is in the external database
Routing two mutually exclusive modes. Ingress (default): ingress.domain + ingress.clusterIssuer required while ingress.enabled. Gateway API: httpRoute.enabled with httpRoute.parentRefs — see Routing: Ingress or Gateway API
External dependencies a database (JDBC), an OIDC provider
Secrets 2 × OnePasswordItem
Install-test ci/urlaubsverwaltung/ (fixtures + test values, INSTALL_TIMEOUT=600)

Prerequisites

  • 1Password operator in the cluster, plus two 1Password items — see the fields below.
  • A database reachable before the first start. Liquibase migrates the schema on every boot, before the HTTP port opens; against an empty database it creates the schema and seeds settings, vacation and sick-note types.
  • An OIDC provider that is up at rollout time. The application resolves <issuer-uri>/.well-known/openid-configuration while building its security beans — a provider that does not answer keeps the pod from starting at all. See The identity provider is required at boot.
  • cert-manager with the ClusterIssuer named in ingress.clusterIssuer, and DNS for ingress.domain pointing at the ingress controller.
  • A registered redirect URI at the provider: this chart registers the client under the id default, so the path is /login/oauth2/code/default, e.g. https://urlaub.example.com/login/oauth2/code/default.

Installation

The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.

helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update

helm install urlaubsverwaltung jlab/urlaubsverwaltung \
  --namespace urlaubsverwaltung --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml:

secrets:
  databaseSecretRef: op://vault/uv-db
  oidcSecretRef: op://vault/uv-oidc

ingress:
  domain: urlaub.example.com
  clusterIssuer: letsencrypt

The same installation in Gateway mode instead — the Gateway, its class and its certificate belong to the cluster, not to this release:

ingress:
  enabled: false
  domain: <the public hostname>   # the HTTPRoute's hostname, see below
httpRoute:
  enabled: true
  parentRefs:
    - name: public
      namespace: gateway
      sectionName: https

This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; ingress.clusterIssuer is not used. See Routing: Ingress or Gateway API.

helm install prints the chart's NOTES.txt, which renders the same information against the values actually set. Read it — it is the per-release version of this page. Read it at install time in particular, because two of the overrides it describes only work then (see Environment variables).

What the 1Password items must contain

The chart renders OnePasswordItem CRs; the operator turns each into a Kubernetes Secret whose keys are the field names of the 1Password item. The field names are decided by this chart and appear nowhere in 1Password. A missing or misspelled field does not fail the install: the Secret exists, looks as if the operator had done its job, and the pod hangs in CreateContainerConfigError naming the key it could not find.

Values key Kubernetes Secret Fields
secrets.databaseSecretRef <release>-urlaubsverwaltung-db-secret jdbc-url (a full JDBC URL, e.g. jdbc:postgresql://postgres.example.com:5432/urlaubsverwaltung), username, password
secrets.oidcSecretRef <release>-urlaubsverwaltung-oidc-secret client-id, client-secret, issuer-name (the provider's display name on the login button), issuer-uri (without /.well-known/openid-configuration), jwt-issuer-uri (the same issuer for the resource-server side), redirect-uri (the full callback URL, path /login/oauth2/code/default)

What the Secret actually delivered:

kubectl -n <namespace> get secret <release>-urlaubsverwaltung-db-secret -o jsonpath='{.data}'

Configurable values

Global

Key Type Default Description
global object unset Helm's conventional cross-chart values. Unused by this chart; the schema keeps it open so an umbrella install cannot break on it.
scaleDown bool false Renders every workload with replicas: 0 without uninstalling. Takes precedence over urlaubsverwaltung.replicas. Both OnePasswordItem CRs, the Service and the Ingress stay, so the certificate is not re-issued. This chart owns no volume — scaling to zero loses nothing.

secrets

Key Type Default Description
secrets.databaseSecretRef string null Required, guarded by required() since 10.0.0 — unset, the render aborts with A database Secret Reference must be provided (secrets.databaseSecretRef). op:// path of the database item. Before 10.0.0 an unset value rendered a CR with an empty itemPath, installed cleanly, and the pod never started.
secrets.oidcSecretRef string null Required, same guard since 10.0.0. op:// path of the OIDC item. There is no non-OIDC login path in this chart, and the provider is resolved at boot rather than at login — see The identity provider is required at boot.

urlaubsverwaltung

Key Type Default Description
urlaubsverwaltung.replicas int 1 Replica count; 0 is allowed. The chart-global scaleDown wins over it.
urlaubsverwaltung.securityContext.pod object runAsNonRoot: true, runAsUser: 1002, runAsGroup: 1001, seccompProfile: RuntimeDefault Passed verbatim into the pod spec. The uid/gid pair is the image's: it is built with Cloud Native Buildpacks and runs as the non-root cnb user. The seccomp profile sits here on purpose — from the pod context it covers every container including init containers.
urlaubsverwaltung.securityContext.container object allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped Passed verbatim into the container spec. The app writes only to /tmp (an emptyDir for the JVM's temp and Tomcat work files), so the root filesystem stays read-only.
urlaubsverwaltung.livenessProbe object GET /actuator/health/liveness:8080, period 10s, timeout 5s, 3 failures livenessState alone, deliberately without db. See The three health paths.
urlaubsverwaltung.readinessProbe object GET /actuator/health/readiness:8080, period 5s, timeout 5s, 3 failures readinessState and db. Raising timeoutSeconds past the JDBC timeout only swaps a probe timeout for a 503 and delays the reaction.
urlaubsverwaltung.startupProbe object GET /actuator/health/liveness:8080, period 10s, timeout 5s, 30 failures (= 300s) Gates liveness and readiness. The budget covers JVM start plus the Liquibase migration; a measured cold start against an empty database took about 30 seconds.
urlaubsverwaltung.env list [] Extra environment variables appended to the chart's own. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Additive, not overriding — see Environment variables and the section below.
urlaubsverwaltung.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request. Here that factor sizes the Java heap — see The memory limit sizes the JVM heap.
urlaubsverwaltung.resources.requests object cpu: 0.1, memory: 768Mi, ephemeralStorage: 10Mi
urlaubsverwaltung.resources.limits object unset An explicit limit always wins over the computed one. A CPU limit is only rendered when set here.

The whole resources and securityContext blocks, and each probe, may also be null or empty: the chart then falls back to exactly the defaults above rather than rendering a container without resource accounting or hardening (style guide §12).

ingress

Key Type Default Description
ingress.enabled bool true Gates the whole Ingress template. Mutually exclusive with httpRoute.enabled. Unlike some other charts here, ingress.domain is read by no environment variable of the container — UV_MAIL_APPLICATIONURL is a hardcoded placeholder and deliberately not derived from it — so domain is unnecessary while both routing modes are off, and clusterIssuer whenever the Ingress is off.
ingress.domain string null Required while either routing mode is on. Host and TLS subject; in Gateway mode the HTTPRoute's single hostname. The address the application considers its own is the OIDC redirect-uri out of the 1Password item, not this value — the two can drift apart without any complaint.
ingress.clusterIssuer string null Required while ingress.enabled is true. cert-manager ClusterIssuer.
ingress.className string nginx Ingress class.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode, and nothing this chart needs lives here.

TLS is stored in tls-<release>-urlaubsverwaltung-ingress. The Ingress publishes the app at / with no path restriction, so /swagger-ui.html and /v3/api-docs — enabled by default in this app version and not turned off by the chart — are served under the same host. Both sit behind the login (302 to the provider like every other path), so this is exposure of an API description, not of data; springdoc.api-docs.enabled / springdoc.swagger-ui.enabled can be added through urlaubsverwaltung.env. The HTTPRoute publishes the app the same way — one PathPrefix: / rule — so that exposure is identical in Gateway mode.

httpRoute

The Gateway-API alternative to the Ingress, off by default. Added with group 1 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4).

Key Type Default Description
httpRoute.enabled bool false Renders <release>-urlaubsverwaltung-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values.
httpRoute.parentRefs list [] The Gateway(s) the route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases.
httpRoute.hostnames list [] Empty means [ingress.domain], so the hostname has a single source of truth. Set it explicitly only to publish under a different or an additional name — which is how a migration off the Ingress runs.

What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-urlaubsverwaltung-service on port 80. The Spring Boot application serves the UI, /login and /actuator from one port.

networkPolicy

Inbound 8080 only. Outbound DNS, TCP 5432 and 3306 (the database — both conventional ports, see the operating note) and 443 (the OIDC provider, which this application requires: there is no other login path).

Value Type Default Meaning
networkPolicy.enabled bool true Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured.
networkPolicy.allowedNamespaces list [] Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it.
networkPolicy.probeSources list [] CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready.
networkPolicy.ingressController object kube-system / app.kubernetes.io/name: rke2-traefik The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed.
networkPolicy.egress.extraPorts list [] Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off.

Operating notes

Routing: Ingress or Gateway API

Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.

Rule Why it is a hard failure and not a default
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths two data paths onto one Service under one hostname is a conflict at DNS level already: one name, two addresses, and which one a client reaches is luck. A migration runs over a second hostname (httpRoute.hostnames set explicitly while the Ingress keeps ingress.domain), never in parallel.
httpRoute.parentRefs must not be empty — the render aborts a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer.
the mode is a value, never auto-detected .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing.

What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, which live in cluster-config. ingress.clusterIssuer is unused and unnecessary in this mode.

The hostname and the application's idea of itself still do not agree by themselves, and this mode does not change that. No environment variable of the container derives from ingress.domain or from httpRoute.hostnames; what the application considers its own address is the OIDC redirect-uri in the 1Password item. So publishing under a new name means updating both, or the route answers and the login bounces with a redirect-uri mismatch while the pod stays green.

Whether the route attached at all is only visible in its status, because no Kubernetes object reports a route that attaches to nothing:

kubectl -n <namespace> get httproute <release>-urlaubsverwaltung-httproute -o yaml

Accepted: True and ResolvedRefs: True under status.parents[] is a working attachment. An empty status means no controller has looked at the route at all — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.

The three health paths and what each one answers for

The chart fixes two health settings in the deployment template rather than exposing them as values, because urlaubsverwaltung.env cannot reliably override them anyway (see below):

MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE=readinessState,db
MANAGEMENT_HEALTH_MAIL_ENABLED=false
Path Contains Used by
/actuator/health everything the release runs: db, diskSpace, ping, ssl and the two lifecycle states. Not the mail indicator. external monitoring — the only path that also fails on diskSpace or ssl
/actuator/health/readiness readinessState and db; 503 on a database outage the readiness probe
/actuator/health/liveness livenessState alone, deliberately without db; stays 200 through an outage the liveness and startup probes

This is the chart that produced the rule. For two years it probed exactly these two split paths and still stayed 1/1 Ready through a total database outage (#218): Spring Boot creates the readiness group by itself, but that default group contains readinessState alone — the paths were right, the group was empty of anything external. Naming the members replaces the default, which is why readinessState is repeated; db is the datasource indicator's real name, read off the running endpoint. The general form is style guide §12.3: whatever aggregates behind a probe path has to be read off the running endpoint, not off the path name.

The liveness group is deliberately left alone. Measured with the database scaled to zero underneath a running pod: readiness 503, liveness 200, pod 0/1 Ready, restart count 0, leaving Ready about 20 seconds after the database went away — and back to Ready by itself within about five seconds when the database returned, still with restart count 0. Nothing had to be rolled or deleted by hand. Adding db to the liveness group would put a crash loop on top of every outage.

How readiness fails is worth knowing before tuning it: the db indicator waits out the JDBC connection timeout first (measured at 30s against a Service with no endpoints), while readinessProbe.timeoutSeconds is 5 — so the kubelet logs Readiness probe failed: ... context deadline exceeded rather than a 503. The pod leaves Ready either way, and faster.

What a Ready pod does and does not prove

  • Ready now means the pod can reach the database, checked on every readiness period.
  • It does not mean the identity provider is reachable: OIDC is resolved once at startup and no health indicator watches it afterwards, so a provider that goes down after the rollout leaves a Ready pod on which nobody can log in.
  • It proves reachability, not correctness: the db indicator validates a connection, never the schema. Measured here — a database that came back empty put the pod straight back to Ready with /actuator/health at 200 while not one table existed.
  • A dependency outage has a signature: READY 0/1, STATUS Running, RESTARTS unchanged. The reason is in the endpoint, not in the log. The aggregate answers {"status":"DOWN","groups":[...]} without naming components unless MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS=always is set via urlaubsverwaltung.env — which publishes the database product, filesystem paths and free bytes, certificate chains and stack-trace classes to anyone who can reach the endpoint.
  • There is no shell in this image. kubectl exec cannot open one, and a debug sidecar sharing the process namespace cannot run the app's tooling either. Everything you learn from a running pod, you learn from its log and its HTTP endpoints.

The identity provider is required at boot, not at login

The application performs OIDC discovery itself, before the HTTP port opens. An issuer-uri that does not resolve, is unreachable from the namespace, or answers something other than a discovery document ends the process with Application run failed ... Unable to resolve Configuration with the provided Issuer of "<issuer-uri>" and the pod goes into CrashLoopBackOff having never served a request. The reason is only in the log:

kubectl -n <namespace> logs deployment/<release>-urlaubsverwaltung-deployment

Note the order: the database migration runs before this step. A rollout that dies on the identity provider has already migrated the schema.

The memory limit sizes the Java heap — it is not just a ceiling

The image's buildpack memory calculator reads the container's memory limit at every start and derives -Xmx and the metaspace from it. With the defaults that limit is computed, not set: limitFactor (3) × requests.memory (768Mi). Lowering it shrinks the heap, raising it raises the heap. A limit chosen too tightly does not merely risk an OOMKill — the calculator refuses to start when what is left after metaspace, code cache and thread stacks is negative, and that failure appears before any application log line. The sizing it chose is the first line of the log:

kubectl -n <namespace> logs deployment/<release>-urlaubsverwaltung-deployment | head -1

urlaubsverwaltung.env is additive only, and a collision is lost on upgrade

This is the chart on which the repo-wide rule was measured (style guide §10, also Lessons learned). Entries are appended after the chart's own list. An entry repeating a name the chart already sets does not replace it — it renders the variable twice:

  • At install both entries reach the pod and the later one — yours — wins in the container. The API server accepts it with Warning: ... hides previous definition of "NAME", which may be dropped when using apply.
  • At upgrade that warning comes true. Kubernetes merges env by name, the pair collapses to one entry, and the survivor is the chart's value. Nothing fails: helm prints "Release has been upgraded" and the revision counts up. Measured with helm 3.19.0 / Kubernetes 1.35: helm get manifest shows the variable twice while the live Deployment shows it once, with the chart's value.

So the trap is silence. Treat the chart's own variables as fixed for the life of a release — to really change one, reinstall — and never read a green upgrade as proof:

kubectl -n <namespace> get deployment <release>-urlaubsverwaltung-deployment \
  -o jsonpath='{.spec.template.spec.containers[0].env}'

Anything sensitive belongs in valueFrom.secretKeyRef: a plain value is stored in the pod spec and readable by anyone who can read pods in the namespace.

The names the chart occupies: SPRING_DATASOURCE_*, MANAGEMENT_ENDPOINT_HEALTH_GROUP_READINESS_INCLUDE, MANAGEMENT_HEALTH_MAIL_ENABLED, UV_MAIL_*, SPRING_MAIL_HOST, SPRING_MAIL_PORT, UV_CALENDAR_ORGANIZER, SPRING_SECURITY_OAUTH2_*, UV_SECURITY_OIDC_CLAIM-MAPPERS_*.

Mail and the application URL are placeholders until you set them

The chart hardcodes the mail wiring to values that work nowhere: SPRING_MAIL_HOST=localhost, SPRING_MAIL_PORT=1025, UV_MAIL_FROM / UV_MAIL_REPLYTO = urlaubsverwaltung@example.org, UV_CALENDAR_ORGANIZER=organizer@example.org, UV_MAIL_APPLICATIONURL=http://localhost:8080. Nothing listens on localhost:1025, so no notification mail leaves the release, and UV_MAIL_APPLICATIONURL — which is not derived from ingress.domain — makes every link inside those mails point at the reader's own machine.

Because that wiring is a placeholder, the chart sets MANAGEMENT_HEALTH_MAIL_ENABLED=false and the mail indicator is gone from /actuator/health; left on, it pinned the aggregate at 503 DOWN for the whole life of a healthy release (#217) and any monitor pointed at it would page forever.

The part that bites later: the day real mail delivery is configured (SPRING_MAIL_HOST / SPRING_MAIL_PORT via urlaubsverwaltung.env), the indicator has to be switched back on in the same change —

urlaubsverwaltung:
  env:
    - name: MANAGEMENT_HEALTH_MAIL_ENABLED
      value: "true"

Left at false it does not report a false alarm, it reports nothing: 200 while the mail server is unreachable and notifications are silently undelivered. And because that name repeats one the chart sets, the override only takes effect if it goes in at install time — added to a running release it does nothing and says so nowhere.

Rotating a secret does not restart the pod

Helm never sees a OnePasswordItem's content, so it cannot hash it (style guide §9); this chart stamps no checksum over the secrets. Pointing a *SecretRef at a different item rolls the pod (the rendered CR changes); editing a value inside the same item does not. After such a rotation:

kubectl -n <namespace> rollout restart deployment/<release>-urlaubsverwaltung-deployment

Both database ports are allowed, and that is a deliberate loosening

This chart is handed a full JDBC URL inside a 1Password item (jdbc-url) and never parses it, so it cannot tell PostgreSQL from MariaDB — and this cluster runs both. Allowing only one would break half the possible deployments on upgrade, and the symptom would be a connection timeout buried in a Java stack trace.

So 5432 and 3306 are both permitted: a small widening against a large and silent failure. A database on any other port still needs networkPolicy.egress.extraPorts.

The probed port is open inside the cluster by default

networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.

It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.

Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.

The policy is only a boundary where the CNI enforces one

The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.

Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.

Upgrades and backup

  • Back up the database before every app-version bump. Liquibase migrates in place on every start; helm rollback puts the previous image back, but the schema does not come back with it. There is no volume in this release to back up — the database is the whole of the state.
  • Raise urlaubsverwaltung.startupProbe.failureThreshold before a long migration, not after the crash loop has started: if the migration outruns the 300s budget the kubelet kills the container mid-migration and the next attempt starts over.
  • A failed rollout does not take the release down — the old pod keeps serving until the new one is Ready, so helm upgrade without --wait reports success while the new pod crashloops behind it. Check with kubectl -n <namespace> rollout status deployment/<release>-urlaubsverwaltung-deployment.
  • App major jumps go stepwise: last release of the old major line first, then the newest major — see Versioning.

CI

ci/urlaubsverwaltung/ holds test-values.yaml (op:// refs against the stub CRD, host uv.ci.local, clusterIssuer: ci), fixtures.yaml (throwaway Postgres, a dex OIDC provider — the app performs discovery at boot, so a fixture provider is mandatory, plus the two dummy secrets) and settings.env with INSTALL_TIMEOUT=600 for the JVM-plus-Liquibase start. The install-test runs on every PR that touches the chart — see CI/CD.

The install-test stays in Ingress mode; the route is held at render level. The install-test installs exactly one configuration per chart and the two modes are exclusive, so one had to be picked. cyberchef is the chart that pays that price and installs in Gateway mode against the real Gateway-API CRDs (ci/gateway-api-crds.sh, pinned, applied --server-side), which is where a malformed parentRefs or backendRefs is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead ci/httproute/urlaubsverwaltung.yaml is a render-only overlay that switches this chart into Gateway mode for ci/run-httproute-match-test.sh, which asserts that every backendRefs entry names a Service and a port the same render creates and that parentRefs is not empty. A chart that has an overlay and renders no route fails that test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI. See CI/CD.

See also