Commit Graph
15 Commits
Author SHA1 Message Date
ZverGuy 74a7a44423 feat(dashboard): route the observability proxy roots from the console
The logs and metrics roots under /apis/cozystack.io and
/apis/apps.cozystack.io are served by the observability proxy, not by
the API server, so the console nginx sends them there ahead of its
/apis prefix location, unbuffered and with the same long timeouts, so
that a live tail reaches the browser as it is written.

nginx resolves a literal upstream once at startup and refuses to start
when the name does not resolve, which would take the console down on
every cluster that does not run the proxy. The upstream is therefore a
variable resolved per request, and such a cluster answers 502 on those
paths only. That resolver does not apply the pod's search domains, so
the config becomes a template the image's entrypoint renders with the
pod's nameservers and the platform cluster domain, which the chart now
passes to the console.

Assisted-by: LLM
Signed-off-by: ZverGuy <maximbel2003@gmail.com>
2026-11-09 15:48:57 +07:00
Aleksei Sviridkin 67adfbce3c feat(dashboard): surface service-domain in the console config.json
The console loads config.json once at startup and treats a missing
serviceDomain key as "show no hostname", so the chart copies
_cluster.service-domain into config.json only when it is non-empty rather
than shipping an empty string the SPA would have to special-case. The
helm-unittest suite pins the unset, empty and set cases.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-30 18:17:37 +03:00
Alexey Artamonov 05b121809c feat(dashboard): brand the token-proxy login page from the console config
The dashboard branding (title, logo) lives in the
cozy-dashboard-console-config ConfigMap, which the SPA reaches only
through the authenticated kube-api. The token-proxy login page — the
first screen an unauthenticated user sees on a non-OIDC cluster — had no
access to it and rendered the hardcoded default title and heading,
defeating branding for customer-facing deployments.

