Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,30 @@ core controller as follows:
`Conflict` phase when an address is held by a Service its binding does not
authorize, and `Lost` when the backing allocation disappears.

The core controller never touches Services, never parses class parameters,
and never puts a packet on a wire.
## Auto-claim for LoadBalancer Services

A LoadBalancer Service that names no claim gets one provisioned for it,
so plain Services — including those a tenant cluster's cloud provider
creates on the host — draw metered, attributed addresses instead of
nothing:

- Mark one class with
`ipaddressclass.local.sdn.cozystack.io/is-auto-claim-class: "true"`, or
name a class on the Service with `local.sdn.cozystack.io/ip-address-class`.
- The core creates an `IPAddressClaim` named after the Service, in its
namespace and controlled by it, and sets the Service's
`local.sdn.cozystack.io/ip-address-claim` annotation; the driver takes it
from there.
- Deleting the Service deletes the claim and its addresses, whatever the
class's `reclaimPolicy`. To keep the address, remove the claim's owner
reference; it is an ordinary claim from then on.
- Skipped: Services that already name a claim, set
`local.sdn.cozystack.io/auto-claim: "false"`, set
`spec.loadBalancerClass` or `spec.loadBalancerIP`, or already have an
address.

Apart from auto-claim, the core controller never touches Services. It
never parses class parameters and never puts a packet on a wire.

## Installing

Expand Down
31 changes: 26 additions & 5 deletions api/v1alpha1/well_known.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,36 @@ const (
// storageclass.kubernetes.io/is-default-class.
IsDefaultClassAnnotation = "ipaddressclass.local.sdn.cozystack.io/is-default-class"

// IsAutoClaimClassAnnotation marks an IPAddressClass as the class the
// core controller auto-provisions claims from for LoadBalancer Services
// that name neither a claim nor a class. Value must be "true"; at most
// one class may carry it. Independent of IsDefaultClassAnnotation.
IsAutoClaimClassAnnotation = "ipaddressclass.local.sdn.cozystack.io/is-auto-claim-class"

// ProvisionerAnnotation is stamped onto an IPAddressClaim by the core
// controller once the claim's class is resolved. Its value is the
// class's spec.provisioner. Per-class drivers watch claims carrying
// their own name here and provision addresses for the pending ones —
// the analogue of volume.kubernetes.io/storage-provisioner.
ProvisionerAnnotation = "local.sdn.cozystack.io/provisioner"

// ServiceClaimAnnotation, set on a Service by a tenant, names an
// IPAddressClaim in the Service's own namespace whose address should be
// pinned to the Service. Consumed by per-class drivers (which translate
// it into the backend's raw pin annotation), never by tenants writing
// backend annotations directly.
// ServiceClaimAnnotation, set on a Service by a tenant or by the core
// controller's auto-claim, names an IPAddressClaim in the Service's own
// namespace whose address should be pinned to the Service. Consumed by
// per-class drivers (which translate it into the backend's raw pin
// annotation), never by tenants writing backend annotations directly.
ServiceClaimAnnotation = "local.sdn.cozystack.io/ip-address-claim"

// ServiceClassAnnotation, set on a LoadBalancer Service that names no
// claim, names the IPAddressClass to auto-provision its claim from
// instead of the auto-claim class.
ServiceClassAnnotation = "local.sdn.cozystack.io/ip-address-class"

// ServiceAutoClaimAnnotation set to "false" on a Service exempts it
// from auto-claim. Drivers stamp it on Services they create for their
// own bookkeeping, such as reservation placeholders.
ServiceAutoClaimAnnotation = "local.sdn.cozystack.io/auto-claim"

// ClaimProtectionFinalizer is placed on every IPAddressClaim by the
// core controller so that claim deletion runs the reclaim flow for the
// bound addresses before the claim disappears.
Expand Down Expand Up @@ -66,4 +82,9 @@ const (
ReasonBound = "Bound"
ReasonWaitingForProvisioning = "WaitingForProvisioning"
ReasonAddressLost = "AddressLost"

// Event reasons recorded on Services by auto-claim.
ReasonAutoClaimed = "AutoClaimed"
ReasonAutoClaimNameTaken = "AutoClaimNameTaken"
ReasonMultipleAutoClaimClasses = "MultipleAutoClaimClasses"
)
16 changes: 16 additions & 0 deletions chart/address-controller/templates/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ rules:
verbs:
- create
- patch
- apiGroups:
- ""
resources:
- services
verbs:
- get
- list
- patch
- watch
- apiGroups:
- ""
resources:
- services/finalizers
verbs:
- update
- apiGroups:
- coordination.k8s.io
resources:
Expand All @@ -28,6 +43,7 @@ rules:
resources:
- ipaddressclaims
verbs:
- create
- get
- list
- patch
Expand Down
8 changes: 8 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "IPAddressClaim")
os.Exit(1)
}
if err = (&controller.ServiceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("service-autoclaim-controller"),
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "Service")
os.Exit(1)
}
// +kubebuilder:scaffold:builder

