Skip to content

Commit 46331ed

Browse files
rgarciaclaude
andauthored
Harden test network cleanup and sweep orphaned iptables rules (#285)
The Linux integration tests create per-bridge iptables FORWARD/NAT rules. On the shared self-hosted runner these leak: the test-harness cleanup called iptables without the -w lock-wait flag and ignored all errors, so under xtables-lock contention from concurrent CI jobs cleanup failed silently. Rules whose bridge interface no longer exists then accumulated indefinitely (cleanupStaleLinkDownRoutes only handles still-present linkdown routes), inflating every iptables operation over time and contributing to flaky "instance did not reach Running within 45s" timeouts. - Add -w 5 to all harness iptables calls (matches lib/network convention) - Surface cleanup errors to stderr instead of swallowing them; retry deletes on transient lock contention; treat already-gone routes/links as benign - Sweep orphaned test iptables rules (interface gone) once per test binary under the existing subnet lock, scoped to the "hm" test prefix so a non-test hypeman process's "ha" rules are never touched - Remove dead HYPEMAN_TEST_NETWORK_TMPDIR plumbing from test.yml (its Go reader was reverted in #268; re-wiring it would reintroduce per-run lock/lease isolation and break cross-run subnet coordination) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e65b87a commit 46331ed

2 files changed

Lines changed: 138 additions & 14 deletions

File tree

.github/workflows/test.yml

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,6 @@ jobs:
7474
sudo env "PATH=$TEST_PATH" bash -lc "command -v '$bin'"
7575
done
7676
77-
- name: Create run-scoped temp directory
78-
run: |
79-
TEST_NETWORK_TMPDIR="/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}"
80-
sudo rm -rf "$TEST_NETWORK_TMPDIR"
81-
mkdir -p "$TEST_NETWORK_TMPDIR"
82-
sudo chown -R "$(id -u):$(id -g)" "$TEST_NETWORK_TMPDIR"
83-
8477
# Avoids rate limits when running the tests
8578
# Tests includes pulling, then converting to disk images
8679
- name: Login to Docker Hub
@@ -141,7 +134,6 @@ jobs:
141134
142135
- name: Run tests
143136
env:
144-
HYPEMAN_TEST_NETWORK_TMPDIR: /tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}
145137
GO_TEST_TIMEOUT: 600s
146138
# Docker auth for tests running as root (sudo)
147139
DOCKER_CONFIG: /home/debianuser/.docker
@@ -171,7 +163,6 @@ jobs:
171163
echo "$units" | xargs -r sudo systemctl reset-failed || true
172164
fi
173165
sudo rm -f /run/hypeman/uffd/ci-${{ github.run_id }}-${{ github.run_attempt }}-*.env
174-
sudo rm -rf "/tmp/hm-net-${{ github.run_id }}-${{ github.run_attempt }}"
175166
rm -f "${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }}"
176167
177168
test-darwin:

lib/instances/test_network_config_test.go

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
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+
3564
type 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+
327428
func 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

423524
func 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+
437554
func 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

479612
func legacyParallelTestNetworkConfig(seq uint32) config.NetworkConfig {

0 commit comments

Comments
 (0)