wire up service checks (HTTP/TCP/DNS) to remote agents (#50)

send check_jobs protobuf to agents during job dispatch cycle, handle
check_result messages back with state transitions and alert
creation/resolution. auto-assign checks to device's effective agent
token on creation, broadcast check changes via PubSub so connected
agents get immediate updates.

add edit/delete support for service checks on device show page with
form pre-population from existing check config. auto-fill TCP host
from device IP address.

includes validate_check_result/1 for protobuf validation and
list_checks_for_agent/2 for querying agent-assigned service checks.

also fixes pre-existing broken device_type_icon reference in device
index template.

Reviewed-on: graham/towerops-web#50
This commit is contained in:
Graham McIntire 2026-03-16 17:25:02 -05:00 committed by graham
parent 06c5d14455
commit c6913685ee
9 changed files with 715 additions and 47 deletions

View file

@ -30,6 +30,7 @@ defmodule Towerops.Agent.Validator do
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.CheckResult
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.InterfaceStat
alias Towerops.Agent.LldpNeighbor
@ -199,6 +200,22 @@ defmodule Towerops.Agent.Validator do
end
end
@doc """
Validate CheckResult message from binary protobuf.
Decodes the binary and validates check_id (UUID), status (0-3), response_time, and output length.
"""
@spec validate_check_result(binary()) :: validation_result(CheckResult.t())
def validate_check_result(binary) when is_binary(binary) do
with {:ok, result} <- safe_decode(CheckResult, binary),
:ok <- validate_check_id(result.check_id),
:ok <- validate_check_status(result.status),
:ok <- validate_check_response_time(result.response_time_ms),
:ok <- validate_string(result.output, "output") do
{:ok, result}
end
end
## Private Validation Functions
# Safe decode with error handling
@ -613,4 +630,30 @@ defmodule Towerops.Agent.Validator do
end
end)
end
# Validate check ID (UUID format, same as device_id)
defp validate_check_id(id) when is_binary(id) do
if String.match?(id, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) do
:ok
else
{:error, {:invalid_check_id, "Check ID must be valid UUID"}}
end
end
defp validate_check_id(_), do: {:error, {:invalid_check_id, "Check ID must be a string"}}
# Validate check status (0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN)
defp validate_check_status(status) when is_integer(status) and status >= 0 and status <= 3 do
:ok
end
defp validate_check_status(_), do: {:error, {:invalid_check_status, "Status must be 0-3"}}
# Validate check response time (non-negative, within max)
defp validate_check_response_time(ms) when is_float(ms) and ms >= 0.0 and ms <= @max_response_time_ms do
:ok
end
defp validate_check_response_time(_),
do: {:error, {:invalid_response_time, "Response time must be 0 to #{@max_response_time_ms}ms"}}
end

View file

@ -48,6 +48,23 @@ defmodule Towerops.Monitoring do
from c in query, where: c.enabled == ^enabled
end
@doc """
Returns the list of service checks assigned to a specific agent.
Only returns http/tcp/dns checks (service checks that agents can execute).
"""
def list_checks_for_agent(agent_token_id, opts \\ []) do
query =
from c in Check,
where: c.agent_token_id == ^agent_token_id,
where: c.check_type in ["http", "tcp", "dns"],
order_by: [asc: c.name]
query
|> maybe_filter_by_enabled(opts[:enabled])
|> Repo.all()
end
@doc """
Gets a single check.
"""
@ -69,25 +86,58 @@ defmodule Towerops.Monitoring do
Creates a check.
"""
def create_check(attrs \\ %{}) do
%Check{}
|> Check.changeset(attrs)
|> Repo.insert()
result =
%Check{}
|> Check.changeset(attrs)
|> Repo.insert()
case result do
{:ok, check} -> broadcast_check_change(check)
_ -> :ok
end
result
end
@doc """
Updates a check.
"""
def update_check(%Check{} = check, attrs) do
check
|> Check.changeset(attrs)
|> Repo.update()
result =
check
|> Check.changeset(attrs)
|> Repo.update()
case result do
{:ok, updated} -> broadcast_check_change(updated)
_ -> :ok
end
result
end
@doc """
Deletes a check.
"""
def delete_check(%Check{} = check) do
Repo.delete(check)
result = Repo.delete(check)
case result do
{:ok, deleted} -> broadcast_check_change(deleted)
_ -> :ok
end
result
end
defp broadcast_check_change(%Check{agent_token_id: nil}), do: :ok
defp broadcast_check_change(%Check{agent_token_id: agent_token_id}) do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"checks:agent:#{agent_token_id}",
:checks_changed
)
end
@doc """

