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