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:
parent
b30f2cf5af
commit
004189aaf9
6 changed files with 614 additions and 0 deletions
54
lib/towerops/devices/device_firmware_history.ex
Normal file
54
lib/towerops/devices/device_firmware_history.ex
Normal 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
|
||||
64
lib/towerops/devices/firmware_release.ex
Normal file
64
lib/towerops/devices/firmware_release.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
255
test/towerops/devices/device_firmware_history_test.exs
Normal file
255
test/towerops/devices/device_firmware_history_test.exs
Normal 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
|
||||
197
test/towerops/devices/firmware_release_test.exs
Normal file
197
test/towerops/devices/firmware_release_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue