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
303 lines
12 KiB
Python
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
|