diff --git a/README.md b/README.md index b747c3b..ac56bfc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/v1alpha1/well_known.go b/api/v1alpha1/well_known.go index 16124e6..a901910 100644 --- a/api/v1alpha1/well_known.go +++ b/api/v1alpha1/well_known.go @@ -25,6 +25,12 @@ 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 @@ -32,13 +38,23 @@ const ( // 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. @@ -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" ) diff --git a/chart/address-controller/templates/role.yaml b/chart/address-controller/templates/role.yaml index eb6e8f8..cb55212 100644 --- a/chart/address-controller/templates/role.yaml +++ b/chart/address-controller/templates/role.yaml @@ -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: @@ -28,6 +43,7 @@ rules: resources: - ipaddressclaims verbs: + - create - get - list - patch diff --git a/cmd/main.go b/cmd/main.go index df44207..7005c3f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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 { diff --git a/docs/design.md b/docs/design.md index cb449f0..333560c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. @@ -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 @@ -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 | | | ✍ | | @@ -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 @@ -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 @@ -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. diff --git a/go.mod b/go.mod index 62aaa52..a3293d2 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/internal/controller/ipaddressclaim_controller.go b/internal/controller/ipaddressclaim_controller.go index d7975c4..9753956 100644 --- a/internal/controller/ipaddressclaim_controller.go +++ b/internal/controller/ipaddressclaim_controller.go @@ -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 @@ -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 diff --git a/internal/controller/ipaddressclaim_controller_test.go b/internal/controller/ipaddressclaim_controller_test.go index ab4911b..4f5b432 100644 --- a/internal/controller/ipaddressclaim_controller_test.go +++ b/internal/controller/ipaddressclaim_controller_test.go @@ -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. diff --git a/internal/controller/service_controller.go b/internal/controller/service_controller.go new file mode 100644 index 0000000..59e4336 --- /dev/null +++ b/internal/controller/service_controller.go @@ -0,0 +1,204 @@ +/* +Copyright 2026 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "sort" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + localv1alpha1 "github.com/cozystack/address-controller/api/v1alpha1" +) + +// serviceKind is the kind a Service controller reference names. +const serviceKind = "Service" + +// ServiceReconciler auto-claims addresses for LoadBalancer Services that +// name no claim: it creates an IPAddressClaim in the Service's namespace, +// controlled by the Service, and points the Service at it with the claim +// annotation. From there the class's driver associates the address exactly +// as for a tenant-made claim. The claim is named after the Service. +type ServiceReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder +} + +// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;patch +// +kubebuilder:rbac:groups="",resources=services/finalizers,verbs=update +// +kubebuilder:rbac:groups=local.sdn.cozystack.io,resources=ipaddressclaims,verbs=get;list;watch;create +// +kubebuilder:rbac:groups=local.sdn.cozystack.io,resources=ipaddressclasses,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// Reconcile auto-claims an address for one Service, if it qualifies. +func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + svc := &corev1.Service{} + if err := r.Get(ctx, req.NamespacedName, svc); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if !wantsAutoClaim(svc) { + return ctrl.Result{}, nil + } + + className := svc.Annotations[localv1alpha1.ServiceClassAnnotation] + if className == "" { + var err error + className, err = r.autoClaimClassName(ctx, svc) + if err != nil || className == "" { + return ctrl.Result{}, err + } + } + + claim := &localv1alpha1.IPAddressClaim{} + err := r.Get(ctx, types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}, claim) + switch { + case apierrors.IsNotFound(err): + claim = &localv1alpha1.IPAddressClaim{ + ObjectMeta: metav1.ObjectMeta{Namespace: svc.Namespace, Name: svc.Name}, + Spec: localv1alpha1.IPAddressClaimSpec{ + ClassName: className, + Family: familyForService(svc), + }, + } + if err := controllerutil.SetControllerReference(svc, claim, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if err := r.Create(ctx, claim); err != nil { + return ctrl.Result{}, err + } + case err != nil: + return ctrl.Result{}, err + case !metav1.IsControlledBy(claim, svc): + // A claim of that name already exists and is not ours — the + // tenant's, or one retained from an earlier Service of the same + // name. Adopting it is the tenant's call, via the claim annotation. + r.Recorder.Eventf(svc, "Warning", localv1alpha1.ReasonAutoClaimNameTaken, + "IPAddressClaim %q exists and is not controlled by this Service", svc.Name) + return ctrl.Result{}, nil + } + + patch := client.MergeFrom(svc.DeepCopy()) + if svc.Annotations == nil { + svc.Annotations = map[string]string{} + } + svc.Annotations[localv1alpha1.ServiceClaimAnnotation] = claim.Name + if err := r.Patch(ctx, svc, patch); err != nil { + return ctrl.Result{}, err + } + r.Recorder.Eventf(svc, "Normal", localv1alpha1.ReasonAutoClaimed, + "auto-claimed IPAddressClaim %q from IPAddressClass %q", claim.Name, claim.Spec.ClassName) + logger.V(1).Info("auto-claimed", "claim", claim.Name, "class", claim.Spec.ClassName) + return ctrl.Result{}, nil +} + +// wantsAutoClaim reports whether a Service qualifies for auto-claim: a live +// LoadBalancer Service that names no claim, has not opted out, is not left +// to another load-balancer implementation, asks for no specific IP, and has +// no address yet. The last rule is what keeps Services addressed by other +// means — before auto-claim existed, or from an auto-assigning pool — from +// being moved to a new address. +func wantsAutoClaim(svc *corev1.Service) bool { + return svc.Spec.Type == corev1.ServiceTypeLoadBalancer && + svc.DeletionTimestamp.IsZero() && + svc.Annotations[localv1alpha1.ServiceClaimAnnotation] == "" && + svc.Annotations[localv1alpha1.ServiceAutoClaimAnnotation] != "false" && + svc.Spec.LoadBalancerClass == nil && + svc.Spec.LoadBalancerIP == "" && + len(svc.Status.LoadBalancer.Ingress) == 0 +} + +// autoClaimClassName finds the single IPAddressClass marked for auto-claim. +// None means auto-claim is off and the Service is left alone; more than one +// is recorded on the Service and nothing is claimed. +func (r *ServiceReconciler) autoClaimClassName(ctx context.Context, svc *corev1.Service) (string, error) { + classes := &localv1alpha1.IPAddressClassList{} + if err := r.List(ctx, classes); err != nil { + return "", err + } + var marked []string + for _, c := range classes.Items { + if c.Annotations[localv1alpha1.IsAutoClaimClassAnnotation] == "true" { + marked = append(marked, c.Name) + } + } + switch len(marked) { + case 0: + return "", nil + case 1: + return marked[0], nil + default: + sort.Strings(marked) + r.Recorder.Eventf(svc, "Warning", localv1alpha1.ReasonMultipleAutoClaimClasses, + "multiple IPAddressClasses marked for auto-claim: %v", marked) + return "", nil + } +} + +// familyForService derives the claim family from the Service's IP families. +// Only RequireDualStack asks for both: a PreferDualStack Service would +// rather have one address than wait on a class that cannot supply two. +func familyForService(svc *corev1.Service) localv1alpha1.AddressFamily { + families := svc.Spec.IPFamilies + if len(families) == 2 && svc.Spec.IPFamilyPolicy != nil && + *svc.Spec.IPFamilyPolicy == corev1.IPFamilyPolicyRequireDualStack { + return localv1alpha1.FamilyDual + } + if len(families) > 0 && families[0] == corev1.IPv6Protocol { + return localv1alpha1.FamilyIPv6 + } + return localv1alpha1.FamilyIPv4 +} + +// servicesForClass maps an IPAddressClass event to every Service that may +// now qualify — a class newly marked for auto-claim, or one named by a +// Service annotation, may unblock it. +func (r *ServiceReconciler) servicesForClass(ctx context.Context, _ client.Object) []reconcile.Request { + services := &corev1.ServiceList{} + if err := r.List(ctx, services); err != nil { + return nil + } + var reqs []reconcile.Request + for _, s := range services.Items { + if wantsAutoClaim(&s) { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: s.Namespace, Name: s.Name}}) + } + } + return reqs +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.Service{}). + Watches(&localv1alpha1.IPAddressClass{}, handler.EnqueueRequestsFromMapFunc(r.servicesForClass)). + Named("service"). + Complete(r) +} diff --git a/internal/controller/service_controller_test.go b/internal/controller/service_controller_test.go new file mode 100644 index 0000000..f85c117 --- /dev/null +++ b/internal/controller/service_controller_test.go @@ -0,0 +1,238 @@ +/* +Copyright 2026 The Cozystack Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + localv1alpha1 "github.com/cozystack/address-controller/api/v1alpha1" +) + +const ( + testServiceName = "web-lb" + testServiceUID = "svc-uid-1" +) + +func serviceReconciler(c client.Client) *ServiceReconciler { + return &ServiceReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(100), + } +} + +func reconcileService(t *testing.T, r *ServiceReconciler) { + t.Helper() + _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testServiceName}, + }) + if err != nil { + t.Fatalf("reconcile: %v", err) + } +} + +func getService(t *testing.T, c client.Client) *corev1.Service { + t.Helper() + svc := &corev1.Service{} + if err := c.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: testServiceName}, svc); err != nil { + t.Fatalf("get service: %v", err) + } + return svc +} + +// getAutoClaim returns the claim named after the test Service, or nil. +func getAutoClaim(t *testing.T, c client.Client) *localv1alpha1.IPAddressClaim { + t.Helper() + claim := &localv1alpha1.IPAddressClaim{} + err := c.Get(context.Background(), types.NamespacedName{Namespace: testNamespace, Name: testServiceName}, claim) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + t.Fatalf("get claim: %v", err) + } + return claim +} + +func autoClaimClass(name string) *localv1alpha1.IPAddressClass { + class := publicClass(map[string]string{localv1alpha1.IsAutoClaimClassAnnotation: "true"}) + class.Name = name + return class +} + +func loadBalancerService() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: testServiceName, Namespace: testNamespace, UID: testServiceUID}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeLoadBalancer, + IPFamilies: []corev1.IPFamily{corev1.IPv4Protocol}, + }, + } +} + +func TestAutoClaimCreatesOwnedClaimAndAnnotatesService(t *testing.T) { + c := testClient(t, autoClaimClass(testClassName), loadBalancerService()) + reconcileService(t, serviceReconciler(c)) + + claim := getAutoClaim(t, c) + if claim == nil { + t.Fatal("no claim auto-provisioned") + } + if claim.Spec.ClassName != testClassName { + t.Errorf("claim className = %q, want %q", claim.Spec.ClassName, testClassName) + } + if claim.Spec.Family != localv1alpha1.FamilyIPv4 { + t.Errorf("claim family = %q, want IPv4", claim.Spec.Family) + } + if ref := metav1.GetControllerOf(claim); ref == nil || ref.Kind != serviceKind || ref.UID != testServiceUID { + t.Errorf("claim controller = %+v, want the Service", ref) + } + if got := getService(t, c).Annotations[localv1alpha1.ServiceClaimAnnotation]; got != testServiceName { + t.Errorf("service claim annotation = %q, want %q", got, testServiceName) + } +} + +func TestAutoClaimSkipsIneligibleServices(t *testing.T) { + cases := map[string]func(*corev1.Service){ + "not a LoadBalancer": func(s *corev1.Service) { s.Spec.Type = corev1.ServiceTypeClusterIP }, + "names a claim": func(s *corev1.Service) { + s.Annotations = map[string]string{localv1alpha1.ServiceClaimAnnotation: "mine"} + }, + "opted out": func(s *corev1.Service) { + s.Annotations = map[string]string{localv1alpha1.ServiceAutoClaimAnnotation: "false"} + }, + "other load balancer class": func(s *corev1.Service) { s.Spec.LoadBalancerClass = new("example.com/lb") }, + "asks for a specific IP": func(s *corev1.Service) { s.Spec.LoadBalancerIP = "203.0.113.50" }, + "already addressed": func(s *corev1.Service) { + s.Status.LoadBalancer.Ingress = []corev1.LoadBalancerIngress{{IP: "203.0.113.51"}} + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + svc := loadBalancerService() + mutate(svc) + c := testClient(t, autoClaimClass(testClassName), svc) + reconcileService(t, serviceReconciler(c)) + + if claim := getAutoClaim(t, c); claim != nil { + t.Errorf("claim auto-provisioned for an ineligible Service") + } + }) + } +} + +func TestAutoClaimWithoutMarkedClassLeavesServiceAlone(t *testing.T) { + c := testClient(t, defaultClass(), loadBalancerService()) + reconcileService(t, serviceReconciler(c)) + + if claim := getAutoClaim(t, c); claim != nil { + t.Error("claim auto-provisioned although no class is marked for auto-claim") + } + if _, ok := getService(t, c).Annotations[localv1alpha1.ServiceClaimAnnotation]; ok { + t.Error("service annotated although nothing was claimed") + } +} + +func TestAutoClaimWithMultipleMarkedClassesClaimsNothing(t *testing.T) { + c := testClient(t, autoClaimClass("a"), autoClaimClass("b"), loadBalancerService()) + reconcileService(t, serviceReconciler(c)) + + if claim := getAutoClaim(t, c); claim != nil { + t.Error("claim auto-provisioned despite an ambiguous auto-claim class") + } +} + +func TestAutoClaimUsesClassNamedByService(t *testing.T) { + svc := loadBalancerService() + svc.Annotations = map[string]string{localv1alpha1.ServiceClassAnnotation: "private"} + c := testClient(t, autoClaimClass(testClassName), svc) + reconcileService(t, serviceReconciler(c)) + + claim := getAutoClaim(t, c) + if claim == nil { + t.Fatal("no claim auto-provisioned") + } + if claim.Spec.ClassName != "private" { + t.Errorf("claim className = %q, want the Service's %q", claim.Spec.ClassName, "private") + } +} + +func TestAutoClaimRefusesForeignClaimOfSameName(t *testing.T) { + foreign := pendingClaim(testClassName) + foreign.Name = testServiceName + c := testClient(t, autoClaimClass(testClassName), loadBalancerService(), foreign) + reconcileService(t, serviceReconciler(c)) + + if _, ok := getService(t, c).Annotations[localv1alpha1.ServiceClaimAnnotation]; ok { + t.Error("service pointed at a claim it does not control") + } + if claim := getAutoClaim(t, c); metav1.GetControllerOf(claim) != nil { + t.Error("foreign claim was adopted") + } +} + +func TestAutoClaimResumesWithOwnedClaim(t *testing.T) { + // A crash between creating the claim and annotating the Service leaves + // an owned claim behind; the next pass must finish the job. + svc := loadBalancerService() + owned := pendingClaim(testClassName) + owned.Name = testServiceName + owned.OwnerReferences = []metav1.OwnerReference{{ + APIVersion: "v1", Kind: serviceKind, Name: testServiceName, UID: testServiceUID, Controller: new(true), + }} + c := testClient(t, autoClaimClass(testClassName), svc, owned) + reconcileService(t, serviceReconciler(c)) + + if got := getService(t, c).Annotations[localv1alpha1.ServiceClaimAnnotation]; got != testServiceName { + t.Errorf("service claim annotation = %q, want %q", got, testServiceName) + } +} + +func TestFamilyForService(t *testing.T) { + cases := map[string]struct { + families []corev1.IPFamily + policy corev1.IPFamilyPolicy + want localv1alpha1.AddressFamily + }{ + "single v4": {[]corev1.IPFamily{corev1.IPv4Protocol}, corev1.IPFamilyPolicySingleStack, localv1alpha1.FamilyIPv4}, + "single v6": {[]corev1.IPFamily{corev1.IPv6Protocol}, corev1.IPFamilyPolicySingleStack, localv1alpha1.FamilyIPv6}, + "require dual": {[]corev1.IPFamily{corev1.IPv6Protocol, corev1.IPv4Protocol}, corev1.IPFamilyPolicyRequireDualStack, localv1alpha1.FamilyDual}, + "prefer dual": {[]corev1.IPFamily{corev1.IPv6Protocol, corev1.IPv4Protocol}, corev1.IPFamilyPolicyPreferDualStack, localv1alpha1.FamilyIPv6}, + "families not set": {nil, "", localv1alpha1.FamilyIPv4}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + svc := loadBalancerService() + svc.Spec.IPFamilies = tc.families + if tc.policy != "" { + svc.Spec.IPFamilyPolicy = new(tc.policy) + } + if got := familyForService(svc); got != tc.want { + t.Errorf("family = %q, want %q", got, tc.want) + } + }) + } +}