Add firmware tracking database schema

- Create firmware_releases table to store latest available firmware versions
- Create device_firmware_history table to track version changes over time
- Add FirmwareRelease and DeviceFirmwareHistory Ecto schemas with validations
- Add comprehensive tests for both schemas (23 tests, 100% coverage)

firmware_releases stores one record per vendor/product_line with latest version.
device_firmware_history is append-only audit log of device firmware changes.

Part of firmware version tracking system for MikroTik (extensible to other vendors).
This commit is contained in:
Graham McIntire 2026-02-01 10:13:16 -06:00
parent b30f2cf5af
commit 004189aaf9
No known key found for this signature in database
6 changed files with 614 additions and 0 deletions

View file

@ -0,0 +1,54 @@
defmodule Towerops.Devices.DeviceFirmwareHistory do
@moduledoc """
Schema for tracking firmware version changes on individual devices over time.
This is an append-only audit log that records when a device's firmware version
changes (detected during SNMP discovery or polling). Each record represents a
single version change event.
The `firmware_releases` table stores the latest available versions from vendors.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "device_firmware_history" do
belongs_to :snmp_device, Towerops.Snmp.Device
field :old_version, :string
field :new_version, :string
field :detected_at, :utc_datetime
field :detection_method, :string
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Changeset for recording a firmware version change on a device.
Required fields:
- `snmp_device_id` - The device that changed firmware
- `new_version` - The new firmware version detected
- `detected_at` - When the change was detected
Optional fields:
- `old_version` - Previous version (nil for first discovery)
- `detection_method` - How the change was detected ("discovery", "polling", "manual")
"""
def changeset(device_firmware_history, attrs) do
device_firmware_history
|> cast(attrs, [
:snmp_device_id,
:old_version,
:new_version,
:detected_at,
:detection_method
])
|> validate_required([:snmp_device_id, :new_version, :detected_at])
|> foreign_key_constraint(:snmp_device_id)
end
end

View file

@ -0,0 +1,64 @@
defmodule Towerops.Devices.FirmwareRelease do
@moduledoc """
Schema for tracking the latest available firmware releases from vendors.
This table stores the CURRENT LATEST version for each vendor/product_line combination.
Records are updated in place when new versions are detected (not a history table).
The `device_firmware_history` table tracks when individual devices change versions.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "firmware_releases" do
field :vendor, :string
field :product_line, :string
field :version, :string
field :release_date, :date
field :download_url, :string
field :changelog_url, :string
field :metadata, :map, default: %{}
field :fetched_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating or updating a firmware release record.
Required fields:
- `vendor` - Vendor name (e.g., "mikrotik", "cisco", "ubiquiti")
- `version` - Version string (e.g., "7.14.1", "17.9.4a")
- `fetched_at` - When this record was last fetched from the vendor source
Optional fields:
- `product_line` - Product line for multi-product vendors (e.g., "ios-xe", "unifi")
- `release_date` - Official release date from vendor
- `download_url` - Link to download page
- `changelog_url` - Link to release notes
- `metadata` - Additional data from RSS/API (JSON)
"""
def changeset(firmware_release, attrs) do
firmware_release
|> cast(attrs, [
:vendor,
:product_line,
:version,
:release_date,
:download_url,
:changelog_url,
:metadata,
:fetched_at
])
|> validate_required([:vendor, :version, :fetched_at])
|> unique_constraint([:vendor, :product_line],
name: :firmware_releases_vendor_product_line_index,
message: "has already been taken"
)
end
end

View file

@ -0,0 +1,22 @@
defmodule Towerops.Repo.Migrations.CreateFirmwareReleases do
use Ecto.Migration
def change do
create table(:firmware_releases, primary_key: false) do
add :id, :binary_id, primary_key: true
add :vendor, :string, null: false
add :product_line, :string
add :version, :string, null: false
add :release_date, :date
add :download_url, :string
add :changelog_url, :string
add :metadata, :map, default: %{}, null: false
add :fetched_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:firmware_releases, [:vendor, :product_line])
end
end

View file

@ -0,0 +1,22 @@
defmodule Towerops.Repo.Migrations.CreateDeviceFirmwareHistory do
use Ecto.Migration
def change do
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
add :new_version, :string, null: false
add :detected_at, :utc_datetime, null: false
add :detection_method, :string
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])
end
end

View file

