feat(tenant): inherit _namespace.gateway from parent

Mirror the existing _namespace.ingress inheritance for Gateway API
attachment. A child tenant under a Gateway-owning ancestor publishes
its routes through that ancestor's Gateway by default, without burning
a dedicated LB IP / Certificate per child.

Resolution rule (tenant.gatewayEffective):
  * gateway=true explicit  → own Gateway (LB IP + cert burned)
  * gateway=false explicit → parked (no inherit, no own)
  * gateway unset          → inherit $parentNamespace.gateway

Removes the auto-default for derived-apex tenants — the previous shape
gave every derived-apex child its own Gateway / IP / cert, which scales
linearly with tenant depth and produces ~N×K LB IPs on a tree of N
top-level × K derived-apex tenants. Inheritance collapses that to one
LB IP per Gateway-owning ancestor.

Custom-apex tenants (host explicitly set to a non-derived value) now
fall through to inheritance instead of auto-firing a separate Gateway.
The previous auto-fire conflated 'custom apex' with 'want public
exposure' and would have silently failed TLS termination anyway (the
ancestor's cert does not cover an unrelated apex). The new rule:
explicit gateway=true is the only way to ask for a separate Gateway.

The namespace.cozystack.io/gateway label and _namespace.gateway in the
rendered cozystack-values Secret now both carry the owner-tenant name
— could be self (when this tenant owns one) or any ancestor up the
chain. Apps in the tenant render HTTPRoutes whose parentRefs.namespace
points at that owner.

helm-unittest pinned for the HelmRelease render side. The
cross-namespace attach end of the flow (ReferenceGrants, listener /
SAN expansion on the owner Gateway) lands separately in the
TenantGateway controller.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
This commit is contained in:
Aleksei Sviridkin committed 2026-05-25 18:18:54 +03:00
1 parent 0994821bda
commit 97dff75b5f
3 files changed
+112 -157

No files matched your search

