towerops/lib/towerops/agents/release_checker.ex
Graham McIntire 532d88ffb9
perf: disable Req retry for faster error tests
- Add retry: false to VISP Client HTTP requests
- Add retry: false to ReleaseChecker GitHub API requests
- Remove unused Plug.Conn import from RemoteIpLogger
- Remove unused default parameter from req_get/2

Results:
- VISP sync test: 7007ms → 113ms (62x faster)
- ReleaseChecker test: 7004ms → 17ms (402x faster)
- UserResetPasswordLive test: 1495ms → 284ms (5x faster)

Req's default retry behavior (1s, 2s, 4s exponential backoff) was
causing 7-second delays for HTTP 500/503 error responses in tests.
For these clients, immediate failure is preferred over retries.
2026-03-10 16:19:31 -05:00

150 lines
4 KiB
Elixir

defmodule Towerops.Agents.ReleaseChecker do
@moduledoc """
Fetches latest release info from GitHub Releases API for the towerops-agent repo.
Caches results for 5 minutes to avoid excessive API calls.
"""
require Logger
@github_repo "towerops-app/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 GitHub.
"""
@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 GitHub.
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://api.github.com/repos/#{@github_repo}/releases/latest"
case req_get(url, headers: [{"accept", "application/vnd.github+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("GitHub API returned status #{status} for latest release")
{:error, {:github_api, status}}
{:error, reason} ->
Logger.warning("Failed to fetch latest release from GitHub: #{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
Keyword.put(opts, :plug, {Req.Test, __MODULE__})
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