# 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
RouterOS latest stable version7.14.1
https://mikrotik.com/download
Wed, 15 Jan 2025 12:00:00 +0000RouterOS 7.14.1 stable release
```
**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
<%= if firmware_update_available?(@snmp_device, @available_firmware) do %>