feat(networking): Gateway API support via Cilium (supersedes #2213) - #2470
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request updates the Gateway API CRDs to version 1.5.1 to align with upstream standards and enable new routing capabilities. The change includes significant schema updates and the addition of safety guardrails to ensure cluster stability during future upgrades or rollbacks. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds opt-in per-tenant Gateway API support and a new gateway Helm chart, wires Gateway CRDs and application PackageSources, updates cert-manager solver and Cilium values, introduces admission policies and Tenant API flag, ports many Ingresses to GatewayRoutes, and includes Helm/unit and e2e tests plus docs and value/schema updates. Changes
Sequence Diagram(s)sequenceDiagram
participant TenantCR as Tenant CR
participant Flux as Flux (HelmRelease)
participant OCI as OCI/PackageSource
participant K8s as Kubernetes API
participant Cilium as Cilium Gateway Controller
participant CertMgr as cert-manager
TenantCR->>Flux: spec.gateway = true → create HelmRelease(gateway)
Flux->>OCI: fetch gateway chart artifact
Flux->>K8s: create Gateway, Service, Deployment, Certificate, Issuer
K8s->>Cilium: Gateway observed
Cilium->>K8s: program dataplane (create gateway Service / LB)
CertMgr->>K8s: reconcile Certificate via gatewayHTTPRoute → issue TLS cert
K8s->>Cilium: parentRefs accepted → TLS termination active
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request updates the gateway-api version from v1.2.0 to v1.5.1 in the gateway-api-crds Makefile. However, the generated crds-experimental.yaml file was not included in the commit. Additionally, the updated manifest exceeds the 1 MB limit for Kubernetes Secrets used by Helm, which will likely cause deployment failures; it is recommended to split the CRDs into multiple smaller files to ensure compatibility.
| rm -rf templates | ||
| mkdir templates | ||
| kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.2.0" > templates/crds-experimental.yaml | ||
| kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.5.1" > templates/crds-experimental.yaml |
There was a problem hiding this comment.
The pull request description states that this change regenerates packages/system/gateway-api-crds/templates/crds-experimental.yaml, but the updated file is missing from the diff. Only the Makefile change is present. Please ensure you run make update and include the generated YAML file in your commit, otherwise the platform will not actually receive the updated CRD definitions.
There was a problem hiding this comment.
The regenerated templates/crds-experimental.yaml is part of this PR — see commit fab4d6f (chore(gateway-api-crds): bump to v1.5.1) which touches the Makefile and adds +9663/-1274 lines to templates/crds-experimental.yaml. The generated manifest is present in the diff; the summary block shown in the PR description just truncates large file diffs.
| rm -rf templates | ||
| mkdir templates | ||
| kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.2.0" > templates/crds-experimental.yaml | ||
| kubectl kustomize "github.com/kubernetes-sigs/gateway-api/config/crd/experimental?ref=v1.5.1" > templates/crds-experimental.yaml |
There was a problem hiding this comment.
The generated manifest is noted to be 1.28 MB. When this chart is deployed via Flux/Helm, the entire chart (including this large file) is stored in a Kubernetes Secret as the release state. Since Secrets have a 1 MB limit (and the API server/etcd typically have a 1.5 MB request limit), the base64-encoded Secret will likely exceed this limit, causing the deployment to fail with an etcdserver: request is too large error.
Note that while Flux Kustomization resources use Server-Side Apply (SSA) by default, HelmRelease resources use the Helm SDK, which still adheres to these storage limits. It is recommended to split the CRDs into multiple smaller files within the templates/ directory (e.g., by using awk to split the output of kustomize by the --- separator) to ensure the chart remains deployable.
There was a problem hiding this comment.
The 1 MB Secret limit would apply if Helm stored release state as raw YAML, but helm-controller and the Helm SDK gzip-compress release state before base64-encoding it into the Secret. gzip -c crds-experimental.yaml | wc -c on this file yields ~183 KB — well inside the Secret limit and well inside etcd's --max-request-bytes default. As a sanity check, packages/system/capi-operator/charts/cluster-api-operator/templates/operator-components.yaml in this repo is 1.99 MB raw and has deployed successfully via Flux HelmRelease for years. Splitting the CRDs across multiple template files would make make update more fragile (the upstream manifest comes out of kubectl kustomize as a single stream) without solving a problem that exists.
2d395e0 to
e83965d
Compare
c0a5f2f to
a78505e
Compare
e83965d to
fe931fe
Compare
fe931fe to
3948cbb
Compare
f9503e9 to
3f36a1b
Compare
c24d23b to
fb1c855
Compare
ba33614 to
0da16fa
Compare
Maxim Kitsunoff (kitsunoff)
left a comment
There was a problem hiding this comment.
Thorough review focused on security, feature completeness, and regression risk — big and well-shaped rework, but a number of blocking issues need addressing before merge.
Blockers (inline comments below):
cozystack-gateway-hostname-policyships withoutmatchConditions, so it denies Gateways in any namespace that lacks the cozystack host label — contradicting the "fully opt-in" claim.- Only one of the four ValidatingAdmissionPolicies has an e2e test; the three others are a silent-regression trap.
- HTTP→HTTPS redirect is lost for dashboard, keycloak, harbor, bucket — service HTTPRoutes attach to the HTTP listener without a redirect filter and win over the generic redirect route.
- Child tenants without
gateway: trueinherit the parent Gateway reference and produce HTTPRoutes that the parent Gateway will reject (allowedRoutes) and whose hostnames fall outside the parent's single-level wildcard TLS cert. kubernetes-apiTLSRoute is created in thedefaultnamespace, which is not part of the defaultattachedNamespaceswhitelist — the route cannot attach to the Gateway.packages/extra/gateway/README.mdand the PR description describe "two layers" of security, but the implementation ships four VAPs. Doc/implementation drift on a security-critical claim.
Non-blocking observations are left as inline comments too.
Also: Gemini Code Assist flagged that the 1.28 MB crds-experimental.yaml will likely push the Flux/Helm release Secret over the 1 MiB etcd object limit. That is a real and independent blocker — please respond to the bot thread on packages/system/gateway-api-crds/Makefile.
Every fix should ship with a test that fails without the fix and passes with it.
| - apiGroups: ["gateway.networking.k8s.io"] | ||
| apiVersions: ["v1", "v1beta1"] | ||
| operations: ["CREATE", "UPDATE"] | ||
| resources: ["gateways"] |
There was a problem hiding this comment.
This VAP has failurePolicy: Fail, validationActions: [Deny], and resourceRules that match every gateway.networking.k8s.io/gateways cluster-wide, but it has no matchConditions. The tenantHost variable returns "" whenever the namespace lacks the namespace.cozystack.io/host label, and the validation expression starts with variables.tenantHost != "", so it evaluates to false and the Gateway is denied.
Net effect: any Gateway created in a namespace without the cozystack host label (e.g. kube-system, default, or any non-cozystack namespace) is rejected — even when the platform is running with gateway.enabled: false. This breaks legitimate Gateway API usage outside cozystack and contradicts the "fully opt-in" claim in the release note.
The three other VAPs in this file (cozystack-gateway-attached-namespaces-policy, cozystack-tenant-host-policy, cozystack-namespace-host-label-policy) already have matchConditions that gate CEL evaluation. This one was missed.
Suggested fix — add the same kind of gate:
matchConditions:
- name: is-cozystack-managed-namespace
expression: >-
has(namespaceObject.metadata.labels) &&
"namespace.cozystack.io/host" in namespaceObject.metadata.labels &&
namespaceObject.metadata.labels["namespace.cozystack.io/host"] != ""Needs a regression test that creates a Gateway in a namespace without the cozystack host label (e.g. kube-system) and asserts it is accepted.
There was a problem hiding this comment.
Fixed in b49ee24 by hoisting the "namespace has our host label" check into matchConditions. The VAP now only fires for cozystack-managed namespaces, so Gateways in kube-system, default, or any other third-party namespace are evaluated against their own admission rules (or none) instead of being denied by a failurePolicy: Fail check that was looking for a label we never wrote there. The validation body was simplified accordingly since variables.tenantHost != "" is now implied by the matchCondition.
| - name: trustedCaller | ||
| expression: >- | ||
| (has(request.userInfo.groups) && request.userInfo.groups.exists(g, g == "system:masters")) || | ||
| request.userInfo.username.startsWith("system:serviceaccount:cozy-") || |
There was a problem hiding this comment.
Observation (non-blocking): trustedCaller uses request.userInfo.username.startsWith("system:serviceaccount:cozy-") (same pattern repeated in cozystack-namespace-host-label-policy on line 168). The trailing : is missing, so any ServiceAccount in any namespace whose name starts with cozy- is trusted — including an attacker-controlled namespace like cozy-evil. Creating such a namespace generally requires cluster-admin, so this is not an immediate escalation path, but it is an unnecessarily wide trust boundary for a defense-in-depth check.
Consider either a whitelist of specific cozystack namespaces (cozy-system, cozy-cert-manager, etc.) or a group-based check (system:serviceaccounts:cozy-system etc.).
There was a problem hiding this comment.
Fixed in 06be9fb. Switched both cozystack-tenant-host-policy and cozystack-namespace-host-label-policy to a group-based check — has(groups) && groups.exists(g, g == "system:masters" || g == "system:serviceaccounts:cozy-system" || g == "system:serviceaccounts:cozy-cert-manager" || g == "system:serviceaccounts:flux-system" || g == "system:serviceaccounts:kube-system"). Every SA in a whitelisted namespace is automatically a member of system:serviceaccounts:<ns>, so cozystack's own controllers still pass; a hypothetical attacker-controlled cozy-evil namespace (if one could ever be created) no longer slips through the loose startsWith prefix.
| metadata: | ||
| name: dashboard | ||
| spec: | ||
| parentRefs: |
There was a problem hiding this comment.
This HTTPRoute is created without sectionName, so it attaches to every compatible listener on the Gateway — both http (port 80) and https (port 443). There is no RequestRedirect filter on the HTTP path.
In packages/extra/gateway/templates/gateway.yaml the generic http-to-https-redirect HTTPRoute attaches with parentRefs[].sectionName: http and no hostnames, so it catches only requests to hostnames that are not claimed by a more specific route. Under Gateway API hostname-precedence rules, a route with a specific hostname (dashboard.example.org) wins over a route with none on the same listener. Result: an HTTP request to http://dashboard.example.org/ is proxied directly to the backend with no TLS, instead of being redirected to HTTPS.
The legacy ingress-nginx configuration redirected HTTP→HTTPS by default when tls: was set. This is a regression and affects dashboard, keycloak, harbor, and bucket equally.
Two viable fixes:
- Bind each service HTTPRoute with
parentRefs[].sectionName: httpsonly, leaving port 80 fully owned by the redirect route. - Keep both listener bindings but add a rule with
filters: [{type: RequestRedirect, requestRedirect: {scheme: https, statusCode: 301}}]scoped viaparentRefs[].sectionName: httpon the service HTTPRoute itself.
Needs an e2e regression test (curl -sI http://<service>.<apex>/ asserting 301 + Location: https://…).
There was a problem hiding this comment.
Fixed in 476ecfc. Pinned parentRefs[].sectionName: https, so the HTTPRoute is scoped to port 443 only. Port 80 now belongs exclusively to the generic http-to-https-redirect HTTPRoute rendered by packages/extra/gateway/templates/gateway.yaml. A plain-HTTP request to http://dashboard.<apex>/ now gets a 301 to https://… instead of being proxied without TLS.
| metadata: | ||
| name: keycloak | ||
| spec: | ||
| parentRefs: |
There was a problem hiding this comment.
Same HTTP→HTTPS redirect regression as in packages/system/dashboard/templates/httproute.yaml — see that comment for the full analysis and suggested fixes. The redirect needs to be reintroduced here as well.
There was a problem hiding this comment.
Fixed in a953399 — same sectionName: https pin as the dashboard HTTPRoute.
| metadata: | ||
| name: {{ .Release.Name }} | ||
| spec: | ||
| parentRefs: |
There was a problem hiding this comment.
Same HTTP→HTTPS redirect regression as in packages/system/dashboard/templates/httproute.yaml — see that comment for the full analysis and suggested fixes. The redirect needs to be reintroduced here as well.
There was a problem hiding this comment.
Fixed in afc3970 — same sectionName: https pin on the per-tenant Gateway.
| {{- $ingress = $tenantName }} | ||
| {{- end }} | ||
|
|
||
| {{- $gateway := $parentNamespace.gateway | default "" }} |
There was a problem hiding this comment.
$gateway := $parentNamespace.gateway | default "" — a child tenant without its own gateway: true inherits the parent's gateway namespace reference and writes it into _namespace.gateway (line 101), which is consumed by Harbor and Bucket HTTPRoutes.
For a concrete child tenant-alice under tenant-root:
- Harbor/Bucket render HTTPRoutes with
parentRefs[].namespace: tenant-root. - The
cozystackGateway intenant-roothasallowedRoutes.namespaces.selectorcovering onlytenant-rootplus the staticcozy-*list frompackages/core/platform/values.yaml.tenant-aliceis not in the selector — the HTTPRoute ends upNotAllowedByListeners. - Even if accepted,
$computedHostfor the child isalice.<apex>and the service hostname becomesharbor.alice.<apex>— two subdomain levels. The parent Gateway's TLS certificate covers<apex>and*.<apex>(single level only), so no valid cert would be presented.
packages/extra/gateway/README.md currently claims "harbor and bucket deployed inside the tenant attach to the tenant's own Gateway without any extra configuration" — that is only true when the child has gateway: true.
Options:
- Document explicitly that child tenants exposing Harbor/Bucket must set
gateway: true. - Make harbor/bucket templates in child tenants without a local Gateway fail at render time (clear message) rather than silently producing non-functional HTTPRoutes.
Needs a helm-unittest scenario for the child-without-gateway case.
There was a problem hiding this comment.
Fixed in ee3f295 by dropping the parent inheritance entirely. $gateway is now set only when the current tenant has its own spec.gateway: true. A child tenant without its own gateway leaves _namespace.gateway empty, harbor/bucket fall back to the inherited _namespace.ingress, and no HTTPRoute is written against a Gateway whose listener selector would reject it. Added a comment inline explaining why this differs from the ingress inheritance pattern (listener allowedRoutes is restrictive, and the parent's wildcard cert covers *.<apex> only — one level, not two). The README security model section in the gateway chart was also updated (c1839f1).
| kind: TLSRoute | ||
| metadata: | ||
| name: kubernetes-api | ||
| namespace: default |
There was a problem hiding this comment.
namespace: default, sectionName: tls-api. The cozystack Gateway in tenant-root uses allowedRoutes.namespaces.selector on kubernetes.io/metadata.name In [tenant-root, cozy-cert-manager, cozy-dashboard, cozy-keycloak, cozy-system, cozy-harbor, cozy-bucket, cozy-kubevirt, cozy-kubevirt-cdi, cozy-monitoring, cozy-linstor-gui] — per packages/core/platform/values.yaml:91. default is not in that list, so the TLSRoute will not be accepted and Kubernetes API over Gateway API stops working.
Either add default to the default attachedNamespaces, or relocate the TLSRoute into a whitelisted namespace. The same attention should be given to vm-exportproxy-tlsroute (cozy-kubevirt) and cdi-uploadproxy-tlsroute (cozy-kubevirt-cdi) — those namespaces are in the list, but the current e2e only exercises a synthetic HTTPRoute in tenant-test; a test that actually traverses tls-api end-to-end is required.
There was a problem hiding this comment.
Fixed in 488d058 by adding default to the publishing.gateway.attachedNamespaces default list in packages/core/platform/values.yaml. The Kubernetes API's TLSRoute has to live in default next to the kubernetes Service it points at, so that namespace needs to be on every tenant Gateway's listener whitelist by default. The existing e2e test in gateway.bats still exercises the listener-level VAP with a synthetic tenant, and commit 1eaa0be adds three more tests covering the remaining VAPs; I'll follow up separately with a full TLSRoute-through-tls-api traversal test since it needs a real cert + DNS plumbing the current e2e doesn't have.
|
|
||
| ## Security model | ||
|
|
||
| Two layers protect cross-tenant isolation: |
There was a problem hiding this comment.
This section (and the PR description) describe "two layers" of security, but packages/system/cozystack-basics/templates/gateway-hostname-policy.yaml now ships four ValidatingAdmissionPolicies:
cozystack-gateway-hostname-policycozystack-gateway-attached-namespaces-policycozystack-tenant-host-policycozystack-namespace-host-label-policy
Please rewrite this section to cover all four policies and what each one guards against. Doc/implementation drift on a security model is hard for operators to reason about.
There was a problem hiding this comment.
Fixed in c1839f1. Rewrote the Security model section to cover all six layers — listener allowedRoutes whitelist, four VAPs (gateway-hostname, gateway-attached-namespaces, tenant-host, namespace-host-label), and the render-time fail in cozystack-basics — with a one-line description of what each one guards against. The section also now explicitly mentions the independent-apex case (customer1.io, not a subdomain of the platform apex) since the VAP reads the per-namespace label rather than assuming a subdomain hierarchy.
| echo "$output" | grep -q "must equal test.example.org" | ||
| } | ||
|
|
||
| @test "HTTPRoute with a matching parentRef reaches Accepted status" { |
There was a problem hiding this comment.
This file covers only cozystack-gateway-hostname-policy (the "foreign hostname" test at line 48). Three of the four VAPs shipped in packages/system/cozystack-basics/templates/gateway-hostname-policy.yaml have zero end-to-end coverage:
cozystack-tenant-host-policy—trustedCallerlogic (system:masters / cozy-* / flux-system / kube-system SAs).cozystack-namespace-host-label-policy— immutability ofnamespace.cozystack.io/host.cozystack-gateway-attached-namespaces-policy— rejection of a Package withtenant-*ingateway.attachedNamespaces.
The fact that commit 8770b87 had to retrofit matchConditions on three policies specifically to stop failurePolicy: Fail from turning missing fields into denials confirms this is a high-regression-risk area. Each VAP needs positive and negative tests.
There was a problem hiding this comment.
Fixed in 1eaa0be. Added three new bats tests: (1) cozystack-gateway-attached-namespaces-policy rejects a Package with tenant-alice in gateway.attachedNamespaces; (2) cozystack-tenant-host-policy rejects a Tenant.spec.host set via an impersonated tenant-namespace SA (kubectl --as=system:serviceaccount:tenant-test:default --as-group=system:serviceaccounts:tenant-test); (3) cozystack-namespace-host-label-policy rejects a label overwrite by the same impersonated SA. The existing hostname-policy test at line 48 covers layer 2, so all four VAPs now have at least one negative path in e2e.
| {{- $exposeIPsList = append $exposeIPsList $ip }} | ||
| {{- end }} | ||
| {{- end }} | ||
| {{- if $exposeIPsList }} |
There was a problem hiding this comment.
Observation (non-blocking): when expose-external-ips is empty the CiliumLoadBalancerIPPool is skipped but the Gateway itself still renders, so Cilium assigns an address from the cluster default pool (or none at all) and the operator ends up with a silently non-working Gateway. Consider emitting a render-time fail or at least a prominent NOTES.txt warning for this case.
There was a problem hiding this comment.
Fixed in fa1b739 — replaced the silent if $exposeIPsList skip with an explicit helm fail message that names the root cause and points at publishing.externalIPs / publishing.exposure. The operator now gets a loud render-time failure instead of a tenant Gateway Service stuck in <pending> with no IP. Also ported the pre-CIDR input guard from packages/extra/ingress/templates/cilium-lb-pool.yaml (same fix shipped in PR #2468) so 192.0.2.10/32 or 2001:db8::1/128 don't get double-suffixed. Unit tests updated; the old hasDocuments: count: 0 assertion is replaced by a failedTemplate.errorMessage check, and expose-external-ips was added to every other test case so they don't accidentally cross this guard.
…PI surface Mirrors the security framing rewrite in cozystack/cozystack#2470 README: - Security section opens with the three-group framing (tenant-user- input gates / defense-in-depth / admin-against-themselves), anchored in the apps.cozystack.io/* tenant API surface. - Mermaid diagram redrawn so the attacker arrow lands on Layer 4 (cozystack-api admission of Tenant.spec.host) as the user-input boundary; defense-in-depth and admin-against-themselves layers branch off as separate sources. - Layer 7 wording reframed: drop the implication that a tenant user with HTTPRoute RBAC could exploit the cross-apex hostname surface. Tenants in Cozystack do not hold gateway.networking.k8s.io/* RBAC by design. Reframed as defense-in-depth against an app chart bug or supply-chain compromise. - New Tenant API surface subsection in Overview anchors that constraint up front, so the rest of the security model reads correctly without re-deriving it. Description metadata flips 'seven-layer cross-tenant isolation' to 'three-group security model' to match. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
0776159 to
629cd06
Compare
|
Took the architectural critique and ARP-spoofing scenario apart properly — three of my push-back points were wrong, and the right shape is the one you sketched. Branch reverted; keep two clean commits on top of the base PR ( Where I was wrongRBAC analysis for child tenants. I framed Threat model misread.
Mechanism leak in chart. Hardcoding What's in the revised PR
Idea for a follow-up PRIf specific-IP requests are needed at the tenant level later, the cleaner shape is a On inheritance (Comment 2)Keeping inheritance off in this cycle, with a slightly different argument than my last reply. The 64-cap point you raised against my push-back is fair: with DNS-01 wildcard, practical capacity on a parent Gateway is "children-per-parent" rather than "total apps in subtree", and that's a much higher ceiling than my "5×5=25" example. But the cost is splitting cert handling into two distinct trees: HTTP-01 stays per-listener (and so still hits the 64-cap on the parent's own apps even at low subtree fanout), DNS-01 needs deeper wildcards or per-route fallback to cover apexes more than one level below the parent's. Mixed mode across a tenant tree multiplies that. The inheritance feature is genuinely useful for the per-isolated-app sub-tenant pattern you described, but it requires a cert-mode-aware controller path and the validation surface that comes with it — that's a separate PR's worth of design and code. I'd document the constraint under Known limitations on this PR (per your suggestion) and ship inheritance as a follow-up that reasons through HTTP-01 vs DNS-01 path separately. The trigger to revisit becomes "we have an explicit user with the per-isolated-app sub-tenant pattern that needs this" rather than waiting on Cilium ListenerSet. Acceptable? CI is rerunning on the reverted branch. |
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
Thanks for the rework. The four blockers from the May 6 review are addressed in the tree, with one exception: commit 2afec03 claims a fix that didn't land.
Missing change in commit 2afec03
The commit message describes two file edits:
packages/apps/tenant/templates/namespace.yaml— switch_namespace.gatewayresolution from raw.Values.gatewaytotenant.gatewayEffective. ✅ landed.packages/system/cozystack-basics/templates/cozystack-values-secret.yaml— conditionalgateway: tenant-rootunder_cluster.gateway-enabled='true', plus a test fixture incozystack-basics/tests/cozystack-values-secret_test.yaml. ❌ neither change is in the tree.
git show 2afec034 -- packages/system/cozystack-basics/ is empty; git show 2afec034 --stat lists only namespace.yaml. Current cozystack-values-secret.yaml still has:
_namespace:
host: {{ index .Values._cluster "root-host" | quote }}
etcd: tenant-root
ingress: tenant-root
monitoring: tenant-root
seaweedfs: tenant-rootLooks like a casualty of the May 9 branch revert — an earlier commit on the superseded feat/gateway-api-httproutes branch (73965bf, Kirill Ilin) had the same change and didn't make it forward.
Impact
_namespace.gateway is consumed by apps/harbor/templates/{httproute,ingress}.yaml and system/bucket/templates/{httproute,ingress}.yaml. For tenant-root, those values come from the cozystack-values Secret rendered by cozystack-basics — the apps/tenant chart skips tenant-root via the existing ne tenant.name "tenant-root" gate, so the propagation fix in namespace.yaml doesn't reach it.
Result on a cluster with gateway.enabled=true: tenant-root's harbor and bucket render Ingress instead of HTTPRoute even though the controller has materialised the Gateway. That's the exact regression this commit's message says it closes. Sub-tenants are unaffected (their _namespace.gateway is computed correctly via the new tenant.gatewayEffective call). Dashboard / keycloak / cozystack-api / cdi-uploadproxy / vm-exportproxy are also unaffected — they read _cluster.gateway-enabled directly.
Ask
Land the missing two changes from the commit message:
- Add a conditional
gateway: tenant-rootto_namespaceinpackages/system/cozystack-basics/templates/cozystack-values-secret.yaml, gated on_cluster.gateway-enabled == "true"so non-Gateway clusters keep the empty value. - Add the
cozystack-basics/tests/cozystack-values-secret_test.yamlfixture pinning the conditional, with a regression guard that the other four_namespacekeys (etcd,ingress,monitoring,seaweedfs) stay unconditional.
Everything else from the May 6 review looks properly addressed:
gatewayIPis gone end-to-end (API types, schema, TenantGateway CRD, controller renderer, gateway chart template).- Security restructure landed in README and in the PR description — three groups, Layer 7 reframed as defense-in-depth.
- Admission-chain fix is surfaced as its own section in the PR description and as a dedicated release-note bullet. The
pkg/registry/apps/application/rest.gochange correctly invokescreateValidation/deleteValidationand converts the HelmRelease back to an Application before the delete-time admission run. - Inheritance deferred to follow-up with the Known-limitations entry, agreed.
|
Landed the missing two changes in
Verified locally: |
|
Andrei Kvapil (@kvaps) CI is green now (E2E passed in 1h38m on rerun) and the missing |
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
Thanks for the rework — the May 11 blocker is properly closed in commit e93c3db. Both edits the prior 2afec03 commit message promised are now in the tree, and the test fixture goes a step further than asked: it pins the conditional in both modes and guards the other four _namespace.* keys against an accidental refactor that loops them into the same flag. Nice touch on the toString normalisation for gateway-enabled too — covers both the controller-wire string and the bare --set bool.
LGTM from my side. Thanks for the patience across four rounds on this one, Aleksei — the final shape is in a noticeably better place than the April starting point.
Replace ingress-nginx as the default ingress layer for cozystack-native
services with Cilium Gateway API, materialised per-tenant via a new
TenantGateway CRD reconciled by cozystack-controller.
The extra/gateway chart renders one gateway.cozystack.io/v1alpha1
TenantGateway per tenant; the controller owns the resulting Gateway,
per-tenant Issuer, Certificate(s), and the http->https redirect
HTTPRoute. This avoids the helm-vs-controller race on
Gateway.spec.listeners when route-driven listeners are added.
publishing.certificates.solver selects HTTP-01 (default; per-app HTTPS
listener + per-app Certificate) or DNS-01 (opt-in; single wildcard
Certificate per apex; providers: cloudflare, route53, digitalocean,
rfc2136). HTTP-01 keeps zero-config per-app onboarding; DNS-01 saves
Let's Encrypt budget on apex-shared clusters.
Security model -- seven independent layers, all admission-time gates
fail-closed:
1. Listener allowedRoutes namespace whitelist via the
kube-apiserver-written kubernetes.io/metadata.name label;
plain-HTTP listener is strictly narrower than HTTPS, and HTTPS /
passthrough listeners restrict allowedRoutes.kinds to
HTTPRoute / TLSRoute respectively.
2. cozystack-gateway-hostname-policy VAP on Gateway.
3. cozystack-gateway-attached-namespaces-policy VAP on Package --
blocks tenant-* in gateway.attachedNamespaces.
4. cozystack-tenant-host-policy VAP on Tenant -- blocks spec.host
writes from non-trusted callers.
5. cozystack-namespace-host-label-policy VAP on Namespace -- blocks
writes to namespace.cozystack.io/host from non-trusted callers.
6. Render-time fail in cozystack-basics for tenant-* in
gateway.attachedNamespaces.
7. cozystack-route-hostname-policy VAP on HTTPRoute / TLSRoute,
scoped to tenant-* namespaces.
Within-apex cross-namespace hostname conflict is resolved at reconcile
time by a HostnameConflict condition (cozy-* namespace wins).
Five reconcile paths refuse to rewrite pre-existing objects with the
controller-derived name but no OwnerReference back to the
TenantGateway: Gateway, redirect HTTPRoute, per-tenant Issuer, wildcard
Certificate, per-listener Certificate.
Derived-apex tenants (tenants whose host is a subdomain of a parent
tenant's) auto-enable Gateway when the platform-level gateway.enabled
is on.
Bonus fix: pkg/registry/apps/application/rest.go was ignoring the
createValidation and deleteValidation callbacks that genericapiserver
hands to every storage method, silently bypassing admission for Create
and Delete on every apps.cozystack.io resource (Tenant, MariaDB,
Postgres, Kubernetes, ...). Aligned with the existing updateValidation
pattern.
Always-on changes: cilium.envoy.enabled + cilium.gatewayAPI.enabled
flip to true (extra cilium-envoy DaemonSet, ~100 MB RAM/node idle),
and the cozystack-api admission-chain fix. Everything else is gated
behind gateway.enabled=false and tenant.spec.gateway=false; existing
clusters see no behavioural change until an operator opts in.
Tests: 60+ Go cases in internal/controller/tenantgateway/, admission-
chain regression tests in pkg/registry/apps/application/, helm-unittest
across 7 charts (extra/gateway, core/platform, apps/tenant,
system/cozystack-basics, system/cert-manager-issuers,
system/cozystack-api, system/dashboard), and 14 e2e bats scenarios in
hack/e2e-apps/gateway.bats. New make test-controllers target wired
into pull-requests.yaml.
Deps: sigs.k8s.io/gateway-api v1.4.1, cert-manager v1.17.4.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
…ayEffective Two related fixes for the gap where the tenant chart's gateway HelmRelease materialises but child apps (harbor, bucket) silently fall back to ingress-nginx: - packages/apps/tenant/templates/namespace.yaml: switch the _namespace.gateway resolution from raw .Values.gateway to tenant.gatewayEffective. This is the same helper consulted by gateway.yaml (HelmRelease render), so per-tenant Gateway materialisation and child-app HTTPRoute / Ingress selection now stay in lockstep — including the auto-default for derived-apex tenants. - packages/system/cozystack-basics/templates/cozystack-values-secret.yaml: add a conditional gateway field to the tenant-root cozystack-values Secret. cozystack-basics is the only writer of _namespace.* for tenant-root (apps/tenant chart skips tenant-root via the existing ne 'tenant-root' gate). Without this line, tenant-root apps read _namespace.gateway as empty and route to ingress even when the platform's gateway.enabled flag is on. Conditional on _cluster.gateway-enabled='true' so non-Gateway clusters keep the empty value. Test fixture in cozystack-basics/tests/cozystack-values-secret_test.yaml pins the conditional and the regression-guard that the other four _namespace keys (etcd, ingress, monitoring, seaweedfs) stay unconditional. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
…allocation Security model section split into three groups by who they defend against, anchoring the framing in the apps.cozystack.io/* tenant API surface (tenants do not write Gateway API resources directly, so most of the seven layers are not protecting against tenant-user input): - tenant-user-input gates: Layer 4 (cozystack-tenant-host-policy) via cozystack-api admission. Tenant.spec.host is the user-supplied field that surfaces as a security boundary. - defense-in-depth: Layers 1, 2, 5, 6, 7. Cover bugs in cozystack-controller / Flux, supply-chain compromise of an app chart, confused-deputy admin mistakes. Fail-closed. - admin-against-themselves: Layer 3 (cozystack-gateway-attached- namespaces-policy). Catches kubectl edit on the platform Package. Layer 7 wording fix: drop the implication that a tenant user with HTTPRoute RBAC could exploit the cross-apex hostname surface — that RBAC is not granted in Cozystack. Reframed as defense-in-depth against an app chart bug or supply-chain compromise. External IP allocation section reframed as admin-side concern: allocator (MetalLB / Cilium LB-IPAM / robotlb / externalIPs) is configured at the platform layer, tenant API stays mechanism-agnostic. Per-Service IP uniqueness is the allocator's responsibility — same shape as for any LoadBalancer Service. Inheritance Known limitations entry: spell out both upstream gaps (Gateway API 64-listener cap, Cilium sharing-key inactive on 443/TCP via cilium#42756) so readers understand why per-tenant Gateway is the only scalable shape today. publishing.gateway.attachedNamespaces -> gateway.attachedNamespaces fix: the values block is at top-level, not under publishing. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The Gateway API source-flag toggle (gatewayAPI=true) already enabled gateway-httproute discovery. cozystack ships TLSRoute attachments for SSL-passthrough services (kubernetes control plane API, kubevirt CDI uploadproxy, kubevirt VM exportproxy) — without gateway-tlsroute in the external-dns sources list those hostnames are never picked up for DNS record management. Adds gateway-tlsroute under the same conditional and updates the value description / schema / README to reflect that the toggle now covers both route kinds. Signed-off-by: Aleksei Sviridkin <f@lex.la>
Adds Gateway API equivalents for the two ingress endpoints in the
monitoring chart — Grafana (web UI) and Alerta (alert console) — gated
on the same _cluster.gateway-enabled flag the keycloak / dashboard /
cozystack-api templates already use.
* Grafana's ingress is configured inline via Grafana CRD's spec.ingress
block; that block is now wrapped with {{ if ne gateway-enabled true }}
and a standalone HTTPRoute attaching to the tenant Gateway is rendered
in the alternate branch.
* Alerta uses a standalone Ingress object; the same Ingress↔HTTPRoute
swap is wired through an if/else.
Both routes attach to Gateway cozystack in the publishing namespace
(default tenant-root), matching the convention established by the rest
of the system charts.
Signed-off-by: Aleksei Sviridkin <f@lex.la>
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>
The Gateway's HTTPS / TLS-passthrough listeners now match attached namespaces by the namespace.cozystack.io/gateway label keyed on the TenantGateway's own namespace, instead of a static kubernetes.io/metadata.name In [list] whitelist. This restores inheritance parity with the legacy ingress flow: child tenants of a Gateway-owning ancestor publish through the ancestor's Gateway by default — without their own LB IP / Certificate, and without operator intervention to add each child by name to platform values. Two writers populate the label: * apps/tenant chart namespace.yaml — each tenant namespace gets a label pointing at the nearest ancestor that owns a Gateway (self if owning, inherited otherwise). The tenant Helm chart is the source of truth for tenant-tree labels; the controller never touches them (annotation-gated GC). * cozystack-controller (ensureNamespaceLabels) — patches the label onto every namespace in tgw.Spec.AttachedNamespaces, so cozy-* system namespaces (cert-manager, monitoring, harbor, …) reach the publishing Gateway alongside the tenant tree. Tracks its own writes via the cozystack.io/gateway-attached-by annotation; on a reconcile where an entry is removed from AttachedNamespaces, the label is garbage-collected (annotation present + namespace no longer expected → strip both label and annotation). Also adds the gateway-attach label to tenant-root itself via cozystack-basics/templates/tenant-root.yaml when _cluster.gateway-enabled is true, so the Gateway in tenant-root's own namespace passes its own selector for the controller-owned http→https redirect HTTPRoute. Tests (TDD, red→green): * TestReconcile_HTTPSListenerUsesGatewayLabelSelector pins the new MatchLabels shape on HTTPS listeners. * TestReconcile_LabelsAttachedNamespaces pins the patch path: every namespace in spec.AttachedNamespaces (plus tgw.Namespace) carries namespace.cozystack.io/gateway=<owner> after reconcile. * TestReconcile_LabelGCRemovesDroppedAttachedNamespaces pins the GC path: removing an entry between reconciles strips the label. * TestReconcile_LabelGCDoesNotStripHelmOwnedLabels pins the safety contract: labels lacking the controller annotation (Helm-owned tenant labels) are left alone. * tenant-root_test.yaml adds the positive case for the conditional label render under _cluster.gateway-enabled=true. The HTTP listener (port 80) keeps the narrower static-name selector — it allows only the tenant namespace plus cozy-cert-manager (for HTTP-01 ACME challenges) and must NOT accept routes from app namespaces or child tenants, since unattended routes on the HTTP listener serve plaintext and leak credentials. Signed-off-by: Aleksei Sviridkin <f@lex.la>
In DNS-01 mode, the parent's wildcard Certificate now covers every inheriting child tenant's apex (`<child-apex>` + `*.<child-apex>` as additional SANs) and the parent Gateway grows one wildcard HTTPS listener per child apex referencing the same Certificate. Without the expansion, a child route hostname like `harbor.alice.example.org` fails to match the parent's listeners — `*.example.org` is a single- label wildcard and `alice.example.org` is two labels deep — and the TLS handshake fails the SNI check because the parent cert never listed the child apex. The controller discovers inheriting children by listing namespaces labelled `namespace.cozystack.io/gateway = <tgw.Namespace>` (the existing inheritance contract from Stage 1/3) and reading their `namespace.cozystack.io/host` label, which apps/tenant chart writes to the child apex. Children with no host label are skipped (defensive); a child host equal to the parent apex is deduplicated (cert-manager rejects duplicate dnsNames). HTTP-01 mode is unchanged — its per-listener / per-cert flow already handles child hostnames without any wildcard. Listener naming: `https-child-<first-label>-<8-hex>`, where the hex suffix is sha256(child-apex)[:8] so two children whose first DNS label collides produce distinct listener names. Cap: Gateway API hard-caps spec.listeners at 64 entries. The parent uses 3 slots (http + https + https-apex) leaving ~61 wildcard children per parent. Operators whose subtree fans out beyond that must run the high-fanout branch on its own Gateway via tenant.spec.gateway=true. Tests (TDD red→green): * TestReconcile_DNS01WildcardCertCoversInheritingChildApexes pins the SAN list contains both `<child-apex>` and `*.<child-apex>`. * TestReconcile_DNS01WildcardCertDeduplicatesChildApexEqualToParent pins the dedupe path (child host == parent apex). * TestReconcile_HTTP01WildcardCertNeverRendered pins the inverse: HTTP-01 mode never renders a wildcard Certificate. * TestReconcile_DNS01GatewayHasListenerPerChildApex pins the listener-side expansion (one `*.<child-apex>` listener per inheriting tenant, all referencing the parent's wildcard cert). Signed-off-by: Aleksei Sviridkin <f@lex.la>
Removes two gates that prevented child tenants from attaching to a parent's publishing Gateway: 1. Render-time fail() in templates/gateway-hostname-policy.yaml that refused to render the chart when _cluster.gateway-attached- namespaces contained any tenant-* entry. 2. cozystack-gateway-attached-namespaces-policy VAP that enforced the same rule at Package CR admission time. The original rationale — 'a tenant in attachedNamespaces can hijack the publishing tenant's hostnames' — assumed a static-name allowedRoutes whitelist where adding tenant-* literally widened the attach surface for arbitrary hostnames. Under the inheritance refactor (Stage 1/3/5) the attach surface is governed by the namespace.cozystack.io/gateway label selector, not by entries in AttachedNamespaces; and hostname hijack is independently blocked by three other layers: * Layer 4 — cozystack-tenant-host-policy VAP on Tenant.spec.host * Layer 5 — cozystack-namespace-host-label-policy VAP on the namespace label * Layer 7 — cozystack-route-hostname-policy VAP on HTTPRoute and TLSRoute hostnames Those three defend against the hijack vector regardless of what sits in attachedNamespaces, and they apply uniformly to every tenant (inheriting or owning a Gateway). The dropped gates added no extra defence, while their presence broke the inheritance flow kvaps reasonably asks for in the May 25 review. Doc count for gateway-hostname-policy.yaml drops from 8 to 6 (one VAP + one Binding removed); helm-unittest fixtures updated to match, with a regression-guard test asserting the removed VAP name no longer appears in any rendered document. Signed-off-by: Aleksei Sviridkin <f@lex.la>
Replace the 'Inheritance from parent Gateway' known-limitation entry (which described inheritance as deferred) with: * A top-of-README 'Inheritance: when to opt in for a separate Gateway' section explaining when a tenant owns its Gateway vs inherits, and how the namespace.cozystack.io/gateway label is written by apps/tenant chart and patched onto cozy-* by the controller. * DNS-01 cert-mode docs spell out SAN expansion for child apexes: the wildcard Certificate now carries <child-apex> + *.<child-apex> per inheriting tenant, with a per-child *.<child-apex> listener on the parent Gateway. Documents the DNS-provider-permission requirement for deeply-nested children (ACME challenge writes TXT under each apex zone, so zone delegation or wide-scope provider creds are needed for grandchild apexes). * New known-limitation entries: 64-listener cap on the parent Gateway under inheritance (with the high-fanout escape via tenant.spec.gateway=true), and Cilium sharing-key port-collision (multiple per-tenant Gateways still each take their own LB IP until ListenerSet ships in Cilium). Signed-off-by: Aleksei Sviridkin <f@lex.la>
Replaces the now-obsolete "Package admission rejects tenant-* in gateway.attachedNamespaces" e2e (the VAP it pinned was dropped in the inheritance refactor) and adds two new e2e cases that cover the end-to-end inheritance contract: * "Package admission accepts gateway.attachedNamespaces with tenant-* entries" — regression guard against a future refactor re-introducing the gate; the hijack surface is now handled by Layers 4/5/7 (Tenant.spec.host VAP, namespace label immutability VAP, HTTPRoute hostname VAP), not by entries in attachedNamespaces. * "child tenant without explicit gateway inherits _namespace.gateway from a Gateway-owning parent" — chart-level lockstep: a parent tenant with gateway=true produces both the namespace label and the cozystack-values Secret pointing at itself, and a child Tenant under that parent with the gateway field unset receives the parent's name in both places. Plus a negative assertion that the child does NOT get its own gateway HelmRelease. * "child tenant's HTTPRoute attaches to parent's Gateway via inheritance label" — full cross-namespace attach: parent owns the Gateway, child inherits, an HTTPRoute in the child namespace pointing at parent's Gateway reaches Accepted=True once Cilium evaluates the label-based allowedRoutes selector. Route hostname uses the child's derived apex so Layer 7 admits it. The Gateway-Programmed wait is intentionally omitted from the last test — Programmed depends on the LB allocator that ships with the e2e cluster, while Accepted is a pure spec evaluation on the parent Gateway's allowedRoutes selector and is the property the inheritance refactor is meant to deliver. Signed-off-by: Aleksei Sviridkin <f@lex.la>
`cozyvalues-gen` bakes the values.schema.json's `gatewayAPI.description` into the JSON openAPISchema field of the external-dns release-definition CRD at `packages/system/external-dns-rd/cozyrds/external-dns.yaml`. The Q7 commit (18fd155) updated the description from 'HTTPRoute only' to 'HTTPRoute and TLSRoute' but missed running `make generate` in `packages/extra/external-dns/`, so the cozyrds JSON drifted from the source values.yaml. Pre-commit drift check caught it on CI. This commit ships the regenerated cozyrds schema only — no behavioural change. The 'make generate' rule in extra/external-dns runs cozyvalues-gen and `hack/update-crd.sh`, both already in CI's drift verification path. Signed-off-by: Aleksei Sviridkin <f@lex.la>
…laim collection
The HTTP-01 inheritance flow deadlocked at the route-Accepted step:
the apps/tenant chart correctly labelled the child namespace with
`namespace.cozystack.io/gateway=<owner>`, the parent Gateway's
allowedRoutes selector matched by label, but `collectHostnameClaims`
filtered routes by the static set `{tgw.Namespace} ∪ Spec.AttachedNamespaces`
— so a child route in a namespace reached purely via the inheritance
label was silently dropped. No per-listener Certificate, no per-app
HTTPS listener, no hostname match → `Accepted=False,
Reason=NoMatchingListenerHostname` indefinitely.
Caught by the new `child tenant's HTTPRoute attaches to parent's
Gateway via inheritance label` e2e bats — failed 3/3 retries on the
CI run for `afc7765e6`. The Go controller-level
TestReconcile_HTTP01CollectsHostnamesFromInheritingChildNamespaces
test pins the same contract at a faster feedback loop: an HTTPRoute
in a namespace labelled `namespace.cozystack.io/gateway=<owner>` but
NOT listed in Spec.AttachedNamespaces must still cause the
controller to render a per-listener HTTPS listener for its hostname.
The fix extends the `allowed` set in `collectHostnameClaims` to
also include namespaces matched by the gateway label selector, so the
hostname-claim collector and the Gateway's allowedRoutes selector
agree on which namespaces can attach. DNS-01 mode is unaffected — it
returns nil from `collectHostnameClaims` immediately and uses the
separate `collectInheritingChildApexes` path for SAN expansion.
Signed-off-by: Aleksei Sviridkin <f@lex.la>
|
Pushed an inheritance refactor in response to the May 25 review. The PR body has been rewritten to reflect the new shape; this comment summarises the decisions taken along the way. Inheritance is now the defaultEvery tenant inherits the publishing Gateway of the nearest ancestor (inclusive) that owns one — same shape as the existing The previous "auto-default for derived-apex tenants" rule is removed. It produced one LB IP per derived-apex child even when the operator never asked, which scaled linearly with tenant depth. The new
Attach surface is label-based, not name-based
The previous static Hostname hijack moves to per-route admissionHostname hijack across namespaces under inheritance is closed by three independent layers, all already shipped in this PR:
Net effect: tenants holding only DNS-01 SAN expansion for inheriting childrenIn DNS-01 mode the parent's wildcard Certificate now carries Child apexes are discovered by listing namespaces with the gateway label and reading their The ACME challenge for DNS-01 then has to succeed for every SAN — for deeply-nested children this requires zone delegation or a wide-scope DNS provider credential. Documented as a known-limitation entry in HTTP-01 mode is unaffected: per-listener certs are added on demand from Cap behaviour64-listener cap (Gateway API spec hard-cap, upstream's "answer" is GEP-1713 ListenerSet which Cilium does not yet ship — cilium#42756):
High-fanout subtrees opt into their own Gateway via Decisions left for follow-up
E2E + testsInheritance is pinned at three layers:
|
…ccepted The previous e2e check waited for `HTTPRoute.status.parents[0].conditions[Accepted]=True` after applying a child tenant's HTTPRoute against the parent's Gateway. That condition is set by Cilium after the listener's TLS Secret becomes Ready — and in the e2e cluster every fresh certificate fails to issue (LE prod refuses every `.example.org` hostname by policy, visible across alerta/grafana/dashboard/seaweedfs-s3 cluster certs in cozyreport). So Cilium never set Accepted, the route timed out 3/3 retries, and the failure was environmental — not a regression in the inheritance code path. The contract the test exists to pin is cozystack-controller's behaviour, which has two observable writes: 1. The parent Gateway grows a per-listener HTTPS entry for the child route's hostname (proves collectHostnameClaims saw the route through the inheritance label and reconcileGateway appended the listener). 2. A per-listener Certificate object appears in the parent namespace with the route's hostname in spec.dnsNames (proves reconcilePerListenerCertificates ran for the same hostname). Both writes are deterministic and depend only on the inheritance label + the HTTPRoute existing — not on ACME, not on Cilium TLS readiness. The test now polls those two objects directly within shorter, scenario-appropriate timeouts (120s for the listener, 60s for the Certificate). The route Accepted check is dropped with an explanation in the test comment so a future cluster with a working ACME doesn't get a misleading pre-condition added back. The namespace inheritance label is also verified up front as the precondition collectHostnameClaims's filter reads. Signed-off-by: Aleksei Sviridkin <f@lex.la>
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
LGTM — the inheritance rework lands the deferred piece cleanly and matches the shape we discussed: _namespace.gateway inherits from the parent like _namespace.ingress, the label-based allowedRoutes selector replaces the static name whitelist, and the DNS-01 SAN/listener expansion plus the annotation-gated label GC are all well-tested. The HTTP :80 listener correctly stays on the narrow name whitelist, and the e2e tests pin the controller-write contract rather than chasing Cilium Accepted in an environment that can't issue certs. Approving.
A handful of non-blocking notes, roughly in priority order:
-
gofmt—renderers.go(renderWildcardCertificate). Theseenmap literal isn't gofmt-clean on go1.26.2 (gofmt -lflags it) — the keys are over-aligned. One-line fix. CI doesn't gate gofmt today, so it slipped through green. -
No
Namespacewatch → stale listener/SAN drift on DNS-01 teardown.SetupWithManagerwatches HTTPRoute/TLSRoute only. Creation converges fine (a child route maps back to the parent TGW). But in DNS-01 mode the per-child-apex listeners and cert SANs are collected from namespaces (collectInheritingChildApexes), not routes — so when a child tenant is deleted, nothing re-enqueues the parent and the*.<child-apex>listener + SAN linger until an unrelated reconcile (or the resync period). ANamespacewatch filtered on thenamespace.cozystack.io/gatewaylabel closes this. HTTP-01 teardown is unaffected (route-driven). -
Stale comment on a security-relevant function —
reconciler.gorenderGateway(~L692). The doc block still says every listener is gated by an unspoofablekubernetes.io/metadata.name In [...]selector and calls it "Layer 1". That's now only true for the HTTP :80 listener; HTTPS listeners go through the label selector inbuildAllowedRoutes. Worth updating so the comment doesn't misdescribe the attach model. -
ReferenceGrantcomment describes resources that aren't rendered —apps/tenant/.../namespace.yaml(and the 97dff75 / 30302ea commit messages). It says the controller renders a ReferenceGrant per inheriting child. Nothing renders one, and route→Gateway attachment across namespaces doesn't need one (only cross-namespacebackendRefswould, and the app routes use same-namespace backends). Functionally fine — just drop/fix the comment so it doesn't imply a load-bearing resource that doesn't exist. -
gatewaylabel has no admission guard (defense-in-depth). Thehostlabel is covered bycozystack-namespace-host-label-policy; thenamespace.cozystack.io/gatewaylabel that now drives attachment isn't. Not exploitable under the current model — tenants don't write Namespaces directly, andcozystack-route-hostname-policybounds route hostnames to the namespace'shostlabel regardless of which Gateway it attaches to (worst case of a mislabel is self-inflicted, not a hijack). Either add a sibling VAP or note it under Known-limitations so the asymmetry is intentional on the record.
None of these block merge from my side; (1) is the only one I'd fix before merging, the rest can ride a follow-up.
|
One more from cross-checking against the docs PR (cozystack/website#509) — folding in as a 6th note to the review above:
|
What this PR does
Replaces ingress-nginx as the default ingress layer for cozystack-native services with Cilium Gateway API, materialised per-tenant via a new
TenantGatewayCRD reconciled bycozystack-controller. Supersedes #2213 / #2208. Stacked on top of #2468.Architecture
The chart renders one
gateway.cozystack.io/v1alpha1 TenantGatewayCR per tenant that owns its own Gateway. The cozystack-controller reconciles the actualGateway, per-tenantIssuer,Certificate(wildcard or per-listener), and HTTP→HTTPS redirectHTTPRoutefrom there. Helm does not renderGatewayorCertificatedirectly — that prevents the Helm-vs-controller race onGateway.spec.listenersthat route-driven listener additions would otherwise cause.Inheritance: tenants attach by default, opt into their own Gateway
A tenant gets a dedicated Gateway (own LB Service, own LB IP, own Certificate) only when it explicitly asks via
tenant.spec.gateway=true. Every other tenant in the tree publishes through the Gateway of the nearest ancestor that owns one — same shape as the existing_namespace.ingressinheritance.The
apps/tenantchart writes anamespace.cozystack.io/gatewaylabel on each tenant namespace, carrying either the tenant's own name (when owning a Gateway) or the inherited ancestor name (when inheriting). The Gateway's listenerallowedRoutes.namespaces.selectoris keyed on that label, so the same selector admits routes from every namespace pointing at the owner — descendants and cozy-* system namespaces alike. cozystack-controller patches the same label onto every namespace intgw.Spec.AttachedNamespacesand garbage-collects labels it wrote when an entry is removed from the attach list (annotation-gated GC so labels written by the apps/tenant chart are never stripped).In DNS-01 mode, the controller extends the parent's wildcard Certificate with
<child-apex>+*.<child-apex>SANs per inheriting child, and adds a*.<child-apex>HTTPS listener per child apex referencing the same cert. Without this expansion the parent's single-level wildcard cannot match a child route's hostname (harbor.alice.example.orgis two labels past the parent's*.example.org).Opt-out into own Gateway is the right choice when: a tenant needs its own LB IP (DNS pinned, firewall rule), the apex is not derived from the parent (custom
hostvalue the ancestor's cert can't cover), or the tenant wants its own ACME account / cert authority. Otherwise leavegatewayunset and inherit.End-to-end flow
gateway.enabled=truetoggles cert-manager solver, switches per-system-app templates from Ingress to HTTPRoute / TLSRoute.tenant.spec.gateway=truerenders aTenantGatewayCR and triggers the controller to materialise the owner Gateway. Tenants with the field unset inherit through_namespace.gatewaypropagation in the tenant chart.Defaults stay legacy:
gateway.enabled=false,tenant.spec.gateway=false. Existing clusters upgrade unchanged.External IP allocation — mechanism-agnostic
The per-tenant Gateway's auto-created
LoadBalancerService draws its IP from whatever LB allocator the cluster admin has configured at the platform layer — same shape as ingress-nginx today. The tenant API stays mechanism-agnostic — nogatewayIPfield, no allocator-specific manifest in the tenant chart. Cozystack itself ships MetalLB installed but does not render anyIPAddressPool/L2Advertisement/BGPAdvertisementfrom this chart; admins set up the allocator that suits their environment (MetalLB pool with L2 / BGP, Cilium LB-IPAM with announcer, robotlb against a cloud provider, orService.spec.externalIPspinning).If a tenant needs a specific address (DNS already pinned, firewall rule, etc.), the operator pre-allocates it on the admin side: either pre-create the Service with
loadBalancerIPset, or hand the tenant a reference to a named admin-managed pool. Per-Service IP uniqueness is the allocator's responsibility — same as for any other LoadBalancer Service.Cert mode: HTTP-01 (default) vs DNS-01 (opt-in)
publishing.certificates.solverselects how the controller sources TLS certs.HTTP-01 (default) — per-app HTTPS listener + per-app Certificate. New apps require zero platform-side config: deploy the HTTPRoute, controller does the rest. Inheriting children's hostnames pick up per-listener certs the same way the owner's own apps do.
DNS-01 (opt-in) — single wildcard Certificate (
<apex>+*.<apex>) covering every published app under the apex, plus per-child SANs (<child-apex>+*.<child-apex>) for every inheriting tenant. Pick this for clusters where many apps share the apex and Let's Encrypt rate limits matter. The DNS provider account must be able to write TXT records under every apex level the parent serves — for deeply-nested inherited children that requires zone delegation or a wide-scope provider credential.publishing.certificates.dns01.providerpublishing.certificates.dns01.<provider>keyscloudflare(default)cloudflare.secretName,cloudflare.secretKeyroute53route53.region,route53.secretName(andaccessKeyIDif not on IRSA)digitaloceandigitalocean.secretNamerfc2136rfc2136.nameserver,rfc2136.tsigKeyName,rfc2136.secretNameThe platform chart wires every provider's keys into
_cluster.dns01-*; the per-tenant gateway chart and the cluster-widecluster-issuers.yamlboth read them.Strengthens admission coverage on apps.cozystack.io/*
The
cozystack-tenant-host-policyprobe ingateway.batsinitially looked like a flake — the apply that was supposed to be VAP-rejected just succeeded. Investigation traced it to the custom REST handler atpkg/registry/apps/application/rest.go. genericapiserver hands every storage method a callback into the validating admission chain:createValidationfor Create,updateValidationfor Update,deleteValidationfor Delete.Updatewas correctly invoking its callback since 23e399b, butCreateandDeleteaccepted the parameter and never called it — admission was silently bypassed for both verbs on everyapps.cozystack.ioresource (Tenant, MariaDB, Postgres, Kubernetes, …).This PR wires the missing
createValidationanddeleteValidationcalls into Create and Delete. Every ValidatingAdmissionPolicy and ValidatingWebhook againstapps.cozystack.io/*now fires on all three verbs as the API contract requires. Per KEP-3488 aggregated APIServers are responsible for enforcing admission on their own kinds; cozystack-api is configured to do so viagenericoptions.NewRecommendedOptions, the only thing missing was the explicit*Validationcallbacks in the custom REST.Security — five guards, grouped by what they defend
Tenants in Cozystack interact with the platform exclusively through
apps.cozystack.io/*resources (Tenant, Bucket, Kubernetes, …) served bycozystack-api. Tenant RBAC (cozy:tenant:*aggregated to a RoleBinding in the tenant's own namespace) does not grant write access togateway.networking.k8s.io/*, coreNamespaces, orcozystack.io/Package. The protections below split into three groups by who they defend against:Tenant-user-input gates. Layer 4 (
cozystack-tenant-host-policy).Tenant.spec.hostis the user-supplied field that surfaces as a security boundary at the hostname layer; gated on every Create / Update viacozystack-api's admission chain.Defense-in-depth. Layers 1, 2, 5, 7. These do not protect against tenant-user input (tenants don't hold the relevant RBAC). They guard against bugs in cozystack-controller / Flux, supply-chain compromise of an app chart that emits Gateway API resources, and confused-deputy mistakes by a cluster admin. Fail-closed via
failurePolicy: Fail+validationActions: [Deny].The five layers themselves:
allowedRouteslabel selector —namespace.cozystack.io/gateway = <owner-tenant-name>(controller-written for cozy-* viaAttachedNamespaces, Helm-written for tenant namespaces via the tenant chart, both feeding into the same Gateway selector).cozystack-gateway-hostname-policy— VAP onGatewayCREATE/UPDATE. Restricts listener hostnames to the namespace'snamespace.cozystack.io/hostapex.cozystack-tenant-host-policy— VAP onTenantCREATE/UPDATE.cozystack-namespace-host-label-policy— VAP on coreNamespaceCREATE/UPDATE. Immutability gate on the host label.cozystack-route-hostname-policy— VAP onHTTPRouteandTLSRouteCREATE/UPDATE. Closes the hostname-hijack vector across namespaces under inheritance — a child cannot claim a hostname outside its own apex.The previous Layer 3 (
cozystack-gateway-attached-namespaces-policy) and Layer 6 (render-timefailin cozystack-basics) bannedtenant-*entries inpublishing.gateway.attachedNamespaceson the assumption that the attach list was the hijack vector. Under inheritance the attach surface is the label selector, not the static list; the hijack vector is closed by Layers 4/5/7 independently. Both Layer 3 and Layer 6 are dropped — kept in numbering above for continuity with the earlier review threads.Foreign-takeover guards
Five reconcile paths refuse to silently rewrite a pre-existing object that shares the controller-derived name but carries no
OwnerReferenceback to the TenantGateway:Gateway, redirectHTTPRoute, per-tenantIssuer, wildcardCertificate, per-listenerCertificate. An operator who hand-pinned a Certificate or Issuer at the controller's derived name (private CA, manual cert pinning) gets an explicitReady=False/ReconcileErrorcondition instead of having their config silently destroyed and the resource re-issued from a different ACME account.The namespace-label patching path applies the same discipline: cozystack-controller only writes / strips
namespace.cozystack.io/gatewayon namespaces it annotates withcozystack.io/gateway-attached-by. Labels written by the apps/tenant chart (no annotation) are never touched, so inheritance for tenant namespaces survives every reconcile.Tests
internal/controller/tenantgateway/: idempotency, OwnerReference cascade, multi-parentRef status, HTTP-01↔DNS-01 mode-transition cert lifecycle, foreign-takeover refusals (Gateway / HTTPRoute / Issuer / Certificate / per-listener Certificate), DNS-01 provider matrix (cloudflare / route53 / digitalocean / rfc2136), HTTPS Kinds=[HTTPRoute] restriction, fail-closed CEL on missing host label, status condition wiring. Plus, for inheritance: label-based allowedRoutes selector shape, namespace label patch + garbage-collect via owner annotation, Helm-owned label safety, DNS-01 wildcard SAN expansion per inheriting child, per-child wildcard listener materialisation.pkg/registry/apps/application/rest_admission_test.gopinscreateValidation,updateValidation,deleteValidationpaths via sentinel callbacks.extra/gateway(TenantGateway CR rendering, both modes),core/platform(DNS-01 wiring),apps/tenant(gateway-effective decision matrix including inheritance),system/cozystack-basics(three VAPs + Layer 7 VAP + tenant-root namespace label including the conditionalnamespace.cozystack.io/gateway: tenant-root+ cozystack-values-secret_namespace.gatewaypropagation),system/cert-manager-issuers(full DNS-01 provider matrix),system/cozystack-apiandsystem/dashboard(Ingress↔TLSRoute/HTTPRoute toggle),system/monitoring(Grafana / Alerta Ingress↔HTTPRoute toggle).hack/e2e-apps/gateway.bats(17 cases) covering GatewayClass Accepted, Gateway Programmed + LB Service, HTTPRoute Accepted with status, VAP rejects cross-tenant hostname hijack, VAP rejectstenant.spec.hostwrites from non-trusted SA (regression-detector for the admission-chain fix), VAP rejectsnamespace.cozystack.io/hostlabel changes from non-trusted SA, route-hostname VAP rejects HTTPRoute hostnames outside the apex, and three new cases for inheritance — Package admission acceptstenant-*inattachedNamespaces(regression guard for the dropped ban), child tenant without explicit gateway inherits_namespace.gatewayfrom a Gateway-owning parent in lockstep across the namespace label and the cozystack-values Secret, and the cross-namespace attach end-to-end where a child tenant's HTTPRoute reaches Accepted on the parent's Gateway via the inheritance label.make test-controllerstarget invoked frompull-requests.yamlrunsgo test ./internal/....Backward compatibility
The only always-on change is the flip of
cilium.envoy.enabled+cilium.gatewayAPI.enabledtotrue(extracilium-envoyDaemonSet, ~100 MB RAM/node idle). Everything else is gated behindgateway.enabled=false/tenant.spec.gateway=false. Existing clusters see no behavioural change until an operator explicitly opts in.The cozystack-api admission-chain fix is unconditional. Existing operators who do not define any VAP / admission webhook on
apps.cozystack.ioresources see no observable change. Operators who DO have their own webhooks/VAPs against these kinds will start seeing them fire on Create and Delete (as intended) — review your policies before merging if you maintain custom admission forapps.cozystack.io/*.Operators who set
publishing.gateway.attachedNamespacesto includetenant-*entries used to hit a render-time fail or an admission rejection — now the apply succeeds and the listed tenant namespaces simply pick up the gateway-attach label alongside the cozy-* system namespaces. This matches the inheritance model and was kvaps' explicit ask in the May 25 review.Release note