towerops/lib/towerops/agents/release_checker.ex
Graham McIntire c7df6a8569
Add CI-triggered mass agent update webhook
POST /api/v1/webhooks/agent-release triggers all connected agents
to self-update via their existing WebSocket channels. Authenticated
with a shared secret (AGENT_WEBHOOK_SECRET env var).

- Add ReleaseChecker.invalidate_cache/0 for fresh GitHub fetch
- Add Agents.list_updatable_agents/0 (enabled + seen < 10min)
- Add Agents.broadcast_mass_update/0 orchestration function
- Add WebhookAuth plug with timing-safe secret comparison
- Add AgentReleaseWebhookController with :webhook pipeline
- Configure AGENT_WEBHOOK_SECRET in dev/test/runtime configs
2026-02-10 13:40:32 -06:00

132 lines
3.5 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"}]) 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"]) do
{:ok, %Req.Response{status: 200, body: body}} ->
body |> String.trim() |> String.split(" ") |> List.first()
_ ->
nil
end
end
end
end