From c8b275bd4c90b18810bb914789e8aa4663a50850 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 21 Mar 2026 18:36:12 -0500 Subject: [PATCH] gleam4 (#105) Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/105 --- lib/towerops/capacity/resolver.ex | 30 +-- lib/towerops/config_changes/correlator.ex | 53 +++-- lib/towerops/ecto_types/ip_address.ex | 32 +-- lib/towerops/ecto_types/mac_address.ex | 160 +++------------ lib/towerops/ecto_types/snmp_oid.ex | 77 ++----- lib/towerops/organizations/policy.ex | 75 +++---- lib/towerops/preseem/baseline.ex | 23 +-- lib/towerops/result.ex | 28 +-- lib/towerops/snmp/sensor_change_detector.ex | 36 ++-- src/towerops/capacity/resolver.gleam | 38 ++++ src/towerops/config_changes/correlator.gleam | 54 +++++ src/towerops/ecto_types/ip_address.gleam | 70 +++++++ src/towerops/ecto_types/mac_address.gleam | 193 ++++++++++++++++++ src/towerops/ecto_types/snmp_oid.gleam | 148 ++++++++++++++ src/towerops/organizations/policy.gleam | 124 +++++++++++ src/towerops/preseem/baseline.gleam | 55 +++++ src/towerops/result.gleam | 54 +++++ .../snmp/sensor_change_detector.gleam | 83 ++++++++ src/towerops_baseline_ffi.erl | 14 ++ src/towerops_ip_address_ffi.erl | 11 + src/towerops_sensor_ffi.erl | 9 + 21 files changed, 1008 insertions(+), 359 deletions(-) create mode 100644 src/towerops/capacity/resolver.gleam create mode 100644 src/towerops/config_changes/correlator.gleam create mode 100644 src/towerops/ecto_types/ip_address.gleam create mode 100644 src/towerops/ecto_types/mac_address.gleam create mode 100644 src/towerops/ecto_types/snmp_oid.gleam create mode 100644 src/towerops/organizations/policy.gleam create mode 100644 src/towerops/preseem/baseline.gleam create mode 100644 src/towerops/result.gleam create mode 100644 src/towerops/snmp/sensor_change_detector.gleam create mode 100644 src/towerops_baseline_ffi.erl create mode 100644 src/towerops_ip_address_ffi.erl create mode 100644 src/towerops_sensor_ffi.erl diff --git a/lib/towerops/capacity/resolver.ex b/lib/towerops/capacity/resolver.ex index de900405..dc2b8366 100644 --- a/lib/towerops/capacity/resolver.ex +++ b/lib/towerops/capacity/resolver.ex @@ -12,6 +12,7 @@ defmodule Towerops.Capacity.Resolver do alias Towerops.Repo alias Towerops.Snmp.Sensor + @gleam :towerops@capacity@resolver @capacity_sensor_types ~w(capacity rate) @doc """ @@ -51,30 +52,11 @@ defmodule Towerops.Capacity.Resolver do def normalize_sensor_to_bps(%Sensor{last_value: v}) when v <= 0, do: nil def normalize_sensor_to_bps(%Sensor{last_value: value, sensor_unit: unit, sensor_divisor: divisor}) do - adjusted = value / (divisor || 1) - - bps = - case normalize_unit(unit) do - :bps -> adjusted - :kbps -> adjusted * 1_000 - :mbps -> adjusted * 1_000_000 - :gbps -> adjusted * 1_000_000_000 - _ -> adjusted - end - - trunc(bps) - end - - defp normalize_unit(nil), do: :bps - - defp normalize_unit(unit) when is_binary(unit) do - case String.downcase(unit) do - "bps" -> :bps - "kbps" -> :kbps - "mbps" -> :mbps - "gbps" -> :gbps - _ -> :bps - end + @gleam.normalize_to_bps( + value / 1.0, + unit || "", + (divisor || 1) / 1.0 + ) end defp resolve_manual(%{capacity_source: "manual", configured_capacity_bps: bps}) when is_integer(bps) and bps > 0 do diff --git a/lib/towerops/config_changes/correlator.ex b/lib/towerops/config_changes/correlator.ex index 15bae3f5..a4058a1f 100644 --- a/lib/towerops/config_changes/correlator.ex +++ b/lib/towerops/config_changes/correlator.ex @@ -17,6 +17,8 @@ defmodule Towerops.ConfigChanges.Correlator do require Logger + @gleam :towerops@config_changes@correlator + @pre_window_hours 2 @post_window_hours 4 @@ -100,10 +102,10 @@ defmodule Towerops.ConfigChanges.Correlator do defp evaluate_impact(event, pre, post) do deltas = %{ - latency_delta: safe_pct_change(pre.avg_latency, post.avg_latency), - jitter_delta: safe_pct_change(pre.avg_jitter, post.avg_jitter), - loss_delta: safe_pct_change(pre.avg_loss, post.avg_loss), - throughput_delta: safe_pct_change(pre.avg_throughput, post.avg_throughput) + latency_delta: @gleam.safe_pct_change(to_float(pre.avg_latency), to_float(post.avg_latency)), + jitter_delta: @gleam.safe_pct_change(to_float(pre.avg_jitter), to_float(post.avg_jitter)), + loss_delta: @gleam.safe_pct_change(to_float(pre.avg_loss), to_float(post.avg_loss)), + throughput_delta: @gleam.safe_pct_change(to_float(pre.avg_throughput), to_float(post.avg_throughput)) } significant? = @@ -148,26 +150,28 @@ defmodule Towerops.ConfigChanges.Correlator do end defp determine_severity(deltas) do - cond do - deltas.latency_delta > 0.50 || deltas.loss_delta > 1.0 || deltas.throughput_delta < -0.30 -> - "critical" - - deltas.latency_delta > @latency_increase_threshold || - deltas.loss_delta > @loss_increase_threshold || - deltas.throughput_delta < -@throughput_drop_threshold -> - "warning" - - true -> - "info" - end + @gleam.determine_severity( + deltas.latency_delta / 1.0, + deltas.loss_delta / 1.0, + deltas.throughput_delta / 1.0 + ) end defp build_description(deltas, event) do parts = [] - |> maybe_add("latency +#{round(deltas.latency_delta * 100)}%", deltas.latency_delta > 0.10) - |> maybe_add("loss +#{round(deltas.loss_delta * 100)}%", deltas.loss_delta > 0.10) - |> maybe_add("throughput #{round(deltas.throughput_delta * 100)}%", deltas.throughput_delta < -0.05) + |> @gleam.maybe_add( + "latency +#{round(deltas.latency_delta * 100)}%", + deltas.latency_delta > 0.10 + ) + |> @gleam.maybe_add( + "loss +#{round(deltas.loss_delta * 100)}%", + deltas.loss_delta > 0.10 + ) + |> @gleam.maybe_add( + "throughput #{round(deltas.throughput_delta * 100)}%", + deltas.throughput_delta < -0.05 + ) sections = if Enum.any?(event.sections_changed), @@ -177,14 +181,9 @@ defmodule Towerops.ConfigChanges.Correlator do "After config change#{sections}: #{Enum.join(parts, ", ")}." end - defp maybe_add(list, text, true), do: list ++ [text] - defp maybe_add(list, _text, false), do: list - - defp safe_pct_change(old, new) when is_number(old) and is_number(new) and old > 0 do - (new - old) / old - end - - defp safe_pct_change(_, _), do: 0.0 + defp to_float(nil), do: 0.0 + defp to_float(v) when is_float(v), do: v + defp to_float(v) when is_integer(v), do: v / 1 defp stringify_metrics(metrics) do %{ diff --git a/lib/towerops/ecto_types/ip_address.ex b/lib/towerops/ecto_types/ip_address.ex index 4b089c72..36dc86ec 100644 --- a/lib/towerops/ecto_types/ip_address.ex +++ b/lib/towerops/ecto_types/ip_address.ex @@ -55,6 +55,9 @@ defmodule Towerops.EctoTypes.IpAddress do @derive {Jason.Encoder, only: [:address]} defstruct [:address, :version, :tuple] + # Gleam module for pure parsing/validation functions + @gleam :towerops@ecto_types@ip_address + @impl Ecto.Type def type, do: :string @@ -192,36 +195,19 @@ defmodule Towerops.EctoTypes.IpAddress do @spec to_tuple(t()) :: ip_tuple() def to_tuple(%__MODULE__{tuple: tuple}), do: tuple - # Private helpers - - defp parse_string(""), do: :error + # Private helpers — parse_string, detect_version, strip_zone_id delegate to Gleam defp parse_string(value) when is_binary(value) do - # Strip IPv6 zone ID if present (e.g., "fe80::1%eth0" -> "fe80::1") - cleaned = strip_zone_id(value) - - # Reject CIDR notation (should use separate prefix_length field) - if String.contains?(cleaned, "/") do - :error - else - # Parse using Erlang's inet module - case cleaned |> String.to_charlist() |> :inet.parse_address() do - {:ok, ip_tuple} -> {:ok, cleaned, ip_tuple} - {:error, _} -> :error - end + case @gleam.parse_string(value) do + {:ok, {address, ip_tuple}} -> {:ok, address, ip_tuple} + {:error, _} -> :error end end - defp strip_zone_id(value) do - case String.split(value, "%", parts: 2) do - [ip, _zone] -> ip - [ip] -> ip - end + defp detect_version(ip_tuple) when is_tuple(ip_tuple) do + @gleam.detect_version(tuple_size(ip_tuple)) end - defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 4, do: :ipv4 - defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 8, do: :ipv6 - defp tuple_to_string(ip_tuple) when is_tuple(ip_tuple) do case :inet.ntoa(ip_tuple) do {:error, _} -> :error diff --git a/lib/towerops/ecto_types/mac_address.ex b/lib/towerops/ecto_types/mac_address.ex index dfbcd7b7..77cd0711 100644 --- a/lib/towerops/ecto_types/mac_address.ex +++ b/lib/towerops/ecto_types/mac_address.ex @@ -51,6 +51,9 @@ defmodule Towerops.EctoTypes.MacAddress do MAC addresses are stored as VARCHAR(17) in the database (sufficient for colon-separated format). No database migrations are required when adopting this custom type - Ecto handles conversion transparently. + + Parsing and formatting logic is implemented in Gleam at + `src/towerops/ecto_types/mac_address.gleam`. """ use Ecto.Type @@ -62,11 +65,8 @@ defmodule Towerops.EctoTypes.MacAddress do @derive {Jason.Encoder, only: [:address]} defstruct [:address, :binary] - # Regex patterns for different MAC address formats - @colon_format ~r/^([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})$/ - @hyphen_format ~r/^([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})$/ - @dot_format ~r/^([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})$/ - @compact_format ~r/^([0-9a-fA-F]{12})$/ + # Gleam module for pure parsing/formatting functions + @gleam :towerops@ecto_types@mac_address @impl Ecto.Type def type, do: :string @@ -82,30 +82,19 @@ defmodule Towerops.EctoTypes.MacAddress do def cast(binary) when is_binary(binary) and byte_size(binary) == 6 do # Check if it's a 6-byte binary (not a 6-character string) if String.printable?(binary) do - # It's a short string, try string parsing - case parse_string(binary) do - {:ok, normalized, bin} -> - {:ok, %__MODULE__{address: normalized, binary: bin}} - - :error -> - :error - end + # It's a short string, try string parsing via Gleam + cast_from_string(binary) else # It's a binary MAC address (SNMP format) - normalized = binary_to_colon_string(binary) + bytes = :erlang.binary_to_list(binary) + normalized = @gleam.format_colon(bytes) {:ok, %__MODULE__{address: normalized, binary: binary}} end end # Cast from string representation def cast(value) when is_binary(value) do - case parse_string(value) do - {:ok, normalized, binary} -> - {:ok, %__MODULE__{address: normalized, binary: binary}} - - :error -> - :error - end + cast_from_string(value) end def cast(_), do: :error @@ -206,130 +195,27 @@ defmodule Towerops.EctoTypes.MacAddress do """ @spec format(t(), :colon | :hyphen | :dot | :compact) :: String.t() def format(%__MODULE__{binary: binary}, format_type) do + bytes = :erlang.binary_to_list(binary) + case format_type do - :colon -> binary_to_colon_string(binary) - :hyphen -> binary_to_hyphen_string(binary) - :dot -> binary_to_dot_string(binary) - :compact -> binary_to_compact_string(binary) + :colon -> @gleam.format_colon(bytes) + :hyphen -> @gleam.format_hyphen(bytes) + :dot -> @gleam.format_dot(bytes) + :compact -> @gleam.format_compact(bytes) end end # Private helpers - defp parse_string(""), do: :error + defp cast_from_string(value) do + case @gleam.parse_string(value) do + {:ok, bytes} -> + binary = :erlang.list_to_binary(bytes) + normalized = @gleam.format_colon(bytes) + {:ok, %__MODULE__{address: normalized, binary: binary}} - defp parse_string(value) when is_binary(value) do - value - |> String.trim() - |> try_parse_formats() - end - - defp try_parse_formats(value) do - cond do - Regex.match?(@colon_format, value) -> parse_colon_format(value) - Regex.match?(@hyphen_format, value) -> parse_hyphen_format(value) - Regex.match?(@dot_format, value) -> parse_dot_format(value) - Regex.match?(@compact_format, value) -> parse_compact_format(value) - true -> :error - end - end - - defp parse_colon_format(value) do - case Regex.run(@colon_format, value) do - [_, o1, o2, o3, o4, o5, o6] -> - build_mac_address([o1, o2, o3, o4, o5, o6]) - - _ -> + {:error, _} -> :error end end - - defp parse_hyphen_format(value) do - case Regex.run(@hyphen_format, value) do - [_, o1, o2, o3, o4, o5, o6] -> - build_mac_address([o1, o2, o3, o4, o5, o6]) - - _ -> - :error - end - end - - defp parse_dot_format(value) do - case Regex.run(@dot_format, value) do - [_, g1, g2, g3] -> - # Split each group into 2-character octets - <> = g1 - <> = g2 - <> = g3 - build_mac_address([o1, o2, o3, o4, o5, o6]) - - _ -> - :error - end - end - - defp parse_compact_format(value) do - case Regex.run(@compact_format, value) do - [_, mac] -> - # Split into 2-character octets - <> = - mac - - build_mac_address([o1, o2, o3, o4, o5, o6]) - - _ -> - :error - end - end - - defp build_mac_address(octets) do - # Convert hex strings to integers - with {:ok, bytes} <- parse_hex_octets(octets) do - binary = :erlang.list_to_binary(bytes) - normalized = binary_to_colon_string(binary) - {:ok, normalized, binary} - end - end - - defp parse_hex_octets(octets) do - bytes = - Enum.map(octets, fn octet -> - {value, ""} = Integer.parse(String.downcase(octet), 16) - value - end) - - {:ok, bytes} - rescue - _ -> :error - end - - defp binary_to_colon_string(<>) do - Enum.map_join([o1, o2, o3, o4, o5, o6], ":", &format_octet/1) - end - - defp binary_to_hyphen_string(<>) do - Enum.map_join([o1, o2, o3, o4, o5, o6], "-", &format_octet/1) - end - - defp binary_to_dot_string(<>) do - Enum.join( - [ - format_octet(o1) <> format_octet(o2), - format_octet(o3) <> format_octet(o4), - format_octet(o5) <> format_octet(o6) - ], - "." - ) - end - - defp binary_to_compact_string(<>) do - Enum.map_join([o1, o2, o3, o4, o5, o6], "", &format_octet/1) - end - - defp format_octet(byte) do - byte - |> Integer.to_string(16) - |> String.downcase() - |> String.pad_leading(2, "0") - end end diff --git a/lib/towerops/ecto_types/snmp_oid.ex b/lib/towerops/ecto_types/snmp_oid.ex index 2f2cca5f..665028cd 100644 --- a/lib/towerops/ecto_types/snmp_oid.ex +++ b/lib/towerops/ecto_types/snmp_oid.ex @@ -72,9 +72,8 @@ defmodule Towerops.EctoTypes.SnmpOid do @derive {Jason.Encoder, only: [:oid, :named]} defstruct [:oid, :named, :parts] - # OID validation patterns - @numeric_oid_pattern ~r/^\.?([0-9]+)(\.([0-9]+))*$/ - @named_oid_pattern ~r/^[a-zA-Z][a-zA-Z0-9_-]*(::[a-zA-Z][a-zA-Z0-9_-]*)?((\.[a-zA-Z0-9_-]+)|(\.[0-9]+))*$/ + # Gleam module for pure OID parsing/manipulation logic + @gleam :towerops@ecto_types@snmp_oid @impl Ecto.Type def type, do: :string @@ -91,7 +90,7 @@ defmodule Towerops.EctoTypes.SnmpOid do case parse_oid(value) do {:ok, normalized_oid, parts} -> # Try to resolve if it's a named OID - named = if is_named_oid?(value), do: value + named = if @gleam.is_named_oid(value), do: value {:ok, %__MODULE__{oid: normalized_oid, named: named, parts: parts}} :error -> @@ -102,7 +101,7 @@ defmodule Towerops.EctoTypes.SnmpOid do # Cast from integer list (OID parts) def cast(parts) when is_list(parts) do if Enum.all?(parts, &is_integer/1) and Enum.all?(parts, &(&1 >= 0)) do - normalized = "." <> Enum.join(parts, ".") + normalized = @gleam.parts_to_string(parts) {:ok, %__MODULE__{oid: normalized, named: nil, parts: parts}} else :error @@ -203,8 +202,8 @@ defmodule Towerops.EctoTypes.SnmpOid do """ @spec parent(t()) :: t() def parent(%__MODULE__{parts: parts}) when length(parts) > 1 do - parent_parts = Enum.slice(parts, 0..-2//1) - parent_oid = "." <> Enum.join(parent_parts, ".") + parent_parts = @gleam.parent_parts(parts) + parent_oid = @gleam.parts_to_string(parent_parts) %__MODULE__{oid: parent_oid, named: nil, parts: parent_parts} end @@ -222,8 +221,7 @@ defmodule Towerops.EctoTypes.SnmpOid do """ @spec child_of?(t(), t()) :: boolean() def child_of?(%__MODULE__{parts: child_parts}, %__MODULE__{parts: parent_parts}) do - length(child_parts) > length(parent_parts) and - List.starts_with?(child_parts, parent_parts) + @gleam.child_of(child_parts, parent_parts) end @doc """ @@ -238,8 +236,8 @@ defmodule Towerops.EctoTypes.SnmpOid do """ @spec append(t(), [non_neg_integer()]) :: t() def append(%__MODULE__{parts: parts}, additional_parts) when is_list(additional_parts) do - new_parts = parts ++ additional_parts - new_oid = "." <> Enum.join(new_parts, ".") + new_parts = @gleam.append_parts(parts, additional_parts) + new_oid = @gleam.parts_to_string(new_parts) %__MODULE__{oid: new_oid, named: nil, parts: new_parts} end @@ -261,7 +259,7 @@ defmodule Towerops.EctoTypes.SnmpOid do # Try SnmpKit resolution case SnmpKit.resolve(oid_string) do {:ok, resolved} -> - {:ok, ensure_leading_dot(resolved)} + {:ok, @gleam.ensure_leading_dot(resolved)} {:error, _} = error -> resolve_numeric_oid(oid_string, error) @@ -269,17 +267,13 @@ defmodule Towerops.EctoTypes.SnmpOid do end defp resolve_numeric_oid(oid_string, error) do - if Regex.match?(@numeric_oid_pattern, oid_string) do - {:ok, ensure_leading_dot(oid_string)} + if @gleam.is_numeric_oid(oid_string) do + {:ok, @gleam.ensure_leading_dot(oid_string)} else error end end - defp ensure_leading_dot(oid_string) do - if String.starts_with?(oid_string, "."), do: oid_string, else: "." <> oid_string - end - # Private helpers defp parse_oid(""), do: :error @@ -288,48 +282,19 @@ defmodule Towerops.EctoTypes.SnmpOid do value = String.trim(value) cond do - Regex.match?(@numeric_oid_pattern, value) -> - parse_numeric_oid(value) - - Regex.match?(@named_oid_pattern, value) -> - # Try to resolve named OID to numeric - case resolve(value) do - {:ok, resolved} -> parse_numeric_oid(resolved) - {:error, _} -> :error - end - - true -> - :error + @gleam.is_numeric_oid(value) -> unwrap_parsed(@gleam.parse_numeric_oid(value)) + @gleam.is_named_oid(value) -> resolve_and_parse(value) + true -> :error end end - defp parse_numeric_oid(value) do - # Strip leading dot if present - value = String.trim_leading(value, ".") + defp unwrap_parsed({:ok, {normalized, parts}}), do: {:ok, normalized, parts} + defp unwrap_parsed({:error, _}), do: :error - # Split into parts and validate - case String.split(value, ".") do - [""] -> - :error - - parts -> - try do - int_parts = Enum.map(parts, &String.to_integer/1) - - if Enum.all?(int_parts, &(&1 >= 0)) do - normalized = "." <> Enum.join(int_parts, ".") - {:ok, normalized, int_parts} - else - :error - end - rescue - _ -> :error - end + defp resolve_and_parse(value) do + case resolve(value) do + {:ok, resolved} -> unwrap_parsed(@gleam.parse_numeric_oid(resolved)) + {:error, _} -> :error end end - - defp is_named_oid?(value) when is_binary(value) do - Regex.match?(@named_oid_pattern, value) and - not Regex.match?(@numeric_oid_pattern, value) - end end diff --git a/lib/towerops/organizations/policy.ex b/lib/towerops/organizations/policy.ex index 210ba5da..3ddb86e6 100644 --- a/lib/towerops/organizations/policy.ex +++ b/lib/towerops/organizations/policy.ex @@ -6,19 +6,38 @@ defmodule Towerops.Organizations.Policy do | Role | Write | Financials | Description | |--------------|-------|------------|------------------------------------------| - | `:owner` | ✅ | ✅ | Full access, can delete organization | - | `:admin` | ✅ | ✅ | Full access except deleting organization | - | `:executive` | ❌ | ✅ | Read-only with full financial visibility | - | `:technician`| ✅* | ❌ | Field ops: devices, sites, alerts only | - | `:member` | ✅* | ❌ | Legacy alias for technician | - | `:viewer` | ❌ | ❌ | Read-only, no financial data | + | `:owner` | Yes | Yes | Full access, can delete organization | + | `:admin` | Yes | Yes | Full access except deleting organization | + | `:executive` | No | Yes | Read-only with full financial visibility | + | `:technician`| Yes* | No | Field ops: devices, sites, alerts only | + | `:member` | Yes* | No | Legacy alias for technician | + | `:viewer` | No | No | Read-only, no financial data | *Technicians/members can create/edit devices, sites, and alerts but cannot manage memberships, invitations, integrations, or billing configuration. + + Permission logic is implemented in Gleam at + `src/towerops/organizations/policy.gleam`. This module provides the Elixir + interface, handling Membership struct unwrapping and mapping unknown atoms + to the Gleam type system. """ alias Towerops.Organizations.Membership + @gleam_module :towerops@organizations@policy + + @known_actions [:view, :list, :create, :edit, :delete, :acknowledge, :manage] + @known_resources [ + :organization, + :site, + :device, + :alert, + :membership, + :invitation, + :integration, + :financials + ] + @doc """ Checks if a user can perform an action on a resource within an organization. @@ -37,7 +56,7 @@ defmodule Towerops.Organizations.Policy do false """ def can?(%Membership{role: role}, action, resource) do - check_permission(role, action, resource) + @gleam_module.check_permission(role, map_action(action), map_resource(resource)) end def can?(nil, _action, _resource), do: false @@ -45,44 +64,12 @@ defmodule Towerops.Organizations.Policy do @doc """ Returns true if the given role can view financial data (MRR, revenue, billing). """ - def can_view_financials?(%Membership{role: role}), do: financials_visible?(role) + def can_view_financials?(%Membership{role: role}), do: @gleam_module.financials_visible(role) def can_view_financials?(nil), do: false - defp financials_visible?(role) when role in [:owner, :admin, :executive], do: true - defp financials_visible?(_role), do: false + defp map_action(action) when action in @known_actions, do: action + defp map_action(_action), do: :other_action - # ── Owner ────────────────────────────────────────────────────────────── - defp check_permission(:owner, _action, _resource), do: true - - # ── Admin ────────────────────────────────────────────────────────────── - defp check_permission(:admin, :delete, :organization), do: false - defp check_permission(:admin, _action, _resource), do: true - - # ── Executive (read-only + financials) ───────────────────────────────── - defp check_permission(:executive, action, _resource) when action in [:view, :list], do: true - defp check_permission(:executive, :acknowledge, :alert), do: true - defp check_permission(:executive, _action, _resource), do: false - - # ── Technician (field ops, no financials) ────────────────────────────── - defp check_permission(:technician, :view, :financials), do: false - defp check_permission(:technician, action, :organization) when action in [:view, :list], do: true - defp check_permission(:technician, :delete, _resource), do: false - defp check_permission(:technician, action, :membership) when action in [:create, :edit], do: false - defp check_permission(:technician, action, :invitation) when action in [:create, :delete], do: false - defp check_permission(:technician, action, :integration) when action in [:create, :edit, :delete], do: false - - defp check_permission(:technician, action, resource) - when action in [:view, :list, :create, :edit] and resource in [:site, :device, :alert], do: true - - defp check_permission(:technician, :acknowledge, :alert), do: true - defp check_permission(:technician, _action, _resource), do: false - - # ── Member (legacy, same as technician) ──────────────────────────────── - defp check_permission(:member, action, resource), do: check_permission(:technician, action, resource) - - # ── Viewer (read-only, no financials) ────────────────────────────────── - defp check_permission(:viewer, action, _resource) when action in [:view, :list], do: true - defp check_permission(:viewer, :view, :financials), do: false - defp check_permission(:viewer, :acknowledge, :alert), do: true - defp check_permission(:viewer, _action, _resource), do: false + defp map_resource(resource) when resource in @known_resources, do: resource + defp map_resource(_resource), do: :other_resource end diff --git a/lib/towerops/preseem/baseline.ex b/lib/towerops/preseem/baseline.ex index e9147da9..c5dc267d 100644 --- a/lib/towerops/preseem/baseline.ex +++ b/lib/towerops/preseem/baseline.ex @@ -71,25 +71,14 @@ defmodule Towerops.Preseem.Baseline do end defp compute_stats(values) do - sorted = Enum.sort(values) - n = length(sorted) - mean = Enum.sum(sorted) / n - - variance = - sorted - |> Enum.map(fn v -> (v - mean) * (v - mean) end) - |> Enum.sum() - |> Kernel./(max(n - 1, 1)) - - stddev = :math.sqrt(variance) - p5_idx = max(round(n * 0.05) - 1, 0) - p95_idx = min(round(n * 0.95) - 1, n - 1) + float_values = Enum.map(values, &to_float/1) + stats = :towerops@preseem@baseline.compute_stats(float_values) %{ - mean: Float.round(mean / 1, 4), - stddev: Float.round(stddev / 1, 4), - p5: sorted |> Enum.at(p5_idx) |> to_float(), - p95: sorted |> Enum.at(p95_idx) |> to_float() + mean: elem(stats, 1), + stddev: elem(stats, 2), + p5: elem(stats, 3), + p95: elem(stats, 4) } end diff --git a/lib/towerops/result.ex b/lib/towerops/result.ex index 7cd07abf..83f1f2b2 100644 --- a/lib/towerops/result.ex +++ b/lib/towerops/result.ex @@ -2,6 +2,8 @@ defmodule Towerops.Result do @moduledoc """ Generic result type for representing success or failure outcomes. + Core logic is implemented in Gleam (`:towerops@result`). + This module provides a type-safe way to handle operations that can succeed or fail, using Elixir's standard {:ok, value} | {:error, reason} pattern with enhanced type safety. @@ -29,6 +31,8 @@ defmodule Towerops.Result do # => {:error, :service_unavailable} """ + @gleam :towerops@result + @type t(ok_value, error_value) :: {:ok, ok_value} | {:error, error_value} @type t(ok_value) :: t(ok_value, term()) @@ -45,9 +49,7 @@ defmodule Towerops.Result do iex> Result.map({:error, :not_found}, fn x -> x * 2 end) {:error, :not_found} """ - @spec map(t(a, e), (a -> b)) :: t(b, e) when a: var, b: var, e: var - def map({:ok, value}, fun) when is_function(fun, 1), do: {:ok, fun.(value)} - def map({:error, _} = err, fun) when is_function(fun, 1), do: err + defdelegate map(result, fun), to: @gleam @doc """ Maps the error value using the given function. @@ -61,9 +63,7 @@ defmodule Towerops.Result do iex> Result.map_error({:ok, 42}, fn _ -> :network_error end) {:ok, 42} """ - @spec map_error(t(o, a), (a -> b)) :: t(o, b) when o: var, a: var, b: var - def map_error({:ok, _} = ok, fun) when is_function(fun, 1), do: ok - def map_error({:error, err}, fun) when is_function(fun, 1), do: {:error, fun.(err)} + defdelegate map_error(result, fun), to: @gleam @doc """ Chains together operations that return results. @@ -78,9 +78,7 @@ defmodule Towerops.Result do iex> Result.and_then({:error, :not_found}, fn x -> {:ok, x * 2} end) {:error, :not_found} """ - @spec and_then(t(a, e), (a -> t(b, e))) :: t(b, e) when a: var, b: var, e: var - def and_then({:ok, value}, fun) when is_function(fun, 1), do: fun.(value) - def and_then({:error, _} = err, fun) when is_function(fun, 1), do: err + defdelegate and_then(result, fun), to: @gleam @doc """ Unwraps a result, returning the success value or a default on error. @@ -93,9 +91,7 @@ defmodule Towerops.Result do iex> Result.unwrap_or({:error, :not_found}, 0) 0 """ - @spec unwrap_or(t(a, term()), a) :: a when a: var - def unwrap_or({:ok, value}, _default), do: value - def unwrap_or({:error, _}, default), do: default + defdelegate unwrap_or(result, default), to: @gleam @doc """ Checks if the result is a success. @@ -108,9 +104,7 @@ defmodule Towerops.Result do iex> Result.ok?({:error, :not_found}) false """ - @spec ok?(t(any(), any())) :: boolean() - def ok?({:ok, _}), do: true - def ok?({:error, _}), do: false + def ok?(result), do: @gleam.is_ok(result) @doc """ Checks if the result is an error. @@ -123,7 +117,5 @@ defmodule Towerops.Result do iex> Result.error?({:ok, 42}) false """ - @spec error?(t(any(), any())) :: boolean() - def error?({:error, _}), do: true - def error?({:ok, _}), do: false + def error?(result), do: @gleam.is_error(result) end diff --git a/lib/towerops/snmp/sensor_change_detector.ex b/lib/towerops/snmp/sensor_change_detector.ex index 30d68777..00d3aee8 100644 --- a/lib/towerops/snmp/sensor_change_detector.ex +++ b/lib/towerops/snmp/sensor_change_detector.ex @@ -4,12 +4,17 @@ defmodule Towerops.Snmp.SensorChangeDetector do Shared between DevicePollerWorker and AgentChannel to ensure consistent event detection across both Phoenix-polled and agent-polled devices. + + Pure decision logic (threshold checking, value formatting) is implemented + in Gleam. PubSub broadcasting, Repo queries, and event building stay here. """ alias Towerops.Snmp.Sensor require Logger + @gleam :towerops@snmp@sensor_change_detector + @doc """ Detects sensor changes and broadcasts events via PubSub. @@ -175,19 +180,28 @@ defmodule Towerops.Snmp.SensorChangeDetector do end defp value_was_over_threshold?(last_value, thresholds) do - (thresholds.warning_high && last_value >= thresholds.warning_high) || - (thresholds.critical_high && last_value >= thresholds.critical_high) || - (thresholds.warning_low && last_value <= thresholds.warning_low) || - (thresholds.critical_low && last_value <= thresholds.critical_low) + @gleam.value_was_over_threshold( + last_value / 1.0, + to_gleam_option(thresholds.warning_high), + to_gleam_option(thresholds.critical_high), + to_gleam_option(thresholds.warning_low), + to_gleam_option(thresholds.critical_low) + ) end defp value_is_normal?(current_value, thresholds) do - (thresholds.warning_high == nil || current_value < thresholds.warning_high) && - (thresholds.critical_high == nil || current_value < thresholds.critical_high) && - (thresholds.warning_low == nil || current_value > thresholds.warning_low) && - (thresholds.critical_low == nil || current_value > thresholds.critical_low) + @gleam.value_is_normal( + current_value / 1.0, + to_gleam_option(thresholds.warning_high), + to_gleam_option(thresholds.critical_high), + to_gleam_option(thresholds.warning_low), + to_gleam_option(thresholds.critical_low) + ) end + defp to_gleam_option(nil), do: :none + defp to_gleam_option(value) when is_number(value), do: {:some, value / 1.0} + # Change detection defp maybe_add_change_event(events, sensor, current_value, timestamp, device_id) do @@ -299,12 +313,8 @@ defmodule Towerops.Snmp.SensorChangeDetector do end end - defp format_sensor_value(value, "") when is_number(value) do - "#{trunc(value)}" - end - defp format_sensor_value(value, unit) when is_number(value) do - "#{Float.round(value / 1.0, 1)}#{unit}" + @gleam.format_sensor_value(value / 1.0, unit || "") end defp format_sensor_value(_, _), do: "N/A" diff --git a/src/towerops/capacity/resolver.gleam b/src/towerops/capacity/resolver.gleam new file mode 100644 index 00000000..cbd19442 --- /dev/null +++ b/src/towerops/capacity/resolver.gleam @@ -0,0 +1,38 @@ +/// Capacity unit normalization. +/// Converts sensor values to bits per second based on their unit string. +import gleam/string + +pub type SpeedUnit { + Bps + Kbps + Mbps + Gbps +} + +/// Normalize a sensor value to bits per second. +pub fn normalize_to_bps(value: Float, unit: String, divisor: Float) -> Int { + let adjusted = value /. divisor + + let bps = case normalize_unit(unit) { + Bps -> adjusted + Kbps -> adjusted *. 1000.0 + Mbps -> adjusted *. 1_000_000.0 + Gbps -> adjusted *. 1_000_000_000.0 + } + + float_to_int(bps) +} + +/// Parse a unit string into a SpeedUnit. +pub fn normalize_unit(unit: String) -> SpeedUnit { + case string.lowercase(unit) { + "bps" -> Bps + "kbps" -> Kbps + "mbps" -> Mbps + "gbps" -> Gbps + _ -> Bps + } +} + +@external(erlang, "erlang", "trunc") +fn float_to_int(value: Float) -> Int diff --git a/src/towerops/config_changes/correlator.gleam b/src/towerops/config_changes/correlator.gleam new file mode 100644 index 00000000..07f01a81 --- /dev/null +++ b/src/towerops/config_changes/correlator.gleam @@ -0,0 +1,54 @@ +/// Config change correlation math. +/// +/// Pure functions for percentage-change calculations, severity classification, +/// and conditional list building used by the Elixir Correlator module. +import gleam/list + +/// Calculate percentage change between two values. +/// Returns 0.0 if old value is zero or negative. +pub fn safe_pct_change(old: Float, new_val: Float) -> Float { + case old >. 0.0 { + True -> { new_val -. old } /. old + False -> 0.0 + } +} + +/// Determine severity based on metric deltas. +/// +/// - "critical" when latency > 50%, loss > 100%, or throughput < -30% +/// - "warning" when latency > 20%, loss > 50%, or throughput < -15% +/// - "info" otherwise +pub fn determine_severity( + latency_delta: Float, + loss_delta: Float, + throughput_delta: Float, +) -> String { + case + latency_delta >. 0.50 + || loss_delta >. 1.0 + || throughput_delta <. -0.30 + { + True -> "critical" + False -> + case + latency_delta >. 0.20 + || loss_delta >. 0.50 + || throughput_delta <. -0.15 + { + True -> "warning" + False -> "info" + } + } +} + +/// Append text to list if condition is true, otherwise return list unchanged. +pub fn maybe_add( + parts: List(String), + text: String, + condition: Bool, +) -> List(String) { + case condition { + True -> list.append(parts, [text]) + False -> parts + } +} diff --git a/src/towerops/ecto_types/ip_address.gleam b/src/towerops/ecto_types/ip_address.gleam new file mode 100644 index 00000000..801945fd --- /dev/null +++ b/src/towerops/ecto_types/ip_address.gleam @@ -0,0 +1,70 @@ +/// Pure IP address parsing and validation functions. +/// +/// Handles string parsing, zone ID stripping, CIDR rejection, and version +/// detection. Delegates actual inet parsing to Erlang FFI. +/// +/// Compiles to `:towerops@ecto_types@ip_address`. +import gleam/dynamic.{type Dynamic} +import gleam/string + +// --------------------------------------------------------------------------- +// FFI declarations +// --------------------------------------------------------------------------- + +@external(erlang, "towerops_ip_address_ffi", "parse_address") +fn inet_parse(value: String) -> Result(Dynamic, Nil) + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// IP address version: IPv4 (4-tuple) or IPv6 (8-tuple). +pub type IpVersion { + Ipv4 + Ipv6 +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Strips the IPv6 zone ID from an address string. +/// +/// Zone IDs appear after a "%" character (e.g., "fe80::1%eth0" -> "fe80::1"). +/// Returns the input unchanged if no zone ID is present. +pub fn strip_zone_id(value: String) -> String { + case string.split(value, "%") { + [ip, _, ..] -> ip + _ -> value + } +} + +/// Detects the IP version based on the tuple size. +/// +/// A tuple of size 4 is IPv4, anything else is IPv6. +pub fn detect_version(size: Int) -> IpVersion { + case size { + 4 -> Ipv4 + _ -> Ipv6 + } +} + +/// Parses an IP address string, stripping zone IDs and rejecting CIDR notation. +/// +/// Returns Ok(#(cleaned_string, ip_tuple)) on success or Error(Nil) on failure. +pub fn parse_string(value: String) -> Result(#(String, Dynamic), Nil) { + case value { + "" -> Error(Nil) + _ -> { + let cleaned = strip_zone_id(value) + case string.contains(cleaned, "/") { + True -> Error(Nil) + False -> + case inet_parse(cleaned) { + Ok(ip_tuple) -> Ok(#(cleaned, ip_tuple)) + Error(_) -> Error(Nil) + } + } + } + } +} diff --git a/src/towerops/ecto_types/mac_address.gleam b/src/towerops/ecto_types/mac_address.gleam new file mode 100644 index 00000000..af5d6198 --- /dev/null +++ b/src/towerops/ecto_types/mac_address.gleam @@ -0,0 +1,193 @@ +/// Pure MAC address parsing and formatting functions. +/// +/// Parses MAC address strings in multiple formats (colon, hyphen, dot, compact) +/// into lists of 6 byte values, and formats byte lists back into string +/// representations. +/// +/// Compiles to `:towerops@ecto_types@mac_address`. +import gleam/int +import gleam/list +import gleam/string + +// --------------------------------------------------------------------------- +// Public API: Parsing +// --------------------------------------------------------------------------- + +/// Parses a MAC address string in any supported format into a list of 6 byte +/// values (0-255). +/// +/// Supported formats: +/// - Colon-separated: "00:11:22:33:44:55" +/// - Hyphen-separated: "00-11-22-33-44-55" +/// - Dot-separated (Cisco): "0011.2233.4455" +/// - Compact: "001122334455" +/// +/// Case-insensitive. Returns Error(Nil) for invalid input. +pub fn parse_string(value: String) -> Result(List(Int), Nil) { + let trimmed = string.trim(value) + case trimmed { + "" -> Error(Nil) + _ -> try_parse_formats(trimmed) + } +} + +// --------------------------------------------------------------------------- +// Public API: Formatting +// --------------------------------------------------------------------------- + +/// Formats a list of 6 byte values as "aa:bb:cc:dd:ee:ff". +pub fn format_colon(bytes: List(Int)) -> String { + bytes + |> list.map(byte_to_hex) + |> string.join(":") +} + +/// Formats a list of 6 byte values as "aa-bb-cc-dd-ee-ff". +pub fn format_hyphen(bytes: List(Int)) -> String { + bytes + |> list.map(byte_to_hex) + |> string.join("-") +} + +/// Formats a list of 6 byte values as "aabb.ccdd.eeff" (Cisco format). +pub fn format_dot(bytes: List(Int)) -> String { + case bytes { + [o1, o2, o3, o4, o5, o6] -> + string.join( + [ + byte_to_hex(o1) <> byte_to_hex(o2), + byte_to_hex(o3) <> byte_to_hex(o4), + byte_to_hex(o5) <> byte_to_hex(o6), + ], + ".", + ) + _ -> "" + } +} + +/// Formats a list of 6 byte values as "aabbccddeeff" (compact, no separators). +pub fn format_compact(bytes: List(Int)) -> String { + bytes + |> list.map(byte_to_hex) + |> string.join("") +} + +// --------------------------------------------------------------------------- +// Internal: Parsing +// --------------------------------------------------------------------------- + +/// Tries each supported format in order. +fn try_parse_formats(value: String) -> Result(List(Int), Nil) { + // Try colon-separated (6 parts split by ":") + case try_split_format(value, ":") { + Ok(bytes) -> Ok(bytes) + Error(_) -> + // Try hyphen-separated (6 parts split by "-") + case try_split_format(value, "-") { + Ok(bytes) -> Ok(bytes) + Error(_) -> + // Try dot-separated (3 groups of 4 hex chars split by ".") + case try_dot_format(value) { + Ok(bytes) -> Ok(bytes) + Error(_) -> + // Try compact (12 hex chars, no separator) + try_compact_format(value) + } + } + } +} + +/// Tries to parse a MAC address by splitting on a separator. +/// Expects exactly 6 parts, each being 2 hex characters. +fn try_split_format(value: String, separator: String) -> Result(List(Int), Nil) { + let parts = string.split(value, separator) + case list.length(parts) { + 6 -> parse_hex_parts(parts, 2) + _ -> Error(Nil) + } +} + +/// Tries to parse a dot-separated (Cisco) MAC address. +/// Expects 3 groups of exactly 4 hex characters. +fn try_dot_format(value: String) -> Result(List(Int), Nil) { + let groups = string.split(value, ".") + case groups { + [g1, g2, g3] -> + case + string.length(g1) == 4 + && string.length(g2) == 4 + && string.length(g3) == 4 + { + True -> { + // Split each 4-char group into two 2-char octets + let parts = split_group(g1, split_group(g2, split_group(g3, []))) + parse_hex_parts(parts, 2) + } + False -> Error(Nil) + } + _ -> Error(Nil) + } +} + +/// Splits a 4-character hex group into two 2-character parts, +/// prepending to an accumulator. +fn split_group(group: String, acc: List(String)) -> List(String) { + let first = string.slice(group, 0, 2) + let second = string.slice(group, 2, 2) + [first, second, ..acc] +} + +/// Tries to parse a compact MAC address (12 hex characters, no separator). +fn try_compact_format(value: String) -> Result(List(Int), Nil) { + case string.length(value) { + 12 -> { + let parts = [ + string.slice(value, 0, 2), + string.slice(value, 2, 2), + string.slice(value, 4, 2), + string.slice(value, 6, 2), + string.slice(value, 8, 2), + string.slice(value, 10, 2), + ] + parse_hex_parts(parts, 2) + } + _ -> Error(Nil) + } +} + +/// Parses a list of hex string parts into a list of integer byte values. +/// Each part must be exactly `expected_len` characters and valid hex. +fn parse_hex_parts( + parts: List(String), + expected_len: Int, +) -> Result(List(Int), Nil) { + parts + |> list.try_map(fn(part) { + case string.length(part) == expected_len { + True -> + string.lowercase(part) + |> int.base_parse(16) + False -> Error(Nil) + } + }) +} + +// --------------------------------------------------------------------------- +// Internal: Formatting +// --------------------------------------------------------------------------- + +/// Converts a single byte (0-255) to a zero-padded 2-character lowercase +/// hex string. +fn byte_to_hex(byte: Int) -> String { + case int.to_base_string(byte, 16) { + Ok(hex) -> { + let lower = string.lowercase(hex) + case string.length(lower) { + 1 -> "0" <> lower + _ -> lower + } + } + // Base 16 is always valid, so this branch is unreachable + Error(_) -> "00" + } +} diff --git a/src/towerops/ecto_types/snmp_oid.gleam b/src/towerops/ecto_types/snmp_oid.gleam new file mode 100644 index 00000000..720accf2 --- /dev/null +++ b/src/towerops/ecto_types/snmp_oid.gleam @@ -0,0 +1,148 @@ +/// Pure SNMP OID parsing and manipulation functions. +/// +/// Handles numeric OID validation, parsing, normalization, and list-based +/// OID operations (parent, child_of, append). Named OID resolution stays +/// in Elixir (SnmpKit). +/// +/// Compiles to `:towerops@ecto_types@snmp_oid`. +import gleam/int +import gleam/list +import gleam/regexp +import gleam/string + +// --------------------------------------------------------------------------- +// Public API: Validation +// --------------------------------------------------------------------------- + +/// Checks if a string matches a numeric OID pattern. +/// +/// Accepts an optional leading dot, then one or more groups of digits +/// separated by dots (e.g., ".1.3.6.1" or "1.3.6.1"). +pub fn is_numeric_oid(value: String) -> Bool { + let assert Ok(re) = regexp.from_string("^\\.?([0-9]+)(\\.[0-9]+)*$") + regexp.check(re, value) +} + +/// Checks if a string matches a named OID pattern. +/// +/// Named OIDs start with a letter, may contain "::" for MIB prefix, +/// and allow dots with alphanumeric/digit suffixes. Returns False for +/// pure numeric OIDs. +pub fn is_named_oid(value: String) -> Bool { + let assert Ok(numeric_re) = regexp.from_string("^\\.?([0-9]+)(\\.[0-9]+)*$") + let assert Ok(named_re) = + regexp.from_string( + "^[a-zA-Z][a-zA-Z0-9_-]*(::[a-zA-Z][a-zA-Z0-9_-]*)?(\\.[a-zA-Z0-9_-]+|\\.[0-9]+)*$", + ) + regexp.check(named_re, value) && !regexp.check(numeric_re, value) +} + +// --------------------------------------------------------------------------- +// Public API: Parsing +// --------------------------------------------------------------------------- + +/// Validates a numeric OID string, normalizes the leading dot, and returns +/// the canonical string representation along with the integer parts. +/// +/// Strips a leading dot, splits on ".", parses each part as a non-negative +/// integer, then rebuilds with a leading dot. +/// +/// Returns `Ok(#(oid_string, parts))` or `Error(Nil)`. +pub fn parse_numeric_oid(value: String) -> Result(#(String, List(Int)), Nil) { + let trimmed = case string.starts_with(value, ".") { + True -> string.drop_start(value, 1) + False -> value + } + case trimmed { + "" -> Error(Nil) + _ -> { + let raw_parts = string.split(trimmed, ".") + case parse_all_ints(raw_parts, []) { + Ok(int_parts) -> + case list.all(int_parts, fn(n) { n >= 0 }) { + True -> Ok(#(parts_to_string(int_parts), int_parts)) + False -> Error(Nil) + } + Error(_) -> Error(Nil) + } + } + } +} + +// --------------------------------------------------------------------------- +// Public API: String helpers +// --------------------------------------------------------------------------- + +/// Prepends "." to the string if not already present. +pub fn ensure_leading_dot(value: String) -> String { + case string.starts_with(value, ".") { + True -> value + False -> "." <> value + } +} + +/// Builds a dotted-decimal string with a leading dot from a list of integers. +/// +/// Example: `[1, 3, 6]` -> `".1.3.6"` +pub fn parts_to_string(parts: List(Int)) -> String { + "." + <> parts + |> list.map(int.to_string) + |> string.join(".") +} + +// --------------------------------------------------------------------------- +// Public API: List operations +// --------------------------------------------------------------------------- + +/// Returns all parts except the last (the parent OID's parts). +pub fn parent_parts(parts: List(Int)) -> List(Int) { + let len = list.length(parts) + case len > 1 { + True -> list.take(parts, len - 1) + False -> parts + } +} + +/// Returns True if child starts with all of parent's parts AND is strictly +/// longer (i.e., a proper child, not equal). +pub fn child_of(child: List(Int), parent: List(Int)) -> Bool { + list.length(child) > list.length(parent) && starts_with(child, parent) +} + +/// Concatenates two lists of OID parts. +pub fn append_parts(parts: List(Int), additional: List(Int)) -> List(Int) { + list.append(parts, additional) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Checks if `haystack` starts with all elements of `prefix` in order. +fn starts_with(haystack: List(a), prefix: List(a)) -> Bool { + case prefix { + [] -> True + [p, ..ptail] -> + case haystack { + [h, ..htail] -> p == h && starts_with(htail, ptail) + [] -> False + } + } +} + +/// Parses a list of string parts into integers, accumulating in reverse +/// then reversing at the end. +fn parse_all_ints( + parts: List(String), + acc: List(Int), +) -> Result(List(Int), Nil) { + case parts { + [] -> Ok(list.reverse(acc)) + [head, ..tail] -> + case int.parse(head) { + Ok(n) -> parse_all_ints(tail, [n, ..acc]) + Error(_) -> Error(Nil) + } + } +} diff --git a/src/towerops/organizations/policy.gleam b/src/towerops/organizations/policy.gleam new file mode 100644 index 00000000..e85a5390 --- /dev/null +++ b/src/towerops/organizations/policy.gleam @@ -0,0 +1,124 @@ +/// Authorization policy for organization resources. +/// +/// Pure permission-check logic using pattern matching on role, action, and +/// resource. Called from the Elixir wrapper module which handles Membership +/// struct unwrapping and unknown-atom mapping. + +/// Organization membership roles, ordered from most to least privileged. +pub type Role { + Owner + Admin + Executive + Technician + Member + Viewer +} + +/// Actions that can be performed on resources. +pub type Action { + View + List + Create + Edit + Delete + Acknowledge + Manage + OtherAction +} + +/// Organization resources subject to authorization. +pub type Resource { + Organization + Site + Device + Alert + Membership + Invitation + Integration + Financials + OtherResource +} + +/// Checks if a role can perform an action on a resource. +pub fn check_permission(role: Role, action: Action, resource: Resource) -> Bool { + case role { + Owner -> True + Admin -> check_admin(action, resource) + Executive -> check_executive(action, resource) + Technician -> check_technician(action, resource) + Member -> check_technician(action, resource) + Viewer -> check_viewer(action, resource) + } +} + +/// Returns whether the given role can view financial data. +pub fn financials_visible(role: Role) -> Bool { + case role { + Owner -> True + Admin -> True + Executive -> True + _ -> False + } +} + +fn check_admin(action: Action, resource: Resource) -> Bool { + case action, resource { + Delete, Organization -> False + _, _ -> True + } +} + +fn check_executive(action: Action, resource: Resource) -> Bool { + case action, resource { + View, _ -> True + List, _ -> True + Acknowledge, Alert -> True + _, _ -> False + } +} + +fn check_technician(action: Action, resource: Resource) -> Bool { + case action, resource { + // Deny financials access + View, Financials -> False + // Allow viewing/listing the organization + View, Organization -> True + List, Organization -> True + // Deny all deletes + Delete, _ -> False + // Deny membership management + Create, Membership -> False + Edit, Membership -> False + // Deny invitation management + Create, Invitation -> False + // Deny integration management + Create, Integration -> False + Edit, Integration -> False + // Allow view/list/create/edit on sites, devices, and alerts + View, Site -> True + View, Device -> True + View, Alert -> True + List, Site -> True + List, Device -> True + List, Alert -> True + Create, Site -> True + Create, Device -> True + Create, Alert -> True + Edit, Site -> True + Edit, Device -> True + Edit, Alert -> True + // Allow acknowledging alerts + Acknowledge, Alert -> True + // Deny everything else + _, _ -> False + } +} + +fn check_viewer(action: Action, resource: Resource) -> Bool { + case action, resource { + View, _ -> True + List, _ -> True + Acknowledge, Alert -> True + _, _ -> False + } +} diff --git a/src/towerops/preseem/baseline.gleam b/src/towerops/preseem/baseline.gleam new file mode 100644 index 00000000..6f887efc --- /dev/null +++ b/src/towerops/preseem/baseline.gleam @@ -0,0 +1,55 @@ +import gleam/float +import gleam/int +import gleam/list + +/// Statistical summary of a list of values. +pub type Stats { + Stats(mean: Float, stddev: Float, p5: Float, p95: Float) +} + +/// Compute descriptive statistics (mean, stddev, p5, p95) for a list of floats. +/// The list must be non-empty. Returns zeroed Stats for an empty list. +pub fn compute_stats(values: List(Float)) -> Stats { + case values { + [] -> Stats(mean: 0.0, stddev: 0.0, p5: 0.0, p95: 0.0) + _ -> { + let sorted = list.sort(values, float.compare) + let n = list.length(sorted) + let sum = float_sum(sorted) + let mean = sum /. int.to_float(n) + + let variance = + sorted + |> list.map(fn(v) { { v -. mean } *. { v -. mean } }) + |> float_sum() + |> fn(s) { s /. int.to_float(int.max(n - 1, 1)) } + + let stddev = float_sqrt(variance) + let p5_idx = int.max(float_round(int.to_float(n) *. 0.05) - 1, 0) + let p95_idx = int.min(float_round(int.to_float(n) *. 0.95) - 1, n - 1) + + Stats( + mean: float_round_to(mean, 4), + stddev: float_round_to(stddev, 4), + p5: float_round_to(list_at(sorted, p5_idx), 4), + p95: float_round_to(list_at(sorted, p95_idx), 4), + ) + } + } +} + +fn float_sum(values: List(Float)) -> Float { + list.fold(values, 0.0, fn(acc, v) { acc +. v }) +} + +@external(erlang, "towerops_baseline_ffi", "float_sqrt") +fn float_sqrt(value: Float) -> Float + +@external(erlang, "towerops_baseline_ffi", "float_round_to") +fn float_round_to(value: Float, decimals: Int) -> Float + +@external(erlang, "towerops_baseline_ffi", "float_round") +fn float_round(value: Float) -> Int + +@external(erlang, "towerops_baseline_ffi", "list_at") +fn list_at(list: List(a), index: Int) -> a diff --git a/src/towerops/result.gleam b/src/towerops/result.gleam new file mode 100644 index 00000000..1ad34e3d --- /dev/null +++ b/src/towerops/result.gleam @@ -0,0 +1,54 @@ +/// Maps the success value using the given function. +/// Error values are passed through unchanged. +pub fn map(result: Result(a, e), fun: fn(a) -> b) -> Result(b, e) { + case result { + Ok(value) -> Ok(fun(value)) + Error(err) -> Error(err) + } +} + +/// Maps the error value using the given function. +/// Success values are passed through unchanged. +pub fn map_error(result: Result(a, e), fun: fn(e) -> f) -> Result(a, f) { + case result { + Ok(value) -> Ok(value) + Error(err) -> Error(fun(err)) + } +} + +/// Chains together operations that return results. +/// If the first result is an error, returns it immediately. +/// Otherwise, applies the function to the success value. +pub fn and_then( + result: Result(a, e), + fun: fn(a) -> Result(b, e), +) -> Result(b, e) { + case result { + Ok(value) -> fun(value) + Error(err) -> Error(err) + } +} + +/// Unwraps a result, returning the success value or a default on error. +pub fn unwrap_or(result: Result(a, e), default: a) -> a { + case result { + Ok(value) -> value + Error(_) -> default + } +} + +/// Checks if the result is a success. +pub fn is_ok(result: Result(a, e)) -> Bool { + case result { + Ok(_) -> True + Error(_) -> False + } +} + +/// Checks if the result is an error. +pub fn is_error(result: Result(a, e)) -> Bool { + case result { + Ok(_) -> False + Error(_) -> True + } +} diff --git a/src/towerops/snmp/sensor_change_detector.gleam b/src/towerops/snmp/sensor_change_detector.gleam new file mode 100644 index 00000000..7884a114 --- /dev/null +++ b/src/towerops/snmp/sensor_change_detector.gleam @@ -0,0 +1,83 @@ +import gleam/int +import gleam/option.{type Option, None, Some} + +/// Format a sensor value with its unit for display in event messages. +/// +/// When the unit is empty (count sensors), the value is truncated to an integer +/// string (e.g. 1000.0 -> "1000"). Otherwise, the value is rounded to 1 decimal +/// place and the unit is appended (e.g. 50.0, "Hz" -> "50.0Hz"). +pub fn format_sensor_value(value: Float, unit: String) -> String { + case unit { + "" -> int.to_string(trunc_float(value)) + _ -> format_float_1dp(value) <> unit + } +} + +/// Check whether the last value exceeded any configured threshold. +/// +/// Returns True if the value was at or above any high threshold, +/// or at or below any low threshold. +pub fn value_was_over_threshold( + last_value: Float, + warning_high: Option(Float), + critical_high: Option(Float), + warning_low: Option(Float), + critical_low: Option(Float), +) -> Bool { + check_at_or_above(last_value, warning_high) + || check_at_or_above(last_value, critical_high) + || check_at_or_below(last_value, warning_low) + || check_at_or_below(last_value, critical_low) +} + +/// Check whether the current value is within all configured thresholds. +/// +/// Returns True only if the value is strictly below all high thresholds +/// and strictly above all low thresholds. Unconfigured thresholds (None) +/// are treated as not restricting. +pub fn value_is_normal( + current_value: Float, + warning_high: Option(Float), + critical_high: Option(Float), + warning_low: Option(Float), + critical_low: Option(Float), +) -> Bool { + is_below(current_value, warning_high) + && is_below(current_value, critical_high) + && is_above(current_value, warning_low) + && is_above(current_value, critical_low) +} + +fn check_at_or_above(value: Float, threshold: Option(Float)) -> Bool { + case threshold { + Some(t) -> value >=. t + None -> False + } +} + +fn check_at_or_below(value: Float, threshold: Option(Float)) -> Bool { + case threshold { + Some(t) -> value <=. t + None -> False + } +} + +fn is_below(value: Float, threshold: Option(Float)) -> Bool { + case threshold { + Some(t) -> value <. t + None -> True + } +} + +fn is_above(value: Float, threshold: Option(Float)) -> Bool { + case threshold { + Some(t) -> value >. t + None -> True + } +} + +@external(erlang, "towerops_sensor_ffi", "trunc_float") +fn trunc_float(value: Float) -> Int + +@external(erlang, "towerops_sensor_ffi", "format_float_1dp") +fn format_float_1dp(value: Float) -> String diff --git a/src/towerops_baseline_ffi.erl b/src/towerops_baseline_ffi.erl new file mode 100644 index 00000000..1c1aecc7 --- /dev/null +++ b/src/towerops_baseline_ffi.erl @@ -0,0 +1,14 @@ +-module(towerops_baseline_ffi). +-export([float_sqrt/1, float_round_to/2, float_round/1, list_at/2]). + +float_sqrt(Value) -> + math:sqrt(Value). + +float_round_to(Value, Decimals) -> + 'Elixir.Float':'round'(Value, Decimals). + +float_round(Value) -> + round(Value). + +list_at(List, Index) -> + lists:nth(Index + 1, List). diff --git a/src/towerops_ip_address_ffi.erl b/src/towerops_ip_address_ffi.erl new file mode 100644 index 00000000..b32430b2 --- /dev/null +++ b/src/towerops_ip_address_ffi.erl @@ -0,0 +1,11 @@ +-module(towerops_ip_address_ffi). +-export([parse_address/1]). + +%% Parse a cleaned IP address string using inet:parse_address/1. +%% Returns {ok, IpTuple} or {error, nil}. +parse_address(Bin) when is_binary(Bin) -> + Charlist = binary_to_list(Bin), + case inet:parse_address(Charlist) of + {ok, IpTuple} -> {ok, IpTuple}; + {error, _} -> {error, nil} + end. diff --git a/src/towerops_sensor_ffi.erl b/src/towerops_sensor_ffi.erl new file mode 100644 index 00000000..193d3d2c --- /dev/null +++ b/src/towerops_sensor_ffi.erl @@ -0,0 +1,9 @@ +-module(towerops_sensor_ffi). +-export([trunc_float/1, format_float_1dp/1]). + +trunc_float(Value) -> + erlang:trunc(Value). + +format_float_1dp(Value) -> + Rounded = 'Elixir.Float':round(Value, 1), + 'Elixir.String.Chars':to_string(Rounded).