View file

@ -23,12 +23,18 @@ defmodule ToweropsWeb.AgentChannel do
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.Check, as: CheckProto
alias Towerops.Agent.CheckList
alias Towerops.Agent.CheckResult, as: CheckResultProto
alias Towerops.Agent.DnsCheckConfig
alias Towerops.Agent.HttpCheckConfig
alias Towerops.Agent.LldpTopologyResult
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.TcpCheckConfig
alias Towerops.Agent.Validator
alias Towerops.Agents
alias Towerops.Alerts
@ -100,6 +106,9 @@ defmodule ToweropsWeb.AgentChannel do
# Subscribe to live poll requests for this agent
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:live_poll")
# Subscribe to check changes for this agent (service checks added/removed/updated)
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "checks:agent:#{agent_token.id}")
# Subscribe to token lifecycle events (disable/delete triggers disconnect)
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
@ -159,6 +168,7 @@ defmodule ToweropsWeb.AgentChannel do
cancel_poll_timer(socket)
updated_socket = build_and_push_jobs(socket)
build_and_push_check_jobs(updated_socket)
# Schedule next job dispatch cycle
interval_ms = poll_interval_ms()
@ -215,6 +225,12 @@ defmodule ToweropsWeb.AgentChannel do
end
end
# Handle check changes — push updated check list to agent
def handle_info(:checks_changed, socket) do
build_and_push_check_jobs(socket)
{:noreply, socket}
end
# Handle token disabled/deleted — disconnect the agent
def handle_info(:token_disabled, socket) do
Logger.warning("Agent token disabled, disconnecting",
@ -599,6 +615,51 @@ defmodule ToweropsWeb.AgentChannel do
end
end
@spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) ::
{:noreply, socket()}
def handle_in("check_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- Validator.validate_check_result(binary) do
maybe_debug_log(socket, "Received check result from agent",
check_id: result.check_id,
status: result.status,
response_time_ms: result.response_time_ms,
binary_size: byte_size(binary_b64)
)
case store_check_result(result, socket) do
:ok ->
:ok
{:error, reason} ->
Logger.error("Failed to store check result",
check_id: result.check_id,
reason: inspect(reason)
)
end
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid check result from agent: #{type} - #{message}",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message,
binary_size: byte_size(binary_b64)
)
{:noreply, socket}
{:error, :base64_decode_failed} ->
Logger.error("Failed to decode check result (invalid base64)",
agent_token_id: socket.assigns.agent_token_id,
binary_size: byte_size(binary_b64)
)
{:noreply, socket}
end
end
@spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) ::
{:noreply, socket()}
def handle_in("lldp_topology_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
@ -811,6 +872,85 @@ defmodule ToweropsWeb.AgentChannel do
socket
end
# Builds service check jobs for the agent and pushes them via "check_jobs" event.
defp build_and_push_check_jobs(socket) do
agent_token_id = socket.assigns.agent_token_id
checks = Monitoring.list_checks_for_agent(agent_token_id, enabled: true)
if checks != [] do
proto_checks = Enum.map(checks, &build_check_protobuf/1)
check_list = %CheckList{checks: proto_checks}
binary = CheckList.encode(check_list)
Logger.info("Sending #{length(proto_checks)} check jobs to agent",
agent_token_id: agent_token_id,
check_ids: inspect(Enum.map(checks, & &1.id))
)
push(socket, "check_jobs", %{binary: Base.encode64(binary)})
end
end
defp build_check_protobuf(check) do
config = check.config || %{}
base_fields = [
id: check.id,
check_type: check.check_type,
interval_seconds: check.interval_seconds,
timeout_ms: check.timeout_ms
]
case check.check_type do
"http" ->
struct!(
CheckProto,
base_fields ++
[
http: %HttpCheckConfig{
url: config["url"] || "",
method: config["method"] || "GET",
expected_status: config["expected_status"] || 200,
verify_ssl: config["verify_ssl"] != false,
regex: config["regex"] || "",
follow_redirects: config["follow_redirects"] != false
}
]
)
"tcp" ->
struct!(
CheckProto,
base_fields ++
[
tcp: %TcpCheckConfig{
host: config["host"] || "",
port: config["port"] || 0,
send: config["send"] || "",
expect: config["expect"] || ""
}
]
)
"dns" ->
struct!(
CheckProto,
base_fields ++
[
dns: %DnsCheckConfig{
hostname: config["hostname"] || "",
server: config["server"] || "",
record_type: config["record_type"] || "A",
expected: config["expected"] || ""
}
]
)
_ ->
struct!(CheckProto, base_fields)
end
end
@spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()]
defp build_jobs_for_agent(agent_token_id) do
agent_token_id
@ -1400,6 +1540,88 @@ defmodule ToweropsWeb.AgentChannel do
end
end
defp store_check_result(%CheckResultProto{} = result, socket) do
organization_id = socket.assigns.organization_id
agent_token_id = socket.assigns.agent_token_id
case Monitoring.get_check(result.check_id) do
nil ->
Logger.error("Check not found for check result: #{result.check_id}")
{:error, :check_not_found}
check ->
if check.organization_id == organization_id do
now = DateTime.truncate(DateTime.utc_now(), :second)
# Record check result
case Monitoring.create_check_result(%{
check_id: check.id,
organization_id: organization_id,
status: result.status,
output: result.output,
response_time_ms: result.response_time_ms,
checked_at: now,
agent_token_id: agent_token_id
}) do
{:ok, _} ->
# Update check state (soft/hard transitions)
old_state = check.current_state
{:ok, updated_check} = Monitoring.update_check_state(check, result.status, result.output)
# Handle alert creation/resolution on hard state changes
handle_check_state_change(old_state, updated_check, result.output)
# Broadcast for LiveView refresh
if check.device_id do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{check.device_id}",
{:monitoring_check_updated, check.device_id}
)
end
:ok
{:error, changeset} ->
Logger.error("Failed to store check result",
check_id: check.id,
errors: inspect(changeset.errors)
)
{:error, :storage_failed}
end
else
Logger.error("Check #{result.check_id} not in agent's organization")
{:error, :wrong_organization}
end
end
end
defp handle_check_state_change(old_state, check, output) do
# State changed from OK to problem and is now hard state
if old_state == 0 and check.current_state in [1, 2] and check.current_state_type == "hard" do
if !Alerts.has_active_check_alert?(check.id) do
severity = if check.current_state == 1, do: 1, else: 2
Alerts.create_alert(%{
check_id: check.id,
device_id: check.device_id,
organization_id: check.organization_id,
alert_type: "check_#{check.check_type}",
severity: severity,
message: "Check '#{check.name}' is #{Monitoring.Check.state_label(check.current_state)}",
output: output,
triggered_at: DateTime.utc_now()
})
end
end
# State changed from problem to OK
if old_state in [1, 2] and check.current_state == 0 do
Alerts.resolve_check_alerts(check.id)
end
end
defp store_lldp_neighbors(%LldpTopologyResult{} = result) do
now = DateTime.utc_now()
device_id = result.device_id

