This commit addresses multiple CRITICAL, HIGH, and MEDIUM severity security vulnerabilities identified in the security audit: CRITICAL FIXES: - Fix weak RNG for recovery codes - replaced Enum.random() with :crypto.strong_rand_bytes/1 for cryptographically secure token generation - Fix subscription limit race conditions - moved free org and device quota checks inside transactions with FOR UPDATE locks to prevent concurrent bypass - Fix default organization race condition - moved is_default check inside transaction to prevent multiple defaults per user HIGH SEVERITY FIXES: - Fix agent token deletion race condition - moved PubSub broadcast inside transaction to ensure agents only receive notification after successful deletion MEDIUM SEVERITY FIXES: - Fix LIKE wildcard injection in search - applied sanitize_like() to all user-facing search queries in devices.ex, sites.ex, and gaiia.ex to prevent enumeration attacks - Fix Jason.decode! DoS - replaced with safe Jason.decode/1 with error handling in device_live/index.ex - Fix SSRF vulnerability - added URL validation in HTTP executor to block requests to private/internal IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16) - Fix error information leakage - replaced inspect() in API responses with generic error messages, logging details server-side only - Fix atom table pollution - HTTP method normalization now uses whitelist mapping instead of String.to_atom() SECURITY IMPROVEMENTS: - All quota checks now use pessimistic locking (SELECT FOR UPDATE) to prevent TOCTOU race conditions - Private IP validation prevents cloud metadata service access (169.254.169.254) - DNS resolution performed before HTTP requests to detect IP spoofing - Error details logged server-side but not exposed to clients Files changed: - lib/towerops/accounts/user_recovery_code.ex - lib/towerops/organizations.ex - lib/towerops/devices.ex - lib/towerops/sites.ex - lib/towerops/gaiia.ex - lib/towerops/agents.ex - lib/towerops/monitoring/executors/http_executor.ex - lib/towerops_web/live/device_live/index.ex - lib/towerops_web/controllers/api/v1/mib_controller.ex - lib/towerops_web/controllers/api/v1/agent_release_webhook_controller.ex - lib/towerops_web/controllers/api/v1/geoip_controller.ex Reviewed-on: graham/towerops-web#108
40 KiB
Gleam Batch 4: Convert Pure-Function Modules to Gleam
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Convert 9 pure-function Elixir modules to Gleam, keeping thin Elixir wrappers for Ecto/OTP integration.
Architecture: Each module follows the established pattern: pure logic moves to .gleam in src/, FFI .erl files bridge Erlang stdlib, Elixir callers use :towerops@module@name.function() syntax. Ecto Type callbacks (cast/load/dump) and Repo/PubSub calls stay in Elixir.
Tech Stack: Gleam (BEAM target), Erlang FFI, Elixir wrappers, ExUnit tests unchanged.
Conversion Pattern Reference
Gleam modules compile to BEAM modules named with @ separators:
src/towerops/foo.gleam→:towerops@foo- Called from Elixir:
:towerops@foo.my_function(arg1, arg2)
FFI files go in src/ as .erl modules referenced via @external(erlang, "module_name", "function_name").
Existing tests stay in ExUnit — they call the Elixir wrapper which delegates to Gleam.
Important Gleam conventions:
- No nil — use
Option(a)withSome(value)/None - No exceptions — use
Result(a, e)withOk(value)/Error(reason) - Snake_case for functions, PascalCase for types
- Pattern matching via
caseexpressions - Strings are UTF-8 binaries (same as Elixir)
- Lists are linked lists (same as Elixir)
- Use
gleam/string,gleam/int,gleam/float,gleam/listfrom stdlib - Use
gleam/regexpfor regex (already a dependency) - No mutable state, no processes
- Gleam
FloatandIntare separate types — explicit conversion required
Task 1: Create branch and set up
Files:
- Branch:
gleam4
Step 1: Create branch
cd /Users/graham/dev/towerops/towerops-web
git checkout -b gleam4
Step 2: Verify Gleam builds
cd /Users/graham/dev/towerops/towerops-web
gleam build
Step 3: Verify tests pass
mix test
Expected: All tests pass, clean starting point.
Task 2: Convert Towerops.Organizations.Policy
Pure authorization logic: (role, action, resource) → bool.
Files:
- Create:
src/towerops/organizations/policy.gleam - Modify:
lib/towerops/organizations/policy.ex
Step 1: Create the Gleam module
Create src/towerops/organizations/policy.gleam with these types and functions:
/// Role-based authorization policy for organization resources.
/// Compiles to `:towerops@organizations@policy`.
/// Organization member roles
pub type Role {
Owner
Admin
Executive
Technician
Member
Viewer
}
/// Actions that can be performed
pub type Action {
View
List
Create
Edit
Delete
Acknowledge
Manage
OtherAction
}
/// Resources within an organization
pub type Resource {
Organization
Site
Device
Alert
Membership
Invitation
Integration
Financials
OtherResource
}
/// Check 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 true if the role can view financial data.
pub fn financials_visible(role: Role) -> Bool {
case role {
Owner | Admin | 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 {
View, Financials -> False
View, Organization -> True
List, Organization -> True
Delete, _ -> False
Create, Membership -> False
Edit, Membership -> False
Create, Invitation -> False
Delete, Invitation -> False
Create, Integration -> False
Edit, Integration -> False
Delete, Integration -> False
View, Site -> True
List, Site -> True
Create, Site -> True
Edit, Site -> True
View, Device -> True
List, Device -> True
Create, Device -> True
Edit, Device -> True
View, Alert -> True
List, Alert -> True
Create, Alert -> True
Edit, Alert -> True
Acknowledge, Alert -> True
_, _ -> False
}
}
fn check_viewer(action: Action, _resource: Resource) -> Bool {
case action {
View -> True
List -> True
Acknowledge -> True
_ -> False
}
}
Step 2: Create FFI for atom-to-type conversion
Create src/towerops_policy_ffi.erl:
-module(towerops_policy_ffi).
-export([atom_to_role/1, atom_to_action/1, atom_to_resource/1]).
atom_to_role(owner) -> {ok, owner};
atom_to_role(admin) -> {ok, admin};
atom_to_role(executive) -> {ok, executive};
atom_to_role(technician) -> {ok, technician};
atom_to_role(member) -> {ok, member};
atom_to_role(viewer) -> {ok, viewer};
atom_to_role(_) -> {error, nil}.
atom_to_action(view) -> {ok, view};
atom_to_action(list) -> {ok, list};
atom_to_action(create) -> {ok, create};
atom_to_action(edit) -> {ok, edit};
atom_to_action(delete) -> {ok, delete};
atom_to_action(acknowledge) -> {ok, acknowledge};
atom_to_action(manage) -> {ok, manage};
atom_to_action(_) -> {ok, other_action}.
atom_to_resource(organization) -> {ok, organization};
atom_to_resource(site) -> {ok, site};
atom_to_resource(device) -> {ok, device};
atom_to_resource(alert) -> {ok, alert};
atom_to_resource(membership) -> {ok, membership};
atom_to_resource(invitation) -> {ok, invitation};
atom_to_resource(integration) -> {ok, integration};
atom_to_resource(financials) -> {ok, financials};
atom_to_resource(_) -> {ok, other_resource}.
Wait — Gleam custom types compile to Erlang atoms/tuples. The Gleam type Owner becomes atom owner, Admin becomes admin, etc. So the Elixir wrapper can just pass atoms directly as the Gleam constructors are lowercase atoms on the BEAM. We don't need FFI at all — Elixir atoms map directly to Gleam constructors.
Actually, let me reconsider. Gleam custom type variants compile to atoms only if they have no fields. Owner (no fields) compiles to owner atom. So :towerops@organizations@policy.check_permission(:owner, :view, :site) should work directly.
But OtherAction and OtherResource compile to other_action and other_resource atoms. The Elixir atom :manage won't match manage Gleam variant because Gleam doesn't know about it... wait, we defined Manage as a variant, so it compiles to atom manage. Good.
The only issue is unknown atoms. If someone passes :foo_bar as an action, Gleam will crash with a function clause error. The Elixir wrapper should handle this with a catch-all.
Step 2 (revised): Update the Elixir wrapper
Modify lib/towerops/organizations/policy.ex to delegate to Gleam:
defmodule Towerops.Organizations.Policy do
@moduledoc """
Authorization policy for organization resources.
Pure logic delegated to Gleam module `:towerops@organizations@policy`.
"""
alias Towerops.Organizations.Membership
@gleam_module :towerops@organizations@policy
def can?(%Membership{role: role}, action, resource) do
@gleam_module.check_permission(role, action, resource)
end
def can?(nil, _action, _resource), do: false
def can_view_financials?(%Membership{role: role}), do: @gleam_module.financials_visible(role)
def can_view_financials?(nil), do: false
end
Hmm, but this won't work for unknown action/resource atoms. The Gleam function expects specific variant atoms. If :manage is passed as action, it matches manage in Gleam (we defined Manage). If :anything is passed, it'll crash. Looking at the tests: Policy.can?(membership, :manage, :members) — :members is used as a resource. We don't have a Members variant.
Looking at the original code more carefully: the original Policy module uses catch-all patterns extensively. The owner returns true for ANY action/resource. The admin returns true for almost anything. The viewer returns true for :view and :list on ANY resource.
So we need either:
- Map all unknown atoms to
OtherAction/OtherResourcein the Elixir wrapper - Or use the FFI approach
Let's go with option 1 — a simple mapping in the Elixir wrapper:
defp map_action(action) when action in [:view, :list, :create, :edit, :delete, :acknowledge, :manage], do: action
defp map_action(_), do: :other_action
defp map_resource(resource) when resource in [:organization, :site, :device, :alert, :membership, :invitation, :integration, :financials], do: resource
defp map_resource(_), do: :other_resource
Step 3: Run tests
mix test test/towerops/organizations/policy_test.exs
Expected: All tests pass.
Step 4: Run full test suite
mix test
Expected: All tests pass.
Step 5: Verify Gleam builds clean
gleam build
Step 6: Commit
git add src/towerops/organizations/policy.gleam lib/towerops/organizations/policy.ex
git commit -m "Convert Organizations.Policy authorization logic to Gleam"
Task 3: Convert Towerops.Result
Elixir Result helpers map naturally to Gleam's native Result type.
Files:
- Create:
src/towerops/result.gleam - Create:
src/towerops_result_ffi.erl - Modify:
lib/towerops/result.ex
Step 1: Create the Gleam module
Since Gleam's Result type uses Ok/Error which are the same as Elixir's {:ok, v}/{:error, e} on the BEAM, we can implement the helpers directly:
/// Result type utilities.
/// Compiles to `:towerops@result`.
import gleam/dynamic.{type Dynamic}
/// Map the success value, passing errors through.
pub fn map(result: Result(a, e), fun: fn(a) -> b) -> Result(b, e) {
case result {
Ok(value) -> Ok(fun(value))
Error(e) -> Error(e)
}
}
/// Map the error value, passing successes through.
pub fn map_error(result: Result(a, e1), fun: fn(e1) -> e2) -> Result(a, e2) {
case result {
Ok(value) -> Ok(value)
Error(e) -> Error(fun(e))
}
}
/// Chain operations that return results.
pub fn and_then(result: Result(a, e), fun: fn(a) -> Result(b, e)) -> Result(b, e) {
case result {
Ok(value) -> fun(value)
Error(e) -> Error(e)
}
}
/// Unwrap a result, returning the success value or a default.
pub fn unwrap_or(result: Result(a, e), default: a) -> a {
case result {
Ok(value) -> value
Error(_) -> default
}
}
/// Check if the result is Ok.
pub fn is_ok(result: Result(a, e)) -> Bool {
case result {
Ok(_) -> True
Error(_) -> False
}
}
/// Check if the result is Error.
pub fn is_error(result: Result(a, e)) -> Bool {
case result {
Ok(_) -> False
Error(_) -> True
}
}
Wait — there's a problem. Elixir calls these functions with anonymous functions as arguments:
Result.map({:ok, 5}, fn x -> x * 2 end)
When calling from Elixir to Gleam, anonymous functions pass through fine since they're both BEAM funs. So :towerops@result.map({:ok, 5}, fn x -> x * 2 end) should work.
But the Gleam type system requires typed function arguments. Since Elixir will pass Dynamic values, we need to use Dynamic types. Actually, on the BEAM level, types are erased — Gleam's type checking is compile-time only. At runtime, any value can be passed. So the Gleam module will work with whatever Elixir passes.
Step 2: Update the Elixir wrapper
defmodule Towerops.Result do
@moduledoc """
Generic result type. Pure logic delegated to Gleam `:towerops@result`.
"""
@type t(ok_value, error_value) :: {:ok, ok_value} | {:error, error_value}
@type t(ok_value) :: t(ok_value, term())
defdelegate map(result, fun), to: :towerops@result
defdelegate map_error(result, fun), to: :towerops@result
defdelegate and_then(result, fun), to: :towerops@result
defdelegate unwrap_or(result, default), to: :towerops@result
def ok?(result), do: :towerops@result.is_ok(result)
def error?(result), do: :towerops@result.is_error(result)
end
Step 3: Run tests
mix test test/towerops/result_test.exs
Expected: All tests pass.
Step 4: Commit
git add src/towerops/result.gleam lib/towerops/result.ex
git commit -m "Convert Result module to Gleam"
Task 4: Convert Towerops.EctoTypes.MacAddress (parsing core)
Extract the pure parsing/formatting logic to Gleam. Ecto Type callbacks stay in Elixir.
Files:
- Create:
src/towerops/ecto_types/mac_address.gleam - Create:
src/towerops_mac_address_ffi.erl - Modify:
lib/towerops/ecto_types/mac_address.ex
Step 1: Create the Gleam module
The Gleam module handles: parsing strings in 4 formats, formatting binary to 4 output formats, hex octet conversion.
/// MAC address parsing and formatting.
/// Compiles to `:towerops@ecto_types@mac_address`.
import gleam/int
import gleam/list
import gleam/regexp
import gleam/result
import gleam/string
/// Parse a MAC address string into a list of 6 byte values.
/// Accepts colon, hyphen, dot (Cisco), and compact formats.
/// Returns Ok(bytes) or Error(Nil).
pub fn parse_string(value: String) -> Result(List(Int), Nil) {
let trimmed = string.trim(value)
case trimmed {
"" -> Error(Nil)
_ -> try_parse_formats(trimmed)
}
}
/// Format 6 bytes as colon-separated lowercase hex.
pub fn format_colon(bytes: List(Int)) -> String {
bytes |> list.map(format_octet) |> string.join(":")
}
/// Format 6 bytes as hyphen-separated lowercase hex.
pub fn format_hyphen(bytes: List(Int)) -> String {
bytes |> list.map(format_octet) |> string.join("-")
}
/// Format 6 bytes as dot-separated groups of 4 hex chars (Cisco).
pub fn format_dot(bytes: List(Int)) -> String {
case bytes {
[a, b, c, d, e, f] ->
format_octet(a) <> format_octet(b)
<> "."
<> format_octet(c) <> format_octet(d)
<> "."
<> format_octet(e) <> format_octet(f)
_ -> ""
}
}
/// Format 6 bytes as compact (no separators).
pub fn format_compact(bytes: List(Int)) -> String {
bytes |> list.map(format_octet) |> string.join("")
}
fn format_octet(byte: Int) -> String {
byte
|> int.to_base16()
|> string.lowercase()
|> string.pad_start(to: 2, with: "0")
}
fn try_parse_formats(value: String) -> Result(List(Int), Nil) {
let assert Ok(colon_re) = regexp.from_string("^([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})$")
let assert Ok(hyphen_re) = regexp.from_string("^([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})$")
let assert Ok(dot_re) = regexp.from_string("^([0-9a-fA-F]{4})\\.([0-9a-fA-F]{4})\\.([0-9a-fA-F]{4})$")
let assert Ok(compact_re) = regexp.from_string("^([0-9a-fA-F]{12})$")
case regexp.scan(colon_re, value) {
[match] -> parse_separated_match(match)
_ ->
case regexp.scan(hyphen_re, value) {
[match] -> parse_separated_match(match)
_ ->
case regexp.scan(dot_re, value) {
[match] -> parse_dot_match(match)
_ ->
case regexp.scan(compact_re, value) {
[match] -> parse_compact_match(match)
_ -> Error(Nil)
}
}
}
}
}
fn parse_separated_match(match: regexp.Match) -> Result(List(Int), Nil) {
match.submatches
|> list.filter_map(fn(submatch) {
case submatch {
gleam.option.Some(s) -> parse_hex(s)
gleam.option.None -> Error(Nil)
}
})
|> fn(bytes) {
case list.length(bytes) == 6 {
True -> Ok(bytes)
False -> Error(Nil)
}
}
}
Hmm, this is getting complex with the regexp submatches. Let me simplify — since we know the format matched, we can just split by separator and parse each part:
fn try_parse_formats(value: String) -> Result(List(Int), Nil) {
// Try colon format: "00:11:22:33:44:55"
case try_separated(value, ":") {
Ok(bytes) -> Ok(bytes)
Error(_) ->
// Try hyphen format: "00-11-22-33-44-55"
case try_separated(value, "-") {
Ok(bytes) -> Ok(bytes)
Error(_) ->
// Try dot format: "0011.2233.4455"
case try_dot(value) {
Ok(bytes) -> Ok(bytes)
Error(_) ->
// Try compact format: "001122334455"
try_compact(value)
}
}
}
}
fn try_separated(value: String, sep: String) -> Result(List(Int), Nil) {
let parts = string.split(value, sep)
case list.length(parts) {
6 -> parse_hex_list(parts)
_ -> Error(Nil)
}
}
fn try_dot(value: String) -> Result(List(Int), Nil) {
let groups = string.split(value, ".")
case groups {
[g1, g2, g3] if string.length(g1) == 4 && string.length(g2) == 4 && string.length(g3) == 4 ->
// Split each 4-char group into two 2-char octets
let octets = split_groups([g1, g2, g3])
parse_hex_list(octets)
_ -> Error(Nil)
}
}
Actually, I'm over-specifying the Gleam code in this plan. The implementor should follow the pattern from existing conversions. Let me keep the plan higher-level for the implementation details and focus on the structure.
The key decisions for this task:
-
Gleam module at
src/towerops/ecto_types/mac_address.gleamexports:parse_string(value: String) -> Result(List(Int), Nil)— parse any format to 6 bytesformat_colon(bytes: List(Int)) -> Stringformat_hyphen(bytes: List(Int)) -> Stringformat_dot(bytes: List(Int)) -> Stringformat_compact(bytes: List(Int)) -> String
-
FFI at
src/towerops_mac_address_ffi.erlexports:parse_hex(String) -> {ok, Int} | {error, nil}— wrapsInteger.parse(s, 16)list_to_binary(List) -> Binary— wrapserlang:list_to_binary/1binary_to_list(Binary) -> List— wrapserlang:binary_to_list/1is_printable(Binary) -> Bool— wrapsString.printable?/1byte_size(Binary) -> Int
-
Elixir wrapper at
lib/towerops/ecto_types/mac_address.exkeeps:- All
use Ecto.Typecallbacks (type/cast/load/dump) - The struct definition and
@derive Jason.Encoder new/1,new!/1,to_string/1,to_binary/1,format/2- Delegates parsing to Gleam:
case :towerops@ecto_types@mac_address.parse_string(value) - Delegates formatting to Gleam for
format/2
- All
Step 2: Run tests
mix test test/towerops/ecto_types/mac_address_test.exs
Expected: All 27 tests pass.
Step 3: Commit
git add src/towerops/ecto_types/mac_address.gleam src/towerops_mac_address_ffi.erl lib/towerops/ecto_types/mac_address.ex
git commit -m "Extract MacAddress parsing/formatting to Gleam"
Task 5: Convert Towerops.Preseem.Baseline (compute_stats)
Extract only the pure compute_stats/1 function.
Files:
- Create:
src/towerops/preseem/baseline.gleam - Create:
src/towerops_baseline_ffi.erl - Modify:
lib/towerops/preseem/baseline.ex
Step 1: Create the Gleam module
/// Statistical baseline computation.
/// Compiles to `:towerops@preseem@baseline`.
import gleam/float
import gleam/int
import gleam/list
/// Result of statistical computation.
pub type Stats {
Stats(mean: Float, stddev: Float, p5: Float, p95: Float)
}
/// Compute mean, stddev, p5, p95 from a list of float values.
/// List must have at least 1 element.
pub fn compute_stats(values: List(Float)) -> Stats {
let sorted = list.sort(values, float.compare)
let n = list.length(sorted)
let n_float = int_to_float(n)
let sum = float_sum(sorted)
let mean = sum /. n_float
let variance = compute_variance(sorted, mean, n)
let stddev = float_sqrt(variance)
let p5_idx = max_int(float_round(n_float *. 0.05) - 1, 0)
let p95_idx = min_int(float_round(n_float *. 0.95) - 1, n - 1)
let p5 = list_at(sorted, p5_idx)
let p95 = list_at(sorted, p95_idx)
Stats(
mean: float_round_to(mean, 4),
stddev: float_round_to(stddev, 4),
p5: float_round_to(p5, 4),
p95: float_round_to(p95, 4),
)
}
fn compute_variance(sorted: List(Float), mean: Float, n: Int) -> Float {
let divisor = int_to_float(max_int(n - 1, 1))
sorted
|> list.map(fn(v) { { v -. mean } *. { v -. mean } })
|> float_sum()
|> fn(s) { s /. divisor }
}
fn float_sum(values: List(Float)) -> Float {
list.fold(values, 0.0, fn(acc, v) { acc +. v })
}
fn max_int(a: Int, b: Int) -> Int {
case a > b { True -> a False -> b }
}
fn min_int(a: Int, b: Int) -> Int {
case a < b { True -> a False -> b }
}
// FFI
@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
@external(erlang, "erlang", "float")
fn int_to_float(i: Int) -> Float
Step 2: Create FFI
Create src/towerops_baseline_ffi.erl:
-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).
Step 3: Update Elixir module
In lib/towerops/preseem/baseline.ex, replace compute_stats/1 and to_float/1:
defp compute_stats(values) do
float_values = Enum.map(values, &to_float/1)
stats = :towerops@preseem@baseline.compute_stats(float_values)
# Gleam Stats record comes back as {:stats, mean, stddev, p5, p95}
%{mean: elem(stats, 1), stddev: elem(stats, 2), p5: elem(stats, 3), p95: elem(stats, 4)}
end
defp to_float(v) when is_float(v), do: v
defp to_float(v) when is_integer(v), do: v * 1.0
Wait — Gleam custom types with fields compile to tuples on the BEAM. Stats(mean: 1.0, stddev: 2.0, p5: 0.5, p95: 9.5) becomes {stats, 1.0, 2.0, 0.5, 9.5}. So we use elem/2 to extract fields.
Step 4: Run tests
mix test test/towerops/preseem/baseline_test.exs
Expected: All tests pass.
Step 5: Commit
git add src/towerops/preseem/baseline.gleam src/towerops_baseline_ffi.erl lib/towerops/preseem/baseline.ex
git commit -m "Extract Baseline.compute_stats to Gleam"
Task 6: Convert Towerops.EctoTypes.IpAddress (parsing core)
Extract pure IP parsing/validation. :inet calls go through FFI.
Files:
- Create:
src/towerops/ecto_types/ip_address.gleam - Create:
src/towerops_ip_address_ffi.erl - Modify:
lib/towerops/ecto_types/ip_address.ex
Key decisions:
-
Gleam module exports:
parse_string(value: String) -> Result(#(String, IpTuple), Nil)— parse, strip zone ID, reject CIDRstrip_zone_id(value: String) -> Stringdetect_version(tuple_size: Int) -> IpVersion- Type:
pub type IpVersion { Ipv4 Ipv6 }
-
FFI
src/towerops_ip_address_ffi.erlwraps:parse_address(String) -> {ok, Tuple} | {error, nil}— wraps:inet.parse_address/1ntoa(Tuple) -> {ok, String} | {error, nil}— wraps:inet.ntoa/1tuple_size(Tuple) -> Int
-
Elixir wrapper keeps all Ecto Type callbacks and struct, delegates parsing to Gleam.
Step 1: Implement Gleam module and FFI
The Gleam module:
/// IP address parsing and validation.
/// Compiles to `:towerops@ecto_types@ip_address`.
import gleam/string
pub type IpVersion {
Ipv4
Ipv6
}
/// Parse an IP address string. Returns Ok(#(cleaned_string, ip_tuple)) or Error(Nil).
/// Strips zone IDs, rejects CIDR notation.
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 -> inet_parse(cleaned)
}
}
}
}
/// Strip IPv6 zone ID (e.g., "fe80::1%eth0" -> "fe80::1").
pub fn strip_zone_id(value: String) -> String {
case string.split_once(value, "%") {
Ok(#(ip, _zone)) -> ip
Error(_) -> value
}
}
/// Detect IP version from tuple size (4 = IPv4, 8 = IPv6).
pub fn detect_version(size: Int) -> IpVersion {
case size {
4 -> Ipv4
_ -> Ipv6
}
}
@external(erlang, "towerops_ip_address_ffi", "parse_address")
fn inet_parse(value: String) -> Result(#(String, Dynamic), Nil)
Step 2: Run tests
mix test test/towerops/ecto_types/ip_address_test.exs
Step 3: Commit
git add src/towerops/ecto_types/ip_address.gleam src/towerops_ip_address_ffi.erl lib/towerops/ecto_types/ip_address.ex
git commit -m "Extract IpAddress parsing to Gleam"
Task 7: Convert Towerops.EctoTypes.SnmpOid (parsing/manipulation)
Extract numeric OID parsing and manipulation. Named OID resolution (SnmpKit) stays in Elixir.
Files:
- Create:
src/towerops/ecto_types/snmp_oid.gleam - Modify:
lib/towerops/ecto_types/snmp_oid.ex
Key decisions:
-
Gleam module exports:
parse_numeric_oid(value: String) -> Result(#(String, List(Int)), Nil)— validates, normalizes leading dot, returns(oid_string, parts)is_numeric_oid(value: String) -> Boolis_named_oid(value: String) -> Boolensure_leading_dot(value: String) -> Stringparent_parts(parts: List(Int)) -> List(Int)— drop lastchild_of(child: List(Int), parent: List(Int)) -> Boolappend_parts(parts: List(Int), additional: List(Int)) -> List(Int)parts_to_string(parts: List(Int)) -> String— builds dotted-decimal with leading dot
-
No FFI needed — all string manipulation + list operations available in Gleam stdlib.
-
Note: already have
snmpkit/snmp_lib/oid.gleamwith some OID logic. This is separate — the Ecto type parsing vs. the SNMP protocol OID manipulation. Keep them separate since they serve different purposes. -
Elixir wrapper keeps Ecto callbacks, struct,
resolve/1(calls SnmpKit), andnew/new!.
Step 1: Implement Gleam module
Use gleam/regexp for the OID patterns, gleam/int for parsing, gleam/list for manipulation.
Step 2: Run tests
mix test test/towerops/ecto_types/snmp_oid_test.exs
Step 3: Commit
git add src/towerops/ecto_types/snmp_oid.gleam lib/towerops/ecto_types/snmp_oid.ex
git commit -m "Extract SnmpOid parsing/manipulation to Gleam"
Task 8: Convert Towerops.Snmp.SensorChangeDetector (threshold/event logic)
Extract the pure decision logic (threshold checking, event building, value formatting). PubSub broadcasting and Repo queries stay in Elixir.
Files:
- Create:
src/towerops/snmp/sensor_change_detector.gleam - Create:
src/towerops_sensor_ffi.erl - Modify:
lib/towerops/snmp/sensor_change_detector.ex
Key decisions:
-
Gleam module exports:
- Types:
SensorInfo— id, descr, type, unit, last_value (the fields needed for detection)Thresholds— warning_high, critical_high, warning_low, critical_low (allOption(Float))SensorEvent— device_id, event_type, severity, message, metadata, occurred_at
- Functions:
detect_events(sensor: SensorInfo, current_value: Float, device_id: String, timestamp: Dynamic, thresholds: Thresholds) -> List(SensorEvent)format_sensor_value(value: Float, unit: String) -> Stringvalue_was_over_threshold(last_value: Float, thresholds: Thresholds) -> Boolvalue_is_normal(current_value: Float, thresholds: Thresholds) -> Bool
- Types:
-
FFI:
float_round/2,float_abs/1,int_trunc/1for numeric formatting. -
Elixir wrapper:
detect_and_broadcast/3stays in Elixir- Calls
get_device_id_from_sensor(Repo) andextract_thresholds(map access) - Builds
SensorInfotuple, calls Gleamdetect_events - Broadcasts returned events via PubSub
Step 1: Implement Gleam module and FFI
Step 2: Run tests
mix test test/towerops/snmp/sensor_change_detector_test.exs
Expected: All 11 tests pass.
Step 3: Commit
git add src/towerops/snmp/sensor_change_detector.gleam src/towerops_sensor_ffi.erl lib/towerops/snmp/sensor_change_detector.ex
git commit -m "Extract SensorChangeDetector threshold/event logic to Gleam"
Task 9: Convert Towerops.Capacity.Resolver (unit normalization)
Extract the pure normalize_sensor_to_bps and normalize_unit functions.
Files:
- Create:
src/towerops/capacity/resolver.gleam - Modify:
lib/towerops/capacity/resolver.ex
Key decisions:
-
Gleam module exports:
normalize_to_bps(value: Float, unit: String, divisor: Float) -> Int— normalizes sensor value to bits-per-secondnormalize_unit(unit: String) -> SpeedUnit- Type:
pub type SpeedUnit { Bps Kbps Mbps Gbps }
-
No FFI needed — just float arithmetic and string matching.
-
Elixir wrapper keeps
resolve_capacity/2,resolve_from_sensors/1(Repo queries), and the manual/if_speed resolution.
Step 1: Implement
/// Capacity unit normalization.
/// Compiles to `:towerops@capacity@resolver`.
import gleam/float
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)
}
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
Step 2: Update Elixir
def normalize_sensor_to_bps(%Sensor{last_value: nil}), do: nil
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
:towerops@capacity@resolver.normalize_to_bps(
value / 1.0,
unit || "",
(divisor || 1) / 1.0
)
end
Step 3: Run tests
mix test test/towerops/capacity/resolver_test.exs
Step 4: Commit
git add src/towerops/capacity/resolver.gleam lib/towerops/capacity/resolver.ex
git commit -m "Extract Capacity.Resolver unit normalization to Gleam"
Task 10: Convert Towerops.ConfigChanges.Correlator (pure math)
Extract safe_pct_change, determine_severity, build_description, maybe_add, stringify_metrics.
Files:
- Create:
src/towerops/config_changes/correlator.gleam - Create:
src/towerops_correlator_ffi.erl - Modify:
lib/towerops/config_changes/correlator.ex
Key decisions:
-
Gleam module exports:
safe_pct_change(old: Float, new: Float) -> Floatdetermine_severity(latency_delta: Float, loss_delta: Float, throughput_delta: Float) -> Stringbuild_description(latency_delta: Float, loss_delta: Float, throughput_delta: Float, sections: List(String)) -> Stringstringify_metrics(avg_latency: Float, avg_jitter: Float, avg_loss: Float, avg_throughput: Float) -> List(#(String, Float))
-
FFI:
float_round/2from Erlang. -
Elixir keeps:
correlate/1,correlate_recent/2, all Repo queries,evaluate_impact,create_suspect_insight.
Step 1: Implement
Step 2: Run tests (no dedicated correlator test exists)
mix test
Ensure no regressions.
Step 3: Commit
git add src/towerops/config_changes/correlator.gleam src/towerops_correlator_ffi.erl lib/towerops/config_changes/correlator.ex
git commit -m "Extract Correlator math/severity to Gleam"
Task 11: Convert Towerops.LogFilter + Towerops.LoggerFilters
Bundle both into a single Gleam module for log noise filtering.
Files:
- Create:
src/towerops/log_filters.gleam - Create:
src/towerops_log_filters_ffi.erl - Modify:
lib/towerops/log_filter.ex - Modify:
lib/towerops/logger_filters.ex
Key decisions:
-
Log events are Erlang maps with atom keys. Gleam needs FFI to pattern match on these.
-
Gleam module exports:
is_bandit_invalid_http_error(msg: String) -> Bool— checks string contains both "Bandit.HTTPError" and "Invalid HTTP version"is_oban_shutdown(msg: String) -> Bool— checks for "EXIT" + ":shutdown"is_oban_component(msg: String) -> Bool— checks for "Oban.Queue" or "Oban.Plugins"is_shutdown_error(msg: String) -> Bool— checks for "port_died" or ("write_failed" + "epipe")is_snmp_mib_error(msg: String) -> Bool— checks for "Cannot find module" + "At line 1 in (none)"
-
Elixir wrappers keep the log event pattern matching (
%{level: :error, msg: {:string, msg}}) and call Gleam for the string predicate checks. -
The structural pattern matching on log events (checking
:level,:meta,:msgtuple shapes) stays in Elixir because it involves atoms and Erlang-specific map structures.
Step 1: Create Gleam module
/// Log message predicate checks for noise filtering.
/// Compiles to `:towerops@log_filters`.
import gleam/string
/// Check if message is a Bandit invalid HTTP version error.
pub fn is_bandit_invalid_http(msg: String) -> Bool {
string.contains(msg, "Bandit.HTTPError")
&& string.contains(msg, "Invalid HTTP version")
}
/// Check if message is an Oban shutdown message (EXIT + :shutdown).
pub fn is_oban_shutdown_msg(msg: String) -> Bool {
string.contains(msg, "EXIT") && string.contains(msg, ":shutdown")
}
/// Check if message references an Oban component.
pub fn is_oban_component(msg: String) -> Bool {
string.contains(msg, "Oban.Queue")
|| string.contains(msg, "Oban.Plugins")
}
/// Check if message is Oban queue shutdown (component + received unexpected + EXIT + shutdown).
pub fn is_oban_queue_shutdown(msg: String) -> Bool {
is_oban_component(msg)
&& string.contains(msg, "received unexpected message")
&& string.contains(msg, "EXIT")
&& string.contains(msg, ":shutdown")
}
/// Check if message is a shutdown error (port_died or write_failed+epipe).
pub fn is_shutdown_error(msg: String) -> Bool {
string.contains(msg, "port_died")
|| { string.contains(msg, "write_failed") && string.contains(msg, "epipe") }
}
/// Check if message is an SNMP MIB resolution error.
pub fn is_snmp_mib_error(msg: String) -> Bool {
string.contains(msg, "Cannot find module")
&& string.contains(msg, "At line 1 in (none)")
}
Step 2: Update Elixir wrappers
In lib/towerops/log_filter.ex:
defp bandit_invalid_http_error?(msg) do
:towerops@log_filters.is_bandit_invalid_http(msg)
end
In lib/towerops/logger_filters.ex:
defp oban_source_shutdown?(%{meta: %{source: :oban}, msg: {:string, msg}}) when is_binary(msg) do
:towerops@log_filters.is_oban_shutdown_msg(msg)
end
defp oban_queue_shutdown?(%{meta: %{error_logger: %{tag: :error_msg}}, msg: {:string, msg}}) when is_binary(msg) do
:towerops@log_filters.is_oban_queue_shutdown(msg)
end
defp shutdown_log_message?(%{msg: {:string, msg}}) when is_binary(msg) do
:towerops@log_filters.is_shutdown_error(msg)
end
defp snmp_mib_error?(%{msg: {:string, msg}}) when is_binary(msg) do
:towerops@log_filters.is_snmp_mib_error(msg)
end
Step 3: Run tests
mix test test/towerops/log_filter_test.exs test/towerops/logger_filters_test.exs
Step 4: Commit
git add src/towerops/log_filters.gleam lib/towerops/log_filter.ex lib/towerops/logger_filters.ex
git commit -m "Extract log filter string predicates to Gleam"
Task 12: Convert Towerops.OnCall.Resolver
The on-call resolution is pure computation but complex — rotation math, layer stacking, time restrictions, overrides. The existing tests use database fixtures (preloaded Schedule struct).
Files:
- Create:
src/towerops/on_call/resolver.gleam - Create:
src/towerops_on_call_ffi.erl - Modify:
lib/towerops/on_call/resolver.ex
Key decisions:
-
The preloaded Schedule struct has deeply nested associations (layers → members → user, overrides → user). Passing these complex Elixir structs to Gleam requires careful FFI mapping.
-
Approach: The Gleam module takes flattened data structures, not Ecto structs:
Override=#(DateTime, DateTime, user_ref)— start_time, end_time, userLayerMember=#(Int, user_ref)— position, userLayer= record with rotation_type, rotation_interval, start_date, position, restriction info, membersresolvetakes a list of overrides, list of layers, and current datetime
-
FFI needed for:
- DateTime comparison and diff (Elixir DateTime functions)
- Time extraction and comparison
- Time.from_iso8601 parsing
-
Elixir wrapper:
resolve/2extracts data from preloaded Schedule struct- Builds flat lists of override tuples and layer records
- Calls Gleam
resolve - Returns the user map
Actually, the DateTime/Time operations are complex to bridge. The Gleam gleam_time library may not have all the needed operations. A simpler approach: extract just the rotation math to Gleam (what position in rotation given elapsed seconds, interval, and member count), and keep the DateTime/Time logic in Elixir.
Revised approach — extract rotation computation:
Gleam module exports:
compute_rotation_position(elapsed_seconds: Int, rotation_seconds: Int, member_count: Int) -> Introtation_duration_seconds(rotation_type: String, rotation_interval: Int) -> Int
Elixir keeps: override resolution, layer filtering, time restriction checking, DateTime operations.
This is a smaller but cleaner extraction that avoids complex DateTime FFI.
Step 1: Implement Gleam module
/// On-call rotation computation.
/// Compiles to `:towerops@on_call@resolver`.
const seconds_per_day = 86_400
const seconds_per_week = 604_800
/// Compute rotation position (0-indexed) given elapsed time.
pub fn compute_rotation_position(
elapsed_seconds: Int,
rotation_seconds: Int,
member_count: Int,
) -> Int {
case member_count {
0 -> 0
_ -> { elapsed_seconds / rotation_seconds } % member_count
}
}
/// Get rotation duration in seconds for a given type and interval.
pub fn rotation_duration_seconds(rotation_type: String, rotation_interval: Int) -> Int {
case rotation_type {
"daily" -> rotation_interval * seconds_per_day
"weekly" -> rotation_interval * seconds_per_week
"custom" -> rotation_interval * seconds_per_day
_ -> rotation_interval * seconds_per_day
}
}
Step 2: Update Elixir
Replace compute_rotation/2 and rotation_duration_seconds/1:
defp compute_rotation(%{members: []}, _datetime), do: nil
defp compute_rotation(layer, datetime) do
members = Enum.sort_by(layer.members, & &1.position)
member_count = length(members)
if member_count == 0 do
nil
else
elapsed_seconds = DateTime.diff(datetime, layer.start_date, :second)
rotation_seconds = :towerops@on_call@resolver.rotation_duration_seconds(layer.rotation_type, layer.rotation_interval)
position = :towerops@on_call@resolver.compute_rotation_position(elapsed_seconds, rotation_seconds, member_count)
member = Enum.at(members, position)
{:ok, member.user}
end
end
Remove the old rotation_duration_seconds/1 private functions.
Step 3: Run tests
mix test test/towerops/on_call/resolver_test.exs
Step 4: Commit
git add src/towerops/on_call/resolver.gleam lib/towerops/on_call/resolver.ex
git commit -m "Extract OnCall.Resolver rotation computation to Gleam"
Task 13: Final verification and cleanup
Step 1: Run full test suite
mix test
Step 2: Run Gleam build
gleam build
Step 3: Run precommit checks
mix precommit
Step 4: Run Gleam format
gleam format src/
Step 5: Verify no regressions
mix test --cover
Check coverage hasn't dropped.
Step 6: Final commit if any formatting changes
git add -A
git commit -m "Format and verify gleam4 batch conversion"