towerops/lib/towerops/workers/firmware_version_fetcher_worker.ex

183 lines
5.3 KiB
Elixir

defmodule Towerops.Workers.FirmwareVersionFetcherWorker do
@moduledoc """
Oban worker that fetches the latest MikroTik RouterOS firmware version from RSS feed.
Runs daily to check for new firmware releases and updates the firmware_releases table.
"""
use Oban.Worker, queue: :maintenance, max_attempts: 10
import SweetXml
alias Towerops.Devices.Firmware
alias Towerops.HTTP
require Logger
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
min(attempt * attempt * 60, 7_200)
end
@rss_url "https://cdn.mikrotik.com/routeros/latest-stable.rss"
@changelog_url "https://mikrotik.com/download/changelogs"
@impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok | {:error, term()}
def perform(%Oban.Job{}) do
Logger.info("Fetching MikroTik RouterOS firmware version from RSS feed")
with {:ok, rss_body} <- fetch_rss_feed(),
{:ok, release_data} <- parse_rss_feed(rss_body),
{:ok, _release} <- Firmware.upsert_firmware_release(release_data) do
Logger.info("Successfully updated firmware release: #{release_data.version}")
:ok
else
{:error, reason} = error ->
Logger.error("Failed to fetch firmware version: #{inspect(reason)}")
error
end
end
@doc """
Fetches the RSS feed from MikroTik CDN.
Returns `{:ok, body}` on success or `{:error, reason}` on failure.
"""
def fetch_rss_feed do
case HTTP.get(__MODULE__, @rss_url, receive_timeout: 10_000) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
{:ok, %Req.Response{status: status}} ->
{:error, {:http_error, status}}
{:error, %Req.TransportError{}} ->
{:error, :network_error}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Parses RSS feed XML and extracts firmware release information.
## Examples
iex> parse_rss_feed(valid_rss_xml)
{:ok, %{vendor: "mikrotik", version: "7.14.1", ...}}
iex> parse_rss_feed(invalid_xml)
{:error, :parse_error}
"""
def parse_rss_feed(xml_string) do
# Parse channel title and first item
channel_title = xpath(xml_string, ~x"//channel/title/text()"s)
# Check if any items exist
items = xpath(xml_string, ~x"//item"l)
if Enum.empty?(items) do
{:error, :no_items}
else
# Extract data from first item
version = xpath(xml_string, ~x"//item/title/text()"s)
download_url = xpath(xml_string, ~x"//item/link/text()"s)
description = xpath(xml_string, ~x"//item/description/text()"s)
pub_date_string = xpath(xml_string, ~x"//item/pubDate/text()"s)
# Validate required fields
if version == "" or is_nil(version) do
{:error, :invalid_data}
else
# Extract clean version number from RSS title
# "RouterOS 7.21.2 [stable]" -> "7.21.2"
clean_version = extract_version_number(version)
# Parse RFC822 date
release_date = parse_rfc822_date(pub_date_string)
{:ok,
%{
vendor: "mikrotik",
product_line: "routeros",
version: clean_version,
release_date: release_date,
download_url: download_url,
changelog_url: @changelog_url,
fetched_at: DateTime.utc_now(),
metadata: %{
"rss_title" => channel_title,
"rss_description" => description,
"raw_version" => version
}
}}
end
end
rescue
_ ->
{:error, :parse_error}
catch
:exit, _ ->
{:error, :parse_error}
end
# Extract clean version number from RSS title
# Examples:
# "RouterOS 7.21.2 [stable]" -> "7.21.2"
# "7.14.1" -> "7.14.1"
defp extract_version_number(version_string) do
# Match pattern: one or more digits, followed by dot, followed by digits
# This extracts semantic version numbers like "7.21.2"
case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do
[version] -> version
# Fallback to original if no match
_ -> version_string
end
end
# Parse RFC822 date format used in RSS feeds
# Example: "Wed, 15 Jan 2025 12:00:00 +0000"
defp parse_rfc822_date(date_string) when is_binary(date_string) and date_string != "" do
# RFC822 format: "Day, DD Mon YYYY HH:MM:SS +ZZZZ"
# We only care about DD Mon YYYY
case Regex.run(~r/\d{1,2}\s+\w{3}\s+\d{4}/, date_string) do
[date_part] ->
parse_date_part(date_part)
_ ->
nil
end
end
defp parse_rfc822_date(_), do: nil
# Parse the date portion "15 Jan 2025"
defp parse_date_part(date_string) do
with [day_str, month_str, year_str] <- String.split(date_string),
{day, ""} <- Integer.parse(day_str),
{year, ""} <- Integer.parse(year_str),
month when is_integer(month) <- parse_month(month_str),
{:ok, date} <- Date.new(year, month, day) do
date
else
_ -> nil
end
end
# Convert three-letter month abbreviation to month number
defp parse_month("Jan"), do: 1
defp parse_month("Feb"), do: 2
defp parse_month("Mar"), do: 3
defp parse_month("Apr"), do: 4
defp parse_month("May"), do: 5
defp parse_month("Jun"), do: 6
defp parse_month("Jul"), do: 7
defp parse_month("Aug"), do: 8
defp parse_month("Sep"), do: 9
defp parse_month("Oct"), do: 10
defp parse_month("Nov"), do: 11
defp parse_month("Dec"), do: 12
defp parse_month(_), do: nil
end