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
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""Скрипт синхронизации 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
|