feat(agent): add Diagnostics.Writer GenServer
This commit is contained in:
parent
c72eed1307
commit
896044cb7f
2 changed files with 178 additions and 0 deletions
95
agent/lib/proxmox_agent/diagnostics/writer.ex
Normal file
95
agent/lib/proxmox_agent/diagnostics/writer.ex
Normal file
|
|
@ -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
|
||||
83
agent/test/proxmox_agent/diagnostics/writer_test.exs
Normal file
83
agent/test/proxmox_agent/diagnostics/writer_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue