Fix agent API protobuf support and Mix.env runtime error

Fixed two critical issues preventing agent communication:

1. Fix Mix.env() runtime error in production
   - Replace Mix.env() with Application.get_env(:towerops, :env)
   - Add :env config to test.exs
   - Mix module not available in production releases

2. Add Protocol Buffers support to agent API endpoints
   - GET /api/v1/agent/config now accepts application/x-protobuf
   - POST /api/v1/agent/heartbeat now accepts application/x-protobuf
   - Added conversion functions for config/equipment/sensors/interfaces
   - Maintains JSON backward compatibility as fallback

All agent controller tests passing (14 tests, 0 failures)
This commit is contained in:
Graham McIntire 2026-01-14 16:35:47 -06:00
parent 8864b8d26d
commit 9a6369bd27
No known key found for this signature in database
4 changed files with 117 additions and 9 deletions

View file

@ -452,4 +452,5 @@ end)
- Use `ConnCase` for controller/LiveView tests
- Use `async: true` for tests that can run in parallel (most unit tests)
- Use `async: false` for tests with shared state (supervisor tests, integration tests)
- never open the test coverage html files
- never open the test coverage html files
- remember when working in rust to always run cargo fmt before committing

View file

@ -43,6 +43,9 @@ config :towerops, ToweropsWeb.Endpoint,
secret_key_base: "XDSSqVUtRXUjEfzxefIAaPIzBfonpNMOrCRyP1a0kjPzzyOpVNSRmMBVae/bwTqj",
server: false
# Set environment identifier for runtime checks
config :towerops, :env, :test
# Use mocks for testing
config :towerops,
ping_module: Towerops.Monitoring.PingMock,

View file

@ -10,6 +10,9 @@ defmodule ToweropsWeb.Api.AgentController do
use ToweropsWeb, :controller
alias Towerops.Agent.AgentConfig
alias Towerops.Agent.HeartbeatMetadata
alias Towerops.Agent.HeartbeatResponse
alias Towerops.Agent.MetricBatch
alias Towerops.Agents
alias Towerops.Snmp
@ -20,6 +23,8 @@ defmodule ToweropsWeb.Api.AgentController do
Returns polling configuration for all equipment that should be polled by this agent.
This includes directly assigned equipment plus equipment that inherits the agent
from site or organization defaults.
Supports both JSON and Protocol Buffers response formats based on Accept header.
"""
def get_config(conn, _params) do
agent_token = conn.assigns.current_agent_token
@ -31,7 +36,21 @@ defmodule ToweropsWeb.Api.AgentController do
equipment: Enum.map(equipment_list, &build_equipment_config/1)
}
json(conn, config)
# Check Accept header for protobuf support
case get_req_header(conn, "accept") do
["application/x-protobuf" | _] ->
# Convert to protobuf and send
proto_config = convert_config_to_proto(config)
binary = AgentConfig.encode(proto_config)
conn
|> put_resp_content_type("application/x-protobuf")
|> send_resp(200, binary)
_ ->
# Default to JSON
json(conn, config)
end
end
@doc """
@ -68,21 +87,51 @@ defmodule ToweropsWeb.Api.AgentController do
POST /api/v1/agent/heartbeat
Updates the agent's last_seen_at timestamp and metadata.
Supports both JSON and Protocol Buffers formats.
"""
def heartbeat(conn, params) do
agent_token = conn.assigns.current_agent_token
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
metadata = %{
"version" => Map.get(params, "version"),
"hostname" => Map.get(params, "hostname"),
"uptime_seconds" => Map.get(params, "uptime_seconds")
}
# Parse metadata based on content type
metadata =
case get_req_header(conn, "content-type") do
["application/x-protobuf" | _] ->
# Decode protobuf
{:ok, body, _conn} = Plug.Conn.read_body(conn)
proto_metadata = HeartbeatMetadata.decode(body)
%{
"version" => proto_metadata.version,
"hostname" => proto_metadata.hostname,
"uptime_seconds" => proto_metadata.uptime_seconds
}
_ ->
# JSON format (fallback)
%{
"version" => Map.get(params, "version"),
"hostname" => Map.get(params, "hostname"),
"uptime_seconds" => Map.get(params, "uptime_seconds")
}
end
# Update synchronously in the heartbeat endpoint
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
json(conn, %{status: "ok"})
# Return response in appropriate format
case get_req_header(conn, "content-type") do
["application/x-protobuf" | _] ->
proto_response = %HeartbeatResponse{status: "ok"}
binary = HeartbeatResponse.encode(proto_response)
conn
|> put_resp_content_type("application/x-protobuf")
|> send_resp(200, binary)
_ ->
json(conn, %{status: "ok"})
end
end
# Private helpers
@ -209,4 +258,59 @@ defmodule ToweropsWeb.Api.AgentController do
_ -> DateTime.truncate(DateTime.utc_now(), :second)
end
end
# Convert internal config format to protobuf
defp convert_config_to_proto(config) do
%AgentConfig{
version: config.version,
poll_interval_seconds: config.poll_interval_seconds,
equipment: Enum.map(config.equipment, &convert_equipment_to_proto/1)
}
end
defp convert_equipment_to_proto(eq) do
%Towerops.Agent.Equipment{
id: eq.id,
name: eq.name,
ip_address: eq.ip_address,
snmp: convert_snmp_to_proto(eq.snmp),
poll_interval_seconds: eq.poll_interval_seconds,
sensors: Enum.map(eq.sensors, &convert_sensor_to_proto/1),
interfaces: Enum.map(eq.interfaces, &convert_interface_to_proto/1)
}
end
defp convert_snmp_to_proto(snmp) do
%Towerops.Agent.SnmpConfig{
enabled: snmp.enabled,
version: snmp.version,
community: snmp.community,
port: snmp.port
}
end
defp convert_sensor_to_proto(sensor) do
%Towerops.Agent.Sensor{
id: sensor.id,
type: sensor.type,
oid: sensor.oid,
divisor: sensor.divisor || 0.0,
unit: sensor.unit || "",
metadata: convert_metadata_to_proto(sensor.metadata)
}
end
defp convert_interface_to_proto(interface) do
%Towerops.Agent.Interface{
id: interface.id,
if_index: interface.if_index,
if_name: interface.if_name
}
end
defp convert_metadata_to_proto(nil), do: %{}
defp convert_metadata_to_proto(metadata) when is_map(metadata) do
Map.new(metadata, fn {k, v} -> {to_string(k), to_string(v)} end)
end
end

View file

@ -46,7 +46,7 @@ defmodule ToweropsWeb.Plugs.AgentAuth do
# In test environment, update synchronously to avoid DB ownership issues
# In production, update asynchronously for better performance
if Mix.env() == :test do
if Application.get_env(:towerops, :env) == :test do
_ = Agents.update_agent_token_heartbeat(agent_token.id, ip)
else
update_heartbeat_async(agent_token.id, ip)