towerops/lib/towerops/agents.ex
Graham McIntire a2d96f8e6e
Implement hierarchical agent assignment for SNMP polling
Add three-level agent assignment hierarchy (Equipment > Site > Organization)
allowing flexible agent deployment strategies. Agents now receive only equipment
assigned to them through direct assignment or inheritance from site/organization
defaults.

Key changes:
- Add agent_token_id to Sites table with migration
- Implement get_effective_agent_token/1 for hierarchical resolution
- Add list_agent_polling_targets/1 to return polling targets per agent
- Update API config endpoint to use hierarchical polling targets
- Add agent assignment UI to equipment, site, and organization forms
- Show agent source (direct/site/org/none) in equipment form
- Add equipment count column to agent list
- Update terminology from "poll from server" to "cloud polling"

Tests:
- Add 8 comprehensive tests for list_agent_polling_targets/1
- Add end-to-end test for hierarchical config endpoint
- All 775 tests passing
2026-01-14 08:38:50 -06:00

435 lines
12 KiB
Elixir

defmodule Towerops.Agents do
@moduledoc """
The Agents context for managing remote SNMP polling agents.
This context provides functions for creating and managing agent authentication tokens,
assigning equipment to agents, and verifying agent credentials.
"""
import Ecto.Query, warn: false
alias Towerops.Agents.AgentAssignment
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
alias Towerops.Repo
## Token management
@doc """
Creates a new agent token for an organization.
Returns {:ok, agent_token, token_string} where token_string is the plain token
that should be shown to the user ONCE and never stored.
## Examples
iex> create_agent_token(org_id, "Datacenter Agent")
{:ok, %AgentToken{}, "abc123..."}
iex> create_agent_token(nil, "")
{:error, %Ecto.Changeset{}}
"""
def create_agent_token(organization_id, name) do
{token, changeset} = AgentToken.build_token(organization_id, name)
case Repo.insert(changeset) do
{:ok, agent_token} -> {:ok, agent_token, token}
{:error, changeset} -> {:error, changeset}
end
end
@doc """
Lists all agent tokens for an organization.
Returns a list of agent tokens ordered by creation date (newest first).
## Examples
iex> list_organization_agent_tokens(org_id)
[%AgentToken{}, ...]
"""
def list_organization_agent_tokens(organization_id) do
AgentToken
|> where([t], t.organization_id == ^organization_id)
|> order_by([t], desc: t.inserted_at)
|> Repo.all()
end
@doc """
Counts the number of equipment directly assigned to an agent.
This only counts equipment with explicit assignments, not equipment
that inherit the agent from site or organization defaults.
## Examples
iex> count_assigned_equipment(agent_token_id)
5
"""
def count_assigned_equipment(agent_token_id) do
AgentAssignment
|> where([a], a.agent_token_id == ^agent_token_id and a.enabled == true)
|> Repo.aggregate(:count)
end
@doc """
Gets a single agent token by ID.
Raises `Ecto.NoResultsError` if the token does not exist.
## Examples
iex> get_agent_token!(id)
%AgentToken{}
iex> get_agent_token!("nonexistent")
** (Ecto.NoResultsError)
"""
def get_agent_token!(id), do: Repo.get!(AgentToken, id)
@doc """
Updates the heartbeat timestamp and metadata for an agent token.
This is called automatically when an agent makes an API request.
## Examples
iex> update_agent_token_heartbeat(token_id, "192.168.1.1", %{version: "0.1.0"})
{1, nil}
"""
def update_agent_token_heartbeat(agent_token_id, ip_address, metadata \\ nil) do
now = DateTime.truncate(DateTime.utc_now(), :second)
# Only update metadata if explicitly provided (not nil and not empty)
updates =
case metadata do
nil -> [last_seen_at: now, last_ip: ip_address]
meta when map_size(meta) > 0 -> [last_seen_at: now, last_ip: ip_address, metadata: meta]
_ -> [last_seen_at: now, last_ip: ip_address]
end
AgentToken
|> where([t], t.id == ^agent_token_id)
|> Repo.update_all(set: updates)
end
@doc """
Verifies an agent token and returns the agent token record if valid.
Returns {:ok, agent_token} if the token is valid and enabled,
otherwise {:error, :invalid_token}.
## Examples
iex> verify_agent_token("valid_token")
{:ok, %AgentToken{}}
iex> verify_agent_token("invalid_token")
{:error, :invalid_token}
"""
def verify_agent_token(token) do
with {:ok, token_string} <- AgentToken.verify_token(token),
%AgentToken{} = agent_token <-
Repo.get_by(AgentToken, token: token_string, enabled: true) do
{:ok, agent_token}
else
_ -> {:error, :invalid_token}
end
end
@doc """
Revokes an agent token by setting enabled to false.
## Examples
iex> revoke_agent_token(id)
{:ok, %AgentToken{enabled: false}}
"""
def revoke_agent_token(id) do
AgentToken
|> Repo.get!(id)
|> Ecto.Changeset.change(enabled: false)
|> Repo.update()
end
## Assignment management
@doc """
Assigns equipment to an agent.
Creates an agent assignment that links equipment to an agent token.
Equipment can only be assigned to one agent at a time due to unique constraint.
## Examples
iex> assign_equipment_to_agent(agent_id, equipment_id)
{:ok, %AgentAssignment{}}
iex> assign_equipment_to_agent(agent_id, already_assigned_equipment_id)
{:error, %Ecto.Changeset{}}
"""
def assign_equipment_to_agent(agent_token_id, equipment_id) do
%AgentAssignment{}
|> AgentAssignment.changeset(%{
agent_token_id: agent_token_id,
equipment_id: equipment_id
})
|> Repo.insert()
end
@doc """
Removes the agent assignment for a piece of equipment.
## Examples
iex> unassign_equipment(equipment_id)
{1, nil}
"""
def unassign_equipment(equipment_id) do
AgentAssignment
|> where([a], a.equipment_id == ^equipment_id)
|> Repo.delete_all()
end
@doc """
Lists all equipment assigned to an agent.
Returns equipment records with preloaded site and SNMP device associations.
## Examples
iex> list_agent_equipment(agent_token_id)
[%Equipment{site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
"""
def list_agent_equipment(agent_token_id) do
Repo.all(
from(e in Equipment,
join: a in AgentAssignment,
on: a.equipment_id == e.id,
where: a.agent_token_id == ^agent_token_id and a.enabled == true,
preload: [:site, snmp_device: [:sensors, :interfaces]]
)
)
end
@doc """
Lists all equipment that should be polled by an agent.
This includes equipment that is:
1. Directly assigned to this agent
2. At a site that has this agent as default
3. In an organization that has this agent as default (and not overridden by site or equipment)
Returns equipment records with preloaded associations, filtered to only SNMP-enabled equipment.
## Examples
iex> list_agent_polling_targets(agent_token_id)
[%Equipment{snmp_enabled: true, site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
"""
def list_agent_polling_targets(agent_token_id) do
# Get all equipment with necessary preloads
equipment_query =
from(e in Equipment,
where: e.snmp_enabled == true,
preload: [
:site,
site: :organization,
snmp_device: [:sensors, :interfaces]
]
)
equipment_list = Repo.all(equipment_query)
# Filter to only equipment that resolves to this agent
Enum.filter(equipment_list, fn equipment ->
equipment_matches_agent?(equipment, agent_token_id)
end)
end
defp equipment_matches_agent?(equipment, agent_token_id) do
# Check direct assignment first
case get_equipment_assignment(equipment.id) do
%AgentAssignment{agent_token_id: ^agent_token_id, enabled: true} ->
true
%AgentAssignment{} ->
# Has a different direct assignment
false
nil ->
# No direct assignment, check site then org
site = equipment.site
site_matches_agent?(site, agent_token_id)
end
end
defp site_matches_agent?(site, agent_token_id) do
cond do
# Check site agent
site.agent_token_id == agent_token_id ->
true
# Check org default (only if site doesn't have an agent set)
is_nil(site.agent_token_id) and site.organization.default_agent_token_id == agent_token_id ->
true
true ->
false
end
end
@doc """
Gets the agent assignment for a piece of equipment.
Returns the assignment record if it exists, nil otherwise.
## Examples
iex> get_equipment_assignment(equipment_id)
%AgentAssignment{}
iex> get_equipment_assignment(unassigned_equipment_id)
nil
"""
def get_equipment_assignment(equipment_id) do
Repo.get_by(AgentAssignment, equipment_id: equipment_id)
end
@doc """
Updates or creates an equipment assignment.
If agent_token_id is nil, removes any existing assignment.
Otherwise, creates or updates the assignment.
## Examples
iex> update_equipment_assignment(equipment_id, agent_token_id)
{:ok, %AgentAssignment{}}
iex> update_equipment_assignment(equipment_id, nil)
{:ok, nil}
"""
def update_equipment_assignment(equipment_id, nil) do
# Remove assignment if setting to nil
unassign_equipment(equipment_id)
{:ok, nil}
end
def update_equipment_assignment(equipment_id, agent_token_id) do
case get_equipment_assignment(equipment_id) do
nil ->
# Create new assignment
assign_equipment_to_agent(agent_token_id, equipment_id)
existing ->
# Update existing assignment
existing
|> AgentAssignment.changeset(%{agent_token_id: agent_token_id})
|> Repo.update()
end
end
@doc """
Gets the effective agent token for a piece of equipment.
Resolves the agent token using the following hierarchy:
1. Equipment's direct agent assignment (highest priority)
2. Site's agent_token_id
3. Organization's default_agent_token_id (lowest priority)
Returns the agent_token_id if found, nil otherwise.
The equipment must be preloaded with site: [organization: :default_agent_token].
## Examples
iex> equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
iex> get_effective_agent_token(equipment)
"agent-token-uuid"
iex> get_effective_agent_token(equipment_without_any_assignment)
nil
"""
def get_effective_agent_token(%Equipment{} = equipment) do
# 1. Check for direct equipment assignment
case get_equipment_assignment(equipment.id) do
%AgentAssignment{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
agent_token_id
_ ->
# 2. Check site's agent token
site = equipment.site
case site do
%{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
agent_token_id
%{organization: %{default_agent_token_id: agent_token_id}}
when not is_nil(agent_token_id) ->
# 3. Use organization's default agent token
agent_token_id
_ ->
nil
end
end
end
@doc """
Gets information about where the effective agent token comes from.
Returns a tuple of {agent_token_id, source} where source is one of:
- :equipment - Direct equipment assignment
- :site - Inherited from site
- :organization - Inherited from organization
- :none - No agent assigned
## Examples
iex> get_effective_agent_token_with_source(equipment)
{"token-id", :equipment}
iex> get_effective_agent_token_with_source(equipment_no_assignment)
{nil, :none}
"""
def get_effective_agent_token_with_source(%Equipment{} = equipment) do
# 1. Check for direct equipment assignment
case get_equipment_assignment(equipment.id) do
%AgentAssignment{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
{agent_token_id, :equipment}
_ ->
# 2. Check site's agent token
site = equipment.site
case site do
%{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
{agent_token_id, :site}
%{organization: %{default_agent_token_id: agent_token_id}}
when not is_nil(agent_token_id) ->
# 3. Use organization's default agent token
{agent_token_id, :organization}
_ ->
{nil, :none}
end
end
end
end