Files
pvemanager/backend/tests/test_guest_ip_aliases_script.py
Dilshod Rasulov 972d54d1f1
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m44s
Deploy to Production / Trigger production update (push) Failing after 11s
SonarCloud / SonarCloud Analysis (push) Failing after 3m0s
fix(ipam): keep extra addresses across a guest reboot
Testing on a live AlmaLinux container showed the alias disappearing after
a restart even though it was reported as applied: Proxmox regenerates the
guest network configuration on every start (pve-container rewrites
/etc/network/interfaces, ifcfg or the NM keyfile from netN), wiping any
address added to the native stack.

    before reboot: address1=10.10.10.103/24 address2=10.10.10.106/24
    after reboot:  addresses=10.10.10.103/24     (file recreated by PVE)

Install a oneshot systemd unit that re-adds the recorded addresses after
network-online.target — /etc/systemd is not touched by Proxmox. The native
branches stay, so persist is now reported as e.g. "nm+systemd"; the unit
and its helper are removed once the last address is released.

Also fixed while testing on the two test instances:
- the alias script is ASCII-only now: it travels through the Proxmox API
  and its marker ends up in guest system configs
- a composite persist marker no longer falls through the parser, which
  used to report a successful apply as runtime_only
- allocate_ip marks the first address of a guest as primary on its own,
  so allocations coming from the creation wizard are flagged correctly
2026-08-22 01:36:10 +05:00

377 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Скрипт синхронизации alias-адресов: детект стека и содержимое конфигов.
Скрипт запускается настоящим /bin/sh в песочнице: PVEM_ROOT подменяет корень
файловой системы, а ip/nmcli/ifup подставляются заглушками через PATH. Так
проверяется именно shell-логика — то, что реально выполняется внутри гостя.
"""
import os
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "app" / "services" / "guest_ip" / "sync_aliases.sh"
FAKE_IP = """#!/bin/sh
echo "$*" >> "$FAKE_LOG/ip.log"
# 'addr show' печатает пустоту: считаем, что на интерфейсе адресов ещё нет
exit 0
"""
FAKE_NMCLI = """#!/bin/sh
echo "$*" >> "$FAKE_LOG/nmcli.log"
if [ "$1" = "-g" ]; then
printf '%s' "$NM_CONNECTION"
fi
exit 0
"""
FAKE_IFUP = """#!/bin/sh
exit 0
"""
FAKE_SYSTEMCTL = """#!/bin/sh
echo "$*" >> "$FAKE_LOG/systemctl.log"
exit 0
"""
@pytest.fixture
def sandbox(tmp_path):
"""Песочница с фейковыми ip/nmcli/ifup и пустым «корнем» гостя."""
root = tmp_path / "root"
bindir = tmp_path / "bin"
logdir = tmp_path / "log"
for path in (root, bindir, logdir):
path.mkdir(parents=True)
for name, body in (("ip", FAKE_IP), ("nmcli", FAKE_NMCLI), ("ifup", FAKE_IFUP),
("systemctl", FAKE_SYSTEMCTL)):
target = bindir / name
target.write_text(body)
target.chmod(0o755)
def run(addresses="", iface="eth0", nm_connection="", tools=("ip",)):
"""Запустить скрипт. tools — какие команды «установлены» в госте."""
env = {
"PATH": f"{bindir}:{os.environ['PATH']}",
"PVEM_ROOT": str(root),
"FAKE_LOG": str(logdir),
"NM_CONNECTION": nm_connection,
"IFACE": iface,
"ADDRESSES": addresses,
}
# Прячем инструменты, которых в этом сценарии быть не должно
for name in ("ip", "nmcli", "ifup", "systemctl"):
path = bindir / name
hidden = bindir / f"{name}.hidden"
if name in tools and hidden.exists():
hidden.rename(path)
elif name not in tools and path.exists():
path.rename(hidden)
result = subprocess.run(
["/bin/sh", str(SCRIPT)], env=env, capture_output=True, text=True, timeout=30
)
return result
def persist_of(result) -> str:
for line in result.stdout.splitlines():
if line.startswith("PVEMANAGER_PERSIST="):
return line.split("=", 1)[1]
raise AssertionError(f"нет маркера persist: {result.stdout} {result.stderr}")
def log(name: str) -> str:
path = logdir / f"{name}.log"
return path.read_text() if path.exists() else ""
return {"root": root, "run": run, "persist_of": persist_of, "log": log}
def _mkdirs(root: Path, *relative: str):
for item in relative:
(root / item).mkdir(parents=True, exist_ok=True)
# --- sysconfig / network-scripts ----------------------------------------------
def test_sysconfig_branch_writes_ifcfg_alias(sandbox):
"""RHEL-based LXC: alias закрепляется отдельным ifcfg-файлом."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24")
assert sandbox["persist_of"](result) == "sysconfig"
written = (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").read_text()
assert "DEVICE=eth0:1" in written
assert "IPADDR=10.10.10.90" in written
assert "PREFIX=24" in written
assert "BOOTPROTO=none" in written
assert "ONBOOT=yes" in written
assert "managed by PVEmanager" in written
def test_sysconfig_numbers_aliases_in_order(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
scripts = root / "etc/sysconfig/network-scripts"
assert "IPADDR=10.10.10.90" in (scripts / "ifcfg-eth0:1").read_text()
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:2").read_text()
def test_sysconfig_drops_files_of_removed_addresses(sandbox):
"""Список сократился — лишний ifcfg удаляется, оставшийся переписывается."""
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
sandbox["run"](addresses="10.10.10.91/24")
assert not (scripts / "ifcfg-eth0:2").exists()
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:1").read_text()
def test_sysconfig_removes_everything_on_empty_list(sandbox):
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
result = sandbox["run"](addresses="")
assert sandbox["persist_of"](result) == "sysconfig"
assert list(scripts.glob("ifcfg-eth0:*")) == []
def test_sysconfig_keeps_foreign_ifcfg_files(sandbox):
"""Файлы, написанные не нами, остаются нетронутыми."""
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
foreign = scripts / "ifcfg-eth0:9"
foreign.write_text("DEVICE=eth0:9\nIPADDR=10.10.10.250\n")
sandbox["run"](addresses="10.10.10.90/24")
assert foreign.exists()
assert "10.10.10.250" in foreign.read_text()
def test_sysconfig_removes_alias_del_from_interface(sandbox):
"""Ушедший адрес снимается с интерфейса, а не только из конфига."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
sandbox["run"](addresses="")
assert "addr del 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
# --- порядок веток -------------------------------------------------------------
def test_networkmanager_without_connection_falls_through_to_sysconfig(sandbox):
"""nmcli установлен, но интерфейсом не управляет — частый случай в LXC."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="",
tools=("ip", "nmcli"))
assert sandbox["persist_of"](result) == "sysconfig"
assert (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").exists()
def test_networkmanager_with_connection_wins_over_sysconfig(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
assert sandbox["persist_of"](result) == "nm"
assert "+ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
assert list((root / "etc/sysconfig/network-scripts").glob("ifcfg-*")) == []
def test_networkmanager_drops_previous_address(sandbox):
_mkdirs(sandbox["root"], "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
sandbox["run"](addresses="10.10.10.91/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
assert "-ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
def test_ifupdown_wins_when_several_stacks_present(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/network/interfaces.d", "etc/netplan", "etc/sysconfig/network-scripts")
(root / "etc/network/interfaces").write_text("auto eth0\niface eth0 inet dhcp\n")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli", "ifup"))
assert sandbox["persist_of"](result) == "ifupdown"
written = (root / "etc/network/interfaces.d/pvemanager-aliases-eth0").read_text()
assert "iface eth0:1 inet static" in written
assert "address 10.10.10.90/24" in written
# source-строка дописывается, раз шаблон её не содержит
assert "source /etc/network/interfaces.d/*" in (root / "etc/network/interfaces").read_text()
def test_ifupdown_needs_ifup_present(sandbox):
"""Каталог interfaces.d без ifupdown — не наш стек, уходим в sysconfig."""
root = sandbox["root"]
_mkdirs(root, "etc/network/interfaces.d", "etc/sysconfig/network-scripts")
(root / "etc/network/interfaces").write_text("auto eth0\n")
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip",))
assert sandbox["persist_of"](result) == "sysconfig"
def test_netplan_branch_writes_yaml(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/netplan", "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
assert sandbox["persist_of"](result) == "netplan"
written = (root / "etc/netplan/99-pvemanager-aliases.yaml").read_text()
assert " eth0:" in written
assert " - 10.10.10.90/24" in written
assert " - 10.10.10.91/24" in written
def test_no_known_stack_reports_none_but_still_adds_address(sandbox):
"""Адрес поднимается всегда; не закрепился — об этом говорит persist=none."""
result = sandbox["run"](addresses="10.10.10.90/24")
assert sandbox["persist_of"](result) == "none"
assert "addr add 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
assert "PVEMANAGER_OK=1" in result.stdout
# --- общие инварианты ----------------------------------------------------------
def test_state_file_tracks_managed_addresses(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
assert (root / "var/lib/pvemanager/aliases.eth0").read_text().strip() == \
"10.10.10.90/24 10.10.10.91/24"
def test_second_run_is_idempotent(sandbox):
"""Повтор с тем же списком не должен ни удалять, ни добавлять заново."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
before = sandbox["log"]("ip")
sandbox["run"](addresses="10.10.10.90/24")
added_again = sandbox["log"]("ip")[len(before):]
assert "addr del" not in added_again
def test_other_interface_aliases_are_untouched(sandbox):
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24", iface="eth0")
sandbox["run"](addresses="10.10.10.91/24", iface="eth1")
assert (scripts / "ifcfg-eth0:1").exists()
assert (scripts / "ifcfg-eth1:1").exists()
def test_missing_iface_is_rejected(sandbox):
result = sandbox["run"](addresses="10.10.10.90/24", iface="")
assert result.returncode == 2
assert "missing IFACE" in result.stdout
def test_script_is_ascii_only():
"""Скрипт уезжает в гостя через API Proxmox, который спотыкается на
не-ASCII, и его маркер попадает в системные конфиги — держим файл в ASCII."""
raw = SCRIPT.read_bytes()
non_ascii = [b for b in raw if b > 127]
assert not non_ascii, f"в скрипте {len(non_ascii)} не-ASCII байт"
# --- systemd-подстраховка ------------------------------------------------------
def test_systemd_unit_is_installed_alongside_native_stack(sandbox):
"""Proxmox переписывает сетевой конфиг гостя при каждом старте, поэтому
адреса восстанавливает ещё и oneshot-юнит — /etc/systemd он не трогает."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts", "etc/systemd/system")
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip", "systemctl"))
assert sandbox["persist_of"](result) == "sysconfig+systemd"
unit = (root / "etc/systemd/system/pvemanager-aliases.service").read_text()
assert "After=network-online.target" in unit
assert "ExecStart=/usr/local/sbin/pvemanager-aliases" in unit
helper = root / "usr/local/sbin/pvemanager-aliases"
assert "ip addr add" in helper.read_text()
assert "enable pvemanager-aliases.service" in sandbox["log"]("systemctl")
def test_systemd_unit_alone_when_no_native_stack(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/systemd/system")
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip", "systemctl"))
assert sandbox["persist_of"](result) == "systemd"
assert (root / "etc/systemd/system/pvemanager-aliases.service").exists()
def test_systemd_unit_removed_when_last_address_goes(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/systemd/system")
sandbox["run"](addresses="10.10.10.90/24", tools=("ip", "systemctl"))
result = sandbox["run"](addresses="", tools=("ip", "systemctl"))
assert sandbox["persist_of"](result) == "none"
assert not (root / "etc/systemd/system/pvemanager-aliases.service").exists()
assert not (root / "usr/local/sbin/pvemanager-aliases").exists()
assert "disable pvemanager-aliases.service" in sandbox["log"]("systemctl")
def test_systemd_unit_stays_while_another_interface_has_addresses(sandbox):
"""Опустошили eth0, но на eth1 адрес остался — юнит нужен дальше."""
root = sandbox["root"]
_mkdirs(root, "etc/systemd/system")
sandbox["run"](addresses="10.10.10.90/24", iface="eth0", tools=("ip", "systemctl"))
sandbox["run"](addresses="10.10.10.91/24", iface="eth1", tools=("ip", "systemctl"))
sandbox["run"](addresses="", iface="eth0", tools=("ip", "systemctl"))
assert (root / "etc/systemd/system/pvemanager-aliases.service").exists()
def test_without_systemd_persist_has_no_suffix(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts", "etc/systemd/system")
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip",))
assert sandbox["persist_of"](result) == "sysconfig"
assert not (root / "etc/systemd/system/pvemanager-aliases.service").exists()