Skip to content

Commit 4981db4

Browse files
committed
minikube: Add integration test for metallb addon
Signed-off-by: Kartik Joshi <karikjoshi21@gmail.com>
1 parent 4c228db commit 4981db4

File tree

1 file changed

+303
-12
lines changed

1 file changed

+303
-12
lines changed

test/integration/addons_test.go

Lines changed: 303 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"encoding/json"
2626
"errors"
2727
"fmt"
28+
"net"
2829
"net/http"
2930
"net/url"
3031
"os"
@@ -141,18 +142,19 @@ func TestAddons(t *testing.T) {
141142
t.Run("parallel", func(t *testing.T) {
142143
tests := []TestCase{
143144
{"Registry", validateRegistryAddon},
144-
{"RegistryCreds", validateRegistryCredsAddon},
145-
{"Ingress", validateIngressAddon},
146-
{"InspektorGadget", validateInspektorGadgetAddon},
147-
{"MetricsServer", validateMetricsServerAddon},
148-
{"Olm", validateOlmAddon},
149-
{"CSI", validateCSIDriverAndSnapshots},
150-
{"Headlamp", validateHeadlampAddon},
151-
{"CloudSpanner", validateCloudSpannerAddon},
152-
{"LocalPath", validateLocalPathAddon},
153-
{"NvidiaDevicePlugin", validateNvidiaDevicePlugin},
154-
{"Yakd", validateYakdAddon},
155-
{"AmdGpuDevicePlugin", validateAmdGpuDevicePlugin},
145+
{"RegistryCreds", validateRegistryCredsAddon},
146+
{"Ingress", validateIngressAddon},
147+
{"InspektorGadget", validateInspektorGadgetAddon},
148+
{"MetricsServer", validateMetricsServerAddon},
149+
{"Olm", validateOlmAddon},
150+
{"CSI", validateCSIDriverAndSnapshots},
151+
{"Headlamp", validateHeadlampAddon},
152+
{"CloudSpanner", validateCloudSpannerAddon},
153+
{"LocalPath", validateLocalPathAddon},
154+
{"NvidiaDevicePlugin", validateNvidiaDevicePlugin},
155+
{"Yakd", validateYakdAddon},
156+
{"AmdGpuDevicePlugin", validateAmdGpuDevicePlugin},
157+
{"MetalLB", validateMetalLBAddon},
156158
}
157159

158160
for _, tc := range tests {
@@ -189,6 +191,295 @@ func TestAddons(t *testing.T) {
189191
})
190192
}
191193

194+
// --- MetalLB helpers ---------------------------------------------------------
195+
196+
// hasUsableMetalLBPool checks whether MetalLB has at least one non-empty address
197+
// range configured (CRD or legacy ConfigMap).
198+
func hasUsableMetalLBPool(ctx context.Context, t *testing.T, profile string, useCRD bool) bool {
199+
if useCRD {
200+
// If any item has a non-empty spec.addresses[0], we consider it usable.
201+
rr, err := Run(t, exec.CommandContext(ctx, "kubectl", "--context", profile,
202+
"-n", "metallb-system", "get", "ipaddresspools",
203+
"-o", "jsonpath={range .items[*]}{.spec.addresses[0]}{'\\n'}{end}"))
204+
if err != nil {
205+
return false
206+
}
207+
for _, line := range strings.Split(strings.TrimSpace(rr.Stdout.String()), "\n") {
208+
if s := strings.TrimSpace(line); s != "" && s != "[]" && !strings.EqualFold(s, "<no value>") {
209+
t.Logf("MetalLB CRD pool present: %q", s)
210+
return true
211+
}
212+
}
213+
return false
214+
}
215+
// Legacy CM: parse the blob for a non-empty addresses list or at least one dash entry.
216+
rr, err := Run(t, exec.CommandContext(ctx, "kubectl", "--context", profile,
217+
"-n", "metallb-system", "get", "cm", "config", "-o", "jsonpath={.data.config}"))
218+
if err != nil {
219+
return false
220+
}
221+
cfg := rr.Stdout.String()
222+
// Look for: addresses: ["A-B"] OR addresses:\n - A-B
223+
rxNonEmptyInline := regexp.MustCompile(`(?m)addresses:\s*\[\s*("[^"\]]+"\s*(,\s*"[^"\]]+")*)\s*\]`)
224+
rxDash := regexp.MustCompile(`(?m)^\s*-\s*[0-9a-fA-F:.]+(?:\s*-\s*[0-9a-fA-F:.]+)?\s*$`)
225+
if rxNonEmptyInline.FindString(cfg) != "" || rxDash.FindString(cfg) != "" {
226+
t.Logf("MetalLB CM appears configured")
227+
return true
228+
}
229+
return false
230+
}
231+
232+
// ensureMetalLBConfigured detects CRD vs legacy ConfigMap mode and applies
233+
// an address pool carved from the tail of the node's IPv4 subnet. This lets
234+
// MetalLB work on Hyper-V External switches where the node lives on the LAN.
235+
func ensureMetalLBConfigured(ctx context.Context, t *testing.T, profile string) {
236+
t.Logf("configuring metallb address pool")
237+
238+
// Detect CRD mode
239+
rr, err := Run(t, exec.CommandContext(ctx, "kubectl", "--context", profile,
240+
"api-resources", "--api-group=metallb.io", "-o", "name"))
241+
useCRD := (err == nil) && strings.Contains(rr.Stdout.String(), "ipaddresspools.metallb.io")
242+
243+
// Already configured?
244+
// Already configured with a usable (non-empty) pool?
245+
if hasUsableMetalLBPool(ctx, t, profile, useCRD) {
246+
return
247+
}
248+
249+
// Work out the node's IPv4 CIDR (eth0 inside minikube)
250+
rr, err = Run(t, exec.CommandContext(ctx, Target(), "-p", profile,
251+
"ssh", "ip -4 -o addr show dev eth0 | awk '{print $4}'"))
252+
if err != nil {
253+
t.Fatalf("failed to get node CIDR: %v", err)
254+
}
255+
cidr := strings.TrimSpace(rr.Stdout.String())
256+
_, ipNet, perr := net.ParseCIDR(cidr)
257+
if perr != nil {
258+
t.Fatalf("parse CIDR %q: %v", cidr, perr)
259+
}
260+
261+
// Tail of subnet: broadcast-32 .. broadcast-4
262+
dec := func(ip net.IP, n int) net.IP {
263+
out := append(net.IP(nil), ip...)
264+
for ; n > 0; n-- {
265+
for j := len(out) - 1; j >= 0; j-- {
266+
if out[j] > 0 {
267+
out[j]--
268+
break
269+
}
270+
out[j] = 255
271+
}
272+
}
273+
return out
274+
}
275+
network := ipNet.IP.Mask(ipNet.Mask)
276+
bcast := make(net.IP, len(network))
277+
for i := range network {
278+
bcast[i] = network[i] | ^ipNet.Mask[i]
279+
}
280+
start, end := dec(bcast, 32), dec(bcast, 4)
281+
282+
if useCRD {
283+
manifest := fmt.Sprintf(`
284+
apiVersion: metallb.io/v1beta1
285+
kind: IPAddressPool
286+
metadata:
287+
name: itest-pool
288+
namespace: metallb-system
289+
spec:
290+
addresses: ["%s-%s"]
291+
---
292+
apiVersion: metallb.io/v1beta1
293+
kind: L2Advertisement
294+
metadata:
295+
name: itest-l2
296+
namespace: metallb-system
297+
`, start.String(), end.String())
298+
cmd := exec.CommandContext(ctx, "kubectl", "--context", profile, "apply", "-f", "-")
299+
cmd.Stdin = bytes.NewBufferString(manifest)
300+
if _, err := Run(t, cmd); err != nil {
301+
t.Fatalf("apply MetalLB CRDs: %v", err)
302+
}
303+
} else {
304+
manifest := fmt.Sprintf(`
305+
apiVersion: v1
306+
kind: ConfigMap
307+
metadata:
308+
name: config
309+
namespace: metallb-system
310+
data:
311+
config: |
312+
address-pools:
313+
- name: default
314+
protocol: layer2
315+
addresses: ["%s-%s"]
316+
`, start.String(), end.String())
317+
cmd := exec.CommandContext(ctx, "kubectl", "--context", profile, "apply", "-f", "-")
318+
cmd.Stdin = bytes.NewBufferString(manifest)
319+
if _, err := Run(t, cmd); err != nil {
320+
t.Fatalf("apply MetalLB CM: %v", err)
321+
}
322+
}
323+
}
324+
325+
// validateMetalLBAddon tests MetalLB by exposing a tiny HTTP pod via a LoadBalancer
326+
// and verifying it receives an external IP and is reachable from the host.
327+
func validateMetalLBAddon(ctx context.Context, t *testing.T, profile string) {
328+
// MetalLB exercises host<->cluster L2/L3 path; skip where that’s not viable.
329+
t.Logf("Running MetalLB e2e")
330+
331+
if NoneDriver() {
332+
t.Skipf("skipping: metallb not supported on 'none' driver in this test")
333+
}
334+
if NeedsPortForward() {
335+
t.Skipf("skipping metallb test: environment requires port-forwarding")
336+
}
337+
defer disableAddon(t, "metallb", profile)
338+
defer PostMortemLogs(t, profile)
339+
340+
client, err := kapi.Client(profile)
341+
if err != nil {
342+
t.Fatalf("failed to get Kubernetes client: %v", err)
343+
}
344+
345+
// Enable MetalLB
346+
if rr, err := Run(t, exec.CommandContext(ctx, Target(), "--alsologtostderr", "-v=1", "addons", "enable", "metallb", "-p", profile)); err != nil {
347+
t.Fatalf("failed to enable metallb addon: args %q : %v", rr.Command(), err)
348+
}
349+
350+
// Auto-configure an IP pool that matches the node's LAN subnet (CRD or legacy CM).
351+
ensureMetalLBConfigured(ctx, t, profile)
352+
353+
// Wait for controller to stabilize. Name differs across versions:
354+
// try "controller" first, fall back to "metallb-controller".
355+
start := time.Now()
356+
waitController := func(name string) error {
357+
return kapi.WaitForDeploymentToStabilize(client, "metallb-system", name, Minutes(6))
358+
}
359+
if err := waitController("controller"); err != nil {
360+
t.Logf("metallb deployment 'controller' not ready (%v); trying 'metallb-controller'", err)
361+
if err2 := waitController("metallb-controller"); err2 != nil {
362+
t.Fatalf("metallb controller failed to stabilize: %v / %v", err, err2)
363+
}
364+
}
365+
// Also wait for the speaker DaemonSet to be ready to avoid LB IP allocation races.
366+
if rr, err := Run(t, exec.CommandContext(ctx, "kubectl", "--context", profile, "-n", "metallb-system",
367+
"rollout", "status", "ds/speaker", "--timeout=180s")); err != nil {
368+
t.Fatalf("failed waiting for metallb speaker ds to be ready: args %q : %v", rr.Command(), err)
369+
}
370+
t.Logf("metallb controller stabilized in %s", time.Since(start))
371+
372+
// Create a tiny HTTP server pod (busybox httpd) with a unique label to avoid collisions.
373+
// We avoid YAML here to keep the patch small and independent of other tests' resources.
374+
if rr, err := Run(t, exec.CommandContext(
375+
ctx, "kubectl", "--context", profile,
376+
"run", "mlb-http",
377+
"--image=gcr.io/k8s-minikube/busybox",
378+
"--labels", "app=mlb-http",
379+
"--restart=Never",
380+
"--command", "--", "sh", "-c",
381+
// create simple index and serve /www
382+
"set -eu; mkdir -p /www; echo ok >/www/index.html; exec httpd -f -p 80 -h /www")); err != nil {
383+
t.Fatalf("failed to create mlb-http pod: args %q : %v", rr.Command(), err)
384+
}
385+
386+
if _, err := PodWait(ctx, t, profile, "default", "app=mlb-http", Minutes(6)); err != nil {
387+
t.Fatalf("failed waiting for mlb-http pod: %v", err)
388+
}
389+
390+
// Expose it as a LoadBalancer service.
391+
if rr, err := Run(t, exec.CommandContext(
392+
ctx, "kubectl", "--context", profile,
393+
"expose", "pod", "mlb-http",
394+
"--name", "mlb-http-lb",
395+
"--type", "LoadBalancer",
396+
"--port", "80",
397+
"--target-port", "80")); err != nil {
398+
t.Fatalf("failed to expose mlb-http-lb service: args %q : %v", rr.Command(), err)
399+
}
400+
t.Cleanup(func() {
401+
// best-effort cleanup
402+
Run(t, exec.CommandContext(ctx, "kubectl", "--context", profile, "delete", "svc", "mlb-http-lb", "--now"))
403+
Run(t, exec.CommandContext(ctx, "kubectl", "--context", profile, "delete", "pod", "mlb-http", "--now"))
404+
})
405+
406+
// Ensure the Service has a ready backend before hitting the VIP.
407+
waitEndpoints := func() error {
408+
rr, err := Run(t, exec.CommandContext(
409+
ctx, "kubectl", "--context", profile,
410+
"-n", "default", "get", "endpoints", "mlb-http-lb",
411+
"-o", "jsonpath={.subsets[0].addresses[0].ip}"))
412+
if err != nil {
413+
return err
414+
}
415+
if strings.TrimSpace(rr.Stdout.String()) == "" {
416+
return fmt.Errorf("endpoints not populated yet")
417+
}
418+
return nil
419+
}
420+
if err := retry.Expo(waitEndpoints, 1*time.Second, Minutes(2)); err != nil {
421+
t.Fatalf("endpoints for mlb-http-lb never became ready: %v", err)
422+
}
423+
424+
// Wait for an external IP from MetalLB.
425+
var lbIP string
426+
waitExternalIP := func() error {
427+
rr, err := Run(t, exec.CommandContext(
428+
ctx, "kubectl", "--context", profile,
429+
"-n", "default", "get", "svc", "mlb-http-lb",
430+
"-o", "jsonpath={.status.loadBalancer.ingress[0].ip}"))
431+
if err != nil {
432+
return err
433+
}
434+
ip := strings.TrimSpace(rr.Stdout.String())
435+
if ip == "" || strings.EqualFold(ip, "<pending>") {
436+
// Fallback: some environments surface 'hostname' instead of 'ip'
437+
rr2, err2 := Run(t, exec.CommandContext(
438+
ctx, "kubectl", "--context", profile,
439+
"-n", "default", "get", "svc", "mlb-http-lb",
440+
"-o", "jsonpath={.status.loadBalancer.ingress[0].hostname}"))
441+
if err2 != nil {
442+
return err2
443+
}
444+
host := strings.TrimSpace(rr2.Stdout.String())
445+
if host == "" || strings.EqualFold(host, "<pending>") {
446+
return fmt.Errorf("external address not allocated yet")
447+
}
448+
lbIP = host
449+
return nil
450+
451+
}
452+
lbIP = ip
453+
return nil
454+
}
455+
456+
if err := retry.Expo(waitExternalIP, 2*time.Second, Minutes(8)); err != nil {
457+
458+
t.Fatalf("failed waiting for LoadBalancer external IP: %v", err)
459+
}
460+
461+
t.Logf("mlb-http-lb external IP: %s", lbIP)
462+
// Small grace for ARP announcement to propagate on some networks.
463+
time.Sleep(2 * time.Second)
464+
465+
// Verify it serves HTTP 200 from the host.
466+
467+
endpoint := fmt.Sprintf("http://%s", lbIP)
468+
checkLB := func() error {
469+
resp, err := retryablehttp.Get(endpoint)
470+
if err != nil {
471+
return err
472+
}
473+
if resp.StatusCode != http.StatusOK {
474+
return fmt.Errorf("%s = status %d, want 200", endpoint, resp.StatusCode)
475+
}
476+
return nil
477+
}
478+
if err := retry.Expo(checkLB, 500*time.Millisecond, Minutes(3)); err != nil {
479+
t.Errorf("failed reaching LoadBalancer %s: %v", endpoint, err)
480+
}
481+
}
482+
192483
// validateIngressAddon tests the ingress addon by deploying a default nginx pod
193484
func validateIngressAddon(ctx context.Context, t *testing.T, profile string) {
194485
if NoneDriver() {

0 commit comments

Comments
 (0)