Files
pvemanager/backend/app/models/ipam.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

162 lines
7.1 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.
from .base import *
class IPAMNetwork(Base):
"""Networks/subnets for IPAM"""
__tablename__ = "ipam_networks"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
network = Column(String(18), nullable=False, index=True)
gateway = Column(String(45), nullable=True)
vlan_id = Column(Integer, nullable=True)
dns_primary = Column(String(45), nullable=True)
dns_secondary = Column(String(45), nullable=True)
dns_domain = Column(String(255), nullable=True)
proxmox_server_id = Column(Integer, nullable=True, index=True)
proxmox_node = Column(String(100), nullable=True)
proxmox_bridge = Column(String(20), nullable=True)
# Привязка сети к рабочей области (workspace). Одна общая подсеть на область:
# сеть/пул выдаются любому узлу области, без привязки к конкретной ноде.
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True, index=True)
# Сеть по умолчанию внутри области — автоматически выбирается в мастерах.
is_default = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_network_name', 'name'),
Index('idx_ipam_network_active', 'is_active'),
)
def __repr__(self):
return f"<IPAMNetwork(id={self.id}, name='{self.name}', network='{self.network}')>"
class IPAMPool(Base):
"""IP pools inside a network"""
__tablename__ = "ipam_pools"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
pool_type = Column(String(20), default="static", nullable=False)
range_start = Column(String(45), nullable=False)
range_end = Column(String(45), nullable=False)
auto_assign = Column(Boolean, default=True, nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_pool_network', 'network_id'),
Index('idx_ipam_pool_type', 'pool_type'),
)
def __repr__(self):
return f"<IPAMPool(id={self.id}, name='{self.name}', range='{self.range_start}-{self.range_end}')>"
class IPAMAllocation(Base):
"""Allocated IP addresses"""
__tablename__ = "ipam_allocations"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, nullable=False, index=True)
pool_id = Column(Integer, nullable=True, index=True)
ip_address = Column(String(45), nullable=False, unique=True, index=True)
mac_address = Column(String(17), nullable=True)
resource_type = Column(String(20), nullable=True)
resource_id = Column(Integer, nullable=True)
resource_name = Column(String(100), nullable=True)
proxmox_server_id = Column(Integer, nullable=True, index=True)
proxmox_vmid = Column(Integer, nullable=True)
proxmox_node = Column(String(100), nullable=True)
status = Column(String(20), default="allocated", nullable=False)
allocation_type = Column(String(20), default="static", nullable=False)
# Гость может держать несколько адресов: ровно один из них основной
# (живёт в net0/ipconfig0 и показывается в колонке IP), остальные
# навешиваются алиасами на интерфейс уже внутри гостя.
is_primary = Column(Boolean, default=False, nullable=False)
assignment_kind = Column(String(10), default="primary", nullable=False) # primary | alias
target_interface = Column(String(20), nullable=True) # eth0, ens18, ...
# Состояние применения алиаса к живому гостю.
# applied — поднят и закреплён в конфиге ОС; runtime_only — поднят, но
# стек не распознан и ребут его не переживёт; pending — гость был
# недоступен; failed — скрипт вернул ошибку.
apply_status = Column(String(20), nullable=True)
apply_error = Column(Text, nullable=True)
applied_at = Column(DateTime(timezone=True), nullable=True)
hostname = Column(String(255), nullable=True, index=True)
fqdn = Column(String(255), nullable=True)
dns_ptr_record = Column(Boolean, default=False, nullable=False)
allocated_by = Column(String(100), nullable=True)
allocated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True)
last_seen = Column(DateTime(timezone=True), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_alloc_network', 'network_id'),
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
Index('idx_ipam_alloc_primary', 'proxmox_server_id', 'proxmox_vmid', 'is_primary'),
Index('idx_ipam_alloc_status', 'status'),
)
def __repr__(self):
return f"<IPAMAllocation(id={self.id}, ip='{self.ip_address}', resource='{self.resource_name}')>"
class IPAMHistory(Base):
"""History of IPAM changes"""
__tablename__ = "ipam_history"
id = Column(Integer, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False, index=True)
network_id = Column(Integer, nullable=True, index=True)
action = Column(String(50), nullable=False)
old_value = Column(JSON, nullable=True)
new_value = Column(JSON, nullable=True)
resource_type = Column(String(20), nullable=True)
resource_id = Column(Integer, nullable=True)
resource_name = Column(String(100), nullable=True)
performed_by = Column(String(100), nullable=True)
performed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
notes = Column(Text, nullable=True)
__table_args__ = (
Index('idx_ipam_history_ip', 'ip_address'),
Index('idx_ipam_history_action', 'action'),
Index('idx_ipam_history_date', 'performed_at'),
)
def __repr__(self):
return f"<IPAMHistory(id={self.id}, ip='{self.ip_address}', action='{self.action}')>"