towerops/lib/towerops/monitoring/ping.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

109 lines
3.2 KiB
Elixir

defmodule Towerops.Monitoring.Ping do
@moduledoc """
Handles ping operations for device monitoring using the system ping command.
This implementation uses the system `ping` binary which is typically setuid root,
allowing ICMP operations without requiring CAP_NET_RAW or elevated privileges
for the application itself.
"""
@behaviour Towerops.Monitoring.PingBehaviour
require Logger
@doc """
Pings an IP address using the system ping command with default timeout (5 seconds).
Returns {:ok, response_time_ms} on success, {:error, reason} on failure.
Response time is returned as a float with millisecond precision.
"""
@impl true
def ping(ip_address) when is_binary(ip_address) do
ping(ip_address, 5000)
end
@doc """
Pings an IP address using the system ping command with custom timeout.
Returns {:ok, response_time_ms} on success, {:error, reason} on failure.
Response time is returned as a float with millisecond precision.
Note: The system ping command has a minimum timeout of 1 second.
Timeouts less than 1000ms will be rounded up to 1 second.
"""
@impl true
def ping(ip_address, timeout_ms) when is_binary(ip_address) and is_integer(timeout_ms) do
case validate_ip_address(ip_address) do
:ok ->
do_ping(ip_address, timeout_ms)
{:error, _} = error ->
error
end
end
defp do_ping(ip_address, timeout_ms) do
timeout_seconds = max(1, div(timeout_ms, 1000))
# Use -W for timeout on macOS/BSD, -w for Linux
# -c 1 sends a single ping
args =
case :os.type() do
{:unix, :darwin} ->
# macOS: -W is timeout in ms, but we use seconds for consistency
["-c", "1", "-W", to_string(timeout_seconds * 1000), ip_address]
{:unix, _} ->
# Linux: -W is timeout in seconds
["-c", "1", "-W", to_string(timeout_seconds), ip_address]
_ ->
# Fallback
["-c", "1", "-W", to_string(timeout_seconds), ip_address]
end
case System.cmd("ping", args, stderr_to_stdout: true) do
{output, 0} ->
parse_ping_output(output)
{output, _exit_code} ->
Logger.debug("Ping failed for #{ip_address}: #{String.trim(output)}")
{:error, :timeout}
end
rescue
e in ErlangError ->
Logger.error("Ping command failed: #{inspect(e)}")
{:error, :command_not_found}
end
@doc false
def validate_ip_address(""), do: {:error, :invalid_ip}
def validate_ip_address(ip_address) when is_binary(ip_address) do
case :inet.parse_address(String.to_charlist(ip_address)) do
{:ok, _} -> :ok
{:error, _} -> {:error, :invalid_ip}
end
end
@doc false
def parse_ping_output(output) do
cond do
match = Regex.run(~r/round-trip.*=\s*[\d.]+\/([\d.]+)\//, output) ->
[_, avg_ms] = match
{:ok, String.to_float(avg_ms)}
match = Regex.run(~r/rtt.*=\s*[\d.]+\/([\d.]+)\//, output) ->
[_, avg_ms] = match
{:ok, String.to_float(avg_ms)}
match = Regex.run(~r/time[=<]([\d.]+)\s*ms/, output) ->
[_, time_ms] = match
{:ok, String.to_float(time_ms)}
true ->
Logger.warning("Could not parse ping output: #{String.trim(output)}")
{:error, :parse_error}
end
end
end