Skip to content
Open
17 changes: 17 additions & 0 deletions app/core/deploy_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
135 changes: 133 additions & 2 deletions app/core/inventory_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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": {}},
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
74 changes: 60 additions & 14 deletions app/overlay/expand_replication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "+"
Expand All @@ -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):
Expand All @@ -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 {}
Expand All @@ -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))
Expand All @@ -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)
Expand All @@ -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


Expand All @@ -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,
Expand Down
Loading
Loading