diff --git a/lib/towerops/devices/device_firmware_history.ex b/lib/towerops/devices/device_firmware_history.ex new file mode 100644 index 00000000..0de8294f --- /dev/null +++ b/lib/towerops/devices/device_firmware_history.ex @@ -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 diff --git a/lib/towerops/devices/firmware_release.ex b/lib/towerops/devices/firmware_release.ex new file mode 100644 index 00000000..7dda37a9 --- /dev/null +++ b/lib/towerops/devices/firmware_release.ex @@ -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 diff --git a/priv/repo/migrations/20260201155525_create_firmware_releases.exs b/priv/repo/migrations/20260201155525_create_firmware_releases.exs new file mode 100644 index 00000000..471c6440 --- /dev/null +++ b/priv/repo/migrations/20260201155525_create_firmware_releases.exs @@ -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 diff --git a/priv/repo/migrations/20260201155526_create_device_firmware_history.exs b/priv/repo/migrations/20260201155526_create_device_firmware_history.exs new file mode 100644 index 00000000..22d9fb31 --- /dev/null +++ b/priv/repo/migrations/20260201155526_create_device_firmware_history.exs @@ -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 diff --git a/test/towerops/devices/device_firmware_history_test.exs b/test/towerops/devices/device_firmware_history_test.exs new file mode 100644 index 00000000..142f378c --- /dev/null +++ b/test/towerops/devices/device_firmware_history_test.exs @@ -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 diff --git a/test/towerops/devices/firmware_release_test.exs b/test/towerops/devices/firmware_release_test.exs new file mode 100644 index 00000000..47d162b1 --- /dev/null +++ b/test/towerops/devices/firmware_release_test.exs @@ -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