From da5ed6cd08ad27c248406e64c84c2b9534e6af35 Mon Sep 17 00:00:00 2001 From: Carsten Date: Tue, 21 Apr 2026 22:34:45 +0200 Subject: [PATCH] feat(agent): vms/lxc collectors for runtime and detail with fixtures --- agent/lib/proxmox_agent/collectors/vms.ex | 164 ++++++++++++++++++ agent/test/fixtures/pvesh/lxc.json | 12 ++ agent/test/fixtures/pvesh/qemu.json | 22 +++ .../pvesh/qemu_100_agent_interfaces.json | 17 ++ .../test/fixtures/pvesh/qemu_100_config.json | 8 + .../proxmox_agent/collectors/vms_test.exs | 76 ++++++++ 6 files changed, 299 insertions(+) create mode 100644 agent/lib/proxmox_agent/collectors/vms.ex create mode 100644 agent/test/fixtures/pvesh/lxc.json create mode 100644 agent/test/fixtures/pvesh/qemu.json create mode 100644 agent/test/fixtures/pvesh/qemu_100_agent_interfaces.json create mode 100644 agent/test/fixtures/pvesh/qemu_100_config.json create mode 100644 agent/test/proxmox_agent/collectors/vms_test.exs diff --git a/agent/lib/proxmox_agent/collectors/vms.ex b/agent/lib/proxmox_agent/collectors/vms.ex new file mode 100644 index 0000000..9ffd92f --- /dev/null +++ b/agent/lib/proxmox_agent/collectors/vms.ex @@ -0,0 +1,164 @@ +defmodule ProxmoxAgent.Collectors.Vms do + @moduledoc """ + Collects VM/LXC runtime (fast path) and per-VM detail incl. IPs (medium path). + """ + + @spec collect_runtime(keyword()) :: %{vms: [map()], errors: [map()]} + def collect_runtime(opts) do + runner = runner(opts) + node = Keyword.fetch!(opts, :node) + + {qemu, e1} = list(runner, node, "qemu") + {lxc, e2} = list(runner, node, "lxc") + + vms = + Enum.map(qemu, &normalize_runtime(&1, "qemu")) ++ + Enum.map(lxc, &normalize_runtime(&1, "lxc")) + + %{vms: vms, errors: Enum.filter([e1, e2], & &1)} + end + + @spec collect_detail(keyword()) :: %{vms: [map()], errors: [map()]} + def collect_detail(opts) do + runner = runner(opts) + node = Keyword.fetch!(opts, :node) + + {qemu, e1} = list(runner, node, "qemu") + {lxc, e2} = list(runner, node, "lxc") + + qemu_details = Enum.map(qemu, &qemu_detail(runner, node, &1)) + lxc_details = Enum.map(lxc, &lxc_detail(runner, node, &1)) + + %{vms: qemu_details ++ lxc_details, errors: Enum.filter([e1, e2], & &1)} + end + + defp runner(opts), do: Keyword.get(opts, :runner, &ProxmoxAgent.Shell.run/2) + + defp list(runner, node, type) do + case runner.("pvesh", ["get", "/nodes/#{node}/#{type}", "--output-format", "json"]) do + {:ok, body} -> + case Jason.decode(body) do + {:ok, list} when is_list(list) -> {list, nil} + {:error, e} -> {[], %{tag: "decode_#{type}", message: Exception.message(e)}} + end + + {:error, reason} -> + {[], %{tag: "list_#{type}", message: inspect(reason)}} + end + end + + defp normalize_runtime(entry, type) do + %{ + vmid: entry["vmid"], + type: type, + name: entry["name"] || entry["hostname"], + status: entry["status"], + uptime_seconds: entry["uptime"] || 0, + cpu_usage: entry["cpu"] || 0.0, + mem_bytes: entry["mem"] || 0, + max_mem_bytes: entry["maxmem"] || 0, + tags: parse_tags(entry["tags"]) + } + end + + defp parse_tags(nil), do: [] + defp parse_tags(""), do: [] + + defp parse_tags(str) when is_binary(str) do + str |> String.split([";", ","], trim: true) |> Enum.map(&String.trim/1) + end + + defp qemu_detail(runner, node, entry) do + vmid = entry["vmid"] + {config, cfg_err} = fetch_json(runner, "/nodes/#{node}/qemu/#{vmid}/config") + {ips, ip_err} = fetch_qemu_agent_ips(runner, node, vmid) + + %{ + vmid: vmid, + type: "qemu", + name: entry["name"], + config: config || %{}, + ips: ips, + errors: Enum.filter([cfg_err, ip_err], & &1) + } + end + + defp lxc_detail(runner, node, entry) do + vmid = entry["vmid"] + {config, cfg_err} = fetch_json(runner, "/nodes/#{node}/lxc/#{vmid}/config") + ips = extract_lxc_ips(config || %{}) + + %{ + vmid: vmid, + type: "lxc", + name: entry["name"], + config: config || %{}, + ips: ips, + errors: Enum.filter([cfg_err], & &1) + } + end + + defp fetch_json(runner, path) do + case runner.("pvesh", ["get", path, "--output-format", "json"]) do + {:ok, body} -> + case Jason.decode(body) do + {:ok, map} -> {map, nil} + {:error, e} -> {nil, %{tag: "decode", message: Exception.message(e)}} + end + + {:error, reason} -> + {nil, %{tag: "pvesh", message: inspect(reason)}} + end + end + + defp fetch_qemu_agent_ips(runner, node, vmid) do + case runner.("pvesh", [ + "get", + "/nodes/#{node}/qemu/#{vmid}/agent/network-get-interfaces", + "--output-format", + "json" + ]) do + {:ok, body} -> + case Jason.decode(body) do + {:ok, %{"result" => interfaces}} -> + ips = + interfaces + |> Enum.reject(&(&1["name"] == "lo")) + |> Enum.flat_map(&Map.get(&1, "ip-addresses", [])) + |> Enum.filter(&(&1["ip-address-type"] == "ipv4")) + |> Enum.map(& &1["ip-address"]) + + {ips, nil} + + _ -> + {[], %{tag: "agent_ips", message: "unexpected shape"}} + end + + {:error, _reason} -> + {[], nil} + end + end + + defp extract_lxc_ips(config) do + config + |> Enum.filter(fn {k, _} -> String.starts_with?(to_string(k), "net") end) + |> Enum.flat_map(fn {_, val} -> parse_lxc_net(val) end) + end + + defp parse_lxc_net(val) when is_binary(val) do + val + |> String.split(",") + |> Enum.find_value([], fn pair -> + case String.split(pair, "=", parts: 2) do + ["ip", ip] -> + ip = ip |> String.split("/") |> hd() + if ip == "dhcp", do: [], else: [ip] + + _ -> + nil + end + end) + end + + defp parse_lxc_net(_), do: [] +end diff --git a/agent/test/fixtures/pvesh/lxc.json b/agent/test/fixtures/pvesh/lxc.json new file mode 100644 index 0000000..6dd70b3 --- /dev/null +++ b/agent/test/fixtures/pvesh/lxc.json @@ -0,0 +1,12 @@ +[ + { + "vmid": 200, + "name": "minecraft", + "status": "running", + "uptime": 3600, + "cpu": 0.15, + "mem": 2147483648, + "maxmem": 4294967296, + "tags": "" + } +] diff --git a/agent/test/fixtures/pvesh/qemu.json b/agent/test/fixtures/pvesh/qemu.json new file mode 100644 index 0000000..1fe14b9 --- /dev/null +++ b/agent/test/fixtures/pvesh/qemu.json @@ -0,0 +1,22 @@ +[ + { + "vmid": 100, + "name": "nginx-proxy", + "status": "running", + "uptime": 86400, + "cpu": 0.05, + "mem": 536870912, + "maxmem": 2147483648, + "tags": "web;production" + }, + { + "vmid": 101, + "name": "db-backup", + "status": "stopped", + "uptime": 0, + "cpu": 0, + "mem": 0, + "maxmem": 4294967296, + "tags": "db" + } +] diff --git a/agent/test/fixtures/pvesh/qemu_100_agent_interfaces.json b/agent/test/fixtures/pvesh/qemu_100_agent_interfaces.json new file mode 100644 index 0000000..322ae09 --- /dev/null +++ b/agent/test/fixtures/pvesh/qemu_100_agent_interfaces.json @@ -0,0 +1,17 @@ +{ + "result": [ + { + "name": "lo", + "ip-addresses": [ + { "ip-address": "127.0.0.1", "ip-address-type": "ipv4" } + ] + }, + { + "name": "eth0", + "ip-addresses": [ + { "ip-address": "192.168.1.10", "ip-address-type": "ipv4" }, + { "ip-address": "fe80::a", "ip-address-type": "ipv6" } + ] + } + ] +} diff --git a/agent/test/fixtures/pvesh/qemu_100_config.json b/agent/test/fixtures/pvesh/qemu_100_config.json new file mode 100644 index 0000000..251d2f7 --- /dev/null +++ b/agent/test/fixtures/pvesh/qemu_100_config.json @@ -0,0 +1,8 @@ +{ + "name": "nginx-proxy", + "cores": 2, + "memory": 2048, + "onboot": 1, + "scsi0": "local-zfs:vm-100-disk-0,size=32G", + "net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0" +} diff --git a/agent/test/proxmox_agent/collectors/vms_test.exs b/agent/test/proxmox_agent/collectors/vms_test.exs new file mode 100644 index 0000000..1dae38f --- /dev/null +++ b/agent/test/proxmox_agent/collectors/vms_test.exs @@ -0,0 +1,76 @@ +defmodule ProxmoxAgent.Collectors.VmsTest do + use ExUnit.Case, async: true + + alias ProxmoxAgent.Collectors.Vms + + @fixtures Path.expand("../../fixtures/pvesh", __DIR__) + + defp read!(name), do: File.read!(Path.join(@fixtures, name)) + + defp fake_runner do + fn + "pvesh", ["get", "/nodes/" <> rest, "--output-format", "json"] -> + cond do + String.ends_with?(rest, "/qemu") -> + {:ok, read!("qemu.json")} + + String.ends_with?(rest, "/lxc") -> + {:ok, read!("lxc.json")} + + String.ends_with?(rest, "/qemu/100/config") -> + {:ok, read!("qemu_100_config.json")} + + String.ends_with?(rest, "/qemu/100/agent/network-get-interfaces") -> + {:ok, read!("qemu_100_agent_interfaces.json")} + + String.ends_with?(rest, "/qemu/101/config") -> + {:ok, ~s({"name":"db-backup","cores":4,"memory":4096})} + + String.ends_with?(rest, "/qemu/101/agent/network-get-interfaces") -> + {:error, {:nonzero_exit, 1, "QEMU guest agent is not running"}} + + String.ends_with?(rest, "/lxc/200/config") -> + {:ok, + ~s({"hostname":"minecraft","cores":4,"memory":4096,"net0":"name=eth0,ip=10.0.0.5/24"})} + end + end + end + + describe "collect_runtime/1" do + test "returns qemu + lxc runtime info" do + sample = Vms.collect_runtime(node: "pve-01", runner: fake_runner()) + + assert length(sample.vms) == 3 + nginx = Enum.find(sample.vms, &(&1.vmid == 100)) + assert nginx.type == "qemu" + assert nginx.name == "nginx-proxy" + assert nginx.status == "running" + assert nginx.cpu_usage == 0.05 + assert nginx.mem_bytes == 536_870_912 + assert nginx.max_mem_bytes == 2_147_483_648 + assert nginx.tags == ["web", "production"] + + mc = Enum.find(sample.vms, &(&1.vmid == 200)) + assert mc.type == "lxc" + end + end + + describe "collect_detail/1" do + test "returns per-VM config + IPs" do + sample = Vms.collect_detail(node: "pve-01", runner: fake_runner()) + + nginx = Enum.find(sample.vms, &(&1.vmid == 100)) + assert nginx.config["cores"] == 2 + assert nginx.config["memory"] == 2048 + assert "192.168.1.10" in nginx.ips + + db = Enum.find(sample.vms, &(&1.vmid == 101)) + assert db.config["cores"] == 4 + assert db.ips == [] + + mc = Enum.find(sample.vms, &(&1.vmid == 200)) + assert mc.config["hostname"] == "minecraft" + assert "10.0.0.5" in mc.ips + end + end +end