Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
160 lines
4.3 KiB
Elixir
160 lines
4.3 KiB
Elixir
defmodule Towerops.NetBox.Client do
|
|
@moduledoc """
|
|
HTTP client for the NetBox REST API.
|
|
|
|
Handles authentication, pagination, and common CRUD operations
|
|
against a user-provided NetBox instance.
|
|
"""
|
|
|
|
alias Towerops.HTTP
|
|
|
|
require Logger
|
|
|
|
@doc """
|
|
Test connectivity and authentication against a NetBox instance.
|
|
Returns `{:ok, info}` with the NetBox version or `{:error, reason}`.
|
|
"""
|
|
def test_connection(url, token) do
|
|
case get(url, "/api/status/", token) do
|
|
{:ok, %{"netbox-version" => version}} ->
|
|
{:ok, "Connected to NetBox #{version}"}
|
|
|
|
{:ok, %{"django-version" => _}} ->
|
|
{:ok, "Connected to NetBox"}
|
|
|
|
{:ok, _body} ->
|
|
{:ok, "Connected to NetBox"}
|
|
|
|
{:error, :unauthorized} ->
|
|
{:error, "Authentication failed — check your API token"}
|
|
|
|
{:error, :not_found} ->
|
|
{:error, "NetBox API not found at this URL — check the URL"}
|
|
|
|
{:error, reason} ->
|
|
{:error, "Connection failed: #{reason}"}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
List devices from NetBox with optional filters.
|
|
"""
|
|
def list_devices(url, token, params \\ %{}) do
|
|
get(url, "/api/dcim/devices/", token, params)
|
|
end
|
|
|
|
@doc """
|
|
List sites from NetBox with optional filters.
|
|
"""
|
|
def list_sites(url, token, params \\ %{}) do
|
|
get(url, "/api/dcim/sites/", token, params)
|
|
end
|
|
|
|
@doc """
|
|
List IP addresses from NetBox with optional filters.
|
|
"""
|
|
def list_ip_addresses(url, token, params \\ %{}) do
|
|
get(url, "/api/ipam/ip-addresses/", token, params)
|
|
end
|
|
|
|
@doc """
|
|
List interfaces from NetBox with optional filters.
|
|
"""
|
|
def list_interfaces(url, token, params \\ %{}) do
|
|
get(url, "/api/dcim/interfaces/", token, params)
|
|
end
|
|
|
|
@doc """
|
|
Create or update a device in NetBox.
|
|
"""
|
|
def upsert_device(url, token, attrs) do
|
|
post(url, "/api/dcim/devices/", token, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Create or update a site in NetBox.
|
|
"""
|
|
def upsert_site(url, token, attrs) do
|
|
post(url, "/api/dcim/sites/", token, attrs)
|
|
end
|
|
|
|
# -- HTTP helpers --
|
|
|
|
defp get(base_url, path, token, params \\ %{}) do
|
|
with {:ok, url} <- normalize_url(base_url) do
|
|
full_url = url <> path
|
|
|
|
case HTTP.get(__MODULE__, full_url,
|
|
headers: auth_headers(token),
|
|
params: params,
|
|
connect_options: [timeout: 10_000],
|
|
receive_timeout: 15_000
|
|
) do
|
|
{:ok, %Req.Response{status: 200, body: body}} ->
|
|
{:ok, body}
|
|
|
|
{:ok, %Req.Response{status: 401}} ->
|
|
{:error, :unauthorized}
|
|
|
|
{:ok, %Req.Response{status: 403}} ->
|
|
{:error, :unauthorized}
|
|
|
|
{:ok, %Req.Response{status: 404}} ->
|
|
{:error, :not_found}
|
|
|
|
{:ok, %Req.Response{status: status}} ->
|
|
{:error, "HTTP #{status}"}
|
|
|
|
{:error, %Req.TransportError{reason: reason}} ->
|
|
{:error, "#{reason}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, inspect(reason)}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp post(base_url, path, token, body) do
|
|
with {:ok, url} <- normalize_url(base_url) do
|
|
full_url = url <> path
|
|
|
|
case HTTP.post(__MODULE__, full_url,
|
|
headers: auth_headers(token),
|
|
json: body,
|
|
connect_options: [timeout: 10_000],
|
|
receive_timeout: 15_000
|
|
) do
|
|
{:ok, %Req.Response{status: status, body: body}} when status in [200, 201] ->
|
|
{:ok, body}
|
|
|
|
{:ok, %Req.Response{status: 401}} ->
|
|
{:error, :unauthorized}
|
|
|
|
{:ok, %Req.Response{status: status, body: body}} ->
|
|
{:error, "HTTP #{status}: #{inspect(body)}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, inspect(reason)}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp auth_headers(token) do
|
|
[{"authorization", "Token #{token}"}, {"accept", "application/json"}]
|
|
end
|
|
|
|
@invalid_url_error "NetBox URL is missing or invalid — a full URL with https:// is required"
|
|
|
|
@doc false
|
|
def normalize_url(nil), do: {:error, @invalid_url_error}
|
|
|
|
def normalize_url(url) when is_binary(url) do
|
|
trimmed = url |> String.trim() |> String.trim_trailing("/")
|
|
trimmed |> URI.parse() |> check_scheme(trimmed)
|
|
end
|
|
|
|
def normalize_url(_), do: {:error, @invalid_url_error}
|
|
|
|
defp check_scheme(%URI{scheme: scheme}, url) when scheme in ["http", "https"], do: {:ok, url}
|
|
defp check_scheme(_uri, _url), do: {:error, @invalid_url_error}
|
|
end
|