View file

@ -2,6 +2,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
@moduledoc false
use ToweropsWeb, :live_component
alias Towerops.Agents
alias Towerops.Monitoring
@impl true
@ -24,7 +25,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="flex items-start justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Add Service Check
{if @action == :edit, do: "Edit Service Check", else: "Add Service Check"}
</h3>
<button
type="button"
@ -52,6 +53,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
]}
phx-change="change_type"
phx-target={@myself}
disabled={@action == :edit}
/>
</div>
@ -96,8 +98,11 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
</div>
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
<.button type="submit" phx-disable-with="Creating...">
Create Check
<.button
type="submit"
phx-disable-with={if @action == :edit, do: "Saving...", else: "Creating..."}
>
{if @action == :edit, do: "Save Changes", else: "Create Check"}
</.button>
<.button
type="button"
@ -251,27 +256,80 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
@impl true
def update(assigns, socket) do
changeset = Monitoring.change_check(%Monitoring.Check{})
action = assigns[:action] || :new
check = assigns[:check]
{selected_type, changeset} =
if action == :edit && check do
# Editing: populate from existing check
type = check.check_type
config = check.config || %{}
form_attrs =
config
|> config_to_form_fields(type)
|> Map.merge(%{
"name" => check.name,
"check_type" => type,
"interval_seconds" => check.interval_seconds,
"timeout_ms" => check.timeout_ms
})
{type, Monitoring.change_check(check, form_attrs)}
else
# Creating: start with defaults
{"http", Monitoring.change_check(%Monitoring.Check{})}
end
{:ok,
socket
|> assign(assigns)
|> assign(:selected_type, "http")
|> assign(:action, action)
|> assign(:selected_type, selected_type)
|> assign(:form, to_form(changeset))
|> assign_default_config("http")}
|> assign_default_config(selected_type)}
end
@impl true
def handle_event("change_type", %{"check" => %{"check_type" => type}}, socket) do
{:noreply, socket |> assign(:selected_type, type) |> assign_default_config(type)}
socket =
socket
|> assign(:selected_type, type)
|> assign_default_config(type)
# Auto-fill TCP host from device IP
socket =
if type == "tcp" && socket.assigns[:device] do
device = socket.assigns.device
if device.ip_address && device.ip_address != "" do
ip_str = to_string(device.ip_address)
form = socket.assigns.form
params = form.params || %{}
params = Map.put(params, "host", ip_str)
changeset = Monitoring.change_check(%Monitoring.Check{}, params)
assign(socket, :form, to_form(changeset))
else
socket
end
else
socket
end
{:noreply, socket}
end
@impl true
def handle_event("validate", %{"check" => check_params}, socket) do
check_params = merge_config_params(check_params, socket.assigns.selected_type)
check =
if socket.assigns.action == :edit,
do: socket.assigns.check,
else: %Monitoring.Check{}
changeset =
%Monitoring.Check{}
check
|> Monitoring.change_check(check_params)
|> Map.put(:action, :validate)
@ -282,22 +340,10 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
def handle_event("save", %{"check" => check_params}, socket) do
check_params = merge_config_params(check_params, socket.assigns.selected_type)
check_params =
check_params
|> Map.put("organization_id", socket.assigns.device.organization_id)
|> Map.put("device_id", socket.assigns.device.id)
|> Map.put("source_type", "manual")
case Monitoring.create_check(check_params) do
{:ok, check} ->
# Schedule first execution
Monitoring.schedule_check(check)
notify_parent({:check_created, check})
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
if socket.assigns.action == :edit do
save_edit(socket, check_params)
else
save_new(socket, check_params)
end
end
@ -307,6 +353,72 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
{:noreply, socket}
end
defp save_new(socket, check_params) do
device = socket.assigns.device
effective_agent_token_id = Agents.get_effective_agent_token(device)
check_params =
check_params
|> Map.put("organization_id", device.organization_id)
|> Map.put("device_id", device.id)
|> Map.put("source_type", "manual")
|> Map.put("agent_token_id", effective_agent_token_id)
case Monitoring.create_check(check_params) do
{:ok, check} ->
Monitoring.schedule_check(check)
notify_parent({:check_created, check})
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_edit(socket, check_params) do
check = socket.assigns.check
case Monitoring.update_check(check, check_params) do
{:ok, updated_check} ->
notify_parent({:check_updated, updated_check})
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp config_to_form_fields(config, "http") do
%{
"url" => config["url"] || "",
"method" => config["method"] || "GET",
"expected_status" => to_string(config["expected_status"] || 200),
"verify_ssl" => to_string(config["verify_ssl"] != false),
"follow_redirects" => to_string(config["follow_redirects"] != false),
"content_match" => config["regex"] || ""
}
end
defp config_to_form_fields(config, "tcp") do
%{
"host" => config["host"] || "",
"port" => to_string(config["port"] || ""),
"send_string" => config["send"] || "",
"expect_string" => config["expect"] || ""
}
end
defp config_to_form_fields(config, "dns") do
%{
"hostname" => config["hostname"] || "",
"record_type" => config["record_type"] || "A",
"dns_server" => config["server"] || "",
"expected_result" => config["expected"] || ""
}
end
defp config_to_form_fields(_config, _type), do: %{}
defp assign_default_config(socket, type) do
config =
case type do
@ -320,7 +432,9 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
}
"tcp" ->
%{"host" => "", "port" => ""}
device = socket.assigns[:device]
host = if device && device.ip_address, do: to_string(device.ip_address), else: ""
%{"host" => host, "port" => ""}
"dns" ->
%{"hostname" => "", "record_type" => "A"}

