Author SHA1 Message Date
Christian Manivong 20fcf2ebc3 fix: four real defects the fourteen failing tests were pointing at
Closes netork#115.

The suite had been red long enough that it stopped being read. Four of the
fourteen failures were the tests being right.

`interfaces_mixin.py` used `re.match` without importing `re`, so
`get_mac_address_table` raised NameError against any node with a Linux bridge.
The tests never reached that line: they mocked the API call underneath
`_exec_ssh_command`, which takes two positional arguments where the doubles
accepted one, and which base64-wraps the command — so a fixture keyed on
"bridge fdb" appearing in the text matched nothing and the helper returned "".
They mock `_exec_ssh_command` itself now, which is the driver's own seam.

`is_alive` called `_resolve_node()`, which returns early without touching the
API whenever a node was configured through optional_args. A dead connection
reported itself alive. It probes `GET /version` now.

The documented `realm` optional_arg was read into `self._realm` in `__init__`
and then never used. Proxmox authenticates against "<user>@<realm>" and rejects
a bare username, so the option had no effect and callers had to know to type the
realm themselves.

`get_vlans` filtered out entries with no member ports on one return path while
the OVS path returned them, so a configured SDN VNet was visible or invisible
depending on which branch ran. A VNet exists on the node whether or not anything
is attached to it, and netOrk's VLAN discovery reads this.

`get_ipv6_neighbors_table` was simply missing and fell through to NAPALM's stub;
it is implemented against `ip -6 neigh show`, dropping FAILED entries.

