Add NetBox sync worker + improve Gaiia error messages

- NetBox.Sync: pull devices/sites from NetBox, match by name/IP, upsert
- NetBoxSyncWorker: Oban cron every 30min for enabled NetBox integrations
- Gaiia sync: show actual error type instead of generic 'unexpected error'
This commit is contained in:
Graham McIntire 2026-02-14 17:11:30 -06:00
parent 33a8bcb0bb
commit c8e398ea05
4 changed files with 339 additions and 1 deletions

View file

@ -202,6 +202,8 @@ if config_env() == :prod do
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker},
# Sync Gaiia data every 15 minutes
{"*/15 * * * *", Towerops.Workers.GaiiaSyncWorker},
# Sync NetBox data every 30 minutes
{"*/30 * * * *", Towerops.Workers.NetBoxSyncWorker},
# Device health insights nightly at 3:30 AM
{"30 3 * * *", Towerops.Workers.DeviceHealthInsightWorker},
# System insights (agent offline detection) every 5 minutes

View file

@ -188,5 +188,18 @@ defmodule Towerops.Gaiia.Sync do
defp humanize_sync_error(%Req.TransportError{reason: reason}), do: "Connection error: #{reason}"
defp humanize_sync_error(reason) when is_binary(reason), do: reason
defp humanize_sync_error(_reason), do: "An unexpected error occurred during sync"
defp humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do
messages = Enum.map_join(errors, "; ", &(&1["message"] || inspect(&1)))
"GraphQL errors: #{messages}"
end
defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Gaiia — retry after #{retry_after}s"
defp humanize_sync_error({:unexpected_status, status}), do: "Gaiia returned unexpected HTTP #{status}"
defp humanize_sync_error(reason) do
Logger.warning("Gaiia sync failed with unexpected error: #{inspect(reason)}")
"An unexpected error occurred during sync: #{inspect(reason)}"
end
end

261
lib/towerops/netbox/sync.ex Normal file
View file