View file

@ -587,7 +587,7 @@
<:col :let={discovered} label={t("Type")}>
<div class="flex items-center gap-2">
<.icon
name={device_type_icon(discovered.device_type)}
name="hero-server"
class="h-4 w-4 text-gray-500 dark:text-gray-400"
/>
<span class="text-sm text-gray-900 dark:text-white">

View file

@ -57,7 +57,10 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :show_check_form, false)}
{:ok,
socket
|> assign(:show_check_form, false)
|> assign(:edit_check, nil)}
end
@impl true
@ -235,13 +238,27 @@ defmodule ToweropsWeb.DeviceLive.Show do
{:noreply,
socket
|> assign(:show_check_form, false)
|> assign(:edit_check, nil)
|> put_flash(:info, t("Check created successfully"))
|> load_equipment_data(socket.assigns.device.id)}
end
@impl true
def handle_info({FormComponent, {:check_updated, _check}}, socket) do
{:noreply,
socket
|> assign(:show_check_form, false)
|> assign(:edit_check, nil)
|> put_flash(:info, t("Check updated successfully"))
|> load_equipment_data(socket.assigns.device.id)}
end
@impl true
def handle_info({FormComponent, :close}, socket) do
{:noreply, assign(socket, :show_check_form, false)}
{:noreply,
socket
|> assign(:show_check_form, false)
|> assign(:edit_check, nil)}
end
# Private functions
@ -1420,7 +1437,44 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
def handle_event("add_check", _params, socket) do
{:noreply, assign(socket, :show_check_form, true)}
{:noreply,
socket
|> assign(:show_check_form, true)
|> assign(:edit_check, nil)}
end
@impl true
def handle_event("edit_check", %{"id" => check_id}, socket) do
case Monitoring.get_check(check_id) do
nil ->
{:noreply, put_flash(socket, :error, t("Check not found"))}
check ->
{:noreply,
socket
|> assign(:show_check_form, true)
|> assign(:edit_check, check)}
end
end
@impl true
def handle_event("delete_check", %{"id" => check_id}, socket) do
case Monitoring.get_check(check_id) do
nil ->
{:noreply, put_flash(socket, :error, t("Check not found"))}
check ->
case Monitoring.delete_check(check) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Check deleted"))
|> assign_checks_data(socket.assigns.device.id)}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to delete check"))}
end
end
end
@impl true