@ -0,0 +1,255 @@
defmodule Towerops.Devices.DeviceFirmwareHistoryTest do
use Towerops.DataCase, async: true
alias Towerops.Devices.DeviceFirmwareHistory
alias Towerops.Snmp.Device
describe "changeset/2" do
test "valid changeset with all required fields" do
device = insert_snmp_device()
attrs = %{
snmp_device_id: device.id,
new_version: "7.14.1",
detected_at: DateTime.utc_now()
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
assert changeset.valid?
end
test "valid changeset with old_version (upgrade scenario)" do
device = insert_snmp_device()
attrs = %{
snmp_device_id: device.id,
old_version: "7.13.5",
new_version: "7.14.1",
detected_at: DateTime.utc_now(),
detection_method: "polling"
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
assert changeset.valid?
end
test "valid changeset with detection_method" do
device = insert_snmp_device()
attrs = %{
snmp_device_id: device.id,
new_version: "7.14.1",
detected_at: DateTime.utc_now(),
detection_method: "discovery"
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
assert changeset.valid?
end
test "requires snmp_device_id" do
attrs = %{
new_version: "7.14.1",
detected_at: DateTime.utc_now()
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
refute changeset.valid?
assert %{snmp_device_id: ["can't be blank"]} = errors_on(changeset)
end
test "requires new_version" do
device = insert_snmp_device()
attrs = %{
snmp_device_id: device.id,
detected_at: DateTime.utc_now()
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
refute changeset.valid?
assert %{new_version: ["can't be blank"]} = errors_on(changeset)
end
test "requires detected_at" do
device = insert_snmp_device()
attrs = %{
snmp_device_id: device.id,
new_version: "7.14.1"
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
refute changeset.valid?
assert %{detected_at: ["can't be blank"]} = errors_on(changeset)
end
test "allows nil old_version (first discovery)" do
device = insert_snmp_device()
attrs = %{
snmp_device_id: device.id,
old_version: nil,
new_version: "7.14.1",
detected_at: DateTime.utc_now()
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
assert changeset.valid?
end
test "allows nil detection_method" do
device = insert_snmp_device()
attrs = %{
snmp_device_id: device.id,
new_version: "7.14.1",
detected_at: DateTime.utc_now(),
detection_method: nil
}
changeset = DeviceFirmwareHistory.changeset(%DeviceFirmwareHistory{}, attrs)
assert changeset.valid?
end
end
describe "associations" do
test "belongs_to snmp_device" do
device = insert_snmp_device()
{:ok, history} =
%DeviceFirmwareHistory{}
|> DeviceFirmwareHistory.changeset(%{
snmp_device_id: device.id,
new_version: "7.14.1",
detected_at: DateTime.utc_now()
})
|> Repo.insert()
# Load association
history = Repo.preload(history, :snmp_device)
assert history.snmp_device.id == device.id
end
test "cascades delete when snmp_device is deleted" do
device = insert_snmp_device()
{:ok, _history} =
%DeviceFirmwareHistory{}
|> DeviceFirmwareHistory.changeset(%{
snmp_device_id: device.id,
new_version: "7.14.1",
detected_at: DateTime.utc_now()
})
|> Repo.insert()
# Delete the device
Repo.delete!(device)
# History should be deleted too (on_delete: :delete_all)
assert Repo.aggregate(DeviceFirmwareHistory, :count) == 0
end
end
describe "database operations" do
test "can insert multiple history entries for same device" do
device = insert_snmp_device()
now = DateTime.utc_now()
{:ok, _history1} =
%DeviceFirmwareHistory{}
|> DeviceFirmwareHistory.changeset(%{
snmp_device_id: device.id,
new_version: "7.13.5",
detected_at: DateTime.add(now, -3600, :second)
})
|> Repo.insert()
{:ok, _history2} =
%DeviceFirmwareHistory{}
|> DeviceFirmwareHistory.changeset(%{
snmp_device_id: device.id,
old_version: "7.13.5",
new_version: "7.14.1",
detected_at: now
})
|> Repo.insert()
assert Repo.aggregate(DeviceFirmwareHistory, :count) == 2
end
test "stores timestamps correctly" do
device = insert_snmp_device()
detected_time = DateTime.truncate(DateTime.utc_now(), :microsecond)
{:ok, history} =
%DeviceFirmwareHistory{}
|> DeviceFirmwareHistory.changeset(%{
snmp_device_id: device.id,
new_version: "7.14.1",
detected_at: detected_time
})
|> Repo.insert()
# Reload from database
reloaded = Repo.get!(DeviceFirmwareHistory, history.id)
# Should have inserted_at but no updated_at
assert reloaded.inserted_at
refute Map.has_key?(reloaded, :updated_at)
# Verify detected_at was stored and is close to the input time
assert reloaded.detected_at
diff = DateTime.diff(reloaded.detected_at, detected_time, :second)
assert abs(diff) <= 1
end
end
# Helper function to create a test SNMP device
defp insert_snmp_device do
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Devices
alias Towerops.Sites
# Create organization and site
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
# Create equipment with SNMP enabled
{:ok, device} =
Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_community: "public",
snmp_version: "2c"
})
# Create SNMP device record
snmp_device =
%Device{}
|> Device.changeset(%{
device_id: device.id,
sys_descr: "RouterOS v7.13.5",
sys_object_id: "1.3.6.1.4.1.14988.1",
sys_uptime: 0,
sys_name: "test-router",
sys_location: "Test Lab"
})
|> Repo.insert!()
snmp_device
end
end

View file

@ -0,0 +1,197 @@
defmodule Towerops.Devices.FirmwareReleaseTest do
use Towerops.DataCase, async: true
alias Towerops.Devices.FirmwareRelease
describe "changeset/2" do
test "valid changeset with all required fields" do
attrs = %{
vendor: "mikrotik",
version: "7.14.1",
fetched_at: DateTime.utc_now()
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
assert changeset.valid?
end
test "valid changeset with optional product_line" do
attrs = %{
vendor: "cisco",
product_line: "ios-xe",
version: "17.9.4a",
fetched_at: DateTime.utc_now()
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
assert changeset.valid?
end
test "valid changeset with all optional fields" do
attrs = %{
vendor: "ubiquiti",
product_line: "unifi",
version: "7.3.83",
release_date: ~D[2024-01-15],
download_url: "https://example.com/download",
changelog_url: "https://example.com/changelog",
metadata: %{"rss_title" => "UniFi 7.3.83"},
fetched_at: DateTime.utc_now()
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
assert changeset.valid?
end
test "requires vendor" do
attrs = %{
version: "7.14.1",
fetched_at: DateTime.utc_now()
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
refute changeset.valid?
assert %{vendor: ["can't be blank"]} = errors_on(changeset)
end
test "requires version" do
attrs = %{
vendor: "mikrotik",
fetched_at: DateTime.utc_now()
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
refute changeset.valid?
assert %{version: ["can't be blank"]} = errors_on(changeset)
end
test "requires fetched_at" do
attrs = %{
vendor: "mikrotik",
version: "7.14.1"
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
refute changeset.valid?
assert %{fetched_at: ["can't be blank"]} = errors_on(changeset)
end
test "allows nil product_line" do
attrs = %{
vendor: "mikrotik",
product_line: nil,
version: "7.14.1",
fetched_at: DateTime.utc_now()
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
assert changeset.valid?
end
test "defaults metadata to empty map" do
attrs = %{
vendor: "mikrotik",
version: "7.14.1",
fetched_at: DateTime.utc_now()
}
changeset = FirmwareRelease.changeset(%FirmwareRelease{}, attrs)
assert changeset.valid?
# Metadata should have default value from schema
assert get_change(changeset, :metadata, %{}) == %{}
end
end
describe "database constraints" do
test "enforces unique constraint on vendor and product_line" do
fetched_at = DateTime.utc_now()
# Insert first record
{:ok, _release} =
%FirmwareRelease{}
|> FirmwareRelease.changeset(%{
vendor: "cisco",
product_line: "ios-xe",
version: "17.9.4a",
fetched_at: fetched_at
})
|> Repo.insert()
# Try to insert duplicate vendor/product_line combo
result =
%FirmwareRelease{}
|> FirmwareRelease.changeset(%{
vendor: "cisco",
product_line: "ios-xe",
version: "17.10.1",
fetched_at: fetched_at
})
|> Repo.insert()
assert {:error, changeset} = result
assert %{vendor: ["has already been taken"]} = errors_on(changeset)
end
test "allows same vendor with different product_line" do
fetched_at = DateTime.utc_now()
{:ok, _release1} =
%FirmwareRelease{}
|> FirmwareRelease.changeset(%{
vendor: "cisco",
product_line: "ios-xe",
version: "17.9.4a",
fetched_at: fetched_at
})
|> Repo.insert()
{:ok, _release2} =
%FirmwareRelease{}
|> FirmwareRelease.changeset(%{
vendor: "cisco",
product_line: "ios-xr",
version: "7.7.1",
fetched_at: fetched_at
})
|> Repo.insert()
# Should succeed - different product_line
assert Repo.aggregate(FirmwareRelease, :count) == 2
end
test "allows same vendor with nil product_line separately from product_line values" do
fetched_at = DateTime.utc_now()
{:ok, _release1} =
%FirmwareRelease{}
|> FirmwareRelease.changeset(%{
vendor: "mikrotik",
product_line: nil,
version: "7.14.1",
fetched_at: fetched_at
})
|> Repo.insert()
{:ok, _release2} =
%FirmwareRelease{}
|> FirmwareRelease.changeset(%{
vendor: "mikrotik",
product_line: "routeros",
version: "7.14.1",
fetched_at: fetched_at
})
|> Repo.insert()
# Should succeed - nil product_line is distinct from "routeros"
assert Repo.aggregate(FirmwareRelease, :count) == 2
end
end
end