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
+265
-9
@@ -10,6 +10,7 @@ from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
Footer,
|
||||
Header,
|
||||
@@ -160,6 +161,168 @@ class BackupScreen(ModalScreen[str | None]):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
_IPV4_MODES = [("DHCP", "dhcp"), ("Static", "static"), ("None", "none")]
|
||||
_IPV6_MODES = [("SLAAC", "auto"), *_IPV4_MODES]
|
||||
|
||||
|
||||
class NetScreen(ModalScreen):
|
||||
"""Edit bridge, VLAN, firewall and IP of one interface; confirms, then applies."""
|
||||
|
||||
BINDINGS = [("escape", "dismiss", "Cancel")] # noqa: RUF012 - Textual idiom
|
||||
DEFAULT_CSS = "NetScreen Horizontal { height: auto; }"
|
||||
|
||||
def __init__(
|
||||
self, guest: dict, config: dict, bridges: list[str], running: bool
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.guest, self.config, self.running = guest, config, running
|
||||
self.nets = cli.nets(config)
|
||||
self.is_vm = cli.kind(guest) == "vm"
|
||||
self.ip_enabled = not self.is_vm or cli.has_cloudinit(config)
|
||||
# a bridge the node no longer lists must stay selectable, or Select rejects the prefill
|
||||
used = {n.get("bridge") for n in self.nets.values()} - {None}
|
||||
self.bridges = sorted(set(bridges) | used)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
yield Static(
|
||||
f"Network of {self.guest['name']} ({self.guest['vmid']})", markup=False
|
||||
)
|
||||
yield Select(
|
||||
[(k, k) for k in self.nets],
|
||||
value=next(iter(self.nets)),
|
||||
allow_blank=False,
|
||||
id="iface",
|
||||
)
|
||||
with Horizontal():
|
||||
yield Select(
|
||||
[(b, b) for b in self.bridges], allow_blank=False, id="bridge"
|
||||
)
|
||||
yield Input(placeholder="VLAN tag (empty = none)", id="tag")
|
||||
yield Checkbox("Firewall", id="firewall")
|
||||
if not self.ip_enabled:
|
||||
yield Static(
|
||||
"IP is configured inside the VM (no cloud-init drive)", id="ip_note"
|
||||
)
|
||||
for fam, modes in (("ipv4", _IPV4_MODES), ("ipv6", _IPV6_MODES)):
|
||||
with Horizontal():
|
||||
yield Select(modes, allow_blank=False, id=fam)
|
||||
yield Input(placeholder="address/prefix", id=f"{fam}_cidr")
|
||||
yield Input(placeholder="gateway", id=f"{fam}_gw")
|
||||
with Horizontal():
|
||||
yield Button("Apply", id="apply", variant="primary")
|
||||
yield Button("Cancel", id="cancel")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
for wid in ("ipv4", "ipv4_cidr", "ipv4_gw", "ipv6", "ipv6_cidr", "ipv6_gw"):
|
||||
self.query_one(f"#{wid}").disabled = not self.ip_enabled
|
||||
self._prefill()
|
||||
|
||||
def on_select_changed(self, event: Select.Changed) -> None:
|
||||
if event.select.id == "iface":
|
||||
self._prefill()
|
||||
|
||||
def _ip_cfg(self, iface: str) -> dict:
|
||||
"""The dict holding the IP keys: the VM's ipconfigN, or the LXC net itself."""
|
||||
if self.is_vm:
|
||||
return cli.parse_net(str(self.config.get(f"ipconfig{iface[3:]}", "")))
|
||||
return self.nets[iface]
|
||||
|
||||
def _prefill(self) -> None:
|
||||
iface = self.query_one("#iface", Select).value
|
||||
net = self.nets[iface]
|
||||
self.query_one("#bridge", Select).value = net.get("bridge", self.bridges[0])
|
||||
self.query_one("#tag", Input).value = net.get("tag", "")
|
||||
self.query_one("#firewall", Checkbox).value = net.get("firewall") == "1"
|
||||
for fam, family in (("ipv4", 4), ("ipv6", 6)):
|
||||
mode, cidr, gw = cli.ip_mode(self._ip_cfg(iface), family)
|
||||
self.query_one(f"#{fam}", Select).value = mode
|
||||
self.query_one(f"#{fam}_cidr", Input).value = cidr
|
||||
self.query_one(f"#{fam}_gw", Input).value = gw
|
||||
|
||||
def _changes(self) -> tuple[dict, dict]:
|
||||
"""(changes, old) as raw netN/ipconfigN strings, None = absent; ValueError on bad input."""
|
||||
iface = self.query_one("#iface", Select).value
|
||||
text = self.query_one("#tag", Input).value.strip()
|
||||
if text and not text.isdecimal():
|
||||
raise ValueError("VLAN tag must be 1-4094")
|
||||
current = self.nets[iface]
|
||||
link = (
|
||||
self.query_one("#bridge", Select).value,
|
||||
int(text) if text else None,
|
||||
self.query_one("#firewall", Checkbox).value,
|
||||
)
|
||||
was = (current.get("bridge"), int(current["tag"]) if current.get("tag") else None,
|
||||
current.get("firewall") == "1") # fmt: skip
|
||||
# untouched fields are not rewritten (e.g. firewall=0 stays as it is)
|
||||
net = cli.apply_link(current, *link) if link != was else dict(current)
|
||||
ip_key = f"ipconfig{iface[3:]}"
|
||||
ip_old = self._ip_cfg(iface) if self.is_vm else {}
|
||||
ip = ip_old if self.is_vm else net # the dict that holds the IP keys
|
||||
if self.ip_enabled:
|
||||
for fam, family in (("ipv4", 4), ("ipv6", 6)):
|
||||
mode, cidr, gw = (
|
||||
self.query_one(f"#{fam}{s}").value for s in ("", "_cidr", "_gw")
|
||||
)
|
||||
wanted = (mode, cidr.strip(), gw.strip())
|
||||
if wanted != cli.ip_mode(
|
||||
ip, family
|
||||
): # untouched: e.g. ip6=manual stays
|
||||
ip = cli.apply_ip(ip, family, *wanted)
|
||||
if not self.is_vm:
|
||||
net = ip
|
||||
changes, old = {}, {}
|
||||
if net != self.nets[iface]:
|
||||
changes[iface] = cli.format_net(net)
|
||||
old[iface] = str(self.config[iface])
|
||||
if self.is_vm and ip != ip_old:
|
||||
changes[ip_key] = cli.format_net(ip) or None
|
||||
old[ip_key] = self.config.get(ip_key)
|
||||
return changes, old
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "cancel":
|
||||
self.dismiss()
|
||||
return
|
||||
try:
|
||||
changes, old = self._changes()
|
||||
except ValueError as exc:
|
||||
self.app.notify(str(exc), severity="error")
|
||||
return
|
||||
if not changes:
|
||||
self.app.notify("No change")
|
||||
return
|
||||
lines = [
|
||||
f"{k}\n from {old[k] or '(not set)'}\n to {v or '(removed)'}"
|
||||
for k, v in changes.items()
|
||||
]
|
||||
notes = []
|
||||
if self.running and any(k.startswith("net") for k in changes):
|
||||
notes.append(
|
||||
"Link changes apply immediately and can cut the guest's network."
|
||||
)
|
||||
if (
|
||||
self.running
|
||||
and not self.is_vm
|
||||
and any(_ip_changed(old[k], v) for k, v in changes.items())
|
||||
): # seen live: static -> dhcp left the CT without IPv4 until it was rebooted
|
||||
notes.append(
|
||||
"IP changes on a running container may need a reboot of the container "
|
||||
"(e.g. DHCP is not restarted)."
|
||||
)
|
||||
if any(k.startswith("ipconfig") for k in changes):
|
||||
notes.append("IP settings (cloud-init) apply at next boot.")
|
||||
who = f"{self.guest['name']} ({self.guest['vmid']})"
|
||||
self.app.push_screen(
|
||||
ConfirmScreen("\n".join([f"Change network of {who}?", *lines, *notes])),
|
||||
lambda ok: (
|
||||
self.app.start_net_change(self.guest, changes, old, self)
|
||||
if ok
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class InfoScreen(ModalScreen):
|
||||
"""Backups, backup job and network of one guest (read-only), plus backup now."""
|
||||
|
||||
@@ -168,6 +331,8 @@ class InfoScreen(ModalScreen):
|
||||
("g", "reload", "Refresh"),
|
||||
("b", "backup", "Backup now"),
|
||||
("a", "toggle_all", "All backups"),
|
||||
("n", "net", "Edit network"),
|
||||
("u", "undo", "Undo network"),
|
||||
]
|
||||
DEFAULT_CSS = "InfoScreen DataTable { height: auto; max-height: 12; }"
|
||||
|
||||
@@ -178,6 +343,8 @@ class InfoScreen(ModalScreen):
|
||||
self.jobs: list[dict] = []
|
||||
self.backups: list[dict] = []
|
||||
self.show_all = False
|
||||
self.config: dict = {}
|
||||
self.running = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical():
|
||||
@@ -231,6 +398,7 @@ class InfoScreen(ModalScreen):
|
||||
addresses = cli.interfaces(guest)
|
||||
except Exception as exc: # noqa: BLE001 - shown inline, the rest of the screen still works
|
||||
unavailable = f"unavailable: {_message(exc)}"
|
||||
self.running = running
|
||||
self.app.call_from_thread(
|
||||
self._show, config, nets, storages, jobs, backups, addresses, unavailable
|
||||
)
|
||||
@@ -250,7 +418,7 @@ class InfoScreen(ModalScreen):
|
||||
def _fill_screen(
|
||||
self, config, nets, storages, jobs, backups, addresses, unavailable
|
||||
) -> None:
|
||||
self.storages, self.jobs = storages, jobs
|
||||
self.storages, self.jobs, self.config = storages, jobs, config
|
||||
covering = cli.covering_jobs(jobs, self.guest)
|
||||
self.query_one("#jobs", Static).update(
|
||||
"; ".join(_job_text(j) for j in covering)
|
||||
@@ -309,6 +477,46 @@ class InfoScreen(ModalScreen):
|
||||
self.show_all = not self.show_all
|
||||
self._show_backups()
|
||||
|
||||
def _refuse_if_busy(self) -> bool:
|
||||
busy = self.guest["vmid"] in self.app.busy
|
||||
if busy:
|
||||
self.app.notify(
|
||||
f"{self.guest['name']} ({self.guest['vmid']}) is busy",
|
||||
severity="warning",
|
||||
)
|
||||
return busy
|
||||
|
||||
def action_net(self) -> None:
|
||||
if cli.nets(self.config) and not self._refuse_if_busy():
|
||||
self._open_net()
|
||||
|
||||
@work(thread=True)
|
||||
def _open_net(self) -> None:
|
||||
try:
|
||||
bridges = cli.bridges(self.guest)
|
||||
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
|
||||
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
|
||||
return
|
||||
self.app.call_from_thread(
|
||||
self.app.push_screen,
|
||||
NetScreen(self.guest, self.config, bridges, self.running),
|
||||
)
|
||||
|
||||
def action_undo(self) -> None:
|
||||
old = self.app.undo.get(self.guest["vmid"])
|
||||
if old is None:
|
||||
self.app.notify("No network change to undo")
|
||||
return
|
||||
if self._refuse_if_busy():
|
||||
return
|
||||
lines = [f"{k} = {v or '(remove)'}" for k, v in old.items()]
|
||||
self.app.push_screen(
|
||||
ConfirmScreen(
|
||||
"\n".join(["Restore the previous network settings?", *lines])
|
||||
),
|
||||
lambda ok: self.app.start_net_change(self.guest, old, None) if ok else None,
|
||||
)
|
||||
|
||||
def action_backup(self) -> None:
|
||||
if not self.storages:
|
||||
self.app.notify("No backup storage available", severity="warning")
|
||||
@@ -324,6 +532,11 @@ class InfoScreen(ModalScreen):
|
||||
)
|
||||
|
||||
|
||||
def _ip_changed(old: str | None, new: str | None) -> bool:
|
||||
before, after = cli.parse_net(old or ""), cli.parse_net(new or "")
|
||||
return any(before.get(k) != after.get(k) for k in ("ip", "gw", "ip6", "gw6"))
|
||||
|
||||
|
||||
def _earlier_guest(backup: dict, name: str) -> bool:
|
||||
"""VMIDs are reused: notes (the job's {{guestname}}) naming another guest mean a deleted one."""
|
||||
notes = backup.get("notes") or ""
|
||||
@@ -355,6 +568,7 @@ class GuestApp(App):
|
||||
super().__init__()
|
||||
self.guests: dict[str, dict] = {}
|
||||
self.busy: set[int] = set()
|
||||
self.undo: dict[int, dict] = {} # vmid -> values before the last network change
|
||||
# bumped on every context switch; replies started earlier come from the old cluster
|
||||
self.generation = 0
|
||||
self.switching = False
|
||||
@@ -476,6 +690,7 @@ class GuestApp(App):
|
||||
"#guests", DataTable
|
||||
).clear() # old cluster's guests: not actionable
|
||||
self.guests = {}
|
||||
self.undo.clear() # VMIDs repeat across clusters: never undo onto another cluster
|
||||
self.generation += 1
|
||||
self.switching = True
|
||||
self._switch_context(name)
|
||||
@@ -576,16 +791,57 @@ class GuestApp(App):
|
||||
else:
|
||||
self.call_from_thread(self.notify, f"Backup done: {who}")
|
||||
finally:
|
||||
for screen in list(
|
||||
self.screen_stack
|
||||
): # reload while still busy, like _run_job
|
||||
if (
|
||||
isinstance(screen, InfoScreen)
|
||||
and screen.guest["vmid"] == guest["vmid"]
|
||||
):
|
||||
screen.load()
|
||||
self._reload_infos(guest)
|
||||
self.busy.discard(guest["vmid"])
|
||||
|
||||
def _reload_infos(self, guest: dict) -> None:
|
||||
"""Runs in a worker thread: reload the guest's open InfoScreens (call while still busy)."""
|
||||
for screen in list(self.screen_stack):
|
||||
if isinstance(screen, InfoScreen) and screen.guest["vmid"] == guest["vmid"]:
|
||||
screen.load()
|
||||
|
||||
def start_net_change(
|
||||
self, guest: dict, changes: dict, entry: dict | None, screen=None
|
||||
) -> None:
|
||||
"""`entry` becomes the guest's undo entry on success (None clears it).
|
||||
|
||||
`screen` (the NetScreen) is closed on success and stays open on failure.
|
||||
"""
|
||||
who = f"{guest['name']} ({guest['vmid']})"
|
||||
if guest["vmid"] in self.busy:
|
||||
self.notify(f"{who} is busy", severity="warning")
|
||||
return
|
||||
self.busy.add(guest["vmid"])
|
||||
self.notify(f"Network change started: {who}")
|
||||
self._run_net(guest, changes, entry, screen)
|
||||
|
||||
@work(thread=True)
|
||||
def _run_net(self, guest: dict, changes: dict, entry: dict | None, screen) -> None:
|
||||
who = f"{guest['name']} ({guest['vmid']})"
|
||||
done = False
|
||||
try:
|
||||
cli.set_net(guest, changes)
|
||||
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
|
||||
self.call_from_thread(
|
||||
self.notify, f"Network change failed: {_message(exc)}", severity="error"
|
||||
)
|
||||
else:
|
||||
done = True
|
||||
if entry is None:
|
||||
self.undo.pop(guest["vmid"], None)
|
||||
else:
|
||||
self.undo[guest["vmid"]] = entry
|
||||
self.call_from_thread(self.notify, f"Network change done: {who}")
|
||||
finally:
|
||||
self._reload_infos(guest)
|
||||
if done and screen is not None:
|
||||
self.call_from_thread(self._close_net, screen)
|
||||
self.busy.discard(guest["vmid"])
|
||||
|
||||
def _close_net(self, screen) -> None:
|
||||
if self.screen is screen: # the user may have left it already
|
||||
screen.dismiss()
|
||||
|
||||
def action_shell(self) -> None:
|
||||
guest = self._selected()
|
||||
if guest is None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Thin subprocess wrapper around cv4pve-cli."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
@@ -274,3 +275,107 @@ def interfaces(guest: dict) -> list[dict]:
|
||||
)
|
||||
found.append({"name": i["name"], "mac": i.get("hardware-address"), **addrs})
|
||||
return [i for i in found if i["name"] != "lo"]
|
||||
|
||||
|
||||
def bridges(guest: dict) -> list[str]:
|
||||
items = json.loads(run("get", "node", "network", guest["node"], "-o", "json"))
|
||||
return sorted(i["iface"] for i in items if i.get("type") in ("bridge", "OVSBridge"))
|
||||
|
||||
|
||||
def format_net(d: dict) -> str:
|
||||
return ",".join(
|
||||
f"{k}={v}" if v else k for k, v in d.items()
|
||||
) # bare flags stay bare
|
||||
|
||||
|
||||
def has_cloudinit(config: dict) -> bool:
|
||||
"""A cloud-init drive: a disk (not a name or description) whose volume says so."""
|
||||
return any(
|
||||
_VM_DISK.fullmatch(k) and "cloudinit" in str(v) for k, v in config.items()
|
||||
)
|
||||
|
||||
|
||||
def apply_link(net: dict, bridge: str, tag: int | None, firewall: bool) -> dict:
|
||||
"""Copy of `net` with bridge, VLAN tag and firewall set; other keys stay in place."""
|
||||
if tag is not None and not 1 <= tag <= 4094:
|
||||
raise ValueError("VLAN tag must be 1-4094")
|
||||
out = dict(net)
|
||||
out["bridge"] = bridge
|
||||
if tag is None:
|
||||
out.pop("tag", None)
|
||||
else:
|
||||
out["tag"] = str(tag)
|
||||
if firewall:
|
||||
out["firewall"] = "1"
|
||||
else:
|
||||
out.pop("firewall", None)
|
||||
return out
|
||||
|
||||
|
||||
def ip_mode(cfg: dict, family: int) -> tuple[str, str, str]:
|
||||
"""`(mode, cidr, gw)` of one family from an LXC net or VM ipconfig dict."""
|
||||
ip, gw = cfg.get(_IP_KEYS[family][0], ""), cfg.get(_IP_KEYS[family][1], "")
|
||||
if ip in ("dhcp", "auto"):
|
||||
return ip, "", ""
|
||||
if ip in ("", "manual"):
|
||||
return "none", "", ""
|
||||
return "static", ip, gw
|
||||
|
||||
|
||||
_IP_KEYS = {4: ("ip", "gw"), 6: ("ip6", "gw6")}
|
||||
|
||||
|
||||
def apply_ip(cfg: dict, family: int, mode: str, cidr: str = "", gw: str = "") -> dict:
|
||||
"""Copy of `cfg` with one family's address keys set; ValueError on bad input."""
|
||||
ip_key, gw_key = _IP_KEYS[family]
|
||||
out = dict(cfg)
|
||||
if mode == "static":
|
||||
try:
|
||||
prefix = cidr.partition("/")[2]
|
||||
if not (
|
||||
prefix.isdecimal() and 1 <= int(prefix) <= (32 if family == 4 else 128)
|
||||
):
|
||||
raise ValueError # CIDR only: no netmask form, no /0
|
||||
if ipaddress.ip_interface(cidr).version != family:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"IPv{family} needs an address like 10.0.0.5/24") from None
|
||||
try:
|
||||
if gw and ipaddress.ip_address(gw).version != family:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"IPv{family} gateway must be an IPv{family} address"
|
||||
) from None
|
||||
out[ip_key] = cidr
|
||||
if gw:
|
||||
out[gw_key] = gw
|
||||
else:
|
||||
out.pop(gw_key, None)
|
||||
elif mode in ("dhcp", "auto"):
|
||||
if mode == "auto" and family != 6:
|
||||
raise ValueError("SLAAC (auto) exists only for IPv6")
|
||||
out[ip_key] = mode
|
||||
out.pop(gw_key, None)
|
||||
elif mode == "none":
|
||||
out.pop(ip_key, None)
|
||||
out.pop(gw_key, None)
|
||||
else:
|
||||
raise ValueError(f"unknown mode: {mode}")
|
||||
return out
|
||||
|
||||
|
||||
def set_net(guest: dict, changes: dict[str, str | None]) -> None:
|
||||
"""Set the given keys; `None` values are deleted. One call."""
|
||||
if not changes:
|
||||
raise ValueError("nothing to set")
|
||||
opts = []
|
||||
for key, value in changes.items():
|
||||
if value is not None:
|
||||
opts += [f"--{key}", value]
|
||||
gone = [k for k, v in changes.items() if v is None]
|
||||
if gone:
|
||||
opts += ["--delete", ",".join(gone)]
|
||||
run(
|
||||
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
|
||||
)
|
||||
Reference in new issue
Block a user