diff --git a/.golangci.yml b/.golangci.yml index b2a70c10..4b16e044 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -98,6 +98,7 @@ linters-settings: - error - generic - "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/ionoscloud.Client" + - "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/loadbalancing.Provisioner" loggercheck: require-string-key: true no-printf-like: true @@ -135,6 +136,12 @@ issues: # changes in PRs and avoid nitpicking. exclude-use-default: false exclude-rules: + # TODO(lubedacht): remove this exclusion, once the provisioners are implemented + # I couldn't find a place where to put nolint to disable this linter inline. + - linters: + - dupl + path: "provisioner_(.+).go" + text: "(\\d+)-(\\d+) lines are duplicate of .*" - linters: - containedctx path: '(.+)_test\.go' diff --git a/PROJECT b/PROJECT index 03204bd2..7a0d1b34 100644 --- a/PROJECT +++ b/PROJECT @@ -34,4 +34,13 @@ resources: kind: IonosCloudMachineTemplate path: github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: cluster.x-k8s.io + group: infrastructure + kind: IonosCloudLoadBalancer + path: github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/ionoscloudloadbalancer_types.go b/api/v1alpha1/ionoscloudloadbalancer_types.go new file mode 100644 index 00000000..1c600f35 --- /dev/null +++ b/api/v1alpha1/ionoscloudloadbalancer_types.go @@ -0,0 +1,130 @@ +/* +Copyright 2024 IONOS Cloud. + +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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +const ( + // LoadBalancerFinalizer allows cleanup of resources, which are + // associated with the IonosCloudLoadBalancer before removing it from the API server. + LoadBalancerFinalizer = "ionoscloudloadbalancer.infrastructure.cluster.x-k8s.io" + + // LoadBalancerReadyCondition is the condition for the IonosCloudLoadBalancer, which indicates that the load balancer is ready. + LoadBalancerReadyCondition clusterv1.ConditionType = "LoadBalancerReady" + + // InvalidEndpointConfigurationReason indicates that the endpoints for IonosCloudCluster and IonosCloudLoadBalancer + // have not been properly configured. + InvalidEndpointConfigurationReason = "InvalidEndpointConfiguration" +) + +// IonosCloudLoadBalancerSpec defines the desired state of IonosCloudLoadBalancer. +type IonosCloudLoadBalancerSpec struct { + // LoadBalancerEndpoint represents the endpoint of the load balanced control plane. + // If the endpoint isn't provided, the controller will reserve a new public IP address. + // The port is optional and defaults to 6443. + // + // For external load balancers, the endpoint and port must be provided. + //+kubebuilder:validation:XValidation:rule="self.host == oldSelf.host || oldSelf.host == ''",message="control plane endpoint host cannot be updated" + //+kubebuilder:validation:XValidation:rule="self.port == oldSelf.port || oldSelf.port == 0",message="control plane endpoint port cannot be updated" + LoadBalancerEndpoint clusterv1.APIEndpoint `json:"loadBalancerEndpoint,omitempty"` + + // LoadBalancerSource is the actual load balancer definition. + LoadBalancerSource `json:",inline"` +} + +// LoadBalancerSource defines the source of the load balancer. +type LoadBalancerSource struct { + // NLB is used for setting up a network load balancer. + //+optional + NLB *NLBSpec `json:"nlb,omitempty"` + + // KubeVIP is used for setting up a highly available control plane. + //+optional + KubeVIP *KubeVIPSpec `json:"kubeVIP,omitempty"` +} + +// NLBSpec defines the spec for a network load balancer. +type NLBSpec struct { + // DatacenterID is the ID of the datacenter where the load balancer should be created. + //+kubebuilder:validation:XValidation:rule="self == oldSelf",message="datacenterID is immutable" + //+kubebuilder:validation:Format=uuid + //+required + DatacenterID string `json:"datacenterID"` +} + +// KubeVIPSpec defines the spec for a high availability load balancer. +type KubeVIPSpec struct { + // Image is the container image to use for the KubeVIP static pod. + // If not provided, the default image will be used. + //+optional + Image string `json:"image,omitempty"` +} + +// IonosCloudLoadBalancerStatus defines the observed state of IonosCloudLoadBalancer. +type IonosCloudLoadBalancerStatus struct { + // Ready indicates that the load balancer is ready. + //+optional + Ready bool `json:"ready,omitempty"` + + // Conditions defines current service state of the IonosCloudLoadBalancer. + //+optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` + + // CurrentRequest shows the current provisioning request for any + // cloud resource that is being provisioned. + //+optional + CurrentRequest *ProvisioningRequest `json:"currentRequest,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// IonosCloudLoadBalancer is the Schema for the ionoscloudloadbalancers API +// +kubebuilder:resource:path=ionoscloudloadbalancers,scope=Namespaced,categories=cluster-api;ionoscloud,shortName=iclb +type IonosCloudLoadBalancer struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec IonosCloudLoadBalancerSpec `json:"spec,omitempty"` + Status IonosCloudLoadBalancerStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// IonosCloudLoadBalancerList contains a list of IonosCloudLoadBalancer. +type IonosCloudLoadBalancerList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []IonosCloudLoadBalancer `json:"items"` +} + +// GetConditions returns the conditions from the status. +func (l *IonosCloudLoadBalancer) GetConditions() clusterv1.Conditions { + return l.Status.Conditions +} + +// SetConditions sets the conditions in the status. +func (l *IonosCloudLoadBalancer) SetConditions(conditions clusterv1.Conditions) { + l.Status.Conditions = conditions +} + +func init() { + objectTypes = append(objectTypes, &IonosCloudLoadBalancer{}) +} diff --git a/api/v1alpha1/ionoscloudloadbalancer_types_test.go b/api/v1alpha1/ionoscloudloadbalancer_types_test.go new file mode 100644 index 00000000..ca6db854 --- /dev/null +++ b/api/v1alpha1/ionoscloudloadbalancer_types_test.go @@ -0,0 +1,115 @@ +/* +Copyright 2024 IONOS Cloud. + +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 v1alpha1 + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ( + exampleDatacenterID = "fe3b4e3d-3b0e-4e6c-9e3e-4f3c9e3e4f3c" + exampleSecondaryDatacenterID = "fe3b4e3d-3b0e-4e6c-9e3e-4f3c9e3e4f3d" +) + +var exampleEndpoint = clusterv1.APIEndpoint{ + Host: "example.com", + Port: 6443, +} + +func defaultLoadBalancer(source LoadBalancerSource) *IonosCloudLoadBalancer { + return &IonosCloudLoadBalancer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-loadbalancer", + Namespace: metav1.NamespaceDefault, + }, + Spec: IonosCloudLoadBalancerSpec{ + LoadBalancerSource: source, + }, + } +} + +var _ = Describe("IonosCloudLoadBalancer", func() { + AfterEach(func() { + err := k8sClient.Delete(context.Background(), defaultLoadBalancer(LoadBalancerSource{})) + Expect(client.IgnoreNotFound(err)).To(Succeed()) + }) + + Context("Create", func() { + When("Using a KubeVIP load balancer", func() { + It("Should succeed when no image is provided", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{KubeVIP: &KubeVIPSpec{}}) + Expect(k8sClient.Create(context.Background(), dlb)).To(Succeed()) + }) + It("Should succeed with an endpoint and a port", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{KubeVIP: &KubeVIPSpec{}}) + dlb.Spec.LoadBalancerEndpoint = exampleEndpoint + Expect(k8sClient.Create(context.Background(), dlb)).To(Succeed()) + }) + }) + When("Using an NLB", func() { + It("Should fail when not providing a datacenter ID", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{NLB: &NLBSpec{}}) + Expect(k8sClient.Create(context.Background(), dlb)).NotTo(Succeed()) + }) + It("Should fail when not providing a uuid for the datacenter ID", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{NLB: &NLBSpec{DatacenterID: "something-invalid"}}) + Expect(k8sClient.Create(context.Background(), dlb)).NotTo(Succeed()) + }) + It("Should succeed when providing a datacenter ID", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{NLB: &NLBSpec{DatacenterID: exampleDatacenterID}}) + Expect(k8sClient.Create(context.Background(), dlb)).To(Succeed()) + }) + It("Should succeed providing an endpoint and a port", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{NLB: &NLBSpec{DatacenterID: exampleDatacenterID}}) + dlb.Spec.LoadBalancerEndpoint = exampleEndpoint + Expect(k8sClient.Create(context.Background(), dlb)).To(Succeed()) + }) + It("Should fail when providing a host and a port without a datacenter ID", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{NLB: &NLBSpec{}}) + dlb.Spec.LoadBalancerEndpoint = exampleEndpoint + Expect(k8sClient.Create(context.Background(), dlb)).NotTo(Succeed()) + }) + }) + Context("Update", func() { + When("Using a KubeVIP load balancer", func() { + It("Should succeed creating a KubeVIP load balancer with an empty endpoint and updating it", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{KubeVIP: &KubeVIPSpec{}}) + Expect(k8sClient.Create(context.Background(), dlb)).To(Succeed()) + + dlb.Spec.LoadBalancerEndpoint = exampleEndpoint + Expect(k8sClient.Update(context.Background(), dlb)).To(Succeed()) + }) + }) + When("Using an NLB", func() { + It("Should fail when attempting to update the datacenter ID", func() { + dlb := defaultLoadBalancer(LoadBalancerSource{NLB: &NLBSpec{DatacenterID: exampleDatacenterID}}) + Expect(k8sClient.Create(context.Background(), dlb)).To(Succeed()) + + dlb.Spec.NLB.DatacenterID = exampleSecondaryDatacenterID + Expect(k8sClient.Update(context.Background(), dlb)).NotTo(Succeed()) + }) + }) + }) + }) +}) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 75adbd9c..715fe396 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -247,6 +247,109 @@ func (in *IonosCloudClusterTemplateSpec) DeepCopy() *IonosCloudClusterTemplateSp return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IonosCloudLoadBalancer) DeepCopyInto(out *IonosCloudLoadBalancer) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IonosCloudLoadBalancer. +func (in *IonosCloudLoadBalancer) DeepCopy() *IonosCloudLoadBalancer { + if in == nil { + return nil + } + out := new(IonosCloudLoadBalancer) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IonosCloudLoadBalancer) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IonosCloudLoadBalancerList) DeepCopyInto(out *IonosCloudLoadBalancerList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]IonosCloudLoadBalancer, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IonosCloudLoadBalancerList. +func (in *IonosCloudLoadBalancerList) DeepCopy() *IonosCloudLoadBalancerList { + if in == nil { + return nil + } + out := new(IonosCloudLoadBalancerList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IonosCloudLoadBalancerList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IonosCloudLoadBalancerSpec) DeepCopyInto(out *IonosCloudLoadBalancerSpec) { + *out = *in + out.LoadBalancerEndpoint = in.LoadBalancerEndpoint + in.LoadBalancerSource.DeepCopyInto(&out.LoadBalancerSource) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IonosCloudLoadBalancerSpec. +func (in *IonosCloudLoadBalancerSpec) DeepCopy() *IonosCloudLoadBalancerSpec { + if in == nil { + return nil + } + out := new(IonosCloudLoadBalancerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IonosCloudLoadBalancerStatus) DeepCopyInto(out *IonosCloudLoadBalancerStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.CurrentRequest != nil { + in, out := &in.CurrentRequest, &out.CurrentRequest + *out = new(ProvisioningRequest) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IonosCloudLoadBalancerStatus. +func (in *IonosCloudLoadBalancerStatus) DeepCopy() *IonosCloudLoadBalancerStatus { + if in == nil { + return nil + } + out := new(IonosCloudLoadBalancerStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IonosCloudMachine) DeepCopyInto(out *IonosCloudMachine) { *out = *in @@ -479,6 +582,46 @@ func (in *IonosCloudMachineTemplateSpec) DeepCopy() *IonosCloudMachineTemplateSp return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeVIPSpec) DeepCopyInto(out *KubeVIPSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeVIPSpec. +func (in *KubeVIPSpec) DeepCopy() *KubeVIPSpec { + if in == nil { + return nil + } + out := new(KubeVIPSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LoadBalancerSource) DeepCopyInto(out *LoadBalancerSource) { + *out = *in + if in.NLB != nil { + in, out := &in.NLB, &out.NLB + *out = new(NLBSpec) + **out = **in + } + if in.KubeVIP != nil { + in, out := &in.KubeVIP, &out.KubeVIP + *out = new(KubeVIPSpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LoadBalancerSource. +func (in *LoadBalancerSource) DeepCopy() *LoadBalancerSource { + if in == nil { + return nil + } + out := new(LoadBalancerSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MachineNetworkInfo) DeepCopyInto(out *MachineNetworkInfo) { *out = *in @@ -526,6 +669,21 @@ func (in *NICInfo) DeepCopy() *NICInfo { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NLBSpec) DeepCopyInto(out *NLBSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NLBSpec. +func (in *NLBSpec) DeepCopy() *NLBSpec { + if in == nil { + return nil + } + out := new(NLBSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Network) DeepCopyInto(out *Network) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 3f1dcf51..cc8143f4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -44,8 +44,9 @@ var ( enableLeaderElection bool diagnosticOptions = flags.DiagnosticsOptions{} - icClusterConcurrency int - icMachineConcurrency int + icClusterConcurrency int + icMachineConcurrency int + icLoadBalancerConcurrency int ) func init() { @@ -90,21 +91,31 @@ func main() { ctx := ctrl.SetupSignalHandler() + const errMsg = "unable to create controller" if err = iccontroller.NewIonosCloudClusterReconciler(mgr).SetupWithManager( ctx, mgr, controller.Options{MaxConcurrentReconciles: icClusterConcurrency}, ); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "IonosCloudCluster") + setupLog.Error(err, errMsg, "controller", "IonosCloudCluster") os.Exit(1) } if err = iccontroller.NewIonosCloudMachineReconciler(mgr).SetupWithManager( mgr, controller.Options{MaxConcurrentReconciles: icMachineConcurrency}, ); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "IonosCloudMachine") + setupLog.Error(err, errMsg, "controller", "IonosCloudMachine") os.Exit(1) } + if err = iccontroller.NewIonosCloudLoadBalancerReconciler(mgr).SetupWithManager( + ctx, + mgr, + controller.Options{MaxConcurrentReconciles: icLoadBalancerConcurrency}, + ); err != nil { + setupLog.Error(err, errMsg, "controller", "IonosCloudLoadBalancer") + os.Exit(1) + } + //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { @@ -137,4 +148,6 @@ func initFlags() { "Number of IonosCloudClusters to process simultaneously") pflag.IntVar(&icMachineConcurrency, "ionoscloudmachine-concurrency", 1, "Number of IonosCloudMachines to process simultaneously") + pflag.IntVar(&icLoadBalancerConcurrency, "ionoscloudloadbalancer-concurrency", 1, + "Number of IonosCloudLoadBalancers to process simultaneously") } diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudloadbalancers.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudloadbalancers.yaml new file mode 100644 index 00000000..60ff6c56 --- /dev/null +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_ionoscloudloadbalancers.yaml @@ -0,0 +1,179 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: ionoscloudloadbalancers.infrastructure.cluster.x-k8s.io +spec: + group: infrastructure.cluster.x-k8s.io + names: + categories: + - cluster-api + - ionoscloud + kind: IonosCloudLoadBalancer + listKind: IonosCloudLoadBalancerList + plural: ionoscloudloadbalancers + shortNames: + - iclb + singular: ionoscloudloadbalancer + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: IonosCloudLoadBalancer is the Schema for the ionoscloudloadbalancers + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: IonosCloudLoadBalancerSpec defines the desired state of IonosCloudLoadBalancer. + properties: + kubeVIP: + description: KubeVIP is used for setting up a highly available control + plane. + properties: + image: + description: |- + Image is the container image to use for the KubeVIP static pod. + If not provided, the default image will be used. + type: string + type: object + loadBalancerEndpoint: + description: |- + LoadBalancerEndpoint represents the endpoint of the load balanced control plane. + If the endpoint isn't provided, the controller will reserve a new public IP address. + The port is optional and defaults to 6443. + + + For external load balancers, the endpoint and port must be provided. + properties: + host: + description: The hostname on which the API server is serving. + type: string + port: + description: The port on which the API server is serving. + format: int32 + type: integer + required: + - host + - port + type: object + x-kubernetes-validations: + - message: control plane endpoint host cannot be updated + rule: self.host == oldSelf.host || oldSelf.host == '' + - message: control plane endpoint port cannot be updated + rule: self.port == oldSelf.port || oldSelf.port == 0 + nlb: + description: NLB is used for setting up a network load balancer. + properties: + datacenterID: + description: DatacenterID is the ID of the datacenter where the + load balancer should be created. + format: uuid + type: string + x-kubernetes-validations: + - message: datacenterID is immutable + rule: self == oldSelf + required: + - datacenterID + type: object + type: object + status: + description: IonosCloudLoadBalancerStatus defines the observed state of + IonosCloudLoadBalancer. + properties: + conditions: + description: Conditions defines current service state of the IonosCloudLoadBalancer. + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + A human readable message indicating details about the transition. + This field may be empty. + type: string + reason: + description: |- + The reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may not be empty. + type: string + severity: + description: |- + Severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + currentRequest: + description: |- + CurrentRequest shows the current provisioning request for any + cloud resource that is being provisioned. + properties: + method: + description: Method is the request method + type: string + requestPath: + description: RequestPath is the sub path for the request URL + type: string + state: + description: RequestStatus is the status of the request in the + queue. + enum: + - QUEUED + - RUNNING + - DONE + - FAILED + type: string + required: + - method + - requestPath + type: object + ready: + description: Ready indicates that the load balancer is ready. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 59e9227f..5f8998ec 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -6,6 +6,7 @@ resources: - bases/infrastructure.cluster.x-k8s.io_ionoscloudclustertemplates.yaml - bases/infrastructure.cluster.x-k8s.io_ionoscloudmachines.yaml - bases/infrastructure.cluster.x-k8s.io_ionoscloudmachinetemplates.yaml +- bases/infrastructure.cluster.x-k8s.io_ionoscloudloadbalancers.yaml #+kubebuilder:scaffold:crdkustomizeresource commonLabels: @@ -24,6 +25,7 @@ patches: #- path: patches/cainjection_in_ionoscloudclusters.yaml #- path: patches/cainjection_in_ionoscloudmachines.yaml #- path: patches/cainjection_in_ionoscloudmachinetemplates.yaml +#- path: patches/cainjection_in_ionoscloudloadbalancers.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/rbac/ionoscloudloadbalancer_editor_role.yaml b/config/rbac/ionoscloudloadbalancer_editor_role.yaml new file mode 100644 index 00000000..b06734f9 --- /dev/null +++ b/config/rbac/ionoscloudloadbalancer_editor_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to edit ionoscloudloadbalancers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-ionoscloud + app.kubernetes.io/managed-by: kustomize + name: ionoscloudloadbalancer-editor-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - ionoscloudloadbalancers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - ionoscloudloadbalancers/status + verbs: + - get diff --git a/config/rbac/ionoscloudloadbalancer_viewer_role.yaml b/config/rbac/ionoscloudloadbalancer_viewer_role.yaml new file mode 100644 index 00000000..3dd1dc3e --- /dev/null +++ b/config/rbac/ionoscloudloadbalancer_viewer_role.yaml @@ -0,0 +1,23 @@ +# permissions for end users to view ionoscloudloadbalancers. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-ionoscloud + app.kubernetes.io/managed-by: kustomize + name: ionoscloudloadbalancer-viewer-role +rules: +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - ionoscloudloadbalancers + verbs: + - get + - list + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - ionoscloudloadbalancers/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 9e48d673..718342b4 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -80,6 +80,32 @@ rules: - get - patch - update +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - ionoscloudloadbalancers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - ionoscloudloadbalancers/finalizers + verbs: + - update +- apiGroups: + - infrastructure.cluster.x-k8s.io + resources: + - ionoscloudloadbalancers/status + verbs: + - get + - patch + - update - apiGroups: - infrastructure.cluster.x-k8s.io resources: diff --git a/config/samples/infrastructure_v1alpha1_ionoscloudloadbalancer.yaml b/config/samples/infrastructure_v1alpha1_ionoscloudloadbalancer.yaml new file mode 100644 index 00000000..0f447130 --- /dev/null +++ b/config/samples/infrastructure_v1alpha1_ionoscloudloadbalancer.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: IonosCloudLoadBalancer +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-ionoscloud + app.kubernetes.io/managed-by: kustomize + name: ionoscloudloadbalancer-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index ddc5c7bd..c63352c8 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -4,4 +4,5 @@ resources: - infrastructure_v1alpha1_ionoscloudcluster.yaml - infrastructure_v1alpha1_ionoscloudmachine.yaml - infrastructure_v1alpha1_ionoscloudmachinetemplate.yaml +- infrastructure_v1alpha1_ionoscloudloadbalancer.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/internal/controller/ionoscloudcluster_controller.go b/internal/controller/ionoscloudcluster_controller.go index e645ae32..ada6486a 100644 --- a/internal/controller/ionoscloudcluster_controller.go +++ b/internal/controller/ionoscloudcluster_controller.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" + "github.com/google/go-cmp/cmp" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" @@ -135,41 +136,56 @@ func (r *IonosCloudClusterReconciler) reconcileNormal( clusterScope *scope.Cluster, cloudService *cloud.Service, ) (ctrl.Result, error) { - log := ctrl.LoggerFrom(ctx) + logger := ctrl.LoggerFrom(ctx) controllerutil.AddFinalizer(clusterScope.IonosCluster, infrav1.ClusterFinalizer) - log.V(4).Info("Reconciling IonosCloudCluster") + logger.V(4).Info("Reconciling IonosCloudCluster") requeue, err := r.checkRequestStatus(ctx, clusterScope, cloudService) if err != nil { return ctrl.Result{}, fmt.Errorf("error when trying to determine in-flight request states: %w", err) } if requeue { - log.Info("Request is still in progress") + logger.Info("Request is still in progress") return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil } - var reconcileSequence []serviceReconcileStep[scope.Cluster] - // TODO: This logic needs to move to another controller. if clusterScope.IonosCluster.Spec.LoadBalancerProviderRef != nil { - // Reserving IP Blocks only makes sense for LB implementations or HA setup with kube-vip. - // - // As we are currently expecting to supply the control plane endpoint manually, - // logic-wise nothing changes for us. As soon as we have implemented - // the load balancer controller, the cluster controller logic will be basically empty. - reconcileSequence = []serviceReconcileStep[scope.Cluster]{ - {"ReconcileControlPlaneEndpoint", cloudService.ReconcileControlPlaneEndpoint}, + var loadBalancer infrav1.IonosCloudLoadBalancer + + cl := clusterScope.IonosCluster + lbKey := client.ObjectKey{ + Namespace: cl.Namespace, + Name: cl.Spec.LoadBalancerProviderRef.Name, } - } - for _, step := range reconcileSequence { - if requeue, err := step.fn(ctx, clusterScope); err != nil || requeue { - if err != nil { - err = fmt.Errorf("error in step %s: %w", step.name, err) + if err := r.Client.Get(ctx, lbKey, &loadBalancer); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil } + return ctrl.Result{}, err + } - return ctrl.Result{RequeueAfter: defaultReconcileDuration}, err + // To make sure that the load balancer knows the related cluster, we will apply a + // controller reference to the load balancer. + if err := r.applyLoadBalancerMeta(ctx, &loadBalancer, cl); err != nil { + return ctrl.Result{}, err } + + // If the load balancer is not ready, we will requeue the reconciliation. + if !loadBalancer.Status.Ready { + return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil + } + + // TODO: This logic needs to move to another controller. + // Reserving IP Blocks only makes sense for LB implementations or HA setup with kube-vip. + // + // As we are currently expecting to supply the control plane endpoint manually, + // logic-wise nothing changes for us. As soon as we have implemented + // the load balancer controller, the cluster controller logic will be basically empty. + // reconcileSequence = []serviceReconcileStep[scope.Cluster]{ + // {"ReconcileControlPlaneEndpoint", cloudService.ReconcileControlPlaneEndpoint}, + // } } conditions.MarkTrue(clusterScope.IonosCluster, infrav1.IonosCloudClusterReady) @@ -180,9 +196,9 @@ func (r *IonosCloudClusterReconciler) reconcileNormal( func (r *IonosCloudClusterReconciler) reconcileDelete( ctx context.Context, clusterScope *scope.Cluster, cloudService *cloud.Service, ) (ctrl.Result, error) { - log := ctrl.LoggerFrom(ctx) + logger := ctrl.LoggerFrom(ctx) if clusterScope.Cluster.DeletionTimestamp.IsZero() { - log.Error(errors.New("deletion was requested but owning cluster wasn't deleted"), + logger.Error(errors.New("deletion was requested but owning cluster wasn't deleted"), "unable to delete IonosCloudCluster") // No need to reconcile again until the owning cluster was deleted. return ctrl.Result{}, nil @@ -193,7 +209,7 @@ func (r *IonosCloudClusterReconciler) reconcileDelete( return ctrl.Result{}, fmt.Errorf("error when trying to determine in-flight request states: %w", err) } if requeue { - log.Info("Request is still in progress") + logger.Info("Request is still in progress") return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil } @@ -203,7 +219,7 @@ func (r *IonosCloudClusterReconciler) reconcileDelete( } if len(machines) > 0 { - log.Info("Waiting for all IonosCloudMachines to be deleted", "remaining", len(machines)) + logger.Info("Waiting for all IonosCloudMachines to be deleted", "remaining", len(machines)) return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil } @@ -229,14 +245,15 @@ func (r *IonosCloudClusterReconciler) reconcileDelete( func (*IonosCloudClusterReconciler) checkRequestStatus( ctx context.Context, clusterScope *scope.Cluster, cloudService *cloud.Service, ) (requeue bool, retErr error) { - log := ctrl.LoggerFrom(ctx) + logger := ctrl.LoggerFrom(ctx) ionosCluster := clusterScope.IonosCluster if req := ionosCluster.Status.CurrentClusterRequest; req != nil { + logger.Info("Checking request status", "request", req.RequestPath, "method", req.Method) status, message, err := cloudService.GetRequestStatus(ctx, req.RequestPath) if err != nil { retErr = fmt.Errorf("could not get request status: %w", err) } else { - requeue, retErr = withStatus(status, message, &log, + requeue, retErr = withStatus(status, message, &logger, func() error { ionosCluster.DeleteCurrentClusterRequest() return nil @@ -247,6 +264,27 @@ func (*IonosCloudClusterReconciler) checkRequestStatus( return requeue, retErr } +func (r *IonosCloudClusterReconciler) applyLoadBalancerMeta( + ctx context.Context, + loadBalancer *infrav1.IonosCloudLoadBalancer, + ionosCloudCluster *infrav1.IonosCloudCluster, +) error { + beforeObject := loadBalancer.DeepCopy() + + if err := controllerutil.SetOwnerReference(ionosCloudCluster, loadBalancer, r.scheme); err != nil { + return err + } + + loadBalancerLabels, clusterLabels := loadBalancer.GetLabels(), ionosCloudCluster.GetLabels() + loadBalancerLabels[clusterv1.ClusterNameLabel] = clusterLabels[clusterv1.ClusterNameLabel] + + if !cmp.Equal(beforeObject.ObjectMeta, loadBalancer.ObjectMeta) { + return r.Client.Patch(ctx, loadBalancer, client.MergeFrom(beforeObject)) + } + + return nil +} + // SetupWithManager sets up the controller with the Manager. func (r *IonosCloudClusterReconciler) SetupWithManager( ctx context.Context, diff --git a/internal/controller/ionoscloudloadbalancer_controller.go b/internal/controller/ionoscloudloadbalancer_controller.go new file mode 100644 index 00000000..759cddd6 --- /dev/null +++ b/internal/controller/ionoscloudloadbalancer_controller.go @@ -0,0 +1,271 @@ +/* +Copyright 2024 IONOS Cloud. + +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" + "errors" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/annotations" + "sigs.k8s.io/cluster-api/util/conditions" + "sigs.k8s.io/cluster-api/util/predicates" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + infrav1 "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/loadbalancing" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/util/locker" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/scope" +) + +var errOwnerReferenceMissing = errors.New("owner reference not yet applied by cluster controller") + +// IonosCloudLoadBalancerReconciler reconciles a IonosCloudLoadBalancer object. +type IonosCloudLoadBalancerReconciler struct { + client.Client + scheme *runtime.Scheme + locker *locker.Locker +} + +// NewIonosCloudLoadBalancerReconciler creates a new IonosCloudLoadBalancerReconciler. +func NewIonosCloudLoadBalancerReconciler(mgr ctrl.Manager) *IonosCloudLoadBalancerReconciler { + r := &IonosCloudLoadBalancerReconciler{ + Client: mgr.GetClient(), + scheme: mgr.GetScheme(), + locker: locker.New(), + } + return r +} + +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=ionoscloudloadbalancers,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=ionoscloudloadbalancers/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=ionoscloudloadbalancers/finalizers,verbs=update + +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.18.4/pkg/reconcile +func (r *IonosCloudLoadBalancerReconciler) Reconcile( + ctx context.Context, + ionosCloudLoadBalancer *infrav1.IonosCloudLoadBalancer, +) (_ ctrl.Result, retErr error) { + logger := log.FromContext(ctx) + + logger.V(4).Info("Reconciling IonosCloudLoadBalancer") + + ionosCluster, err := r.getIonosCluster(ctx, ionosCloudLoadBalancer.ObjectMeta) + if err != nil { + if errors.Is(err, errOwnerReferenceMissing) { + logger.Info("Owner reference not yet applied to IonosCloudLoadBalancer") + return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil + } + + return ctrl.Result{}, err + } + + cluster, err := util.GetOwnerCluster(ctx, r.Client, ionosCluster.ObjectMeta) + if err != nil { + return ctrl.Result{}, err + } + + if cluster == nil { + logger.Info("Waiting for cluster controller to set OwnerRef on IonosCloudCluster") + return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil + } + + if annotations.IsPaused(cluster, ionosCloudLoadBalancer) { + logger.Info("IONOS Cloud load balancer or linked cluster is marked as paused. not reconciling") + return ctrl.Result{}, nil + } + + // TODO(lubedacht) this check needs to move into a validating webhook and should prevent that the resource + // can be applied in the first place. + if err = r.validateLoadBalancerSource(ionosCloudLoadBalancer.Spec.LoadBalancerSource); err != nil { + return ctrl.Result{}, reconcile.TerminalError(err) + } + + clusterScope, err := scope.NewCluster(scope.ClusterParams{ + Client: r.Client, + Cluster: cluster, + IonosCluster: ionosCluster, + Locker: r.locker, + }) + if err != nil { + return ctrl.Result{}, err + } + + loadBalancerScope, err := scope.NewLoadBalancer(scope.LoadBalancerParams{ + Client: r.Client, + LoadBalancer: ionosCloudLoadBalancer, + ClusterScope: clusterScope, + Locker: r.locker, + }) + if err != nil { + return ctrl.Result{}, err + } + + defer func() { + err := loadBalancerScope.Finalize() + retErr = errors.Join(retErr, err) + }() + + cloudService, err := createServiceFromCluster(ctx, r.Client, ionosCluster, logger) + if err != nil { + return ctrl.Result{}, err + } + + prov, err := loadbalancing.NewProvisioner(cloudService, ionosCloudLoadBalancer.Spec.LoadBalancerSource) + if err != nil { + return ctrl.Result{}, err + } + + if !ionosCloudLoadBalancer.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, loadBalancerScope, prov) + } + + return r.reconcileNormal(ctx, loadBalancerScope, prov) +} + +func (r *IonosCloudLoadBalancerReconciler) getIonosCluster( + ctx context.Context, + meta metav1.ObjectMeta, +) (*infrav1.IonosCloudCluster, error) { + var ionosCluster infrav1.IonosCloudCluster + for _, ref := range meta.GetOwnerReferences() { + if ref.Kind != infrav1.IonosCloudClusterKind { + continue + } + + clusterKey := client.ObjectKey{Namespace: meta.Namespace, Name: ref.Name} + if err := r.Client.Get(ctx, clusterKey, &ionosCluster); err != nil { + return nil, err + } + + return &ionosCluster, nil + } + + return nil, errOwnerReferenceMissing +} + +func (r *IonosCloudLoadBalancerReconciler) reconcileNormal( + ctx context.Context, + loadBalancerScope *scope.LoadBalancer, + prov loadbalancing.Provisioner, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + logger.V(4).Info("Reconciling IonosCloudLoadBalancer") + + controllerutil.AddFinalizer(loadBalancerScope.LoadBalancer, infrav1.LoadBalancerFinalizer) + + if err := r.validateEndpoints(loadBalancerScope); err != nil { + conditions.MarkFalse( + loadBalancerScope.LoadBalancer, + infrav1.LoadBalancerReadyCondition, + infrav1.InvalidEndpointConfigurationReason, + clusterv1.ConditionSeverityError, "") + return ctrl.Result{}, reconcile.TerminalError(err) + } + + if requeue, err := prov.Provision(ctx, loadBalancerScope); err != nil || requeue { + if err != nil { + err = fmt.Errorf("error during provisioning: %w", err) + } + + return ctrl.Result{RequeueAfter: defaultReconcileDuration}, err + } + + conditions.MarkTrue(loadBalancerScope.LoadBalancer, infrav1.LoadBalancerReadyCondition) + loadBalancerScope.LoadBalancer.Status.Ready = true + + logger.V(4).Info("Successfully reconciled IonosCloudLoadBalancer") + return ctrl.Result{}, nil +} + +func (*IonosCloudLoadBalancerReconciler) reconcileDelete( + ctx context.Context, + loadBalancerScope *scope.LoadBalancer, + prov loadbalancing.Provisioner, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + logger.V(4).Info("Deleting IonosCloudLoadBalancer") + + if requeue, err := prov.Destroy(ctx, loadBalancerScope); err != nil || requeue { + if err != nil { + err = fmt.Errorf("error during cleanup: %w", err) + } + + return ctrl.Result{RequeueAfter: defaultReconcileDuration}, err + } + + controllerutil.RemoveFinalizer(loadBalancerScope.LoadBalancer, infrav1.LoadBalancerFinalizer) + logger.V(4).Info("Successfully deleted IonosCloudLoadBalancer") + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *IonosCloudLoadBalancerReconciler) SetupWithManager(ctx context.Context, + mgr ctrl.Manager, + options controller.Options, +) error { + return ctrl.NewControllerManagedBy(mgr). + WithOptions(options). + For(&infrav1.IonosCloudLoadBalancer{}). + WithEventFilter(predicates.ResourceNotPaused(ctrl.LoggerFrom(ctx))). + Complete(reconcile.AsReconciler[*infrav1.IonosCloudLoadBalancer](r.Client, r)) +} + +func (*IonosCloudLoadBalancerReconciler) validateEndpoints(loadBalancerScope *scope.LoadBalancer) error { + s := loadBalancerScope + + if s.InfraClusterEndpoint().IsValid() && s.Endpoint().IsZero() { + return errors.New("infra cluster already has an endpoint set, but the load balancer does not") + } + + if s.InfraClusterEndpoint().IsValid() && s.Endpoint().IsValid() { + if s.InfraClusterEndpoint() == s.Endpoint() { + return nil + } + + return errors.New("infra cluster and load balancer endpoints do not match") + } + + return nil +} + +func (*IonosCloudLoadBalancerReconciler) validateLoadBalancerSource(source infrav1.LoadBalancerSource) error { + if source.NLB == nil && source.KubeVIP == nil { + return errors.New("exactly one source needs to be set, none are set") + } + + if source.NLB != nil && source.KubeVIP != nil { + return errors.New("exactly one source needs to be set, both are set") + } + + return nil +} diff --git a/internal/controller/ionoscloudmachine_controller.go b/internal/controller/ionoscloudmachine_controller.go index 9707491a..e9b20425 100644 --- a/internal/controller/ionoscloudmachine_controller.go +++ b/internal/controller/ionoscloudmachine_controller.go @@ -144,11 +144,11 @@ func (r *IonosCloudMachineReconciler) Reconcile( func (r *IonosCloudMachineReconciler) reconcileNormal( ctx context.Context, cloudService *cloud.Service, machineScope *scope.Machine, ) (ctrl.Result, error) { - log := ctrl.LoggerFrom(ctx) - log.V(4).Info("Reconciling IonosCloudMachine") + logger := ctrl.LoggerFrom(ctx) + logger.V(4).Info("Reconciling IonosCloudMachine") if machineScope.HasFailed() { - log.Info("Error state detected, skipping reconciliation") + logger.Info("Error state detected, skipping reconciliation") return ctrl.Result{}, nil } @@ -170,11 +170,11 @@ func (r *IonosCloudMachineReconciler) reconcileNormal( // proceed with the reconciliation and are stuck in a loop. // // In any case we log the error. - log.Error(err, "Error when trying to determine inflight request states") + logger.Error(err, "Error when trying to determine inflight request states") } if requeue { - log.Info("Request is still in progress") + logger.Info("Request is still in progress") return ctrl.Result{RequeueAfter: defaultReconcileDuration}, nil } @@ -201,7 +201,7 @@ func (r *IonosCloudMachineReconciler) reconcileNormal( func (r *IonosCloudMachineReconciler) reconcileDelete( ctx context.Context, machineScope *scope.Machine, cloudService *cloud.Service, ) (ctrl.Result, error) { - log := ctrl.LoggerFrom(ctx) + logger := ctrl.LoggerFrom(ctx) requeue, err := r.checkRequestStates(ctx, machineScope, cloudService) if err != nil { @@ -210,11 +210,11 @@ func (r *IonosCloudMachineReconciler) reconcileDelete( // proceed with the reconciliation and are stuck in a loop. // // In any case we log the error. - log.Error(err, "Error when trying to determine inflight request states") + logger.Error(err, "Error when trying to determine inflight request states") } if requeue { - log.Info("Deletion request is still in progress") + logger.Info("Deletion request is still in progress") return ctrl.Result{RequeueAfter: reducedReconcileDuration}, nil } @@ -253,14 +253,15 @@ func (*IonosCloudMachineReconciler) checkRequestStates( machineScope *scope.Machine, cloudService *cloud.Service, ) (requeue bool, retErr error) { - log := ctrl.LoggerFrom(ctx) + logger := ctrl.LoggerFrom(ctx) // check cluster-wide request if req, exists := machineScope.ClusterScope.GetCurrentRequestByDatacenter(machineScope.DatacenterID()); exists { + logger.Info("Checking cluster-wide request", "request", req.RequestPath, "method", req.Method) status, message, err := cloudService.GetRequestStatus(ctx, req.RequestPath) if err != nil { retErr = fmt.Errorf("could not get request status: %w", err) } else { - requeue, retErr = withStatus(status, message, &log, + requeue, retErr = withStatus(status, message, &logger, func() error { // remove the request from the status and patch the cluster machineScope.ClusterScope.DeleteCurrentRequestByDatacenter(machineScope.DatacenterID()) @@ -272,15 +273,16 @@ func (*IonosCloudMachineReconciler) checkRequestStates( // check machine related request if req := machineScope.IonosMachine.Status.CurrentRequest; req != nil { + logger.Info("Checking machine request", "request", req.RequestPath, "method", req.Method) status, message, err := cloudService.GetRequestStatus(ctx, req.RequestPath) if err != nil { retErr = errors.Join(retErr, fmt.Errorf("could not get request status: %w", err)) } else { - requeue, _ = withStatus(status, message, &log, + requeue, _ = withStatus(status, message, &logger, func() error { // no need to patch the machine here as it will be patched // after the machine reconciliation is done. - log.V(4).Info("Request is done, clearing it from the status") + logger.V(4).Info("Request is done, clearing it from the status") machineScope.IonosMachine.DeleteCurrentRequest() return nil }, @@ -298,10 +300,10 @@ func (*IonosCloudMachineReconciler) checkRequestStates( } func (*IonosCloudMachineReconciler) isInfrastructureReady(ctx context.Context, ms *scope.Machine) bool { - log := ctrl.LoggerFrom(ctx) + logger := ctrl.LoggerFrom(ctx) // Make sure the infrastructure is ready. if !ms.ClusterScope.Cluster.Status.InfrastructureReady { - log.Info("Cluster infrastructure is not ready yet") + logger.Info("Cluster infrastructure is not ready yet") conditions.MarkFalse( ms.IonosMachine, infrav1.MachineProvisionedCondition, @@ -313,7 +315,7 @@ func (*IonosCloudMachineReconciler) isInfrastructureReady(ctx context.Context, m // Make sure to wait until the data secret was created if ms.Machine.Spec.Bootstrap.DataSecretName == nil { - log.Info("Bootstrap data secret is not available yet") + logger.Info("Bootstrap data secret is not available yet") conditions.MarkFalse( ms.IonosMachine, infrav1.MachineProvisionedCondition, diff --git a/internal/controller/util.go b/internal/controller/util.go index 2cf13300..23726a74 100644 --- a/internal/controller/util.go +++ b/internal/controller/util.go @@ -39,7 +39,11 @@ const ( reducedReconcileDuration = time.Second * 10 ) -type serviceReconcileStep[T scope.Cluster | scope.Machine] struct { +type scoped interface { + scope.Cluster | scope.Machine | scope.LoadBalancer +} + +type serviceReconcileStep[T scoped] struct { name string fn func(context.Context, *T) (requeue bool, err error) } diff --git a/internal/loadbalancing/provisioner.go b/internal/loadbalancing/provisioner.go new file mode 100644 index 00000000..126031f1 --- /dev/null +++ b/internal/loadbalancing/provisioner.go @@ -0,0 +1,48 @@ +/* +Copyright 2024 IONOS Cloud. + +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 loadbalancing provides the load balancer provisioner interface and its implementations. +// The provisioner is responsible for managing the provisioning of and cleanup of various types of load balancers. +package loadbalancing + +import ( + "context" + "fmt" + + infrav1 "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/service/cloud" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/scope" +) + +// Provisioner is an interface for managing the provisioning and cleanup of various types of load balancers. +type Provisioner interface { + // Provision is responsible for creating the load balancer. + Provision(ctx context.Context, loadBalancerScope *scope.LoadBalancer) (requeue bool, err error) + + // Destroy is responsible for deleting the load balancer. + Destroy(ctx context.Context, loadBalancerScope *scope.LoadBalancer) (requeue bool, err error) +} + +// NewProvisioner creates a new load balancer provisioner, based on the load balancer type. +func NewProvisioner(_ *cloud.Service, source infrav1.LoadBalancerSource) (Provisioner, error) { + switch { + case source.KubeVIP != nil: + return &kubeVIPProvisioner{}, nil + case source.NLB != nil: + return &nlbProvisioner{}, nil + } + return nil, fmt.Errorf("unknown load balancer config %#v", source) +} diff --git a/internal/loadbalancing/provisioner_kubevip.go b/internal/loadbalancing/provisioner_kubevip.go new file mode 100644 index 00000000..ee3eaf0c --- /dev/null +++ b/internal/loadbalancing/provisioner_kubevip.go @@ -0,0 +1,33 @@ +/* +Copyright 2024 IONOS Cloud. + +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 loadbalancing + +import ( + "context" + + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/scope" +) + +type kubeVIPProvisioner struct{} + +func (*kubeVIPProvisioner) Provision(_ context.Context, _ *scope.LoadBalancer) (requeue bool, err error) { + panic("implement me") +} + +func (*kubeVIPProvisioner) Destroy(_ context.Context, _ *scope.LoadBalancer) (requeue bool, err error) { + panic("implement me") +} diff --git a/internal/loadbalancing/provisioner_nlb.go b/internal/loadbalancing/provisioner_nlb.go new file mode 100644 index 00000000..cfc8d1d4 --- /dev/null +++ b/internal/loadbalancing/provisioner_nlb.go @@ -0,0 +1,33 @@ +/* +Copyright 2024 IONOS Cloud. + +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 loadbalancing + +import ( + "context" + + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/scope" +) + +type nlbProvisioner struct{} + +func (*nlbProvisioner) Provision(_ context.Context, _ *scope.LoadBalancer) (requeue bool, err error) { + panic("implement me") +} + +func (*nlbProvisioner) Destroy(_ context.Context, _ *scope.LoadBalancer) (requeue bool, err error) { + panic("implement me") +} diff --git a/internal/service/cloud/request.go b/internal/service/cloud/request.go index 54a2751a..975e1b8f 100644 --- a/internal/service/cloud/request.go +++ b/internal/service/cloud/request.go @@ -200,7 +200,11 @@ func hasRequestTargetType(req sdk.Request, typeName sdk.Type) bool { return false } -func scopedFindResource[T any, S scope.Cluster | scope.Machine]( +type scoped interface { + scope.Cluster | scope.Machine | scope.LoadBalancer +} + +func scopedFindResource[T any, S scoped]( ctx context.Context, s *S, tryLookupResource func(context.Context, *S) (*T, error), diff --git a/scope/loadbalancer.go b/scope/loadbalancer.go new file mode 100644 index 00000000..328e99b0 --- /dev/null +++ b/scope/loadbalancer.go @@ -0,0 +1,127 @@ +/* +Copyright 2024 IONOS Cloud. + +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 scope + +import ( + "context" + "errors" + "fmt" + "time" + + "k8s.io/client-go/util/retry" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/util/conditions" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/controller-runtime/pkg/client" + + infrav1 "github.com/ionos-cloud/cluster-api-provider-ionoscloud/api/v1alpha1" + "github.com/ionos-cloud/cluster-api-provider-ionoscloud/internal/util/locker" +) + +// LoadBalancerParams is a struct that contains the params used to create a new LoadBalancer through NewLoadBalancer. +type LoadBalancerParams struct { + Client client.Client + LoadBalancer *infrav1.IonosCloudLoadBalancer + + ClusterScope *Cluster + Locker *locker.Locker +} + +// LoadBalancer defines a basic loadbalancer context for primary use in IonosCloudLoadBalancerReconciler. +type LoadBalancer struct { + client client.Client + patchHelper *patch.Helper + Locker *locker.Locker + + LoadBalancer *infrav1.IonosCloudLoadBalancer + + ClusterScope *Cluster +} + +// NewLoadBalancer creates a new LoadBalancer scope. +func NewLoadBalancer(params LoadBalancerParams) (*LoadBalancer, error) { + if params.Client == nil { + return nil, errors.New("load balancer scope params lack a client") + } + if params.LoadBalancer == nil { + return nil, errors.New("load balancer scope params lack an IONOS Cloud load balancer") + } + if params.ClusterScope == nil { + return nil, errors.New("load balancer scope params need a IONOS Cloud cluster scope") + } + if params.Locker == nil { + return nil, errors.New("load balancer scope params need a locker") + } + + helper, err := patch.NewHelper(params.LoadBalancer, params.Client) + if err != nil { + return nil, fmt.Errorf("failed to init patch helper: %w", err) + } + + return &LoadBalancer{ + client: params.Client, + patchHelper: helper, + Locker: params.Locker, + LoadBalancer: params.LoadBalancer, + ClusterScope: params.ClusterScope, + }, nil +} + +// Endpoint returns the load balancer endpoint. +func (l *LoadBalancer) Endpoint() clusterv1.APIEndpoint { + return l.LoadBalancer.Spec.LoadBalancerEndpoint +} + +// InfraClusterEndpoint returns the endpoint from the infra cluster.. +func (l *LoadBalancer) InfraClusterEndpoint() clusterv1.APIEndpoint { + return l.ClusterScope.IonosCluster.Spec.ControlPlaneEndpoint +} + +// PatchObject will apply all changes from the IonosCloudLoadBalancer. +// It will also make sure to patch the status subresource. +func (l *LoadBalancer) PatchObject() error { + conditions.SetSummary(l.LoadBalancer, + conditions.WithConditions( + infrav1.LoadBalancerReadyCondition)) + + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + // We don't accept and forward a context here. This is on purpose: Even if a reconciliation is + // aborted, we want to make sure that the final patch is applied. Reusing the context from the reconciliation + // would cause the patch to be aborted as well. + return l.patchHelper.Patch( + timeoutCtx, + l.LoadBalancer, + patch.WithOwnedConditions{Conditions: []clusterv1.ConditionType{ + clusterv1.ReadyCondition, + }}) +} + +// Finalize will make sure to apply a patch to the current IonosCloudLoadBalancer. +// It also implements a retry mechanism to increase the chance of success +// in case the patch operation was not successful. +func (l *LoadBalancer) Finalize() error { + // NOTE(lubedacht) retry is only a way to reduce the failure chance, + // but in general, the reconciliation logic must be resilient + // to handle an outdated resource from that API server. + shouldRetry := func(error) bool { return true } + return retry.OnError( + retry.DefaultBackoff, + shouldRetry, + l.PatchObject) +}