towerops/lib/towerops/monitoring/check.ex
Graham McIntire 272d4bcdc9 fix: check state never persisted due to missing cast fields (#73)
Check.changeset/2 did not include state fields (current_state,
current_state_type, current_check_attempt, last_check_at, etc.) in
its cast list. Ecto silently dropped all state updates, so
update_check_state succeeded but wrote nothing to the database.

Added Check.state_changeset/2 for internal state machine updates,
keeping the regular changeset safe from user input. Updated
Monitoring.update_check_state/3 to use it directly.

Reviewed-on: graham/towerops-web#73
2026-03-18 14:54:52 -05:00

224 lines
6.6 KiB
Elixir

defmodule Towerops.Monitoring.Check do
@moduledoc """
Generic check schema following Icinga2's Checkable pattern.
Supports multiple check types (http, tcp, dns, passive, ping) with
type-specific configuration stored in JSONB `config` field.
Following Icinga2's design:
- Generic check fields (interval, retry, max_attempts, state tracking)
- Check-specific config in `config` map (like Icinga2's vars)
- Flapping detection
- Soft/hard state transitions
- Optional device association (can be standalone)
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Agents.AgentToken
alias Towerops.Devices.Device
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@check_types ~w(http tcp dns ssl passive ping snmp_sensor snmp_interface snmp_processor snmp_storage)
schema "checks" do
field :name, :string
field :check_type, :string
field :description, :string
# Source tracking for auto-discovered checks
field :source_type, :string
field :source_id, :binary_id
# Generic check settings (from Icinga2's Checkable)
field :interval_seconds, :integer, default: 60
field :retry_interval_seconds, :integer, default: 30
field :max_check_attempts, :integer, default: 3
field :timeout_ms, :integer, default: 5000
field :enabled, :boolean, default: true
# Check-specific configuration (JSONB, like Icinga2's vars)
field :config, :map, default: %{}
# State tracking (soft/hard states like Icinga2)
field :current_state, :integer, default: 3
field :current_state_type, :string, default: "soft"
field :current_check_attempt, :integer, default: 1
field :last_check_at, :utc_datetime
field :last_state_change_at, :utc_datetime
field :last_hard_state_change_at, :utc_datetime
# Flapping detection
field :enable_flapping, :boolean, default: false
field :flapping_threshold_low, :float, default: 25.0
field :flapping_threshold_high, :float, default: 30.0
field :is_flapping, :boolean, default: false
field :flapping_state_changes, :integer, default: 0
field :flapping_window_start_at, :utc_datetime
# Passive checks
field :enable_passive_checks, :boolean, default: false
field :freshness_threshold_seconds, :integer
field :check_key, :string
belongs_to :organization, Organization
belongs_to :device, Device
belongs_to :agent_token, AgentToken
timestamps(type: :utc_datetime)
end
def changeset(check, attrs) do
check
|> cast(attrs, [
:name,
:check_type,
:description,
:source_type,
:source_id,
:interval_seconds,
:retry_interval_seconds,
:max_check_attempts,
:timeout_ms,
:enabled,
:config,
:enable_flapping,
:flapping_threshold_low,
:flapping_threshold_high,
:enable_passive_checks,
:freshness_threshold_seconds,
:check_key,
:organization_id,
:device_id,
:agent_token_id
])
|> validate_required([:name, :check_type, :organization_id, :config])
|> validate_inclusion(:check_type, @check_types)
|> validate_source_type()
|> validate_number(:interval_seconds, greater_than: 0)
|> validate_number(:retry_interval_seconds, greater_than: 0)
|> validate_number(:max_check_attempts, greater_than: 0)
|> validate_number(:timeout_ms, greater_than: 0)
|> unique_constraint(:check_key)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:agent_token_id)
|> validate_config_by_type()
|> generate_check_key_if_passive()
end
defp validate_config_by_type(changeset) do
check_type = get_field(changeset, :check_type)
config = get_field(changeset, :config) || %{}
case check_type do
"http" -> validate_http_config(changeset, config)
"tcp" -> validate_tcp_config(changeset, config)
"dns" -> validate_dns_config(changeset, config)
"ssl" -> validate_ssl_config(changeset, config)
"ping" -> validate_ping_config(changeset, config)
_ -> changeset
end
end
defp validate_http_config(changeset, config) do
if Map.has_key?(config, "url") do
changeset
else
add_error(changeset, :config, "HTTP check requires 'url' in config")
end
end
defp validate_tcp_config(changeset, config) do
cond do
not Map.has_key?(config, "host") ->
add_error(changeset, :config, "TCP check requires 'host' in config")
not Map.has_key?(config, "port") ->
add_error(changeset, :config, "TCP check requires 'port' in config")
true ->
changeset
end
end
defp validate_dns_config(changeset, config) do
if Map.has_key?(config, "hostname") do
changeset
else
add_error(changeset, :config, "DNS check requires 'hostname' in config")
end
end
defp validate_ssl_config(changeset, config) do
if Map.has_key?(config, "host") do
changeset
else
add_error(changeset, :config, "SSL check requires 'host' in config")
end
end
defp validate_ping_config(changeset, config) do
if Map.has_key?(config, "host") do
changeset
else
add_error(changeset, :config, "Ping check requires 'host' in config")
end
end
defp validate_source_type(changeset) do
source_type = get_field(changeset, :source_type)
if source_type && source_type not in ["auto_discovery", "manual"] do
add_error(changeset, :source_type, "must be auto_discovery or manual")
else
changeset
end
end
defp generate_check_key_if_passive(changeset) do
check_type = get_field(changeset, :check_type)
check_key = get_field(changeset, :check_key)
if check_type == "passive" and is_nil(check_key) do
put_change(changeset, :check_key, generate_random_key())
else
changeset
end
end
defp generate_random_key do
32
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
@doc """
Changeset for internal state updates (soft/hard state transitions).
Allows updating check execution state fields that are not permitted
through the regular changeset (which protects them from user input).
"""
def state_changeset(check, attrs) do
cast(check, attrs, [
:current_state,
:current_state_type,
:current_check_attempt,
:last_check_at,
:last_state_change_at,
:last_hard_state_change_at,
:is_flapping,
:flapping_state_changes,
:flapping_window_start_at
])
end
def state_label(0), do: "OK"
def state_label(1), do: "WARNING"
def state_label(2), do: "CRITICAL"
def state_label(3), do: "UNKNOWN"
end