@@ -153,6 +153,7 @@ func TestAddons(t *testing.T) {
153153 {"NvidiaDevicePlugin" , validateNvidiaDevicePlugin },
154154 {"Yakd" , validateYakdAddon },
155155 {"AmdGpuDevicePlugin" , validateAmdGpuDevicePlugin },
156+ {"MetalLB" , validateMetalLBAddon },
156157 }
157158
158159 for _ , tc := range tests {
@@ -189,6 +190,115 @@ func TestAddons(t *testing.T) {
189190 })
190191}
191192
193+ // validateMetalLBAddon tests MetalLB by exposing a tiny HTTP pod via a LoadBalancer
194+ // and verifying it receives an external IP and is reachable from the host.
195+ func validateMetalLBAddon (ctx context.Context , t * testing.T , profile string ) {
196+ // MetalLB exercises host<->cluster L2/L3 path; skip where that’s not viable.
197+ if NoneDriver () {
198+ t .Skipf ("skipping: metallb not supported on 'none' driver in this test" )
199+ }
200+ if NeedsPortForward () {
201+ t .Skipf ("skipping metallb test: environment requires port-forwarding" )
202+ }
203+ defer disableAddon (t , "metallb" , profile )
204+ defer PostMortemLogs (t , profile )
205+
206+ client , err := kapi .Client (profile )
207+ if err != nil {
208+ t .Fatalf ("failed to get Kubernetes client: %v" , err )
209+ }
210+
211+ // Enable MetalLB
212+ if rr , err := Run (t , exec .CommandContext (ctx , Target (), "addons" , "enable" , "metallb" , "-p" , profile , "--alsologtostderr" , "-v=1" )); err != nil {
213+ t .Fatalf ("failed to enable metallb addon: args %q : %v" , rr .Command (), err )
214+ }
215+
216+ // Wait for controller to stabilize. Name differs across versions:
217+ // try "controller" first, fall back to "metallb-controller".
218+ start := time .Now ()
219+ waitController := func (name string ) error {
220+ return kapi .WaitForDeploymentToStabilize (client , "metallb-system" , name , Minutes (6 ))
221+ }
222+ if err := waitController ("controller" ); err != nil {
223+ t .Logf ("metallb deployment 'controller' not ready (%v); trying 'metallb-controller'" , err )
224+ if err2 := waitController ("metallb-controller" ); err2 != nil {
225+ t .Fatalf ("metallb controller failed to stabilize: %v / %v" , err , err2 )
226+ }
227+ }
228+ t .Logf ("metallb controller stabilized in %s" , time .Since (start ))
229+
230+ // Create a tiny HTTP server pod (busybox httpd) with a unique label to avoid collisions.
231+ // We avoid YAML here to keep the patch small and independent of other tests' resources.
232+ if rr , err := Run (t , exec .CommandContext (
233+ ctx , "kubectl" , "--context" , profile ,
234+ "run" , "mlb-http" ,
235+ "--image=gcr.io/k8s-minikube/busybox" ,
236+ "--labels" , "app=mlb-http" ,
237+ "--restart=Never" ,
238+ "--command" , "--" , "sh" , "-c" , "httpd -f -p 80" )); err != nil {
239+ t .Fatalf ("failed to create mlb-http pod: args %q : %v" , rr .Command (), err )
240+ }
241+
242+ if _ , err := PodWait (ctx , t , profile , "default" , "app=mlb-http" , Minutes (6 )); err != nil {
243+ t .Fatalf ("failed waiting for mlb-http pod: %v" , err )
244+ }
245+
246+ // Expose it as a LoadBalancer service.
247+ if rr , err := Run (t , exec .CommandContext (
248+ ctx , "kubectl" , "--context" , profile ,
249+ "expose" , "pod" , "mlb-http" ,
250+ "--name" , "mlb-http-lb" ,
251+ "--type" , "LoadBalancer" ,
252+ "--port" , "80" ,
253+ "--target-port" , "80" )); err != nil {
254+ t .Fatalf ("failed to expose mlb-http-lb service: args %q : %v" , rr .Command (), err )
255+ }
256+ t .Cleanup (func () {
257+ // best-effort cleanup
258+ Run (t , exec .CommandContext (ctx , "kubectl" , "--context" , profile , "delete" , "svc" , "mlb-http-lb" , "--now" ))
259+ Run (t , exec .CommandContext (ctx , "kubectl" , "--context" , profile , "delete" , "pod" , "mlb-http" , "--now" ))
260+ })
261+
262+ // Wait for an external IP from MetalLB.
263+ var lbIP string
264+ waitExternalIP := func () error {
265+ rr , err := Run (t , exec .CommandContext (
266+ ctx , "kubectl" , "--context" , profile ,
267+ "-n" , "default" , "get" , "svc" , "mlb-http-lb" ,
268+ "-o" , "jsonpath={.status.loadBalancer.ingress[0].ip}" ))
269+ if err != nil {
270+ return err
271+ }
272+ ip := strings .TrimSpace (rr .Stdout .String ())
273+ if ip == "" || strings .EqualFold (ip , "<pending>" ) {
274+ return fmt .Errorf ("external IP not allocated yet" )
275+ }
276+ lbIP = ip
277+ return nil
278+ }
279+ if err := retry .Expo (waitExternalIP , 2 * time .Second , Minutes (5 )); err != nil {
280+ t .Fatalf ("failed waiting for LoadBalancer external IP: %v" , err )
281+ }
282+ t .Logf ("mlb-http-lb external IP: %s" , lbIP )
283+
284+ // Verify it serves HTTP 200 from the host.
285+ endpoint := fmt .Sprintf ("http://%s" , lbIP )
286+ checkLB := func () error {
287+ resp , err := retryablehttp .Get (endpoint )
288+ if err != nil {
289+ return err
290+ }
291+ if resp .StatusCode != http .StatusOK {
292+ return fmt .Errorf ("%s = status %d, want 200" , endpoint , resp .StatusCode )
293+ }
294+ return nil
295+ }
296+ if err := retry .Expo (checkLB , 500 * time .Millisecond , Minutes (3 )); err != nil {
297+ t .Errorf ("failed reaching LoadBalancer %s: %v" , endpoint , err )
298+ }
299+ }
300+
301+
192302// validateIngressAddon tests the ingress addon by deploying a default nginx pod
193303func validateIngressAddon (ctx context.Context , t * testing.T , profile string ) {
194304 if NoneDriver () {
0 commit comments