Skip to content

Commit 62af470

Browse files
c-poclaude
andcommitted
T3871: handle phase-2 rename failures in safe_bulk_rename()
The phase-2 (scratch -> final target) loop ignored rename_interface()'s return value entirely: applied[old] was already set in phase 1 (staging to the scratch name), so a failed final rename still got reported as a success, and the unconditional `ip link set dev {target} up` tried to bring up a name that was never actually assigned - the interface stays down, stuck under its scratch (vyethN) name, with nothing tracking it correctly. Downstream, sync_rescan_hints() would see it under that scratch name and (since its MAC is normally a configured one, that's the whole reason it was being renamed) skip leaving a rescan hint for it too, since a configured MAC never gets a hint regardless of what name it's stuck under - so the interface would just silently vanish from the admin's view. Track (old, target) per scratch entry instead of target alone, so a phase-2 failure can remove the corresponding old->target entry from applied and bring the interface back up under its actual (scratch) name instead of the target it never reached. Successful renames are unaffected, and one failure in a batch doesn't affect the others. Adds TestSafeBulkRename, the first direct unit test for this function: the success path, a phase-2 failure not being reported as applied, the interface being brought back up under its scratch name rather than the unassigned target, and one failure not affecting other renames in the same batch. Verified all three failure-mode tests actually fail against the original code before confirming they pass with the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f28df1c commit 62af470

2 files changed

Lines changed: 63 additions & 4 deletions

File tree

src/system/vyos-net-name-resolve.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -356,12 +356,17 @@ def safe_bulk_rename(plan: dict) -> dict:
356356
for old, target in plan.items():
357357
tmp = f'vyeth{get_ifindex(old)}'
358358
if rename_interface(old, tmp):
359-
scratch[tmp] = target
359+
scratch[tmp] = (old, target)
360360
applied[old] = target
361361

362-
for tmp, target in scratch.items():
363-
rename_interface(tmp, target)
364-
run(f'ip link set dev {target} up')
362+
for tmp, (old, target) in scratch.items():
363+
if rename_interface(tmp, target):
364+
run(f'ip link set dev {target} up')
365+
else:
366+
# still sitting under the scratch name, not the target - don't
367+
# report it as renamed, and don't leave it down indefinitely
368+
del applied[old]
369+
run(f'ip link set dev {tmp} up')
365370

366371
return applied
367372

src/tests/test_net_name_resolve.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,60 @@ def test_bootstrap_targets_never_collide_with_existing_plan_values(self):
329329
self.assertNotEqual(plan.get('newnic'), 'eth0')
330330

331331

332+
class TestSafeBulkRename(unittest.TestCase):
333+
"""The two-phase rename must accurately report what actually happened -
334+
a phase-2 (scratch -> final target) failure must not be reported as a
335+
successful rename, and must not leave the interface down indefinitely
336+
under a name nothing else knows about.
337+
"""
338+
339+
def setUp(self):
340+
patcher = mock.patch.object(resolver, 'get_ifindex',
341+
side_effect=lambda name: name)
342+
patcher.start()
343+
self.addCleanup(patcher.stop)
344+
345+
def test_all_renames_succeed(self):
346+
with mock.patch.object(resolver, 'run', return_value=0):
347+
applied = resolver.safe_bulk_rename({'eth5': 'eth0'})
348+
self.assertEqual(applied, {'eth5': 'eth0'})
349+
350+
def test_phase_two_failure_is_not_reported_as_applied(self):
351+
# 'ip link set dev vyetheth5 name eth0' fails (exit 1); everything
352+
# else (down, the phase-1 rename to scratch) succeeds
353+
def fake_run(command, *_a, **_kw):
354+
return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0
355+
356+
with mock.patch.object(resolver, 'run', side_effect=fake_run):
357+
applied = resolver.safe_bulk_rename({'eth5': 'eth0'})
358+
359+
self.assertNotIn('eth5', applied)
360+
self.assertNotEqual(applied.get('eth5'), 'eth0')
361+
362+
def test_phase_two_failure_brings_scratch_name_back_up(self):
363+
calls = []
364+
365+
def fake_run(command, *_a, **_kw):
366+
calls.append(command)
367+
return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0
368+
369+
with mock.patch.object(resolver, 'run', side_effect=fake_run):
370+
resolver.safe_bulk_rename({'eth5': 'eth0'})
371+
372+
self.assertIn('ip link set dev vyetheth5 up', calls)
373+
self.assertNotIn('ip link set dev eth0 up', calls)
374+
375+
def test_one_failure_does_not_affect_other_renames_in_the_batch(self):
376+
def fake_run(command, *_a, **_kw):
377+
return 1 if command == 'ip link set dev vyetheth5 name eth0' else 0
378+
379+
with mock.patch.object(resolver, 'run', side_effect=fake_run):
380+
applied = resolver.safe_bulk_rename({'eth5': 'eth0', 'eth9': 'eth1'})
381+
382+
self.assertNotIn('eth5', applied)
383+
self.assertEqual(applied.get('eth9'), 'eth1')
384+
385+
332386
class TestGetPermanentMac(unittest.TestCase):
333387
"""Part of the original race is reading a MAC before the driver has
334388
programmed the permanent address. Verify the ethtool-first,

0 commit comments

Comments
 (0)