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
132 lines
3.5 KiB
Elixir
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
|