Files
pvemanager/backend/tests/test_ipam_multi_ip.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

303 lines
12 KiB
Python

"""Несколько IP-адресов на одном инстансе: сервис, API и колонка IP."""
import pytest
from app.ipam_service import IPAMService
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
from app.services import guest_ip_service, script_engine
OK_OUTPUT = "PVEMANAGER_PERSIST=ifupdown\nPVEMANAGER_OK=1\n"
@pytest.fixture
def env(db_session, seed_users):
server = ProxmoxServer(name="Homelab: Dev", ip_address="10.10.10.2", hostname="dev",
api_user="root@pam", use_password=True, password="pw",
is_online=True)
db_session.add(server)
db_session.commit()
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
is_active=True)
db_session.add(network)
db_session.commit()
instance = VMInstance(server_id=server.id, vmid=109, node="dev", vm_type="lxc",
name="gitea", status="running")
db_session.add(instance)
db_session.commit()
service = IPAMService(db_session)
def allocate(ip, primary=False, kind="alias", interface="eth0"):
allocation, error = service.allocate_ip(
ip_address=ip, network_id=network.id, resource_type="lxc", resource_name="gitea",
proxmox_server_id=server.id, proxmox_vmid=109, proxmox_node="dev",
allocated_by="tester", is_primary=primary, assignment_kind=kind,
target_interface=interface,
)
assert error is None, error
return allocation
return {"db": db_session, "server": server, "network": network, "instance": instance,
"service": service, "allocate": allocate}
@pytest.fixture(autouse=True)
def no_real_guest(monkeypatch):
"""Скрипт в гостя не уезжает — транспорт проверяется отдельным тестом."""
monkeypatch.setattr(
guest_ip_service.script_engine, "execute",
lambda server, **kwargs: script_engine.ExecResult(True, OK_OUTPUT, 0),
)
# --- сервис -------------------------------------------------------------------
def test_guest_can_hold_several_addresses(env):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
rows = env["service"].find_allocations_by_resource(env["server"].id, 109)
assert [r.ip_address for r in rows] == ["10.10.10.17", "10.10.10.90", "10.10.10.91"]
assert rows[0].is_primary is True
def test_primary_lookup_returns_the_flagged_address(env):
env["allocate"]("10.10.10.90")
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
assert found.id == primary.id
def test_lookup_falls_back_to_earliest_without_flag(env):
"""Записи старше миграции multi-IP флага не имеют — берём самую раннюю."""
first = env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
assert found.id == first.id
def test_set_primary_leaves_exactly_one_primary(env):
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
new = env["allocate"]("10.10.10.90")
updated, error = env["service"].set_primary(env["server"].id, 109, new.id, changed_by="tester")
assert error is None and updated.id == new.id
env["db"].refresh(old)
assert [a.is_primary for a in env["service"].find_allocations_by_resource(env["server"].id, 109)] \
== [True, False]
assert old.is_primary is False
def test_set_primary_rejects_foreign_allocation(env):
other = env["service"].allocate_ip(
ip_address="10.10.10.50", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
updated, error = env["service"].set_primary(env["server"].id, 109, other.id)
assert updated is None and "does not belong" in error
def test_deleting_a_guest_releases_every_address(env):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
released, ips = env["service"].release_ip_by_vmid(env["server"].id, 109, released_by="tester")
assert released is True
assert "10.10.10.17" in ips and "10.10.10.91" in ips
assert env["db"].query(IPAMAllocation).count() == 0
# --- API ----------------------------------------------------------------------
def test_list_addresses_returns_primary_first(env, client, admin_headers):
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.get(
f"/ipam/api/guests/{env['server'].id}/109/addresses", headers=admin_headers)
assert response.status_code == 200
body = response.json()
assert body["count"] == 2
assert body["addresses"][0]["ip_address"] == "10.10.10.17"
assert body["addresses"][0]["is_primary"] is True
assert body["addresses"][0]["prefix"] == "24"
def test_add_address_allocates_and_applies(env, client, admin_headers):
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["applied"] is True
assert body["apply_status"] == guest_ip_service.APPLIED
assert body["address"]["assignment_kind"] == "alias"
stored = env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.90").one()
assert stored.proxmox_vmid == 109
assert stored.target_interface == "eth0"
def test_add_address_picks_next_free_ip(env, client, admin_headers):
env["allocate"]("10.10.10.2", primary=True, kind="primary")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "target_interface": "eth0"},
)
assert response.status_code == 200, response.text
assigned = response.json()["address"]["ip_address"]
assert assigned.startswith("10.10.10.")
assert assigned not in ("10.10.10.1", "10.10.10.2") # шлюз и занятый адрес
def test_add_address_can_take_over_as_primary(env, client, admin_headers):
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0", "make_primary": True},
)
assert response.status_code == 200, response.text
env["db"].refresh(old)
env["db"].refresh(env["instance"])
assert old.is_primary is False
# Кэш инстанса показывает новый основной адрес
assert env["instance"].ip_address == "10.10.10.90"
def test_duplicate_address_is_rejected(env, client, admin_headers):
env["allocate"]("10.10.10.90")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0"},
)
assert response.status_code == 400
assert "already allocated" in response.json()["detail"]
def test_release_address_frees_it(env, client, admin_headers):
alias = env["allocate"]("10.10.10.90")
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{alias.id}", headers=admin_headers)
assert response.status_code == 200, response.text
assert response.json()["released"] == "10.10.10.90"
assert env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.90").first() is None
def test_primary_address_cannot_be_released_here(env, client, admin_headers):
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{primary.id}", headers=admin_headers)
assert response.status_code == 400
assert "NIC config" in response.json()["detail"]
assert env["db"].query(IPAMAllocation).count() == 1
def test_address_of_another_guest_is_not_accessible(env, client, admin_headers):
foreign = env["service"].allocate_ip(
ip_address="10.10.10.50", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{foreign.id}", headers=admin_headers)
assert response.status_code == 404
def test_instance_list_exposes_all_addresses(env, client, admin_headers):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
response = client.get("/proxmox/api/virtual-machines", headers=admin_headers)
assert response.status_code == 200
vm = next(v for v in response.json() if v["vmid"] == 109)
assert vm["ip"] == "10.10.10.17" # колонка IP показывает основной
assert [a["ip"] for a in vm["ips"]] == ["10.10.10.17", "10.10.10.90"]
assert [a["is_primary"] for a in vm["ips"]] == [True, False]
def test_network_scan_skips_node_and_cached_guest_ips(env):
"""Скан вне пула не должен предлагать адрес ноды или известного гостя."""
# Нода Homelab: Dev живёт на 10.10.10.2, гость gitea — на 10.10.10.3
env["instance"].ip_address = "10.10.10.3"
env["db"].commit()
ip = env["service"].get_next_available_ip(
env["network"].id, None, allow_network_scan=True)
assert ip not in ("10.10.10.1", "10.10.10.2", "10.10.10.3")
def test_first_address_of_a_guest_becomes_primary_automatically(env):
"""Мастер создания инстанса не передаёт is_primary — флаг ставится сам."""
first, error = env["service"].allocate_ip(
ip_address="10.10.10.60", network_id=env["network"].id, resource_type="lxc",
resource_name="gitea", proxmox_server_id=env["server"].id, proxmox_vmid=109,
proxmox_node="dev", allocated_by="wizard",
)
assert error is None and first.is_primary is True
second, error = env["service"].allocate_ip(
ip_address="10.10.10.61", network_id=env["network"].id, resource_type="lxc",
resource_name="gitea", proxmox_server_id=env["server"].id, proxmox_vmid=109,
proxmox_node="dev", allocated_by="wizard",
)
assert error is None and second.is_primary is False
def test_alias_never_claims_primary_by_itself(env):
alias, error = env["service"].allocate_ip(
ip_address="10.10.10.62", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=109, allocated_by="tester",
assignment_kind="alias", target_interface="eth0",
)
assert error is None and alias.is_primary is False
def test_explicit_flag_still_wins(env):
env["allocate"]("10.10.10.63", primary=True, kind="primary")
forced, error = env["service"].allocate_ip(
ip_address="10.10.10.64", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=109, allocated_by="tester",
is_primary=False,
)
assert error is None and forced.is_primary is False
def test_reservation_without_a_guest_is_not_primary(env):
"""Ручная бронь без привязки к гостю основной быть не может."""
manual, error = env["service"].allocate_ip(
ip_address="10.10.10.65", network_id=env["network"].id,
resource_name="reserved by hand", allocated_by="admin",
)
assert error is None and manual.is_primary is False