if metricsCertWatcher != nil {
Expand Down
62 changes: 56 additions & 6 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ storage subsystem. It owns:
- accepting and completing claim–address bindings,
- matching pre-provisioned addresses to claims,
- phase and condition bookkeeping on both sides,
- finalizer-driven reclaim when a claim is deleted.
- finalizer-driven reclaim when a claim is deleted,
- auto-claiming addresses for LoadBalancer Services that name no claim
(§10).

It never allocates an address, never interprets class parameters, never
touches a Service, and never talks to a backend. Everything
associates an address with a Service, and never talks to a backend. Everything
backend-specific is a per-class driver's job, in a separate deployment,
discovered through the class's `spec.provisioner` — exactly as a
StorageClass names a CSI driver.
Expand All @@ -49,6 +51,9 @@ StorageClass names a CSI driver.
read by the core. Shape is defined by the driver.
- The annotation `ipaddressclass.local.sdn.cozystack.io/is-default-class:
"true"` marks the class used by claims that name none.
- The annotation `ipaddressclass.local.sdn.cozystack.io/is-auto-claim-class:
"true"` marks the class auto-claims draw from (§10). Independent of the
default class; at most one class may carry it.

### IPAddress

Expand Down Expand Up @@ -132,7 +137,7 @@ The contract is largely a discipline about who may write which field.

| field | tenant | admin | core | driver |
|---|---|---|---|---|
| `Claim.spec` | ✍ creates/edits | | | |
| `Claim.spec` | ✍ creates/edits | | ✍ creates (auto-claim only) | |
| `Claim` provisioner annotation | | | ✍ stamps | reads |
| `Claim.status.*` | | | ✍ | never |
| `Claim` protection finalizer | | | ✍ | |
Expand Down Expand Up @@ -315,6 +320,10 @@ the claim disappears:
- `Delete` → delete the `IPAddress` object. The core does not tear down
any backend state; the driver's own finalizer on the address
intercepts the deletion and deallocates first (§7 obligation 4).
- Exception: a claim whose controller is a Service (an auto-claim, §10)
lives and dies with that Service, so its addresses are deleted
whatever their `reclaimPolicy`. Removing the controller reference turns
it into an ordinary claim, and the policy applies again.
- Remove the finalizer; the claim goes away.

**What each policy actually reclaims.** "The address" is up to three
Expand Down Expand Up @@ -412,7 +421,8 @@ tenant core driver

Deleting the claim, not the Service, is what releases the address — via
§6, honoring the reclaim policy. This asymmetry is the entire point of
the model.
the model. Auto-claims (§10) are the deliberate exception: they belong to
their Service until someone detaches them.

## 9. Relation to the design proposal

Expand All @@ -424,7 +434,47 @@ driver-registration CRD; no modeling of driver capabilities
on; opaque-object class parameters rather than a string map; refusal over
newest-wins for multiple default classes; dual-stack as two `IPAddress`
objects under one claim; sticky class resolution recorded in status
(there is no admission webhook to default the spec); and association,
Service-watching, and admission policy left entirely to drivers. The API
(there is no admission webhook to default the spec); association and
admission policy left entirely to drivers; and auto-claim (§10) as the
one place the core watches Services. The API
group is `local.sdn.cozystack.io` rather than the proposal's sketched
`ipam.cozystack.io`.

## 10. Auto-claim for LoadBalancer Services

Plain LoadBalancer Services — a tenant's, or the host-side Services a
tenant cluster's cloud provider creates — name no claim, and drivers keep
their pools away from automatic assignment. Without auto-claim such a
Service gets either nothing or an address from some other pool that
nobody meters. Auto-claim gives it a claim instead.

A Service qualifies when it is a live `type: LoadBalancer` Service that:

- carries no `local.sdn.cozystack.io/ip-address-claim` annotation,
- does not carry `local.sdn.cozystack.io/auto-claim: "false"` (drivers
stamp this on Services they create for their own bookkeeping),
- sets neither `spec.loadBalancerClass` (another implementation's) nor
`spec.loadBalancerIP` (asks for a specific address),
- has no `status.loadBalancer.ingress` yet. This is what keeps a Service
addressed by other means from being moved to a new address.

The class is the one named by the Service's
`local.sdn.cozystack.io/ip-address-class` annotation, else the single class
marked `is-auto-claim-class`. With no marked class the Service is left
alone; with several, a warning event is recorded on it and nothing is
claimed.

The core then creates an `IPAddressClaim` named after the Service, in the
Service's namespace, with that `className`, the family derived from the
Service (`Dual` only for `RequireDualStack`), and the Service as its
controller. If a claim of that name exists and is not controlled by the
Service, nothing is adopted and an event says so. Finally the core sets
the Service's `ip-address-claim` annotation to the claim; from there the
driver associates the address exactly as for a tenant-made claim (§7
obligation 5).

Lifecycle: deleting the Service garbage-collects the claim, and the claim
finalizer deletes its addresses regardless of `reclaimPolicy` (§6). To
keep the address, remove the claim's controller reference; the claim then
outlives the Service and can be named by another one. The core sets the
reference only when creating the claim and never restores it.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26
godebug default=go1.23

require (
k8s.io/api v0.32.0
k8s.io/apimachinery v0.32.0
k8s.io/client-go v0.32.0
sigs.k8s.io/controller-runtime v0.20.0
Expand Down Expand Up @@ -81,7 +82,6 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.32.0 // indirect
k8s.io/apiextensions-apiserver v0.32.0 // indirect
k8s.io/apiserver v0.32.0 // indirect
k8s.io/component-base v0.32.0 // indirect
Expand Down
23 changes: 19 additions & 4 deletions internal/controller/ipaddressclaim_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,20 +123,29 @@ func (r *IPAddressClaimReconciler) finalizeClaim(ctx context.Context, claim *loc
if err != nil {
return err
}
// A claim still controlled by a Service lives and dies with it, so its
// addresses are deleted whatever their reclaimPolicy. Removing the
// controller reference is how such a claim is retained.
ownedByService := controlledByService(claim)
for i := range addrs {
addr := &addrs[i]
if !addr.DeletionTimestamp.IsZero() {
continue
}
switch addr.Spec.ReclaimPolicy {
case localv1alpha1.ReclaimDelete:
switch {
case ownedByService || addr.Spec.ReclaimPolicy == localv1alpha1.ReclaimDelete:
// The driver's own finalizer tears down the backend allocation
// before the object goes away.
if err := r.Delete(ctx, addr); err != nil && !apierrors.IsNotFound(err) {
return err
}
r.Recorder.Eventf(claim, "Normal", "AddressDeleted",
"IPAddress %s deleted per reclaimPolicy Delete", addr.Name)
if ownedByService {
r.Recorder.Eventf(claim, "Normal", "AddressDeleted",
"IPAddress %s deleted with the claim's owning Service", addr.Name)
} else {
r.Recorder.Eventf(claim, "Normal", "AddressDeleted",
"IPAddress %s deleted per reclaimPolicy Delete", addr.Name)
}
default: // Retain
if addr.Status.Phase != localv1alpha1.IPAddressReleased {
addr.Status.Phase = localv1alpha1.IPAddressReleased
Expand All @@ -152,6 +161,12 @@ func (r *IPAddressClaimReconciler) finalizeClaim(ctx context.Context, claim *loc
return r.Update(ctx, claim)
}

// controlledByService reports whether a claim's controller is a Service.
func controlledByService(claim *localv1alpha1.IPAddressClaim) bool {
ref := metav1.GetControllerOf(claim)
return ref != nil && ref.APIVersion == "v1" && ref.Kind == serviceKind
}

// resolveClass resolves the claim's class name — spec first, then the sticky
// status record, then the default class — verifies the class exists, and
// stamps the provisioner annotation. An empty return with nil error means the
Expand Down
29 changes: 29 additions & 0 deletions internal/controller/ipaddressclaim_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,35 @@ func TestClaimDeletionDeletePolicyDeletesAddress(t *testing.T) {
}
}

func TestServiceOwnedClaimDeletionDeletesRetainedAddress(t *testing.T) {
claim := pendingClaim(testClassName)
claim.Finalizers = []string{localv1alpha1.ClaimProtectionFinalizer}
claim.OwnerReferences = []metav1.OwnerReference{{
APIVersion: "v1", Kind: serviceKind, Name: testClaimName, UID: testServiceUID, Controller: new(true),
}}
addr := &localv1alpha1.IPAddress{
ObjectMeta: metav1.ObjectMeta{Name: "ip-auto"},
Spec: localv1alpha1.IPAddressSpec{
ClassName: testClassName,
Address: "203.0.113.13",
ReclaimPolicy: localv1alpha1.ReclaimRetain,
ClaimRef: &localv1alpha1.ClaimReference{Namespace: testNamespace, Name: testClaimName, UID: testClaimUID},
Source: localv1alpha1.IPAddressSource{FromClass: &localv1alpha1.FromClassSource{}},
},
Status: localv1alpha1.IPAddressStatus{Phase: localv1alpha1.IPAddressBound},
}
c := testClient(t, publicClass(nil), claim, addr)
if err := c.Delete(context.Background(), claim); err != nil {
t.Fatal(err)
}
reconcileClaim(t, claimReconciler(c))

err := c.Get(context.Background(), types.NamespacedName{Name: "ip-auto"}, &localv1alpha1.IPAddress{})
if !apierrors.IsNotFound(err) {
t.Errorf("address of a Service-owned claim survived the claim under Retain: %v", err)
}
}

func TestBoundClaimSurvivesClassDeletion(t *testing.T) {
// A fully bound claim must reconcile without touching its class:
// deleting the class breaks neither the binding nor status upkeep.
Expand Down
Loading
Loading