88 "os"
99 "os/exec"
1010 "path/filepath"
11+ "regexp"
1112 "runtime"
1213 "strconv"
1314 "strings"
@@ -32,6 +33,34 @@ const (
3233 testSubnetThirdOctetMax = 250
3334)
3435
36+ // iptablesWaitSeconds matches the production convention in
37+ // lib/network/bridge_linux.go so the test harness cooperates with the
38+ // system-wide xtables lock instead of failing immediately under contention.
39+ const testIPTablesWaitSeconds = "5"
40+
41+ // iptablesDeleteAttempts is the number of times we retry an iptables delete on
42+ // transient lock contention before giving up.
43+ const iptablesDeleteAttempts = 3
44+
45+ // newTestIPTablesCommand builds an iptables command with the -w wait flag so it
46+ // waits for the xtables lock rather than failing immediately.
47+ func newTestIPTablesCommand (args ... string ) * exec.Cmd {
48+ fullArgs := make ([]string , 0 , len (args )+ 2 )
49+ fullArgs = append (fullArgs , "-w" , testIPTablesWaitSeconds )
50+ fullArgs = append (fullArgs , args ... )
51+ return exec .Command ("iptables" , fullArgs ... )
52+ }
53+
54+ // logTestNetworkErr surfaces a real cleanup failure to stderr. These helpers run
55+ // both with and without a *testing.T (e.g. under testNetworkGuardCleanupOnce and
56+ // in t.Cleanup), so we log instead of swallowing the error silently.
57+ func logTestNetworkErr (op string , err error ) {
58+ if err == nil {
59+ return
60+ }
61+ fmt .Fprintf (os .Stderr , "hypeman-test-network: %s: %v\n " , op , err )
62+ }
63+
3564type testNetworkLease struct {
3665 cfg config.NetworkConfig
3766 release func ()
@@ -105,6 +134,10 @@ func allocateTestNetworkLease(testName string, seq uint32) (*testNetworkLease, e
105134
106135 testNetworkGuardCleanupOnce .Do (func () {
107136 cleanupStaleLinkDownRoutes (routes )
137+ // Sweep iptables rules for test bridges that no longer exist. Once a
138+ // bridge is fully deleted its route is gone too, so linkdown cleanup
139+ // above can't catch these — they would otherwise leak forever.
140+ sweepOrphanedTestIPTablesRules ()
108141 // Refresh route snapshot after cleanup so subnet selection sees current state.
109142 refreshed , refreshErr := listHostRoutes ()
110143 if refreshErr == nil {
@@ -324,6 +357,74 @@ func cleanupStaleLinkDownRoutes(routes []hostRoute) {
324357 }
325358}
326359
360+ // testRuleCommentPattern matches hypeman test-harness iptables rule comments and
361+ // captures both the full comment and the referenced bridge name. We deliberately
362+ // anchor on the "hm" test prefix so we never touch "ha"-prefixed rules from a
363+ // real (non-test) hypeman process running on the same host.
364+ var testRuleCommentPattern = regexp .MustCompile (`hypeman-(?:fwd-out|fwd-in|nat)-(hm[0-9a-f]+)` )
365+
366+ // sweepOrphanedTestIPTablesRules removes hypeman test iptables rules whose
367+ // referenced bridge interface no longer exists. Once a bridge is fully deleted,
368+ // its route disappears, so cleanupStaleLinkDownRoutes can't reach these rules and
369+ // they accumulate indefinitely on the shared CI runner, slowing every iptables
370+ // operation under the xtables lock.
371+ //
372+ // This is conservative and safe: a rule whose bridge interface is gone can never
373+ // affect live traffic, and we never delete a rule whose interface still exists.
374+ func sweepOrphanedTestIPTablesRules () {
375+ sweepOrphanedTestRulesInChain ("" , "FORWARD" )
376+ sweepOrphanedTestRulesInChain ("nat" , "POSTROUTING" )
377+ }
378+
379+ func sweepOrphanedTestRulesInChain (table , chain string ) {
380+ args := []string {}
381+ if table != "" {
382+ args = append (args , "-t" , table )
383+ }
384+ args = append (args , "-S" , chain )
385+ output , err := newTestIPTablesCommand (args ... ).Output ()
386+ if err != nil {
387+ logTestNetworkErr (fmt .Sprintf ("iptables -S %s/%s for orphan sweep" , table , chain ), err )
388+ return
389+ }
390+
391+ // Collect comments whose bridge interface is gone. Cache bridge existence and
392+ // dedupe comments so we shell out and delete each orphan only once.
393+ exists := make (map [string ]bool )
394+ seen := make (map [string ]struct {})
395+ var orphanedComments []string
396+ for _ , line := range strings .Split (string (output ), "\n " ) {
397+ match := testRuleCommentPattern .FindStringSubmatch (line )
398+ if match == nil {
399+ continue
400+ }
401+ comment := match [0 ]
402+ bridge := match [1 ]
403+
404+ alive , checked := exists [bridge ]
405+ if ! checked {
406+ alive = bridgeExists (bridge )
407+ exists [bridge ] = alive
408+ }
409+ if alive {
410+ // Never delete a rule whose bridge interface still exists.
411+ continue
412+ }
413+
414+ if _ , ok := seen [comment ]; ok {
415+ continue
416+ }
417+ seen [comment ] = struct {}{}
418+ orphanedComments = append (orphanedComments , comment )
419+ }
420+
421+ // Delete by comment, reusing the existing line-number-based deleter which
422+ // handles quoting and renumbering correctly.
423+ for _ , comment := range orphanedComments {
424+ deleteIPTablesRulesByComment (table , chain , comment )
425+ }
426+ }
427+
327428func pruneStaleLeases (leases map [string ]subnetLease , routes []hostRoute ) {
328429 liveRoutes := make (map [string ]struct {}, len (routes ))
329430 for _ , route := range routes {
@@ -422,10 +523,18 @@ func isTestCIDR(cidr string) bool {
422523
423524func cleanupTestNetworkArtifacts (bridgeName , subnetCIDR string ) {
424525 if subnetCIDR != "" && bridgeName != "" {
425- _ = exec .Command ("ip" , "-4" , "route" , "del" , subnetCIDR , "dev" , bridgeName ).Run ()
526+ // The route may already be gone (linkdown cleanup, prior pass) — that is
527+ // not a real failure, so only surface unexpected errors.
528+ out , err := exec .Command ("ip" , "-4" , "route" , "del" , subnetCIDR , "dev" , bridgeName ).CombinedOutput ()
529+ if err != nil && ! isNoSuchObjectErr (out ) {
530+ logTestNetworkErr (fmt .Sprintf ("ip route del %s dev %s: %s" , subnetCIDR , bridgeName , strings .TrimSpace (string (out ))), err )
531+ }
426532 }
427533 if bridgeName != "" {
428- _ = exec .Command ("ip" , "link" , "delete" , bridgeName , "type" , "bridge" ).Run ()
534+ out , err := exec .Command ("ip" , "link" , "delete" , bridgeName , "type" , "bridge" ).CombinedOutput ()
535+ if err != nil && ! isNoSuchObjectErr (out ) {
536+ logTestNetworkErr (fmt .Sprintf ("ip link delete %s: %s" , bridgeName , strings .TrimSpace (string (out ))), err )
537+ }
429538 }
430539
431540 bridgeSuffix := strings .ToLower (bridgeName )
@@ -434,6 +543,14 @@ func cleanupTestNetworkArtifacts(bridgeName, subnetCIDR string) {
434543 deleteIPTablesRulesByComment ("" , "FORWARD" , "hypeman-fwd-in-" + bridgeSuffix )
435544}
436545
546+ // isNoSuchObjectErr reports whether an `ip` command output indicates the object
547+ // (route/link) was already gone, which is an expected, benign outcome for
548+ // idempotent cleanup.
549+ func isNoSuchObjectErr (combinedOutput []byte ) bool {
550+ out := strings .ToLower (string (combinedOutput ))
551+ return strings .Contains (out , "cannot find" ) || strings .Contains (out , "no such" )
552+ }
553+
437554func deleteIPTablesRulesByComment (table , chain , comment string ) {
438555 if comment == "" {
439556 return
@@ -444,9 +561,9 @@ func deleteIPTablesRulesByComment(table, chain, comment string) {
444561 args = append (args , "-t" , table )
445562 }
446563 args = append (args , "-L" , chain , "--line-numbers" , "-n" )
447- listCmd := exec .Command ("iptables" , args ... )
448- output , err := listCmd .Output ()
564+ output , err := newTestIPTablesCommand (args ... ).Output ()
449565 if err != nil {
566+ logTestNetworkErr (fmt .Sprintf ("iptables list %s/%s for comment %q" , table , chain , comment ), err )
450567 return
451568 }
452569
@@ -472,8 +589,24 @@ func deleteIPTablesRulesByComment(table, chain, comment string) {
472589 delArgs = append (delArgs , "-t" , table )
473590 }
474591 delArgs = append (delArgs , "-D" , chain , strconv .Itoa (ruleNums [i ]))
475- _ = exec .Command ("iptables" , delArgs ... ).Run ()
592+ deleteIPTablesRuleWithRetry (delArgs )
593+ }
594+ }
595+
596+ // deleteIPTablesRuleWithRetry runs an iptables `-D` delete, retrying a few times
597+ // on transient lock contention (the failure mode under concurrent CI jobs).
598+ func deleteIPTablesRuleWithRetry (delArgs []string ) {
599+ var err error
600+ for attempt := 0 ; attempt < iptablesDeleteAttempts ; attempt ++ {
601+ if attempt > 0 {
602+ time .Sleep (time .Duration (attempt ) * 100 * time .Millisecond )
603+ }
604+ err = newTestIPTablesCommand (delArgs ... ).Run ()
605+ if err == nil {
606+ return
607+ }
476608 }
609+ logTestNetworkErr (fmt .Sprintf ("iptables %s" , strings .Join (delArgs , " " )), err )
477610}
478611
479612func legacyParallelTestNetworkConfig (seq uint32 ) config.NetworkConfig {
0 commit comments