Add network editing with VLAN, firewall, IPv4/IPv6 and undo
Press n in the info screen to edit one interface: bridge, VLAN tag, firewall, IPv4 (DHCP, static or none) and IPv6 (SLAAC, DHCP, static or none). The netN string is parsed, the chosen fields are changed and the string is written back, so hwaddr, model=MAC, mtu, rate, trunks and type are kept. For VMs, IP settings go to cloud-init ipconfigN and only when the VM has a cloud-init drive. A confirm shows the old and new raw values before anything is sent. Press u to restore the values from before the last change to that guest in this session. Review fixes: - Undo entries are cleared on a context switch, because VMIDs repeat across clusters. - has_cloudinit only looks at disks. - CIDR needs a decimal prefix: no netmask form and no /0. - Fields you did not touch are not rewritten. Live test on CT 104: firewall, VLAN 99, IPv6 DHCP and static IPv4 were each applied and undone, and net0 ended identical to the baseline. Going from static back to DHCP left the CT without IPv4 until a reboot, so the confirm now warns that IP changes on a running container may need one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #4.
This commit is contained in:
1 parent
b874996b1d
commit
5038148686
9 files changed
+1422
-11
No files matched your search
+549
-1
@@ -10,7 +10,14 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, DataTable, Input, OptionList, Select, Static
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
DataTable,
|
||||
Input,
|
||||
OptionList,
|
||||
Select,
|
||||
Static,
|
||||
)
|
||||
|
||||
from cv4pve_tui import app as appmod
|
||||
from cv4pve_tui import cli
|
||||
@@ -52,6 +59,9 @@ class Fake:
|
||||
self.iface_calls, self.backups_calls, self.backup_runs = [], [], []
|
||||
self.backup_error = None
|
||||
self.backup_hook = None
|
||||
# delivery B (network editing)
|
||||
self.bridge_list = ["vmbr0"]
|
||||
self.set_calls, self.set_error, self.set_hook = [], None, None
|
||||
|
||||
def list_guests(self):
|
||||
self.list_calls += 1
|
||||
@@ -134,6 +144,25 @@ class Fake:
|
||||
if self.backup_error:
|
||||
raise self.backup_error
|
||||
|
||||
def bridges(self, guest):
|
||||
return list(self.bridge_list)
|
||||
|
||||
def set_net(self, guest, changes):
|
||||
self.set_calls.append((guest["vmid"], dict(changes)))
|
||||
if self.set_hook:
|
||||
self.set_hook()
|
||||
if self.set_error:
|
||||
raise self.set_error
|
||||
config = self.net_configs.setdefault(guest["vmid"], {})
|
||||
for (
|
||||
key,
|
||||
value,
|
||||
) in changes.items(): # mirror the server so reloads see the change
|
||||
if value is None:
|
||||
config.pop(key, None)
|
||||
else:
|
||||
config[key] = value
|
||||
|
||||
|
||||
BACKUP_JOB = {"id": "backup-1", "enabled": 1, "storage": "truenas-backup", "all": 1,
|
||||
"vmid": None, "exclude": None, "node": None, "schedule": "mon,wed,fri 04:00",
|
||||
@@ -164,6 +193,8 @@ def fake(monkeypatch):
|
||||
"backup_jobs",
|
||||
"backups",
|
||||
"backup_now",
|
||||
"bridges",
|
||||
"set_net",
|
||||
):
|
||||
monkeypatch.setattr(cli, name, getattr(f, name), raising=False)
|
||||
for name in ("list_guests", "guest_config", "disks", "action", "set_resources",
|
||||
@@ -1316,3 +1347,520 @@ async def test_no_hint_when_nothing_hidden(fake):
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
assert str(app.screen.query_one("#hidden", Static).render()).strip() == ""
|
||||
|
||||
|
||||
# --- delivery B: network editing ----------------------------------------------
|
||||
# spec: docs/plans/guest-tui-mvp.md ("Network editing - delivery B" > app.py)
|
||||
|
||||
CT_OLD = CT_CONFIG["net0"]
|
||||
VM_CI = {
|
||||
**VM_CONFIG,
|
||||
"ide2": "local-lvm:vm-108-cloudinit,media=cdrom",
|
||||
} # VM with cloud-init
|
||||
CT_STATIC6 = ("name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,"
|
||||
"ip6=2001:db8::5/64,gw6=2001:db8::1,type=veth") # fmt: skip
|
||||
|
||||
|
||||
async def open_net(app, pilot, down=1):
|
||||
"""Open NetScreen; default row is CT 100 (stopped), down=0 is VM 108 (running)."""
|
||||
await open_info(app, pilot, down=down)
|
||||
await pilot.press("n")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.NetScreen)
|
||||
|
||||
|
||||
def val(app, sel):
|
||||
return app.screen.query_one(f"#{sel}").value
|
||||
|
||||
|
||||
async def setv(app, pilot, **values):
|
||||
for key, value in values.items():
|
||||
app.screen.query_one(f"#{key}").value = value
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
async def to_info(app, pilot):
|
||||
for _ in range(4):
|
||||
if isinstance(app.screen, appmod.InfoScreen):
|
||||
return
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
|
||||
|
||||
async def apply_to_confirm(app, pilot):
|
||||
await press_button(app, pilot, "#apply")
|
||||
assert isinstance(app.screen, ConfirmScreen)
|
||||
return text_of(app.screen)
|
||||
|
||||
|
||||
async def change_and_confirm(app, pilot, **values):
|
||||
await setv(app, pilot, **values)
|
||||
text = await apply_to_confirm(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
return text
|
||||
|
||||
|
||||
def keys_of(changes):
|
||||
return {k: cli.parse_net(v) if v is not None else None for k, v in changes.items()}
|
||||
|
||||
|
||||
async def test_n_opens_net_screen_prefilled_for_ct_sample(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
assert val(app, "iface") == "net0"
|
||||
assert val(app, "bridge") == "vmbr0"
|
||||
assert val(app, "tag") == ""
|
||||
assert val(app, "firewall") is False
|
||||
assert val(app, "ipv4") == "dhcp"
|
||||
assert val(app, "ipv6") == "auto"
|
||||
|
||||
|
||||
async def test_net_screen_prefilled_for_vm_from_ipconfig(fake):
|
||||
fake.net_configs[108] = dict(VM_CI)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
assert val(app, "bridge") == "vmbr0"
|
||||
assert val(app, "tag") == "20"
|
||||
assert val(app, "firewall") is True
|
||||
assert val(app, "ipv4") == "dhcp"
|
||||
assert val(app, "ipv6") == "none"
|
||||
assert not app.screen.query_one("#ipv4", Select).disabled
|
||||
|
||||
|
||||
async def test_n_on_guest_without_nets_opens_nothing(fake):
|
||||
app = make_app() # default Fake config has no netN
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot)
|
||||
await pilot.press("n")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
|
||||
|
||||
async def test_changing_iface_prefills_again(fake):
|
||||
fake.bridge_list = ["vmbr0", "vmbr1"]
|
||||
fake.net_configs[100] = {
|
||||
**CT_CONFIG,
|
||||
"net1": "name=eth1,bridge=vmbr1,hwaddr=BC:24:11:36:DE:79,tag=7,firewall=1,"
|
||||
"ip=10.1.1.5/24,gw=10.1.1.1,ip6=manual,type=veth",
|
||||
} # fmt: skip
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await setv(app, pilot, iface="net1")
|
||||
assert val(app, "bridge") == "vmbr1" and val(app, "tag") == "7"
|
||||
assert val(app, "firewall") is True
|
||||
assert val(app, "ipv4") == "static"
|
||||
assert (
|
||||
val(app, "ipv4_cidr") == "10.1.1.5/24" and val(app, "ipv4_gw") == "10.1.1.1"
|
||||
)
|
||||
assert val(app, "ipv6") == "none"
|
||||
|
||||
|
||||
async def test_vm_without_cloudinit_disables_ip_widgets_and_explains(fake):
|
||||
fake.net_configs[108] = dict(VM_CONFIG) # no cloud-init drive
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
for wid in ("ipv4", "ipv4_cidr", "ipv4_gw", "ipv6", "ipv6_cidr", "ipv6_gw"):
|
||||
assert app.screen.query_one(f"#{wid}").disabled, wid
|
||||
assert str(app.screen.query_one("#ip_note", Static).render()).strip() != ""
|
||||
assert not app.screen.query_one("#tag").disabled
|
||||
|
||||
|
||||
async def test_vm_without_cloudinit_link_change_keeps_key_position_and_no_ipconfig(
|
||||
fake,
|
||||
):
|
||||
fake.net_configs[108] = dict(VM_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await change_and_confirm(app, pilot, tag="30")
|
||||
assert fake.set_calls == [
|
||||
(108, {"net0": "virtio=02:24:A6:49:A8:25,bridge=vmbr0,tag=30,firewall=1"})
|
||||
]
|
||||
|
||||
|
||||
async def test_set_vlan_99_confirm_shows_old_and_new_then_sets_once_busy_and_reloads(
|
||||
fake,
|
||||
):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
busy_seen = []
|
||||
fake.set_hook = lambda: busy_seen.append(set(app.busy))
|
||||
new = CT_OLD + ",tag=99"
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
before = len(fake.backups_calls)
|
||||
await setv(app, pilot, tag="99")
|
||||
text = await apply_to_confirm(app, pilot)
|
||||
assert CT_OLD in text and new in text
|
||||
assert fake.set_calls == [] # nothing before "yes"
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.set_calls == [(100, {"net0": new})]
|
||||
assert busy_seen and busy_seen[0]
|
||||
assert app.busy == set() and errors(app) == []
|
||||
assert len(fake.backups_calls) > before # InfoScreen reloaded
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
assert cells(app.screen.query_one("#nets", DataTable), 0)[2] == "99"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["5000", "0", "abc"])
|
||||
async def test_invalid_tag_errors_without_call_and_screen_stays(fake, bad):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await setv(app, pilot, tag=bad)
|
||||
await press_button(app, pilot, "#apply")
|
||||
assert errors(app) and fake.set_calls == []
|
||||
assert isinstance(app.screen, appmod.NetScreen)
|
||||
|
||||
|
||||
async def test_static_ipv4_without_prefix_errors_without_call(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await setv(app, pilot, ipv4="static", ipv4_cidr="10.0.0.5")
|
||||
await press_button(app, pilot, "#apply")
|
||||
assert errors(app) and fake.set_calls == []
|
||||
assert isinstance(app.screen, appmod.NetScreen)
|
||||
|
||||
|
||||
async def test_no_change_gives_info_notify_no_confirm_no_call(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await press_button(app, pilot, "#apply")
|
||||
assert not isinstance(app.screen, ConfirmScreen)
|
||||
assert fake.set_calls == [] and errors(app) == []
|
||||
assert any(sev == "information" for _, sev in app.seen)
|
||||
|
||||
|
||||
async def test_static_ipv4_with_gateway_keeps_hwaddr_and_other_keys(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await change_and_confirm(
|
||||
app,
|
||||
pilot,
|
||||
ipv4="static",
|
||||
ipv4_cidr="172.16.41.200/23",
|
||||
ipv4_gw="172.16.40.1",
|
||||
)
|
||||
assert len(fake.set_calls) == 1
|
||||
vmid, changes = fake.set_calls[0]
|
||||
assert vmid == 100 and set(changes) == {"net0"}
|
||||
assert cli.parse_net(changes["net0"]) == {
|
||||
"name": "eth0", "bridge": "vmbr0", "hwaddr": "BC:24:11:36:DE:78",
|
||||
"ip": "172.16.41.200/23", "gw": "172.16.40.1", "ip6": "auto", "type": "veth",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
async def test_ipv6_static_to_dhcp_removes_gw6(fake):
|
||||
fake.net_configs[100] = {"net0": CT_STATIC6}
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
assert val(app, "ipv6") == "static" and val(app, "ipv6_gw") == "2001:db8::1"
|
||||
await change_and_confirm(app, pilot, ipv6="dhcp")
|
||||
new = cli.parse_net(fake.set_calls[0][1]["net0"])
|
||||
assert new["ip6"] == "dhcp" and "gw6" not in new
|
||||
assert new["hwaddr"] == "BC:24:11:36:DE:78" and new["ip"] == "dhcp"
|
||||
|
||||
|
||||
async def test_vm_ipconfig_change_goes_into_ipconfig0_and_confirm_mentions_next_boot(
|
||||
fake,
|
||||
):
|
||||
fake.net_configs[108] = dict(VM_CI)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await setv(
|
||||
app, pilot, ipv4="static", ipv4_cidr="10.0.0.5/24", ipv4_gw="10.0.0.1"
|
||||
)
|
||||
text = await apply_to_confirm(app, pilot)
|
||||
assert "next boot" in text.lower()
|
||||
assert "ip=dhcp" in text and "10.0.0.5/24" in text # old and new raw strings
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert [v for v, _ in fake.set_calls] == [108]
|
||||
changes = fake.set_calls[0][1]
|
||||
assert set(changes) == {"ipconfig0"} # net0 is unchanged and not sent
|
||||
assert cli.parse_net(changes["ipconfig0"]) == {
|
||||
"ip": "10.0.0.5/24",
|
||||
"gw": "10.0.0.1",
|
||||
}
|
||||
|
||||
|
||||
async def test_vm_ipconfig_none_none_deletes_ipconfig0(fake):
|
||||
fake.net_configs[108] = dict(VM_CI)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await change_and_confirm(app, pilot, ipv4="none") # ipv6 already none
|
||||
assert fake.set_calls == [(108, {"ipconfig0": None})]
|
||||
|
||||
|
||||
async def test_link_confirm_for_running_guest_says_applies_immediately(fake):
|
||||
fake.net_configs[108] = dict(VM_CI)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await setv(app, pilot, firewall=False)
|
||||
text = await apply_to_confirm(app, pilot)
|
||||
assert "immediately" in text.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("how", ["cancel", "escape"])
|
||||
async def test_cancel_or_escape_in_net_screen_makes_no_call(fake, how):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await setv(app, pilot, tag="99")
|
||||
if how == "cancel":
|
||||
await press_button(app, pilot, "#cancel")
|
||||
else:
|
||||
await pilot.press("escape")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, appmod.NetScreen)
|
||||
assert fake.set_calls == [] and app.busy == set()
|
||||
|
||||
|
||||
async def test_declining_confirm_makes_no_call(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await setv(app, pilot, tag="99")
|
||||
await apply_to_confirm(app, pilot)
|
||||
await press_button(app, pilot, "#no")
|
||||
assert fake.set_calls == [] and app.busy == set()
|
||||
|
||||
|
||||
async def test_apply_while_guest_busy_warns_and_makes_no_second_call(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
started, release = threading.Event(), threading.Event()
|
||||
fake.set_hook = lambda: (started.set(), release.wait(10))
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
try:
|
||||
await setv(app, pilot, tag="99")
|
||||
await apply_to_confirm(app, pilot)
|
||||
app.screen.query_one("#yes", Button).press()
|
||||
for _ in range(200):
|
||||
if started.is_set():
|
||||
break
|
||||
await pilot.pause(0.01)
|
||||
assert started.is_set()
|
||||
await pilot.press(
|
||||
"escape"
|
||||
) # leave whatever is on top; the guest is still busy
|
||||
await pilot.pause()
|
||||
await to_info(app, pilot)
|
||||
await pilot.press("n")
|
||||
await pilot.pause()
|
||||
if isinstance(
|
||||
app.screen, appmod.NetScreen
|
||||
): # refusal may come at apply/yes
|
||||
await setv(app, pilot, tag="55")
|
||||
app.screen.query_one("#apply", Button).press()
|
||||
await pilot.pause()
|
||||
if isinstance(app.screen, ConfirmScreen):
|
||||
app.screen.query_one("#yes", Button).press()
|
||||
await pilot.pause()
|
||||
assert any(sev == "warning" for _, sev in app.seen)
|
||||
assert len(fake.set_calls) == 1
|
||||
finally:
|
||||
release.set()
|
||||
await settle(app, pilot)
|
||||
assert len(fake.set_calls) == 1
|
||||
|
||||
|
||||
async def test_set_net_failure_notifies_error_releases_busy_and_stores_no_undo(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
fake.set_error = cli_error("Error: unable to apply net0")
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await change_and_confirm(app, pilot, tag="99")
|
||||
assert any("unable to apply net0" in m for m in errors(app))
|
||||
assert app.busy == set() and app.is_running
|
||||
await to_info(app, pilot)
|
||||
calls = len(fake.set_calls)
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, ConfirmScreen) # no undo entry was stored
|
||||
assert len(fake.set_calls) == calls
|
||||
|
||||
|
||||
# --- undo ---------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_u_without_undo_entry_gives_info_notify_and_no_call(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_info(app, pilot, down=1)
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
assert any(sev == "information" for _, sev in app.seen)
|
||||
assert fake.set_calls == []
|
||||
|
||||
|
||||
async def test_u_after_change_confirms_then_restores_old_net_string_and_clears_entry(
|
||||
fake,
|
||||
):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await change_and_confirm(app, pilot, tag="99")
|
||||
await to_info(app, pilot)
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, ConfirmScreen)
|
||||
assert CT_OLD in text_of(app.screen) # what will be restored
|
||||
assert len(fake.set_calls) == 1 # nothing before "yes"
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.set_calls[1] == (100, {"net0": CT_OLD})
|
||||
assert app.busy == set() and errors(app) == []
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
assert cells(app.screen.query_one("#nets", DataTable), 0)[2] == "-"
|
||||
await pilot.press("u") # entry cleared after success
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, ConfirmScreen) and len(fake.set_calls) == 2
|
||||
|
||||
|
||||
async def test_undo_declined_makes_no_call_and_keeps_entry(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await change_and_confirm(app, pilot, tag="99")
|
||||
await to_info(app, pilot)
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
await press_button(app, pilot, "#no")
|
||||
assert len(fake.set_calls) == 1
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, ConfirmScreen) # entry still there
|
||||
|
||||
|
||||
async def test_undo_of_key_that_did_not_exist_deletes_it(fake):
|
||||
ci_no_ipconfig = {k: v for k, v in VM_CI.items() if k != "ipconfig0"}
|
||||
fake.net_configs[108] = ci_no_ipconfig
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await change_and_confirm(app, pilot, ipv4="dhcp")
|
||||
assert fake.set_calls == [(108, {"ipconfig0": "ip=dhcp"})]
|
||||
await to_info(app, pilot)
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.set_calls[1] == (108, {"ipconfig0": None})
|
||||
|
||||
|
||||
async def test_undo_restores_both_net_and_ipconfig_of_one_change(fake):
|
||||
fake.net_configs[108] = dict(VM_CI)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await change_and_confirm(app, pilot, tag="30", ipv4="none")
|
||||
assert set(fake.set_calls[0][1]) == {"net0", "ipconfig0"}
|
||||
await to_info(app, pilot)
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.set_calls[1] == (
|
||||
108,
|
||||
{"net0": VM_CONFIG["net0"], "ipconfig0": VM_CONFIG["ipconfig0"]},
|
||||
)
|
||||
|
||||
|
||||
async def test_undo_entry_is_per_guest(fake):
|
||||
fake.net_configs[100] = dict(CT_CONFIG)
|
||||
fake.net_configs[108] = dict(VM_CI)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot) # CT 100
|
||||
await change_and_confirm(app, pilot, tag="99")
|
||||
await pilot.press("escape") # close InfoScreen
|
||||
await settle(app, pilot)
|
||||
await pilot.press("up")
|
||||
await pilot.press("i") # VM 108: no change was made there
|
||||
await settle(app, pilot)
|
||||
assert isinstance(app.screen, appmod.InfoScreen)
|
||||
await pilot.press("u")
|
||||
await settle(app, pilot)
|
||||
assert not isinstance(app.screen, ConfirmScreen)
|
||||
|
||||
|
||||
# --- review findings: network editing -------------------------------------------
|
||||
|
||||
|
||||
async def test_context_switch_clears_undo_entries(fake):
|
||||
# VMIDs repeat across clusters: an undo from one cluster must never hit the other
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await settle(app, pilot)
|
||||
app.undo[108] = {"net0": "virtio=02:24:A6:49:A8:25,bridge=vmbr0"}
|
||||
await switch_to_edge(app, pilot)
|
||||
await settle(app, pilot)
|
||||
assert app.undo == {}
|
||||
|
||||
|
||||
async def test_untouched_fields_are_not_rewritten(fake):
|
||||
# ip6=manual is left alone when only the VLAN changes; firewall=0 == absent (spec: off removes it)
|
||||
fake.net_configs[100] = {
|
||||
"net0": "name=eth0,bridge=vmbr0,firewall=0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=manual,type=veth"
|
||||
}
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot)
|
||||
await press_button(app, pilot, "#apply")
|
||||
assert not isinstance(
|
||||
app.screen, ConfirmScreen
|
||||
) # nothing touched -> "no change"
|
||||
await setv(app, pilot, tag="99")
|
||||
await press_button(app, pilot, "#apply")
|
||||
await press_button(app, pilot, "#yes")
|
||||
assert fake.set_calls[-1][1] == {
|
||||
"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:36:DE:78,ip=dhcp,ip6=manual,type=veth,tag=99"
|
||||
}
|
||||
|
||||
|
||||
# --- live finding: a running CT does not follow IP mode changes -------------------
|
||||
|
||||
|
||||
async def test_ip_change_on_running_ct_warns_reboot_may_be_needed(fake):
|
||||
# live on CT 104: static -> dhcp left the CT without IPv4 until a reboot
|
||||
fake.guests = [RUNNING_CT]
|
||||
fake.net_configs[104] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await setv(app, pilot, ipv4="static", ipv4_cidr="172.16.41.200/23")
|
||||
text = await apply_to_confirm(app, pilot)
|
||||
assert "reboot" in text.lower()
|
||||
|
||||
|
||||
async def test_link_only_change_on_running_ct_does_not_mention_reboot(fake):
|
||||
fake.guests = [RUNNING_CT]
|
||||
fake.net_configs[104] = dict(CT_CONFIG)
|
||||
app = make_app()
|
||||
async with app.run_test() as pilot:
|
||||
await open_net(app, pilot, down=0)
|
||||
await setv(app, pilot, tag="99")
|
||||
text = await apply_to_confirm(app, pilot)
|
||||
assert "reboot" not in text.lower()
|
||||
Reference in new issue
Block a user