From 9a6369bd27ffc30222b3ce02501e1fc83fd3df35 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 14 Jan 2026 16:35:47 -0600 Subject: [PATCH] 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) --- CLAUDE.md | 3 +- config/test.exs | 3 + .../controllers/api/agent_controller.ex | 118 ++++++++++++++++-- lib/towerops_web/plugs/agent_auth.ex | 2 +- 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0f01eac3..a95b6342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 \ No newline at end of file +- never open the test coverage html files +- remember when working in rust to always run cargo fmt before committing \ No newline at end of file diff --git a/config/test.exs b/config/test.exs index 9790da73..fb0ae1d4 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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, diff --git a/lib/towerops_web/controllers/api/agent_controller.ex b/lib/towerops_web/controllers/api/agent_controller.ex index 12d5ade1..d63ef184 100644 --- a/lib/towerops_web/controllers/api/agent_controller.ex +++ b/lib/towerops_web/controllers/api/agent_controller.ex @@ -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 diff --git a/lib/towerops_web/plugs/agent_auth.ex b/lib/towerops_web/plugs/agent_auth.ex index ac7da8ff..e0657144 100644 --- a/lib/towerops_web/plugs/agent_auth.ex +++ b/lib/towerops_web/plugs/agent_auth.ex @@ -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)