+33 -32
View File
@@ -15,46 +15,47 @@
{{- end }}
{{/*
tenant.gatewayEffective resolves whether a tenant should have its own
per-tenant Gateway. Cozystack targets low-skill operators: the default
must "just work" without forcing them to learn that `gateway` is a
separate flag from `host`.
tenant.gatewayEffective resolves whether THIS tenant should own a
dedicated Gateway resource. Returns the literal string "true" or
"false" (callers must compare with `eq ... "true"`, bool coercion
is not safe).
When the helper returns "false" the tenant does NOT skip Gateway
routing — it attaches its published Routes to the Gateway of the
nearest ancestor that owns one, via _namespace.gateway propagation
in namespace.yaml. This mirrors the existing _namespace.ingress
inheritance: a child tenant publishes through its parent's
publishing layer unless it explicitly asks for its own.
Rules:
1. If tenant.spec.gateway is set explicitly (true|false) → use it.
Operator's explicit choice always wins, regardless of platform
state — the helmrelease for `gateway` will fail upstream if the
platform doesn't have gateway.enabled, but that's a user-visible
error which is the right outcome for explicit opt-in.
2. Otherwise (gateway unset) → consult both:
a. `_cluster.gateway-enabled` — the platform-level flag. If the
platform has not opted in to Gateway API, no auto-default
fires. Auto-on a Gateway when the cluster doesn't ship the
gateway-application chart only produces broken HelmReleases.
b. `tenant.spec.host` — when the platform IS Gateway-enabled,
a tenant with derived apex (`host` empty, computed as
`<name>.<parent apex>`) gets auto-on; a tenant with a
custom non-derived apex requires explicit opt-in, since
a custom apex is a deliberate operator choice and they may
not have intended public exposure.
1. tenant.spec.gateway set explicitly (true|false) → use it.
Explicit choice always wins. A tenant that asks for its own
Gateway gets its own Service / LB IP / Certificate.
2. Otherwise → "false". The tenant inherits the parent's Gateway
through _namespace.gateway. Derived-apex children (`host`
empty) flow naturally: their predefined hostnames
`*.<name>.<parent-apex>` are routed by the ancestor's
controller which extends listener / SAN coverage as children
attach. If no ancestor owns a Gateway, _namespace.gateway
stays empty and apps fall back to Ingress (legacy path).
Escape hatch: an operator who wants a derived-apex tenant without a
Gateway sets `gateway: false` explicitly on the tenant CR.
Custom-apex tenants (operator sets tenant.spec.host to something
not derived from the parent apex, e.g. `customer1.io`) must opt in
explicitly via `gateway: true` if they want public exposure. The
ancestor's TLS cert does not cover their apex and Let's Encrypt
wildcards are single-level, so silent inheritance would either
leak through the wrong cert or fail to terminate TLS. Forcing the
explicit flag keeps "I want my apex routable" a deliberate choice.
Implementation note: values.yaml leaves `gateway` absent (no key) so
Helm reads it as missing → `kindIs "invalid"`. cozyvalues-gen's
Implementation note: values.yaml leaves `gateway` absent (no key)
so Helm reads it as missing → `kindIs "invalid"`. cozyvalues-gen's
`[gateway]` (optional) marker syntax does not allow nullable schema
typing, so the "key absent" form is what distinguishes "unset" from
explicit `false`.
The helper renders the literal string "true" or "false" — callers
must compare with `eq ... "true"` rather than rely on bool coercion.
typing, so the "key absent" form is what distinguishes "unset"
from explicit `false`.
*/}}
{{- define "tenant.gatewayEffective" -}}
{{- if kindIs "invalid" .Values.gateway -}}
{{- $platformOn := eq ((index .Values "_cluster" "gateway-enabled") | default "false") "true" -}}
{{- $derivedApex := eq (.Values.host | default "") "" -}}
{{- if and $platformOn $derivedApex -}}true{{- else -}}false{{- end -}}
false
{{- else -}}
{{- if .Values.gateway -}}true{{- else -}}false{{- end -}}
{{- end -}}
+18 -12
View File
@@ -30,19 +30,25 @@
{{- end }}
{{/*
Gateway reference is NOT inherited from parent. A child tenant
namespace is not on the parent Gateway's allowedRoutes whitelist,
and the parent Gateway's wildcard cert only covers <apex> +
*.<apex> (one level) while a child apex would be two levels deep —
inheritance would break both admission and TLS. Each tenant that
wants Gateway-attached apps either sets tenant.spec.gateway=true
explicitly or hits the auto-default for derived-apex tenants
(tenant.gatewayEffective). The same helper is consulted by
gateway.yaml (HelmRelease render) so the per-tenant Gateway, the
namespace label, and _namespace.gateway propagation to apps stay
in lockstep.
Gateway reference inherits from the parent, same shape as ingress
above: $gateway is the name of the nearest ancestor (inclusive)
that owns a Gateway. Apps in this tenant render HTTPRoutes whose
parentRefs.namespace points at that owner namespace, and the
owner's Gateway lists this namespace in allowedRoutes via the
matching namespace.cozystack.io/gateway label.
Override fires only when the tenant itself owns a Gateway —
tenant.gatewayEffective returns "true" for explicit
tenant.spec.gateway=true and for tenants with a custom non-derived
host (their apex isn't covered by any ancestor's cert / Issuer).
In those cases the controller renders a separate Gateway in this
tenant's namespace, with its own LB Service / IP / Certificate.
Cross-namespace attach is gated by ReferenceGrants rendered by the
TenantGateway controller in the owner namespace, one per child
that inherits.
*/}}
{{- $gateway := "" }}
{{- $gateway := $parentNamespace.gateway | default "" }}
{{- if eq (include "tenant.gatewayEffective" .) "true" }}
{{- $gateway = $tenantName }}
{{- end }}
@@ -1,28 +1,27 @@
suite: tenant gateway default — auto-on for derived apex, opt-in for custom
suite: tenant gateway default — inherit from parent, opt-in for own Gateway
templates:
- templates/gateway.yaml
# Cozystack targets low-skill operators. A tenant with a derived apex
# (`<name>.<parent>`, computed by the chart when tenant.spec.host is
# empty) implicitly expects routability under that apex — forcing
# operators to also set `tenant.spec.gateway: true` is a UX bug.
#
# The auto-default fires only when the platform itself is gateway-
# enabled (`_cluster.gateway-enabled=true`). Auto-on a Gateway when
# the cluster doesn't ship the gateway-application chart only produces
# broken HelmReleases — explicit opt-in still works in that case so
# operators can preview the wiring on a non-Gateway cluster.
# Cozystack targets low-skill operators. A child tenant under a Gateway-
# enabled parent must publish through the parent's Gateway by default —
# forcing every child to set `tenant.spec.gateway: true` would break
# parity with how `_namespace.ingress` already inherits, and would burn
# one LB IP per child tenant for no isolation benefit.
#
# Rules under test (see _helpers.tpl::tenant.gatewayEffective):
# - platform off + any host + gateway unset → OFF (auto-default
# waits for the
# platform flag)
# - platform on + host="" + gateway unset → ON (auto-default)
# - platform on + host=custom + gateway unset → OFF (custom apex
# needs explicit
# opt-in)
# - gateway=true explicit → ON (regardless)
# - gateway=false explicit → OFF (escape hatch)
# - gateway unset → OFF own (inherit from parent)
# - gateway=true explicit → ON own (LB IP / cert burned)
# - gateway=false explicit → OFF own (parked, no inherit)
#
# The inheritance propagation itself happens in namespace.yaml:
# $gateway := $parentNamespace.gateway | default ""
# overridden to $tenantName when tenant.gatewayEffective is "true".
# That logic is exercised by the cozystack-basics secret test for the
# root tenant (cozystack-values-secret_test.yaml) plus by the
# controller-level reconciler tests for the cross-namespace attach
# flow — helm-unittest cannot mock the `lookup` call namespace.yaml
# uses for ownerReferences, so the chart-level pin here covers only
# the HelmRelease render side.
release:
name: tenant-alice
@@ -35,9 +34,35 @@ set:
gateway-enabled: "true"
tests:
- it: derived apex + gateway unset → renders HelmRelease (auto-on)
- it: gateway unset → inherit, no own HelmRelease
# The default for a derived-apex child. _namespace.gateway will
# propagate the parent's owner-name through namespace.yaml; the
# tenant chart does NOT render its own gateway HelmRelease.
set:
host: ""
asserts:
- template: templates/gateway.yaml
hasDocuments:
count: 0
- it: gateway unset on custom-apex tenant → inherit, no own HelmRelease
# Custom apex without explicit `gateway: true` falls through to
# inheritance — same rule. The HTTPRoutes for this tenant's apps
# will attach to the parent Gateway but fail TLS (parent cert
# doesn't cover customer1.io). That's the operator's responsibility
# to notice; we don't auto-Gateway because they might just want
# the namespace for isolation with no public exposure.
set:
host: customer1.io
asserts:
- template: templates/gateway.yaml
hasDocuments:
count: 0
- it: gateway=true explicit on derived apex → own HelmRelease
set:
gateway: true
host: ""
asserts:
- template: templates/gateway.yaml
equal:
@@ -48,26 +73,10 @@ tests:
path: metadata.name
value: gateway
- it: custom apex + gateway unset → no HelmRelease (explicit opt-in)
set:
host: customer1.io
asserts:
- template: templates/gateway.yaml
hasDocuments:
count: 0
- it: gateway=true explicit on derived apex → renders (passthrough)
set:
gateway: true
host: ""
asserts:
- template: templates/gateway.yaml
equal:
path: kind
value: HelmRelease
- it: gateway=true explicit on custom apex → renders (overrides default)
- it: gateway=true explicit on custom apex → own HelmRelease
# The intended path for an operator who wants a custom apex
# publicly reachable: set host + gateway=true. The tenant gets
# its own Gateway / LB IP / Certificate / Issuer.
set:
gateway: true
host: customer1.io
@@ -77,10 +86,12 @@ tests:
path: kind
value: HelmRelease
- it: gateway=false explicit on derived apex → no HelmRelease (escape hatch)
# The reason this matters: an operator who wants a derived-apex
# tenant for namespace-isolation purposes only (no public exposure)
# must be able to opt out without inventing a custom apex.
- it: gateway=false explicit → no own HelmRelease (parked)
# The escape hatch. Operator opts out of any Gateway publishing,
# even inheritance: namespace.yaml writes _namespace.gateway as
# empty string (the cozystack-basics-side label pins this in
# cozystack-values-secret_test.yaml), and the parent Gateway's
# allowedRoutes selector therefore does not match this namespace.
set:
gateway: false
host: ""
@@ -89,74 +100,11 @@ tests:
hasDocuments:
count: 0
- it: gateway=false explicit on custom apex → no HelmRelease (passthrough)
set:
gateway: false
host: customer1.io
asserts:
- template: templates/gateway.yaml
hasDocuments:
count: 0
- it: tenant-root with gateway unset and host set → no auto-default
# tenant-root always has tenant.spec.host set explicitly (it's the
# platform apex). This must NOT auto-Gateway tenant-root just
# because the chart receives the platform apex via `host`.
# Operators who want gateway on tenant-root set it explicitly.
release:
name: tenant-root
namespace: tenant-root
set:
host: example.org
asserts:
- template: templates/gateway.yaml
hasDocuments:
count: 0
# The platform-off cases below pin a critical regression-guard: a
# tenant on a cluster where `gateway.enabled=false` at the platform
# level must NOT silently auto-Gateway. The gateway-application
# chart isn't installed on such clusters, so an auto-on would
# produce a broken HelmRelease. The e2e install bats lives in this
# mode (gateway not enabled in the bundle), and a regression here
# broke `Create tenant with isolated mode enabled` until the
# platform-flag gate landed.
- it: platform off + derived apex + gateway unset → no HelmRelease
set:
host: ""
_cluster:
expose-ingress: tenant-root
expose-external-ips: "192.0.2.10"
gateway-enabled: "false"
asserts:
- template: templates/gateway.yaml
hasDocuments:
count: 0
- it: platform unset + derived apex + gateway unset → no HelmRelease
# gateway-enabled empty string — what cozystack-values renders
# when an operator hasn't flipped gateway.enabled in the platform
# chart. The helper treats anything other than the literal
# "true" as off. This is what an upgraded existing cluster looks
# like before the operator opts in.
set:
host: ""
_cluster:
expose-ingress: tenant-root
expose-external-ips: "192.0.2.10"
gateway-enabled: ""
asserts:
- template: templates/gateway.yaml
hasDocuments:
count: 0
- it: platform off + gateway=true explicit → renders (operator override wins)
# Explicit operator opt-in always wins, even when the platform
# hasn't flipped gateway.enabled. The gateway HelmRelease will
# fail at runtime (no chart), but that's a user-visible error
# which is the right outcome — the operator deliberately asked
# for it.
- it: gateway=true on platform-off cluster → own HelmRelease (operator override)
# The HelmRelease will fail at runtime (no gateway-application
# chart on the cluster) — that's a user-visible error, which is
# the right outcome for explicit opt-in against a not-yet-
# provisioned platform.
set:
gateway: true
host: ""