diff --git a/k8s/debug-redis.sh b/k8s/debug-redis.sh deleted file mode 100755 index 1fa5886f..00000000 --- a/k8s/debug-redis.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -# Script to debug Redis/Valkey connectivity in production - -set -e - -NAMESPACE="${1:-towerops}" -# Get a running pod (not terminating) -POD_NAME=$(kubectl get pods -n "$NAMESPACE" -l app=towerops --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) - -if [ -z "$POD_NAME" ]; then - echo "Error: No running towerops pods found in namespace $NAMESPACE" - exit 1 -fi - -echo "=== Debugging Redis/Valkey in pod: $POD_NAME ===" -echo - -echo "1. Checking if both containers are running:" -kubectl get pod "$POD_NAME" -n "$NAMESPACE" -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.ready}{"\t"}{.state}{"\n"}{end}' -echo - -echo "2. Checking Valkey container logs:" -kubectl logs "$POD_NAME" -n "$NAMESPACE" -c valkey --tail=20 -echo - -echo "3. Testing Valkey connectivity from towerops container:" -kubectl exec "$POD_NAME" -n "$NAMESPACE" -c towerops -- sh -c 'nc -zv 127.0.0.1 6379 2>&1 || echo "Cannot connect to Redis at 127.0.0.1:6379"' -echo - -echo "4. Checking Redis environment variables:" -kubectl exec "$POD_NAME" -n "$NAMESPACE" -c towerops -- env | grep -i redis -echo - -echo "5. Checking if Valkey is accepting connections:" -kubectl exec "$POD_NAME" -n "$NAMESPACE" -c valkey -- valkey-cli ping 2>&1 || echo "Valkey ping failed" -echo - -echo "=== Debug complete ===" -echo -echo "If Valkey is not running, try deleting the pod to restart it:" -echo " kubectl delete pod $POD_NAME -n $NAMESPACE" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 8643ec1f..0f998f39 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -5,7 +5,7 @@ metadata: name: towerops namespace: towerops spec: - replicas: 4 + replicas: 1 strategy: type: RollingUpdate rollingUpdate: diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index b91514e1..25c5e4b9 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -203,15 +203,7 @@ defmodule Towerops.Devices do device.site.organization.snmp_version || "2c" - # Determine source based on where community string came from (primary credential) - # If no community anywhere, source is :default - source = - cond do - device.snmp_community != nil -> :device - device.site.snmp_community != nil -> :site - device.site.organization.snmp_community != nil -> :organization - true -> :default - end + source = determine_snmp_source(device) %{ version: version, @@ -220,6 +212,17 @@ defmodule Towerops.Devices do } end + defp determine_snmp_source(device) do + # Determine source based on where community string came from (primary credential) + # If no community anywhere, source is :default + cond do + device.snmp_community != nil -> :device + device.site.snmp_community != nil -> :site + device.site.organization.snmp_community != nil -> :organization + true -> :default + end + end + @doc """ Creates device. """ diff --git a/lib/towerops/monitoring/ping.ex b/lib/towerops/monitoring/ping.ex index e6fc35ab..fe86e037 100644 --- a/lib/towerops/monitoring/ping.ex +++ b/lib/towerops/monitoring/ping.ex @@ -81,19 +81,7 @@ defmodule Towerops.Monitoring.Ping do :ok = :gen_udp.send(socket, ip_tuple, 0, packet) # Wait for ICMP echo reply - case :gen_udp.recv(socket, 0, timeout_ms) do - {:ok, {^ip_tuple, 0, reply_packet}} -> - case parse_icmp_reply(reply_packet, identifier, sequence) do - :ok -> :ok - {:error, reason} -> {:error, reason} - end - - {:error, :timeout} -> - {:error, :timeout} - - {:error, reason} -> - {:error, reason} - end + handle_icmp_recv(socket, ip_tuple, timeout_ms, identifier, sequence) after :gen_udp.close(socket) end @@ -114,6 +102,16 @@ defmodule Towerops.Monitoring.Ping do {:error, {:exception, Exception.message(e)}} end + defp handle_icmp_recv(socket, ip_tuple, timeout_ms, identifier, sequence) do + case :gen_udp.recv(socket, 0, timeout_ms) do + {:ok, {^ip_tuple, 0, reply_packet}} -> + parse_icmp_reply(reply_packet, identifier, sequence) + + {:error, reason} -> + {:error, reason} + end + end + defp build_icmp_echo_request(identifier, sequence) do # ICMP Echo Request format: # Type (8) | Code (0) | Checksum (16) | Identifier (16) | Sequence (16) | Data (variable) diff --git a/test/towerops/alerts_test.exs b/test/towerops/alerts_test.exs index eb08f2a4..fcad60be 100644 --- a/test/towerops/alerts_test.exs +++ b/test/towerops/alerts_test.exs @@ -139,5 +139,193 @@ defmodule Towerops.AlertsTest do assert Alerts.get_active_alert(device.id, :device_down) == nil end + + test "count_active_alerts/1 returns count of unresolved alerts", %{ + device: device, + organization: organization + } do + attrs = Map.put(@valid_attrs, :device_id, device.id) + {:ok, _alert1} = Alerts.create_alert(attrs) + {:ok, _alert2} = Alerts.create_alert(attrs) + + # Create resolved alert + {:ok, resolved} = Alerts.create_alert(attrs) + {:ok, _} = Alerts.resolve_alert(resolved) + + assert Alerts.count_active_alerts(organization.id) == 2 + end + + test "count_active_alerts/1 returns 0 when no active alerts", %{organization: organization} do + assert Alerts.count_active_alerts(organization.id) == 0 + end + + test "list_devices_alerts/2 respects limit parameter", %{device: device} do + attrs = Map.put(@valid_attrs, :device_id, device.id) + + # Create 5 alerts + for _i <- 1..5 do + {:ok, _} = Alerts.create_alert(attrs) + end + + # Request only 3 + alerts = Alerts.list_devices_alerts(device.id, 3) + assert length(alerts) == 3 + end + + test "list_devices_alerts/2 orders by triggered_at descending", %{device: device} do + attrs = Map.put(@valid_attrs, :device_id, device.id) + + {:ok, old_alert} = + Alerts.create_alert(Map.put(attrs, :triggered_at, ~U[2025-12-21 10:00:00Z])) + + {:ok, new_alert} = + Alerts.create_alert(Map.put(attrs, :triggered_at, ~U[2025-12-21 12:00:00Z])) + + [first, second] = Alerts.list_devices_alerts(device.id) + assert first.id == new_alert.id + assert second.id == old_alert.id + end + + test "list_organization_alerts/2 with status filter active", %{ + device: device, + organization: organization, + user: user + } do + attrs = Map.put(@valid_attrs, :device_id, device.id) + + # Create unacknowledged alert + {:ok, active_alert} = Alerts.create_alert(attrs) + + # Create acknowledged alert + {:ok, acked_alert} = Alerts.create_alert(attrs) + {:ok, _} = Alerts.acknowledge_alert(acked_alert, user.id) + + # Create resolved alert + {:ok, resolved_alert} = Alerts.create_alert(attrs) + {:ok, _} = Alerts.resolve_alert(resolved_alert) + + active = Alerts.list_organization_alerts(organization.id, %{"status" => "active"}) + assert length(active) == 1 + assert hd(active).id == active_alert.id + end + + test "list_organization_alerts/2 with status filter acknowledged", %{ + device: device, + organization: organization, + user: user + } do + attrs = Map.put(@valid_attrs, :device_id, device.id) + + # Create acknowledged alert + {:ok, acked_alert} = Alerts.create_alert(attrs) + {:ok, _} = Alerts.acknowledge_alert(acked_alert, user.id) + + # Create unacknowledged alert + {:ok, _active} = Alerts.create_alert(attrs) + + acknowledged = + Alerts.list_organization_alerts(organization.id, %{"status" => "acknowledged"}) + + assert length(acknowledged) == 1 + assert hd(acknowledged).id == acked_alert.id + end + + test "list_organization_alerts/2 with status filter resolved", %{ + device: device, + organization: organization + } do + attrs = Map.put(@valid_attrs, :device_id, device.id) + + # Create resolved alert + {:ok, resolved_alert} = Alerts.create_alert(attrs) + {:ok, _} = Alerts.resolve_alert(resolved_alert) + + # Create active alert + {:ok, _active} = Alerts.create_alert(attrs) + + resolved = Alerts.list_organization_alerts(organization.id, %{"status" => "resolved"}) + assert length(resolved) == 1 + assert hd(resolved).id == resolved_alert.id + end + + test "list_organization_alerts/2 with limit in filters map", %{ + device: device, + organization: organization + } do + attrs = Map.put(@valid_attrs, :device_id, device.id) + + for _i <- 1..5 do + {:ok, _} = Alerts.create_alert(attrs) + end + + alerts = Alerts.list_organization_alerts(organization.id, %{"limit" => 2}) + assert length(alerts) == 2 + end + + test "list_organization_alerts/2 defaults to limit 100", %{ + device: device, + organization: organization + } do + attrs = Map.put(@valid_attrs, :device_id, device.id) + + # Just verify it doesn't error with no limit specified + {:ok, _} = Alerts.create_alert(attrs) + + alerts = Alerts.list_organization_alerts(organization.id, %{}) + assert length(alerts) == 1 + end + + test "list_organization_active_alerts/1 only returns device_down alerts", %{ + device: device, + organization: organization + } do + # Create device_down alert + {:ok, down_alert} = + Alerts.create_alert(%{ + device_id: device.id, + alert_type: :device_down, + triggered_at: DateTime.utc_now() + }) + + # Create device_up alert + {:ok, _up_alert} = + Alerts.create_alert(%{ + device_id: device.id, + alert_type: :device_up, + triggered_at: DateTime.utc_now() + }) + + active = Alerts.list_organization_active_alerts(organization.id) + assert length(active) == 1 + assert hd(active).id == down_alert.id + assert hd(active).alert_type == :device_down + end + + test "list_organization_active_alerts/1 preloads associations", %{ + device: device, + organization: organization + } do + attrs = Map.put(@valid_attrs, :device_id, device.id) + {:ok, _alert} = Alerts.create_alert(attrs) + + [alert] = Alerts.list_organization_active_alerts(organization.id) + assert Ecto.assoc_loaded?(alert.device) + assert Ecto.assoc_loaded?(alert.device.site) + end + + test "get_alert!/1 preloads associations", %{device: device} do + attrs = Map.put(@valid_attrs, :device_id, device.id) + {:ok, alert} = Alerts.create_alert(attrs) + + fetched = Alerts.get_alert!(alert.id) + assert Ecto.assoc_loaded?(fetched.device) + assert Ecto.assoc_loaded?(fetched.acknowledged_by) + end + + test "get_alert!/1 raises when alert doesn't exist" do + assert_raise Ecto.NoResultsError, fn -> + Alerts.get_alert!(Ecto.UUID.generate()) + end + end end end diff --git a/test/towerops/log_filter_test.exs b/test/towerops/log_filter_test.exs new file mode 100644 index 00000000..597e1748 --- /dev/null +++ b/test/towerops/log_filter_test.exs @@ -0,0 +1,163 @@ +defmodule Towerops.LogFilterTest do + use ExUnit.Case, async: true + + alias Towerops.LogFilter + + describe "filter_bandit_errors/2" do + test "stops Bandit invalid HTTP version string errors" do + log_event = %{ + level: :error, + msg: {:string, "** (Bandit.HTTPError) Invalid HTTP version"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :stop + end + + test "stops Bandit HTTP errors with additional context" do + log_event = %{ + level: :error, + msg: {:string, "Some context Bandit.HTTPError: Invalid HTTP version details"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :stop + end + + test "ignores Bandit errors without 'Invalid HTTP version'" do + log_event = %{ + level: :error, + msg: {:string, "** (Bandit.HTTPError) Some other error"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "stops error reports containing Bandit.HTTPError" do + log_event = %{ + level: :error, + msg: { + :report, + %{ + label: {:error_logger, :error_report}, + report: [message: "** (Bandit.HTTPError) Connection error"] + } + } + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :stop + end + + test "ignores error reports without Bandit.HTTPError" do + log_event = %{ + level: :error, + msg: { + :report, + %{ + label: {:error_logger, :error_report}, + report: [message: "Some other error"] + } + } + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores error reports with non-binary message" do + log_event = %{ + level: :error, + msg: { + :report, + %{ + label: {:error_logger, :error_report}, + report: [message: {:atom, :some_error}] + } + } + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores error reports with no message key" do + log_event = %{ + level: :error, + msg: { + :report, + %{ + label: {:error_logger, :error_report}, + report: [other_key: "value"] + } + } + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores non-error level logs" do + log_event = %{ + level: :info, + msg: {:string, "** (Bandit.HTTPError) Invalid HTTP version"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores non-string messages" do + log_event = %{ + level: :error, + msg: {:atom, :some_error} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores logs with unexpected structure" do + log_event = %{ + level: :error + # Missing :msg key + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores empty map" do + log_event = %{} + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores warning level with Bandit error" do + log_event = %{ + level: :warning, + msg: {:string, "** (Bandit.HTTPError) Invalid HTTP version"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "stops error with Bandit.HTTPError and Invalid HTTP version (case sensitive)" do + log_event = %{ + level: :error, + msg: {:string, "Bandit.HTTPError: Invalid HTTP version"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :stop + end + + test "ignores if only 'Bandit.HTTPError' present without 'Invalid HTTP version'" do + log_event = %{ + level: :error, + msg: {:string, "Bandit.HTTPError occurred"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + + test "ignores if only 'Invalid HTTP version' present without 'Bandit.HTTPError'" do + log_event = %{ + level: :error, + msg: {:string, "Invalid HTTP version detected"} + } + + assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + end + end +end diff --git a/test/towerops/snmp/discovery_test.exs b/test/towerops/snmp/discovery_test.exs index fdccef6a..d34e03d0 100644 --- a/test/towerops/snmp/discovery_test.exs +++ b/test/towerops/snmp/discovery_test.exs @@ -5,6 +5,7 @@ defmodule Towerops.Snmp.DiscoveryTest do import Towerops.AccountsFixtures alias Towerops.Devices.Device + alias Towerops.Repo alias Towerops.Snmp.Device alias Towerops.Snmp.Discovery alias Towerops.Snmp.Interface