The rest were stale tests. The DNS fixture put an FQDN where a search domain
belongs, which made `get_facts` build "pve1.pve1.example.com" and look like a
driver bug. The LLDP fixture was a simplified shape that real `lldpcli show
neighbors summary` does not produce — the parser matches on the ", via: LLDP"
that follows the interface name. And `test_bridge_vlan_show_parsing` covered a
fallback that was replaced by VM-config scanning, asserting an "interfaces" key
this method has never returned; it is now a test of the fallback that exists.
2026-08-21 13:22:03 +07:00
Christian Manivong 51f67704e1 feat: declare USES_SSH = False and REBOOT_SETTLE_SECONDS = 90
Both were facts about this driver that netOrk kept in hardcoded driver-name
sets, each duplicated across a file pair (netork#113). The driver is the right
place for them: everything runs over the PVE REST API, and a node reboots
through a full init sequence plus storage checks before it is worth polling.
2026-08-21 13:07:14 +07:00
Christian Manivong 39f8d80352 refactor(warnings): report raw signal only, no severity/presentation
get_device_warnings() now returns only {code, meta} — severity, title,
message, and action are resolved centrally by netork's
WARNING_CATALOG (netork/core/device_warnings.py), not by the driver.
Keeps this driver independent of netork and avoids per-vendor drift in
how the same warning code is presented.
2026-07-20 09:49:38 +02:00
Christian Manivong 38f0c0a656 fix(vm_provision_mixin): stale same-named cloud image cache causes checksum mismatch
_download_cloud_image() cached downloaded images under just the URL's
basename (e.g. ubuntu-26.04-server-cloudimg-amd64.img). Ubuntu's per-build
download URLs change daily under that same stable basename
(.../release-20260713/... vs .../release-20260714/...), so a previous
day's cached file satisfied the "already cached" check and got checksum-
verified against the *new* day's expected hash from NetOrk's daily catalog
sync — failing outright and aborting the whole provisioning job, even
though a plain retry would have re-downloaded and succeeded (the bad file
was already being deleted on mismatch, just never re-fetched).

Found live during a NetOrk deploy: "Checksum mismatch for
https://cloud-images.ubuntu.com/.../release-20260713/
ubuntu-26.04-server-cloudimg-amd64.img: expected 0826c500..., got
3ee4f67f...".

Fix: key the cache path on a hash of the full URL (not just the
basename), and retry the download once after a checksum-mismatch cleanup
before raising.
2026-07-14 16:23:13 +02:00
Christian ManivongandClaude Sonnet 5 3181ade728 fix(vm_provision_mixin): destroy_vm's delete call rejected by Proxmox (400)
Passed destroy_unreferenced_disks (underscore) as a kwarg to proxmoxer's
delete(), but Proxmox's actual DELETE /nodes/{node}/qemu/{vmid} parameter
is hyphenated (destroy-unreferenced-disks). proxmoxer forwards kwargs to
the request verbatim with no underscore-to-hyphen translation, so Proxmox
rejected every call with "property is not defined in schema" before ever
touching the VM — the VM stayed fully intact (config, disks) despite the
caller believing destroy had at least been attempted. Fixed by building
the params as a dict (bypassing the Python-identifier restriction) with
the correct hyphenated key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 19:54:05 +02:00
Christian Manivong ee2f0e94ff Merge fix/guest-agent-ip-wait: correct guest-agent REST path + skip lo 2026-07-08 14:09:43 +02:00
Christian Manivong 7038494b49 fix(vm_provision_mixin): get_vm_status hit a non-existent guest-agent path
agent.network_get_interfaces.get() built the URL path segment literally
("network_get_interfaces"), but the real Proxmox REST endpoint uses
hyphens ("network-get-interfaces") and must be reached via agent(...) as
a callable resource — the underscored attribute path 404ed silently on
every poll, so wait_for_ip always ran out the full timeout even though
the guest agent was reporting the IP to Proxmox correctly the whole time.

Also stopped assuming interfaces[0] is the real NIC — the guest agent
commonly reports "lo" first, matching the working pattern already used
in vm_mixin.py (skip "lo", require ip-address-type == "ipv4").
2026-07-08 14:09:40 +02:00
Christian Manivong 0da4ca3c69 Merge feature/guest-agent-channel: agent=1 for guest-agent virtio-serial channel 2026-07-08 12:56:12 +02:00
Christian Manivong 40d36b4b99 feat(vm_provision_mixin): set agent=1 on VM create for guest-agent channel
Proxmox only opens the virtio-serial channel qemu-guest-agent needs when
agent=1 is set at VM creation — without it, the agent package can be
installed but never actually reachable.
2026-07-08 12:56:09 +02:00
Christian Manivong 79f40b074c Merge fix/cloudinit-drive-image-storage: ide2 needs images storage 2026-07-08 11:07:09 +02:00
Christian Manivong bcadd77420 fix(vm_provision_mixin): cloud-init drive (ide2) needs images storage, not snippets
Proxmox's cloud-init drive is a disk image and requires a storage with
content='images' — the same requirement as the root disk — not the
snippets storage. These are commonly different storages (e.g. 'local'
with content=snippets-only, 'local-zfs' with content=images), and real
Proxmox now creates the VM fine but fails at *start* time with "storage
'X' does not support content-type 'images'" once it tries to generate
the cloud-init ISO.

Found live: a real deployment created the VM successfully, and only
failed when the user started it manually on the Proxmox side.
2026-07-08 11:07:06 +02:00
Christian Manivong 18fd3c8958 Merge feature/nic-mac-address: pin explicit NIC MAC when given 2026-07-08 09:09:31 +02:00
Christian Manivong 1d6aabb5a1 feat(vm_provision_mixin): pin explicit NIC MAC when given
nics[i]['mac'] is set via virtio=<mac>,bridge=... instead of the bare
virtio,bridge=... form, so a caller-supplied MAC actually takes effect
(needed for DHCP reservations created before the VM exists).
2026-07-08 09:09:28 +02:00
Christian Manivong c8d45e4336 Merge fix/snippet-write-via-ssh: write Cloud-Init snippet via SSH 2026-07-07 23:24:10 +02:00
Christian Manivong 685d9b67ae fix(vm_provision_mixin): write Cloud-Init snippet via SSH, not the upload API
Real Proxmox's POST /nodes/{node}/storage/{storage}/upload only accepts
content in {iso, vztmpl, import} — content='snippets' is rejected
outright with a 400 ("does not have a value in the enumeration").
Snippets can only be written directly to the storage's filesystem path.

Found live, right after the previous multipart-upload fix: the VM
shell, disk import, and node-scoped storage selection all succeeded,
then create_vm_from_cloud_init failed with a 400 at the snippet write
step. Resolves the storage's path via the cluster storage config and
writes the file over SSH (base64-piped, to survive arbitrary YAML
content safely).
2026-07-07 23:24:05 +02:00
Christian Manivong 86af2cb7a7 Merge fix/snippet-upload-multipart: fix Cloud-Init snippet upload 2026-07-07 23:01:30 +02:00
Christian Manivong 80d9c7335f fix(vm_provision_mixin): upload Cloud-Init snippet as a real multipart file
proxmoxer only builds a multipart request for io.IOBase values passed
as kwargs; a plain filename string (plus a nonexistent "data" field,
as the old code sent) goes out as an ordinary form-urlencoded POST
instead. Real Proxmox's /storage/{s}/upload endpoint expects an actual
file upload for "filename" and responds to anything else by closing
the connection with no HTTP response at all.

Found live: the VM shell, disk import, and node-scoped storage
selection all succeeded, then create_vm_from_cloud_init failed with
requests.exceptions.ConnectionError / RemoteDisconnected right at the
snippet upload step.
2026-07-07 23:01:27 +02:00
Christian Manivong fe4f5e84d8 Merge feature/node-scoped-image-storage: fix node-scoped storage query + selectable storage 2026-07-07 22:36:16 +02:00
Christian Manivong 4d568bc6dc fix(vm_provision_mixin): query node-scoped storage, not cluster-wide
The cluster-wide /storage endpoint lists every storage regardless of
its "nodes" restriction, so _find_default_image_storage (and the
snippet-storage lookup) could pick a storage not actually available on
the node the VM is being created on. On a real server this stranded a
freshly-created VM shell with no disk attached: "qm importdisk" failed
with "storage 'local-lvm' is not available on node 'pve-02'" after the
VM (VMID 103) already existed. Querying /nodes/{node}/storage instead
fixes this, since Proxmox itself only lists what's available there.

Also adds get_image_storages() and an optional storage= override on
create_vm_from_cloud_init, so callers aren't stuck with auto-detection.
2026-07-07 22:36:14 +02:00
Christian Manivong 12135735cb fix(vm_provision_mixin): storage 'enabled' absent means enabled, not disabled
Proxmox's /storage API omits the "enabled" key entirely for storages that
were never explicitly toggled, rather than defaulting it to 1 — it isn't
present-and-falsy, it's just absent. Both _find_default_image_storage and
the snippet-storage discovery treated storage.get("enabled") as truthy-check,
so every storage without an explicit "enabled": 1 was silently excluded.

Confirmed live against a real Proxmox test server: local-lvm, local-zfs, and
fast-zfs all had content=images with no "enabled" key at all, causing
create_vm_from_cloud_init to always fail with "No storage with
content='images' found" despite multiple valid storages existing. All prior
tests used "enabled": 1 explicitly in their fixtures, masking the bug.

Fix: storage.get("enabled", 1) != 0 — absent or truthy means enabled, only
an explicit 0 excludes it. 4 new regression tests, 28 total pass.
2026-07-07 12:12:44 +02:00
Christian Manivong 9264cdcba9 feat(vm_provision_mixin): create_vm_from_cloud_init downloads cloud images directly
Replaces the template-clone flow with: create empty VM shell, download the
cloud image on the node (cached by filename, optional checksum verification),
qm importdisk, attach as scsi0. NIC config, snippet upload, ssh keys, disk
resize, and start remain unchanged (already generic).

New helpers: _run_node_command (strict SSH exec with custom timeout and
non-zero-exit detection, unlike the best-effort _exec_ssh_command),
_download_cloud_image (idempotent download + checksum check),
_find_default_image_storage (content=images discovery, mirrors the existing
snippet-storage discovery).

24 tests pass (10 new: _run_node_command x2, _download_cloud_image x4, plus
rewrites of the 4 existing create_vm_from_cloud_init tests for the new flow).
2026-07-07 10:37:36 +02:00
Christian Manivong 55b6fe669c Merge feature/network-target-vlan-tag: expose fixed VLAN tag for SDN vnets 2026-07-07 10:23:00 +02:00
Christian Manivong ddd4e6fc03 feat(vm_provision_mixin): expose fixed_vlan_tag for SDN vnets
vnet's SDN tag (VLAN ID) is now surfaced in get_network_targets() output
instead of being silently discarded. Bridges never set this field.
2026-07-07 10:22:56 +02:00
Christian Manivong 76d74753da Merge feature/network-targets: implement get_network_targets() 2026-07-07 09:10:04 +02:00
Christian Manivong c7289fa674 feat(vm_provision_mixin): implement get_network_targets()
Filters _get_node_network() to bridge/OVSBridge types only (excludes physical
NICs, bonds), plus SDN vnets from _get_sdn_vnets(). vlan_aware: Linux bridge
reflects its bridge_vlan_aware config flag; OVS bridge always true; SDN vnet
always false (VLAN already fixed by the vnet's zone/tag).

4 new tests: bridge/vnet filtering, Linux bridge vlan_aware flag, OVS bridge
always vlan_aware, SDN vnet never vlan_aware. All 15 tests in the file pass.
2026-07-07 09:08:21 +02:00
Christian Manivong a5a5b634b0 Reapply "Merge feature/generic-vm-provisioning: generalize vm_provision_mixin for arbitrary NIC configs"
This reverts commit 6ede48d244.
2026-07-07 08:21:00 +02:00
Christian Manivong 6ede48d244 Revert "Merge feature/generic-vm-provisioning: generalize vm_provision_mixin for arbitrary NIC configs"
This reverts commit 1d2006f9fb, reversing
changes made to 7bdac4c496.
2026-07-07 00:53:13 +02:00
Christian Manivong 1d2006f9fb Merge feature/generic-vm-provisioning: generalize vm_provision_mixin for arbitrary NIC configs 2026-07-07 00:47:02 +02:00
11 changed files with 1430 additions and 188 deletions
+22 -3
View File
@@ -78,6 +78,10 @@ class ProxmoxDriver(
VENDOR = "Proxmox" VENDOR = "Proxmox"
DRIVER_NAME = "proxmox" DRIVER_NAME = "proxmox"
# Everything runs over the Proxmox REST API; there is no SSH session.
USES_SSH = False
# A PVE node reboots through a full init sequence plus storage checks.
REBOOT_SETTLE_SECONDS = 90
PORT_SPECS = [ PORT_SPECS = [
PortSpec("https", 8006, weight=8.0), PortSpec("https", 8006, weight=8.0),
] ]
@@ -137,9 +141,18 @@ class ProxmoxDriver(
# The openssh backend tunnels all kwargs through to # The openssh backend tunnels all kwargs through to
# openssh_wrapper.CommandBaseSession, which does not # openssh_wrapper.CommandBaseSession, which does not
# accept password/verify_ssl/token params. # accept password/verify_ssl/token params.
# Proxmox authenticates against "<user>@<realm>" and rejects a bare
# username outright. A caller who typed a realm keeps it; one who
# did not gets self._realm, which is what the documented `realm`
# optional_arg is for -- it was read in __init__ and then never
# used, so the option had no effect and a bare username failed.
user = self.username or ""
if user and "@" not in user:
user = f"{user}@{self._realm}"
kwargs: _JsonDict = { kwargs: _JsonDict = {
"host": self.hostname, "host": self.hostname,
"user": self.username, "user": user,
"password": self.password, "password": self.password,
"port": self._port, "port": self._port,
"verify_ssl": self._verify_ssl, "verify_ssl": self._verify_ssl,
@@ -206,11 +219,17 @@ class ProxmoxDriver(
self._ssh_client = None self._ssh_client = None
def is_alive(self) -> _JsonDict: def is_alive(self) -> _JsonDict:
"""Return connection liveness.""" """Return connection liveness.
Probes ``GET /version``, the cheapest endpoint that proves the session
still authenticates. It used to call ``_resolve_node()``, which returns
early without touching the API whenever a node was configured via
optional_args — so a dead connection reported itself alive.
"""
alive = False alive = False
if self._api: if self._api:
try: try:
self._resolve_node() self._api.version.get()
alive = True alive = True
except Exception: except Exception:
pass pass
+42
View File
@@ -17,6 +17,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Any from typing import Any
from napalm_proxmox import utils from napalm_proxmox import utils
@@ -113,6 +114,47 @@ class ProxmoxInterfaceMixin:
} }
return result return result
def get_ipv6_neighbors_table(self) -> list[_JsonDict]:
"""Return the IPv6 neighbour table, read via ``ip -6 neigh show``.
The IPv6 counterpart to :meth:`get_arp_table`. Proxmox exposes no REST
endpoint for it, so it goes through the node exec helper like the ARP
table does.
Entries in FAILED state are dropped: they record an address the kernel
could not resolve, so there is no neighbour to report.
"""
raw = self._exec_ssh_command("ip -6 neigh show 2>/dev/null || true")
if not raw:
return []
entries: list[_JsonDict] = []
for line in raw.splitlines():
parts = line.split()
# "<ip> dev <iface> lladdr <mac> <STATE>" — an entry without lladdr
# never resolved and carries no neighbour.
if len(parts) < 6 or "lladdr" not in parts:
continue
state = parts[-1].upper()
if state == "FAILED":
continue
try:
iface = parts[parts.index("dev") + 1]
mac = parts[parts.index("lladdr") + 1]
except (ValueError, IndexError):
continue
entries.append(
{
"interface": iface,
"mac": utils.normalize_mac(mac),
"ip": parts[0],
# `ip neigh` reports no age; NAPALM's shape requires the key.
"age": 0.0,
"state": state,
}
)
return entries
def get_arp_table(self, vrf: str = "") -> list[_JsonDict]: def get_arp_table(self, vrf: str = "") -> list[_JsonDict]:
"""Return ARP table. """Return ARP table.
+8 -5
View File
@@ -61,7 +61,8 @@ class ProxmoxSDNMixin:
OVSIntPort (access ports with ovs_tag) → untagged membership, and OVSIntPort (access ports with ovs_tag) → untagged membership, and
OVSPort / OVSBridge (trunk ports) → tagged membership. OVSPort / OVSBridge (trunk ports) → tagged membership.
Falls back to ``bridge vlan show`` for classic Linux-bridge nodes. Without OVS ports, VLAN membership is derived from the ``tag=`` values
in each VM's and container's ``netN`` config (:meth:`_get_vm_vlan_tags`).
""" """
result: dict[str, _JsonDict] = {} result: dict[str, _JsonDict] = {}
node_network = self._get_node_network() node_network = self._get_node_network()
@@ -113,10 +114,12 @@ class ProxmoxSDNMixin:
if bridge not in entry["untagged"]: if bridge not in entry["untagged"]:
entry["untagged"].append(bridge) entry["untagged"].append(bridge)
return { # Deliberately unfiltered. This used to drop entries with no member
vid: entry for vid, entry in result.items() # ports, which hid every configured SDN VNet that no VM happened to be
if entry.get("tagged") or entry.get("untagged") # attached to — and contradicted the OVS branch above, which returns
} # empty-membership VLANs. A VNet exists on the node whether or not
# anything currently uses it, and netOrk's VLAN discovery reads this.
return result
def _get_vm_vlan_tags(self) -> dict[str, set[str]]: def _get_vm_vlan_tags(self) -> dict[str, set[str]]:
"""Return ``{vlan_tag: {bridge_names}}`` derived from VM/container net configs. """Return ``{vlan_tag: {bridge_names}}`` derived from VM/container net configs.
+2 -18
View File
@@ -288,11 +288,7 @@ class ProxmoxSystemMixin:
try: try:
lldpd_path = self._exec_ssh_command("command -v lldpd 2>/dev/null").strip() lldpd_path = self._exec_ssh_command("command -v lldpd 2>/dev/null").strip()
if not lldpd_path: if not lldpd_path:
warnings.append({ warnings.append({"code": "lldpd_not_installed"})
"code": "lldpd_not_installed",
"severity": "warning",
"action": "install_lldpd",
})
except Exception as exc: except Exception as exc:
logger.debug("Failed to check for lldpd: %s", exc) logger.debug("Failed to check for lldpd: %s", exc)
@@ -302,13 +298,6 @@ class ProxmoxSystemMixin:
if updates: if updates:
warnings.append({ warnings.append({
"code": "updates_available", "code": "updates_available",
"severity": "warning",
"title": (
f"{len(updates)} package update"
f"{'s' if len(updates) != 1 else ''} available"
),
"message": None,
"action": None,
"meta": { "meta": {
"count": len(updates), "count": len(updates),
"packages": [u["name"] for u in updates], "packages": [u["name"] for u in updates],
@@ -322,12 +311,7 @@ class ProxmoxSystemMixin:
sub = self._get_node_subscription() sub = self._get_node_subscription()
status = sub.get("status", "") status = sub.get("status", "")
if status in ("NotFound", "Invalid", "Expired"): if status in ("NotFound", "Invalid", "Expired"):
warnings.append({ warnings.append({"code": "no_subscription", "meta": {"status": status}})
"code": "no_subscription",
"severity": "warning",
"action": None,
"meta": {"status": status},
})
except Exception as exc: except Exception as exc:
logger.debug("Failed to check subscription status: %s", exc) logger.debug("Failed to check subscription status: %s", exc)
+352 -71
View File
@@ -2,20 +2,200 @@
from __future__ import annotations from __future__ import annotations
import base64
import hashlib
import logging import logging
import time import time
import yaml import yaml
from typing import Any, Dict, List from typing import Any, Dict, List
from urllib.parse import quote from urllib.parse import quote
from napalm_device_types.models import VMProvisionResultDict, VMStatusDict from napalm_device_types.models import (
NetworkTargetDict,
StorageTargetDict,
VMProvisionResultDict,
VMStatusDict,
)
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Downloaded cloud images are cached here on the hypervisor node, keyed by
# filename, so provisioning multiple VMs from the same image only pays the
# download cost once.
_IMAGE_CACHE_DIR = "/var/lib/vz/template/netork-images"
class ProxmoxVMProvisionMixin: class ProxmoxVMProvisionMixin:
"""Mixin to add VM provisioning to ProxmoxDriver.""" """Mixin to add VM provisioning to ProxmoxDriver."""
def _run_node_command(self, command: str, timeout: int) -> str:
"""
Execute a shell command on the Proxmox node via SSH, raising on failure.
Unlike ``_exec_ssh_command`` (best-effort, fixed timeout, swallows
errors), this is for critical provisioning steps — image download,
disk import — where a non-zero exit or a caller-specific timeout must
surface as a hard failure rather than an empty string.
"""
import paramiko
if self._ssh_client is None:
ssh_user = self._ssh_username or self.username
ssh_pass = self._ssh_password or self.password
ssh_pkey = None
if self._ssh_key and not ssh_pass:
from io import StringIO as _StringIO
ssh_pkey = paramiko.RSAKey.from_private_key(_StringIO(self._ssh_key))
self._ssh_client = paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs: Dict[str, Any] = {
"hostname": self.hostname,
"port": 22,
"username": ssh_user,
"timeout": self.timeout,
}
if ssh_pkey:
connect_kwargs["pkey"] = ssh_pkey
else:
connect_kwargs["password"] = ssh_pass
self._ssh_client.connect(**connect_kwargs)
_, stdout, stderr = self._ssh_client.exec_command(command, timeout=timeout)
exit_status = stdout.channel.recv_exit_status()
out = stdout.read().decode().strip()
err = stderr.read().decode().strip()
if exit_status != 0:
raise RuntimeError(f"Command failed (exit {exit_status}): {command}\n{err or out}")
return out
def _download_cloud_image(
self, image_url: str, image_checksum: str | None, timeout: int
) -> str:
"""
Download image_url to the node's image cache dir if not already present.
Returns the local path on the hypervisor node. The cache filename is
prefixed with a hash of the *full* URL, not just its basename —
Ubuntu (and others) publish per-build URLs that change daily under a
stable basename (e.g. .../release-20260713/ubuntu-26.04-server-
cloudimg-amd64.img), so keying the cache on the basename alone let a
stale previous-day build satisfy the "already cached" check and fail
checksum verification against today's expected hash.
Verifies image_checksum (format "<algo>:<hex>", e.g. "sha256:abc123...")
if given. On mismatch, removes the bad file and retries the download
once (covers a corrupted/partial transfer or a stale same-keyed file)
before raising.
"""
filename = image_url.rstrip("/").rsplit("/", 1)[-1]
url_hash = hashlib.sha256(image_url.encode()).hexdigest()[:12]
local_path = f"{_IMAGE_CACHE_DIR}/{url_hash}-{filename}"
algo, _, expected = (image_checksum or "").partition(":")
algo = (algo or "sha256").lower()
max_attempts = 2
for attempt in range(1, max_attempts + 1):
exists = self._run_node_command(
f"mkdir -p {_IMAGE_CACHE_DIR} && test -f {local_path} "
f"&& echo EXISTS || echo MISSING",
timeout=30,
)
if "EXISTS" not in exists:
_logger.info(f"Downloading cloud image {image_url} -> {local_path}")
self._run_node_command(
f"wget -q -O {local_path}.tmp '{image_url}' "
f"&& mv {local_path}.tmp {local_path}",
timeout=timeout,
)
if not image_checksum:
return local_path
actual = self._run_node_command(
f"{algo}sum {local_path} | awk '{{print $1}}'", timeout=60
)
if actual.lower() == expected.lower():
return local_path
# Remove the bad file so the next attempt re-downloads instead of
# reusing it.
self._run_node_command(f"rm -f {local_path}", timeout=30)
if attempt == max_attempts:
raise RuntimeError(
f"Checksum mismatch for {image_url}: expected {expected}, got {actual}"
)
_logger.warning(
f"Checksum mismatch for {image_url} on attempt {attempt}/{max_attempts} "
"— retrying download"
)
raise AssertionError("unreachable") # loop always returns or raises above
def _find_default_image_storage(self) -> str:
"""Find a storage suitable for VM root disks (content includes 'images').
Proxmox's /storage API omits the "enabled" field entirely for storages
that were never explicitly toggled — it is not present-and-falsy, it is
just absent, defaulting to enabled. Only an explicit 0 means disabled.
Queries the node-scoped /nodes/{node}/storage endpoint, not the
cluster-wide /storage one: a storage can be configured with a "nodes"
restriction limiting it to other cluster members, and the cluster-wide
list doesn't reflect that — it would happily return a storage this
node can't actually see, and "qm importdisk" would fail with
"storage 'X' is not available on node 'Y'" after the VM shell was
already created.
"""
for storage in self._node_api().storage.get():
content = storage.get("content", "")
if "images" in content and storage.get("enabled", 1) != 0:
return storage["storage"]
raise ValueError(
"No storage with content='images' found. Configure a storage for VM disks."
)
def _get_storage_path(self, storage: str) -> str:
"""Resolve a storage's filesystem path on the node.
Needed to write Cloud-Init snippets directly: Proxmox's
/storage/{s}/upload API only accepts content in {iso, vztmpl,
import} — "snippets" is rejected outright, so snippets must be
written straight to the filesystem instead. Only dir-backed storages
(dir, nfs, cifs, cephfs) expose "path"; those are also the only
storage types Proxmox itself allows content='snippets' on.
"""
config = self._api.storage(storage).get()
path = config.get("path")
if not path:
raise ValueError(
f"Storage '{storage}' has no filesystem path (content='snippets' "
"requires a dir/nfs/cifs/cephfs-backed storage)"
)
return path
def get_image_storages(self) -> List[StorageTargetDict]:
"""List node-available storage pools suitable for a new VM's root disk."""
targets: List[StorageTargetDict] = []
for storage in self._node_api().storage.get():
content = storage.get("content", "")
if "images" not in content or storage.get("enabled", 1) == 0:
continue
if storage.get("active", 1) == 0:
continue
total = storage.get("total") or 0
avail = storage.get("avail") or 0
targets.append(
{
"name": storage["storage"],
"type": storage.get("type", ""),
"total_gb": round(total / (1024**3), 1),
"available_gb": round(avail / (1024**3), 1),
}
)
return targets
def _wait_for_task(self, upid: str, timeout: int = 120) -> None: def _wait_for_task(self, upid: str, timeout: int = 120) -> None:
""" """
Poll a Proxmox task until completion. Poll a Proxmox task until completion.
@@ -51,68 +231,111 @@ class ProxmoxVMProvisionMixin:
self, self,
name: str, name: str,
*, *,
template: str, image_url: str,
cpu: int, cpu: int,
memory: int, memory: int,
nics: List[Dict[str, Any]], nics: List[Dict[str, Any]],
cloud_init_config: Dict[str, Any], cloud_init_config: Dict[str, Any],
image_checksum: str | None = None,
ssh_public_keys: List[str] | None = None, ssh_public_keys: List[str] | None = None,
disk_resize_gb: int | None = None, disk_resize_gb: int | None = None,
storage: str | None = None,
download_timeout: int = 300,
timeout: int = 180, timeout: int = 180,
) -> VMProvisionResultDict: ) -> VMProvisionResultDict:
""" """
Create a new VM from a Cloud-Init template via Proxmox API. Create a new VM from a downloaded cloud image via Proxmox API.
Steps: Steps:
1. Get next available VMID from cluster 1. Get next available VMID from cluster
2. Clone template VM (full clone, new VMID) 2. Create an empty VM shell (no clone — no pre-existing template needed)
3. Configure CPU, memory, and network interfaces 3. Download the cloud image on the node (cached by filename) and
4. Verify snippet storage exists import it as the VM's root disk
5. Render cloud-init config to YAML and upload 4. Configure CPU, memory, and network interfaces
6. Set Cloud-Init config references and SSH keys 5. Verify snippet storage exists
7. Optionally resize root disk 6. Render cloud-init config to YAML and upload
8. Start the VM 7. Set Cloud-Init config references and SSH keys
9. Return VMID, name, node 8. Optionally resize root disk
9. Start the VM
10. Return VMID, name, node
Args: Args:
name: new VM display name name: new VM display name
template: template VMID/name to clone from image_url: URL of the cloud image to download and use as root disk
cpu: number of vCPUs cpu: number of vCPUs
memory: RAM in MB memory: RAM in MB
nics: list of NIC config dicts (bridge, vlan_tag/trunk_vlan_tags, dhcp flag) nics: list of NIC config dicts (bridge, vlan_tag/trunk_vlan_tags, dhcp flag)
cloud_init_config: user-data dict (will be YAML-rendered) cloud_init_config: user-data dict (will be YAML-rendered)
image_checksum: expected "<algo>:<hex>" checksum of the image, verified
after download (None = no verification)
ssh_public_keys: SSH public keys to inject ssh_public_keys: SSH public keys to inject
disk_resize_gb: resize root disk to this size (None = no resize) disk_resize_gb: resize root disk to this size (None = no resize)
timeout: max seconds for provisioning storage: storage pool for the root disk (None = auto-detect first
enabled, node-available storage with content='images')
download_timeout: max seconds for the image download (skipped if cached)
timeout: max seconds for the remaining provisioning steps
Returns: Returns:
{"vmid": str, "name": str, "node": str} {"vmid": str, "name": str, "node": str}
Raises: Raises:
RuntimeError: provisioning failure (clone, config, timeout, etc.) RuntimeError: provisioning failure (download, import, config, timeout, etc.)
ValueError: invalid storage or configuration ValueError: invalid storage or configuration
""" """
try: try:
_logger.info(f"Creating VM '{name}' from template {template}") _logger.info(f"Creating VM '{name}' from image {image_url}")
# Step 1: Get next VMID # Step 1: Get next VMID
next_vmid = self._api.cluster.nextid.get() next_vmid = self._api.cluster.nextid.get()
vmid = int(next_vmid) vmid = int(next_vmid)
_logger.info(f"Allocated VMID {vmid}") _logger.info(f"Allocated VMID {vmid}")
# Step 2: Clone template # Step 2: Create empty VM shell (no disks yet)
_logger.info(f"Cloning template {template} → VMID {vmid}") _logger.info(f"Creating VM shell {vmid}")
clone_upid = self._node_api().qemu(template).clone.post( self._node_api().qemu.post(
newid=vmid, vmid=vmid,
full=1,
name=name, name=name,
memory=memory,
cores=cpu,
ostype="l26",
scsihw="virtio-scsi-pci",
# Without this, Proxmox never attaches the virtio-serial
# channel the QEMU guest agent needs — get_vm_status's
# agent queries (below) would have nothing to talk to.
agent="1",
) )
self._wait_for_task(clone_upid, timeout=timeout)
# Step 3: Configure CPU, memory, and NICs # Step 3: Download cloud image (cached) and import as root disk
_logger.info(f"Configuring VM {vmid}: {cpu} CPU, {memory}MB RAM, {len(nics)} NIC(s)") local_path = self._download_cloud_image(
image_url, image_checksum, timeout=download_timeout
)
image_storage = storage or self._find_default_image_storage()
config_args = {"cores": cpu, "memory": memory} _logger.info(f"Importing {local_path} into VM {vmid} on storage {image_storage}")
self._run_node_command(
f"qm importdisk {vmid} {local_path} {image_storage} --format qcow2",
timeout=timeout,
)
# Proxmox leaves the imported disk as an "unusedN" reference — find
# it and attach it as the boot disk.
imported_config = self._node_api().qemu(vmid).config.get()
unused_value = next(
(v for k, v in imported_config.items() if k.startswith("unused")), None
)
if not unused_value:
raise RuntimeError(
f"Disk import for VM {vmid} did not produce an unused disk reference"
)
self._node_api().qemu(vmid).config.post(
scsi0=f"{unused_value},discard=on",
boot="order=scsi0",
)
# Step 4: Configure network interfaces (CPU/memory already set at shell creation)
_logger.info(f"Configuring {len(nics)} NIC(s) for VM {vmid}")
config_args: Dict[str, Any] = {}
# Build NIC config strings generically # Build NIC config strings generically
for i, nic in enumerate(nics): for i, nic in enumerate(nics):
@@ -120,8 +343,9 @@ class ProxmoxVMProvisionMixin:
if not bridge: if not bridge:
raise ValueError(f"NIC {i}: bridge is required") raise ValueError(f"NIC {i}: bridge is required")
# Build base config: model + bridge # Build base config: model[=mac] + bridge
net_config = f"virtio,bridge={bridge}" mac = nic.get("mac")
net_config = f"virtio={mac},bridge={bridge}" if mac else f"virtio,bridge={bridge}"
# Add VLAN configuration (access vs trunk) # Add VLAN configuration (access vs trunk)
if "trunk_vlan_tags" in nic and nic["trunk_vlan_tags"]: if "trunk_vlan_tags" in nic and nic["trunk_vlan_tags"]:
@@ -134,13 +358,15 @@ class ProxmoxVMProvisionMixin:
self._node_api().qemu(vmid).config.post(**config_args) self._node_api().qemu(vmid).config.post(**config_args)
# Step 4: Verify snippet storage exists # Step 5: Verify snippet storage exists
# (enabled is absent-not-falsy, and node-scoping matters — see
# _find_default_image_storage)
_logger.info("Checking for snippet storage...") _logger.info("Checking for snippet storage...")
storages = self._api.storage.get() storages = self._node_api().storage.get()
snippet_storage = None snippet_storage = None
for storage in storages: for storage in storages:
content = storage.get("content", "") content = storage.get("content", "")
if "snippets" in content and storage.get("enabled"): if "snippets" in content and storage.get("enabled", 1) != 0:
snippet_storage = storage["storage"] snippet_storage = storage["storage"]
break break
@@ -152,7 +378,7 @@ class ProxmoxVMProvisionMixin:
) )
_logger.info(f"Using snippet storage: {snippet_storage}") _logger.info(f"Using snippet storage: {snippet_storage}")
# Step 5: Render and upload Cloud-Init config # Step 6: Render and upload Cloud-Init config
_logger.info(f"Rendering Cloud-Init config for VMID {vmid}") _logger.info(f"Rendering Cloud-Init config for VMID {vmid}")
user_data_yaml = "#cloud-config\n" + yaml.dump( user_data_yaml = "#cloud-config\n" + yaml.dump(
@@ -160,20 +386,32 @@ class ProxmoxVMProvisionMixin:
) )
filename = f"{vmid}-user-data.yaml" filename = f"{vmid}-user-data.yaml"
_logger.debug(f"Uploading Cloud-Init snippet {filename} to {snippet_storage}") _logger.debug(f"Writing Cloud-Init snippet {filename} to {snippet_storage}")
# Upload to snippet storage # Proxmox's /storage/{s}/upload API only accepts content in
self._node_api().storage(snippet_storage).upload.post( # {iso, vztmpl, import} — "snippets" is rejected outright
content="snippets", # ("does not have a value in the enumeration"). Snippets can only
filename=filename, # be written directly to the filesystem, so resolve the storage's
data=user_data_yaml, # backing path and write the file over SSH instead.
storage_path = self._get_storage_path(snippet_storage)
encoded = base64.b64encode(user_data_yaml.encode("utf-8")).decode("ascii")
self._run_node_command(
f"mkdir -p {storage_path}/snippets && "
f"echo {encoded} | base64 -d > {storage_path}/snippets/{filename}",
timeout=30,
) )
# Step 6: Configure Cloud-Init references and SSH keys # Step 7: Configure Cloud-Init references and SSH keys
_logger.info(f"Setting Cloud-Init config for VM {vmid}") _logger.info(f"Setting Cloud-Init config for VM {vmid}")
cloud_init_args = { cloud_init_args = {
"ide2": f"{snippet_storage}:cloudinit", # The cloud-init drive is a disk image — it needs a storage
# with content='images' (same requirement as the root disk),
# NOT the snippet storage (content='snippets'). These are
# often different storages; Proxmox fails at VM start with
# "storage 'X' does not support content-type 'images'" if
# this points at a snippets-only storage.
"ide2": f"{image_storage}:cloudinit",
"citype": "nocloud", "citype": "nocloud",
"cicustom": f"user={snippet_storage}:snippets/{filename}", "cicustom": f"user={snippet_storage}:snippets/{filename}",
} }
@@ -191,7 +429,7 @@ class ProxmoxVMProvisionMixin:
self._node_api().qemu(vmid).config.post(**cloud_init_args) self._node_api().qemu(vmid).config.post(**cloud_init_args)
# Step 7: Optionally resize root disk # Step 8: Optionally resize root disk
if disk_resize_gb is not None: if disk_resize_gb is not None:
_logger.info(f"Resizing root disk to {disk_resize_gb}GB") _logger.info(f"Resizing root disk to {disk_resize_gb}GB")
# Find root disk (scsi0, virtio0, ide0, sata0 — whichever is first) # Find root disk (scsi0, virtio0, ide0, sata0 — whichever is first)
@@ -212,7 +450,7 @@ class ProxmoxVMProvisionMixin:
except Exception as e: except Exception as e:
_logger.warning(f"Failed to resize disk: {e}, continuing anyway") _logger.warning(f"Failed to resize disk: {e}, continuing anyway")
# Step 8: Start the VM # Step 9: Start the VM
_logger.info(f"Starting VM {vmid}") _logger.info(f"Starting VM {vmid}")
start_upid = self._node_api().qemu(vmid).status.start.post() start_upid = self._node_api().qemu(vmid).status.start.post()
self._wait_for_task(start_upid, timeout=timeout) self._wait_for_task(start_upid, timeout=timeout)
@@ -264,11 +502,16 @@ class ProxmoxVMProvisionMixin:
_logger.debug(f"VM {vmid} stop failed (may already be stopped): {e}") _logger.debug(f"VM {vmid} stop failed (may already be stopped): {e}")
# Step 2: Delete VM # Step 2: Delete VM
# Proxmox's API parameter is hyphenated (destroy-unreferenced-disks),
# not a valid Python identifier — proxmoxer forwards kwargs to the
# request verbatim with no underscore-to-hyphen translation, so this
# must be built as a dict and unpacked rather than passed as a kwarg.
_logger.debug(f"Deleting VM {vmid} configuration and disks") _logger.debug(f"Deleting VM {vmid} configuration and disks")
self._node_api().qemu(vmid_int).delete( delete_params = {
purge=1, "purge": 1,
destroy_unreferenced_disks=1 if remove_disk else 0, "destroy-unreferenced-disks": 1 if remove_disk else 0,
) }
self._node_api().qemu(vmid_int).delete(**delete_params)
# Step 3: Clean up Cloud-Init snippets # Step 3: Clean up Cloud-Init snippets
# (This is best-effort; snippet files may be unreachable if storage is unavailable) # (This is best-effort; snippet files may be unreachable if storage is unavailable)
@@ -325,51 +568,49 @@ class ProxmoxVMProvisionMixin:
vmid_int = int(vmid) vmid_int = int(vmid)
_logger.debug(f"Getting status for VM {vmid}") _logger.debug(f"Getting status for VM {vmid}")
# Get VM config to infer net0 MAC (for matching guest-agent results) # VM must exist / be readable before we start polling.
try: try:
config = self._node_api().qemu(vmid_int).config.get() self._node_api().qemu(vmid_int).config.get()
except Exception: except Exception:
# VM may not exist yet or config not readable # VM may not exist yet or config not readable
return {"status": "unknown"} return {"status": "unknown"}
# Parse net0 MAC from config (if present)
net0_line = config.get("net0", "")
expected_mac = None
# Example: "virtio,bridge=vmbr0,tag=10" — no explicit MAC
# Proxmox auto-generates MACs in a deterministic pattern, but we'll
# match by looking for the first NIC's IP in guest-agent results
# Polling loop # Polling loop
start_time = time.time() start_time = time.time()
while True: while True:
elapsed = time.time() - start_time elapsed = time.time() - start_time
if wait_for_ip and elapsed > timeout: if wait_for_ip and elapsed > timeout:
raise RuntimeError( raise RuntimeError(f"VM {vmid} failed to acquire IP within {timeout}s")
f"VM {vmid} failed to acquire IP within {timeout}s"
)
try: try:
# Query guest-agent network interfaces # Query guest-agent network interfaces. Proxmox's REST path is
agent_info = self._node_api().qemu(vmid_int).agent.network_get_interfaces.get() # "network-get-interfaces" (hyphens) — it must be passed as a
interfaces = agent_info.get("result", []) # resource id via __call__, not dotted attribute access (which
# would silently build a non-existent "network_get_interfaces"
# path and 404 on every poll).
agent_info = (
self._node_api().qemu(vmid_int).agent("network-get-interfaces").get()
)
interfaces = (agent_info or {}).get("result", [])
# Find net0 (first interface with IP) # The guest agent does not report interfaces in a fixed order —
if interfaces: # "lo" commonly comes first. Skip it and take the first real
net0_iface = interfaces[0] # Assumes net0 is first in list # NIC that has an IPv4 address.
net0_mac = net0_iface.get("hardware-address", "") for iface in interfaces:
ip_addresses = net0_iface.get("ip-addresses", []) name = iface.get("name", "")
if not name or name == "lo":
if ip_addresses: continue
# Found IP for addr in iface.get("ip-addresses", []):
ip_info = ip_addresses[0] if addr.get("ip-address-type") != "ipv4":
ip_addr = ip_info.get("ip-address", "") continue
ip_addr = addr.get("ip-address", "")
if ip_addr: if ip_addr:
_logger.info(f"VM {vmid} acquired IP {ip_addr}") _logger.info(f"VM {vmid} acquired IP {ip_addr}")
return { return {
"status": "running", "status": "running",
"ip_address": ip_addr, "ip_address": ip_addr,
"hostname": net0_iface.get("name", ""), "hostname": name,
"mac_address": net0_mac, "mac_address": iface.get("hardware-address", ""),
} }
except Exception as e: except Exception as e:
@@ -387,3 +628,43 @@ class ProxmoxVMProvisionMixin:
except Exception as e: except Exception as e:
_logger.exception(f"Error getting status for VM {vmid}: {e}") _logger.exception(f"Error getting status for VM {vmid}: {e}")
raise RuntimeError(f"Failed to get VM {vmid} status: {e}") raise RuntimeError(f"Failed to get VM {vmid} status: {e}")
def get_network_targets(self) -> List[NetworkTargetDict]:
"""
List selectable network targets (bridges + SDN vnets) for a new VM's NIC.
Excludes physical NICs, bonds, and other non-bridge interface types —
those are never valid ``NICConfigDict.bridge`` values on Proxmox.
"""
targets: List[NetworkTargetDict] = []
for iface in self._get_node_network():
iface_type = iface.get("type")
name = iface.get("iface", "")
if not name:
continue
if iface_type == "bridge":
vlan_aware = bool(int(iface.get("bridge_vlan_aware", 0) or 0))
targets.append({"name": name, "kind": "bridge", "vlan_aware": vlan_aware})
elif iface_type == "OVSBridge":
# OVS bridges tag per-port regardless of a dedicated "VLAN aware" setting.
targets.append({"name": name, "kind": "bridge", "vlan_aware": True})
for vnet in self._get_sdn_vnets():
name = vnet.get("vnet", "")
if not name:
continue
# A vnet's VLAN is already fixed by its zone/tag — no separate vlan_tag applies.
tag = vnet.get("tag")
fixed_vlan_tag = int(tag) if tag is not None else None
targets.append(
{
"name": name,
"kind": "vnet",
"vlan_aware": False,
"fixed_vlan_tag": fixed_vlan_tag,
}
)
return targets
+4 -1
View File
@@ -91,7 +91,10 @@ SDN_SUBNETS_VNET1 = [
SDN_SUBNETS_VNET2: list = [] SDN_SUBNETS_VNET2: list = []
DNS_INFO = {"search": "pve1.example.com", "dns1": "8.8.8.8"} # A DNS *search domain*, not an FQDN. It used to read "pve1.example.com",
# which made get_facts build "pve1.pve1.example.com" and looked like a
# driver bug rather than bad test data.
DNS_INFO = {"search": "example.com", "dns1": "8.8.8.8"}
NTP_INFO = {"server": "pool.ntp.org,time.cloudflare.com"} NTP_INFO = {"server": "pool.ntp.org,time.cloudflare.com"}
+4 -1
View File
@@ -34,7 +34,10 @@ class TestOpen:
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api) as mock_cls: with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api) as mock_cls:
drv.open() drv.open()
call_kwargs = mock_cls.call_args.kwargs call_kwargs = mock_cls.call_args.kwargs
assert call_kwargs["user"] == "napalm@pam!mytoken" # proxmoxer wants the two halves separately, not the combined
# "<user>!<tokenid>" string that Proxmox's UI displays.
assert call_kwargs["user"] == "napalm@pam"
assert call_kwargs["token_name"] == "mytoken"
assert call_kwargs["token_value"] == "super-secret" assert call_kwargs["token_value"] == "super-secret"
def test_open_connection_error(self): def test_open_connection_error(self):
+19 -8
View File
@@ -263,18 +263,29 @@ class TestGetRouteTo:
class TestLLDPNeighbors: class TestLLDPNeighbors:
# Real `lldpcli show neighbors summary` output. The interface line carries
# ", via: LLDP, ..." after the name, which is what the parser matches on —
# the previous fixture stopped at the name and matched nothing.
LLDP_SUMMARY = ( LLDP_SUMMARY = (
" Interface: eth0\n" "LLDP neighbors:\n"
" SysName: sw01.example.com\n" "-------------------------------------------------------------------------------\n"
" PortID: ifname GigabitEthernet1/0/1\n" "Interface: eth0, via: LLDP, RID: 1, Time: 0 day, 00:11:22\n"
" Interface: eth1\n" " Chassis:\n"
" SysName: sw02.example.com\n" " SysName: sw01.example.com\n"
" PortID: ifname GigabitEthernet1/0/2\n" " Port:\n"
" PortID: ifname GigabitEthernet1/0/1\n"
"-------------------------------------------------------------------------------\n"
"Interface: eth1, via: LLDP, RID: 2, Time: 0 day, 00:11:22\n"
" Chassis:\n"
" SysName: sw02.example.com\n"
" Port:\n"
" PortID: ifname GigabitEthernet1/0/2\n"
"-------------------------------------------------------------------------------\n"
) )
def test_neighbors_found(self, driver): def test_neighbors_found(self, driver):
driver._node_api().execute.post.return_value = {"data": self.LLDP_SUMMARY} with patch.object(driver, "_exec_ssh_command", return_value=self.LLDP_SUMMARY):
result = driver.get_lldp_neighbors() result = driver.get_lldp_neighbors()
assert "eth0" in result assert "eth0" in result
assert result["eth0"][0]["hostname"] == "sw01.example.com" assert result["eth0"][0]["hostname"] == "sw01.example.com"
+10 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from unittest.mock import patch
from napalm_proxmox import utils from napalm_proxmox import utils
@@ -126,6 +127,11 @@ class TestGetARPTable:
class TestGetMACAddressTable: class TestGetMACAddressTable:
# Mocked at _exec_ssh_command, the driver's own seam. Mocking the API call
# underneath it broke twice over: that helper passes two positional
# arguments where these doubles accepted one, and it base64-wraps the
# command, so a fixture keyed on "bridge fdb" appearing in the text never
# matched.
BRIDGE_FDB = ( BRIDGE_FDB = (
"aa:bb:cc:dd:ee:01 dev eth0 vlan 10 master vmbr0 permanent\n" "aa:bb:cc:dd:ee:01 dev eth0 vlan 10 master vmbr0 permanent\n"
"cc:dd:ee:ff:00:11 dev eth0 vlan 20 master vmbr0\n" "cc:dd:ee:ff:00:11 dev eth0 vlan 20 master vmbr0\n"
@@ -142,10 +148,8 @@ class TestGetMACAddressTable:
return self.BRIDGE_FDB return self.BRIDGE_FDB
return "" return ""
driver._node_api().execute.post.side_effect = lambda command: { with patch.object(driver, "_exec_ssh_command", side_effect=_exec):
"data": _exec(command) result = driver.get_mac_address_table()
}
result = driver.get_mac_address_table()
macs = {e["mac"] for e in result} macs = {e["mac"] for e in result}
assert "aa:bb:cc:dd:ee:01" in macs assert "aa:bb:cc:dd:ee:01" in macs
@@ -155,9 +159,7 @@ class TestGetMACAddressTable:
return self.BRIDGE_FDB return self.BRIDGE_FDB
return "" return ""
driver._node_api().execute.post.side_effect = lambda command: { with patch.object(driver, "_exec_ssh_command", side_effect=_exec):
"data": _exec(command) result = driver.get_mac_address_table()
}
result = driver.get_mac_address_table()
static_entries = [e for e in result if e["mac"] == "aa:bb:cc:dd:ee:01"] static_entries = [e for e in result if e["mac"] == "aa:bb:cc:dd:ee:01"]
assert static_entries[0]["static"] is True assert static_entries[0]["static"] is True
+26 -12
View File
@@ -22,19 +22,33 @@ class TestGetVlans:
result = driver.get_vlans() result = driver.get_vlans()
assert "100000" in result assert "100000" in result
def test_bridge_vlan_show_parsing(self, driver): def test_membership_derived_from_vm_configs(self, driver):
# Simulate bridge vlan output """Without OVS ports, VLAN membership comes from each VM's netN config.
bridge_output = (
"vmbr0 1\n" This replaces a test for a `bridge vlan show` fallback that no longer
" 10\n" exists — it also asserted an "interfaces" key this method has never
" 20\n" produced, so it could not have passed against any version of the code.
"eth0 1\n" """
) node = driver._node_api()
driver._node_api().execute.post.return_value = {"data": bridge_output} node.qemu.get.return_value = [{"vmid": 100}]
node.lxc.get.return_value = []
node.qemu.return_value.config.get.return_value = {
"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=10",
}
result = driver.get_vlans() result = driver.get_vlans()
# Interface vmbr0 should appear in vlan 1 assert "vmbr0" in result["10"]["untagged"]
entry = result.get("1", {})
assert "vmbr0" in entry.get("interfaces", []) def test_configured_vnets_appear_even_without_members(self, driver):
"""An SDN VNet exists on the node whether or not anything is attached.
Entries with no member ports used to be filtered out of this one return
path while the OVS path returned them, so a configured VLAN was visible
or invisible depending on which branch ran.
"""
result = driver.get_vlans()
assert result["20"]["name"] == "vnet1"
assert result["20"]["untagged"] == []
def test_empty_sdn_returns_dict(self): def test_empty_sdn_returns_dict(self):
from unittest.mock import patch from unittest.mock import patch
File diff suppressed because it is too large Load Diff