towerops/lib/towerops/uisp/config_snapshot.ex
Graham McIntire 4421727e61 fix: resolve all Elixir 1.20 type system warnings across project
Removes unreachable catch-all clauses, drops unused `require Logger`
lines, adds pin operators to bitstring size patterns, reorders function
heads for correct dispatch, and replaces deprecated LoggerBackends calls.
2026-05-28 14:52:30 -05:00

199 lines
5.9 KiB
Elixir

defmodule Towerops.Uisp.ConfigSnapshot do
@moduledoc """
Stores and diffs UISP device configuration snapshots.
Fetches device configuration from UISP API, stores compressed snapshots,
and provides diff functionality between versions.
"""
import Ecto.Query
alias Towerops.Repo
alias Towerops.Uisp.Client
@doc """
Fetches and stores configuration snapshots for all UISP devices.
Only stores a new snapshot if the config has changed (based on hash).
Returns `{:ok, %{stored: count, unchanged: count}}`.
"""
def sync_configs(%{credentials: credentials} = _integration, device_map) do
base_url = credentials["url"]
api_key = credentials["api_key"]
result =
Enum.reduce(device_map, %{stored: 0, unchanged: 0}, fn {uisp_device_id, local_device}, acc ->
case fetch_device_config(base_url, api_key, uisp_device_id) do
{:ok, config_data} ->
store_if_changed(local_device, config_data, acc)
{:error, _reason} ->
acc
end
end)
{:ok, result}
end
defp fetch_device_config(base_url, api_key, device_id) do
Client.request_raw(:get, "#{base_url}/devices/#{device_id}", api_key)
end
@doc false
def store_if_changed(device, config_data, acc) do
config_json = Jason.encode!(config_data)
config_hash = :sha256 |> :crypto.hash(config_json) |> Base.encode16(case: :lower)
latest = get_latest_snapshot(device.id)
if is_nil(latest) or latest.config_hash != config_hash do
compressed = :zlib.compress(config_json)
attrs = %{
device_id: device.id,
config_compressed: compressed,
config_hash: config_hash,
config_size_bytes: byte_size(config_json),
compressed_size_bytes: byte_size(compressed),
source: "uisp",
snapshot_at: Towerops.Time.now()
}
case insert_snapshot(attrs) do
{:ok, _} -> Map.update!(acc, :stored, &(&1 + 1))
{:error, _} -> acc
end
else
Map.update!(acc, :unchanged, &(&1 + 1))
end
end
@doc """
Gets the latest config snapshot for a device.
"""
def get_latest_snapshot(device_id) do
device_id = cast_uuid(device_id)
Repo.one(
from(s in "uisp_config_snapshots",
where: s.device_id == type(^device_id, :binary_id),
order_by: [desc: s.snapshot_at],
limit: 1,
select: %{
id: s.id,
device_id: s.device_id,
config_hash: s.config_hash,
config_size_bytes: s.config_size_bytes,
snapshot_at: s.snapshot_at
}
)
)
end
@doc """
Lists config snapshots for a device, most recent first.
"""
def list_snapshots(device_id, limit \\ 20) do
device_id = cast_uuid(device_id)
Repo.all(
from(s in "uisp_config_snapshots",
where: s.device_id == type(^device_id, :binary_id),
order_by: [desc: s.snapshot_at],
limit: ^limit,
select: %{
id: s.id,
config_hash: s.config_hash,
config_size_bytes: s.config_size_bytes,
compressed_size_bytes: s.compressed_size_bytes,
snapshot_at: s.snapshot_at
}
)
)
end
@doc """
Gets and decompresses a specific snapshot's config.
"""
def get_config(snapshot_id) do
snapshot_id = cast_uuid(snapshot_id)
case Repo.one(
from(s in "uisp_config_snapshots",
where: s.id == type(^snapshot_id, :binary_id),
select: s.config_compressed
)
) do
nil ->
{:error, :not_found}
compressed ->
{:ok, compressed |> :zlib.uncompress() |> Jason.decode!()}
end
end
# Normalizes a UUID input — schemas hand us a string; callers may pass a
# 16-byte binary. `type(^value, :binary_id)` handles both uniformly as long
# as the Elixir value is a string.
defp cast_uuid(value) when is_binary(value) and byte_size(value) == 16 do
case Ecto.UUID.load(value) do
{:ok, str} -> str
_ -> value
end
end
defp cast_uuid(value), do: value
@doc """
Diffs two config snapshots, returning added/removed/changed keys.
"""
def diff_snapshots(snapshot_id_a, snapshot_id_b) do
with {:ok, config_a} <- get_config(snapshot_id_a),
{:ok, config_b} <- get_config(snapshot_id_b) do
{:ok, compute_diff(config_a, config_b)}
end
end
@doc """
Computes a shallow diff of two config maps.
Returns `%{added: [...], removed: [...], changed: [...]}` where:
- `added` entries are `{key, new_value}` (present only in `b`)
- `removed` entries are `{key, old_value}` (present only in `a`)
- `changed` entries are `{key, old_value, new_value}` (differ between sides)
Non-map inputs yield an empty diff.
"""
@spec compute_diff(map(), map()) :: %{added: list(), removed: list(), changed: list()}
def compute_diff(a, b) when is_map(a) and is_map(b) do
all_keys = MapSet.union(MapSet.new(Map.keys(a)), MapSet.new(Map.keys(b)))
Enum.reduce(all_keys, %{added: [], removed: [], changed: []}, fn key, acc ->
classify_key(acc, key, Map.get(a, key), Map.get(b, key))
end)
end
def compute_diff(_a, _b), do: %{added: [], removed: [], changed: []}
defp classify_key(acc, key, nil, new_val), do: Map.update!(acc, :added, &[{key, new_val} | &1])
defp classify_key(acc, key, old_val, nil), do: Map.update!(acc, :removed, &[{key, old_val} | &1])
defp classify_key(acc, _key, same, same), do: acc
defp classify_key(acc, key, old_val, new_val), do: Map.update!(acc, :changed, &[{key, old_val, new_val} | &1])
defp insert_snapshot(attrs) do
{:ok, id_binary} = Ecto.UUID.dump(Ecto.UUID.generate())
{:ok, device_id_binary} = Ecto.UUID.dump(attrs.device_id)
row = Map.merge(attrs, %{id: id_binary, device_id: device_id_binary, inserted_at: Towerops.Time.now()})
"uisp_config_snapshots"
|> Repo.insert_all([row])
|> case do
{1, _} -> {:ok, :inserted}
_ -> {:error, :insert_failed}
end
end
end