Repository navigation
design-proposal: application endpoints and EndpointAttachment - #45
Timofei Larkin (lllamnyp) wants to merge 4 commits into
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📝 WalkthroughWalkthroughAdds a design proposal for a namespaced ChangesEndpointAttachment design proposal
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@design-proposals/endpoint-attachments/README.md`:
- Around line 78-80: Update the application identity used by authorization to
include the API group alongside kind and name, and ensure the resolver compares
all three fields. Apply this consistently to the applicationRef examples and
related authorization flow, or explicitly define and validate a single allowed
group; prefer binding to the application UID to prevent delete/recreate reuse.
- Around line 86-94: Resolve the unattainable Attached phase value in the status
definition by either documenting the lifecycle transition and finalizer behavior
that emits Detached, or removing Detached from the phase enum and its Pending |
Attached | Detached comment. Keep endpoint-loss behavior represented through
Resolved=False and resource deletion semantics consistent.
- Around line 104-105: Update the Render design to make the attachment UID in
the controller owner reference the authoritative identity for the generated
LoadBalancer Service. Use the ownership label only for indexing or diagnostics,
and require reconciliation to verify the Service’s controller owner reference
points to the attachment before adopting or updating it; otherwise ignore it and
create or rediscover the correctly owned Service.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: dd691aa7-3010-4ff4-b440-29580c3c0eca
📒 Files selected for processing (1)
design-proposals/endpoint-attachments/README.md
| applicationRef: # immutable; the application, in this namespace | ||
| kind: Postgres | ||
| name: mydb |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Include the API group in the application identity used for authorization.
applicationRef contains only kind and name, and the resolver compares only those two fields, while the proposal says lineage labels also include .group. Unless a single application group is hard-coded and validated, same-kind/name resources from another group could satisfy the authorization check. Add the group to the reference—or explicitly constrain it—and compare all identity fields; binding to the application UID would also prevent reuse after delete/recreate.
Also applies to: 102-103, 159-162
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@design-proposals/endpoint-attachments/README.md` around lines 78 - 80, Update
the application identity used by authorization to include the API group
alongside kind and name, and ensure the resolver compares all three fields.
Apply this consistently to the applicationRef examples and related authorization
flow, or explicitly define and validate a single allowed group; prefer binding
to the application UID to prevent delete/recreate reuse.
| status: | ||
| phase: Attached # Pending | Attached | Detached | ||
| serviceName: mydb-replicas-public-x7ktq # the rendered LoadBalancer Service (generateName) | ||
| addresses: | ||
| - "203.0.113.7" | ||
| conditions: | ||
| - type: Resolved # applicationRef + endpoint resolve to a live, lineage-labeled Service | ||
| - type: Provisioned # claim bound, LB Service has its address | ||
| ``` |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Define or remove the Detached phase.
No lifecycle path emits Detached: deletion removes the resource, while endpoint loss leaves it present with Resolved=False. Specify the transition/finalizer semantics or remove this unreachable status value.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@design-proposals/endpoint-attachments/README.md` around lines 86 - 94,
Resolve the unattainable Attached phase value in the status definition by either
documenting the lifecycle transition and finalizer behavior that emits Detached,
or removing Detached from the phase enum and its Pending | Attached | Detached
comment. Keep endpoint-loss behavior represented through Resolved=False and
resource deletion semantics consistent.
| 3. **Render.** Create one additive `type: LoadBalancer` Service via `generateName` (prefixed with the attachment's name), owned by the attachment and found again through an ownership label — never by name, so an attachment name can never collide with an existing Service. The Service **copies the selector and ports of the resolved endpoint Service** and stays in sync with them; this is what makes the mechanism engine-agnostic, and it is the structural answer to why #29's chart-side mechanism could not work: the controller does not need to know how CloudNativePG or mariadb-operator label their pods, it only needs to mirror the Service they already maintain. The Service carries the substrate's consumption annotation `local.sdn.cozystack.io/ip-address-claim: <claim>`; the per-class driver does the rest (pin, announce, associate). `externalTrafficPolicy: Local` and node-port allocation follow the platform's existing conventions. | ||
| 4. **Report.** `status.addresses` mirrors the claim's bound addresses; `status.serviceName` names the rendered Service; conditions say why anything is missing. Nothing is fabricated client-side. |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Use the owner reference as the rendered-Service identity.
The design says the Service is owned by the attachment but rediscovered through an ownership label. Labels are mutable and can collide, so a stale or tenant-created Service could be selected, while label removal could cause duplicate LoadBalancer Services. Reconcile only a Service whose controller owner reference points to the attachment UID; use the label only as an index or diagnostic.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@design-proposals/endpoint-attachments/README.md` around lines 104 - 105,
Update the Render design to make the attachment UID in the controller owner
reference the authoritative identity for the generated LoadBalancer Service. Use
the ownership label only for indexing or diagnostics, and require reconciliation
to verify the Service’s controller owner reference points to the attachment
before adopting or updating it; otherwise ignore it and create or rediscover the
correctly owned Service.
myasnikovdaniil
left a comment
There was a problem hiding this comment.
Hi Timofei Larkin (@lllamnyp)!
I've read your design and find some issues,
Main blocker for now - this design not mention accepted database exposure design that may contradicts with yours at some places, take a look please.
Non-blocking follow-ups
-
Mutation semantics of
spec.loadBalancerare undefined (line 78-83).spec.applicationRefis immutable via CEL andclassName/claimNameare mutually exclusive, but nothing states what happens onclassName→ a different class,claimName→ a different claim, or a switch between the two members. Each of those silently changes the address a client may have allow-listed — the exact failure named in What exists today ("Turningexternaloff and on may change the address a customer has allow-listed"). Either declarespec.loadBalancerimmutable alongsideapplicationRef, or specify the re-mint and release semantics. -
The API group choice is what buys the quota claim; say so.
count/endpointattachments.cozystack.io(line 153) works becausecozystack.iois CRD-served by the kube-apiserver (packages/system/cozystack-controller/definitions/cozystack.io_workloadmonitors.yaml), matching the existingcount/certificates.cert-manager.iopattern documented atpackages/core/platform/values.yaml:327.SecurityGroup— the resource this one is modelled beside — instead lives insdn.cozystack.io, served by the aggregatedcozystack-api(pkg/registry/sdn/securitygroup/rest.go, no CRD underdefinitions/), where the kube-apiserver's ResourceQuota admission never runs. A later move tosdn.cozystack.iofor group consistency would silently drop stock quota support, so the coupling deserves a sentence. -
Does any platform path delete and recreate an application's HelmRelease under the same name? Lifecycle and garbage collection hangs attachment GC on an
ownerReferenceto the application's HelmRelease. Kubernetes GC matches owners by UID, so a same-name recreate orphans and collects every dependent — here, tearing down tenant exposure and releasing minted addresses. The platform already uses delete-and-let-the-chart-recreate for system releases (packages/core/platform/images/migrations/migrations/20,.../25:15-17); I could not find that pattern applied to a tenant application HelmRelease, so this is a question rather than a finding. If it is reachable, the design needs a finalizer or a name-based fallback. -
spec.applicationRefshould carry the group, per existing precedent. The precedent:SecurityGroup'sApplicationReferencealready resolves on all three of group, kind and name (pkg/registry/sdn/securitygroup/rest.go:67-69), and the proposal's own resolver text names only.kindand.namewhile the labels it reads include.group. -
status.phaseandstatus.conditionsis duplicating info? how conditions list managed if it not have timestamps or some identification for list items?
| - type: Provisioned # claim bound, LB Service has its address | ||
| ``` | ||
|
|
||
| `spec.applicationRef` is immutable (CEL); retargeting is delete-and-recreate. `spec.endpoint.serviceName` is required — a struct rather than a bare string so that a future named-endpoint vocabulary (`endpoint: {name: ro}`) can join it as an alternative member. Within `loadBalancer`, `className` and `claimName` are mutually exclusive (CEL); both absent means "mint from the default class". An optional `family` (`IPv4|IPv6|Dual`, defaulting per the substrate) passes through to a minted claim. |
There was a problem hiding this comment.
You are identifying spec.endpoint.serviceName here in comment as struct but example shows plain string.
|
|
||
| ## Open questions | ||
|
|
||
| 1. **Port subsetting.** An attachment currently mirrors all ports of the endpoint Service. Is a `ports` filter (publish 5432 but not metrics) worth having in v1, or does it invite divergence from the endpoint definition? |
There was a problem hiding this comment.
I think it is charts part to address (do not expose mixed services that serve data+ui or data+metrics), not this endpoint attachments responsibility.
|
|
||
| 1. **Port subsetting.** An attachment currently mirrors all ports of the endpoint Service. Is a `ports` filter (publish 5432 but not metrics) worth having in v1, or does it invite divergence from the endpoint definition? | ||
| 2. **Family/dual-stack surface.** Pass `family` through to minted claims only, or also validate it against a referenced claim's family at resolve time? | ||
| 3. **Attachment identity in the dashboard.** Attachments are the user-visible record of "how is my app reachable" — does the dashboard list them per application via a label, and should the controller stamp `apps.cozystack.io/application.*` lineage labels on attachments themselves for symmetry? |
There was a problem hiding this comment.
Looks like this endpoint attachment resources is not application - it can't be created by itself without other app to attach. So it is first option or maybe define new category of resources, something like "applicationaddons"
|
|
||
| `external: true` keeps working unchanged, and its sunset is explicitly gated on an address-preserving migration path. | ||
|
|
||
| ## Scope and related proposals |
There was a problem hiding this comment.
You did not mentioned already accepted proposal for external exposure of databases. How do you think both designs will coexist? Accepted design address #29 as owner of exposure mechanics, and you superseded it, so now you need to resolve claims made to it. Also I see problem in idea: you making app-agnostic additive exposure, accepted design making app based exposure - certificates for tls sni based routing and encryption must be emitted by app itself, so it is not additive.
|
|
||
| One controller, engine-agnostic, reconciling four things per attachment: | ||
|
|
||
| 1. **Resolve.** Look up `spec.endpoint.serviceName` in the attachment's namespace and require that the Service carries lineage labels matching `spec.applicationRef` (`apps.cozystack.io/application.kind` and `.name`, stamped today by the lineage webhook from `ApplicationDefinition.spec.services`). This is the authorization seam: a tenant can attach only to Services the platform has already marked as tenant-facing endpoints of that application, never to arbitrary or foreign Services. Failure → `Resolved=False` with a reason; the attachment waits. Validation is by condition, not admission, because the Service set is dynamic (a mariadb `-secondary` Service exists only while `replicas > 1`) — eventual consistency, not admission-time races. |
There was a problem hiding this comment.
internal/lineagecontrollerwebhook/webhook.go:159-174 stamps apps.cozystack.io/application.{group,kind,name} on every object resolving to a managed HelmRelease. ApplicationDefinition.spec.services feeds a different label at lines 180-189 — internal.cozystack.io/tenantresource — and that is the tenant-facing marker (default-deny, matcher.go:65-73). Since the allowlists are strict subsets (kafka declares exactly one Service name, clickhouse one), the specified check would admit operator-internal and headless Services to a public address. Fix is a one-word change: gate on internal.cozystack.io/tenantresource: "true".
|
|
||
| ## Security | ||
|
|
||
| - **The lineage-label check is the authorization boundary for what may be exposed.** A tenant can attach only to Services in their own namespace that the platform's lineage webhook has labeled as tenant-facing endpoints of the named application. Attaching to unlabeled, system, or foreign Services fails `Resolved`. All references (`applicationRef`, `serviceName`, `claimName`) are same-namespace by construction. |
There was a problem hiding this comment.
Here is the correct wording for the L102 labels problem
myasnikovdaniil
left a comment
There was a problem hiding this comment.
Forgot to request changes in previous review message. Doing now
Align the document with what is built and argue the group choice on the record rather than leaving it in a commit message of another repository. - API group: ipam.cozystack.io -> local.sdn.cozystack.io throughout, including the association annotation, the default-class annotation and the example provisioner name, which now match what the controllers actually ship. Positioning carries the argument: Cozystack networking is already sdn.cozystack.io (SecurityGroup, served by the Cozystack API), cozyplane serves its own SecurityGroup there plus local.sdn.cozystack.io for its CRD-served kinds, and addresses are the third networking feature to want a home. One sdn family, with the local. prefix marking CRD-served kinds and the bare group the aggregated ones; ipam.cozystack.io would add a fourth name to a subject area that should have one. Stated plainly as a proposal, not a precedent: nothing serves local.sdn.cozystack.io today. - New Implementation status section: the core controller and the MetalLB reference driver exist, and cozyplane consumes reservations through the contract with no module import, no CRD dependency and no informer on the claim kinds -- the association seam is exercised by a second consumer, while Rollout phase 2, a second provisioner, remains outstanding. The ValidatingAdmissionPolicy is recorded as implemented nowhere and still part of phase 1. - The stub banner no longer says the proposal predates any code, and Rollout phases 1 and 2 say what exists. - Scope records that community #45 depends on this proposal, which does not wait on it. - Directory renamed public-ip-claims -> ip-address-claims; the kinds lost the "public" prefix two revisions ago. Assisted-By: Claude Opus 5 Signed-off-by: Timofei Larkin <[email protected]>
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
The document is thorough, and the two structural choices are right: mirroring the endpoint Service's selector and ports is what makes the mechanism engine-agnostic without per-chart render logic, and the lineage-label check is a real authorization seam rather than a stated intention. Alternatives considered is honest about why this is not ServiceExposure repeated, which was the obvious objection.
myasnikovdaniil's blocker is correct, and I want to sharpen it, because it is more than a missing cross-reference. Then a concrete requirement from our side that I think changes the scope.
1. The accepted design this collides with is community #20, and it is merged
design-proposals/external-database-exposure opens with the problem statement: "Today every managed database a tenant exposes externally gets its own LoadBalancer Service, and therefore its own public IP." Its goal is to collapse a tenant's databases — including several instances of the same engine — onto the single LoadBalancer IP of the tenant Gateway, routed by SNI.
EndpointAttachment renders one additive LoadBalancer Service per attachment, each drawing its own address. That is the pattern #20 was accepted to replace. Scope and related proposals lists #35, #29, #3218, #2922, unified-TLS and Gateway API — and not #20.
I do not think these conflict as designs: SNI consolidation for the common case, a dedicated address for the tenant whose client allow-lists an IP or whose driver cannot do SNI. But the document has to say so, because as written it reads as the general answer to external exposure, and the general answer was decided differently.
2. This proposal inherited an obligation from #29 and does not know it
#20's User-facing changes says its trigger is "an external-adjacent toggle to select passthrough/SNI mode (expected to become an expose entry once the structured-exposure model lands)". #20 was written expecting #29 to be its user-facing surface. This proposal declares itself #29's successor — so it is now that surface.
The practical consequence today: a tenant cannot request SNI exposure at all. #29 is closed, and the Gateway union member here is "future work, not designed here". The merged design has no trigger. That reframes the union member: it is not speculative, it is specified elsewhere and waiting.
3. The hostname non-goal points past the accepted answer
Non-goals sends hostnames, DNS and certificates to the unified-TLS effort. For databases, #20 already settled the SNI hostname scheme and the end-to-end TLS story. Deferring to unified-TLS is right for the PKI half and misses that the naming half for this exact case is decided.
4. A question about the mirroring contract
Failure and edge cases says a changed endpoint selector is "mirrored on the next reconcile". That is safe where an operator expresses instance role as a pod label and keeps the Service selector static — CloudNativePG's -rw works this way, so the flagship case is fine. It is not safe for an operator that rewrites the Service selector on failover: for the reconcile window, external write traffic would land on a demoted primary. Worth stating the assumption and which engines were checked against it.
5. VMs are a second first-class consumer, not future work
Non-goals defers vm-instance ("the existing externalMethod contract stays as-is; folding it into the union is future work"). We have a live requirement that does not fit that deferral: attaching one or more public IPs to a VM.
That requirement is exactly this proposal's N-per-application shape, and more of the machinery already fits than the document assumes. On a live cluster, a vm-instance Service already carries everything the resolve step needs:
labels:
apps.cozystack.io/application.group: apps.cozystack.io
apps.cozystack.io/application.kind: VMInstance
apps.cozystack.io/application.name: whmcs
apps.cozystack.io/user-service: "true"
service.kubernetes.io/service-proxy-name: cozy-proxyThe lineage labels are stamped, and the selector is a normal pod selector that mirrors fine. Three things block it:
- Ports must be in the attachment spec. A VM's non-external Service is headless with a single sentinel port
65535, so there is nothing meaningful to mirror, andexternalPortshas no counterpart. This answers your open question 1 — aportsfilter is required, not optional. - A whole-IP mode. The rendered Service needs the
service.kubernetes.io/service-proxy-name: cozy-proxylabel and thenetworking.cozystack.io/wholeIP/allowICMPannotations. The datapath already exists; only the render is missing. - Egress with several addresses is a real design question.
WholeIPtoday means both "all ports in" and "the VM egresses as this address". With two attachments that is ambiguous. Either one attachment is marked primary for egress, or egress stays on the application's own Service, orwholeIPis permitted on at most one attachment. This is the one part that is not a field.
Related, the deprecation sequence has an internal tension: step 3 removes external from chart schemas "with a validation error pointing at attachments", while Non-goals says vm-instance's contract stays as-is. For VMs there is currently nothing to point at.
6. A granularity mismatch worth settling before implementation
We are mapping a portal-side model onto this: a standalone PublicIP resource (a clean fit for #35's IPAddressClaim) attached through an instance's NIC list, where each NIC carries its own security groups. So one VM can have a management address with SSH open and a public address with only HTTPS.
Cozystack's SecurityGroup.spec.attachments is a list of ApplicationReference, and the controller stamps a membership label onto the pods of the referenced application (pkg/apis/sdn/v1alpha1/securitygroup_types.go). The granularity is per-application, so two addresses on the same VM necessarily share one rule set.
Per-attachment ports covers part of that in practice, but it is port filtering rather than source-IP ACL. Since Non-goals hands ACL to #2922 wholesale, it is worth saying explicitly whether per-address ACL is intended to become expressible, or whether consumers should expect application-level granularity permanently.
Points 1–3 are a few lines each. Point 5 is the one that changes scope, and I would rather raise it now than after the MVP ships database-only.
Separately: your five non-blocking points from 2026-08-05 have had no response and the PR has not moved in three and a half weeks. Points 1 (mutation semantics of spec.loadBalancer) and 3 (same-name HelmRelease recreate orphaning attachments by UID) look load-bearing to me, not nits.
Not adding a second CHANGES_REQUESTED on top of myasnikovdaniil's — one block is enough.
Review approved the shape and left four specifics. Three change the document. - spec.addressName appeared once, commented out in the claim example, with no prose anywhere. Two reviewers read it opposite ways -- one as absent entirely, one as an ungoverned way for a tenant to name a cluster-scoped object. It is now specified: it narrows the candidate set and never widens it, honoured only for an address that is Available, unclaimed, not being deleted, and of the claim's own resolved class and family, with a name that matches nothing leaving the claim Pending rather than failing. Failure cases cover the absent, Bound, Released and wrong-class names. Security now states where this component stops. It secures the surface a reservation can be stolen through; it does not decide which class a namespace may draw from, and neither addressName nor className can be gated by namespace RBAC. That is deliberate: the capability is reserve/hold/attach/release, and encoding a tenancy model this controller cannot see belongs above it -- an admission policy over those two fields, or machinery that mints claims on a tenant's behalf. Both fields are named so a deployment hardening this does not have to find them. - ExposureClass was described as a kind that exists in network.cozystack.io. It was proposed alongside community #29 and never shipped, and that group does not exist; the direction went to loadBalancerClass. Open question 3 was anchored to its fate and so could not be answered. It now asks the live question -- whether a class should declare the loadBalancerClass it corresponds to, so an association can check the pair -- and records that loadBalancerClass is immutable on an existing Service, so a mismatch has to be a refused association rather than a rewrite. - The non-goal said a workload holds one claim, which forbids a case that is not ours to forbid and that community #45 depends on: a managed service reachable on a public address and an internal routable one is two Services with two claims. The 1:1 rule that is real -- one claim, one consumer at a time -- is kept, and what stays deferred is N owned addresses on a single consumer. Also records, in Positioning, why the group is not ipam.cozystack.io: this is not all of IPAM and cannot become it, since allocation stays with the allocator. Assisted-By: Claude Opus 5 Signed-off-by: Timofei Larkin <[email protected]>
Add the exposure half to the IP-addresses-as-a-resource substrate (#35): a tenant-created, namespaced EndpointAttachment that attaches an external address to one endpoint (tenant-facing Service) of a managed application. Attachments are additive and engine-agnostic (the controller mirrors the endpoint Service's selector and ports), mint or reference IPAddressClaims, and are garbage-collected with the application. Succeeds the closed structured-external-exposure proposal (#29); external: true coexists until an address-preserving migration exists. Assisted-by: LLM Signed-off-by: Timofei Larkin <[email protected]>
…#20; add VM modes Resolve the review feedback on community#45 and finalize the proposal: - applicationRef gains group (default apps.cozystack.io); the resolver compares all three lineage labels AND requires the tenant-facing marker internal.cozystack.io/tenantresource=true — lineage labels alone cover operator-internal Services too. - Address-source fields (className/claimName/family) are immutable via CEL; render knobs stay mutable. Phase enum defined (Detached is the endpoint-absent state); conditions are standard metav1.Condition, phase a derived summary. - The rendered Service's identity is the controller ownerReference; the ownership label is index/diagnostic only. The controller sets loadBalancerClass from the claim's class at creation, per #35 §5. - GC identity is UID-anchored by design: attachments belong to one application incarnation; same-name recreate collects them. - New section positioning attachments beside the accepted SNI consolidation design (#20): dedicated addresses are the exception, the gateway union member is #20's inherited tenant-facing trigger. - VMs are a second first-class consumer: ports filter, method WholeIP|PortList rendering the existing cozy-proxy contract, with an at-most-one-whole-IP rule; the multi-address egress model is the one open question. - Quota/group coupling, per-address ACL granularity, and the mirroring contract (static selector, pod-label roles) stated explicitly. Assisted-by: LLM Signed-off-by: Timofei Larkin <[email protected]>
Whole-IP is defined as 1:1 NAT between one external address and one workload interface: it presumes a single-backend endpoint (refused otherwise), and the default is at most one whole-IP Service per application — which is the current datapath's actual shape, since cozy-proxy translates through strictly one-to-one stateless address maps where a second mapping for a workload replaces the first. Widening beyond one whole-IP is left open for a datapath with stateful NAT and symmetric flows (cozyplane the expected first candidate): with conntrack, replies leave through the arrival address for free and only unsolicited egress needs a designated primary mapping, so the remaining open question narrows to how that primary is designated. Signed-off-by: Timofei Larkin <[email protected]>
6ae684d to
ab8ea4c
Compare
…al.sdn.cozystack.io Move the kind from cozystack.io into #35's networking family. The local. prefix marks CRD-served kinds served by the kube-apiserver, where ResourceQuota admission runs, so count/endpointattachments quota keeps working; the bare aggregated sdn.cozystack.io (SecurityGroup's group) would silently drop it. Encoding the CRD-served requirement in the group name protects it from a later group tidy-up, and the same real storage is what incarnation-anchored ownerReference GC depends on. Signed-off-by: Timofei Larkin <[email protected]>
… local.sdn family Community #45 now homes its EndpointAttachment kind in local.sdn.cozystack.io rather than consuming the substrate from another group. Record it as another CRD-served resident of the family, which exercises the local.-prefix quota guarantee the Positioning section argues for. Signed-off-by: Timofei Larkin <[email protected]>
myasnikovdaniil
left a comment
There was a problem hiding this comment.
Requesting changes, four blockers. Five non-blocking points from my first review are addressed and the section about #20 is in, blocker 4 is what is left of my first blocker.
Blockers
- A second delegated Service on a VM breaks the first one, in PortList mode too (
README.md:122, also :12, :53, :216, :218)
Doc limits only whole-IP to one per application, and promises that a second PortList address works ("both addresses reachable on their ports", :216) and that legacy external: true Service stays untouched (:218). Datapath doesn't work like that. Cozystack ships cozy-proxy v0.4.0 (packages/system/cozy-proxy/charts/cozy-proxy/values.yaml:3), and EnsureRules in pkg/proxy/nft.go:394-455 deletes the existing pod_svc and svc_pod entries of the pod before adding the new pair. It is called from pkg/controllers/services_controller.go:237,335,365,425 for every delegated Service, wholeIP only affects port filter. Main has the same limit ("a pod IP can only stand for one service IP", nft.go:406-441), and your own implementation says it in docs/endpoint-attachments.md:69 of cozystack/cozystack#4324. I only read the code, did not run it on live datapath.
So second attachment reports Provisioned=True and the earlier address (attachment or chart Service) stops working. IPv6 and Dual claims with method are same story, cozy-proxy uses ipv4 table and To4() (nft.go:57,397), nothing will serve that address.
Please restate the limit as one cozy-proxy delegated Service per backend pod in any method, chart Service included, and refuse second one and non ipv4 family with Provisioned=False. Rewrite :12, :53, :216, :218 to match. Several addresses per VM need datapath change, :230 puts it to future CNI but nothing tracks it in cozystack/cozy-proxy, please open issue there and gate VM modes on it wherever more than one address is promised.
- Claims are minted under controller identity, so nobody checks the class, and minted address doesn't die with attachment (
README.md:189, also :56, :118, :206, :214)
:189 says class selection and admission are #35 surface and this proposal "adds no new path to an address that the substrate does not already govern". #35 says opposite in Security, "Where this component stops": substrate doesn't decide which class a namespace may draw from, that belongs to a ValidatingAdmissionPolicy restricting classes or to "platform machinery that mints claims on a tenant's behalf". The controller from step 2 (:118) is that machinery. In cozystack/cozystack#4324 it is a reconciler inside cozystack-controller and the claim is created by its ServiceAccount, so any policy keyed on tenant never sees it. Also there tenant roles have endpointattachments but not ipaddressclaims (clusterroles.yaml:40,141,304, only controller has it, rbac.yaml:39), so attachment is the only way to mint, with any className.
Second part, :56 says minted address "dies with its attachment" and e2e at :214 expects "address released". #35 section 2 has reclaimPolicy default Retain, and after claim delete with Retain the IPAddress stays Released until admin clears claimRef. So with default class every create/delete cycle strands one address.
Please name the controller and identity that creates the claim, and say who authorizes className for the tenant (SubjectAccessReview on the class, or allow-list in controller with Provisioned=False otherwise). Then either mint only from Delete classes, or change :56, :206, :214 to class dependent reclaim and say how stranded address is recovered.
- Public address on a database weakens the no-plaintext guarantee and doc doesn't say it (
README.md:149, also :43, :186-192)
:43 says external doubles as TLS default and :149 says loadBalancer member is additive "TLS or not", Security has nothing about plaintext on public address. Today external: true makes platform refuse plaintext, attachment never writes external so those guards don't run. Redis: cozystack-database-external-tls-policy (packages/system/cozystack-basics/templates/database-external-tls-policy.yaml:4,39-42,73) denies external Redis without TLS, but matches only redises, and TLS is fixed at creation (:15-19), so existing plaintext Redis can't be made safe after attach. Postgres: tls.required unset follows external (packages/apps/postgres/templates/_tls.tpl:19-23), only then pg_hba gets hostssl plus reject (db.yaml:280-310), otherwise cnpg default rule accepts plaintext (db.yaml:281-283).
So attach to -ro of Postgres with external: false, main use case from :48, gives plaintext credentials and data to anyone on the path, weaker than external: true. You know about it, docs/endpoint-attachments.md:63 in cozystack/cozystack#4324 says "Attaching a database does not turn its TLS on" and "expect a follow-up that enforces 'no plaintext database on a public address'". That follow-up is not in this proposal.
Please write the hole in Security and make that follow-up a mandatory Rollout item with owner, landing before database endpoints can be attached, because shipping attachment first removes the guarantee external: true gives today. Where enforcement is not practical for some engine docs may be enough, but decide it per engine and write it down. Add per engine TLS check to Rollout 1 as release gate next to the mirroring contract check.
- SNI default and trigger must be agreed with #20 (
README.md:14, :144-148, also :65, :173, :226)
These two proposals are two halves of how tenant exposes a database and they disagree. :14, :144 and :146 call SNI consolidation the general answer and "the default", #20 keeps per-database LoadBalancer as default and makes SNI opt-in ("Upgrade and rollback compatibility"). :65 and :148 say no tenant-facing trigger exists, #20 section 5 defines interim external adjacent toggle and the expose model as long term trigger. And cozystack/cozystack#4324 says the trigger decision moved to TLS work, which contradicts :65, :148, :173, :226. Three documents, three answers for what turns SNI exposure on, so two teams can build two switches for the same thing.
Please agree one answer with the owner of #20, fix whichever text is wrong and record it in both proposals. Until then drop "the default" and "no trigger exists".
Follow-ups, not blocking
-
README.md:137: "no path that delete-and-recreates a tenant application's HelmRelease" is not true today. Restore of VMInstance under new name creates new HelmRelease and deletes the old one (internal/backupcontroller/velerostrategy_controller.go:1063-1130), migration 28 did the same for mysql to mariadb. I don't ask to change those flows and UID anchored GC can stay, but since they exist please say what happens with attachments and minted addresses on restore or rename (lost or carried over). -
README.md:117and :188 call thetenantresourcemarker the declaration of tenant-facing Services, butopenbao-{{ .name }}-internal(packages/system/openbao-rd/cozyrds/openbao.yaml:37) andqdrant-{{ .name }}-headless(packages/system/qdrant-rd/cozyrds/qdrant.yaml:46) are inspec.services. Tenant can only publish own application, so risk is low, but Rollout 3 (:224) should check over-declaration too, not only under-declaration. -
WholeIP with more than one backend: :122 and :204 refuse with
Provisioned=False, cozystack/cozystack#4324 keeps Service and claim and selects nothing (MultipleBackends) so rolling update and live migration don't churn the address. Please align both. I could not verify from vm chart if both pods are selected during migration. -
Doc builds on #35 and on address-controller packaging in cozystack/cozystack#3382, both open with changes requested (Gateway as association target, holder based reservation and confused deputy in #35, unpinned sources and images in cozystack/cozystack#3382). Group
local.sdn.cozystack.io, claim annotation andloadBalancerClasscontract come from #35, so please add Dependencies line to Status and say what changes here if #35 changes,gatewaymember first.
| 3. **Render.** Create one additive `type: LoadBalancer` Service via `generateName` (prefixed with the attachment's name) — so an attachment name can never collide with an existing Service. **The controller owner reference is the Service's identity:** the controller sets itself as controller owner and rediscovers the Service by that reference on every reconcile; an ownership label is stamped for indexing and diagnostics but is never trusted for adoption, because labels are mutable and can collide — a Service whose controller owner reference does not point at the attachment's UID is ignored, never adopted or updated. The Service **mirrors the resolved endpoint Service**: the selector is always the endpoint's, kept in sync by watching the endpoint Service (event-driven, not polled); ports come from the member's `ports` when set (a subset filter, and the only source when the endpoint has nothing meaningful to mirror), otherwise from the endpoint. Mirroring is what makes the mechanism engine-agnostic, and it is the structural answer to why #29's chart-side mechanism could not work: the controller does not need to know how CloudNativePG or mariadb-operator label their pods, it only needs to mirror the Service they already maintain. **The mirroring contract assumes the operator expresses instance role as pod labels under a static Service selector** — CloudNativePG does (the `-rw` selector is static; the operator relabels pods on switchover), so the flagship case holds. An operator that instead rewrites the *Service selector* on failover leaves the mirror stale for one watch-driven reconcile, during which external write traffic can land on a demoted primary; *Rollout* therefore gates each engine's documentation on verifying its selector model. The Service carries the substrate's consumption annotation `local.sdn.cozystack.io/ip-address-claim: <claim>`, and the controller sets `spec.loadBalancerClass` at creation to the value the claim's resolved `IPAddressClass` fixes — per #35 §5 that check is refusal-only and the field is immutable, so the creator must be right at creation, and this controller can be, because it reads the class first. The per-class driver does the rest (pin, announce, associate). `externalTrafficPolicy: Local` and node-port allocation follow the platform's existing conventions. | ||
| 4. **Report.** `status.addresses` mirrors the claim's bound addresses; `status.serviceName` names the rendered Service; conditions say why anything is missing. Nothing is fabricated client-side. The controller also stamps the application's lineage labels (`apps.cozystack.io/application.*`) onto the attachment itself, so dashboards and tooling list an application's attachments with the same selector that finds its other objects — an attachment is an application add-on, not an application, and its presentation is the dashboard's call. | ||
|
|
||
| **VM datapath modes.** A vm-instance's tenant-facing Service is headless with a single sentinel port (`65535`) until exposed, so there is nothing to mirror: a VM attachment must set `ports` or `method` in the `loadBalancer` member (neither → `Provisioned=False` with a reason). `method: WholeIP` renders the existing cozy-proxy contract — the `service.kubernetes.io/service-proxy-name: cozy-proxy` label, the `networking.cozystack.io/wholeIP: "true"` annotation, and the sentinel port when `ports` is absent. `method: PortList` renders the same label with `wholeIP: "false"`, `networking.cozystack.io/allowICMP` from `allowICMP`, and requires `ports`. These are byte-for-byte the objects vm-instance's chart renders under `externalMethod` today — the datapath already exists; only this render was missing, and the enum deliberately reuses the chart's `externalMethod` vocabulary. **Whole-IP is 1:1 NAT, so it presumes a single backend**: the mapping is between one external address and one workload interface address, so an endpoint resolving to more than one backend has no interface to map — its external address is simply a load-balancer address, and `WholeIP` on such an endpoint is refused (`Provisioned=False`), not half-applied. **Default: at most one whole-IP Service per application**, counting a legacy `externalMethod: WholeIP` chart Service. This states the datapath's actual shape, not a caution: cozy-proxy translates statelessly — per-packet source/destination rewrites through strictly one-to-one address maps — and a second mapping for the same workload replaces the first in both directions, so a second whole-IP address today would not be ambiguous but dead. The controller refuses it with `Provisioned=False` (cross-object state, so a condition, not admission). Widening the default is left open for a more capable datapath — see *Open questions*. |
There was a problem hiding this comment.
Same as blocker 1, cozy-proxy keeps one mapping per backend pod in any method (v0.4.0 nft.go:394-455), so second PortList address from :216 can't work.
|
|
||
| The accepted general answer to external *database* exposure is #20: any number of a tenant's fitting databases — same-engine instances included — collapse onto the tenant Gateway's single LoadBalancer address, SNI-routed on native ports, with end-to-end TLS from the operator-issued certificate. This proposal is not that answer's replacement, and must not be read as the general path for databases. The division of labor: | ||
|
|
||
| - **SNI consolidation is the default for the engines it fits** (Redis, direct-TLS PostgreSQL 17+, sharded `mongos`): one address per tenant, a stable hostname per database, zero per-database address cost. |
There was a problem hiding this comment.
#20 keeps per-database LoadBalancer as default and SNI opt-in, so "the default" here contradicts it. Blocker 4.
| - **SNI consolidation is the default for the engines it fits** (Redis, direct-TLS PostgreSQL 17+, sharded `mongos`): one address per tenant, a stable hostname per database, zero per-database address cost. | ||
| - **A dedicated address — an attachment — is the deliberate exception**: the client allow-lists an IP; the client cannot emit SNI (pre-17 Postgres clients, `redis-cli` without `--sni`); the engine's wire protocol precludes SNI entirely (MariaDB, server-speaks-first — permanently outside #20); the endpoint is not a database at all (a VM, the managed-Kubernetes API); or the tenant needs an address they can hold and move (#35's reservation semantics, which a shared Gateway address by definition cannot give one database). | ||
| - **The two meet at the trigger, and the obligation is inherited.** #20 left its tenant-facing trigger to the structured-exposure model it expected from #29; this proposal succeeded #29, so the trigger surface lands here: the `gateway` union member *is* it — an attachment whose member selects the tenant Gateway renders a per-release `TLSRoute` onto #20's shared per-engine listener (mechanics in #20 §5, not re-designed here) instead of a LoadBalancer Service. Wiring it is a phase-2 *Rollout* item. Until it ships, a tenant cannot request SNI exposure at all — this sequencing accepts that gap knowingly rather than leaving the merged design's trigger unowned. | ||
| - **The `gateway` member cannot be purely additive, and this is expected.** SNI exposure requires the operator-issued certificate to carry the external hostname SANs (#20 §6) — chart/operator cooperation, gated per engine on the unified-TLS convergence #20 already depends on. The `loadBalancer` member carries no such dependency: it is additive against any passthrough-clean engine, TLS or not. One union, two members, two honesty levels — the additive one ships first. |
There was a problem hiding this comment.
Attachment never writes external, so Redis admission policy and Postgres tls.required default don't fire. Blocker 3.
| ## Security | ||
|
|
||
| - **The tenant-facing-endpoint check is the authorization boundary for what may be exposed.** A tenant can attach only to Services in their own namespace whose lineage identity labels match `spec.applicationRef` on group, kind, *and* name, and which carry `internal.cozystack.io/tenantresource: "true"`. The marker is the load-bearing half: lineage labels alone also cover operator-internal and headless Services, and admitting those to a public address would leak surfaces the platform never declared tenant-facing. Attaching to unlabeled, internal, system, or foreign Services fails `Resolved`. All references (`applicationRef`, `serviceName`, `claimName`) are same-namespace by construction. | ||
| - **Who may expose to which pool** is the substrate's question: `IPAddressClass` selection, claim quota, and the admission posture against raw pin annotations are #35's surface, consumed here unchanged. This proposal adds no new path to an address that the substrate does not already govern. |
There was a problem hiding this comment.
#35 leaves class authorization to machinery that mints claims on tenant's behalf, that is this controller, so "no new path" is not true. Blocker 2.
What this proposal is
Managed applications already have endpoints — the Services their operators create (Postgres
-rw/-ro, a tenant cluster's API Service, Redis master) — and, with #35, addresses are becoming first-class resources that can be held, moved, and quota'd. Nothing connects the two: the only exposure mechanism is the chart-levelexternal: true|false, which publishes one chart-chosen endpoint on one unchooseable address through five structurally different render paths.This proposal adds
EndpointAttachment(cozystack.io/v1alpha1, namespaced, tenant-created): attach an external address to one endpoint of one application — as many attachments per application as needed, each with an independent lifecycle, none of them ever mutating the application's own Services or values. An attachment either mints anIPAddressClaimfrom a class (ephemeral, dies with the attachment) or binds a pre-reserved claim (the elastic-IP hold/move experience — the address survives detachment). The controller is engine-agnostic: it renders one additivetype: LoadBalancerService mirroring the endpoint Service's selector and ports, and consumes the address through the substrate's claim annotation, so no per-chart render logic is needed. Attachments are garbage-collected with their application.Relationship to prior proposals
ServiceExposure): the Alternatives considered section addresses why this is not that mistake repeated — N-per-app attachment lifecycle, claim hold/move semantics, and an authorization seam over which Services may be published do not reduce to aloadBalancerClassfield on an existing Service.external: truekeeps its exact behavior; its sunset is explicitly gated on an address-preserving migration via the substrate's adoption path.Out of scope
Hostnames/DNS/TLS/SANs (unified-TLS effort, #19), source-IP ACL (
SecurityGroup, cozystack#2922), the discovery-engine write-back loop (Kafka / Mongo replica sets / NATS — deferred, stated plainly), Gateway API (a future union member the API shape leaves room for), and vm-instance whole-IP semantics.Summary by CodeRabbit