towerops/lib/towerops/proto/encode.ex
Graham McIntire efaf5558ff refactor: convert 6 Gleam modules to idiomatic Elixir with TDD (#196)
Phase 1: Foundation Types (100% complete)
- query_helpers: SQL LIKE sanitization with pipe operators
- numeric: Integer parsing with pattern matching guards
- result: Pure Elixir Result monad (map, and_then, unwrap_or)

Phase 2: Ecto Domain Types (100% complete)
- ip_address: IPv4/IPv6 validation using :inet directly
- mac_address: Multi-format MAC parsing (colon/hyphen/dot/compact)
- snmp_oid: OID parsing/manipulation with recursive pattern matching

All 198 tests passing across converted modules.
API changed from Gleam-style {:some/:none to idiomatic {:ok/:error.
Refactored parse_numeric_oid to use with statement, reducing nesting depth.

Reviewed-on: graham/towerops-web#196
2026-03-28 09:52:07 -05:00

445 lines
16 KiB
Elixir

defmodule Towerops.Proto.Encode do
@moduledoc """
Protobuf encode functions for all message types.
Uses iodata for efficient concatenation. Each function accepts a struct
and returns a binary of wire-format bytes. Default values (empty string,
0, false) are omitted per proto3 spec.
"""
alias Towerops.Proto.Types
alias Towerops.Proto.Wire
# --- Public encode functions ---
@spec encode_agent_heartbeat(Types.AgentHeartbeat.t()) :: binary()
def encode_agent_heartbeat(%Types.AgentHeartbeat{} = hb) do
[]
|> Wire.encode_string_field(1, hb.version)
|> Wire.encode_string_field(2, hb.hostname)
|> Wire.encode_uint_field(3, hb.uptime_seconds)
|> Wire.encode_string_field(4, hb.ip_address)
|> Wire.encode_string_field(5, hb.arch)
|> IO.iodata_to_binary()
end
@spec encode_heartbeat_metadata(Types.HeartbeatMetadata.t()) :: binary()
def encode_heartbeat_metadata(%Types.HeartbeatMetadata{} = m) do
[]
|> Wire.encode_string_field(1, m.version)
|> Wire.encode_string_field(2, m.hostname)
|> Wire.encode_uint_field(3, m.uptime_seconds)
|> IO.iodata_to_binary()
end
@spec encode_heartbeat_response(Types.HeartbeatResponse.t()) :: binary()
def encode_heartbeat_response(%Types.HeartbeatResponse{} = r) do
[]
|> Wire.encode_string_field(1, r.status)
|> IO.iodata_to_binary()
end
@spec encode_agent_config(Types.AgentConfig.t()) :: binary()
def encode_agent_config(%Types.AgentConfig{} = c) do
[]
|> Wire.encode_string_field(1, c.version)
|> Wire.encode_uint_field(2, c.poll_interval_seconds)
|> encode_repeated_messages(3, c.devices, &encode_device/1)
|> encode_repeated_messages(4, c.checks, &encode_check/1)
|> IO.iodata_to_binary()
end
@spec encode_device(Types.Device.t()) :: binary()
def encode_device(%Types.Device{} = d) do
[]
|> Wire.encode_string_field(1, d.id)
|> Wire.encode_string_field(2, d.name)
|> Wire.encode_string_field(3, d.ip_address)
|> encode_optional_message(4, d.snmp, &encode_snmp_config/1)
|> Wire.encode_uint_field(5, d.poll_interval_seconds)
|> encode_repeated_messages(6, d.sensors, &encode_sensor/1)
|> encode_repeated_messages(7, d.interfaces, &encode_interface/1)
|> Wire.encode_bool_field(8, d.monitoring_enabled)
|> Wire.encode_uint_field(9, d.check_interval_seconds)
|> IO.iodata_to_binary()
end
@spec encode_snmp_config(Types.SnmpConfig.t()) :: binary()
def encode_snmp_config(%Types.SnmpConfig{} = s) do
[]
|> Wire.encode_bool_field(1, s.enabled)
|> Wire.encode_string_field(2, s.version)
|> Wire.encode_string_field(3, s.community)
|> Wire.encode_uint_field(4, s.port)
|> Wire.encode_string_field(5, s.transport)
|> IO.iodata_to_binary()
end
@spec encode_sensor(Types.Sensor.t()) :: binary()
def encode_sensor(%Types.Sensor{} = s) do
[]
|> Wire.encode_string_field(1, s.id)
|> Wire.encode_string_field(2, s.sensor_type)
|> Wire.encode_string_field(3, s.oid)
|> Wire.encode_double_field(4, s.divisor)
|> Wire.encode_string_field(5, s.unit)
|> encode_map_fields(6, s.metadata)
|> IO.iodata_to_binary()
end
@spec encode_interface(Types.Interface.t()) :: binary()
def encode_interface(%Types.Interface{} = i) do
[]
|> Wire.encode_string_field(1, i.id)
|> Wire.encode_uint_field(2, i.if_index)
|> Wire.encode_string_field(3, i.if_name)
|> IO.iodata_to_binary()
end
@spec encode_metric_batch(Types.MetricBatch.t()) :: binary()
def encode_metric_batch(%Types.MetricBatch{} = batch) do
[]
|> encode_repeated_messages(1, batch.metrics, &encode_metric/1)
|> IO.iodata_to_binary()
end
@spec encode_metric(Types.metric()) :: binary()
def encode_metric(metric) do
builder = []
case_result =
case metric do
{:sensor_reading, sr} ->
Wire.encode_message_field(builder, 1, encode_sensor_reading(sr))
{:interface_stat, is} ->
Wire.encode_message_field(builder, 2, encode_interface_stat(is))
{:neighbor_discovery, nd} ->
Wire.encode_message_field(builder, 3, encode_neighbor_discovery(nd))
{:monitoring_check, mc} ->
Wire.encode_message_field(builder, 4, encode_monitoring_check(mc))
{:check_result, cr} ->
Wire.encode_message_field(builder, 5, encode_check_result(cr))
end
IO.iodata_to_binary(case_result)
end
@spec encode_sensor_reading(Types.SensorReading.t()) :: binary()
def encode_sensor_reading(%Types.SensorReading{} = sr) do
[]
|> Wire.encode_string_field(1, sr.sensor_id)
|> Wire.encode_double_field(2, sr.value)
|> Wire.encode_string_field(3, sr.status)
|> Wire.encode_int64_field(4, sr.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_interface_stat(Types.InterfaceStat.t()) :: binary()
def encode_interface_stat(%Types.InterfaceStat{} = is) do
[]
|> Wire.encode_string_field(1, is.interface_id)
|> Wire.encode_int64_field(2, is.if_in_octets)
|> Wire.encode_int64_field(3, is.if_out_octets)
|> Wire.encode_int64_field(4, is.if_in_errors)
|> Wire.encode_int64_field(5, is.if_out_errors)
|> Wire.encode_int64_field(6, is.if_in_discards)
|> Wire.encode_int64_field(7, is.if_out_discards)
|> Wire.encode_int64_field(8, is.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_neighbor_discovery(Types.NeighborDiscovery.t()) :: binary()
def encode_neighbor_discovery(%Types.NeighborDiscovery{} = nd) do
[]
|> Wire.encode_string_field(1, nd.interface_id)
|> Wire.encode_string_field(2, nd.protocol)
|> Wire.encode_string_field(3, nd.remote_chassis_id)
|> Wire.encode_string_field(4, nd.remote_system_name)
|> Wire.encode_string_field(5, nd.remote_system_description)
|> Wire.encode_string_field(6, nd.remote_platform)
|> Wire.encode_string_field(7, nd.remote_port_id)
|> Wire.encode_string_field(8, nd.remote_port_description)
|> Wire.encode_string_field(9, nd.remote_address)
|> encode_repeated_strings(10, nd.remote_capabilities)
|> Wire.encode_int64_field(11, nd.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_monitoring_check(Types.MonitoringCheck.t()) :: binary()
def encode_monitoring_check(%Types.MonitoringCheck{} = mc) do
[]
|> Wire.encode_string_field(1, mc.device_id)
|> Wire.encode_string_field(2, mc.status)
|> Wire.encode_double_field(3, mc.response_time_ms)
|> Wire.encode_int64_field(4, mc.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_check(Types.Check.t()) :: binary()
def encode_check(%Types.Check{} = c) do
builder =
[]
|> Wire.encode_string_field(1, c.id)
|> Wire.encode_string_field(2, c.check_type)
|> Wire.encode_uint_field(3, c.interval_seconds)
|> Wire.encode_uint_field(4, c.timeout_ms)
case_result =
case c.config do
{:http, h} ->
Wire.encode_message_field(builder, 5, encode_http_check_config(h))
{:tcp, t} ->
Wire.encode_message_field(builder, 6, encode_tcp_check_config(t))
{:dns, d} ->
Wire.encode_message_field(builder, 7, encode_dns_check_config(d))
{:ssl, s} ->
Wire.encode_message_field(builder, 8, encode_ssl_check_config(s))
:no_config ->
builder
end
IO.iodata_to_binary(case_result)
end
@spec encode_http_check_config(Types.HttpCheckConfig.t()) :: binary()
def encode_http_check_config(%Types.HttpCheckConfig{} = h) do
[]
|> Wire.encode_string_field(1, h.url)
|> Wire.encode_string_field(2, h.method)
|> Wire.encode_uint_field(3, h.expected_status)
|> Wire.encode_bool_field(4, h.verify_ssl)
|> encode_map_fields(5, h.headers)
|> Wire.encode_string_field(6, h.body)
|> Wire.encode_string_field(7, h.regex)
|> Wire.encode_bool_field(8, h.follow_redirects)
|> IO.iodata_to_binary()
end
@spec encode_tcp_check_config(Types.TcpCheckConfig.t()) :: binary()
def encode_tcp_check_config(%Types.TcpCheckConfig{} = t) do
[]
|> Wire.encode_string_field(1, t.host)
|> Wire.encode_uint_field(2, t.port)
|> Wire.encode_string_field(3, t.send)
|> Wire.encode_string_field(4, t.expect)
|> IO.iodata_to_binary()
end
@spec encode_dns_check_config(Types.DnsCheckConfig.t()) :: binary()
def encode_dns_check_config(%Types.DnsCheckConfig{} = d) do
[]
|> Wire.encode_string_field(1, d.hostname)
|> Wire.encode_string_field(2, d.server)
|> Wire.encode_string_field(3, d.record_type)
|> Wire.encode_string_field(4, d.expected)
|> IO.iodata_to_binary()
end
@spec encode_ssl_check_config(Types.SslCheckConfig.t()) :: binary()
def encode_ssl_check_config(%Types.SslCheckConfig{} = s) do
[]
|> Wire.encode_string_field(1, s.host)
|> Wire.encode_uint_field(2, s.port)
|> Wire.encode_uint_field(3, s.warning_days)
|> IO.iodata_to_binary()
end
@spec encode_check_result(Types.CheckResult.t()) :: binary()
def encode_check_result(%Types.CheckResult{} = cr) do
[]
|> Wire.encode_string_field(1, cr.check_id)
|> Wire.encode_uint_field(2, cr.status)
|> Wire.encode_string_field(3, cr.output)
|> Wire.encode_double_field(4, cr.response_time_ms)
|> Wire.encode_int64_field(5, cr.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_check_list(Types.CheckList.t()) :: binary()
def encode_check_list(%Types.CheckList{} = cl) do
[]
|> encode_repeated_messages(1, cl.checks, &encode_check/1)
|> IO.iodata_to_binary()
end
@spec encode_agent_job_list(Types.AgentJobList.t()) :: binary()
def encode_agent_job_list(%Types.AgentJobList{} = jl) do
[]
|> encode_repeated_messages(1, jl.jobs, &encode_agent_job/1)
|> IO.iodata_to_binary()
end
@spec encode_agent_job(Types.AgentJob.t()) :: binary()
def encode_agent_job(%Types.AgentJob{} = job) do
[]
|> Wire.encode_string_field(1, job.job_id)
|> Wire.encode_enum_field(2, Types.job_type_to_int(job.job_type))
|> Wire.encode_string_field(3, job.device_id)
|> encode_optional_message(4, job.snmp_device, &encode_snmp_device/1)
|> encode_repeated_messages(5, job.queries, &encode_snmp_query/1)
|> encode_optional_message(6, job.mikrotik_device, &encode_mikrotik_device/1)
|> encode_repeated_messages(7, job.mikrotik_commands, &encode_mikrotik_command/1)
|> IO.iodata_to_binary()
end
@spec encode_snmp_device(Types.SnmpDevice.t()) :: binary()
def encode_snmp_device(%Types.SnmpDevice{} = sd) do
[]
|> Wire.encode_string_field(1, sd.ip)
|> Wire.encode_string_field(2, sd.community)
|> Wire.encode_string_field(3, sd.version)
|> Wire.encode_uint_field(4, sd.port)
|> Wire.encode_string_field(5, sd.v3_security_level)
|> Wire.encode_string_field(6, sd.v3_username)
|> Wire.encode_string_field(7, sd.v3_auth_protocol)
|> Wire.encode_string_field(8, sd.v3_auth_password)
|> Wire.encode_string_field(9, sd.v3_priv_protocol)
|> Wire.encode_string_field(10, sd.v3_priv_password)
|> Wire.encode_string_field(11, sd.transport)
|> IO.iodata_to_binary()
end
@spec encode_snmp_query(Types.SnmpQuery.t()) :: binary()
def encode_snmp_query(%Types.SnmpQuery{} = sq) do
[]
|> Wire.encode_enum_field(1, Types.query_type_to_int(sq.query_type))
|> encode_repeated_strings(2, sq.oids)
|> IO.iodata_to_binary()
end
@spec encode_snmp_result(Types.SnmpResult.t()) :: binary()
def encode_snmp_result(%Types.SnmpResult{} = sr) do
[]
|> Wire.encode_string_field(1, sr.device_id)
|> Wire.encode_enum_field(2, Types.job_type_to_int(sr.job_type))
|> encode_map_fields(3, sr.oid_values)
|> Wire.encode_int64_field(4, sr.timestamp)
|> Wire.encode_string_field(5, sr.job_id)
|> IO.iodata_to_binary()
end
@spec encode_agent_error(Types.AgentError.t()) :: binary()
def encode_agent_error(%Types.AgentError{} = ae) do
[]
|> Wire.encode_string_field(1, ae.device_id)
|> Wire.encode_string_field(2, ae.job_id)
|> Wire.encode_string_field(3, ae.message)
|> Wire.encode_int64_field(4, ae.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_credential_test_result(Types.CredentialTestResult.t()) :: binary()
def encode_credential_test_result(%Types.CredentialTestResult{} = ctr) do
[]
|> Wire.encode_string_field(1, ctr.test_id)
|> Wire.encode_bool_field(2, ctr.success)
|> Wire.encode_string_field(3, ctr.error_message)
|> Wire.encode_string_field(4, ctr.system_description)
|> Wire.encode_int64_field(5, ctr.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_mikrotik_device(Types.MikrotikDevice.t()) :: binary()
def encode_mikrotik_device(%Types.MikrotikDevice{} = md) do
[]
|> Wire.encode_string_field(1, md.ip)
|> Wire.encode_uint_field(2, md.port)
|> Wire.encode_string_field(3, md.username)
|> Wire.encode_string_field(4, md.password)
|> Wire.encode_bool_field(5, md.use_ssl)
|> Wire.encode_uint_field(6, md.ssh_port)
|> IO.iodata_to_binary()
end
@spec encode_mikrotik_command(Types.MikrotikCommand.t()) :: binary()
def encode_mikrotik_command(%Types.MikrotikCommand{} = mc) do
[]
|> Wire.encode_string_field(1, mc.command)
|> encode_map_fields(2, mc.args)
|> IO.iodata_to_binary()
end
@spec encode_mikrotik_result(Types.MikrotikResult.t()) :: binary()
def encode_mikrotik_result(%Types.MikrotikResult{} = mr) do
[]
|> Wire.encode_string_field(1, mr.device_id)
|> Wire.encode_string_field(2, mr.job_id)
|> encode_repeated_messages(3, mr.sentences, &encode_mikrotik_sentence/1)
|> Wire.encode_string_field(4, mr.error)
|> Wire.encode_int64_field(5, mr.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_mikrotik_sentence(Types.MikrotikSentence.t()) :: binary()
def encode_mikrotik_sentence(%Types.MikrotikSentence{} = ms) do
[]
|> encode_map_fields(1, ms.attributes)
|> IO.iodata_to_binary()
end
@spec encode_lldp_topology_result(Types.LldpTopologyResult.t()) :: binary()
def encode_lldp_topology_result(%Types.LldpTopologyResult{} = ltr) do
[]
|> Wire.encode_string_field(1, ltr.device_id)
|> Wire.encode_string_field(2, ltr.job_id)
|> Wire.encode_string_field(3, ltr.local_system_name)
|> encode_repeated_messages(4, ltr.neighbors, &encode_lldp_neighbor/1)
|> Wire.encode_int64_field(5, ltr.timestamp)
|> IO.iodata_to_binary()
end
@spec encode_lldp_neighbor(Types.LldpNeighbor.t()) :: binary()
def encode_lldp_neighbor(%Types.LldpNeighbor{} = ln) do
[]
|> Wire.encode_string_field(1, ln.neighbor_name)
|> Wire.encode_string_field(2, ln.local_port)
|> Wire.encode_string_field(3, ln.remote_port)
|> Wire.encode_string_field(4, ln.remote_port_id)
|> encode_repeated_strings(5, ln.management_addresses)
|> IO.iodata_to_binary()
end
# --- Helper functions ---
defp encode_optional_message(builder, _field_number, nil, _encode_fn), do: builder
defp encode_optional_message(builder, field_number, value, encode_fn) do
Wire.encode_message_field(builder, field_number, encode_fn.(value))
end
defp encode_repeated_messages(builder, field_number, items, encode_fn) do
Enum.reduce(items, builder, fn item, b ->
Wire.encode_message_field(b, field_number, encode_fn.(item))
end)
end
defp encode_repeated_strings(builder, field_number, items) do
Enum.reduce(items, builder, fn item, b ->
[
b,
Wire.encode_tag(field_number, 2),
Wire.encode_bytes(item)
]
end)
end
defp encode_map_fields(builder, field_number, map) do
Enum.reduce(map, builder, fn {key, value}, b ->
entry =
[]
|> Wire.encode_string_field(1, key)
|> Wire.encode_string_field(2, value)
|> IO.iodata_to_binary()
Wire.encode_message_field(b, field_number, entry)
end)
end
end