View file

@ -2720,14 +2720,35 @@
<% end %>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
<.link
navigate={
~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h"
}
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
>
{t("Graph")}
</.link>
<div class="flex items-center justify-end gap-3">
<.link
navigate={
~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h"
}
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
>
{t("Graph")}
</.link>
<%= if check.check_type in ["http", "tcp", "dns"] do %>
<button
type="button"
phx-click="edit_check"
phx-value-id={check.id}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<.icon name="hero-pencil-square" class="h-4 w-4" />
</button>
<button
type="button"
phx-click="delete_check"
phx-value-id={check.id}
data-confirm="Are you sure you want to delete this check?"
class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
<% end %>
</div>
</td>
</tr>
<% end %>
@ -3285,9 +3306,9 @@
<%= if @show_check_form do %>
<.live_component
module={FormComponent}
id={:new}
title="Add Service Check"
action={:new}
id={if @edit_check, do: @edit_check.id, else: :new}
action={if @edit_check, do: :edit, else: :new}
check={@edit_check}
device={@device}
/>
<% end %>

View file

@ -3,6 +3,7 @@ defmodule Towerops.Agent.ValidatorTest do
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.CheckResult
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.InterfaceStat
alias Towerops.Agent.Metric
@ -1516,4 +1517,93 @@ defmodule Towerops.Agent.ValidatorTest do
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
end
end
describe "validate_check_result/1" do
test "validates valid check result" do
result = %CheckResult{
check_id: "550e8400-e29b-41d4-a716-446655440000",
status: 0,
output: "HTTP 200 OK",
response_time_ms: 42.5,
timestamp: 1_700_000_000
}
binary = CheckResult.encode(result)
assert {:ok, validated} = Validator.validate_check_result(binary)
assert validated.check_id == "550e8400-e29b-41d4-a716-446655440000"
assert validated.status == 0
assert validated.output == "HTTP 200 OK"
end
test "validates all valid statuses (0-3)" do
for status <- [0, 1, 2, 3] do
result = %CheckResult{
check_id: "550e8400-e29b-41d4-a716-446655440000",
status: status,
output: "",
response_time_ms: 0.0,
timestamp: 1_700_000_000
}
binary = CheckResult.encode(result)
assert {:ok, _} = Validator.validate_check_result(binary)
end
end
test "rejects invalid check_id format" do
result = %CheckResult{
check_id: "not-a-uuid",
status: 0,
output: "",
response_time_ms: 0.0,
timestamp: 1_700_000_000
}
binary = CheckResult.encode(result)
assert {:error, {:invalid_check_id, _}} = Validator.validate_check_result(binary)
end
test "rejects status > 3" do
result = %CheckResult{
check_id: "550e8400-e29b-41d4-a716-446655440000",
status: 4,
output: "",
response_time_ms: 0.0,
timestamp: 1_700_000_000
}
binary = CheckResult.encode(result)
assert {:error, {:invalid_check_status, _}} = Validator.validate_check_result(binary)
end
test "rejects negative response time" do
result = %CheckResult{
check_id: "550e8400-e29b-41d4-a716-446655440000",
status: 0,
output: "",
response_time_ms: -1.0,
timestamp: 1_700_000_000
}
binary = CheckResult.encode(result)
assert {:error, {:invalid_response_time, _}} = Validator.validate_check_result(binary)
end
test "rejects excessive response time" do
result = %CheckResult{
check_id: "550e8400-e29b-41d4-a716-446655440000",
status: 0,
output: "",
response_time_ms: 99_999.0,
timestamp: 1_700_000_000
}
binary = CheckResult.encode(result)
assert {:error, {:invalid_response_time, _}} = Validator.validate_check_result(binary)
end
test "rejects invalid protobuf binary" do
assert {:error, {:decode_error, _}} = Validator.validate_check_result("garbage")
end
end
end

