@@ -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
193484func validateIngressAddon (ctx context.Context , t * testing.T , profile string ) {
194485 if NoneDriver () {
0 commit comments