add parallel build

This commit is contained in:
Graham McIntire 2026-01-19 14:17:05 -06:00
parent 00f704cd83
commit b8b3c5dce0
No known key found for this signature in database
5 changed files with 1214 additions and 22 deletions

View file

@ -21,19 +21,20 @@ ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} AS builder
ENV MIX_OS_DEPS_COMPILE_PARTITION_COUNT=6
# install build dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force \
&& mix local.rebar --force
&& mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
@ -80,13 +81,13 @@ RUN mix release
FROM ${RUNNER_IMAGE} AS final
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates snmp snmp-mibs-downloader \
&& rm -rf /var/lib/apt/lists/*
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen
&& locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
@ -101,10 +102,6 @@ ENV MIX_ENV="prod"
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/towerops ./
# Copy MIB files for SNMP MIB name resolution
# LibreNMS MIBs will be mounted or copied separately
RUN mkdir -p /app/mibs
USER nobody
# If using an environment that doesn't automatically reap zombie processes, it is
@ -114,6 +111,6 @@ USER nobody
# Health check for container orchestration
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD ["/app/bin/towerops", "rpc", "1 + 1"]
CMD ["/app/bin/towerops", "rpc", "1 + 1"]
CMD ["/app/bin/server"]

View file

@ -21,19 +21,20 @@ ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} AS builder
ENV MIX_OS_DEPS_COMPILE_PARTITION_COUNT=6
# install build dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force \
&& mix local.rebar --force
&& mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
@ -80,13 +81,13 @@ RUN mix release
FROM ${RUNNER_IMAGE} AS final
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
&& rm -rf /var/lib/apt/lists/*
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen
&& locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
@ -110,6 +111,6 @@ USER nobody
# Health check for container orchestration
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD ["/app/bin/towerops", "rpc", "1 + 1"]
CMD ["/app/bin/towerops", "rpc", "1 + 1"]
CMD ["/app/bin/server"]

View file

@ -0,0 +1,154 @@
defmodule Towerops.ApiTokens.ApiTokenTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens.ApiToken
describe "changeset/2" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
test "valid changeset with all required fields", %{
user: user,
organization: organization
} do
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Test Token",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
assert changeset.valid?
end
test "valid changeset without user_id", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: "Test Token",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
assert changeset.valid?
end
test "valid changeset with expires_at", %{organization: organization} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
attrs = %{
organization_id: organization.id,
name: "Test Token",
token_hash: "abc123",
expires_at: expires_at
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
assert changeset.valid?
end
test "valid changeset with last_used_at", %{organization: organization} do
last_used_at = DateTime.utc_now()
attrs = %{
organization_id: organization.id,
name: "Test Token",
token_hash: "abc123",
last_used_at: last_used_at
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
assert changeset.valid?
end
test "invalid changeset without organization_id", %{user: user} do
attrs = %{
user_id: user.id,
name: "Test Token",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
test "invalid changeset without name", %{organization: organization} do
attrs = %{
organization_id: organization.id,
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "invalid changeset without token_hash", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: "Test Token"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).token_hash
end
test "invalid changeset with blank name", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: "",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "invalid changeset with name too short", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: "",
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
refute changeset.valid?
end
test "invalid changeset with name too long", %{organization: organization} do
long_name = String.duplicate("a", 256)
attrs = %{
organization_id: organization.id,
name: long_name,
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
refute changeset.valid?
assert "should be at most 255 character(s)" in errors_on(changeset).name
end
test "changeset with maximum valid name length", %{organization: organization} do
max_name = String.duplicate("a", 255)
attrs = %{
organization_id: organization.id,
name: max_name,
token_hash: "abc123"
}
changeset = ApiToken.changeset(%ApiToken{}, attrs)
assert changeset.valid?
end
end
end

View file

@ -0,0 +1,471 @@
defmodule Towerops.ApiTokensTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
alias Towerops.ApiTokens.ApiToken
describe "create_api_token/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
test "creates an API token with valid attributes", %{
user: user,
organization: organization
} do
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
}
assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
assert api_token.name == "Test Token"
assert api_token.organization_id == organization.id
assert api_token.user_id == user.id
assert is_binary(api_token.token_hash)
assert String.starts_with?(raw_token, "towerops_")
assert is_nil(api_token.last_used_at)
assert is_nil(api_token.expires_at)
end
test "creates an API token without user_id", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: "Test Token"
}
assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs)
assert api_token.name == "Test Token"
assert api_token.organization_id == organization.id
assert is_nil(api_token.user_id)
assert is_binary(api_token.token_hash)
assert String.starts_with?(raw_token, "towerops_")
end
test "creates an API token with expiration", %{
user: user,
organization: organization
} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Expiring Token",
expires_at: expires_at
}
assert {:ok, {api_token, _raw_token}} = ApiTokens.create_api_token(attrs)
assert api_token.expires_at == DateTime.truncate(expires_at, :second)
end
test "returns error with invalid attributes" do
assert {:error, %Ecto.Changeset{}} = ApiTokens.create_api_token(%{})
end
test "returns error with blank name", %{organization: organization} do
attrs = %{
organization_id: organization.id,
name: ""
}
assert {:error, changeset} = ApiTokens.create_api_token(attrs)
assert "can't be blank" in errors_on(changeset).name
end
test "returns error without organization_id" do
attrs = %{
name: "Test Token"
}
assert {:error, changeset} = ApiTokens.create_api_token(attrs)
assert "can't be blank" in errors_on(changeset).organization_id
end
test "generates unique tokens", %{user: user, organization: organization} do
attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
}
{:ok, {_token1, raw_token1}} = ApiTokens.create_api_token(attrs)
{:ok, {_token2, raw_token2}} = ApiTokens.create_api_token(attrs)
assert raw_token1 != raw_token2
end
end
describe "list_organization_api_tokens/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
test "returns all tokens for an organization", %{
user: user,
organization: organization
} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_organization_api_tokens(organization.id)
assert length(tokens) == 2
assert Enum.any?(tokens, fn t -> t.id == token1.id end)
assert Enum.any?(tokens, fn t -> t.id == token2.id end)
end
test "returns tokens ordered by creation date", %{
user: user,
organization: organization
} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_organization_api_tokens(organization.id)
assert length(tokens) == 2
# Verify tokens are ordered by inserted_at (descending)
[first, second] = tokens
assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
# Verify both tokens are present
token_ids = Enum.map(tokens, & &1.id)
assert token1.id in token_ids
assert token2.id in token_ids
end
test "returns empty list for organization with no tokens", %{organization: organization} do
assert [] = ApiTokens.list_organization_api_tokens(organization.id)
end
test "does not return tokens from other organizations", %{user: user, organization: org1} do
org2 = organization_fixture(user.id)
{:ok, {_token1, _}} =
ApiTokens.create_api_token(%{
organization_id: org1.id,
user_id: user.id,
name: "Org 1 Token"
})
{:ok, {_token2, _}} =
ApiTokens.create_api_token(%{
organization_id: org2.id,
user_id: user.id,
name: "Org 2 Token"
})
tokens = ApiTokens.list_organization_api_tokens(org1.id)
assert length(tokens) == 1
assert hd(tokens).name == "Org 1 Token"
end
end
describe "list_user_api_tokens/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
test "returns all tokens created by a user", %{user: user, organization: organization} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_user_api_tokens(user.id)
assert length(tokens) == 2
assert Enum.any?(tokens, fn t -> t.id == token1.id end)
assert Enum.any?(tokens, fn t -> t.id == token2.id end)
end
test "preloads organization", %{user: user, organization: organization} do
{:ok, {_token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
tokens = ApiTokens.list_user_api_tokens(user.id)
assert [token] = tokens
assert %Ecto.Association.NotLoaded{} != token.organization
assert token.organization.id == organization.id
end
test "returns tokens ordered by creation date", %{
user: user,
organization: organization
} do
{:ok, {token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 1"
})
{:ok, {token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Token 2"
})
tokens = ApiTokens.list_user_api_tokens(user.id)
assert length(tokens) == 2
# Verify tokens are ordered by inserted_at (descending)
[first, second] = tokens
assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
# Verify both tokens are present
token_ids = Enum.map(tokens, & &1.id)
assert token1.id in token_ids
assert token2.id in token_ids
end
test "returns empty list for user with no tokens", %{user: user} do
assert [] = ApiTokens.list_user_api_tokens(user.id)
end
test "does not return tokens created by other users", %{organization: organization} do
user1 = user_fixture()
user2 = user_fixture()
{:ok, {_token1, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user1.id,
name: "User 1 Token"
})
{:ok, {_token2, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user2.id,
name: "User 2 Token"
})
tokens = ApiTokens.list_user_api_tokens(user1.id)
assert length(tokens) == 1
assert hd(tokens).name == "User 1 Token"
end
end
describe "get_api_token!/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token}
end
test "returns the token with the given id", %{token: token} do
fetched_token = ApiTokens.get_api_token!(token.id)
assert fetched_token.id == token.id
assert fetched_token.name == token.name
end
test "raises if the token does not exist" do
assert_raise Ecto.NoResultsError, fn ->
ApiTokens.get_api_token!(Ecto.UUID.generate())
end
end
end
describe "verify_token/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token, raw_token: raw_token, organization: organization, user: user}
end
test "returns organization_id and user for valid token", %{
raw_token: raw_token,
organization: organization,
user: user
} do
assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
assert returned_user.id == user.id
end
test "updates last_used_at timestamp", %{token: token, raw_token: raw_token} do
assert is_nil(token.last_used_at)
{:ok, _org_id, _user} = ApiTokens.verify_token(raw_token)
# Give the async Task time to complete
Process.sleep(50)
updated_token = ApiTokens.get_api_token!(token.id)
assert updated_token.last_used_at
assert DateTime.diff(updated_token.last_used_at, DateTime.utc_now(), :second) <= 1
end
test "returns error for invalid token" do
assert {:error, :invalid_token} = ApiTokens.verify_token("invalid_token")
end
test "returns error for expired token", %{
user: user,
organization: organization
} do
expires_at = DateTime.add(DateTime.utc_now(), -1, :day)
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Expired Token",
expires_at: expires_at
})
assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token)
end
test "works for token without expiration", %{
raw_token: raw_token,
organization: organization
} do
assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
end
test "works for token without user_id", %{organization: organization} do
{:ok, {_token, raw_token}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
name: "No User Token"
})
assert {:ok, org_id, nil} = ApiTokens.verify_token(raw_token)
assert org_id == organization.id
end
end
describe "delete_api_token/1" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token}
end
test "deletes the token", %{token: token} do
assert {:ok, %ApiToken{}} = ApiTokens.delete_api_token(token)
assert_raise Ecto.NoResultsError, fn ->
ApiTokens.get_api_token!(token.id)
end
end
end
describe "update_api_token/2" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, {token, _}} =
ApiTokens.create_api_token(%{
organization_id: organization.id,
user_id: user.id,
name: "Test Token"
})
%{token: token}
end
test "updates the token name", %{token: token} do
assert {:ok, updated_token} = ApiTokens.update_api_token(token, %{name: "New Name"})
assert updated_token.name == "New Name"
end
test "updates the expiration", %{token: token} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
assert {:ok, updated_token} =
ApiTokens.update_api_token(token, %{expires_at: expires_at})
assert updated_token.expires_at == DateTime.truncate(expires_at, :second)
end
test "returns error with invalid name", %{token: token} do
assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: ""})
assert "can't be blank" in errors_on(changeset).name
end
test "returns error with too long name", %{token: token} do
long_name = String.duplicate("a", 256)
assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: long_name})
assert "should be at most 255 character(s)" in errors_on(changeset).name
end
end
end

View file

@ -0,0 +1,569 @@
defmodule Towerops.Agent.ProtoTest do
@moduledoc """
Tests for Protocol Buffer generated modules.
These tests verify that encoding and decoding works correctly for all
protobuf message types used in agent communication.
"""
use ExUnit.Case, async: true
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.MonitoringCheck
alias Towerops.Agent.NeighborDiscovery
alias Towerops.Agent.Sensor
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
describe "AgentError" do
test "encodes and decodes correctly" do
error = %AgentError{
device_id: "device-123",
job_id: "job-456",
message: "SNMP timeout",
timestamp: 1_234_567_890
}
encoded = AgentError.encode(error)
decoded = AgentError.decode(encoded)
assert decoded.device_id == error.device_id
assert decoded.job_id == error.job_id
assert decoded.message == error.message
assert decoded.timestamp == error.timestamp
end
test "encodes empty message" do
error = %AgentError{}
encoded = AgentError.encode(error)
decoded = AgentError.decode(encoded)
assert decoded.device_id == ""
assert decoded.job_id == ""
assert decoded.message == ""
assert decoded.timestamp == 0
end
end
describe "AgentHeartbeat" do
test "encodes and decodes correctly" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent-host",
uptime_seconds: 3600,
ip_address: "192.168.1.100"
}
encoded = AgentHeartbeat.encode(heartbeat)
decoded = AgentHeartbeat.decode(encoded)
assert decoded.version == heartbeat.version
assert decoded.hostname == heartbeat.hostname
assert decoded.uptime_seconds == heartbeat.uptime_seconds
assert decoded.ip_address == heartbeat.ip_address
end
test "encodes empty message" do
heartbeat = %AgentHeartbeat{}
encoded = AgentHeartbeat.encode(heartbeat)
decoded = AgentHeartbeat.decode(encoded)
assert decoded.version == ""
assert decoded.hostname == ""
assert decoded.uptime_seconds == 0
assert decoded.ip_address == ""
end
end
describe "JobType enum" do
test "encodes DISCOVER constant" do
# Enums are represented as atoms in structs but integers in the wire format
job = %AgentJob{job_type: :DISCOVER}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert decoded.job_type == :DISCOVER
end
test "encodes POLL constant" do
job = %AgentJob{job_type: :POLL}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert decoded.job_type == :POLL
end
end
describe "QueryType enum" do
test "encodes GET constant" do
query = %SnmpQuery{query_type: :GET}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :GET
end
test "encodes WALK constant" do
query = %SnmpQuery{query_type: :WALK}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :WALK
end
end
describe "SnmpDevice" do
test "encodes and decodes correctly" do
device = %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
monitoring_enabled: true,
check_interval_seconds: 60
}
encoded = SnmpDevice.encode(device)
decoded = SnmpDevice.decode(encoded)
assert decoded.ip == device.ip
assert decoded.community == device.community
assert decoded.version == device.version
assert decoded.port == device.port
assert decoded.monitoring_enabled == device.monitoring_enabled
assert decoded.check_interval_seconds == device.check_interval_seconds
end
test "encodes empty message" do
device = %SnmpDevice{}
encoded = SnmpDevice.encode(device)
decoded = SnmpDevice.decode(encoded)
assert decoded.ip == ""
assert decoded.community == ""
assert decoded.version == ""
assert decoded.port == 0
assert decoded.monitoring_enabled == false
assert decoded.check_interval_seconds == 0
end
end
describe "SnmpQuery" do
test "encodes and decodes GET query" do
query = %SnmpQuery{
query_type: :GET,
oids: ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"]
}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :GET
assert decoded.oids == query.oids
end
test "encodes and decodes WALK query" do
query = %SnmpQuery{
query_type: :WALK,
oids: ["1.3.6.1.2.1.2.2.1"]
}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :WALK
assert decoded.oids == query.oids
end
test "encodes empty message" do
query = %SnmpQuery{}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :GET
assert decoded.oids == []
end
end
describe "AgentJob" do
test "encodes and decodes DISCOVER job" do
job = %AgentJob{
job_id: "discover:device-123",
job_type: :DISCOVER,
device_id: "device-123",
snmp_device: %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
monitoring_enabled: true,
check_interval_seconds: 60
},
queries: [
%SnmpQuery{
query_type: :GET,
oids: ["1.3.6.1.2.1.1.1.0"]
}
]
}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert decoded.job_id == job.job_id
assert decoded.job_type == :DISCOVER
assert decoded.device_id == job.device_id
assert decoded.snmp_device.ip == job.snmp_device.ip
assert decoded.snmp_device.community == job.snmp_device.community
assert decoded.snmp_device.version == job.snmp_device.version
assert decoded.snmp_device.port == job.snmp_device.port
assert decoded.snmp_device.monitoring_enabled == job.snmp_device.monitoring_enabled
assert decoded.snmp_device.check_interval_seconds ==
job.snmp_device.check_interval_seconds
assert length(decoded.queries) == 1
assert hd(decoded.queries).query_type == :GET
assert hd(decoded.queries).oids == ["1.3.6.1.2.1.1.1.0"]
end
test "encodes and decodes POLL job" do
job = %AgentJob{
job_id: "poll:device-123",
job_type: :POLL,
device_id: "device-123",
snmp_device: %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161
},
queries: [
%SnmpQuery{
query_type: :GET,
oids: ["1.3.6.1.4.1.14988.1.1.3.8.0"]
}
]
}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert decoded.job_id == job.job_id
assert decoded.job_type == :POLL
assert decoded.device_id == job.device_id
assert decoded.snmp_device.ip == job.snmp_device.ip
assert length(decoded.queries) == 1
end
test "encodes empty message" do
job = %AgentJob{}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert decoded.job_id == ""
assert decoded.job_type == :DISCOVER
assert decoded.device_id == ""
assert decoded.snmp_device == nil
assert decoded.queries == []
end
end
describe "AgentJobList" do
test "encodes and decodes list of jobs" do
job_list = %AgentJobList{
jobs: [
%AgentJob{
job_id: "poll:device-1",
job_type: :POLL,
device_id: "device-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: []
},
%AgentJob{
job_id: "discover:device-2",
job_type: :DISCOVER,
device_id: "device-2",
snmp_device: %SnmpDevice{ip: "192.168.1.2"},
queries: []
}
]
}
encoded = AgentJobList.encode(job_list)
decoded = AgentJobList.decode(encoded)
assert length(decoded.jobs) == 2
assert Enum.at(decoded.jobs, 0).job_id == "poll:device-1"
assert Enum.at(decoded.jobs, 0).job_type == :POLL
assert Enum.at(decoded.jobs, 1).job_id == "discover:device-2"
assert Enum.at(decoded.jobs, 1).job_type == :DISCOVER
end
test "encodes empty job list" do
job_list = %AgentJobList{jobs: []}
encoded = AgentJobList.encode(job_list)
decoded = AgentJobList.decode(encoded)
assert decoded.jobs == []
end
end
describe "SnmpResult" do
test "encodes and decodes with oid_values map" do
result = %SnmpResult{
device_id: "device-123",
job_type: :POLL,
oid_values: %{
"1.3.6.1.2.1.1.1.0" => "Cisco IOS",
"1.3.6.1.2.1.1.3.0" => "12345"
},
timestamp: 1_234_567_890
}
encoded = SnmpResult.encode(result)
decoded = SnmpResult.decode(encoded)
assert decoded.device_id == result.device_id
assert decoded.job_type == :POLL
assert decoded.oid_values == result.oid_values
assert decoded.timestamp == result.timestamp
end
test "encodes and decodes DISCOVER result" do
result = %SnmpResult{
device_id: "device-123",
job_type: :DISCOVER,
oid_values: %{},
timestamp: 1_234_567_890
}
encoded = SnmpResult.encode(result)
decoded = SnmpResult.decode(encoded)
assert decoded.device_id == result.device_id
assert decoded.job_type == :DISCOVER
assert decoded.timestamp == result.timestamp
end
test "encodes empty message" do
result = %SnmpResult{}
encoded = SnmpResult.encode(result)
decoded = SnmpResult.decode(encoded)
assert decoded.device_id == ""
assert decoded.job_type == :DISCOVER
assert decoded.oid_values == %{}
assert decoded.timestamp == 0
end
end
describe "SnmpResult.OidValuesEntry" do
test "encodes and decodes map entry" do
entry = %SnmpResult.OidValuesEntry{
key: "1.3.6.1.2.1.1.1.0",
value: "System Description"
}
encoded = SnmpResult.OidValuesEntry.encode(entry)
decoded = SnmpResult.OidValuesEntry.decode(encoded)
assert decoded.key == entry.key
assert decoded.value == entry.value
end
end
describe "MonitoringCheck" do
test "encodes and decodes success check" do
check = %MonitoringCheck{
device_id: "device-123",
status: "success",
response_time_ms: 45.2,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
decoded = MonitoringCheck.decode(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
assert decoded.response_time_ms == check.response_time_ms
assert decoded.timestamp == check.timestamp
end
test "encodes and decodes failure check" do
check = %MonitoringCheck{
device_id: "device-123",
status: "failure",
response_time_ms: 0.0,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
decoded = MonitoringCheck.decode(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
assert decoded.response_time_ms == check.response_time_ms
assert decoded.timestamp == check.timestamp
end
test "encodes empty message" do
check = %MonitoringCheck{}
encoded = MonitoringCheck.encode(check)
decoded = MonitoringCheck.decode(encoded)
assert decoded.device_id == ""
assert decoded.status == ""
assert decoded.response_time_ms == 0.0
assert decoded.timestamp == 0
end
end
describe "NeighborDiscovery" do
test "encodes and decodes LLDP neighbor" do
neighbor = %NeighborDiscovery{
interface_id: "interface-123",
protocol: "LLDP",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "neighbor-switch",
remote_system_description: "Cisco IOS",
remote_platform: "cisco",
remote_port_id: "GigabitEthernet1/0/1",
remote_port_description: "Uplink to Core",
remote_address: "192.168.1.2",
remote_capabilities: ["bridge", "router"],
timestamp: 1_234_567_890
}
encoded = NeighborDiscovery.encode(neighbor)
decoded = NeighborDiscovery.decode(encoded)
assert decoded.interface_id == neighbor.interface_id
assert decoded.protocol == neighbor.protocol
assert decoded.remote_chassis_id == neighbor.remote_chassis_id
assert decoded.remote_system_name == neighbor.remote_system_name
assert decoded.remote_system_description == neighbor.remote_system_description
assert decoded.remote_platform == neighbor.remote_platform
assert decoded.remote_port_id == neighbor.remote_port_id
assert decoded.remote_port_description == neighbor.remote_port_description
assert decoded.remote_address == neighbor.remote_address
assert decoded.remote_capabilities == neighbor.remote_capabilities
assert decoded.timestamp == neighbor.timestamp
end
test "encodes and decodes CDP neighbor" do
neighbor = %NeighborDiscovery{
interface_id: "interface-456",
protocol: "CDP",
remote_chassis_id: "Router-2",
remote_system_name: "core-router",
remote_platform: "Cisco 2960",
remote_port_id: "FastEthernet0/1",
timestamp: 1_234_567_890
}
encoded = NeighborDiscovery.encode(neighbor)
decoded = NeighborDiscovery.decode(encoded)
assert decoded.interface_id == neighbor.interface_id
assert decoded.protocol == neighbor.protocol
assert decoded.remote_chassis_id == neighbor.remote_chassis_id
assert decoded.timestamp == neighbor.timestamp
end
test "encodes empty message" do
neighbor = %NeighborDiscovery{}
encoded = NeighborDiscovery.encode(neighbor)
decoded = NeighborDiscovery.decode(encoded)
assert decoded.interface_id == ""
assert decoded.protocol == ""
assert decoded.remote_chassis_id == ""
assert decoded.remote_system_name == ""
assert decoded.remote_capabilities == []
assert decoded.timestamp == 0
end
end
describe "Sensor.MetadataEntry" do
test "encodes and decodes map entry" do
entry = %Sensor.MetadataEntry{
key: "location",
value: "CPU Core 1"
}
encoded = Sensor.MetadataEntry.encode(entry)
decoded = Sensor.MetadataEntry.decode(encoded)
assert decoded.key == entry.key
assert decoded.value == entry.value
end
test "encodes empty entry" do
entry = %Sensor.MetadataEntry{}
encoded = Sensor.MetadataEntry.encode(entry)
decoded = Sensor.MetadataEntry.decode(encoded)
assert decoded.key == ""
assert decoded.value == ""
end
end
describe "Sensor (protobuf)" do
test "encodes and decodes sensor with metadata" do
sensor = %Sensor{
id: "sensor-123",
type: "temperature",
oid: "1.3.6.1.4.1.14988.1.1.3.10.0",
divisor: 10.0,
unit: "celsius",
metadata: %{
"location" => "CPU Core 1",
"threshold" => "80"
}
}
encoded = Sensor.encode(sensor)
decoded = Sensor.decode(encoded)
assert decoded.id == sensor.id
assert decoded.type == sensor.type
assert decoded.oid == sensor.oid
assert decoded.divisor == sensor.divisor
assert decoded.unit == sensor.unit
assert decoded.metadata == sensor.metadata
end
test "encodes and decodes sensor without metadata" do
sensor = %Sensor{
id: "sensor-456",
type: "voltage",
oid: "1.3.6.1.4.1.14988.1.1.3.8.0",
divisor: 10.0,
unit: "volts"
}
encoded = Sensor.encode(sensor)
decoded = Sensor.decode(encoded)
assert decoded.id == sensor.id
assert decoded.type == sensor.type
assert decoded.oid == sensor.oid
assert decoded.divisor == sensor.divisor
assert decoded.unit == sensor.unit
end
test "encodes empty message" do
sensor = %Sensor{}
encoded = Sensor.encode(sensor)
decoded = Sensor.decode(encoded)
assert decoded.id == ""
assert decoded.type == ""
assert decoded.oid == ""
assert decoded.divisor == 0.0
assert decoded.unit == ""
assert decoded.metadata == %{}
end
end
end