diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex
index 79f65f33..098827c1 100644
--- a/lib/towerops/agents.ex
+++ b/lib/towerops/agents.ex
@@ -258,6 +258,37 @@ defmodule Towerops.Agents do
result
end
+ @doc """
+ Requests an agent to restart by broadcasting on its lifecycle topic.
+
+ The agent channel picks this up and pushes a "restart" event to the agent,
+ which exits cleanly so Docker can restart it.
+ """
+ @spec restart_agent(binary()) :: :ok | {:error, term()}
+ def restart_agent(agent_token_id) do
+ Phoenix.PubSub.broadcast(
+ Towerops.PubSub,
+ "agent:#{agent_token_id}:lifecycle",
+ :restart_requested
+ )
+ end
+
+ @doc """
+ Requests an agent to self-update by broadcasting the download URL and checksum
+ on its lifecycle topic.
+
+ The agent channel picks this up and pushes an "update" event to the agent,
+ which downloads the binary, verifies the checksum, and replaces itself.
+ """
+ @spec update_agent(binary(), String.t(), String.t()) :: :ok | {:error, term()}
+ def update_agent(agent_token_id, url, checksum) do
+ Phoenix.PubSub.broadcast(
+ Towerops.PubSub,
+ "agent:#{agent_token_id}:lifecycle",
+ {:update_requested, url, checksum}
+ )
+ end
+
@doc """
Toggles remote debugging for an agent.
diff --git a/lib/towerops/agents/release_checker.ex b/lib/towerops/agents/release_checker.ex
new file mode 100644
index 00000000..505f5ba5
--- /dev/null
+++ b/lib/towerops/agents/release_checker.ex
@@ -0,0 +1,120 @@
+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 """
+ 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
diff --git a/lib/towerops/proto/agent.pb.ex b/lib/towerops/proto/agent.pb.ex
index 8b3773e0..d1d5e32a 100644
--- a/lib/towerops/proto/agent.pb.ex
+++ b/lib/towerops/proto/agent.pb.ex
@@ -358,6 +358,7 @@ defmodule Towerops.Agent.AgentHeartbeat do
field :hostname, 2, type: :string
field :uptime_seconds, 3, type: :uint64, json_name: "uptimeSeconds"
field :ip_address, 4, type: :string, json_name: "ipAddress"
+ field :arch, 5, type: :string
end
defmodule Towerops.Agent.AgentError do
diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex
index 751036e6..1cf7ce68 100644
--- a/lib/towerops_web/channels/agent_channel.ex
+++ b/lib/towerops_web/channels/agent_channel.ex
@@ -170,6 +170,27 @@ defmodule ToweropsWeb.AgentChannel do
{:stop, :token_disabled, socket}
end
+ # Handle restart request — push restart event to agent, then stop channel
+ def handle_info(:restart_requested, socket) do
+ Logger.info("Restart requested, notifying agent",
+ agent_token_id: socket.assigns.agent_token_id
+ )
+
+ push(socket, "restart", %{})
+ {:stop, :restart_requested, socket}
+ end
+
+ # Handle update request — push update event with download URL and checksum to agent
+ def handle_info({:update_requested, url, checksum}, socket) do
+ Logger.info("Update requested, notifying agent",
+ agent_token_id: socket.assigns.agent_token_id,
+ url: url
+ )
+
+ push(socket, "update", %{url: url, checksum: checksum})
+ {:noreply, socket}
+ end
+
# Handle PubSub broadcast when device assignments change.
# Debounces rapid changes — cancels any pending refresh and schedules a new one in 500ms.
def handle_info({:assignments_changed, _event}, socket) do
@@ -359,7 +380,8 @@ defmodule ToweropsWeb.AgentChannel do
{:ok, heartbeat} <- Validator.validate_heartbeat(binary) do
metadata = %{
"version" => heartbeat.version,
- "uptime_seconds" => heartbeat.uptime_seconds
+ "uptime_seconds" => heartbeat.uptime_seconds,
+ "arch" => heartbeat.arch
}
_ =
diff --git a/lib/towerops_web/live/agent_live/show.ex b/lib/towerops_web/live/agent_live/show.ex
index fdca6adb..cab46e25 100644
--- a/lib/towerops_web/live/agent_live/show.ex
+++ b/lib/towerops_web/live/agent_live/show.ex
@@ -5,6 +5,7 @@ defmodule ToweropsWeb.AgentLive.Show do
import ToweropsWeb.AgentLive.Helpers
alias Towerops.Agents
+ alias Towerops.Agents.ReleaseChecker
@impl true
def mount(%{"id" => id}, _session, socket) do
@@ -47,6 +48,41 @@ defmodule ToweropsWeb.AgentLive.Show do
{:noreply, assign(socket, :active_tab, tab)}
end
+ def handle_event("restart_agent", _params, socket) do
+ if socket.assigns.current_scope.user.is_superuser do
+ Agents.restart_agent(socket.assigns.agent_token.id)
+
+ {:noreply, put_flash(socket, :info, "Restart command sent to agent")}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("update_agent", _params, socket) do
+ if socket.assigns.current_scope.user.is_superuser do
+ agent_token = socket.assigns.agent_token
+ arch = get_in(agent_token.metadata, ["arch"]) || "x86_64"
+
+ {:noreply, send_update_command(socket, agent_token, arch)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ defp send_update_command(socket, agent_token, arch) do
+ with {:ok, release} <- ReleaseChecker.get_latest_release(),
+ {:ok, asset} <- ReleaseChecker.asset_for_arch(release.assets, arch) do
+ Agents.update_agent(agent_token.id, asset.url, asset.checksum)
+ put_flash(socket, :info, "Update command sent to agent (v#{release.version})")
+ else
+ {:error, :no_matching_asset} ->
+ put_flash(socket, :error, "No binary available for architecture: #{arch}")
+
+ {:error, _reason} ->
+ put_flash(socket, :error, "Failed to fetch latest release from GitHub")
+ end
+ end
+
defp assignment_source(device, agent_token_id) do
cond do
directly_assigned?(device, agent_token_id) ->
diff --git a/lib/towerops_web/live/agent_live/show.html.heex b/lib/towerops_web/live/agent_live/show.html.heex
index cedeb554..9d4759f0 100644
--- a/lib/towerops_web/live/agent_live/show.html.heex
+++ b/lib/towerops_web/live/agent_live/show.html.heex
@@ -7,6 +7,22 @@
{@page_title}
<:subtitle>Agent Details and Health
<:actions>
+
+
<.link
navigate={~p"/agents/#{@agent_token.id}/edit"}
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600"
diff --git a/test/towerops/agents/release_checker_test.exs b/test/towerops/agents/release_checker_test.exs
new file mode 100644
index 00000000..637b4602
--- /dev/null
+++ b/test/towerops/agents/release_checker_test.exs
@@ -0,0 +1,53 @@
+defmodule Towerops.Agents.ReleaseCheckerTest do
+ use ExUnit.Case, async: true
+
+ alias Towerops.Agents.ReleaseChecker
+
+ describe "get_latest_release/0" do
+ @tag :integration
+ test "returns release info with version and assets" do
+ {:ok, release} = ReleaseChecker.get_latest_release()
+
+ assert is_binary(release.version)
+ assert is_list(release.assets)
+
+ assert Enum.any?(release.assets, fn asset ->
+ String.contains?(asset.name, "amd64")
+ end)
+
+ assert Enum.any?(release.assets, fn asset ->
+ String.contains?(asset.name, "arm64")
+ end)
+ end
+ end
+
+ describe "asset_for_arch/2" do
+ test "returns matching asset for x86_64 arch" do
+ assets = [
+ %{name: "towerops-agent-linux-amd64", url: "https://example.com/amd64", checksum: "abc"},
+ %{name: "towerops-agent-linux-arm64", url: "https://example.com/arm64", checksum: "def"}
+ ]
+
+ assert {:ok, asset} = ReleaseChecker.asset_for_arch(assets, "x86_64")
+ assert asset.name == "towerops-agent-linux-amd64"
+ end
+
+ test "returns matching asset for aarch64 arch" do
+ assets = [
+ %{name: "towerops-agent-linux-amd64", url: "https://example.com/amd64", checksum: "abc"},
+ %{name: "towerops-agent-linux-arm64", url: "https://example.com/arm64", checksum: "def"}
+ ]
+
+ assert {:ok, asset} = ReleaseChecker.asset_for_arch(assets, "aarch64")
+ assert asset.name == "towerops-agent-linux-arm64"
+ end
+
+ test "returns error for unknown arch" do
+ assets = [
+ %{name: "towerops-agent-linux-amd64", url: "https://example.com/amd64", checksum: "abc"}
+ ]
+
+ assert {:error, :no_matching_asset} = ReleaseChecker.asset_for_arch(assets, "riscv64")
+ end
+ end
+end
diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs
index d2e69406..f48334ea 100644
--- a/test/towerops/agents_test.exs
+++ b/test/towerops/agents_test.exs
@@ -1813,4 +1813,29 @@ defmodule Towerops.AgentsTest do
assert Agents.should_phoenix_poll_device?(device) == true
end
end
+
+ describe "restart_agent/1" do
+ test "broadcasts restart_requested on lifecycle topic", %{organization: org} do
+ {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
+
+ assert :ok = Agents.restart_agent(agent_token.id)
+
+ assert_receive :restart_requested
+ end
+ end
+
+ describe "update_agent/3" do
+ test "broadcasts update_requested with url and checksum on lifecycle topic", %{organization: org} do
+ {:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
+
+ url = "https://github.com/towerops-app/towerops-agent/releases/download/v0.2.0/towerops-agent-linux-amd64"
+ checksum = "abc123def456"
+
+ assert :ok = Agents.update_agent(agent_token.id, url, checksum)
+
+ assert_receive {:update_requested, ^url, ^checksum}
+ end
+ end
end
diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs
index 61488340..8995b7e1 100644
--- a/test/towerops_web/live/agent_live_test.exs
+++ b/test/towerops_web/live/agent_live_test.exs
@@ -524,6 +524,72 @@ defmodule ToweropsWeb.AgentLiveTest do
end
end
+ describe "Show - Restart Agent" do
+ test "superuser sees restart button and can restart agent", %{conn: conn, organization: organization, user: user} do
+ Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
+ {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
+
+ {:ok, view, html} = live(conn, ~p"/agents/#{agent_token.id}")
+
+ assert html =~ "Restart"
+
+ # Subscribe to lifecycle topic to verify broadcast
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
+
+ view
+ |> element("button", "Restart")
+ |> render_click()
+
+ assert_receive :restart_requested
+ assert render(view) =~ "Restart command sent"
+ end
+
+ test "regular user does not see restart button", %{conn: conn, organization: organization} do
+ {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
+
+ {:ok, _view, html} = live(conn, ~p"/agents/#{agent_token.id}")
+
+ refute html =~ "Restart"
+ end
+ end
+
+ describe "Show - Update Agent" do
+ test "superuser sees update button", %{conn: conn, organization: organization, user: user} do
+ Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
+ {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
+
+ {:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
+
+ assert has_element?(view, "button", "Update")
+ end
+
+ test "superuser clicking update shows error when no releases available", %{
+ conn: conn,
+ organization: organization,
+ user: user
+ } do
+ Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
+ {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
+
+ {:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
+
+ html =
+ view
+ |> element("button", "Update")
+ |> render_click()
+
+ assert html =~ "Failed to fetch latest release"
+ end
+
+ test "regular user does not see update button", %{conn: conn, organization: organization} do
+ {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
+
+ {:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
+
+ refute has_element?(view, "button", "Update")
+ end
+ end
+
describe "Show - Regular User Cross-Org Access" do
test "regular user cannot view agent from another organization", %{conn: conn, organization: organization, user: user} do
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id, bypass_limits: true)