design-proposal: structured, additive external exposure for managed applications#29
design-proposal: structured, additive external exposure for managed applications#29Andrei Kvapil (kvaps) wants to merge 2 commits into
Conversation
…pplications Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 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 introduces a design proposal for structured, additive external exposure for managed applications in Cozystack, replacing the binary external boolean with a flexible expose list of {target, class} entries. The feedback highlights several important areas for improvement: expanding the target list for MariaDB, MongoDB, and Redis to support read-replicas; including the actual class name in the endpoint status for better traceability; and addressing a critical TLS verification issue where dynamically allocated external IPs will not match the certificate SANs when TLS is terminated directly at the database pod.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| | clickhouse | `http`, `tcp` | `http` | | ||
| | rabbitmq | `amqp`, `management` | `amqp` | | ||
| | kafka | `bootstrap` | `bootstrap` | | ||
| | mariadb / mongodb / redis | `primary` | `primary` | |
There was a problem hiding this comment.
For databases like MariaDB, MongoDB, and Redis, it is highly common to route read-only traffic to replicas or secondaries to scale read performance. Restricting these engines to only a primary target prevents users from exposing read-replicas externally.
Consider expanding the target list to support replicas, for example:
mariadb:primary,replicamongodb:primary,secondaryredis:master,replica
|
|
||
| ### Status | ||
|
|
||
| The application reports every way to reach it as `status.endpoints[]`, each entry `{target, scope, host, port, tls}` where `scope` is one of `in-cluster`, `public`, or `private`. The in-cluster entry is always present; public/private hosts are read back from the corresponding `ServiceExposure.status.assignedIPs`, never fabricated client-side. For the two-entry Postgres example above: |
There was a problem hiding this comment.
Since ExposureClass names are arbitrary and defined by the cluster administrator (e.g., dmz, office-vpn, public-direct), relying solely on a fixed scope enum (in-cluster, public, private) in status.endpoints[] makes it difficult for consumers to map an endpoint back to its specific ExposureClass. This is especially problematic if there are multiple classes with the same scope or if the scope mapping is not obvious.
Including the actual class (or exposureClass) name in the endpoint status would provide clear traceability.
For example, the endpoint entry could be:
- target: rw
scope: private
class: private-vpn
host: 10.0.0.5
port: 5432
tls: true|
|
||
| ## Security | ||
|
|
||
| **TLS.** External exposure implies TLS — there is no plaintext-external combination. TLS is controlled per engine by a tri-state `tls.enabled`: unset defaults to "on when externally exposed", an explicit value always wins, and termination happens at the database pod itself (passthrough-friendly, never at an edge or gateway). External clients validate against the merged `ca.crt`-only trust anchor, the `<release>-ca-cert` Secret (#2989), surfaced through `core.cozystack.io/tenantsecrets`. TLS is already merged for Postgres, Kafka, NATS, and Qdrant; it is still converging for Redis, MongoDB, RabbitMQ, OpenSearch, and MariaDB (#2729 / #2692 / #2683 / #2682 / #2680). Until those land, the "external implies TLS" guarantee is engine-by-engine, and this convergence is in progress. |
There was a problem hiding this comment.
Terminating TLS directly at the database pod introduces a significant challenge regarding certificate SANs (Subject Alternative Names).
Since the external IP addresses (e.g., 10.0.0.5, 203.0.113.7) are allocated dynamically by the serviceexposure-controller after the database pods are already running, the certificates initially minted for the pods will not contain these external IPs in their SANs. If an external client attempts to connect to these IPs, TLS hostname/IP verification will fail unless the certificates are dynamically re-issued and reloaded by the database engine.
Not all database engines support hot-reloading TLS certificates without a restart, and frequent restarts upon IP allocation/re-allocation are undesirable.
It would be highly beneficial to address how SANs for dynamically allocated IPs/hosts will be handled, or if a DNS-based approach (where the certificate uses a wildcard or stable DNS SAN, and external clients connect via DNS rather than raw IPs) is expected to mitigate this.
…s/ServiceExposure Aligns the expose field with cozystack/cozystack#3218, which removes the network.cozystack.io ExposureClass/ServiceExposure API group. expose[].class now maps to a native Service.spec.loadBalancerClass; each entry renders an additive type: LoadBalancer Service and the address is read from status.loadBalancer.ingress. The dedicated exposure API is recorded as a rejected alternative. Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
myasnikovdaniil
left a comment
There was a problem hiding this comment.
The field shape (expose: [{target, class}]) and the additive-status goal are the right direction, but the design's central mechanism doesn't match how exposure actually works across the managed apps, and the engine table has factual errors. I went through every app under cozystack/cozystack packages/apps/. Three problems, two of them structural:
1. "The chart renders one additive type: LoadBalancer Service per target" is false for most engines. Only postgres, redis, opensearch, and mongodb's sharded-mongos path have a chart-owned Service. For kafka, rabbitmq, mariadb, qdrant, nats, and mongodb's replica-set path the chart only flips a field on an operator CR or subchart values — the operator/subchart owns Service creation. A Helm render reading Service.status.loadBalancer.ingress back can't even attribute which Services it should read. The mechanism section, the mermaid diagram, and the unit-test plan all assume a chart-owned Service that mostly doesn't exist.
2. The advertised-address feedback loop is missing for the discovery engines, and the design removed the only component that could host it. For kafka (Strimzi writes advertised.listeners + rolls), mongodb replica sets (Percona creates one LB per pod and rewrites rs.conf, issue #2514), and nats (connect_urls gossip), the allocated external address must be written back into the engine config and the pods rolled — a loop a Helm render fundamentally cannot do. Today the per-engine operator owns it. This proposal never mentions it, and explicitly removes the ServiceExposure controller (#3164) that was the natural home for it. So for the discovery engines the design either silently delegates to the operator (then say so, and drop "the chart renders the Service") or it has no owner.
3. Factual errors in the engine table. clickhouse and bucket have no external field today — there is no existing external listener to "make selectable", so those rows are net-new exposure work, which contradicts "this proposal does not add new listeners." And the redis app is Sentinel-based redis-failover fronting the master directly — not Redis Cluster — so it has no cluster-announce-ip, and the primary row conflates a Class-B engine (mongodb) with two Class-A ones (mariadb, redis).
What I'd want before this is mergeable: (a) split the engines into a passthrough class (single endpoint, report the IP — postgres/mariadb/rabbitmq/redis/opensearch/qdrant) and a discovery class (advertised address + per-peer fan-out + roll — kafka/mongodb-RS/nats), and design each explicitly rather than as one uniform mechanism; (b) name who owns the read-back-and-reconfigure loop for the discovery class (operator delegation vs a controller), given the boolean's own controller was removed; (c) correct the table (clickhouse/bucket = new capability; redis = Sentinel not Cluster; split the primary row). Inline detail on each spot below.
| | postgres | `rw`, `ro` | `rw` | | ||
| | clickhouse | `http`, `tcp` | `http` | | ||
| | rabbitmq | `amqp`, `management` | `amqp` | | ||
| | kafka | `bootstrap` | `bootstrap` | |
There was a problem hiding this comment.
bootstrap as a single target doesn't model Kafka external access. external: true today flips a Strimzi listener type: loadbalancer (packages/apps/kafka/templates/kafka.yaml:37-42); Strimzi then creates a bootstrap LB plus one LB per broker, reads the allocated IPs, writes them into each broker's advertised.listeners, and rolls the pods. So (a) it isn't one Service, it's a bootstrap + N-broker fan-out, and (b) the reachable set isn't a single bootstrap host — a client that only has bootstrap is redirected to per-broker addresses that must themselves be externally advertised. Neither the per-broker fan-out nor the advertised-address write-back is expressible in {target, class} → one Service.
| | clickhouse | `http`, `tcp` | `http` | | ||
| | rabbitmq | `amqp`, `management` | `amqp` | | ||
| | kafka | `bootstrap` | `bootstrap` | | ||
| | mariadb / mongodb / redis | `primary` | `primary` | |
There was a problem hiding this comment.
This row collapses three engines that behave differently, and one of them is a Class-B discovery engine the uniform model can't handle:
- mongodb — the replica-set path flips
replsets[].exposeon the Percona CR (packages/apps/mongodb/templates/mongodb.yaml:106-113); the operator creates one LB per pod and rewritesrs.confso external drivers can discover the primary. The in-repo comment says exactly this (packages/apps/mongodb/templates/external-svc.yaml:1-8, issue #2514). Per-replica fan-out + config write-back + roll — a singleprimarytarget hides all of it. - redis — this is Sentinel-based
redis-failover, not Redis Cluster (redisfailover.yamltype: sentinel; Service selectorredisfailovers-role: master,redis/templates/service.yaml:22). Nocluster-announce-ip; the LB fronts the master directly. Class A, but for a different reason than the table implies. - mariadb — operator flips
primaryService.type: LoadBalancer(packages/apps/mariadb/templates/mariadb.yaml:87-90), single primary Service, no redirect. Class A.
Folding B (mongodb) together with A (mariadb, redis) under one primary row is exactly where the uniform model buries the hard case.
| | kubernetes | `api` | `api` | | ||
| | bucket (S3) | `s3` | `s3` (always public; special-cased) | | ||
|
|
||
| The set is exactly what each engine can already expose — this proposal does not add new listeners, it makes the existing ones individually selectable. |
There was a problem hiding this comment.
"The set is exactly what each engine can already expose … does not add new listeners" doesn't hold for two rows in the table:
- clickhouse — there is no
externalkey in the chart at all (packages/apps/clickhouse/renders only a ClusterIPsvc-template); thehttp/tcptargets are net-new exposure, not existing listeners made selectable. - bucket — no
externalkey either; S3 reach is via the platform's shared ingress, not a per-app LB.s3"always public; special-cased" is new behavior.
These should be framed and designed as new capability, not as a refactor of an existing boolean.
|
|
||
| `class` is a native `Service.spec.loadBalancerClass` value, not a fixed API enum. It names which LoadBalancer controller and address pool should handle the Service — built into the core Kubernetes API, the same way a `PersistentVolumeClaim` names a `StorageClass`. By convention an admin provisions pools named `public` and `private`, but the names are arbitrary and the mechanism behind each is whatever LoadBalancer controller the admin has wired to that class (MetalLB, Cilium LB-IPAM, a cloud LB). Omitting `class` lets the cluster's default LoadBalancer controller claim the Service. | ||
|
|
||
| For each `expose` entry the chart renders one additive `type: LoadBalancer` Service for the selected `target`, with `spec.loadBalancerClass` set to `class` when given (and omitted otherwise, to fall through to the default controller). This Service is added alongside the untouched in-cluster `ClusterIP` Service. |
There was a problem hiding this comment.
This is the load-bearing claim of the design, and it's false for most engines in the table. Chart-owned hand-written Service: only postgres (packages/apps/postgres/templates/external-svc.yaml), redis (redis/templates/service.yaml), opensearch (opensearch/templates/external-svc.yaml), and mongodb's sharded-mongos path. For kafka, rabbitmq, mariadb, qdrant, nats, and mongodb's replica-set path the chart only flips a field on an operator CR (Strimzi / RabbitmqCluster / MariaDB / Percona) or on subchart values (nats, qdrant) — the operator or subchart creates the Service(s).
Two consequences: class → Service.spec.loadBalancerClass has to be threaded through each operator's own listener/service config, not set on a chart Service; and a render that "reads Service.status.loadBalancer.ingress back" can't know which operator-created Services to read. The mechanism, the mermaid diagram, and the unit-test plan ("expose entries render the expected additive Services") all assume an ownership model that doesn't hold.
|
|
||
| ### Status | ||
|
|
||
| The application reports every way to reach it as `status.endpoints[]`, each entry `{target, scope, host, port, tls}` where `scope` is one of `in-cluster`, `public`, or `private`. The in-cluster entry is always present; public/private hosts are read back from the corresponding `Service.status.loadBalancer.ingress`, never fabricated client-side. For the two-entry Postgres example above: |
There was a problem hiding this comment.
The address flows one way here — out of Service.status into status.endpoints[] for reporting. For the discovery engines it must also flow the other way: the allocated address has to be written into the engine config and the pods rolled, or the reported endpoint is reachable at the first hop and dead on the redirect (kafka advertised.listeners; mongodb rs.conf, see mongodb/templates/external-svc.yaml:1-8; nats connect_urls). That write-back-and-roll step is absent.
Separately: status.endpoints[] is entirely new machinery — no chart reads Service.status.loadBalancer.ingress back today — so the reporting half is unbuilt for every engine, not only the hard ones. Worth stating what component populates it.
|
|
||
| ## Security | ||
|
|
||
| **TLS.** External exposure implies TLS — there is no plaintext-external combination. TLS is controlled per engine by a tri-state `tls.enabled`: unset defaults to "on when externally exposed", an explicit value always wins, and termination happens at the database pod itself (passthrough-friendly, never at an edge or gateway). External clients validate against the merged `ca.crt`-only trust anchor, the `<release>-ca-cert` Secret (#2989), surfaced through `core.cozystack.io/tenantsecrets`. TLS is already merged for Postgres, Kafka, NATS, and Qdrant; it is still converging for Redis, MongoDB, RabbitMQ, OpenSearch, and MariaDB (#2729 / #2692 / #2683 / #2682 / #2680). Until those land, the "external implies TLS" guarantee is engine-by-engine, and this convergence is in progress. |
There was a problem hiding this comment.
Pod-terminated TLS + a dynamically allocated address is the same chicken-and-egg as advertised.listeners: the address must land in the cert SAN, i.e. be known before the cert is minted. This is already engine-specific, not uniform — postgres appends a DNS name (not the dynamic IP) to spec.certificates.serverAltDNSNames (packages/apps/postgres/templates/db.yaml:23-26), and Strimzi bakes per-broker advertised hosts into the broker certs after allocation. With cert issuance declared a non-goal (line 47), the design needs to state which component closes the SAN gap per engine; it can't be a single uniform rule. (Gemini's automated review flagged this independently.)
|
|
||
| **A raw `metallb.io/address-pool` annotation on the Service.** Rejected. It is mechanism-specific (MetalLB only), invisible to status, and unable to model anything but a single pool. Native `loadBalancerClass` subsumes it — a built-in, mechanism-agnostic field (MetalLB, Cilium LB-IPAM, and cloud LBs all honor it) whose result is reported back through `status.loadBalancer.ingress`. | ||
|
|
||
| **A dedicated `network.cozystack.io` exposure API (`ExposureClass` / `ServiceExposure`).** Tried (#3081) and removed (#3164 / cozystack/cozystack#3218) as redundant for the managed-app path — the chart already owns the Service, so native `type: LoadBalancer` + `loadBalancerClass` + an admin pool covers it without a second tenant-networking API group next to `sdn.cozystack.io`. |
There was a problem hiding this comment.
Removing the ServiceExposure controller is the crux of problems 1 and 2 in the summary. A controller is the natural (and only) home for the allocate → read-back → reconfigure → roll loop, and for reading operator-created Service status back into status.endpoints[], because it can watch and act. A Helm render can't wait for the LB IP and then re-render the CR/StatefulSet with the advertised address baked in.
Dropping the controller doesn't make that work disappear — it pushes it onto the per-engine operators for the discovery engines (unstated), and leaves the chart-owned engines' status read-back with no owner at all. "The chart already owns the Service" is true for a minority; for the rest, this removed the component that made the model implementable.
What
Adds a design proposal under
design-proposals/structured-external-exposure/that replaces the chart-level booleanexternal: true|falseon managed applications with a structured, additiveexposefield — a list of{target, class}entries.Why
The boolean is a dead end: it publishes exactly one endpoint, offers no way to choose the address source, cannot select which named listener of an engine is published, and reports its result by mutating the in-cluster connection details in place. Each chart also implements it slightly differently, so there is no shared model or status contract.
The model
expose: []— a list of{target, class}entries. Absent or empty means in-cluster only. The in-clusterClusterIPService is always present and never touched; every entry is additive.target— a per-engine closed enum derived from the engine's existing named listeners (Postgresrw/ro, ClickHousehttp/tcp, and so on); omitted selects the engine's primary listener.class— a reference to an admin-definedExposureClass(StorageClass-like, #3081); omitted selects the cluster default. Each entry renders an additiveClusterIPService plus aServiceExposure, and the controller allocates the external address and reports it.status.endpoints[]—{target, scope, host, port, tls}, resolved fromServiceExposure.status.assignedIPs, never fabricated client-side.ca.crt-only trust anchor (#2989).SecurityGroup(#2922), with the deny/allow posture owned by the consuming orchestrator, not Cozystack core.external: truestays accepted as a deprecated alias for a single primary entry.Scope
Cozystack side only — the chart API, the rendered objects, and the reported status. It builds entirely on already-merged primitives (#3081, #2989, #2922, #2470) and stays forward-compatible with future Gateway/SNI endpoint consolidation (community #20) without implementing it.
Status
Draft — opening for early design feedback before the API shape is locked.