diff --git a/app/core/deploy_trigger.py b/app/core/deploy_trigger.py index 316a14e..373e022 100644 --- a/app/core/deploy_trigger.py +++ b/app/core/deploy_trigger.py @@ -107,6 +107,9 @@ async def start_attempt(session: AsyncSession, *, attempt: Attempt, "r42_scope": attempt.scope, "r42_team_id": attempt.team_id, "r42_playbook_path": str(playbook_path), + # team_count is a per-team multiplier the playbook needs on every host + # (set_fact on localhost is not visible to the proxmox-cli plays). + "team_count": dep.team_count or 1, } # Build the tainted-string set for substring redaction. Always includes @@ -169,6 +172,20 @@ async def start_attempt(session: AsyncSession, *, attempt: Attempt, else: proxmox_address = api_url.split(":", 1)[0] + # Proxmox API creds for the _universal playbook's node-network tasks + # (the proxmox_controller role reads proxmox_api_* as plain vars; the + # generated inventory carries no token). token_ref: "user!tokenid=secret". + extravars["proxmox_api_host"] = api_url.split("://", 1)[-1].rstrip("/") + extravars["proxmox_node"] = target_host.node_name + _tok = target_host.token_ref or "" + if "!" in _tok and "=" in _tok: + _userpart, _secret = _tok.split("=", 1) + _user, _tokid = _userpart.split("!", 1) + extravars["proxmox_api_user"] = _user + extravars["proxmox_api_token_id"] = _tokid + extravars["proxmox_api_token_secret"] = _secret + tainted.add(_secret) + inventory_dir = ws / "inventory" inventory_dir.mkdir(parents=True, exist_ok=True) write_inventory( diff --git a/app/core/inventory_writer.py b/app/core/inventory_writer.py index 525e360..10ea0a9 100644 --- a/app/core/inventory_writer.py +++ b/app/core/inventory_writer.py @@ -20,11 +20,16 @@ """ from __future__ import annotations +import ipaddress from pathlib import Path from typing import Any import yaml +# Reuse the canonical per-team template renderer so inventory CIDR resolution +# never drifts from the playbook/compose expansion (see #84). +from app.overlay.expand_replication import _render_template + # Node kinds that become Ansible hosts (others are infra primitives) _HOST_KINDS = {"vm", "lxc", "docker"} @@ -94,6 +99,117 @@ def _ip_for_node(bridge_base: int, team_id: int | None, seq: int) -> str: return f"192.168.{octet}.{200 + seq}" +def _resolve_network_cidr( + net_node: dict, team_id: int | None, bridge_base: int +) -> str | None: + """Resolve a network node's CIDR, rendering a node-level ``cidr_template`` + (canonical schema form) for the given team if needed.""" + if net_node.get("cidr"): + return net_node["cidr"] + tpl = net_node.get("cidr_template") + if tpl: + return _render_template(tpl, team_id or 0, bridge_base) + return None + + +def _host_ip_from_cidr(cidr: str, seq: int) -> str | None: + """Derive a host address inside ``cidr`` following the existing host-octet + convention (network address + 200 + seq). Returns None on a malformed CIDR.""" + try: + net = ipaddress.ip_network(cidr, strict=False) + except ValueError: + return None + return str(net.network_address + (200 + seq)) + + +def _resolve_network( + net_node: dict, team_id: int | None, bridge_base: int +) -> dict[str, str | None]: + """Resolve a network node's ``{bridge, cidr, gateway}`` for a team, + rendering node-level templates via the canonical renderer.""" + tid = team_id or 0 + bridge = net_node.get("bridge") + if not bridge and net_node.get("bridge_template"): + bridge = _render_template(net_node["bridge_template"], tid, bridge_base) + gateway = net_node.get("gateway") + if not gateway and net_node.get("gateway_template"): + gateway = _render_template(net_node["gateway_template"], tid, bridge_base) + return { + "bridge": bridge, + "cidr": _resolve_network_cidr(net_node, team_id, bridge_base), + "gateway": gateway, + } + + +def _host_ci_net( + node: dict, team_id: int | None, bridge_base: int, + networks_by_id: dict[str, dict], +) -> tuple[int, str, str]: + """Per-host cloud-init network triple (netmask, gateway, bridge). Derived + from the bound network, else the legacy 192.168.{bridge_base+team} scheme.""" + octet = bridge_base + (team_id or 0) + netmask, gateway, bridge = 24, f"192.168.{octet}.1", f"vmbr{octet}" + nics = node.get("networks") or [] + primary = nics[0] if nics else None + ref = primary.get("node_ref") if primary else None + if ref and ref in networks_by_id: + r = _resolve_network(networks_by_id[ref], team_id, bridge_base) + if r["cidr"] and "/" in r["cidr"]: + try: + netmask = int(r["cidr"].split("/")[1]) + except ValueError: + pass + if r["gateway"]: + gateway = r["gateway"] + if r["bridge"]: + bridge = r["bridge"] + return netmask, gateway, bridge + + +def _build_network_map( + topology: dict, team_count: int, bridge_base: int +) -> dict[str, dict[str, dict]]: + """Per network id, the resolved {bridge, cidr, gateway} keyed by team id + (or 'shared') — the single source the playbook reads to create bridges.""" + out: dict[str, dict[str, dict]] = {} + for net in topology.get("nodes", []): + if net.get("kind") != "network" or not net.get("id"): + continue + scope = (net.get("replication") or {}).get("scope", "shared") + entry: dict[str, dict] = {} + if scope == "per_team": + for tid in range(1, team_count + 1): + entry[str(tid)] = _resolve_network(net, tid, bridge_base) + else: + entry["shared"] = _resolve_network(net, None, bridge_base) + out[net["id"]] = entry + return out + + +def _ansible_host_ip( + node: dict, team_id: int | None, seq: int, bridge_base: int, + networks_by_id: dict[str, dict], +) -> str: + """Determine a host's ansible_host. Precedence: explicit NIC ip > + node_ref-bound network CIDR derivation > hardcoded 192.168.{bridge_base} + fallback (backward-compatible with topologies that omit networks[]).""" + nics = node.get("networks") or [] + primary = nics[0] if nics else None + if primary: + explicit = primary.get("ip") + if explicit: + return explicit + ref = primary.get("node_ref") + net = networks_by_id.get(ref) if ref else None + if net is not None: + cidr = _resolve_network_cidr(net, team_id, bridge_base) + if cidr: + derived = _host_ip_from_cidr(cidr, seq) + if derived: + return derived + return _ip_for_node(bridge_base, team_id, seq) + + def write_inventory( *, topology: dict[str, Any], @@ -125,6 +241,13 @@ def write_inventory( # Filter nodes: only vm/lxc/docker become Ansible hosts host_nodes = [n for n in topology.get("nodes", []) if n.get("kind") in _HOST_KINDS] + # Network nodes, keyed by id, for NIC node_ref -> CIDR resolution. + networks_by_id = { + n.get("id"): n + for n in topology.get("nodes", []) + if n.get("kind") == "network" and n.get("id") + } + children: dict[str, dict[str, Any]] = { "r42_admin": {"hosts": {}}, "r42_admin_wazuh_clients": {"hosts": {}}, @@ -157,7 +280,10 @@ def write_inventory( host = _hostname(prefix, team_id, node_id) user = ssh_user_for_role.get(role, _DEFAULT_SSH_USER.get(role, "alice")) - ip = _ip_for_node(bridge_base, team_id, seq) + ip = _ansible_host_ip(node, team_id, seq, bridge_base, networks_by_id) + ci_netmask, ci_gateway, net_bridge = _host_ci_net( + node, team_id, bridge_base, networks_by_id + ) children[group]["hosts"][host] = { "ansible_host": ip, @@ -170,6 +296,10 @@ def write_inventory( "r42_team_id": team_id, "r42_node_name": node_id, "r42_template_vmid": node.get("template_vmid"), + # Cloud-init network triple — single source for the playbook (#73). + "r42_ci_netmask": ci_netmask, + "r42_ci_gateway": ci_gateway, + "r42_net_bridge": net_bridge, } # If this node has a wazuh-agent attachment, also list under @@ -182,7 +312,8 @@ def write_inventory( if "wazuh" in ref.lower() and "agent" in ref.lower(): children["r42_admin_wazuh_clients"]["hosts"][host] = {} - inv = {"all": {"children": children}} + network_map = _build_network_map(topology, team_count, bridge_base) + inv = {"all": {"vars": {"r42_network_map": network_map}, "children": children}} dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(yaml.safe_dump(inv, sort_keys=False)) return dest diff --git a/app/overlay/expand_replication.py b/app/overlay/expand_replication.py index b9862dd..0a3a067 100644 --- a/app/overlay/expand_replication.py +++ b/app/overlay/expand_replication.py @@ -26,10 +26,47 @@ class ExpandResult(TypedDict): document: dict[str, Any] +# Canonical schema form: Jinja-ish `{{ bridge_base + team_id }}`. +_JINJA_RE = re.compile(r"\{\{\s*([^{}]+?)\s*\}\}") +# Legacy single-brace numeric form: `{140+team_id}` (still accepted). _TEMPLATE_RE = re.compile(r"\{(\d*)\s*([+\-*])?\s*team_id\s*\}") +_TOKEN_RE = re.compile(r"\d+|team_id|bridge_base|[+\-*]") + + +def _eval_expr(expr: str, team_id: int, bridge_base: int) -> str: + """Evaluate a minimal left-to-right integer expression over the variables + ``team_id`` and ``bridge_base`` with ``+ - *`` (no operator precedence). + Kept deliberately simple to guarantee TS/Python parity.""" + tokens = _TOKEN_RE.findall(expr) + if not tokens: + return expr + + def val(tok: str) -> int: + if tok == "team_id": + return team_id + if tok == "bridge_base": + return bridge_base + return int(tok) + + acc = val(tokens[0]) + i = 1 + while i < len(tokens) - 1: + op, operand = tokens[i], val(tokens[i + 1]) + i += 2 + if op == "+": + acc += operand + elif op == "-": + acc -= operand + elif op == "*": + acc *= operand + return str(acc) + +def _render_template(tpl: str, team_id: int, bridge_base: int = 140) -> str: + tpl = _JINJA_RE.sub( + lambda m: _eval_expr(m.group(1), team_id, bridge_base), tpl + ) -def _render_template(tpl: str, team_id: int) -> str: def sub(m: re.Match[str]) -> str: base = int(m.group(1) or 0) op = m.group(2) or "+" @@ -43,25 +80,29 @@ def sub(m: re.Match[str]) -> str: def _apply_offsets(node: dict, team_id: int, id_offset: dict | None, - namespace_sink: list[str]) -> dict: + namespace_sink: list[str], + bridge_base: int) -> dict: out = deepcopy(node) out["id"] = f"{node['id']}__team_{team_id}" cfg = out.get("config") or {} if "name_template" in cfg: - cfg["name"] = _render_template(cfg.pop("name_template"), team_id) - if "bridge_template" in cfg: - cfg["bridge"] = _render_template(cfg.pop("bridge_template"), team_id) + cfg["name"] = _render_template(cfg.pop("name_template"), team_id, bridge_base) if "vlan_template" in cfg: - cfg["vlan"] = int(_render_template(cfg.pop("vlan_template"), team_id)) - if "cidr_template" in cfg: - cfg["cidr"] = _render_template(cfg.pop("cidr_template"), team_id) + cfg["vlan"] = int(_render_template(cfg.pop("vlan_template"), team_id, bridge_base)) if id_offset and "vmid" in id_offset and "vm_id" in cfg: cfg["vm_id"] = int(cfg["vm_id"]) + id_offset["vmid"] * team_id out["config"] = cfg + # Network templates live at node level per the canonical schema + # (cidr_template/bridge_template/gateway_template, network kind only). + for tkey, okey in (("cidr_template", "cidr"), + ("bridge_template", "bridge"), + ("gateway_template", "gateway")): + if tkey in out: + out[okey] = _render_template(out.pop(tkey), team_id, bridge_base) if isinstance(out.get("networks"), list): for nw in out["networks"]: if "ip_template" in nw: - nw["ip"] = _render_template(nw.pop("ip_template"), team_id) + nw["ip"] = _render_template(nw.pop("ip_template"), team_id, bridge_base) # Rewrite play-level notify targets inside attachments. for att in out.get("attachments") or []: if isinstance(att.get("notify"), list): @@ -77,7 +118,7 @@ def _apply_offsets(node: dict, team_id: int, def _walk_and_expand(nodes: list[dict], team_count: int, - namespace_sink: list[str]) -> list[dict]: + namespace_sink: list[str], bridge_base: int) -> list[dict]: result: list[dict] = [] for n in nodes: rep = n.get("replication") or {} @@ -86,7 +127,7 @@ def _walk_and_expand(nodes: list[dict], team_count: int, if n.get("kind") == "group" and isinstance(n.get("children"), list): nn = deepcopy(n) nn["children"] = _walk_and_expand( - n["children"], team_count, namespace_sink) + n["children"], team_count, namespace_sink, bridge_base) result.append(nn) else: result.append(deepcopy(n)) @@ -96,7 +137,7 @@ def _walk_and_expand(nodes: list[dict], team_count: int, for tid in range(1, team_count + 1): if n.get("kind") == "group" and isinstance(n.get("children"), list): expanded_children = [ - _apply_offsets(c, tid, id_offset, namespace_sink) + _apply_offsets(c, tid, id_offset, namespace_sink, bridge_base) for c in n["children"] ] grp = deepcopy(n) @@ -105,7 +146,8 @@ def _walk_and_expand(nodes: list[dict], team_count: int, grp["replication"] = {"scope": "shared"} result.append(grp) else: - result.append(_apply_offsets(n, tid, id_offset, namespace_sink)) + result.append( + _apply_offsets(n, tid, id_offset, namespace_sink, bridge_base)) return result @@ -114,8 +156,12 @@ def expand_replication(doc: dict, team_count: int) -> ExpandResult: raise ValueError(f"invalid team_count: {team_count}") out = deepcopy(doc) namespace_sink: list[str] = [] + # Match the TS expander: a non-numeric bridge_base (null/string/bool) + # falls back to 140 rather than crashing. + _bb = doc.get("bridge_base", 140) + bridge_base = _bb if isinstance(_bb, int) and not isinstance(_bb, bool) else 140 out["nodes"] = _walk_and_expand( - doc.get("nodes", []), team_count, namespace_sink) + doc.get("nodes", []), team_count, namespace_sink, bridge_base) return { "plays_per_team": team_count, "handler_namespaces": namespace_sink, diff --git a/app/routes/v1/proxmox/vms.py b/app/routes/v1/proxmox/vms.py index 6b691d8..baa564c 100644 --- a/app/routes/v1/proxmox/vms.py +++ b/app/routes/v1/proxmox/vms.py @@ -22,7 +22,12 @@ from app.core.vmid_guard import VmidProtectedError as GuardVmidProtected from app.core.vmid_guard import assert_vmid_safe from app.schemas.v1.common import Page -from app.schemas.v1.proxmox import TaskStatus, VmActionResult, VmSummary +from app.schemas.v1.proxmox import ( + TaskStatus, + VmActionResult, + VmConfigResult, + VmSummary, +) router = APIRouter() log = get_logger(__name__) @@ -117,6 +122,45 @@ async def list_host_vms(host_id: str, session: AsyncSession = Depends(_session)) ) +@router.get( + "/hosts/{host_id}/vms/{vmid}/config", response_model=VmConfigResult +) +async def vm_config( + host_id: str, + vmid: int, + vmtype: Literal["qemu", "lxc"] = "qemu", + session: AsyncSession = Depends(_session), +): + """Return the raw PVE guest config (net0/net1/ipconfig*, etc.). The UI import + flow parses net* to rebuild per-NIC bridge/network edges (#79); the v1 VM + list deliberately omits per-NIC detail.""" + row = await _get_host(host_id, session) + base = row.api_url.rstrip("/") + url = f"{base}/api2/json/nodes/{row.node_name}/{vmtype}/{vmid}/config" + try: + async with httpx.AsyncClient(verify=False, timeout=15) as cli: + r = await cli.get(url, headers=_auth_headers(row)) + except httpx.RequestError as e: + raise _unreachable(row, e) from e + if r.status_code in (401, 403): + raise AuthFailedError(details=[{ + "field": "token_ref", + "reason": f"Proxmox API rejected credentials ({r.status_code})", + }]) + if r.status_code != 200: + raise Range42Error( + error="upstream_error", + code="PROXMOX_ERROR", + status=502, + message=f"Proxmox returned {r.status_code} for vm config", + details=[{"field": "vmid", "reason": (r.text or "")[:300]}], + ) + return VmConfigResult( + vmid=vmid, node=row.node_name, type=vmtype, + config=r.json().get("data", {}) or {}, + ) + + @router.post( "/hosts/{host_id}/vms/{vmid}/status/{action}", response_model=VmActionResult ) diff --git a/app/schemas/generated.py b/app/schemas/generated.py index de3d207..76c6a91 100644 --- a/app/schemas/generated.py +++ b/app/schemas/generated.py @@ -165,6 +165,26 @@ class Node(BaseModel): description="Bridge name template, e.g., vmbr{{ bridge_base + team_id }} (network kind only)" ), ] = None + gateway_template: Annotated[ + str | None, + Field( + description="Gateway IP template, e.g., 192.168.{{ bridge_base + team_id }}.1 (network kind only)" + ), + ] = None + cidr: Annotated[ + str | None, + Field( + description="Rendered CIDR (by expand_replication) or static custom CIDR (network kind only)" + ), + ] = None + bridge: Annotated[ + str | None, + Field(description="Rendered bridge name or static bridge (network kind only)"), + ] = None + gateway: Annotated[ + str | None, + Field(description="Rendered gateway IP or static gateway (network kind only)"), + ] = None vlan_tag: Annotated[ int | None, Field( diff --git a/app/schemas/v1/proxmox.py b/app/schemas/v1/proxmox.py index 54dce6d..feba360 100644 --- a/app/schemas/v1/proxmox.py +++ b/app/schemas/v1/proxmox.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, HttpUrl @@ -55,3 +55,12 @@ class TaskStatus(BaseModel): status: Literal["running", "stopped"] exitstatus: str | None = None node: str + + +class VmConfigResult(BaseModel): + """Raw PVE guest config (net0/net1/ipconfig*, cores, memory, ...). The UI + import flow parses net* to reconstruct per-NIC bridge/network edges (#79).""" + vmid: int + node: str + type: Literal["qemu", "lxc"] = "qemu" + config: dict[str, Any] diff --git a/tests/core/test_inventory_writer.py b/tests/core/test_inventory_writer.py index 5656206..e257495 100644 --- a/tests/core/test_inventory_writer.py +++ b/tests/core/test_inventory_writer.py @@ -101,6 +101,135 @@ def test_skips_non_host_node_kinds(tmp_path): "Network nodes must not appear as inventory hosts" +def _write(topology, tmp_path, **kw): + out = tmp_path / "hosts.yml" + write_inventory( + topology=topology, codename="X", proxmox_address="10.0.0.1", + ssh_keys_dir=tmp_path, dest=out, **kw, + ) + return yaml.safe_load(out.read_text()) + + +def test_ansible_host_derives_from_bound_static_network_cidr(tmp_path): + """A host NIC's node_ref points to a network node with a static cidr; + ansible_host is derived from that cidr (network addr + 200 + seq), not the + hardcoded 192.168.{bridge_base} scheme.""" + topology = { + "schema_version": "1.0", "kind": "gamenet", "bridge_base": 140, + "nodes": [ + {"id": "net-custom", "kind": "network", + "replication": {"scope": "shared"}, "cidr": "10.50.0.0/24"}, + {"id": "host-01", "kind": "vm", "role": "admin", + "replication": {"scope": "shared"}, "template_vmid": 9001, + "config": {}, "networks": [{"node_ref": "net-custom"}], + "attachments": []}, + ], + } + inv = _write(topology, tmp_path, team_count=1) + admin = inv["all"]["children"]["r42_admin"]["hosts"] + host = next(h for h in admin if "host-01" in h) + assert admin[host]["ansible_host"] == "10.50.0.200" + + +def test_explicit_nic_ip_overrides_network_derivation(tmp_path): + """An explicit NIC ip takes precedence over node_ref cidr derivation.""" + topology = { + "schema_version": "1.0", "kind": "gamenet", "bridge_base": 140, + "nodes": [ + {"id": "net-custom", "kind": "network", + "replication": {"scope": "shared"}, "cidr": "10.50.0.0/24"}, + {"id": "host-01", "kind": "vm", "role": "admin", + "replication": {"scope": "shared"}, "template_vmid": 9001, + "config": {}, "networks": [{"node_ref": "net-custom", "ip": "10.50.0.42"}], + "attachments": []}, + ], + } + inv = _write(topology, tmp_path, team_count=1) + admin = inv["all"]["children"]["r42_admin"]["hosts"] + host = next(h for h in admin if "host-01" in h) + assert admin[host]["ansible_host"] == "10.50.0.42" + + +def test_ansible_host_derives_from_per_team_cidr_template(tmp_path): + """A per-team host bound to a per-team network with a node-level + cidr_template resolves the cidr per team id and derives the host ip.""" + topology = { + "schema_version": "1.0", "kind": "gamenet", "bridge_base": 140, + "nodes": [ + {"id": "team-net", "kind": "network", + "replication": {"scope": "per_team"}, + "cidr_template": "10.{{ bridge_base + team_id }}.0.0/16"}, + {"id": "box", "kind": "vm", "role": "team", + "replication": {"scope": "per_team"}, "template_vmid": 9020, + "config": {}, "networks": [{"node_ref": "team-net"}], + "attachments": []}, + ], + } + inv = _write(topology, tmp_path, team_count=2) + blank = inv["all"]["children"]["r42_blank_group"]["hosts"] + t1 = next(h for h in blank if "1-box" in h) + t2 = next(h for h in blank if "2-box" in h) + # team1 -> 10.141.0.0/16 + 200 -> 10.141.0.200 ; team2 -> 10.142.0.200 + assert blank[t1]["ansible_host"] == "10.141.0.200" + assert blank[t2]["ansible_host"] == "10.142.0.200" + + +def test_host_gets_cloudinit_network_vars_from_bound_network(tmp_path): + """A host bound to a network exposes r42_ci_netmask/r42_ci_gateway/ + r42_net_bridge (resolved from that network) for the playbook's cloud-init — + single source of truth, no playbook-side recompute.""" + topology = { + "schema_version": "1.0", "kind": "gamenet", "bridge_base": 140, + "nodes": [ + {"id": "net-a", "kind": "network", "replication": {"scope": "shared"}, + "cidr": "10.20.0.0/24", "gateway": "10.20.0.254", "bridge": "vmbr80"}, + {"id": "host-01", "kind": "vm", "role": "admin", + "replication": {"scope": "shared"}, "template_vmid": 9001, + "config": {}, "networks": [{"node_ref": "net-a"}], "attachments": []}, + ], + } + inv = _write(topology, tmp_path, team_count=1) + admin = inv["all"]["children"]["r42_admin"]["hosts"] + host = admin[next(h for h in admin if "host-01" in h)] + assert host["ansible_host"] == "10.20.0.200" + assert host["r42_ci_netmask"] == 24 + assert host["r42_ci_gateway"] == "10.20.0.254" + assert host["r42_net_bridge"] == "vmbr80" + + +def test_inventory_exposes_network_map(tmp_path): + """inventory carries all.vars.r42_network_map: per network id + team, the + resolved {bridge, cidr, gateway} the playbook needs to create bridges.""" + topology = { + "schema_version": "1.0", "kind": "gamenet", "bridge_base": 140, + "nodes": [ + {"id": "team-net", "kind": "network", "replication": {"scope": "per_team"}, + "cidr_template": "10.{{ bridge_base + team_id }}.0.0/16", + "bridge_template": "vmbr{{ bridge_base + team_id }}", + "gateway_template": "10.{{ bridge_base + team_id }}.0.1"}, + ], + } + inv = _write(topology, tmp_path, team_count=2) + nmap = inv["all"]["vars"]["r42_network_map"] + assert nmap["team-net"]["1"] == { + "bridge": "vmbr141", "cidr": "10.141.0.0/16", "gateway": "10.141.0.1"} + assert nmap["team-net"]["2"] == { + "bridge": "vmbr142", "cidr": "10.142.0.0/16", "gateway": "10.142.0.1"} + + +def test_host_without_network_keeps_legacy_cloudinit_vars(tmp_path): + """Backward-compat: a host with no networks[] falls back to the existing + 192.168.{bridge_base+team} scheme for ci vars.""" + topology = json.loads((FIXTURES / "02-multi-team.json").read_text()) + inv = _write(topology, tmp_path, team_count=2) + blank = inv["all"]["children"]["r42_blank_group"]["hosts"] + h = blank[next(host for host in blank if "1-trainee" in host)] + assert h["ansible_host"] == "192.168.141.200" + assert h["r42_ci_netmask"] == 24 + assert h["r42_ci_gateway"] == "192.168.141.1" + assert h["r42_net_bridge"] == "vmbr141" + + def test_rejects_node_without_role(tmp_path): """VM/LXC nodes MUST have a role; preflight catches this but inventory_writer is also a defense layer.""" diff --git a/tests/integration/test_universal_deploy_smoke.py b/tests/integration/test_universal_deploy_smoke.py index 863e847..a67d288 100644 --- a/tests/integration/test_universal_deploy_smoke.py +++ b/tests/integration/test_universal_deploy_smoke.py @@ -93,7 +93,7 @@ async def _build_universal_attempt(tmp_path: Path, ws: Path, auth_kind="none")) s.add(ProxmoxHost( id="h", name="n", api_url="https://10.0.0.5:8006", - node_name="n", token_ref="t", + node_name="n", token_ref="root@pam!range42-backend=secret123", )) await s.commit() async with dbmod.get_session_factory()() as s: @@ -217,6 +217,14 @@ async def start(self, *, private_data_dir, extravars, envvars): "scenarios/_universal/main.yml" ) + # ASSERT: Proxmox API creds for the playbook's node-network tasks are + # derived from the host (token_ref format: user!tokenid=secret). + assert extravars["proxmox_api_host"] == "10.0.0.5:8006" + assert extravars["proxmox_node"] == "n" + assert extravars["proxmox_api_user"] == "root@pam" + assert extravars["proxmox_api_token_id"] == "range42-backend" + assert extravars["proxmox_api_token_secret"] == "secret123" + # ASSERT: the stubbed checkout dropped topology.json into ws/project/ assert (ws / "project" / "topology.json").is_file(), \ "checkout_project stub did not write topology.json" diff --git a/tests/routes/test_proxmox_vms.py b/tests/routes/test_proxmox_vms.py index aee18a7..dacf414 100644 --- a/tests/routes/test_proxmox_vms.py +++ b/tests/routes/test_proxmox_vms.py @@ -68,6 +68,13 @@ async def get(self, url, headers=None): return _FakeResp(200, [ {"vmid": 200, "name": "ct-1", "status": "stopped"}, ]) + if url.endswith("/config"): + return _FakeResp(200, { + "net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr142,firewall=1", + "net1": "virtio=11:22:33:44:55:66,bridge=vmbr140", + "ipconfig0": "ip=192.168.142.50/24,gw=192.168.142.1", + "cores": 2, "memory": 2048, + }) if "/tasks/" in url and url.endswith("/status"): return _FakeResp(200, { "upid": "UPID:pve01:0001:delete::", @@ -118,6 +125,28 @@ async def test_list_host_vms_merges_qemu_and_lxc(tmp_path, monkeypatch): await dbmod.dispose_engine() +@pytest.mark.asyncio +async def test_vm_config_returns_nic_bridges(tmp_path, monkeypatch): + app, dbmod = await _boot(tmp_path, monkeypatch) + monkeypatch.setattr(httpx, "AsyncClient", _FakeProxmox) + _FakeProxmox.calls = [] + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + hid = await _create_host(c) + r = await c.get(f"/v1/proxmox/hosts/{hid}/vms/4001/config") + assert r.status_code == 200, r.text + body = r.json() + assert body["vmid"] == 4001 + assert body["node"] == "pve01" + assert body["type"] == "qemu" + assert "bridge=vmbr142" in body["config"]["net0"] + assert "bridge=vmbr140" in body["config"]["net1"] + assert any(u.endswith("/qemu/4001/config") + for (_, u, *_) in _FakeProxmox.calls) + finally: + await dbmod.dispose_engine() + + @pytest.mark.asyncio async def test_list_host_vms_unknown_host_404(tmp_path, monkeypatch): app, dbmod = await _boot(tmp_path, monkeypatch)