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:
parent
33a8bcb0bb
commit
c8e398ea05
4 changed files with 339 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
261
lib/towerops/netbox/sync.ex
Normal 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
|
||||
62
lib/towerops/workers/netbox_sync_worker.ex
Normal file
62
lib/towerops/workers/netbox_sync_worker.ex
Normal 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
|
||||
Loading…
Add table
Reference in a new issue