Mount that ConfigMap read-only into the token-proxy container and render
its titleText, logoText and logoSvg on the login page, falling back to
the historical unbranded page when the file is absent or unreadable so a
cluster without branding is unaffected. logoSvg is already base64 in the
config — the SPA's Logo.tsx consumes it that way — so it is served
verbatim through an <img> data URI, never re-encoded, which keeps any
embedded script inert. The OIDC branch is untouched: its login is served
by Keycloak, already branded through the realm theme.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-22 19:20:36 +03:00
scooby87 176dd78c1a feat(dashboard): serve console branding without kube-api access (#4256)
## What this PR does

Part of #4217. The SPA fetched its branding (title, logo) only by
reading the `cozy-dashboard-console-config` ConfigMap through the
kube-api. That read already works for any signed-in user —
`console-rbac.yaml` binds `get` on it to `system:authenticated` — but it
still depends on the kube-api being reachable and on the apiserver
authenticating the session's token. A dashboard login whose token the
apiserver rejects (an OIDC session the kube-api does not accept) gets no
branding at all. (The unauthenticated login page is a separate surface,
branded in #4255.)

This mounts that ConfigMap read-only under the nginx web root and serves
it at `/branding/config.json`, and reads that static asset first in
`loadConfig`, falling back to the kube-api ConfigMap for charts that
predate the mount (their static path serves the SPA `index.html`, whose
non-JSON body trips the fallback). Branding then resolves with no
kube-api call at all, so it renders regardless of whether the apiserver
authenticates the session and without the `console-config-reader` RBAC.

Because `config.json` is now a static asset reachable by anyone past the
proxy, it is restricted to the documented white-labeling keys: the
branding map also carries Keycloak-only keys, including
`loginThemeImage`, which can point at a private registry. Keycloak reads
those from `_cluster.branding` directly. The mount uses an `items`
selector so only `config.json` is exposed under the web root.

Verified with `pnpm typecheck`, `pnpm test` (7 `loadConfig` cases),
`eslint`, and `helm unittest` (mount + `items`, the `config.json` key
the mount serves, white-label keys kept incl. `tenantText`,
Keycloak-only keys absent). Also validated live on a dev cluster:
`console-rbac.yaml` binds the ConfigMap read to `system:authenticated`
(`kubectl auth can-i get` this CM as an unbound user → yes; another CM →
no), and a throwaway pod on the real console image serving the mounted
ConfigMap returns `200 application/json` at `/branding/config.json`
while an `items`-excluded key falls through to `index.html`.

### Screenshots

No visual regression to show in a static render: this changes where the
branding config is fetched from, not how it is displayed.

### Downstream repositories

The diff touches only `packages/system/dashboard` (the in-repo console
image and its chart). No app is added, renamed or removed, no
`values.schema.json` / enum / default changes, no CRD or namespace
changes, and the branding keys the SPA reads are unchanged — only the
transport (static asset vs kube-api) and a narrowing of which keys are
served. Nothing in the trigger map applies.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
feat(dashboard): the console serves its branding as a static asset (`/branding/config.json`) read with no kube-api call, so branding renders even when the apiserver does not authenticate the session and without the console-config-reader RBAC; the served file is narrowed to the documented white-labeling keys.
```
2026-09-22 18:36:47 +03:00
Alexey Artamonov 80d32b3ab7 fix(keycloak): complete realm-name coverage and guard non-string names
The enable-update-email hook Job in keycloak-configure still pinned
REALM=cozy, so on a custom realmName it waited on a realm the chart never
provisions and the cold install timed out — the one seventh consumer left
behind, and the only value nobody could set without breaking the install.

A bare-integer or YAML-reserved realm name reached the printf %s that
builds the derived keycloakInternalUrl as a Go int, rendering a
"%!s(...)" format error into the URL; coerce the name to a string once
at the source.

Document that renaming the realm on a running cluster provisions a fresh
empty realm and orphans the accounts in the old one, and note in the OIDC
tenant and grafana guides that the realm name is now the configurable
default. info/Makefile carried NAME=etcd, so cozyhr drove the wrong
release from that package; correct it while touching the file.

Assisted-by: LLM
Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-21 12:15:10 +03:00
Alexey Artamonov 44a9a8e735 feat(platform): make the Keycloak realm name configurable
The realm name "cozy" was hardcoded in keycloak-configure (which owns
the realm, its clients and the cozystack-cluster-admin roles), in the
dashboard and linstor-gui gatekeepers, in the info kubeconfig, and in the
Grafana and managed-Kubernetes apiserver OIDC issuers. Operators could
not rename the realm without patching vendored templates, which
`make update` reverts. The apiserver OIDC issuer is already configurable
via talm oidcIssuerUrl, so pointing it at a custom realm left every SSO
component on "cozy" and broke login with an issuer mismatch.

authentication.oidc.realmName (default "cozy") now rides the _cluster
channel so every consumer resolves the same realm, and the default
keycloakInternalUrl is derived from it so the realm is named in one place.

Assisted-by: LLM
Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-21 12:15:10 +03:00
Alexey Artamonov 3a6e04011f feat(dashboard): serve console branding without kube-api access
The SPA fetched its branding (title, logo) only by reading the
cozy-dashboard-console-config ConfigMap through the kube-api. That read
already works for any signed-in user — console-rbac.yaml binds get on
that ConfigMap to system:authenticated — but it still depends on the
kube-api being reachable and on the apiserver authenticating the
session's token. A dashboard login whose token the apiserver rejects (an
OIDC session the kube-api does not accept) gets no branding at all.

Mount that ConfigMap read-only under the nginx web root and serve it at
/branding/config.json, and read that static asset first in loadConfig,
falling back to the kube-api ConfigMap for charts that predate the mount
(their static path serves the SPA index.html, whose non-JSON body trips
the fallback). Branding then resolves with no kube-api call at all, so it
renders regardless of whether the apiserver authenticates the session and
without the console-config-reader RBAC.

Because config.json is now a static asset reachable by anyone past the
proxy, restrict it to the documented white-labeling keys: the branding
map also carries Keycloak-only keys, including loginThemeImage, which can
point at a private registry. Keycloak reads those from _cluster.branding
directly. The mount uses an items selector so only config.json, not any
other key the ConfigMap may grow, is exposed under the web root.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-18 18:04:11 +03:00
Aleksei Sviridkin eef5fe6908 fix(platform): declare the injected _cluster values in system charts
Ten system charts cannot be rendered on their own. They read the
platform-injected _cluster channel, and two of them (bucket,
monitoring) also read _namespace, without declaring either key in
values.yaml. On a cluster the platform supplies both through the
cozystack-values Secret, so installs are unaffected, but rendering a
chart by itself dies before a single template is emitted: on "index of
untyped nil" for a missing _cluster, and on "nil pointer evaluating
interface {}.host" for a missing _namespace.

Declare the keys with empty defaults. Helm merges chart defaults under
the supplied values, so an injected _cluster still wins and rendered
output does not change. Each of the ten also gets a suite that renders
with nothing injected; drop a declaration again and that chart's suite
goes red.

This makes `helm template` succeed for all ten. `helm lint` also
reports a missing cozy-lib dependency on keycloak and monitoring,
which is a separate pre-existing defect and survives this change.

etcd-operator reads _cluster too and stays undeclared here: its single
call site already guards with `| default dict`, so the chart renders
standalone as it is.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-07 02:23:47 +03:00
Aleksei Sviridkin f24b56327b fix(platform): route OIDC backend calls through the in-cluster Keycloak by default
authentication.oidc.keycloakInternalUrl defaulted to an empty string, which
left oauth2-proxy performing OIDC discovery against the external Keycloak
hostname. That request resolves through public DNS and lands on the root
ingress controller, which does not serve yet on a fresh install: the root
tenant's IngressClass only exists once tenant-root is ready, and tenant-root
is still reconciling while the dashboard starts. Discovery has nothing to talk
to, the gatekeeper fails, and the dashboard never comes up.

The bundled Keycloak is the only supported provider — the external hostname is
templated as keycloak.<host> and the realm is fixed — so backend-to-backend
calls have no reason to leave the cluster. Default the value to
http://keycloak-http.cozy-keycloak.svc:8080/realms/cozy, the same Service
address keycloak-configure already uses. The dashboard and linstor-gui
gatekeepers then run with --skip-oidc-discovery and send token, jwks, userinfo
and logout requests to the Service, while browser redirects stay on the
external host. Those calls travel as plaintext HTTP between pods, as
keycloak-configure's admin calls to the same address already do.

The value remains overridable for a Keycloak that does not live at the standard
in-cluster address, and setting it back to an empty string restores external
discovery. Existing OIDC clusters that never set it get a gatekeeper rollout
onto the internal path on upgrade.

Enabling OIDC swaps the dashboard's token-proxy container for oauth2-proxy, so
the E2E install now drives that swap to completion: it waits for the auth-proxy
container to appear, then gates on the gatekeeper rollout. Readiness alone
proves nothing there — the HelmRelease is still Ready from the token-proxy
install, and the Deployment keeps the old ReplicaSet Available while a new pod
crashloops, so only the rollout catches a regression back to external discovery.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-14 14:22:46 +03:00
Aleksei Sviridkin 9e3ae7447b fix(ingress): keep tls section in operator wildcard mode
Dropping the whole tls block in wildcard mode disabled the forced
HTTP-to-HTTPS redirect: ingress-nginx only issues the 308 redirect when
TLS is enabled for the Ingress, while a host without a tls section is
still served on 443 with the default certificate but stays reachable
over plain HTTP. Keep the tls section with the host list and omit only
the per-host secretName, so ingress-nginx falls back to the operator
wildcard configured as the default SSL certificate and the redirect
stays enforced, matching the non-wildcard behaviour.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-06-10 03:18:42 +03:00
Aleksei Sviridkin b1f5d9579c feat(ingress): serve operator wildcard via default SSL certificate
When publishing.certificates.wildcardSecretName is set, the root
tenant's ingress-nginx controller now serves the operator-supplied
wildcard Secret as its --default-ssl-certificate, and the platform
service Ingresses stop requesting their own per-host ACME certificates.

The flag is set only on the publishing controller (Release.Namespace ==
expose-ingress) because ingress-nginx loads the Secret from its own
namespace; child-tenant controllers cannot reach a Secret in another
namespace, so they keep the existing ACME path.

Each service Ingress (dashboard, keycloak, grafana, alerta, bucket,
linstor-gui, harbor, seaweedfs S3, bootbox) drops its cert-manager
cluster-issuer annotation, the http01 challenge-class annotation, and
its tls block in wildcard mode; nginx then falls back to the default
certificate for those hosts. Other annotations and routing are
unchanged. Each file keeps its existing render guard intact, and the
annotations block collapses cleanly where the cert annotations were its
only content.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-06-10 03:08:41 +03:00
Aleksei Sviridkin 2869f1554d fix(dashboard): unblock token-proxy startup when JWKS is briefly unreachable (#2745)
<!-- Thank you for making a contribution! Here are some tips for you:
- Use Conventional Commits for the PR title: `type(scope): description`
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
- Scopes are not an exhaustive list — pick the most specific scope for
the change and extend the list when a genuinely new area appears.
Examples:
- System components: dashboard, platform, operator, cilium, kube-ovn,
linstor, fluxcd, cluster-api
- Managed apps: postgres, mariadb, redis, kafka, clickhouse,
virtual-machine, kubernetes
- Development and maintenance: api, hack, tests, ci, docs, maintenance
- Breaking changes: append `!` after type/scope (`feat(api)!: ...`) or
add a `BREAKING CHANGE:` footer
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

Closes #2741.

token-proxy panicked on every cold start where the in-cluster JWKS
endpoint at `kubernetes.default.svc` was briefly unreachable — exactly
the window where CoreDNS, the CNI, or the apiserver endpoint is still
warming up. The pod entered CrashLoopBackOff, the dashboard HelmRelease
never installed, and every release that depended on dashboard stalled
behind it.

Two startup paths touched the network synchronously:
`jwk.Cache.Register` with the default `WaitReady(true)` and an explicit
follow-up `Refresh`, both wrapped in `panic`. `WaitReady(true)` had no
built-in timeout, so a permanently unreachable JWKS would have turned
the panic into an unbounded startup wait rather than fixing anything.

This PR:

- Moves JWK cache construction out of `init()` into a testable function.
- Passes `WithWaitReady(false)` on `Register` and drops the redundant
`Refresh`. Cache setup now returns immediately; the background worker
keeps retrying.
- Lowers `MinInterval` from 5 minutes to 5 seconds. A single bad
response from the apiserver no longer holds the pod NotReady for several
minutes.
- Wires `httprc.WithErrorSink` so background fetch failures show up in
the container log instead of being invisible behind a 503.
- Adds `/healthz/live` (process up) and `/healthz/ready` (JWK cache
populated) plus a `startupProbe` so a genuinely misconfigured JWKS
eventually surfaces as `RestartCount`/`CrashLoopBackOff` instead of a
silently NotReady pod.
- Disambiguates the cold-start window from a real bad token at the
sign-in handler via an `errJWKSNotReady` sentinel.

The auth-proxy (`oidc-enabled=true`) branch keeps its existing `/ping`
probes unchanged.

### Testing

- Go test coverage: WaitReady(false) contract, background-worker
recovery from transient HTTP 5xx (no explicit Refresh — the real
production path), lazy population, catastrophic-misconfig surface
(missing/malformed CA still errors hard), errJWKSNotReady
disambiguation, SA token injection, and the /healthz/ready 503→200
transition.
- helm-unittest pins the gatekeeper template structure for both
`oidc-enabled` branches so a future refactor that collapses the if/else
differently fails the suite.
- `make test` now runs both `helm unittest` and `go test`, picked up
automatically by `hack/helm-unit-tests.sh`.

### Screenshots

N/A — no UI changes.

### Release note

```release-note
fix(dashboard): token-proxy no longer crash-loops when the in-cluster JWKS endpoint is briefly unreachable during a cold install; readiness now reflects JWK cache state and a startupProbe surfaces permanent JWKS misconfig as CrashLoopBackOff instead of a silently NotReady pod.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added dedicated health check endpoints (`/healthz/live` and
`/healthz/ready`) for better container orchestration integration
* Enhanced health probes with separate startup, liveness, and readiness
checks

* **Tests**
  * Added comprehensive test suite for token-proxy functionality

* **Chores**
  * Updated build system with improved test targets
  * Refined Docker image build configuration for better reliability
  * Updated runtime user ID for cluster parity

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/cozystack/cozystack/pull/2745?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-28 14:18:26 +03:00
Aleksei Sviridkin 2abc7c2ab0 fix(dashboard): unblock token-proxy startup when JWKS is briefly unreachable
token-proxy panicked at startup whenever the very first JWKS fetch
against kubernetes.default.svc failed, which is routine on a fresh
install: CoreDNS, the CNI, or the API-server endpoint may not be ready
in the seconds between the pod scheduling and the cache initialization
running. The pod then entered CrashLoopBackOff with exit code 2 and no
logs, the dashboard HelmRelease never installed, and every release
that depended on it stalled behind it.

Two paths into init() touched the network synchronously: jwk.Cache.Register
with the default WaitReady(true), and an explicit follow-up Refresh.
WaitReady has no built-in timeout, so a permanently-unreachable JWKS
would turn the panic into a different failure mode (an unbounded
startup wait) rather than fixing it. The explicit Refresh on top of
Register was redundant.

Drop the redundant Refresh and pass WithWaitReady(false) on Register
so the cache setup returns immediately. The background worker keeps
retrying: a pure network failure (DNS, dial, TLS) skips Sync's SetNext
and falls back to httprc's 1-second tick, while an HTTP error response
(apiserver 5xx, RBAC 403) honors MinInterval — lower it from 5 minutes
to 5 seconds so a single bad response can no longer hold the pod
NotReady for several minutes. Move the cache construction out of
init() into a testable function; init() now only registers flags.

Background fetch failures are wired into the container log via
httprc.WithErrorSink so a persistent JWKS misconfig produces a
recognisable stdout signature instead of being invisible behind the
readiness probe.

To prevent the new failure mode (silently broken sign-in when JWKS is
permanently misconfigured) from regressing observability, expose
/healthz/live and /healthz/ready: liveness is process-up; readiness
calls jwkCache.Lookup so a permanent misconfig surfaces as 0/1 Ready
and keeps the pod out of service endpoints. A startupProbe pointed at
/healthz/ready with a generous failure budget (~5 minutes) serves as
a backstop: a genuinely stuck pod eventually restarts so the operator
sees RestartCount/CrashLoopBackOff instead of a silently NotReady pod
that nothing escalates. The oauth2-proxy branch keeps its own /ping
probes unchanged.

The sign-in handler distinguishes errJWKSNotReady from a real invalid
token: during the cold-start window users see "Service is starting,
please retry in a moment" instead of the misleading "Invalid token".

Tests cover: WaitReady(false) contract (Register returns in <1s
against unreachable JWKS), background-worker recovery from transient
HTTP 5xx without an explicit Refresh (the production cold-start path),
lazy population, catastrophic-misconfig surface (missing/malformed CA
still returns hard error), errJWKSNotReady disambiguation, SA token
injection, and /healthz/ready 503-then-200 transition. The package
Makefile gains a test-go target wired into the existing 'test' target
so hack/helm-unit-tests.sh picks up the new Go tests automatically.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-05-26 18:06:45 +03:00
Aleksei Sviridkin adddd4f6e1 feat(dashboard): add cluster-usage RBAC for the new admin page
The cluster-usage admin page in the dashboard reads cluster-scoped
resources (nodes, pods, metrics.k8s.io) to render aggregate utilisation
and a per-node table that includes arbitrary extended resources such as
GPUs. The built-in 'edit' ClusterRole already bound to
cozystack-cluster-admin via cozystack-dashboard-cluster-admin does not
grant cluster-wide pod read or any access to 'nodes', so the dashboard
needs an explicit grant for the page to render anything beyond an error.

The page-side sidebar item is gated by SelfSubjectAccessReview on
'nodes list' — without this binding the gate fails closed and the entry
stays invisible, which is the desired fallback for clusters that have
not adopted the new RBAC yet.

The chart intentionally does not add permissions for deeper sources
(Prometheus, DCGM, HAMi). Those belong in their respective packages or
operator-provided RBAC; the page detects them via discovery and gates
each section with its own per-source review.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-05-26 17:25:31 +03:00
Aleksei Sviridkin d2e851f63b feat: Gateway API support via Cilium
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>
2026-05-25 17:07:02 +03:00