- Add firmware context module with upsert, query, and logging functions - Add FirmwareVersionFetcherWorker to fetch MikroTik RSS daily - Add Oban cron schedules (2 AM dev, 4 AM prod) - Add version change detection to Discovery module - Track firmware history with PubSub broadcasts - All tests passing Phase 3-5 of firmware tracking implementation complete. Next: LiveView UI indicators.
954 lines
28 KiB
Markdown
954 lines
28 KiB
Markdown
# Firmware Version Tracking System Design
|
|
|
|
**Date:** 2026-02-01
|
|
**Status:** Draft
|
|
**Target:** MikroTik initially, extensible to all vendors
|
|
|
|
## Overview
|
|
|
|
Implement a firmware version tracking system that:
|
|
1. Fetches latest stable firmware versions from vendor sources (MikroTik RSS feed initially)
|
|
2. Tracks firmware version changes over time with audit logging
|
|
3. Displays update indicators on device detail pages with download links
|
|
4. Supports multiple vendors through polymorphic design
|
|
|
|
## Database Schema
|
|
|
|
### New Tables
|
|
|
|
#### 1. `firmware_releases` - Latest available firmware versions
|
|
|
|
Stores the most recent stable firmware version for each vendor/product line.
|
|
|
|
```elixir
|
|
create table(:firmware_releases, primary_key: false) do
|
|
add :id, :binary_id, primary_key: true
|
|
add :vendor, :string, null: false # "mikrotik", "cisco", "ubiquiti"
|
|
add :product_line, :string # "routeros", "ios-xe", "unifi", null for single product vendors
|
|
add :version, :string, null: false # "7.14.1", "17.9.4a"
|
|
add :release_date, :date # When this version was released
|
|
add :download_url, :string # Official download page URL
|
|
add :changelog_url, :string # Release notes URL
|
|
add :metadata, :map, default: %{} # JSON: RSS item data, API response, etc.
|
|
add :fetched_at, :utc_datetime, null: false # When we last fetched this
|
|
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
# Unique constraint: one current version per vendor/product_line
|
|
create unique_index(:firmware_releases, [:vendor, :product_line])
|
|
```
|
|
|
|
**Design Notes:**
|
|
- `vendor` is lowercase string for consistency ("mikrotik", not "MikroTik")
|
|
- `product_line` allows vendors with multiple firmware branches (Cisco IOS vs IOS-XE, MikroTik RouterOS vs SwOS)
|
|
- `metadata` stores raw source data for debugging and future feature extraction
|
|
- Single record per vendor/product_line, updated in place when new version detected
|
|
|
|
#### 2. `device_firmware_history` - Version change audit trail
|
|
|
|
Tracks when devices change firmware versions.
|
|
|
|
```elixir
|
|
create table(:device_firmware_history, primary_key: false) do
|
|
add :id, :binary_id, primary_key: true
|
|
add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all), null: false
|
|
add :old_version, :string # Previous version (null for first discovery)
|
|
add :new_version, :string, null: false # New version detected
|
|
add :detected_at, :utc_datetime, null: false # When the change was detected
|
|
add :detection_method, :string # "discovery", "polling", "manual"
|
|
|
|
timestamps(type: :utc_datetime, updated_at: false)
|
|
end
|
|
|
|
create index(:device_firmware_history, [:snmp_device_id, :detected_at])
|
|
create index(:device_firmware_history, [:detected_at])
|
|
```
|
|
|
|
**Design Notes:**
|
|
- Links to `snmp_devices` (not `devices`) since firmware is SNMP-discovered data
|
|
- `old_version` nullable for initial discovery (no previous version)
|
|
- `detected_at` separate from `inserted_at` to record actual change time vs when we logged it
|
|
- Index on `snmp_device_id + detected_at` for efficient device history queries
|
|
- Cascading delete: history removed when SNMP device deleted
|
|
|
|
### Schema Modifications
|
|
|
|
No changes to existing `snmp_devices` table needed - the existing `firmware_version` field remains as the "current version" source of truth.
|
|
|
|
## RSS Fetching Implementation
|
|
|
|
### Oban Worker: `FirmwareVersionFetcherWorker`
|
|
|
|
**Location:** `lib/towerops/workers/firmware_version_fetcher_worker.ex`
|
|
|
|
```elixir
|
|
defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
|
|
use Oban.Worker, queue: :maintenance, max_attempts: 3
|
|
require Logger
|
|
|
|
@impl Oban.Worker
|
|
def perform(%Oban.Job{}) do
|
|
Logger.info("Starting firmware version fetch")
|
|
|
|
# Fetch each vendor's latest firmware
|
|
results = [
|
|
fetch_mikrotik_routeros(),
|
|
# Future: fetch_cisco_ios(),
|
|
# Future: fetch_ubiquiti_unifi()
|
|
]
|
|
|
|
case Enum.all?(results, &match?(:ok, &1)) do
|
|
true -> :ok
|
|
false -> {:error, "One or more firmware fetches failed"}
|
|
end
|
|
end
|
|
|
|
defp fetch_mikrotik_routeros do
|
|
# Implementation details below
|
|
end
|
|
end
|
|
```
|
|
|
|
**Cron Schedule:** Daily at 2:00 AM
|
|
|
|
Add to `config/dev.exs` and `config/runtime.exs` (production):
|
|
|
|
```elixir
|
|
config :towerops, Oban,
|
|
plugins: [
|
|
{Oban.Plugins.Cron,
|
|
crontab: [
|
|
# ... existing cron jobs ...
|
|
{"0 2 * * *", Towerops.Workers.FirmwareVersionFetcherWorker}
|
|
]}
|
|
]
|
|
```
|
|
|
|
### MikroTik RSS Parsing
|
|
|
|
**RSS URL:** `https://cdn.mikrotik.com/routeros/latest-stable.rss`
|
|
|
|
**Sample RSS Structure:**
|
|
```xml
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<title>RouterOS latest stable version</title>
|
|
<item>
|
|
<title>7.14.1</title>
|
|
<link>https://mikrotik.com/download</link>
|
|
<pubDate>Wed, 15 Jan 2025 12:00:00 +0000</pubDate>
|
|
<description>RouterOS 7.14.1 stable release</description>
|
|
</item>
|
|
</channel>
|
|
</rss>
|
|
```
|
|
|
|
**Parsing Implementation:**
|
|
|
|
```elixir
|
|
defp fetch_mikrotik_routeros do
|
|
url = "https://cdn.mikrotik.com/routeros/latest-stable.rss"
|
|
|
|
with {:ok, %{status: 200, body: body}} <- Req.get(url),
|
|
{:ok, parsed} <- parse_mikrotik_rss(body),
|
|
{:ok, _release} <- upsert_firmware_release(parsed) do
|
|
Logger.info("Successfully fetched MikroTik RouterOS: #{parsed.version}")
|
|
:ok
|
|
else
|
|
{:error, reason} = error ->
|
|
Logger.error("Failed to fetch MikroTik firmware: #{inspect(reason)}")
|
|
error
|
|
end
|
|
end
|
|
|
|
defp parse_mikrotik_rss(xml_body) do
|
|
# Use :xmerl or SweetXml library
|
|
import SweetXml
|
|
|
|
try do
|
|
version = xml_body |> xpath(~x"//item/title/text()"s) |> String.trim()
|
|
link = xml_body |> xpath(~x"//item/link/text()"s) |> String.trim()
|
|
pub_date_str = xml_body |> xpath(~x"//item/pubDate/text()"s) |> String.trim()
|
|
|
|
release_date = parse_rfc822_date(pub_date_str)
|
|
|
|
{:ok, %{
|
|
vendor: "mikrotik",
|
|
product_line: "routeros",
|
|
version: version,
|
|
release_date: release_date,
|
|
download_url: link,
|
|
changelog_url: "https://mikrotik.com/download/changelogs",
|
|
metadata: %{
|
|
rss_title: version,
|
|
rss_description: xml_body |> xpath(~x"//item/description/text()"s)
|
|
}
|
|
}}
|
|
rescue
|
|
e -> {:error, "XML parsing failed: #{inspect(e)}"}
|
|
end
|
|
end
|
|
|
|
defp parse_rfc822_date(date_str) do
|
|
# "Wed, 15 Jan 2025 12:00:00 +0000" -> ~D[2025-01-15]
|
|
case Timex.parse(date_str, "{RFC822}") do
|
|
{:ok, datetime} -> DateTime.to_date(datetime)
|
|
_ -> Date.utc_today() # Fallback to today if parse fails
|
|
end
|
|
end
|
|
```
|
|
|
|
**Dependencies:**
|
|
- Add `{:sweet_xml, "~> 0.7"}` to `mix.exs` for XML parsing
|
|
- Use existing `:req` for HTTP (already in project)
|
|
- Add `{:timex, "~> 3.7"}` for RFC822 date parsing (or use standard library alternative)
|
|
|
|
### Database Upsert Logic
|
|
|
|
```elixir
|
|
defp upsert_firmware_release(attrs) do
|
|
import Ecto.Query
|
|
|
|
# Use ON CONFLICT to update existing record
|
|
%Towerops.Devices.FirmwareRelease{}
|
|
|> Towerops.Devices.FirmwareRelease.changeset(attrs)
|
|
|> Towerops.Repo.insert(
|
|
on_conflict: {:replace_all_except, [:id, :inserted_at]},
|
|
conflict_target: [:vendor, :product_line]
|
|
)
|
|
end
|
|
```
|
|
|
|
## Version Change Detection
|
|
|
|
### Integration Point: Discovery Flow
|
|
|
|
**File:** `lib/towerops/snmp/discovery.ex`
|
|
|
|
**Current Flow:**
|
|
1. `run_discovery/1` orchestrates discovery stages
|
|
2. `build_device_info/3` extracts firmware version from SNMP
|
|
3. `upsert_device/2` updates/inserts SNMP device record
|
|
|
|
**New Logic:** Add version change detection in `upsert_device/2`
|
|
|
|
```elixir
|
|
defp upsert_device(device, device_info) do
|
|
# Fetch current version BEFORE update
|
|
current_version = get_current_firmware_version(device.id)
|
|
new_version = device_info[:firmware_version]
|
|
|
|
# Perform existing upsert
|
|
result =
|
|
device
|
|
|> SnmpDevice.changeset(device_info)
|
|
|> Repo.insert_or_update()
|
|
|
|
# Detect and log version change
|
|
case result do
|
|
{:ok, snmp_device} ->
|
|
if version_changed?(current_version, new_version) do
|
|
log_firmware_change(snmp_device.id, current_version, new_version)
|
|
end
|
|
{:ok, snmp_device}
|
|
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp get_current_firmware_version(device_id) do
|
|
case Repo.get_by(SnmpDevice, device_id: device_id) do
|
|
nil -> nil
|
|
snmp_device -> snmp_device.firmware_version
|
|
end
|
|
end
|
|
|
|
defp version_changed?(nil, new_version) when is_binary(new_version), do: false # Initial discovery
|
|
defp version_changed?(old, new) when old == new, do: false
|
|
defp version_changed?(old, new) when is_binary(old) and is_binary(new), do: true
|
|
defp version_changed?(_, _), do: false
|
|
```
|
|
|
|
### Firmware Change Logging
|
|
|
|
**File:** `lib/towerops/devices/firmware.ex` (new context module)
|
|
|
|
```elixir
|
|
defmodule Towerops.Devices.Firmware do
|
|
import Ecto.Query
|
|
alias Towerops.Repo
|
|
alias Towerops.Devices.DeviceFirmwareHistory
|
|
|
|
def log_firmware_change(snmp_device_id, old_version, new_version) do
|
|
attrs = %{
|
|
snmp_device_id: snmp_device_id,
|
|
old_version: old_version,
|
|
new_version: new_version,
|
|
detected_at: DateTime.utc_now(),
|
|
detection_method: "discovery"
|
|
}
|
|
|
|
%DeviceFirmwareHistory{}
|
|
|> DeviceFirmwareHistory.changeset(attrs)
|
|
|> Repo.insert()
|
|
|> case do
|
|
{:ok, history} ->
|
|
# Create audit log entry
|
|
create_audit_log(history)
|
|
|
|
# Broadcast to device topic for real-time updates
|
|
broadcast_firmware_change(snmp_device_id, old_version, new_version)
|
|
|
|
{:ok, history}
|
|
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp create_audit_log(history) do
|
|
# Use existing audit log system if available
|
|
# Or create device event
|
|
Logger.info("Firmware changed on device #{history.snmp_device_id}: #{history.old_version} -> #{history.new_version}")
|
|
end
|
|
|
|
defp broadcast_firmware_change(snmp_device_id, old_version, new_version) do
|
|
Phoenix.PubSub.broadcast(
|
|
Towerops.PubSub,
|
|
"device:#{snmp_device_id}",
|
|
{:firmware_changed, snmp_device_id, old_version, new_version}
|
|
)
|
|
end
|
|
end
|
|
```
|
|
|
|
## Version Comparison Logic
|
|
|
|
### Semantic Version Parser
|
|
|
|
**File:** `lib/towerops/devices/version_comparator.ex`
|
|
|
|
```elixir
|
|
defmodule Towerops.Devices.VersionComparator do
|
|
@moduledoc """
|
|
Semantic version comparison for firmware versions.
|
|
|
|
Handles common formats:
|
|
- X.Y.Z (7.14.1)
|
|
- X.Y (7.14)
|
|
- X.Y.Z-suffix (7.14.1-beta)
|
|
"""
|
|
|
|
def compare(version1, version2) do
|
|
parsed1 = parse_version(version1)
|
|
parsed2 = parse_version(version2)
|
|
|
|
do_compare(parsed1, parsed2)
|
|
end
|
|
|
|
def newer?(current, available) do
|
|
compare(current, available) == :lt
|
|
end
|
|
|
|
defp parse_version(version) when is_binary(version) do
|
|
# Remove common prefixes
|
|
cleaned = version
|
|
|> String.trim()
|
|
|> String.replace(~r/^v/i, "")
|
|
|
|
# Split on dots and extract numbers
|
|
parts = cleaned
|
|
|> String.split(["-", " "], parts: 2)
|
|
|> List.first()
|
|
|> String.split(".")
|
|
|> Enum.map(&String.to_integer/1)
|
|
|
|
# Pad to [major, minor, patch] format
|
|
case parts do
|
|
[major, minor, patch] -> {major, minor, patch}
|
|
[major, minor] -> {major, minor, 0}
|
|
[major] -> {major, 0, 0}
|
|
_ -> {0, 0, 0}
|
|
end
|
|
rescue
|
|
_ -> {0, 0, 0} # Invalid version defaults to 0.0.0
|
|
end
|
|
|
|
defp do_compare({maj1, min1, patch1}, {maj2, min2, patch2}) do
|
|
cond do
|
|
maj1 > maj2 -> :gt
|
|
maj1 < maj2 -> :lt
|
|
min1 > min2 -> :gt
|
|
min1 < min2 -> :lt
|
|
patch1 > patch2 -> :gt
|
|
patch1 < patch2 -> :lt
|
|
true -> :eq
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
**Test Cases:**
|
|
|
|
```elixir
|
|
defmodule Towerops.Devices.VersionComparatorTest do
|
|
use ExUnit.Case, async: true
|
|
alias Towerops.Devices.VersionComparator
|
|
|
|
describe "compare/2" do
|
|
test "major version differences" do
|
|
assert VersionComparator.compare("7.14.1", "8.0.0") == :lt
|
|
assert VersionComparator.compare("8.0.0", "7.14.1") == :gt
|
|
end
|
|
|
|
test "minor version differences" do
|
|
assert VersionComparator.compare("7.12.1", "7.14.1") == :lt
|
|
assert VersionComparator.compare("7.14.1", "7.12.1") == :gt
|
|
end
|
|
|
|
test "patch version differences" do
|
|
assert VersionComparator.compare("7.14.1", "7.14.3") == :lt
|
|
assert VersionComparator.compare("7.14.3", "7.14.1") == :gt
|
|
end
|
|
|
|
test "equal versions" do
|
|
assert VersionComparator.compare("7.14.1", "7.14.1") == :eq
|
|
end
|
|
|
|
test "handles missing patch versions" do
|
|
assert VersionComparator.compare("7.14", "7.14.1") == :lt
|
|
assert VersionComparator.compare("7.14.0", "7.14") == :eq
|
|
end
|
|
|
|
test "handles version prefixes" do
|
|
assert VersionComparator.compare("v7.14.1", "7.14.2") == :lt
|
|
assert VersionComparator.compare("V7.14.1", "7.14.2") == :lt
|
|
end
|
|
|
|
test "handles beta/rc suffixes" do
|
|
assert VersionComparator.compare("7.14.1-beta", "7.14.1") == :eq # Ignores suffix
|
|
assert VersionComparator.compare("7.14.1-rc1", "7.14.2") == :lt
|
|
end
|
|
|
|
test "handles invalid versions" do
|
|
assert VersionComparator.compare("invalid", "7.14.1") == :lt
|
|
assert VersionComparator.compare("7.14.1", "invalid") == :gt
|
|
end
|
|
end
|
|
|
|
describe "newer?/2" do
|
|
test "returns true when available version is newer" do
|
|
assert VersionComparator.newer?("7.12.1", "7.14.1")
|
|
end
|
|
|
|
test "returns false when current version is newer or equal" do
|
|
refute VersionComparator.newer?("7.14.1", "7.12.1")
|
|
refute VersionComparator.newer?("7.14.1", "7.14.1")
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
## LiveView Integration
|
|
|
|
### Device Detail Page Updates
|
|
|
|
**File:** `lib/towerops_web/live/device_live/show.ex`
|
|
|
|
**Add to mount/assigns:**
|
|
|
|
```elixir
|
|
def mount(%{"id" => id}, _session, socket) do
|
|
# ... existing code ...
|
|
|
|
socket = socket
|
|
# ... existing assigns ...
|
|
|> assign(:available_firmware, nil) # Will be loaded on first refresh
|
|
|> load_available_firmware() # New helper
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
defp load_available_firmware(socket) do
|
|
snmp_device = socket.assigns.snmp_device
|
|
|
|
case get_available_firmware(snmp_device) do
|
|
{:ok, firmware_release} ->
|
|
assign(socket, :available_firmware, firmware_release)
|
|
|
|
{:error, _} ->
|
|
assign(socket, :available_firmware, nil)
|
|
end
|
|
end
|
|
|
|
defp get_available_firmware(nil), do: {:error, :no_snmp_device}
|
|
defp get_available_firmware(snmp_device) do
|
|
# Determine vendor/product_line from snmp_device.manufacturer
|
|
vendor = determine_vendor(snmp_device.manufacturer)
|
|
product_line = determine_product_line(snmp_device.manufacturer, snmp_device.model)
|
|
|
|
case Towerops.Devices.Firmware.get_latest_release(vendor, product_line) do
|
|
nil -> {:error, :no_release_data}
|
|
release -> {:ok, release}
|
|
end
|
|
end
|
|
|
|
defp determine_vendor("MikroTik"), do: "mikrotik"
|
|
defp determine_vendor("Cisco"), do: "cisco"
|
|
defp determine_vendor(_), do: nil
|
|
|
|
defp determine_product_line("MikroTik", _model), do: "routeros"
|
|
# Future: Cisco IOS vs IOS-XE detection based on model
|
|
defp determine_product_line(_, _), do: nil
|
|
```
|
|
|
|
**Add to PubSub handler:**
|
|
|
|
```elixir
|
|
def handle_info({:firmware_changed, _snmp_device_id, _old, _new}, socket) do
|
|
# Reload firmware data when change detected
|
|
{:noreply, load_available_firmware(socket)}
|
|
end
|
|
```
|
|
|
|
### UI Component
|
|
|
|
**File:** `lib/towerops_web/live/device_live/show.html.heex`
|
|
|
|
**Add near top of Overview tab (after device name/status):**
|
|
|
|
```heex
|
|
<!-- Firmware Update Indicator -->
|
|
<%= if firmware_update_available?(@snmp_device, @available_firmware) do %>
|
|
<div class="mt-4 rounded-md bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800">
|
|
<div class="flex">
|
|
<div class="flex-shrink-0">
|
|
<.icon name="hero-arrow-up-circle" class="h-5 w-5 text-blue-400" />
|
|
</div>
|
|
<div class="ml-3 flex-1">
|
|
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">
|
|
Firmware Update Available
|
|
</h3>
|
|
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
|
<p>
|
|
Current version: <span class="font-mono font-semibold"><%= @snmp_device.firmware_version %></span>
|
|
<br>
|
|
Latest version: <span class="font-mono font-semibold"><%= @available_firmware.version %></span>
|
|
<span :if={@available_firmware.release_date} class="text-xs">
|
|
(released <%= format_date(@available_firmware.release_date) %>)
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div class="mt-3">
|
|
<a
|
|
href={@available_firmware.download_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
|
>
|
|
Download Firmware
|
|
<.icon name="hero-arrow-top-right-on-square" class="ml-1.5 h-4 w-4" />
|
|
</a>
|
|
<a
|
|
:if={@available_firmware.changelog_url}
|
|
href={@available_firmware.changelog_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="ml-3 inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500"
|
|
>
|
|
View Release Notes
|
|
<.icon name="hero-arrow-top-right-on-square" class="ml-1 h-4 w-4" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
```
|
|
|
|
**Helper functions:**
|
|
|
|
```elixir
|
|
defp firmware_update_available?(nil, _), do: false
|
|
defp firmware_update_available?(_, nil), do: false
|
|
defp firmware_update_available?(snmp_device, available_firmware) do
|
|
current = snmp_device.firmware_version
|
|
available = available_firmware.version
|
|
|
|
current && available &&
|
|
Towerops.Devices.VersionComparator.newer?(current, available)
|
|
end
|
|
|
|
defp format_date(nil), do: ""
|
|
defp format_date(date) do
|
|
Calendar.strftime(date, "%B %-d, %Y") # "January 15, 2026"
|
|
end
|
|
```
|
|
|
|
## Context Module: `Towerops.Devices.Firmware`
|
|
|
|
**File:** `lib/towerops/devices/firmware.ex`
|
|
|
|
```elixir
|
|
defmodule Towerops.Devices.Firmware do
|
|
@moduledoc """
|
|
Context for firmware version tracking and management.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
alias Towerops.Repo
|
|
alias Towerops.Devices.{FirmwareRelease, DeviceFirmwareHistory}
|
|
require Logger
|
|
|
|
## Firmware Releases
|
|
|
|
def get_latest_release(vendor, product_line) do
|
|
Repo.get_by(FirmwareRelease, vendor: vendor, product_line: product_line)
|
|
end
|
|
|
|
def upsert_firmware_release(attrs) do
|
|
%FirmwareRelease{}
|
|
|> FirmwareRelease.changeset(attrs)
|
|
|> Repo.insert(
|
|
on_conflict: {:replace_all_except, [:id, :inserted_at]},
|
|
conflict_target: [:vendor, :product_line]
|
|
)
|
|
end
|
|
|
|
## Firmware History
|
|
|
|
def list_device_firmware_history(snmp_device_id, limit \\ 20) do
|
|
DeviceFirmwareHistory
|
|
|> where([h], h.snmp_device_id == ^snmp_device_id)
|
|
|> order_by([h], desc: h.detected_at)
|
|
|> limit(^limit)
|
|
|> Repo.all()
|
|
end
|
|
|
|
def log_firmware_change(snmp_device_id, old_version, new_version) do
|
|
attrs = %{
|
|
snmp_device_id: snmp_device_id,
|
|
old_version: old_version,
|
|
new_version: new_version,
|
|
detected_at: DateTime.utc_now(),
|
|
detection_method: "discovery"
|
|
}
|
|
|
|
%DeviceFirmwareHistory{}
|
|
|> DeviceFirmwareHistory.changeset(attrs)
|
|
|> Repo.insert()
|
|
|> case do
|
|
{:ok, history} ->
|
|
log_change_event(history)
|
|
broadcast_firmware_change(snmp_device_id, old_version, new_version)
|
|
{:ok, history}
|
|
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp log_change_event(history) do
|
|
Logger.info(
|
|
"Firmware version changed",
|
|
snmp_device_id: history.snmp_device_id,
|
|
old_version: history.old_version,
|
|
new_version: history.new_version,
|
|
detected_at: history.detected_at
|
|
)
|
|
end
|
|
|
|
defp broadcast_firmware_change(snmp_device_id, old_version, new_version) do
|
|
Phoenix.PubSub.broadcast(
|
|
Towerops.PubSub,
|
|
"device:#{snmp_device_id}",
|
|
{:firmware_changed, snmp_device_id, old_version, new_version}
|
|
)
|
|
end
|
|
end
|
|
```
|
|
|
|
## Extensibility for Future Vendors
|
|
|
|
### Adding Cisco IOS Support (Example)
|
|
|
|
**1. Add fetch function to worker:**
|
|
|
|
```elixir
|
|
# In FirmwareVersionFetcherWorker
|
|
defp fetch_cisco_ios do
|
|
# Cisco uses different mechanism - maybe API or web scraping
|
|
url = "https://software.cisco.com/download/latest/ios-xe"
|
|
|
|
with {:ok, %{status: 200, body: body}} <- Req.get(url, headers: [{"Accept", "application/json"}]),
|
|
{:ok, parsed} <- parse_cisco_api(body),
|
|
{:ok, _release} <- Towerops.Devices.Firmware.upsert_firmware_release(parsed) do
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp parse_cisco_api(json_body) do
|
|
# Vendor-specific parsing
|
|
data = Jason.decode!(json_body)
|
|
|
|
{:ok, %{
|
|
vendor: "cisco",
|
|
product_line: "ios-xe",
|
|
version: data["latestVersion"],
|
|
download_url: data["downloadUrl"],
|
|
# ...
|
|
}}
|
|
end
|
|
```
|
|
|
|
**2. Update vendor detection in LiveView:**
|
|
|
|
```elixir
|
|
defp determine_vendor("Cisco Systems"), do: "cisco"
|
|
|
|
defp determine_product_line("Cisco Systems", model) do
|
|
cond do
|
|
String.contains?(model, "Catalyst") -> "ios-xe"
|
|
String.contains?(model, "ASR") -> "ios-xr"
|
|
true -> "ios"
|
|
end
|
|
end
|
|
```
|
|
|
|
**3. Run migration to add new firmware release record** (happens automatically via worker)
|
|
|
|
### Configuration-Based Approach (Future Enhancement)
|
|
|
|
Store vendor fetch configurations in database or config file:
|
|
|
|
```elixir
|
|
# config/firmware_sources.exs
|
|
[
|
|
%{
|
|
vendor: "mikrotik",
|
|
product_line: "routeros",
|
|
source_type: :rss,
|
|
url: "https://cdn.mikrotik.com/routeros/latest-stable.rss",
|
|
parser: Towerops.Devices.FirmwareParsers.MikroTikRSS
|
|
},
|
|
%{
|
|
vendor: "cisco",
|
|
product_line: "ios-xe",
|
|
source_type: :api,
|
|
url: "https://software.cisco.com/api/latest",
|
|
parser: Towerops.Devices.FirmwareParsers.CiscoAPI,
|
|
auth: :api_key
|
|
}
|
|
]
|
|
```
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
|
|
**1. Version Comparator Tests** (see above)
|
|
|
|
**2. RSS Parsing Tests:**
|
|
|
|
```elixir
|
|
defmodule Towerops.Workers.FirmwareVersionFetcherWorkerTest do
|
|
use Towerops.DataCase
|
|
|
|
test "parses MikroTik RSS correctly" do
|
|
xml = """
|
|
<?xml version="1.0"?>
|
|
<rss version="2.0">
|
|
<channel>
|
|
<item>
|
|
<title>7.14.1</title>
|
|
<link>https://mikrotik.com/download</link>
|
|
<pubDate>Wed, 15 Jan 2025 12:00:00 +0000</pubDate>
|
|
</item>
|
|
</channel>
|
|
</rss>
|
|
"""
|
|
|
|
# Test parsing logic
|
|
end
|
|
end
|
|
```
|
|
|
|
**3. Version Change Detection:**
|
|
|
|
```elixir
|
|
test "detects firmware version change during discovery" do
|
|
device = insert(:device)
|
|
snmp_device = insert(:snmp_device, device: device, firmware_version: "7.12.1")
|
|
|
|
# Run discovery with new version
|
|
# Assert DeviceFirmwareHistory record created
|
|
# Assert PubSub broadcast sent
|
|
end
|
|
|
|
test "does not log change for initial discovery" do
|
|
device = insert(:device)
|
|
# No existing snmp_device
|
|
|
|
# Run discovery
|
|
# Assert no DeviceFirmwareHistory record
|
|
end
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
**1. Worker Execution:**
|
|
|
|
```elixir
|
|
test "FirmwareVersionFetcherWorker fetches and stores MikroTik version" do
|
|
# Mock HTTP response
|
|
# Execute worker
|
|
# Assert FirmwareRelease record created/updated
|
|
end
|
|
```
|
|
|
|
**2. LiveView Display:**
|
|
|
|
```elixir
|
|
test "shows firmware update indicator when newer version available" do
|
|
firmware_release = insert(:firmware_release, vendor: "mikrotik", version: "7.14.1")
|
|
snmp_device = insert(:snmp_device, manufacturer: "MikroTik", firmware_version: "7.12.1")
|
|
|
|
{:ok, view, _html} = live(conn, ~p"/devices/#{snmp_device.device_id}")
|
|
|
|
assert has_element?(view, "[data-test='firmware-update-indicator']")
|
|
assert render(view) =~ "7.14.1"
|
|
end
|
|
```
|
|
|
|
## Implementation Checklist
|
|
|
|
### Phase 1: Database Schema
|
|
- [ ] Create migration for `firmware_releases` table
|
|
- [ ] Create migration for `device_firmware_history` table
|
|
- [ ] Create `FirmwareRelease` schema module
|
|
- [ ] Create `DeviceFirmwareHistory` schema module
|
|
- [ ] Create `Towerops.Devices.Firmware` context module
|
|
- [ ] Write tests for schema validations
|
|
|
|
### Phase 2: Version Comparison
|
|
- [ ] Create `VersionComparator` module
|
|
- [ ] Write comprehensive version comparison tests
|
|
- [ ] Handle edge cases (missing patches, prefixes, suffixes)
|
|
|
|
### Phase 3: RSS Fetching Worker
|
|
- [ ] Add `sweet_xml` dependency to mix.exs
|
|
- [ ] Create `FirmwareVersionFetcherWorker`
|
|
- [ ] Implement MikroTik RSS parsing
|
|
- [ ] Add worker to Oban cron schedule
|
|
- [ ] Write worker tests with mocked HTTP responses
|
|
- [ ] Test error handling (network failures, malformed XML)
|
|
|
|
### Phase 4: Version Change Detection
|
|
- [ ] Modify `Discovery.upsert_device/2` to detect changes
|
|
- [ ] Implement `Firmware.log_firmware_change/3`
|
|
- [ ] Add PubSub broadcast for firmware changes
|
|
- [ ] Write integration tests for change detection
|
|
- [ ] Test initial discovery (no history created)
|
|
|
|
### Phase 5: LiveView Integration
|
|
- [ ] Add `available_firmware` assign to DeviceLive.Show
|
|
- [ ] Implement `load_available_firmware/1` helper
|
|
- [ ] Add firmware update indicator component to template
|
|
- [ ] Add PubSub handler for `:firmware_changed` events
|
|
- [ ] Write LiveView tests for indicator display
|
|
- [ ] Test with different vendors (show for MikroTik, hide for others)
|
|
|
|
### Phase 6: Documentation & Cleanup
|
|
- [ ] Update CLAUDE.md with firmware tracking info
|
|
- [ ] Add developer documentation for adding new vendors
|
|
- [ ] Run `mix format` on all new files
|
|
- [ ] Run `mix dialyzer` and fix any warnings
|
|
- [ ] Ensure test coverage >90% for new modules
|
|
- [ ] Manual testing with real MikroTik device
|
|
|
|
## Future Enhancements
|
|
|
|
1. **Version History Display:**
|
|
- Add "Firmware History" tab to device detail page
|
|
- Show timeline of version changes with dates
|
|
|
|
2. **Firmware Update Notifications:**
|
|
- Email digest of devices with available updates
|
|
- Dashboard widget showing update summary
|
|
|
|
3. **Bulk Update Tracking:**
|
|
- Mark multiple devices as "planned for update"
|
|
- Track update completion across organization
|
|
|
|
4. **Multi-Vendor Support:**
|
|
- Cisco IOS/IOS-XE (requires API or web scraping)
|
|
- Ubiquiti UniFi (RSS or API)
|
|
- Juniper JunOS (API)
|
|
|
|
5. **Release Notes Parsing:**
|
|
- Extract changelog highlights from vendor sources
|
|
- Display in-app without external link
|
|
|
|
6. **Smart Update Recommendations:**
|
|
- Security bulletin awareness (CVE tracking)
|
|
- Recommend updates based on severity
|
|
- "Skip this version" for known problematic releases
|
|
|
|
## Security Considerations
|
|
|
|
1. **External HTTP Requests:**
|
|
- RSS feeds are public, no authentication required
|
|
- Use HTTPS for all vendor URLs
|
|
- Set reasonable timeouts (5-10 seconds)
|
|
- Rate limit to avoid overwhelming vendor servers
|
|
|
|
2. **Data Validation:**
|
|
- Sanitize all parsed XML/JSON before storage
|
|
- Validate version format before comparison
|
|
- Prevent XML entity expansion attacks (XXE)
|
|
|
|
3. **PII/Sensitive Data:**
|
|
- No sensitive data in firmware tracking
|
|
- Download URLs are public vendor pages
|
|
- Device firmware versions are operational metadata
|
|
|
|
## Performance Considerations
|
|
|
|
1. **RSS Fetching:**
|
|
- Daily schedule minimizes external requests
|
|
- Async worker doesn't block web requests
|
|
- Failed fetches retry (Oban max_attempts: 3)
|
|
|
|
2. **LiveView Loading:**
|
|
- Firmware lookup is single query by vendor/product_line
|
|
- Indexed unique constraint makes lookup fast
|
|
- Version comparison is in-memory (no DB query)
|
|
|
|
3. **Version History:**
|
|
- Indexed on `snmp_device_id + detected_at`
|
|
- Limit queries to recent N records (default 20)
|
|
- Cascading delete prevents orphaned records
|
|
|
|
## Rollout Plan
|
|
|
|
1. **Deploy schema migrations** (production downtime: ~5 seconds)
|
|
2. **Deploy worker code** (no immediate execution)
|
|
3. **Manually trigger worker** to seed initial firmware data: `Oban.insert(Towerops.Workers.FirmwareVersionFetcherWorker.new(%{}))`
|
|
4. **Verify firmware release record** created
|
|
5. **Monitor cron execution** daily at 2am
|
|
6. **Enable LiveView indicator** after confirming data accuracy
|
|
|
|
## Monitoring & Alerts
|
|
|
|
1. **Worker Failures:**
|
|
- Oban dashboard shows failed jobs
|
|
- Email alert if worker fails 3 consecutive days
|
|
- Log warning if RSS format changes unexpectedly
|
|
|
|
2. **Data Freshness:**
|
|
- Alert if `firmware_releases.fetched_at` >48 hours old
|
|
- Dashboard shows last successful fetch time
|
|
|
|
3. **Version Comparison Issues:**
|
|
- Log warning for unparseable version strings
|
|
- Store raw version in history for manual review
|