View file

@ -2,6 +2,7 @@ defmodule Towerops.MonitoringTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
import Towerops.AgentsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Monitoring
@ -103,6 +104,79 @@ defmodule Towerops.MonitoringTest do
end
end
describe "list_checks_for_agent/2" do
test "returns service checks assigned to an agent", %{organization: org} do
{:ok, agent_token, _token_string} = agent_token_fixture(org.id)
{:ok, http_check} =
Monitoring.create_check(
valid_check_attrs(org.id, %{name: "HTTP Check", check_type: "http", agent_token_id: agent_token.id})
)
{:ok, tcp_check} =
Monitoring.create_check(
valid_check_attrs(org.id, %{
name: "TCP Check",
check_type: "tcp",
config: %{"host" => "10.0.0.1", "port" => 80},
agent_token_id: agent_token.id
})
)
checks = Monitoring.list_checks_for_agent(agent_token.id)
ids = Enum.map(checks, & &1.id)
assert http_check.id in ids
assert tcp_check.id in ids
end
test "excludes non-service check types", %{organization: org} do
{:ok, agent_token, _token_string} = agent_token_fixture(org.id)
{:ok, _snmp} =
Monitoring.create_check(
valid_check_attrs(org.id, %{
name: "SNMP Sensor",
check_type: "snmp_sensor",
config: %{"oid" => "1.3.6.1"},
agent_token_id: agent_token.id
})
)
{:ok, _http} =
Monitoring.create_check(valid_check_attrs(org.id, %{name: "HTTP Check", agent_token_id: agent_token.id}))
checks = Monitoring.list_checks_for_agent(agent_token.id)
assert length(checks) == 1
assert hd(checks).check_type == "http"
end
test "filters by enabled", %{organization: org} do
{:ok, agent_token, _token_string} = agent_token_fixture(org.id)
{:ok, _enabled} =
Monitoring.create_check(valid_check_attrs(org.id, %{name: "Enabled", agent_token_id: agent_token.id}))
{:ok, _disabled} =
Monitoring.create_check(
valid_check_attrs(org.id, %{name: "Disabled", enabled: false, agent_token_id: agent_token.id})
)
checks = Monitoring.list_checks_for_agent(agent_token.id, enabled: true)
assert length(checks) == 1
assert hd(checks).name == "Enabled"
end
test "does not return checks for other agents", %{organization: org} do
{:ok, agent1, _} = agent_token_fixture(org.id)
{:ok, agent2, _} = agent_token_fixture(org.id)
{:ok, _check} =
Monitoring.create_check(valid_check_attrs(org.id, %{name: "Agent 1 Check", agent_token_id: agent1.id}))
assert Monitoring.list_checks_for_agent(agent2.id) == []
end
end
describe "get_check/1 and get_check!/1" do
test "returns the check", %{organization: org} do
{:ok, check} = Monitoring.create_check(valid_check_attrs(org.id))