Skip to content

Ship an optional admission policy reserving MetalLB's Service pins - #6

Merged
Timofei Larkin (lllamnyp) merged 1 commit into
mainfrom
feat/service-admission-policy
Nov 6, 2026
Merged

Timofei Larkin (lllamnyp) merged 1 commit into
mainfrom
feat/service-admission-policy

Conversation

@lllamnyp

Copy link
Copy Markdown
Member

Design §8 leaves two jobs to an admission layer: protecting reserved addresses during the handoff gap (§5), and stopping tenants from steering MetalLB at reserved addresses directly. Without it, any principal that can write a Service can grab an address. They can pin it (metallb.io/loadBalancerIPs), pick the reserved pool (metallb.io/address-pool), share another Service's IP (allow-shared-ip) or set spec.loadBalancerIP. MetalLB honours all of these against autoAssign: false pools. In Cozystack this also reaches tenant Kubernetes clusters: cloud-provider-kubevirt copies a tenant Service's annotations and loadBalancerIP onto the host Service it creates.

This PR lets the chart install that layer as an opt-in ValidatingAdmissionPolicy and binding (admissionPolicy.enabled, off by default).

What it enforces

On Service CREATE/UPDATE, only the driver's ServiceAccount and allowlisted principals may set, change or remove:

  • metallb.io/loadBalancerIPs, metallb.io/address-pool, metallb.io/allow-shared-ip, and the same three under the legacy metallb.universe.tf/ prefix;
  • spec.loadBalancerIP;
  • metallb.drivers.local.sdn.cozystack.io/pinned, the driver's marker for pins it owns.

The policy compares against the old object, so a write that leaves these fields as they were is always admitted. Controllers that Update whole Services keep working, cloud-provider-kubevirt's port sync among them. local.sdn.cozystack.io/ip-address-claim stays writable by tenants; it is the sanctioned path. A denial names the offending annotations and points at the claim annotation instead.

Values

  • namespaceSelector (default {}, all namespaces). Cozystack should scope it to tenant namespaces. Its kube-ovn chart sets metallb.universe.tf/allow-shared-ip in a system namespace and would otherwise be denied.
  • allowedUsernames / allowedGroups. These are exempt in addition to system:serviceaccount:<release-ns>:metallb-iad. The defaults are system:masters and kubeadm:cluster-admins. Admission policies apply to system:masters too, and kubeadm has issued admin credentials in kubeadm:cluster-admins since 1.29. Installations whose admins authenticate otherwise, such as through an SSO proxy, need their group listed.
  • validationActions (default [Deny]). [Warn, Audit] reports would-be denials without enforcing them, which suits a first rollout.

README and design §8 are updated to match.

Verification

helm lint passes with the policy on and off. I applied the rendered policy to a throwaway kind cluster (Kubernetes v1.32.2) with the binding scoped by a namespace label. It type-checks cleanly (status.typeChecking is empty). These cases behaved as expected:

Case Result
Tenant creates a plain LoadBalancer Service admitted
Tenant creates with metallb.io/loadBalancerIPs denied
Tenant creates with metallb.universe.tf/allow-shared-ip denied
Tenant creates with spec.loadBalancerIP denied
Driver SA writes a pin + pinned onto the tenant's Service admitted
Tenant relabels it, or kubectl replaces it whole, pin untouched admitted
Tenant removes or changes the pin, or removes pinned denied
Tenant sets local.sdn.cozystack.io/ip-address-claim admitted
Cluster admin (kubeadm:cluster-admins) sets address-pool admitted
Tenant sets a pin in a namespace outside the selector admitted
Driver SA removes its pin admitted

The admin case failed until kubeadm:cluster-admins was added to the defaults, which is why it is there. The chart has no template tests, and none are added here.

The design leaves the handoff gap and tenant steering of MetalLB to an
admission layer. The chart can now install it: a
ValidatingAdmissionPolicy that lets only the driver and allowlisted
principals set, change or remove the MetalLB pin, pool and sharing
annotations (both prefixes), spec.loadBalancerIP and the driver's pinned
marker. Writes that leave them unchanged are admitted, so controllers
that rewrite whole Services keep working. Off by default; the namespace
selector, exemptions and validation actions are values.

Assisted-By: LLM
Signed-off-by: Timofei Larkin <[email protected]>
@coderabbitai

coderabbitai Bot commented Nov 2, 2026

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e7ef107e-3b2c-4f49-9a40-8828892e8946
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Share

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: optional admission policy (layer 1)

The policy matches the design's Security section. The protected set is exactly the authoritative list: metallb.io/loadBalancerIPs, metallb.io/address-pool, metallb.io/allow-shared-ip, the same three under the legacy metallb.universe.tf/ prefix, Service.spec.loadBalancerIP, plus the driver's own pinned marker (chart/metallb-iad/templates/admission-policy.yaml:30-40,53-59). The comparison is old value against new value, so an unchanged pin passes and the tenant can still edit the rest of the Service, and removing a pin is gated, both as the design requires. The CREATE path short-circuits on oldObject, so there is no null dereference. The driver's ServiceAccount is appended to the allowlist, so the driver does not block itself, and the status subresource is not matched, so MetalLB's status writes pass. failurePolicy: Fail is the right default for a theft fence. The values file documents that cluster admins must be allowlisted because the policy binds system:masters too, which is easy to miss.

[MINOR] The default namespaceSelector: {} with [Deny] is cluster-wide

With enabled: true and the defaults, the policy applies to every namespace. A non-allowlisted deputy that copies a tenant's annotations, cloud-provider-kubevirt copying a guest Service's metallb.io/* pin onto the host Service, then has the whole host Service rejected, not just the gated field. The design calls layer 1 "partial" for such deputies; under [Deny] the effect is a hard failure of Service creation rather than a partial gap. The values comment already advises scoping the selector to tenant namespaces; worth either narrowing the default or calling out the cloud-provider-kubevirt namespace explicitly. enabled: false by default keeps this from biting out of the box.

Note, not for this PR

The design says the admission policy "must cover Gateway.spec.addresses" (README §5). This policy fences MetalLB's inputs on Services, which is correct for the MetalLB driver, MetalLB does not honour Gateway.spec.addresses. The Gateway-by-value surface is honoured by Cilium LB-IPAM, so covering it belongs to the Cilium driver's policy and the distribution bundle (cozystack/cozystack#3382), not here. Flagging so it has an owner rather than falling between the two.

LGTM, with the namespaceSelector default worth a second look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants