From 896044cb7f831911fc803159e5b048d09514459e Mon Sep 17 00:00:00 2001 From: Carsten Date: Wed, 22 Apr 2026 22:19:51 +0200 Subject: [PATCH] feat(agent): add Diagnostics.Writer GenServer --- agent/lib/proxmox_agent/diagnostics/writer.ex | 95 +++++++++++++++++++ .../proxmox_agent/diagnostics/writer_test.exs | 83 ++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 agent/lib/proxmox_agent/diagnostics/writer.ex create mode 100644 agent/test/proxmox_agent/diagnostics/writer_test.exs diff --git a/agent/lib/proxmox_agent/diagnostics/writer.ex b/agent/lib/proxmox_agent/diagnostics/writer.ex new file mode 100644 index 0000000..24a89c7 --- /dev/null +++ b/agent/lib/proxmox_agent/diagnostics/writer.ex @@ -0,0 +1,95 @@ +defmodule ProxmoxAgent.Diagnostics.Writer do + @moduledoc """ + Serializes diagnostic dump writes. Owns two append-only file handles + for commands.log and samples.log under a configured directory. + + Started only when the agent's dump_dir is set. All writes are cast-based + to keep the caller path free of file I/O latency. + """ + + use GenServer + require Logger + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + dir = Keyword.fetch!(opts, :dir) + + with {:ok, commands} <- open(Path.join(dir, "commands.log")), + {:ok, samples} <- open(Path.join(dir, "samples.log")) do + {:ok, %{dir: dir, commands: commands, samples: samples}} + else + {:error, reason} -> + {:stop, {:open_failed, reason}} + end + end + + @impl true + def handle_cast({:command, cmd, args, result, duration_us}, state) do + write(state.commands, format_command(cmd, args, result, duration_us)) + {:noreply, state} + end + + def handle_cast({:sample, kind, payload}, state) do + write(state.samples, format_sample(kind, payload)) + {:noreply, state} + end + + @impl true + def handle_call(:flush, _from, state) do + {:reply, :ok, state} + end + + @impl true + def terminate(_reason, state) do + File.close(state.commands) + File.close(state.samples) + :ok + end + + # --- private ---------------------------------------------------------- + + defp open(path), do: File.open(path, [:append, :utf8]) + + defp write(io, data) do + case IO.write(io, data) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning("diagnostics writer: write failed (#{inspect(reason)})") + :ok + end + end + + defp format_command(cmd, args, result, duration_us) do + header = "===== #{now_iso()} =====\n$ #{cmd} #{Enum.join(args, " ")}\n" + + status = + case result do + {:ok, body} -> + "exit=0 duration=#{ms(duration_us)}ms size=#{byte_size(body)}\n\n#{body}\n\n" + + {:error, {:nonzero_exit, code, body}} -> + "exit=#{code} duration=#{ms(duration_us)}ms size=#{byte_size(body)}\n\n#{body}\n\n" + + {:error, reason} -> + "error=#{inspect(reason)} duration=#{ms(duration_us)}ms\n\n" + end + + header <> status + end + + defp format_sample(kind, payload) do + body = Jason.encode!(payload, pretty: true) + "===== #{now_iso()} kind=#{kind} =====\n#{body}\n\n" + end + + defp now_iso, do: DateTime.utc_now() |> DateTime.to_iso8601() + + defp ms(us) when is_integer(us), do: div(us, 1_000) +end diff --git a/agent/test/proxmox_agent/diagnostics/writer_test.exs b/agent/test/proxmox_agent/diagnostics/writer_test.exs new file mode 100644 index 0000000..9607840 --- /dev/null +++ b/agent/test/proxmox_agent/diagnostics/writer_test.exs @@ -0,0 +1,83 @@ +defmodule ProxmoxAgent.Diagnostics.WriterTest do + use ExUnit.Case, async: false + + alias ProxmoxAgent.Diagnostics.Writer + + setup do + dir = Path.join(System.tmp_dir!(), "writer-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + {:ok, pid} = Writer.start_link(dir: dir) + + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(pid) + File.rm_rf(dir) + end) + + %{dir: dir, pid: pid} + end + + test "handle_cast :command appends a formatted entry to commands.log", %{dir: dir, pid: pid} do + GenServer.cast(pid, {:command, "zpool", ["list", "-j"], {:ok, "hello body"}, 1_234}) + :ok = GenServer.call(pid, :flush) + + body = File.read!(Path.join(dir, "commands.log")) + + assert body =~ "$ zpool list -j" + assert body =~ "exit=0" + assert body =~ "duration=1ms" + assert body =~ "size=10" + assert body =~ "hello body" + assert body =~ "=====\n" + end + + test "handle_cast :command records errors in place of exit", %{dir: dir, pid: pid} do + GenServer.cast(pid, {:command, "zpool", ["list"], {:error, {:enoent, "zpool"}}, 300}) + :ok = GenServer.call(pid, :flush) + + body = File.read!(Path.join(dir, "commands.log")) + assert body =~ "error={:enoent, \"zpool\"}" + assert body =~ "duration=0ms" + refute body =~ "exit=" + end + + test "handle_cast :command formats non-zero exit results", %{dir: dir, pid: pid} do + GenServer.cast(pid, {:command, "/bin/sh", ["-c", "exit 7"], {:error, {:nonzero_exit, 7, "oops"}}, 500}) + :ok = GenServer.call(pid, :flush) + + body = File.read!(Path.join(dir, "commands.log")) + assert body =~ "exit=7" + assert body =~ "size=4" + assert body =~ "oops" + end + + test "handle_cast :sample appends pretty-printed JSON to samples.log", %{dir: dir, pid: pid} do + GenServer.cast(pid, {:sample, "fast", %{"host" => %{"load1" => 0.1}}}) + :ok = GenServer.call(pid, :flush) + + body = File.read!(Path.join(dir, "samples.log")) + assert body =~ "kind=fast" + assert body =~ "\"host\"" + assert body =~ "\"load1\"" + assert body =~ "=====\n" + end + + test "multiple casts serialize and do not interleave", %{dir: dir, pid: pid} do + for i <- 1..50 do + GenServer.cast(pid, {:command, "cmd#{i}", [], {:ok, "body#{i}"}, 1}) + end + + :ok = GenServer.call(pid, :flush) + body = File.read!(Path.join(dir, "commands.log")) + + # Every body1..body50 must appear exactly once, in order. + for i <- 1..50 do + assert body =~ "body#{i}" + end + end + + test "terminate closes handles cleanly", %{pid: pid} do + ref = Process.monitor(pid) + GenServer.stop(pid, :normal) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal}, 1000 + end +end