@ -0,0 +1,261 @@
defmodule Towerops.NetBox.Sync do
@moduledoc """
Orchestrates syncing data from NetBox into the local database.
Pulls devices and sites from NetBox via the REST API, upserts them
into local tables, and updates integration sync status.
"""
alias Towerops.Devices
alias Towerops.Integrations
alias Towerops.NetBox.Client
alias Towerops.Sites
require Logger
@doc """
Main entry point: syncs enabled entity types for the given integration.
Returns `{:ok, %{devices: n, sites: n}}` or `{:error, reason}`.
"""
def sync_organization(%Integrations.Integration{} = integration) do
url = integration.credentials["url"]
token = integration.credentials["api_token"]
org_id = integration.organization_id
sync_devices? = integration.credentials["sync_devices"] in [true, "true"]
sync_sites? = integration.credentials["sync_sites"] in [true, "true"]
results = %{devices: 0, sites: 0}
with {:ok, results} <- maybe_sync_sites(results, sync_sites?, url, token, org_id, integration),
{:ok, results} <- maybe_sync_devices(results, sync_devices?, url, token, org_id, integration) do
message =
"Synced #{results.sites} sites, #{results.devices} devices from NetBox"
Integrations.update_sync_status(integration, "success", message)
{:ok, results}
else
{:error, reason} ->
Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason))
{:error, reason}
end
end
defp maybe_sync_sites(results, false, _url, _token, _org_id, _integration), do: {:ok, results}
defp maybe_sync_sites(results, true, url, token, org_id, integration) do
params = build_site_params(integration.credentials)
case Client.list_sites(url, token, params) do
{:ok, %{"results" => sites}} ->
count = upsert_sites(org_id, sites)
Logger.info("NetBox sync: upserted #{count} sites for org #{org_id}")
{:ok, %{results | sites: count}}
{:error, reason} ->
{:error, reason}
end
end
defp maybe_sync_devices(results, false, _url, _token, _org_id, _integration), do: {:ok, results}
defp maybe_sync_devices(results, true, url, token, org_id, integration) do
params = build_device_params(integration.credentials)
case Client.list_devices(url, token, params) do
{:ok, %{"results" => devices}} ->
count = upsert_devices(org_id, devices)
Logger.info("NetBox sync: upserted #{count} devices for org #{org_id}")
{:ok, %{results | devices: count}}
{:error, reason} ->
{:error, reason}
end
end
# --- Sites ---
defp upsert_sites(org_id, netbox_sites) do
existing = Sites.list_organization_sites(org_id)
site_by_name = Map.new(existing, &{String.downcase(&1.name), &1})
Enum.reduce(netbox_sites, 0, fn nb_site, count ->
case upsert_site(org_id, nb_site, site_by_name) do
{:ok, _} ->
count + 1
{:error, reason} ->
Logger.warning("NetBox sync: failed to upsert site #{nb_site["name"]}: #{inspect(reason)}")
count
end
end)
end
defp upsert_site(org_id, nb_site, site_by_name) do
name = nb_site["name"] || ""
key = String.downcase(name)
latitude = nb_site |> get_in(["latitude"]) |> parse_decimal()
longitude = nb_site |> get_in(["longitude"]) |> parse_decimal()
attrs = %{
"name" => name,
"latitude" => latitude,
"longitude" => longitude,
"organization_id" => org_id
}
case Map.get(site_by_name, key) do
nil -> Sites.create_site(attrs)
existing -> Sites.update_site(existing, attrs)
end
end
# --- Devices ---
defp upsert_devices(org_id, netbox_devices) do
existing = Devices.list_organization_devices(org_id)
device_by_name = Map.new(existing, &{String.downcase(&1.name), &1})
device_by_ip =
existing
|> Enum.filter(& &1.ip_address)
|> Map.new(&{&1.ip_address, &1})
# Build site lookup for assignment
sites = Sites.list_organization_sites(org_id)
site_by_name = Map.new(sites, &{String.downcase(&1.name), &1})
Enum.reduce(netbox_devices, 0, fn nb_device, count ->
case upsert_device(org_id, nb_device, device_by_name, device_by_ip, site_by_name) do
{:ok, _} ->
count + 1
{:error, reason} ->
Logger.warning("NetBox sync: failed to upsert device #{nb_device["name"]}: #{inspect(reason)}")
count
end
end)
end
defp upsert_device(org_id, nb_device, device_by_name, device_by_ip, site_by_name) do
name = nb_device["name"] || ""
ip = extract_ip(nb_device["primary_ip4"] || nb_device["primary_ip"])
role = get_in(nb_device, ["role", "name"]) || get_in(nb_device, ["device_role", "name"])
status = get_in(nb_device, ["status", "value"]) || nb_device["status"]
serial = nb_device["serial"]
site_name = get_in(nb_device, ["site", "name"])
site_id =
if site_name do
case Map.get(site_by_name, String.downcase(site_name)) do
nil -> nil
site -> site.id
end
end
attrs = %{
"name" => name,
"ip_address" => ip,
"device_role" => role,
"status" => map_status(status),
"serial_number" => serial,
"site_id" => site_id,
"organization_id" => org_id
}
# Match by name first, then by IP
existing =
Map.get(device_by_name, String.downcase(name)) ||
(ip && Map.get(device_by_ip, ip))
case existing do
nil -> Devices.create_device(attrs)
device -> Devices.update_device(device, attrs)
end
end
# --- Helpers ---
defp extract_ip(nil), do: nil
defp extract_ip(%{"address" => addr}) when is_binary(addr) do
# NetBox returns "10.0.0.1/24" format
addr |> String.split("/") |> List.first()
end
defp extract_ip(_), do: nil
defp map_status("active"), do: "up"
defp map_status("offline"), do: "down"
defp map_status("planned"), do: "pending"
defp map_status("staged"), do: "pending"
defp map_status("decommissioning"), do: "down"
defp map_status(other), do: other
defp parse_decimal(nil), do: nil
defp parse_decimal(val) when is_float(val), do: Decimal.from_float(val)
defp parse_decimal(val) when is_binary(val) do
case Decimal.parse(val) do
{dec, _} -> dec
:error -> nil
end
end
defp parse_decimal(val) when is_integer(val), do: Decimal.new(val)
defp parse_decimal(_), do: nil
defp build_device_params(credentials) do
params = %{}
params =
case credentials["device_role_filter"] do
nil -> params
"" -> params
role -> Map.put(params, "role", role)
end
params =
case credentials["site_filter"] do
nil -> params
"" -> params
site -> Map.put(params, "site", site)
end
case credentials["tag_filter"] do
nil -> params
"" -> params
tag -> Map.put(params, "tag", tag)
end
end
defp build_site_params(credentials) do
case credentials["tag_filter"] do
nil -> %{}
"" -> %{}
tag -> %{"tag" => tag}
end
end
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your NetBox API token"
defp humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions"
defp humanize_sync_error(:not_found), do: "API endpoint not found — check your NetBox URL"
defp humanize_sync_error(:timeout), do: "Connection timed out — NetBox may be temporarily unavailable"
defp humanize_sync_error(%Req.TransportError{reason: :econnrefused}), do: "Connection refused — unable to reach NetBox"
defp humanize_sync_error(%Req.TransportError{reason: reason}), do: "Connection error: #{reason}"
defp humanize_sync_error(reason) when is_binary(reason), do: reason
defp humanize_sync_error({:unexpected_status, status}), do: "NetBox returned unexpected HTTP #{status}"
defp humanize_sync_error(reason) do
Logger.warning("NetBox sync failed with unexpected error: #{inspect(reason)}")
"An unexpected error occurred during sync: #{inspect(reason)}"
end
end

View file

@ -0,0 +1,62 @@
defmodule Towerops.Workers.NetBoxSyncWorker do
@moduledoc """
Oban cron worker that syncs NetBox data for all enabled integrations.
Runs every 30 minutes. For each org with an enabled NetBox integration,
checks if enough time has elapsed since the last sync (based on the
integration's sync_interval_minutes), then calls NetBox.Sync.sync_organization/1.
"""
use Oban.Worker, queue: :maintenance
alias Towerops.Integrations
alias Towerops.NetBox.Sync
require Logger
@impl Oban.Worker
def perform(%Oban.Job{}) do
integrations = Integrations.list_enabled_integrations("netbox")
results = Enum.map(integrations, &sync_integration/1)
synced = Enum.count(results, &match?({:ok, _}, &1))
failed = Enum.count(results, &match?({:error, _}, &1))
skipped = Enum.count(results, &(&1 == :skipped))
if synced > 0 or failed > 0 do
Logger.info("NetBox sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped")
end
:ok
end
defp sync_integration(integration) do
if should_sync?(integration) do
case Sync.sync_organization(integration) do
{:ok, result} ->
Logger.info("NetBox sync completed for org #{integration.organization_id}: #{inspect(result)}")
{:ok, result}
{:error, reason} ->
Logger.error("NetBox sync failed for org #{integration.organization_id}: #{inspect(reason)}")
{:error, reason}
end
else
:skipped
end
end
defp should_sync?(integration) do
case integration.last_synced_at do
nil ->
true
last_synced_at ->
interval_seconds = (integration.sync_interval_minutes || 30) * 60
elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second)
elapsed >= interval_seconds
end
end
end