add multiple cloud agents

This commit is contained in:
Graham McIntire 2026-01-23 18:04:01 -06:00
parent f8d6837f20
commit f084e09ea1
No known key found for this signature in database
10 changed files with 831 additions and 55 deletions

View file

@ -39,10 +39,35 @@ defmodule Towerops.Agents do
end
end
@doc """
Creates a new cloud poller agent token (superadmin only).
Cloud pollers are application-wide agents that are not tied to any organization
and can poll devices for any 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_cloud_poller("Cloud Poller 1")
{:ok, %AgentToken{is_cloud_poller: true}, "abc123..."}
"""
def create_cloud_poller(name) do
{token, changeset} = AgentToken.build_token(nil, name, is_cloud_poller: true)
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).
Does not include cloud pollers.
## Examples
@ -52,7 +77,25 @@ defmodule Towerops.Agents do
"""
def list_organization_agent_tokens(organization_id) do
AgentToken
|> where([t], t.organization_id == ^organization_id)
|> where([t], t.organization_id == ^organization_id and t.is_cloud_poller == false)
|> order_by([t], desc: t.inserted_at)
|> Repo.all()
end
@doc """
Lists all cloud poller agent tokens (superadmin only).
Returns a list of cloud poller agent tokens ordered by creation date (newest first).
## Examples
iex> list_cloud_pollers()
[%AgentToken{is_cloud_poller: true}, ...]
"""
def list_cloud_pollers do
AgentToken
|> where([t], t.is_cloud_poller == true)
|> order_by([t], desc: t.inserted_at)
|> Repo.all()
end

View file

@ -23,6 +23,7 @@ defmodule Towerops.Agents.AgentToken do
field :last_seen_at, :utc_datetime
field :last_ip, :string
field :enabled, :boolean, default: true
field :is_cloud_poller, :boolean, default: false
field :metadata, :map, default: %{}
belongs_to :organization, Organization
@ -37,8 +38,9 @@ defmodule Towerops.Agents.AgentToken do
last_seen_at: DateTime.t() | nil,
last_ip: String.t() | nil,
enabled: boolean(),
is_cloud_poller: boolean(),
metadata: map(),
organization_id: Ecto.UUID.t(),
organization_id: Ecto.UUID.t() | nil,
organization: Ecto.Association.NotLoaded.t() | Organization.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
@ -50,6 +52,8 @@ defmodule Towerops.Agents.AgentToken do
Returns a tuple of {token_string, changeset}. The token_string is stored in the database
and can be retrieved later for display to authorized users.
For cloud pollers, pass `is_cloud_poller: true` in opts and organization_id will be nil.
## Examples
iex> {token, changeset} = AgentToken.build_token(org_id, "My Agent")
@ -58,28 +62,51 @@ defmodule Towerops.Agents.AgentToken do
iex> byte_size(Base.url_decode64!(token, padding: false))
48
iex> {token, changeset} = AgentToken.build_token(nil, "Cloud Poller", is_cloud_poller: true)
iex> changeset.changes.is_cloud_poller
true
"""
def build_token(organization_id, name) do
def build_token(organization_id, name, opts \\ []) do
is_cloud_poller = Keyword.get(opts, :is_cloud_poller, false)
# Generate 48 bytes (384 bits) of cryptographically secure random data
token = :crypto.strong_rand_bytes(@rand_size)
encoded_token = Base.url_encode64(token, padding: false)
attrs = %{
organization_id: organization_id,
name: name,
token: encoded_token,
is_cloud_poller: is_cloud_poller
}
changeset =
%__MODULE__{}
|> cast(
%{
organization_id: organization_id,
name: name,
token: encoded_token
},
[:organization_id, :name, :token]
)
|> validate_required([:organization_id, :name, :token])
|> cast(attrs, [:organization_id, :name, :token, :is_cloud_poller])
|> validate_cloud_poller_constraints()
|> validate_required([:name, :token])
|> unique_constraint(:token)
{encoded_token, changeset}
end
defp validate_cloud_poller_constraints(changeset) do
is_cloud_poller = get_field(changeset, :is_cloud_poller)
organization_id = get_field(changeset, :organization_id)
cond do
is_cloud_poller && organization_id != nil ->
add_error(changeset, :organization_id, "must be nil for cloud pollers")
!is_cloud_poller && organization_id == nil ->
add_error(changeset, :organization_id, "can't be blank for non-cloud pollers")
true ->
changeset
end
end
@doc """
Changeset for updating an agent token.

View file

@ -10,8 +10,17 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
current_user = socket.assigns.current_scope.user
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# If superadmin, also load cloud pollers
cloud_pollers =
if current_user.is_superuser do
Agents.list_cloud_pollers()
else
[]
end
# device counts for each agent (both direct and total with inheritance)
equipment_counts =
Map.new(agent_tokens, fn token ->
@ -32,6 +41,7 @@ defmodule ToweropsWeb.AgentLive.Index do
socket
|> assign(:page_title, "Remote Agents")
|> assign(:agent_tokens, agent_tokens)
|> assign(:cloud_pollers, cloud_pollers)
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
@ -39,7 +49,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|> assign(:agent_image, agent_image)
|> assign(:new_token, nil)
|> assign(:show_token_modal, false)
|> assign(:agent_form, to_form(%{"name" => ""}))}
|> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))}
end
@impl true
@ -49,45 +59,20 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def handle_event("create_agent", params, socket) do
name =
case params do
%{"agent_form" => %{"name" => n}} -> n
%{"name" => n} -> n
end
{name, is_cloud_poller} = parse_agent_params(params)
organization = socket.assigns.current_organization
case Agents.create_agent_token(organization.id, name) do
{:ok, agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# device counts
equipment_counts =
Map.new(agent_tokens, fn t ->
direct = Agents.count_assigned_devices(t.id)
total = length(Agents.list_agent_polling_targets(t.id))
{t.id, %{direct: direct, total: total}}
end)
# Refresh health statistics
agent_health_stats = Stats.get_organization_agent_health(organization.id)
assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id)
offline_agents = Stats.get_offline_agents(organization.id)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:offline_agents, offline_agents)
|> assign(:new_token, %{agent_token: agent_token, token: token})
|> assign(:show_token_modal, true)
|> assign(:agent_form, to_form(%{"name" => ""}))
|> put_flash(:info, "Agent created successfully")}
with :ok <- validate_cloud_poller_permission(socket.assigns.current_scope.user, is_cloud_poller),
{:ok, agent_token, token} <- create_agent(socket.assigns.current_organization, name, is_cloud_poller) do
handle_agent_creation_success(socket, agent_token, token, is_cloud_poller)
else
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "Only superadmins can create cloud pollers")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to create agent")}
error_message =
if is_cloud_poller, do: "Failed to create cloud poller", else: "Failed to create agent"
{:noreply, put_flash(socket, :error, error_message)}
end
end
@ -174,4 +159,79 @@ defmodule ToweropsWeb.AgentLive.Index do
defp apply_action(socket, :index, _params) do
socket
end
defp parse_agent_params(params) do
case params do
%{"agent_form" => %{"name" => n, "is_cloud_poller" => cp}} ->
{n, cp == "true" || cp == true}
%{"agent_form" => %{"name" => n}} ->
{n, false}
%{"name" => n} ->
{n, false}
end
end
defp validate_cloud_poller_permission(user, is_cloud_poller) do
if is_cloud_poller && !user.is_superuser do
{:error, :unauthorized}
else
:ok
end
end
defp create_agent(organization, name, is_cloud_poller) do
if is_cloud_poller do
Agents.create_cloud_poller(name)
else
Agents.create_agent_token(organization.id, name)
end
end
defp handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) do
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope.user)
equipment_counts = calculate_device_counts(agent_tokens)
agent_health_stats = Stats.get_organization_agent_health(organization.id)
assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id)
offline_agents = Stats.get_offline_agents(organization.id)
success_message = get_success_message(is_cloud_poller)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:cloud_pollers, cloud_pollers)
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:offline_agents, offline_agents)
|> assign(:new_token, %{agent_token: agent_token, token: token})
|> assign(:show_token_modal, true)
|> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))
|> put_flash(:info, success_message)}
end
defp load_cloud_pollers_if_superuser(user) do
if user.is_superuser, do: Agents.list_cloud_pollers(), else: []
end
defp calculate_device_counts(agent_tokens) do
Map.new(agent_tokens, fn t ->
direct = Agents.count_assigned_devices(t.id)
total = length(Agents.list_agent_polling_targets(t.id))
{t.id, %{direct: direct, total: total}}
end)
end
defp get_success_message(is_cloud_poller) do
if is_cloud_poller,
do: "Cloud poller created successfully",
else: "Agent created successfully"
end
end

View file

@ -30,6 +30,13 @@
placeholder="e.g., Datacenter A"
required
/>
<%= if @current_scope.user.is_superuser do %>
<.input
field={@agent_form[:is_cloud_poller]}
type="checkbox"
label="Cloud Poller (Application-wide agent, not tied to this organization)"
/>
<% end %>
<div class="flex gap-3">
<.button type="submit" variant="primary">
Create Agent
@ -157,6 +164,103 @@
</.table>
</div>
<% end %>
<%!-- Cloud Pollers Section (Superadmin Only) --%>
<%= if @current_scope.user.is_superuser && @cloud_pollers != [] do %>
<div class="mt-12">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Cloud Pollers
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins.
</p>
<.table id="cloud-pollers-table" rows={@cloud_pollers}>
<:col :let={agent} label="Name">
<div class="flex items-center gap-2">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/agents/#{agent.id}"}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{agent.name}
</.link>
<span class="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/30">
Cloud Poller
</span>
</div>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
v{agent.metadata["version"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Status">
<% {status, label} = agent_status(agent) %>
<span class={"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}>
<span class={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} />
{label}
</span>
</:col>
<:col :let={agent} label="Last Seen">
<div class="text-sm text-gray-900 dark:text-white">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} />
</div>
<%= if agent.last_seen_at do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{format_datetime(agent.last_seen_at, @timezone)}
</div>
<% end %>
<%= if agent.last_ip do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
{agent.last_ip}
</div>
<% end %>
</:col>
<:col :let={agent} label="Metadata">
<%= if agent.metadata["hostname"] do %>
<div class="text-sm text-gray-600 dark:text-gray-400">
<.icon name="hero-computer-desktop" class="h-4 w-4 inline" />
{agent.metadata["hostname"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Created">
<div class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
</:col>
<:action :let={agent}>
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
View Setup
</button>
<button
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
data-confirm="Are you sure you want to delete this cloud poller? This will affect all organizations using it."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Delete
</button>
</div>
<% else %>
<span class="text-xs text-gray-500 dark:text-gray-400">Disabled</span>
<% end %>
</:action>
</.table>
</div>
<% end %>
<!-- Token Display Modal -->
<%= if @show_token_modal && @new_token do %>

View file

@ -15,6 +15,10 @@ defmodule ToweropsWeb.DeviceLive.Form do
sites = Sites.list_organization_sites(organization.id)
agents = Agents.list_organization_agent_tokens(organization.id)
# Include cloud pollers in the available agents list
cloud_pollers = Agents.list_cloud_pollers()
all_agents = agents ++ cloud_pollers
# Redirect to sites page if no sites exist
if Enum.empty?(sites) do
{:ok,
@ -26,7 +30,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
socket
|> assign(:organization, organization)
|> assign(:available_sites, sites)
|> assign(:available_agents, agents)
|> assign(:available_agents, all_agents)
|> assign(:preselected_site_id, params["site_id"])
|> assign(:snmp_test_result, nil)
|> assign(:duplicate_device, nil)

View file

@ -112,7 +112,16 @@
type="select"
label="Remote Agent"
prompt="Inherit from site/organization"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
options={
Enum.map(@available_agents, fn agent ->
label =
if agent.is_cloud_poller,
do: "#{agent.name} (Cloud Poller)",
else: agent.name
{label, agent.id}
end)
}
/>
<%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %>
<%= case @agent_source do %>

View file

@ -9,7 +9,6 @@
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"delta_crdt": {:hex, :delta_crdt, "0.6.5", "c7bb8c2c7e60f59e46557ab4e0224f67ba22f04c02826e273738f3dcc4767adc", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6ae23a525d30f96494186dd11bf19ed9ae21d9fe2c1f1b217d492a7cc7294ae"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
@ -30,14 +29,11 @@
"gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"honeybadger": {:hex, :honeybadger, "0.24.1", "13ffe56b4d148649c8fbb0e091fefecc5d8e8eb7ade684b6900085a947d741d5", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:process_tree, "~> 0.2.1", [hex: :process_tree, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c97d5a82c42298b9935dbc0a7e3c14372c8f55257f603828258ef9f7e0da892"},
"horde": {:hex, :horde, "0.10.0", "31c6a633057c3ec4e73064d7b11ba409c9f3c518aa185377d76bee441b76ceb0", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "0b51c435cb698cac9bf9c17391dce3ebb1376ae6154c81f077fc61db771b9432"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
"libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"},
"merkle_map": {:hex, :merkle_map, "0.2.2", "f36ff730cca1f2658e317a3c73406f50bbf5ac8aff54cf837d7ca2069a6e251c", [:mix], [], "hexpm", "383107f0503f230ac9175e0631647c424efd027e89ea65ab5ea12eeb54257aaf"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},

View file

@ -0,0 +1,21 @@
defmodule Towerops.Repo.Migrations.AddIsCloudPollerToAgentTokens do
use Ecto.Migration
def change do
alter table(:agent_tokens) do
add :is_cloud_poller, :boolean, default: false, null: false
end
create index(:agent_tokens, [:is_cloud_poller])
# Make organization_id nullable for cloud pollers
execute "ALTER TABLE agent_tokens ALTER COLUMN organization_id DROP NOT NULL",
"ALTER TABLE agent_tokens ALTER COLUMN organization_id SET NOT NULL"
# Add check constraint: cloud pollers must have null organization_id
create constraint(:agent_tokens, :cloud_poller_no_org,
check:
"(is_cloud_poller = false AND organization_id IS NOT NULL) OR (is_cloud_poller = true AND organization_id IS NULL)"
)
end
end

View file

@ -1154,4 +1154,228 @@ defmodule Towerops.AgentsTest do
assert Ecto.assoc_loaded?(device.site.organization)
end
end
describe "create_cloud_poller/1" do
test "creates cloud poller with no organization" do
assert {:ok, agent_token, token} = Agents.create_cloud_poller("Cloud Poller 1")
assert is_binary(token)
assert byte_size(token) > 0
assert agent_token.organization_id == nil
assert agent_token.name == "Cloud Poller 1"
assert agent_token.enabled == true
assert agent_token.is_cloud_poller == true
assert is_binary(agent_token.token)
assert agent_token.token == token
end
test "returns error with empty name" do
assert {:error, changeset} = Agents.create_cloud_poller("")
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
end
describe "list_cloud_pollers/0" do
test "returns all cloud pollers ordered by created date" do
{:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1")
{:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2")
cloud_pollers = Agents.list_cloud_pollers()
assert length(cloud_pollers) == 2
# Verify both cloud pollers are in the list
cloud_ids = Enum.map(cloud_pollers, & &1.id)
assert cloud1.id in cloud_ids
assert cloud2.id in cloud_ids
end
test "returns empty list when no cloud pollers exist" do
assert Agents.list_cloud_pollers() == []
end
test "does not include organization agents" do
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, _org_agent, _} = Agents.create_agent_token(org.id, "Org Agent")
{:ok, cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller")
cloud_pollers = Agents.list_cloud_pollers()
assert length(cloud_pollers) == 1
assert hd(cloud_pollers).id == cloud_agent.id
assert hd(cloud_pollers).is_cloud_poller == true
end
end
describe "list_organization_agent_tokens/1 - cloud poller exclusion" do
test "excludes cloud pollers from organization agents", %{organization: org} do
{:ok, org_agent1, _} = Agents.create_agent_token(org.id, "Org Agent 1")
{:ok, org_agent2, _} = Agents.create_agent_token(org.id, "Org Agent 2")
{:ok, _cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller")
org_agents = Agents.list_organization_agent_tokens(org.id)
assert length(org_agents) == 2
assert Enum.any?(org_agents, &(&1.id == org_agent1.id))
assert Enum.any?(org_agents, &(&1.id == org_agent2.id))
assert Enum.all?(org_agents, &(&1.is_cloud_poller == false))
end
end
describe "cloud poller validation constraints" do
test "cloud poller cannot have organization_id" do
# Attempt to create cloud poller with organization_id should fail
{_token, changeset} = AgentToken.build_token("fake-org-id", "Invalid", is_cloud_poller: true)
refute changeset.valid?
assert "must be nil for cloud pollers" in errors_on(changeset).organization_id
end
test "regular agent must have organization_id" do
# Attempt to create regular agent without organization_id should fail
{_token, changeset} = AgentToken.build_token(nil, "Invalid", is_cloud_poller: false)
refute changeset.valid?
assert "can't be blank for non-cloud pollers" in errors_on(changeset).organization_id
end
end
describe "cloud poller polling targets" do
setup do
user = user_fixture()
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id)
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id)
{:ok, site1} = Towerops.Sites.create_site(%{name: "Site 1", organization_id: org1.id})
{:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org2.id})
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Cloud Poller")
%{
org1: org1,
org2: org2,
site1: site1,
site2: site2,
cloud_poller: cloud_poller
}
end
test "cloud poller can be assigned to devices across organizations", %{
site1: site1,
site2: site2,
cloud_poller: cloud_poller
} do
# Create devices in different organizations
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.2.1",
site_id: site2.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Assign both devices to cloud poller
{:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device1.id)
{:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device2.id)
# Cloud poller should see both devices across organizations
targets = Agents.list_agent_polling_targets(cloud_poller.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
end
test "cloud poller can be organization default for multiple orgs", %{
org1: org1,
org2: org2,
site1: site1,
site2: site2,
cloud_poller: cloud_poller
} do
# Set cloud poller as default for both organizations
{:ok, _} =
Towerops.Organizations.update_organization(org1, %{
default_agent_token_id: cloud_poller.id
})
{:ok, _} =
Towerops.Organizations.update_organization(org2, %{
default_agent_token_id: cloud_poller.id
})
# Create devices in both organizations
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.2.1",
site_id: site2.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Cloud poller should see both devices via organization inheritance
targets = Agents.list_agent_polling_targets(cloud_poller.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
end
test "cloud poller can be site default for sites in different orgs", %{
site1: site1,
site2: site2,
cloud_poller: cloud_poller
} do
# Set cloud poller as default for both sites
{:ok, _} = Towerops.Sites.update_site(site1, %{agent_token_id: cloud_poller.id})
{:ok, _} = Towerops.Sites.update_site(site2, %{agent_token_id: cloud_poller.id})
# Create devices in both sites
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Device 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Device 2",
ip_address: "192.168.2.1",
site_id: site2.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Cloud poller should see both devices via site inheritance
targets = Agents.list_agent_polling_targets(cloud_poller.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == device1.id))
assert Enum.any?(targets, &(&1.id == device2.id))
end
end
end

View file

@ -200,4 +200,292 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
assert render(view) =~ "Remote Agents"
end
end
describe "cloud poller creation - superadmin only" do
setup %{user: _user, organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin =
superadmin
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
# Add superadmin to the organization
{:ok, _membership} =
Towerops.Organizations.create_membership(%{
organization_id: organization.id,
user_id: superadmin.id,
role: "admin"
})
%{superadmin: superadmin}
end
test "superadmin can create cloud poller", %{
conn: conn,
superadmin: superadmin,
organization: organization
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
{:ok, view, html} = live(conn)
# Superadmin should see the cloud poller checkbox
assert html =~ "Cloud Poller"
# Create a cloud poller
html =
render_submit(view, "create_agent", %{
"agent_form" => %{"name" => "Test Cloud Poller", "is_cloud_poller" => "true"}
})
assert html =~ "Cloud poller created successfully"
assert html =~ "Test Cloud Poller"
# Verify cloud poller was created
cloud_pollers = Agents.list_cloud_pollers()
assert length(cloud_pollers) == 1
assert hd(cloud_pollers).name == "Test Cloud Poller"
assert hd(cloud_pollers).is_cloud_poller == true
assert hd(cloud_pollers).organization_id == nil
end
test "non-superadmin cannot create cloud poller", %{
conn: conn,
user: user,
organization: organization
} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
{:ok, view, html} = live(conn)
# Regular user should NOT see the cloud poller checkbox
refute html =~ "Cloud Poller (Application-wide agent"
# Attempt to create a cloud poller (bypassing UI)
html =
render_submit(view, "create_agent", %{
"agent_form" => %{"name" => "Unauthorized Cloud Poller", "is_cloud_poller" => "true"}
})
assert html =~ "Only superadmins can create cloud pollers"
# Verify cloud poller was NOT created
cloud_pollers = Agents.list_cloud_pollers()
assert Enum.empty?(cloud_pollers)
# Verify organization agent was NOT created
org_agents = Agents.list_organization_agent_tokens(organization.id)
assert Enum.empty?(org_agents)
end
test "superadmin can create regular agent by not checking cloud poller", %{
conn: conn,
superadmin: superadmin,
organization: organization
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
{:ok, view, _html} = live(conn)
# Create a regular agent (is_cloud_poller = false)
html =
render_submit(view, "create_agent", %{
"agent_form" => %{"name" => "Regular Agent", "is_cloud_poller" => "false"}
})
assert html =~ "Agent created successfully"
assert html =~ "Regular Agent"
# Verify regular agent was created
org_agents = Agents.list_organization_agent_tokens(organization.id)
assert length(org_agents) == 1
assert hd(org_agents).name == "Regular Agent"
assert hd(org_agents).is_cloud_poller == false
assert hd(org_agents).organization_id == organization.id
# Verify cloud poller was NOT created
cloud_pollers = Agents.list_cloud_pollers()
assert Enum.empty?(cloud_pollers)
end
end
describe "cloud poller visibility" do
setup %{user: _user, organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin =
superadmin
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
# Add superadmin to the organization
{:ok, _membership} =
Towerops.Organizations.create_membership(%{
organization_id: organization.id,
user_id: superadmin.id,
role: "admin"
})
# Create some cloud pollers
{:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1")
{:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2")
# Create organization agents
{:ok, org_agent, _} = Agents.create_agent_token(organization.id, "Org Agent")
%{superadmin: superadmin, cloud1: cloud1, cloud2: cloud2, org_agent: org_agent}
end
test "superadmin sees cloud pollers in separate section", %{
conn: conn,
superadmin: superadmin,
organization: organization,
cloud1: cloud1,
cloud2: cloud2,
org_agent: org_agent
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
{:ok, _view, html} = live(conn)
# Should see cloud pollers section header
assert html =~ "Cloud Pollers"
# Should see both cloud pollers
assert html =~ cloud1.name
assert html =~ cloud2.name
# Should see organization agent in main section
assert html =~ org_agent.name
# Should see visual distinction for cloud pollers
assert html =~ "Cloud Poller" or html =~ "cloud-poller"
end
test "non-superadmin does not see cloud pollers section", %{
conn: conn,
user: user,
organization: organization,
cloud1: cloud1,
cloud2: cloud2,
org_agent: org_agent
} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
{:ok, _view, html} = live(conn)
# Should NOT see cloud pollers section header
refute html =~ "Cloud Pollers"
# Should NOT see cloud poller names
refute html =~ cloud1.name
refute html =~ cloud2.name
# Should see organization agent in main section
assert html =~ org_agent.name
end
end
describe "cloud poller management" do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin =
superadmin
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
# Add superadmin to the organization
{:ok, _membership} =
Towerops.Organizations.create_membership(%{
organization_id: organization.id,
user_id: superadmin.id,
role: "admin"
})
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Test Cloud Poller")
%{superadmin: superadmin, cloud_poller: cloud_poller}
end
test "superadmin can delete cloud poller", %{
conn: conn,
superadmin: superadmin,
organization: organization,
cloud_poller: cloud_poller
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
{:ok, view, _html} = live(conn)
html = render_click(view, "delete_agent", %{"id" => cloud_poller.id})
assert html =~ "Agent deleted successfully"
# Verify cloud poller was deleted
cloud_pollers = Agents.list_cloud_pollers()
assert Enum.empty?(cloud_pollers)
end
test "superadmin can regenerate cloud poller token", %{
conn: conn,
superadmin: superadmin,
organization: organization,
cloud_poller: cloud_poller
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
{:ok, view, _html} = live(conn)
html = render_click(view, "regenerate_token", %{"id" => cloud_poller.id})
assert html =~ "New token generated successfully"
assert html =~ "Test Cloud Poller"
end
test "superadmin can view cloud poller setup instructions", %{
conn: conn,
superadmin: superadmin,
organization: organization,
cloud_poller: cloud_poller
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
{:ok, view, _html} = live(conn)
html = render_click(view, "show_setup", %{"id" => cloud_poller.id})
assert html =~ "Test Cloud Poller"
# Setup modal should show token and instructions
assert html =~ cloud_poller.token or html =~ "docker run" or html =~ "Setup"
end
end
end