towerops/lib/towerops/agents/release_checker.ex
Graham McIntire 38375dcebb chore: migrate agent repo references from GitHub to Codeberg
Updates all docs, UI copy, release checker, proto go_package,
container image refs, and tests to point at
codeberg.org/towerops-agent/towerops-agent. Also deletes stray
empty package.json/package-lock.json (Phoenix uses esbuild — no
npm) and an unused test_encode_decode.exs scratch file.
2026-05-07 07:44:48 -05:00

153 lines
4.1 KiB
Elixir

defmodule Towerops.Agents.ReleaseChecker do
@moduledoc """
Fetches latest release info from the Codeberg (Forgejo) Releases API for the
towerops-agent repo.
Caches results for 5 minutes to avoid excessive API calls.
"""
require Logger
@codeberg_repo "towerops-agent/towerops-agent"
@cache_ttl_ms to_timeout(minute: 5)
@arch_map %{
"x86_64" => "amd64",
"aarch64" => "arm64"
}
@doc """
Clears the cached release info so the next call to `get_latest_release/0`
fetches fresh data from Codeberg.
"""
@spec invalidate_cache() :: :ok
def invalidate_cache do
:persistent_term.erase({__MODULE__, :release})
:ok
rescue
ArgumentError -> :ok
end
@doc """
Returns the latest release info from Codeberg.
Returns `{:ok, %{version: String.t(), assets: [asset]}}` where each asset
has `name`, `url`, and `checksum` fields.
Results are cached for 5 minutes.
"""
@spec get_latest_release() :: {:ok, map()} | {:error, term()}
def get_latest_release do
case get_cached() do
{:ok, release} ->
{:ok, release}
:miss ->
fetch_and_cache()
end
end
@doc """
Finds the asset matching the given architecture.
Maps Rust architecture strings (x86_64, aarch64) to release asset names (amd64, arm64).
"""
@spec asset_for_arch([map()], String.t()) :: {:ok, map()} | {:error, :no_matching_asset}
def asset_for_arch(assets, arch) do
target = Map.get(@arch_map, arch, arch)
case Enum.find(assets, fn asset -> String.contains?(asset.name, target) end) do
nil -> {:error, :no_matching_asset}
asset -> {:ok, asset}
end
end
defp get_cached do
case :persistent_term.get({__MODULE__, :release}, nil) do
{release, cached_at} ->
if System.monotonic_time(:millisecond) - cached_at < @cache_ttl_ms do
{:ok, release}
else
:miss
end
nil ->
:miss
end
end
defp fetch_and_cache do
url = "https://codeberg.org/api/v1/repos/#{@codeberg_repo}/releases/latest"
case req_get(url, headers: [{"accept", "application/json"}], retry: false) do
{:ok, %Req.Response{status: 200, body: body}} ->
release = parse_release(body)
:persistent_term.put({__MODULE__, :release}, {release, System.monotonic_time(:millisecond)})
{:ok, release}
{:ok, %Req.Response{status: status}} ->
Logger.warning("Codeberg API returned status #{status} for latest release")
{:error, {:codeberg_api, status}}
{:error, reason} ->
Logger.warning("Failed to fetch latest release from Codeberg: #{inspect(reason)}")
{:error, reason}
end
end
defp parse_release(body) do
version = String.trim_leading(body["tag_name"], "v")
assets =
(body["assets"] || [])
|> Enum.filter(fn asset ->
name = asset["name"]
String.starts_with?(name, "towerops-agent-linux-") and not String.ends_with?(name, ".sha256")
end)
|> Enum.map(fn asset ->
checksum = fetch_checksum(body["assets"], asset["name"])
%{
name: asset["name"],
url: asset["browser_download_url"],
checksum: checksum
}
end)
%{version: version, assets: assets}
end
defp fetch_checksum(all_assets, binary_name) do
sha_asset = Enum.find(all_assets, fn a -> a["name"] == "#{binary_name}.sha256" end)
if sha_asset do
case req_get(sha_asset["browser_download_url"], retry: false) do
{:ok, %Req.Response{status: 200, body: body}} ->
body |> String.trim() |> String.split(" ") |> List.first()
_ ->
nil
end
end
end
defp req_get(url, opts) do
opts =
if Application.get_env(:towerops, :env) == :test do
opts
|> Keyword.put(:plug, {Req.Test, __MODULE__})
|> Keyword.put_new(:retry, false)
else
opts
end
Req.get(url, opts)
rescue
error in RuntimeError ->
if String.contains?(Exception.message(error), "cannot find mock/stub") do
{:error, :no_test_stub}
else
reraise error, __STACKTRACE__
end
end
end