Skip to content

Commit ef8abbc

Browse files
committed
feat: service controller
1 parent c455c55 commit ef8abbc

File tree

2 files changed

+289
-3
lines changed

2 files changed

+289
-3
lines changed

internal/provider/loadbalancer.go

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
package provider
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strings"
10+
11+
"github.com/oxidecomputer/oxide.go/oxide"
12+
v1 "k8s.io/api/core/v1"
13+
"k8s.io/client-go/kubernetes"
14+
cloudprovider "k8s.io/cloud-provider"
15+
"k8s.io/klog/v2"
16+
)
17+
18+
var _ cloudprovider.LoadBalancer = (*LoadBalancer)(nil)
19+
20+
// LoadBalancer implements [cloudprovider.LoadBalancer] to provide Oxide specific
21+
// load balancer functionality using floating IPs.
22+
type LoadBalancer struct {
23+
client *oxide.Client
24+
project string
25+
k8sClient kubernetes.Interface
26+
}
27+
28+
// GetLoadBalancerName returns the name of the load balancer for the given service.
29+
// The name follows the format "lb-{namespace}-{service-name}".
30+
func (lb *LoadBalancer) GetLoadBalancerName(ctx context.Context, clusterName string, service *v1.Service) string {
31+
return fmt.Sprintf("lb-%s-%s", service.Namespace, service.Name)
32+
}
33+
34+
// GetLoadBalancer returns the load balancer status for the given service.
35+
// It checks if a floating IP exists with the expected name and returns its status.
36+
func (lb *LoadBalancer) GetLoadBalancer(ctx context.Context, clusterName string, service *v1.Service) (*v1.LoadBalancerStatus, bool, error) {
37+
name := lb.GetLoadBalancerName(ctx, clusterName, service)
38+
39+
klog.V(4).InfoS("getting load balancer", "name", name, "service", service.Name, "namespace", service.Namespace)
40+
41+
floatingIP, err := lb.client.FloatingIpView(ctx, oxide.FloatingIpViewParams{
42+
Project: oxide.NameOrId(lb.project),
43+
FloatingIp: oxide.NameOrId(name),
44+
})
45+
if err != nil {
46+
if strings.Contains(err.Error(), "NotFound") {
47+
return nil, false, nil
48+
}
49+
return nil, false, fmt.Errorf("failed viewing floating ip %s: %w", name, err)
50+
}
51+
52+
status := &v1.LoadBalancerStatus{
53+
Ingress: []v1.LoadBalancerIngress{
54+
{
55+
IP: floatingIP.Ip,
56+
},
57+
},
58+
}
59+
60+
return status, true, nil
61+
}
62+
63+
// EnsureLoadBalancer creates or updates a load balancer for the given service.
64+
// It creates a floating IP and attaches it to a control plane node.
65+
func (lb *LoadBalancer) EnsureLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) (*v1.LoadBalancerStatus, error) {
66+
name := lb.GetLoadBalancerName(ctx, clusterName, service)
67+
68+
klog.InfoS("ensuring load balancer", "name", name, "service", service.Name, "namespace", service.Namespace)
69+
70+
// Find a control plane node
71+
controlPlaneNode, err := lb.findControlPlaneNode(ctx, nodes)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed finding control plane node: %w", err)
74+
}
75+
76+
instanceID, err := InstanceIDFromProviderID(controlPlaneNode.Spec.ProviderID)
77+
if err != nil {
78+
return nil, fmt.Errorf("failed retrieving instance id from provider id: %w", err)
79+
}
80+
81+
// Check if floating IP already exists
82+
floatingIP, err := lb.client.FloatingIpView(ctx, oxide.FloatingIpViewParams{
83+
Project: oxide.NameOrId(lb.project),
84+
FloatingIp: oxide.NameOrId(name),
85+
})
86+
if err != nil && !strings.Contains(err.Error(), "NotFound") {
87+
return nil, fmt.Errorf("failed viewing floating ip %s: %w", name, err)
88+
}
89+
90+
// Create floating IP if it doesn't exist
91+
if floatingIP == nil {
92+
klog.V(2).InfoS("creating floating ip", "name", name)
93+
94+
floatingIP, err = lb.client.FloatingIpCreate(ctx, oxide.FloatingIpCreateParams{
95+
Project: oxide.NameOrId(lb.project),
96+
Body: &oxide.FloatingIpCreate{
97+
Description: fmt.Sprintf("Load balancer for service %s/%s", service.Namespace, service.Name),
98+
Name: oxide.Name(name),
99+
Pool: oxide.NameOrId("default"),
100+
},
101+
})
102+
if err != nil {
103+
return nil, fmt.Errorf("failed creating floating ip %s: %w", name, err)
104+
}
105+
106+
klog.InfoS("created floating ip", "name", name, "ip", floatingIP.Ip)
107+
}
108+
109+
// Attach floating IP to the control plane node if not already attached
110+
if floatingIP.InstanceId == "" || floatingIP.InstanceId != instanceID {
111+
// If it's attached to a different instance, detach it first
112+
if floatingIP.InstanceId != "" {
113+
klog.V(2).InfoS("detaching floating ip from previous instance", "name", name, "instance", floatingIP.InstanceId)
114+
115+
if _, err := lb.client.FloatingIpDetach(ctx, oxide.FloatingIpDetachParams{
116+
Project: oxide.NameOrId(lb.project),
117+
FloatingIp: oxide.NameOrId(name),
118+
}); err != nil {
119+
return nil, fmt.Errorf("failed detaching floating ip %s from instance %s: %w", name, floatingIP.InstanceId, err)
120+
}
121+
}
122+
123+
klog.V(2).InfoS("attaching floating ip to control plane node", "name", name, "instance", instanceID, "node", controlPlaneNode.Name)
124+
125+
floatingIP, err = lb.client.FloatingIpAttach(ctx, oxide.FloatingIpAttachParams{
126+
Project: oxide.NameOrId(lb.project),
127+
FloatingIp: oxide.NameOrId(name),
128+
Body: &oxide.FloatingIpAttach{
129+
Kind: oxide.FloatingIpParentKindInstance,
130+
Parent: oxide.NameOrId(instanceID),
131+
},
132+
})
133+
if err != nil {
134+
return nil, fmt.Errorf("failed attaching floating ip %s to instance %s: %w", name, instanceID, err)
135+
}
136+
137+
klog.InfoS("attached floating ip to control plane node", "name", name, "ip", floatingIP.Ip, "node", controlPlaneNode.Name)
138+
}
139+
140+
status := &v1.LoadBalancerStatus{
141+
Ingress: []v1.LoadBalancerIngress{
142+
{
143+
IP: floatingIP.Ip,
144+
},
145+
},
146+
}
147+
148+
return status, nil
149+
}
150+
151+
// UpdateLoadBalancer updates the hosts under the specified load balancer.
152+
// It ensures the floating IP is attached to an available control plane node.
153+
func (lb *LoadBalancer) UpdateLoadBalancer(ctx context.Context, clusterName string, service *v1.Service, nodes []*v1.Node) error {
154+
name := lb.GetLoadBalancerName(ctx, clusterName, service)
155+
156+
klog.InfoS("updating load balancer", "name", name, "service", service.Name, "namespace", service.Namespace)
157+
158+
// Find a control plane node
159+
controlPlaneNode, err := lb.findControlPlaneNode(ctx, nodes)
160+
if err != nil {
161+
return fmt.Errorf("failed finding control plane node: %w", err)
162+
}
163+
164+
instanceID, err := InstanceIDFromProviderID(controlPlaneNode.Spec.ProviderID)
165+
if err != nil {
166+
return fmt.Errorf("failed retrieving instance id from provider id: %w", err)
167+
}
168+
169+
// Get the floating IP
170+
floatingIP, err := lb.client.FloatingIpView(ctx, oxide.FloatingIpViewParams{
171+
Project: oxide.NameOrId(lb.project),
172+
FloatingIp: oxide.NameOrId(name),
173+
})
174+
if err != nil {
175+
return fmt.Errorf("failed viewing floating ip %s: %w", name, err)
176+
}
177+
178+
// Update attachment if necessary
179+
if floatingIP.InstanceId == "" || floatingIP.InstanceId != instanceID {
180+
// Detach from current instance if attached
181+
if floatingIP.InstanceId != "" {
182+
klog.V(2).InfoS("detaching floating ip from previous instance", "name", name, "instance", floatingIP.InstanceId)
183+
184+
if _, err := lb.client.FloatingIpDetach(ctx, oxide.FloatingIpDetachParams{
185+
Project: oxide.NameOrId(lb.project),
186+
FloatingIp: oxide.NameOrId(name),
187+
}); err != nil {
188+
return fmt.Errorf("failed detaching floating ip %s: %w", name, err)
189+
}
190+
}
191+
192+
klog.V(2).InfoS("attaching floating ip to control plane node", "name", name, "instance", instanceID, "node", controlPlaneNode.Name)
193+
194+
if _, err := lb.client.FloatingIpAttach(ctx, oxide.FloatingIpAttachParams{
195+
Project: oxide.NameOrId(lb.project),
196+
FloatingIp: oxide.NameOrId(name),
197+
Body: &oxide.FloatingIpAttach{
198+
Kind: oxide.FloatingIpParentKindInstance,
199+
Parent: oxide.NameOrId(instanceID),
200+
},
201+
}); err != nil {
202+
return fmt.Errorf("failed attaching floating ip %s to instance %s: %w", name, instanceID, err)
203+
}
204+
205+
klog.InfoS("updated floating ip attachment", "name", name, "node", controlPlaneNode.Name)
206+
}
207+
208+
return nil
209+
}
210+
211+
// EnsureLoadBalancerDeleted deletes the specified load balancer.
212+
// It detaches and deletes the floating IP associated with the service.
213+
func (lb *LoadBalancer) EnsureLoadBalancerDeleted(ctx context.Context, clusterName string, service *v1.Service) error {
214+
name := lb.GetLoadBalancerName(ctx, clusterName, service)
215+
216+
klog.InfoS("ensuring load balancer deleted", "name", name, "service", service.Name, "namespace", service.Namespace)
217+
218+
// Get the floating IP to check if it's attached
219+
floatingIP, err := lb.client.FloatingIpView(ctx, oxide.FloatingIpViewParams{
220+
Project: oxide.NameOrId(lb.project),
221+
FloatingIp: oxide.NameOrId(name),
222+
})
223+
if err != nil {
224+
if strings.Contains(err.Error(), "NotFound") {
225+
klog.V(2).InfoS("floating ip not found, already deleted", "name", name)
226+
return nil
227+
}
228+
return fmt.Errorf("failed viewing floating ip %s: %w", name, err)
229+
}
230+
231+
// Detach the floating IP if it's attached to an instance
232+
if floatingIP.InstanceId != "" {
233+
klog.V(2).InfoS("detaching floating ip", "name", name, "instance", floatingIP.InstanceId)
234+
235+
if _, err := lb.client.FloatingIpDetach(ctx, oxide.FloatingIpDetachParams{
236+
Project: oxide.NameOrId(lb.project),
237+
FloatingIp: oxide.NameOrId(name),
238+
}); err != nil {
239+
return fmt.Errorf("failed detaching floating ip %s: %w", name, err)
240+
}
241+
}
242+
243+
// Delete the floating IP
244+
klog.V(2).InfoS("deleting floating ip", "name", name)
245+
246+
if err := lb.client.FloatingIpDelete(ctx, oxide.FloatingIpDeleteParams{
247+
Project: oxide.NameOrId(lb.project),
248+
FloatingIp: oxide.NameOrId(name),
249+
}); err != nil {
250+
return fmt.Errorf("failed deleting floating ip %s: %w", name, err)
251+
}
252+
253+
klog.InfoS("deleted floating ip", "name", name)
254+
255+
return nil
256+
}
257+
258+
// findControlPlaneNode finds the first available control plane node from the provided list.
259+
// Control plane nodes are identified by the presence of the "node-role.kubernetes.io/control-plane"
260+
// or "node-role.kubernetes.io/master" label.
261+
func (lb *LoadBalancer) findControlPlaneNode(ctx context.Context, nodes []*v1.Node) (*v1.Node, error) {
262+
for _, node := range nodes {
263+
if node.Labels == nil {
264+
continue
265+
}
266+
267+
// Check for control plane label (current standard)
268+
if _, ok := node.Labels["node-role.kubernetes.io/control-plane"]; ok {
269+
klog.V(4).InfoS("found control plane node", "node", node.Name)
270+
return node, nil
271+
}
272+
273+
// Check for master label (legacy, but still supported)
274+
if _, ok := node.Labels["node-role.kubernetes.io/master"]; ok {
275+
klog.V(4).InfoS("found master node", "node", node.Name)
276+
return node, nil
277+
}
278+
}
279+
280+
return nil, fmt.Errorf("no control plane node found among %d nodes", len(nodes))
281+
}

internal/provider/provider.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,15 @@ func (o *Oxide) InstancesV2() (cloudprovider.InstancesV2, bool) {
105105
}, true
106106
}
107107

108-
// LoadBalancer is currently unimplemented. This may be implemented in the
109-
// future.
108+
// LoadBalancer returns an implementation of [cloudprovider.LoadBalancer]
109+
// that provides functionality to create and manage Oxide floating IPs for
110+
// Kubernetes LoadBalancer services.
110111
func (o *Oxide) LoadBalancer() (cloudprovider.LoadBalancer, bool) {
111-
return nil, false
112+
return &LoadBalancer{
113+
client: o.client,
114+
project: o.project,
115+
k8sClient: o.k8sClient,
116+
}, true
112117
}
113118

114119
// Routes is purposefully unimplemented. It is expected that the Kubernetes

0 commit comments

Comments
 (0)