towerops/lib/towerops/monitoring/executors/ping_executor.ex
Graham McIntire 28e97ff5f0 add service checks REST API, GraphQL API, and ping executor (#52)
- REST CRUD at /api/v1/checks for HTTP, TCP, DNS, ping checks
- GraphQL queries (checks, check) and mutations (create/update/delete)
- ping executor using system ping with macOS/Linux parsing
- check config validation for ping type (requires host)
- API documentation updated with checks resource section

Reviewed-on: graham/towerops-web#52
2026-03-16 18:40:18 -05:00

125 lines
3.7 KiB
Elixir

defmodule Towerops.Monitoring.Executors.PingExecutor do
@moduledoc """
Executes ICMP ping checks using the system `ping` command.
Config format:
%{
"host" => "10.0.0.1",
"count" => 3 # optional, default: 3
}
"""
require Logger
@default_count 3
@default_timeout 5000
@doc """
Executes a ping check.
Returns:
- {:ok, response_time_ms, output} on success
- {:error, reason} on failure
"""
def execute(config, timeout_ms \\ @default_timeout) do
host = Map.fetch!(config, "host")
count = config |> Map.get("count", @default_count) |> max(1) |> min(10)
with :ok <- validate_host(host) do
args = build_args(host, count, timeout_ms)
# Use timeout_ms plus buffer for the system call
system_timeout = timeout_ms + count * 1000 + 2000
case System.cmd("ping", args, stderr_to_stdout: true, timeout: system_timeout) do
{output, 0} ->
parse_output(output)
{output, _exit_code} ->
# Non-zero exit — try to parse anyway (partial loss still has stats)
case parse_output(output) do
{:ok, _, _} = success -> success
{:error, _} = error -> error
end
end
end
rescue
e ->
Logger.error("Ping check exception: #{inspect(e)}")
{:error, "Exception: #{Exception.message(e)}"}
catch
:exit, {:timeout, _} ->
{:error, "Ping command timed out"}
end
@doc """
Parses ping command output to extract RTT and packet loss.
"""
def parse_output(output) when is_binary(output) and byte_size(output) > 0 do
with {:ok, loss_pct, packets_info} <- parse_packet_loss(output),
{:ok, avg_ms} <- parse_rtt(output) do
if loss_pct >= 100.0 do
{:error, "100% packet loss"}
else
summary = "#{packets_info}, #{format_loss(loss_pct)} loss, avg #{avg_ms}ms"
{:ok, avg_ms, summary}
end
end
end
def parse_output(_), do: {:error, "No ping output"}
defp validate_host(host) do
# Only allow valid hostnames and IP addresses — no shell metacharacters
if Regex.match?(~r/^[a-zA-Z0-9\.\-\:]+$/, host) do
:ok
else
{:error, "Invalid host: contains disallowed characters"}
end
end
defp build_args(host, count, timeout_ms) do
count_str = Integer.to_string(count)
case :os.type() do
{:unix, :darwin} ->
# macOS: -W is in milliseconds
["-c", count_str, "-W", Integer.to_string(timeout_ms), host]
{:unix, _} ->
# Linux: -w is in seconds (total deadline)
deadline = max(div(timeout_ms, 1000), count + 1)
["-c", count_str, "-w", Integer.to_string(deadline), host]
end
end
defp parse_packet_loss(output) do
# Match patterns like "3 packets transmitted, 3 packets received, 0.0% packet loss"
# or "3 packets transmitted, 3 received, 0% packet loss"
case Regex.run(~r/(\d+) packets? transmitted, (\d+) (?:packets? )?received, ([\d.]+)% packet loss/, output) do
[_, transmitted, _received, loss_str] ->
{loss_pct, _} = Float.parse(loss_str)
{:ok, loss_pct, "#{transmitted} packets"}
nil ->
{:error, "Could not parse packet loss from ping output"}
end
end
defp parse_rtt(output) do
# macOS: "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms"
# Linux: "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms"
case Regex.run(~r/(?:round-trip|rtt) min\/avg\/max\/(?:std|m)dev = [\d.]+\/([\d.]+)\//, output) do
[_, avg_str] ->
{avg_ms, _} = Float.parse(avg_str)
{:ok, avg_ms}
nil ->
# No RTT stats (100% loss case)
{:error, "100% packet loss"}
end
end
defp format_loss(loss_pct) when loss_pct == 0.0, do: "0%"
defp format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%"
end