Files
pvemanager/backend/tests/test_guest_ip_aliases_script.py
Dilshod Rasulov 6796903712
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m46s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 3m7s
feat(ipam): several IP addresses per instance
A guest was implicitly limited to a single allocation: lookups were
"(server, vmid) -> one row", release_ip_by_vmid freed one address, and
the link tool skipped guests that already had one. An instance can now
hold a primary address plus any number of aliases, managed from the
Network tab of the instance and visible on the IPAM pages.

Schema and service:
- ipam_allocations gains is_primary, assignment_kind, target_interface
  and apply_* columns; migration 43 marks existing rows as primary
- find_allocations_by_resource/set_primary; release_ip_by_vmid now frees
  every address of a deleted guest instead of just the first one
- next-available search may scan the whole subnet when a network has no
  auto-assign pool, skipping node and cached guest addresses so it never
  hands out an address that is already live

Applying to the guest (services/guest_ip_service.py):
- reuses script_engine, so LXC goes through pct exec and VMs through the
  guest agent; the shell script synchronises the whole alias list of an
  interface, which makes add, remove and retry the same code path
- persists via ifupdown, netplan, NetworkManager or sysconfig and never
  restarts guest networking; an unknown stack degrades to runtime_only
  and an unreachable guest keeps the allocation as pending

API and UI:
- /ipam/api/guests/{server_id}/{vmid}/addresses: list, add, apply,
  primary, delete; allocations list accepts guest filters
- instance list exposes ips[] alongside the primary ip
- addresses card on the Network tab, "+N" badge in the IP column,
  primary badge in the allocations table

Fixes found along the way:
- link-allocations registers every address of a guest, carries the MAC
  over, refuses to take an address away from another live guest and no
  longer matches allocations by resource name (which leaked an address
  between same-named guests on different servers)
- IPAM history no longer fails to serialise datetime values, which used
  to abort re-linking with a 500
2026-08-21 23:48:20 +05:00

299 lines
11 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
"""
@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)):
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"):
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