towerops/docs/version_tracking.md
Graham McIntire 61a06fc11c
Add firmware version tracking system
- 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.
2026-02-01 10:46:27 -06:00

28 KiB

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.

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.

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

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):

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 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:

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

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

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)

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

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:

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:

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:

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):

<!-- 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:

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

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:

# 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:

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:

# 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:

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:

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:

test "FirmwareVersionFetcherWorker fetches and stores MikroTik version" do
  # Mock HTTP response
  # Execute worker
  # Assert FirmwareRelease record created/updated
end

2. LiveView Display:

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