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.
199 lines
5.9 KiB
Elixir
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
|