Implemented ability to rename agents through a new edit page following TDD: Backend changes: - Added update_agent_token/2 in Agents context for updating agent names - Added update_changeset/2 in AgentToken schema that only allows name updates - Security: Token field cannot be changed, only name field is allowed LiveView implementation: - Created AgentLive.Edit module with mount, validate, and save handlers - Created edit.html.heex template with form for renaming agents - Added route /orgs/:org_slug/agents/:id/edit - Added Edit button to agent show page header - Validates organization ownership using Repo.get_by! Tests: - Added comprehensive context tests for update_agent_token/2 - Added LiveView tests for edit page functionality - Tests cover: loading, validation, save/redirect, auth, and org ownership All 801 tests passing, no Credo warnings.
472 lines
13 KiB
Elixir
472 lines
13 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 devices to agents, and verifying agent credentials.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
alias Towerops.Agents.AgentAssignment
|
|
alias Towerops.Agents.AgentToken
|
|
alias Towerops.Devices.Device
|
|
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 devices directly assigned to an agent.
|
|
|
|
This only counts devices with explicit assignments, not devices
|
|
that inherit the agent from site or organization defaults.
|
|
|
|
## Examples
|
|
|
|
iex> count_assigned_devices(agent_token_id)
|
|
5
|
|
|
|
"""
|
|
def count_assigned_devices(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}
|
|
|
|
"""
|
|
@spec update_agent_token_heartbeat(Ecto.UUID.t(), String.t() | nil, map() | nil) ::
|
|
{non_neg_integer(), nil | [term()]}
|
|
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}
|
|
|
|
"""
|
|
@spec verify_agent_token(String.t()) :: {:ok, AgentToken.t()} | {: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 """
|
|
Updates an agent token with the given attributes.
|
|
|
|
Currently only supports updating the name field. Other fields like
|
|
the token itself cannot be changed (use regenerate_token instead).
|
|
|
|
## Examples
|
|
|
|
iex> update_agent_token(agent_token, %{name: "New Name"})
|
|
{:ok, %AgentToken{name: "New Name"}}
|
|
|
|
iex> update_agent_token(agent_token, %{name: ""})
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
def update_agent_token(%AgentToken{} = agent_token, attrs) do
|
|
agent_token
|
|
|> AgentToken.update_changeset(attrs)
|
|
|> Repo.update()
|
|
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
|
|
|
|
@doc """
|
|
Deletes an agent token and cleans up all references.
|
|
|
|
This function:
|
|
1. Removes all direct device assignments for this agent
|
|
2. Removes this agent from any sites using it as default
|
|
3. Removes this agent from any organizations using it as default
|
|
4. Deletes the agent token
|
|
|
|
Devices that were assigned to this agent will fall back to:
|
|
- Site-level agent (if configured)
|
|
- Organization-level default agent (if configured)
|
|
- Cloud polling (if no fallback exists)
|
|
|
|
## Examples
|
|
|
|
iex> delete_agent_token(id)
|
|
{:ok, %AgentToken{}}
|
|
|
|
"""
|
|
def delete_agent_token(id) do
|
|
Repo.transaction(fn ->
|
|
agent_token = Repo.get!(AgentToken, id)
|
|
|
|
# 1. Remove all direct device assignments
|
|
Repo.delete_all(from(a in AgentAssignment, where: a.agent_token_id == ^id))
|
|
|
|
# 2. Remove this agent from any sites using it as default
|
|
Repo.update_all(from(s in Towerops.Sites.Site, where: s.agent_token_id == ^id), set: [agent_token_id: nil])
|
|
|
|
# 3. Remove this agent from any organizations using it as default
|
|
Repo.update_all(from(o in Towerops.Organizations.Organization, where: o.default_agent_token_id == ^id),
|
|
set: [default_agent_token_id: nil]
|
|
)
|
|
|
|
# 4. Delete the agent token
|
|
Repo.delete!(agent_token)
|
|
end)
|
|
end
|
|
|
|
## Assignment management
|
|
|
|
@doc """
|
|
Assigns device to an agent.
|
|
|
|
Creates an agent assignment that links device to an agent token.
|
|
Devices can only be assigned to one agent at a time due to unique constraint.
|
|
|
|
## Examples
|
|
|
|
iex> assign_device_to_agent(agent_id, device_id)
|
|
{:ok, %AgentAssignment{}}
|
|
|
|
iex> assign_device_to_agent(agent_id, already_assigned_device_id)
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
def assign_device_to_agent(agent_token_id, device_id) do
|
|
%AgentAssignment{}
|
|
|> AgentAssignment.changeset(%{
|
|
agent_token_id: agent_token_id,
|
|
device_id: device_id
|
|
})
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Removes the agent assignment for a piece of device.
|
|
|
|
## Examples
|
|
|
|
iex> unassign_device(device_id)
|
|
{1, nil}
|
|
|
|
"""
|
|
def unassign_device(device_id) do
|
|
AgentAssignment
|
|
|> where([a], a.device_id == ^device_id)
|
|
|> Repo.delete_all()
|
|
end
|
|
|
|
@doc """
|
|
Lists all devices assigned to an agent.
|
|
|
|
Returns device records with preloaded site and SNMP device associations.
|
|
|
|
## Examples
|
|
|
|
iex> list_agent_devices(agent_token_id)
|
|
[%Device{site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
|
|
|
|
"""
|
|
def list_agent_devices(agent_token_id) do
|
|
Repo.all(
|
|
from(e in Device,
|
|
join: a in AgentAssignment,
|
|
on: a.device_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 devices that should be polled by an agent.
|
|
|
|
This includes devices 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 device)
|
|
|
|
Returns device records with preloaded associations, filtered to only SNMP-enabled devices.
|
|
|
|
## Examples
|
|
|
|
iex> list_agent_polling_targets(agent_token_id)
|
|
[%Device{snmp_enabled: true, site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
|
|
|
|
"""
|
|
@spec list_agent_polling_targets(Ecto.UUID.t()) :: [Device.t()]
|
|
def list_agent_polling_targets(agent_token_id) do
|
|
# Use SQL to filter devices by effective agent assignment
|
|
# This avoids loading all devices into memory and filtering in application code
|
|
Repo.all(
|
|
from(e in Device,
|
|
join: s in assoc(e, :site),
|
|
join: o in assoc(s, :organization),
|
|
left_join: aa in AgentAssignment,
|
|
on: aa.device_id == e.id and aa.enabled == true,
|
|
where:
|
|
e.snmp_enabled == true and
|
|
(aa.agent_token_id == ^agent_token_id or
|
|
(is_nil(aa.agent_token_id) and s.agent_token_id == ^agent_token_id) or
|
|
(is_nil(aa.agent_token_id) and is_nil(s.agent_token_id) and
|
|
o.default_agent_token_id == ^agent_token_id)),
|
|
preload: [
|
|
:agent_assignments,
|
|
site: :organization,
|
|
snmp_device: [:sensors, :interfaces]
|
|
]
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Gets the agent assignment for a piece of device.
|
|
|
|
Returns the assignment record if it exists, nil otherwise.
|
|
|
|
## Examples
|
|
|
|
iex> get_device_assignment(device_id)
|
|
%AgentAssignment{}
|
|
|
|
iex> get_device_assignment(unassigned_device_id)
|
|
nil
|
|
|
|
"""
|
|
def get_device_assignment(device_id) do
|
|
Repo.get_by(AgentAssignment, device_id: device_id)
|
|
end
|
|
|
|
@doc """
|
|
Updates or creates an device assignment.
|
|
|
|
If agent_token_id is nil, removes any existing assignment.
|
|
Otherwise, creates or updates the assignment.
|
|
|
|
## Examples
|
|
|
|
iex> update_device_assignment(device_id, agent_token_id)
|
|
{:ok, %AgentAssignment{}}
|
|
|
|
iex> update_device_assignment(device_id, nil)
|
|
{:ok, nil}
|
|
|
|
"""
|
|
def update_device_assignment(device_id, nil) do
|
|
# Remove assignment if setting to nil
|
|
unassign_device(device_id)
|
|
{:ok, nil}
|
|
end
|
|
|
|
def update_device_assignment(device_id, agent_token_id) do
|
|
case get_device_assignment(device_id) do
|
|
nil ->
|
|
# Create new assignment
|
|
assign_device_to_agent(agent_token_id, device_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 device.
|
|
|
|
Resolves the agent token using the following hierarchy:
|
|
1. Device'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 device must be preloaded with site: [organization: :default_agent_token].
|
|
|
|
## Examples
|
|
|
|
iex> device = Repo.preload(device, site: [organization: :default_agent_token])
|
|
iex> get_effective_agent_token(device)
|
|
"agent-token-uuid"
|
|
|
|
iex> get_effective_agent_token(equipment_without_any_assignment)
|
|
nil
|
|
|
|
"""
|
|
def get_effective_agent_token(%Device{} = device) do
|
|
# device assignment
|
|
case get_device_assignment(device.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 = device.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:
|
|
- :device - Direct device assignment
|
|
- :site - Inherited from site
|
|
- :organization - Inherited from organization
|
|
- :none - No agent assigned
|
|
|
|
## Examples
|
|
|
|
iex> get_effective_agent_token_with_source(device)
|
|
{"token-id", :device}
|
|
|
|
iex> get_effective_agent_token_with_source(equipment_no_assignment)
|
|
{nil, :none}
|
|
|
|
"""
|
|
def get_effective_agent_token_with_source(%Device{} = device) do
|
|
# device assignment
|
|
case get_device_assignment(device.id) do
|
|
%AgentAssignment{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
|
|
{agent_token_id, :device}
|
|
|
|
_ ->
|
|
# 2. Check site's agent token
|
|
site = device.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
|