From 44a80cd5eb73f4ffd00dd00a82ecc0c177ccf8e2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 29 Mar 2026 11:03:20 -0500 Subject: [PATCH] remove-gleam (#218) Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/218 --- .gitignore | 2 +- .tool-versions | 1 - Dockerfile | 14 +- .../2026-03-21-gleam4-batch-conversion.md | 1333 ------------- docs/references/gleam-best-practices.md | 1725 ----------------- k8s/base-image/Dockerfile | 10 +- lib/towerops/ecto_types/mac_address.ex | 5 +- lib/towerops/proto/agent.ex | 203 +- ...rlang_compat_decode.ex => tuple_decode.ex} | 40 +- ...rlang_compat_encode.ex => tuple_encode.ex} | 42 +- lib/towerops/snmp/sensor_change_detector.ex | 2 +- lib/towerops_web/changelog_parser.ex | 2 +- nix/shell.nix | 6 +- test/towerops/proto/agent_test.exs | 2 +- ...ser_test.exs => changelog_parser_test.exs} | 2 +- 15 files changed, 155 insertions(+), 3234 deletions(-) delete mode 100644 docs/plans/2026-03-21-gleam4-batch-conversion.md delete mode 100644 docs/references/gleam-best-practices.md rename lib/towerops/proto/{erlang_compat_decode.ex => tuple_decode.ex} (87%) rename lib/towerops/proto/{erlang_compat_encode.ex => tuple_encode.ex} (93%) rename test/towerops_web/{gleam_changelog_parser_test.exs => changelog_parser_test.exs} (98%) diff --git a/.gitignore b/.gitignore index 17e380b8..b21198f4 100644 --- a/.gitignore +++ b/.gitignore @@ -100,7 +100,7 @@ profiles.json .stride_auth.md -# Gleam build artifacts +# Build artifacts /build/ # Generated by nix, machine-specific paths diff --git a/.tool-versions b/.tool-versions index c88c24b4..3f245103 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,4 @@ erlang 28.3 elixir 1.19.5-otp-28 #elixir 1.20.0-rc.1-otp-28 -gleam 1.15.2 nodejs 22.22.2 diff --git a/Dockerfile b/Dockerfile index 7e69f31b..5ff81367 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,29 +34,21 @@ RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git curl libsnmp-dev \ && rm -rf /var/lib/apt/lists/* -# Install Gleam compiler -ARG GLEAM_VERSION=1.15.2 -RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \ - | tar xz -C /usr/local/bin/ - # prepare build dir WORKDIR /app # set build ENV ENV MIX_ENV="prod" -# install hex + rebar + mix_gleam archive (must use cache mounts so they -# persist into subsequent cache-mounted steps) +# install hex + rebar (must use cache mounts so they persist into subsequent cache-mounted steps) RUN --mount=type=cache,target=/root/.hex \ --mount=type=cache,target=/root/.mix \ mix local.hex --force \ - && mix local.rebar --force \ - && mix archive.install hex mix_gleam --force + && mix local.rebar --force # install mix dependencies -COPY mix.exs mix.lock gleam.toml ./ +COPY mix.exs mix.lock ./ COPY vendor vendor -COPY src src RUN --mount=type=cache,target=/root/.hex \ --mount=type=cache,target=/root/.mix \ mix deps.get --only $MIX_ENV diff --git a/docs/plans/2026-03-21-gleam4-batch-conversion.md b/docs/plans/2026-03-21-gleam4-batch-conversion.md deleted file mode 100644 index 0c352ac9..00000000 --- a/docs/plans/2026-03-21-gleam4-batch-conversion.md +++ /dev/null @@ -1,1333 +0,0 @@ -# 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)` with `Some(value)` / `None` -- No exceptions — use `Result(a, e)` with `Ok(value)` / `Error(reason)` -- Snake_case for functions, PascalCase for types -- Pattern matching via `case` expressions -- Strings are UTF-8 binaries (same as Elixir) -- Lists are linked lists (same as Elixir) -- Use `gleam/string`, `gleam/int`, `gleam/float`, `gleam/list` from stdlib -- Use `gleam/regexp` for regex (already a dependency) -- No mutable state, no processes -- Gleam `Float` and `Int` are separate types — explicit conversion required - ---- - -### Task 1: Create branch and set up - -**Files:** -- Branch: `gleam4` - -**Step 1: Create branch** - -```bash -cd /Users/graham/dev/towerops/towerops-web -git checkout -b gleam4 -``` - -**Step 2: Verify Gleam builds** - -```bash -cd /Users/graham/dev/towerops/towerops-web -gleam build -``` - -**Step 3: Verify tests pass** - -```bash -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: - -```gleam -/// 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`: - -```erlang --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: - -```elixir -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: -1. Map all unknown atoms to `OtherAction`/`OtherResource` in the Elixir wrapper -2. Or use the FFI approach - -Let's go with option 1 — a simple mapping in the Elixir wrapper: - -```elixir -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** - -```bash -mix test test/towerops/organizations/policy_test.exs -``` - -Expected: All tests pass. - -**Step 4: Run full test suite** - -```bash -mix test -``` - -Expected: All tests pass. - -**Step 5: Verify Gleam builds clean** - -```bash -gleam build -``` - -**Step 6: Commit** - -```bash -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: - -```gleam -/// 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: -```elixir -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** - -```elixir -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** - -```bash -mix test test/towerops/result_test.exs -``` - -Expected: All tests pass. - -**Step 4: Commit** - -```bash -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. - -```gleam -/// 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: - -```gleam -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:** - -1. Gleam module at `src/towerops/ecto_types/mac_address.gleam` exports: - - `parse_string(value: String) -> Result(List(Int), Nil)` — parse any format to 6 bytes - - `format_colon(bytes: List(Int)) -> String` - - `format_hyphen(bytes: List(Int)) -> String` - - `format_dot(bytes: List(Int)) -> String` - - `format_compact(bytes: List(Int)) -> String` - -2. FFI at `src/towerops_mac_address_ffi.erl` exports: - - `parse_hex(String) -> {ok, Int} | {error, nil}` — wraps `Integer.parse(s, 16)` - - `list_to_binary(List) -> Binary` — wraps `erlang:list_to_binary/1` - - `binary_to_list(Binary) -> List` — wraps `erlang:binary_to_list/1` - - `is_printable(Binary) -> Bool` — wraps `String.printable?/1` - - `byte_size(Binary) -> Int` - -3. Elixir wrapper at `lib/towerops/ecto_types/mac_address.ex` keeps: - - All `use Ecto.Type` callbacks (`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` - -**Step 2: Run tests** - -```bash -mix test test/towerops/ecto_types/mac_address_test.exs -``` - -Expected: All 27 tests pass. - -**Step 3: Commit** - -```bash -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** - -```gleam -/// 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`: - -```erlang --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`: - -```elixir -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** - -```bash -mix test test/towerops/preseem/baseline_test.exs -``` - -Expected: All tests pass. - -**Step 5: Commit** - -```bash -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:** - -1. Gleam module exports: - - `parse_string(value: String) -> Result(#(String, IpTuple), Nil)` — parse, strip zone ID, reject CIDR - - `strip_zone_id(value: String) -> String` - - `detect_version(tuple_size: Int) -> IpVersion` - - Type: `pub type IpVersion { Ipv4 Ipv6 }` - -2. FFI `src/towerops_ip_address_ffi.erl` wraps: - - `parse_address(String) -> {ok, Tuple} | {error, nil}` — wraps `:inet.parse_address/1` - - `ntoa(Tuple) -> {ok, String} | {error, nil}` — wraps `:inet.ntoa/1` - - `tuple_size(Tuple) -> Int` - -3. Elixir wrapper keeps all Ecto Type callbacks and struct, delegates parsing to Gleam. - -**Step 1: Implement Gleam module and FFI** - -The Gleam module: -```gleam -/// 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** - -```bash -mix test test/towerops/ecto_types/ip_address_test.exs -``` - -**Step 3: Commit** - -```bash -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:** - -1. 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) -> Bool` - - `is_named_oid(value: String) -> Bool` - - `ensure_leading_dot(value: String) -> String` - - `parent_parts(parts: List(Int)) -> List(Int)` — drop last - - `child_of(child: List(Int), parent: List(Int)) -> Bool` - - `append_parts(parts: List(Int), additional: List(Int)) -> List(Int)` - - `parts_to_string(parts: List(Int)) -> String` — builds dotted-decimal with leading dot - -2. No FFI needed — all string manipulation + list operations available in Gleam stdlib. - -3. Note: already have `snmpkit/snmp_lib/oid.gleam` with some OID logic. This is separate — the Ecto type parsing vs. the SNMP protocol OID manipulation. Keep them separate since they serve different purposes. - -4. Elixir wrapper keeps Ecto callbacks, struct, `resolve/1` (calls SnmpKit), and `new/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** - -```bash -mix test test/towerops/ecto_types/snmp_oid_test.exs -``` - -**Step 3: Commit** - -```bash -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:** - -1. 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 (all `Option(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) -> String` - - `value_was_over_threshold(last_value: Float, thresholds: Thresholds) -> Bool` - - `value_is_normal(current_value: Float, thresholds: Thresholds) -> Bool` - -2. FFI: `float_round/2`, `float_abs/1`, `int_trunc/1` for numeric formatting. - -3. Elixir wrapper: - - `detect_and_broadcast/3` stays in Elixir - - Calls `get_device_id_from_sensor` (Repo) and `extract_thresholds` (map access) - - Builds `SensorInfo` tuple, calls Gleam `detect_events` - - Broadcasts returned events via PubSub - -**Step 1: Implement Gleam module and FFI** - -**Step 2: Run tests** - -```bash -mix test test/towerops/snmp/sensor_change_detector_test.exs -``` - -Expected: All 11 tests pass. - -**Step 3: Commit** - -```bash -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:** - -1. Gleam module exports: - - `normalize_to_bps(value: Float, unit: String, divisor: Float) -> Int` — normalizes sensor value to bits-per-second - - `normalize_unit(unit: String) -> SpeedUnit` - - Type: `pub type SpeedUnit { Bps Kbps Mbps Gbps }` - -2. No FFI needed — just float arithmetic and string matching. - -3. Elixir wrapper keeps `resolve_capacity/2`, `resolve_from_sensors/1` (Repo queries), and the manual/if_speed resolution. - -**Step 1: Implement** - -```gleam -/// 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** - -```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** - -```bash -mix test test/towerops/capacity/resolver_test.exs -``` - -**Step 4: Commit** - -```bash -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:** - -1. Gleam module exports: - - `safe_pct_change(old: Float, new: Float) -> Float` - - `determine_severity(latency_delta: Float, loss_delta: Float, throughput_delta: Float) -> String` - - `build_description(latency_delta: Float, loss_delta: Float, throughput_delta: Float, sections: List(String)) -> String` - - `stringify_metrics(avg_latency: Float, avg_jitter: Float, avg_loss: Float, avg_throughput: Float) -> List(#(String, Float))` - -2. FFI: `float_round/2` from Erlang. - -3. 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)** - -```bash -mix test -``` - -Ensure no regressions. - -**Step 3: Commit** - -```bash -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:** - -1. Log events are Erlang maps with atom keys. Gleam needs FFI to pattern match on these. - -2. 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)" - -3. Elixir wrappers keep the log event pattern matching (`%{level: :error, msg: {:string, msg}}`) and call Gleam for the string predicate checks. - -4. The structural pattern matching on log events (checking `:level`, `:meta`, `:msg` tuple shapes) stays in Elixir because it involves atoms and Erlang-specific map structures. - -**Step 1: Create Gleam module** - -```gleam -/// 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`: -```elixir -defp bandit_invalid_http_error?(msg) do - :towerops@log_filters.is_bandit_invalid_http(msg) -end -``` - -In `lib/towerops/logger_filters.ex`: -```elixir -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** - -```bash -mix test test/towerops/log_filter_test.exs test/towerops/logger_filters_test.exs -``` - -**Step 4: Commit** - -```bash -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:** - -1. The preloaded Schedule struct has deeply nested associations (layers → members → user, overrides → user). Passing these complex Elixir structs to Gleam requires careful FFI mapping. - -2. Approach: The Gleam module takes flattened data structures, not Ecto structs: - - `Override` = `#(DateTime, DateTime, user_ref)` — start_time, end_time, user - - `LayerMember` = `#(Int, user_ref)` — position, user - - `Layer` = record with rotation_type, rotation_interval, start_date, position, restriction info, members - - `resolve` takes a list of overrides, list of layers, and current datetime - -3. FFI needed for: - - DateTime comparison and diff (Elixir DateTime functions) - - Time extraction and comparison - - Time.from_iso8601 parsing - -4. Elixir wrapper: - - `resolve/2` extracts 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) -> Int` -- `rotation_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** - -```gleam -/// 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`: - -```elixir -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** - -```bash -mix test test/towerops/on_call/resolver_test.exs -``` - -**Step 4: Commit** - -```bash -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** - -```bash -mix test -``` - -**Step 2: Run Gleam build** - -```bash -gleam build -``` - -**Step 3: Run precommit checks** - -```bash -mix precommit -``` - -**Step 4: Run Gleam format** - -```bash -gleam format src/ -``` - -**Step 5: Verify no regressions** - -```bash -mix test --cover -``` - -Check coverage hasn't dropped. - -**Step 6: Final commit if any formatting changes** - -```bash -git add -A -git commit -m "Format and verify gleam4 batch conversion" -``` diff --git a/docs/references/gleam-best-practices.md b/docs/references/gleam-best-practices.md deleted file mode 100644 index af659f21..00000000 --- a/docs/references/gleam-best-practices.md +++ /dev/null @@ -1,1725 +0,0 @@ -# Gleam Language Reference & Best Practices - -> Practical reference for building production Gleam applications on the BEAM. -> Covers language fundamentals, OTP, web development, and real-world patterns. - ---- - -## Table of Contents - -1. [Language Fundamentals](#1-language-fundamentals) -2. [OTP from Gleam](#2-otp-from-gleam) -3. [Web Development](#3-web-development) -4. [Package Ecosystem](#4-package-ecosystem) -5. [Interop (Erlang, Elixir, JavaScript)](#5-interop) -6. [Testing](#6-testing) -7. [Project Structure](#7-project-structure) -8. [Real-World Patterns](#8-real-world-patterns) - ---- - -## 1. Language Fundamentals - -### The Type System - -Gleam is statically typed with full type inference. No `null`, no exceptions, no implicit conversions. - -#### Result Type - -The core error handling primitive. Every fallible operation returns `Result(value, error)`. - -```gleam -import gleam/result -import gleam/int - -pub fn parse_age(input: String) -> Result(Int, String) { - case int.parse(input) { - Ok(age) if age >= 0 && age <= 150 -> Ok(age) - Ok(_) -> Error("Age out of range") - Error(_) -> Error("Not a number") - } -} - -pub fn main() { - // Chain results with result.try (like flatMap/bind) - let output = - "25" - |> parse_age - |> result.map(fn(age) { age + 1 }) - - // Pattern match on results - case output { - Ok(age) -> echo age - Error(msg) -> echo msg - } -} -``` - -#### Option (gleam/option) - -Gleam has no `null`. Optional values use `Option(a)` which is `Some(a) | None`. - -```gleam -import gleam/option.{type Option, None, Some} - -pub type User { - User(name: String, email: Option(String)) -} - -pub fn display_email(user: User) -> String { - case user.email { - Some(email) -> email - None -> "No email provided" - } -} - -// option module has helpers -pub fn get_email_or_default(user: User) -> String { - user.email - |> option.unwrap("no-reply@example.com") -} -``` - -#### Custom Types (Algebraic Data Types) - -Custom types are the backbone of Gleam data modeling. They're tagged unions (sum types) with named fields (product types). - -```gleam -// Simple enum -pub type Color { - Red - Green - Blue -} - -// Record with fields -pub type User { - User(id: Int, name: String, role: Role) -} - -// Sum type (tagged union) -pub type Role { - Admin - Moderator(permissions: List(String)) - Member -} - -// Generic types -pub type Validated(a) { - Valid(value: a) - Invalid(errors: List(String)) -} - -// Opaque types - hide internals from other modules -pub opaque type Email { - Email(String) -} - -pub fn new_email(value: String) -> Result(Email, String) { - case value { - _ if value == "" -> Error("Email cannot be empty") - _ -> Ok(Email(value)) - } -} - -pub fn to_string(email: Email) -> String { - let Email(value) = email - value -} -``` - -#### Record Updates - -```gleam -pub type Config { - Config(host: String, port: Int, debug: Bool) -} - -pub fn enable_debug(config: Config) -> Config { - Config(..config, debug: True) -} -``` - -### Pattern Matching - -Pattern matching is pervasive in Gleam — it's the primary flow control mechanism. - -```gleam -import gleam/int -import gleam/list - -// Match on custom types -pub fn describe_role(role: Role) -> String { - case role { - Admin -> "Administrator" - Moderator(perms) -> "Mod with " <> int.to_string(list.length(perms)) <> " permissions" - Member -> "Regular member" - } -} - -// Multiple subjects -pub fn classify(x: Int, y: Int) -> String { - case x, y { - 0, 0 -> "Origin" - 0, _ -> "Y axis" - _, 0 -> "X axis" - _, _ -> "Somewhere else" - } -} - -// Guards -pub fn categorize_age(age: Int) -> String { - case age { - a if a < 0 -> "Invalid" - a if a < 13 -> "Child" - a if a < 18 -> "Teenager" - a if a < 65 -> "Adult" - _ -> "Senior" - } -} - -// String prefix matching -pub fn parse_command(input: String) -> Result(String, String) { - case input { - "GET " <> path -> Ok(path) - "POST " <> path -> Ok(path) - _ -> Error("Unknown command") - } -} - -// List patterns -pub fn first_two(items: List(a)) -> String { - case items { - [] -> "empty" - [_] -> "one item" - [_, _, ..] -> "two or more" - } -} - -// Nested patterns -pub fn get_admin_name(user: User) -> Result(String, Nil) { - case user { - User(name:, role: Admin, ..) -> Ok(name) - _ -> Error(Nil) - } -} - -// As patterns (bind a name to the whole matched value) -pub fn check(user: User) -> User { - case user { - User(role: Admin, ..) as admin -> { - echo "Admin found" - admin - } - other -> other - } -} -``` - -### Use Expressions - -`use` is syntactic sugar for callback functions. It converts the rest of the function body into an anonymous function passed as the last argument. - -```gleam -import gleam/result -import gleam/list - -// WITHOUT use - nested callbacks -pub fn without_use(input: String) -> Result(String, String) { - result.try(parse_name(input), fn(name) { - result.try(validate_name(name), fn(valid_name) { - result.map(format_greeting(valid_name), fn(greeting) { - greeting - }) - }) - }) -} - -// WITH use - flat and readable -pub fn with_use(input: String) -> Result(String, String) { - use name <- result.try(parse_name(input)) - use valid_name <- result.try(validate_name(name)) - use greeting <- result.map(format_greeting(valid_name)) - greeting -} - -// use with list.map -pub fn double_all(numbers: List(Int)) -> List(Int) { - use n <- list.map(numbers) - n * 2 -} - -// use with list.filter -pub fn only_positive(numbers: List(Int)) -> List(Int) { - use n <- list.filter(numbers) - n > 0 -} - -// use for middleware (very common in web handlers) -pub fn handle(req: Request) -> Response { - use <- wisp.log_request(req) - use <- wisp.serve_static(req, under: "/static", from: "/public") - use req <- middleware(req) - route(req) -} -``` - -**How `use` works mechanically:** - -```gleam -// This: -use x <- some_function(arg1, arg2) -do_something(x) - -// Desugars to: -some_function(arg1, arg2, fn(x) { - do_something(x) -}) -``` - -### Pipelines - -The `|>` operator passes the left-hand value as the first argument to the right-hand function. - -```gleam -import gleam/string -import gleam/list -import gleam/int - -pub fn format_names(names: List(String)) -> String { - names - |> list.map(string.trim) - |> list.filter(fn(s) { s != "" }) - |> list.sort(string.compare) - |> list.map(string.capitalise) - |> string.join(", ") -} - -// Pipe to a different argument position with function capture -pub fn example() { - "world" - |> string.append("hello ", _) // Becomes string.append("hello ", "world") -} - -// Debug in the middle of a pipeline -pub fn debug_pipeline(x: Int) -> Int { - x - |> int.multiply(2) - |> echo // prints the intermediate value, passes it through - |> int.add(1) -} -``` - -### Labelled Arguments - -```gleam -pub fn create_user( - name name: String, - email email: String, - role role: Role, -) -> User { - User(id: 0, name:, role:) -} - -// Call with labels (order doesn't matter for labelled args) -pub fn example() { - create_user(role: Admin, name: "Alice", email: "alice@example.com") -} - -// Shorthand: when variable name matches label -pub fn example2() { - let name = "Bob" - let email = "bob@example.com" - let role = Member - create_user(name:, email:, role:) -} -``` - -### Generics - -```gleam -// Generic function -pub fn first(pair: #(a, b)) -> a { - pair.0 -} - -// Generic custom type -pub type Stack(a) { - Stack(items: List(a)) -} - -pub fn push(stack: Stack(a), item: a) -> Stack(a) { - Stack(items: [item, ..stack.items]) -} - -pub fn pop(stack: Stack(a)) -> Result(#(a, Stack(a)), Nil) { - case stack.items { - [] -> Error(Nil) - [top, ..rest] -> Ok(#(top, Stack(items: rest))) - } -} - -// Constrained generics don't exist in Gleam — -// use higher-order functions instead: -pub fn map_pair(pair: #(a, a), f: fn(a) -> b) -> #(b, b) { - #(f(pair.0), f(pair.1)) -} -``` - ---- - -## 2. OTP from Gleam - -Gleam runs on the BEAM and has first-class OTP support via `gleam_erlang` and `gleam_otp`. - -``` -gleam add gleam_otp gleam_erlang -``` - -### Processes and Subjects - -A `Subject(msg)` is a type-safe process mailbox reference. It's Gleam's answer to untyped Erlang PIDs. - -```gleam -import gleam/erlang/process.{type Subject} - -pub fn spawn_logger() -> Subject(String) { - // process.new_subject() creates a subject for receiving messages - let subject = process.new_subject() - - // Spawn a process - process.start(linked: True, running: fn() { - logger_loop(subject) - }) - - subject -} - -fn logger_loop(subject: Subject(String)) -> Nil { - // Receive a message (blocks until one arrives) - let msg = process.receive(subject, within: 5000) - case msg { - Ok(text) -> { - echo text - logger_loop(subject) - } - Error(Nil) -> { - echo "Logger timed out, stopping" - Nil - } - } -} - -pub fn main() { - let logger = spawn_logger() - process.send(logger, "Hello from main!") - process.send(logger, "Another message") - process.sleep(100) -} -``` - -### Actors (gleam_otp) - -Actors are the recommended abstraction — they handle OTP system messages automatically. - -```gleam -import gleam/erlang/process.{type Subject} -import gleam/otp/actor - -// Define your message type -pub type CounterMsg { - Increment(by: Int) - Decrement(by: Int) - GetCount(reply_to: Subject(Int)) - Reset -} - -// State is just a plain type -pub type CounterState { - CounterState(count: Int, name: String) -} - -// Start the actor -pub fn start(name: String) -> Result(Subject(CounterMsg), actor.StartError) { - let init_state = CounterState(count: 0, name:) - - actor.new(init_state) - |> actor.on_message(handle_message) - |> actor.start -} - -// Handle messages — return actor.Next to continue or actor.Stop to shut down -fn handle_message( - state: CounterState, - message: CounterMsg, -) -> actor.Next(CounterState, CounterMsg) { - case message { - Increment(by:) -> { - let new_state = CounterState(..state, count: state.count + by) - actor.continue(new_state) - } - - Decrement(by:) -> { - let new_state = CounterState(..state, count: state.count - by) - actor.continue(new_state) - } - - GetCount(reply_to:) -> { - actor.send(reply_to, state.count) - actor.continue(state) - } - - Reset -> { - actor.continue(CounterState(..state, count: 0)) - } - } -} - -// Client API — hide the message protocol behind functions -pub fn increment(counter: Subject(CounterMsg), by amount: Int) -> Nil { - actor.send(counter, Increment(by: amount)) -} - -pub fn get_count(counter: Subject(CounterMsg)) -> Int { - // actor.call sends a message and waits for a reply - actor.call(counter, GetCount, within: 1000) -} - -pub fn main() { - let assert Ok(counter) = start("my_counter") - - increment(counter, by: 5) - increment(counter, by: 3) - actor.send(counter, Decrement(by: 2)) - - let count = get_count(counter) - echo count // 6 -} -``` - -### Supervisors - -```gleam -import gleam/otp/static_supervisor - -pub fn start_app() { - static_supervisor.new(static_supervisor.OneForOne) - |> static_supervisor.add(static_supervisor.worker_child( - id: "counter_1", - run: fn(_) { start("counter_1") }, - )) - |> static_supervisor.add(static_supervisor.worker_child( - id: "counter_2", - run: fn(_) { start("counter_2") }, - )) - |> static_supervisor.start -} -``` - -### Selectors (Listening to Multiple Subjects) - -```gleam -import gleam/erlang/process.{type Selector, type Subject} - -pub type Event { - UserMessage(String) - SystemAlert(String) - Tick -} - -pub fn listen( - user_sub: Subject(String), - system_sub: Subject(String), - tick_sub: Subject(Nil), -) -> Event { - // Build a selector that maps different subjects to a unified Event type - let selector = - process.new_selector() - |> process.selecting(user_sub, UserMessage) - |> process.selecting(system_sub, SystemAlert) - |> process.selecting(tick_sub, fn(_) { Tick }) - - // Wait for any of them - let assert Ok(event) = process.select(selector, within: 5000) - event -} -``` - ---- - -## 3. Web Development - -### Wisp Framework - -Wisp is the standard web framework for Gleam. It wraps `gleam_http` types and provides middleware. - -``` -gleam add wisp mist gleam_http gleam_erlang -``` - -#### Basic App Structure - -```gleam -// src/app.gleam — entry point -import gleam/erlang/process -import mist -import wisp -import wisp/wisp_mist -import app/router -import app/web.{type Context, Context} - -pub fn main() { - wisp.configure_logger() - - let secret_key_base = wisp.random_string(64) - let ctx = Context( - secret_key_base:, - db: Nil, // your DB connection goes here - ) - - let assert Ok(_) = - wisp_mist.handler(router.handle_request(_, ctx), secret_key_base) - |> mist.new - |> mist.port(8000) - |> mist.start_http - - process.sleep_forever() -} -``` - -#### Context Type - -```gleam -// src/app/web.gleam -pub type Context { - Context(secret_key_base: String, db: connection) -} -``` - -#### Routing - -```gleam -// src/app/router.gleam -import wisp.{type Request, type Response} -import app/web.{type Context} -import app/handlers/user_handler -import app/handlers/health_handler - -pub fn handle_request(req: Request, ctx: Context) -> Response { - // Apply global middleware - use req <- middleware(req, ctx) - - // Route based on method + path segments - case wisp.path_segments(req) { - // GET / - [] -> wisp.ok() |> wisp.string_body("Welcome!") - - // GET /health - ["health"] -> health_handler.index(req) - - // /users/... - ["users", ..rest] -> user_routes(req, ctx, rest) - - // 404 - _ -> wisp.not_found() - } -} - -fn user_routes(req: Request, ctx: Context, path: List(String)) -> Response { - case req.method, path { - // GET /users - http.Get, [] -> user_handler.list(req, ctx) - - // POST /users - http.Post, [] -> user_handler.create(req, ctx) - - // GET /users/:id - http.Get, [id] -> user_handler.show(req, ctx, id) - - // PUT /users/:id - http.Put, [id] -> user_handler.update(req, ctx, id) - - // DELETE /users/:id - http.Delete, [id] -> user_handler.delete(req, ctx, id) - - // Method not allowed - _, _ -> wisp.method_not_allowed([http.Get, http.Post, http.Put, http.Delete]) - } -} - -fn middleware(req: Request, ctx: Context, handler: fn(Request) -> Response) -> Response { - let req = wisp.method_override(req) - use <- wisp.log_request(req) - use <- wisp.rescue_crashes - use req <- wisp.handle_head(req) - use <- wisp.serve_static(req, under: "/static", from: static_directory()) - - handler(req) -} - -fn static_directory() -> String { - let assert Ok(priv) = wisp.priv_directory("app") - priv <> "/static" -} -``` - -#### Request Handling - -```gleam -// src/app/handlers/user_handler.gleam -import gleam/http -import gleam/json -import gleam/dynamic/decode -import wisp.{type Request, type Response} -import app/web.{type Context} - -pub fn create(req: Request, ctx: Context) -> Response { - // Read JSON body - use json_body <- wisp.require_json(req) - - // Decode the JSON - let decoder = - decode.into({ - use name <- decode.parameter - use email <- decode.parameter - #(name, email) - }) - |> decode.field("name", decode.string) - |> decode.field("email", decode.string) - - case decode.run(json_body, decoder) { - Ok(#(name, email)) -> { - // Do something with the data... - let response_json = - json.object([ - #("name", json.string(name)), - #("email", json.string(email)), - #("id", json.int(1)), - ]) - |> json.to_string_tree - - wisp.json_response(response_json, 201) - } - Error(_) -> wisp.unprocessable_entity() - } -} - -pub fn list(_req: Request, _ctx: Context) -> Response { - let body = - json.array([ - json.object([#("id", json.int(1)), #("name", json.string("Alice"))]), - json.object([#("id", json.int(2)), #("name", json.string("Bob"))]), - ], of: fn(x) { x }) - |> json.to_string_tree - - wisp.json_response(body, 200) -} -``` - -### Lustre (Frontend Framework) - -Lustre is Gleam's Elm-inspired frontend framework. Model-Update-View architecture. - -``` -gleam add lustre -gleam add --dev lustre_dev_tools -``` - -#### Counter App - -```gleam -import gleam/int -import lustre -import lustre/attribute -import lustre/element.{text} -import lustre/element/html.{button, div, h1, p} -import lustre/event - -// MODEL -pub type Model { - Model(count: Int, name: String) -} - -fn init(_flags) -> Model { - Model(count: 0, name: "Counter") -} - -// UPDATE -pub type Msg { - Increment - Decrement - Reset - SetName(String) -} - -fn update(model: Model, msg: Msg) -> Model { - case msg { - Increment -> Model(..model, count: model.count + 1) - Decrement -> Model(..model, count: model.count - 1) - Reset -> Model(..model, count: 0) - SetName(name) -> Model(..model, name:) - } -} - -// VIEW -fn view(model: Model) -> element.Element(Msg) { - div([], [ - h1([], [text(model.name)]), - p([], [text("Count: " <> int.to_string(model.count))]), - button([event.on_click(Increment)], [text("+")]), - button([event.on_click(Decrement)], [text("-")]), - button([event.on_click(Reset)], [text("Reset")]), - html.input([ - attribute.value(model.name), - event.on_input(SetName), - attribute.placeholder("Counter name"), - ]), - ]) -} - -// MAIN -pub fn main() { - let app = lustre.simple(init, update, view) - let assert Ok(_) = lustre.start(app, "#app", Nil) - Nil -} -``` - -#### Lustre with Effects - -For apps that need side effects (HTTP requests, timers, etc.), use `lustre.application` instead of `lustre.simple`. - -```gleam -import lustre -import lustre/effect.{type Effect} - -pub fn main() { - let app = lustre.application(init, update, view) - let assert Ok(_) = lustre.start(app, "#app", Nil) - Nil -} - -fn init(_flags) -> #(Model, Effect(Msg)) { - #(Model(loading: True, data: None), fetch_data()) -} - -fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { - case msg { - DataLoaded(data) -> #(Model(loading: False, data: Some(data)), effect.none()) - FetchFailed -> #(Model(loading: False, data: None), effect.none()) - Refresh -> #(Model(..model, loading: True), fetch_data()) - } -} - -fn fetch_data() -> Effect(Msg) { - // Use lustre_http or similar packages for HTTP effects - effect.none() // placeholder -} -``` - -#### Lustre Server Components - -Lustre components can run on the server and push updates to connected clients, similar to Phoenix LiveView. - -```gleam -import lustre -import lustre/server_component - -// Create a server component -pub fn app() { - lustre.component(init, update, view, on_attribute_change()) -} - -fn on_attribute_change() -> Dict(String, Decoder(Msg)) { - dict.new() -} -``` - -### Mist HTTP Server - -Mist is the HTTP server that Wisp runs on top of. You can use it directly for lower-level control. - -```gleam -import gleam/bytes_tree -import gleam/http/response -import gleam/erlang/process -import mist - -pub fn main() { - let assert Ok(_) = - fn(_req) { - response.new(200) - |> response.set_body(mist.Bytes( - bytes_tree.from_string("Hello from Mist!"), - )) - } - |> mist.new - |> mist.port(3000) - |> mist.start_http - - process.sleep_forever() -} -``` - ---- - -## 4. Package Ecosystem - -### Key Packages - -| Package | Purpose | Install | -|---------|---------|---------| -| `gleam_stdlib` | Standard library (lists, strings, result, option, etc.) | Included by default | -| `gleam_json` | JSON encoding/decoding | `gleam add gleam_json` | -| `gleam_http` | HTTP types (Request, Response, Method) | `gleam add gleam_http` | -| `gleam_crypto` | Hashing, HMAC, secure random | `gleam add gleam_crypto` | -| `gleam_erlang` | Erlang interop, process, atoms | `gleam add gleam_erlang` | -| `gleam_otp` | OTP actors, supervisors | `gleam add gleam_otp` | -| `wisp` | Web framework | `gleam add wisp` | -| `mist` | HTTP server | `gleam add mist` | -| `lustre` | Frontend framework | `gleam add lustre` | -| `sqlight` | SQLite bindings | `gleam add sqlight` | -| `gleam_pgo` | PostgreSQL client (PGO) | `gleam add gleam_pgo` | -| `cake` | SQL query builder | `gleam add cake` | -| `envoy` | Environment variables | `gleam add envoy` | -| `argv` | CLI argument parsing | `gleam add argv` | -| `tom` | TOML parser | `gleam add tom` | -| `simplifile` | File system operations | `gleam add simplifile` | -| `gleeunit` | Test runner | `gleam add --dev gleeunit` | - -### gleam_json - -```gleam -import gleam/json -import gleam/dynamic/decode - -// Encoding -pub fn encode_user(user: User) -> String { - json.object([ - #("id", json.int(user.id)), - #("name", json.string(user.name)), - #("role", encode_role(user.role)), - ]) - |> json.to_string -} - -fn encode_role(role: Role) -> json.Json { - case role { - Admin -> json.string("admin") - Moderator(perms) -> json.object([ - #("type", json.string("moderator")), - #("permissions", json.array(perms, json.string)), - ]) - Member -> json.string("member") - } -} - -// Decoding -pub fn decode_user(data: String) -> Result(User, json.DecodeError) { - let user_decoder = - decode.into({ - use id <- decode.parameter - use name <- decode.parameter - use role <- decode.parameter - User(id:, name:, role:) - }) - |> decode.field("id", decode.int) - |> decode.field("name", decode.string) - |> decode.field("role", role_decoder()) - - json.parse(data, user_decoder) -} - -fn role_decoder() -> decode.Decoder(Role) { - decode.one_of(decode.string |> decode.then(fn(s) { - case s { - "admin" -> decode.into(Admin) - "member" -> decode.into(Member) - _ -> decode.fail("Role") - } - }), [ - // Or decode the object form - decode.into({ - use perms <- decode.parameter - Moderator(permissions: perms) - }) - |> decode.field("permissions", decode.list(decode.string)), - ]) -} -``` - -### sqlight (SQLite) - -```gleam -import sqlight - -pub fn main() { - use conn <- sqlight.with_connection("mydb.sqlite") - - // Create table - let assert Ok(_) = - sqlight.exec( - "CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE - )", - conn, - ) - - // Insert - let assert Ok(_) = - sqlight.query( - "INSERT INTO users (name, email) VALUES (?, ?)", - conn, - [sqlight.text("Alice"), sqlight.text("alice@example.com")], - dynamic.dynamic, - ) - - // Query - let assert Ok(rows) = - sqlight.query( - "SELECT id, name, email FROM users WHERE name = ?", - conn, - [sqlight.text("Alice")], - decode.into({ - use id <- decode.parameter - use name <- decode.parameter - use email <- decode.parameter - #(id, name, email) - }) - |> decode.field(0, decode.int) - |> decode.field(1, decode.string) - |> decode.field(2, decode.string), - ) - - echo rows -} -``` - -### Publishing to Hex - -```toml -# gleam.toml -name = "my_package" -version = "1.0.0" -description = "A useful Gleam package" -licences = ["Apache-2.0"] - -[repository] -type = "github" -user = "myuser" -repo = "my_package" -``` - -```bash -# Build and publish -gleam publish - -# Retire a version (if you published something broken) -gleam hex retire my_package 1.0.0 security "Use 1.0.1 instead" -``` - ---- - -## 5. Interop - -### Calling Erlang from Gleam (FFI) - -Create an Erlang file alongside your Gleam module: - -```gleam -// src/app/crypto_utils.gleam - -// Declare external Erlang functions -@external(erlang, "crypto", "hash") -fn erlang_hash(algorithm: atom, data: BitArray) -> BitArray - -// Wrap it in a Gleam-friendly API -import gleam/erlang/atom - -pub fn sha256(data: BitArray) -> BitArray { - let assert Ok(algo) = atom.from_string("sha256") - erlang_hash(algo, data) -} -``` - -For custom Erlang code, create a `.erl` file in `src/`: - -```erlang -%% src/my_ffi.erl --module(my_ffi). --export([system_time/0, format_timestamp/1]). - -system_time() -> - erlang:system_time(millisecond). - -format_timestamp(Ms) -> - calendar:system_time_to_rfc3339(Ms div 1000, [{unit, second}]). -``` - -```gleam -// src/app/time.gleam - -@external(erlang, "my_ffi", "system_time") -pub fn system_time_ms() -> Int - -@external(erlang, "my_ffi", "format_timestamp") -pub fn format_timestamp(ms: Int) -> String -``` - -### Calling Elixir from Gleam - -Elixir modules are just Erlang modules with an `Elixir.` prefix: - -```gleam -// Call Elixir's Jason library -@external(erlang, "Elixir.Jason", "encode!") -fn jason_encode(term: dynamic.Dynamic) -> String - -// Call Elixir's Enum module -@external(erlang, "Elixir.Enum", "shuffle") -pub fn shuffle(list: List(a)) -> List(a) -``` - -**Important:** To use Elixir dependencies, add them to your `gleam.toml`: - -```toml -[dependencies] -jason = ">= 1.4.0 and < 2.0.0" # Elixir/Erlang hex packages work -``` - -### JavaScript Target FFI - -For the JavaScript target, create `.mjs` files: - -```javascript -// src/app/dom_ffi.mjs -export function get_element(id) { - const el = document.getElementById(id); - if (el) return new Ok(el); - return new Error(undefined); -} - -export function set_inner_html(element, html) { - element.innerHTML = html; - return undefined; -} -``` - -```gleam -// src/app/dom.gleam - -@external(javascript, "./dom_ffi.mjs", "get_element") -pub fn get_element(id: String) -> Result(Dynamic, Nil) - -@external(javascript, "./dom_ffi.mjs", "set_inner_html") -pub fn set_inner_html(element: Dynamic, html: String) -> Nil -``` - -### Target-Conditional Code - -```gleam -// Provide different implementations per target -@external(erlang, "my_ffi", "monotonic_time") -@external(javascript, "./time_ffi.mjs", "monotonic_time") -pub fn monotonic_time() -> Int -``` - -### Erlang Target vs JavaScript Target - -| Feature | Erlang (BEAM) | JavaScript | -|---------|---------------|------------| -| Concurrency | Full OTP (actors, supervisors) | Single-threaded (async) | -| Int size | Arbitrary precision | 64-bit float | -| FFI target | `.erl` files | `.mjs` files | -| Use cases | Servers, distributed systems | Browser apps, serverless | -| OTP support | ✅ Full | ❌ Not available | -| Package compat | Erlang + Elixir hex packages | npm packages via FFI | -| Build output | BEAM bytecode | JavaScript modules | - ---- - -## 6. Testing - -### gleeunit - -Gleam's standard test runner. Any public function in `test/` ending in `_test` is run as a test. - -```gleam -// test/app_test.gleam -import gleeunit -import gleeunit/should -import app/user - -pub fn main() { - gleeunit.main() -} - -pub fn parse_age_valid_test() { - user.parse_age("25") - |> should.be_ok - |> should.equal(25) -} - -pub fn parse_age_negative_test() { - user.parse_age("-1") - |> should.be_error - |> should.equal("Age out of range") -} - -pub fn parse_age_not_number_test() { - user.parse_age("abc") - |> should.be_error - |> should.equal("Not a number") -} -``` - -```bash -gleam test -``` - -### Custom Assertions - -```gleam -// test/test_helpers.gleam -import gleam/string - -pub fn should_contain(haystack: String, needle: String) -> String { - case string.contains(haystack, needle) { - True -> haystack - False -> - panic as { - "Expected \"" <> haystack <> "\" to contain \"" <> needle <> "\"" - } - } -} - -pub fn should_be_between(value: Int, low: Int, high: Int) -> Int { - case value >= low && value <= high { - True -> value - False -> - panic as "Expected value to be between bounds" - } -} -``` - -```gleam -// test/string_test.gleam -import test_helpers.{should_contain} - -pub fn greeting_test() { - build_greeting("Alice") - |> should_contain("Alice") - |> should_contain("Hello") -} -``` - -### Testing Actors - -```gleam -import gleam/erlang/process - -pub fn counter_increment_test() { - let assert Ok(counter) = counter.start("test") - - counter.increment(counter, by: 5) - counter.increment(counter, by: 3) - - counter.get_count(counter) - |> should.equal(8) -} -``` - -### Testing Wisp Handlers - -```gleam -import wisp/testing - -pub fn health_check_test() { - let req = testing.get("/health", []) - let resp = router.handle_request(req, test_context()) - - resp.status - |> should.equal(200) -} - -pub fn create_user_test() { - let body = "{\"name\": \"Alice\", \"email\": \"alice@test.com\"}" - let req = testing.post_json("/users", [], body) - let resp = router.handle_request(req, test_context()) - - resp.status - |> should.equal(201) -} - -fn test_context() -> Context { - Context(secret_key_base: "test-secret", db: Nil) -} -``` - ---- - -## 7. Project Structure - -### gleam.toml Configuration - -```toml -name = "my_app" -version = "1.0.0" -target = "erlang" # or "javascript" -description = "My Gleam application" -licences = ["Apache-2.0"] - -# OTP application config (for BEAM deployment) -[erlang] -application_start_module = "my_app/application" -extra_applications = ["crypto", "ssl"] # Erlang apps to start - -[repository] -type = "github" -user = "myuser" -repo = "my_app" - -[dependencies] -gleam_stdlib = ">= 0.34.0 and < 2.0.0" -gleam_erlang = ">= 0.25.0 and < 2.0.0" -gleam_otp = ">= 0.10.0 and < 2.0.0" -gleam_http = ">= 3.6.0 and < 4.0.0" -gleam_json = ">= 1.0.0 and < 3.0.0" -wisp = ">= 1.0.0 and < 3.0.0" -mist = ">= 1.0.0 and < 4.0.0" - -[dev-dependencies] -gleeunit = ">= 1.0.0 and < 2.0.0" -``` - -### Module Organization - -``` -my_app/ -├── gleam.toml -├── manifest.toml # Lock file (commit this) -├── src/ -│ ├── my_app.gleam # Entry point (pub fn main) -│ ├── my_app/ -│ │ ├── application.gleam # OTP application (supervisor tree) -│ │ ├── router.gleam # HTTP routing -│ │ ├── web.gleam # Context type, shared web helpers -│ │ ├── internal.gleam # Internal helpers (not public API) -│ │ ├── handlers/ -│ │ │ ├── user_handler.gleam -│ │ │ └── health_handler.gleam -│ │ ├── models/ -│ │ │ ├── user.gleam # User type + encoding/decoding -│ │ │ └── session.gleam -│ │ ├── services/ -│ │ │ ├── auth.gleam # Business logic -│ │ │ └── email.gleam -│ │ └── db/ -│ │ ├── repo.gleam # Database connection + queries -│ │ └── migrations.gleam -│ └── my_ffi.erl # Erlang FFI (if needed) -├── test/ -│ ├── my_app_test.gleam -│ └── my_app/ -│ ├── handlers/ -│ │ └── user_handler_test.gleam -│ └── models/ -│ └── user_test.gleam -└── priv/ - └── static/ # Static files served by wisp - ├── css/ - └── js/ -``` - -### Gleam vs Elixir: When to Use Each - -| Dimension | Gleam | Elixir | -|-----------|-------|--------| -| **Type safety** | ✅ Full static types, no runtime type errors | ❌ Dynamic typing, runtime crashes possible | -| **Ecosystem maturity** | 🟡 Growing rapidly, some gaps | ✅ Mature, battle-tested (Phoenix, Ecto, LiveView) | -| **OTP support** | 🟡 Subset (type-safe actors, supervisors) | ✅ Full OTP (GenServer, GenStage, DynamicSupervisor) | -| **Metaprogramming** | ❌ No macros (by design) | ✅ Powerful macros | -| **Learning curve** | ✅ Small language, few concepts | 🟡 More features to learn | -| **Refactoring** | ✅ Compiler catches everything | 🟡 Tests + dialyzer help, but gaps exist | -| **Web (backend)** | 🟡 Wisp is solid but young | ✅ Phoenix is world-class | -| **Web (frontend)** | ✅ Lustre (Elm-like, universal components) | 🟡 LiveView (server-dependent) | -| **Database** | 🟡 sqlight, gleam_pgo, cake (basic) | ✅ Ecto (migrations, schemas, changesets) | -| **Interop** | ✅ Calls Erlang & Elixir directly | ✅ Calls Erlang directly | -| **JS target** | ✅ Compiles to JavaScript | ❌ BEAM only | - -**Use Gleam when:** -- Type safety is a priority -- You want compile-time guarantees -- Building API services where Wisp's simplicity is sufficient -- Frontend + backend in one language (JavaScript target) -- Starting a new service that doesn't need Ecto/Phoenix ecosystem - -**Use Elixir when:** -- You need the full Phoenix/LiveView stack -- Ecto's database abstractions are important -- You need macros or DSLs -- The library you need only exists in Elixir -- You're extending an existing Elixir codebase - -**Use both:** -- Gleam and Elixir interop seamlessly on the BEAM -- Write type-safe core logic in Gleam, use Elixir for Phoenix/Ecto -- Gradually introduce Gleam into an Elixir project - ---- - -## 8. Real-World Patterns - -### Error Handling with Result Chains - -```gleam -import gleam/result - -pub type AppError { - NotFound(resource: String) - Unauthorized - ValidationError(field: String, message: String) - DatabaseError(detail: String) -} - -// Chain operations that can fail -pub fn update_user_email( - db: Connection, - user_id: Int, - new_email: String, -) -> Result(User, AppError) { - use user <- result.try(find_user(db, user_id)) - use validated_email <- result.try(validate_email(new_email)) - use updated <- result.try(save_user(db, User(..user, email: validated_email))) - Ok(updated) -} - -// Map errors between types -pub fn find_user(db: Connection, id: Int) -> Result(User, AppError) { - db_query(db, id) - |> result.map_error(fn(_) { NotFound("user") }) -} - -// Recover from errors -pub fn get_user_or_guest(db: Connection, id: Int) -> User { - find_user(db, id) - |> result.unwrap(guest_user()) -} - -// Convert AppError to HTTP responses -pub fn error_to_response(error: AppError) -> Response { - case error { - NotFound(resource) -> - wisp.not_found() - |> wisp.string_body(resource <> " not found") - Unauthorized -> - wisp.response(401) - |> wisp.string_body("Unauthorized") - ValidationError(field, message) -> - wisp.unprocessable_entity() - |> wisp.string_body(field <> ": " <> message) - DatabaseError(_) -> - wisp.internal_server_error() - } -} -``` - -### Configuration - -```gleam -import envoy -import gleam/int -import gleam/result - -pub type Config { - Config( - port: Int, - host: String, - database_url: String, - secret_key: String, - log_level: LogLevel, - ) -} - -pub type LogLevel { - Debug - Info - Warn - Error -} - -pub fn load() -> Result(Config, String) { - use port <- result.try( - envoy.get("PORT") - |> result.then(int.parse) - |> result.replace_error("Invalid PORT"), - ) - use host <- result.try( - envoy.get("HOST") - |> result.replace_error("Missing HOST"), - ) - use database_url <- result.try( - envoy.get("DATABASE_URL") - |> result.replace_error("Missing DATABASE_URL"), - ) - use secret_key <- result.try( - envoy.get("SECRET_KEY") - |> result.replace_error("Missing SECRET_KEY"), - ) - let log_level = - envoy.get("LOG_LEVEL") - |> result.unwrap("info") - |> parse_log_level - - Ok(Config(port:, host:, database_url:, secret_key:, log_level:)) -} - -fn parse_log_level(s: String) -> LogLevel { - case s { - "debug" -> Debug - "warn" -> Warn - "error" -> Error - _ -> Info - } -} -``` - -### Logging - -```gleam -import gleam/io -import gleam/erlang - -// Use wisp's built-in logging (wraps Erlang's logger) -import wisp - -pub fn main() { - wisp.configure_logger() - wisp.log_info("Application starting") - wisp.log_warning("Something might be wrong") - wisp.log_error("Something went wrong!") -} - -// Or use Erlang's logger directly via FFI -@external(erlang, "logger", "info") -pub fn log_info(message: String) -> Nil -``` - -### JSON API Design Pattern - -```gleam -import gleam/json -import gleam/dynamic/decode -import gleam/http -import wisp.{type Request, type Response} - -// Consistent API response wrapper -pub fn json_success(data: json.Json, status: Int) -> Response { - json.object([ - #("ok", json.bool(True)), - #("data", data), - ]) - |> json.to_string_tree - |> wisp.json_response(status) -} - -pub fn json_error(message: String, status: Int) -> Response { - json.object([ - #("ok", json.bool(False)), - #("error", json.string(message)), - ]) - |> json.to_string_tree - |> wisp.json_response(status) -} - -// Full CRUD handler example -pub fn handle(req: Request, ctx: Context, id: String) -> Response { - case req.method { - http.Get -> show(ctx, id) - http.Put -> { - use body <- wisp.require_json(req) - update(ctx, id, body) - } - http.Delete -> delete(ctx, id) - _ -> wisp.method_not_allowed([http.Get, http.Put, http.Delete]) - } -} - -fn show(ctx: Context, id: String) -> Response { - case find_item(ctx.db, id) { - Ok(item) -> json_success(encode_item(item), 200) - Error(NotFound(_)) -> json_error("Not found", 404) - Error(_) -> json_error("Internal error", 500) - } -} - -fn update(ctx: Context, id: String, body: Dynamic) -> Response { - let decoder = - decode.into({ - use name <- decode.parameter - use value <- decode.parameter - #(name, value) - }) - |> decode.field("name", decode.string) - |> decode.field("value", decode.int) - - case decode.run(body, decoder) { - Ok(#(name, value)) -> { - case save_item(ctx.db, id, name, value) { - Ok(item) -> json_success(encode_item(item), 200) - Error(e) -> error_to_response(e) - } - } - Error(_) -> json_error("Invalid request body", 422) - } -} -``` - -### Database Access Pattern - -```gleam -import gleam/pgo -import gleam/dynamic/decode - -pub type Repo { - Repo(db: pgo.Connection) -} - -pub fn connect(database_url: String) -> Repo { - let config = - pgo.url_config(database_url) - |> pgo.pool_size(10) - - let db = pgo.connect(config) - Repo(db:) -} - -pub fn find_user_by_email( - repo: Repo, - email: String, -) -> Result(User, AppError) { - let query = "SELECT id, name, email, role FROM users WHERE email = $1" - - let user_decoder = - decode.into({ - use id <- decode.parameter - use name <- decode.parameter - use email <- decode.parameter - use role <- decode.parameter - User(id:, name:, email:, role: parse_role(role)) - }) - |> decode.field(0, decode.int) - |> decode.field(1, decode.string) - |> decode.field(2, decode.string) - |> decode.field(3, decode.string) - - case pgo.execute(query, repo.db, [pgo.text(email)], user_decoder) { - Ok(pgo.Returned(_, [user])) -> Ok(user) - Ok(pgo.Returned(_, [])) -> Error(NotFound("user")) - Error(e) -> Error(DatabaseError(string.inspect(e))) - } -} - -pub fn create_user( - repo: Repo, - name: String, - email: String, - role: String, -) -> Result(User, AppError) { - let query = - "INSERT INTO users (name, email, role) VALUES ($1, $2, $3) - RETURNING id, name, email, role" - - let user_decoder = - decode.into({ - use id <- decode.parameter - use name <- decode.parameter - use email <- decode.parameter - use role <- decode.parameter - User(id:, name:, email:, role: parse_role(role)) - }) - |> decode.field(0, decode.int) - |> decode.field(1, decode.string) - |> decode.field(2, decode.string) - |> decode.field(3, decode.string) - - case pgo.execute(query, repo.db, [pgo.text(name), pgo.text(email), pgo.text(role)], user_decoder) { - Ok(pgo.Returned(_, [user])) -> Ok(user) - Error(e) -> Error(DatabaseError(string.inspect(e))) - } -} - -fn parse_role(s: String) -> Role { - case s { - "admin" -> Admin - "moderator" -> Moderator(permissions: []) - _ -> Member - } -} -``` - ---- - -## Quick Reference - -### Common Imports - -```gleam -import gleam/io // println, debug -import gleam/int // Int operations -import gleam/float // Float operations -import gleam/string // String operations -import gleam/list // List operations -import gleam/dict.{type Dict} // Dict (hash map) -import gleam/option.{type Option, Some, None} -import gleam/result // Result helpers -import gleam/json // JSON encode/decode -import gleam/dynamic/decode // Dynamic value decoding -import gleam/http.{Get, Post, Put, Delete} -import gleam/erlang/process.{type Subject} -import gleam/otp/actor // OTP actors -``` - -### Common Operations Cheat Sheet - -```gleam -// String interpolation (there isn't any — use <>) -"Hello " <> name <> "!" - -// Convert to string -int.to_string(42) -float.to_string(3.14) - -// Parse -int.parse("42") // Ok(42) -float.parse("3.14") // Ok(3.14) - -// List operations -list.map(items, fn(x) { x + 1 }) -list.filter(items, fn(x) { x > 0 }) -list.fold(items, 0, fn(acc, x) { acc + x }) -list.find(items, fn(x) { x.id == target_id }) - -// Dict operations -dict.new() -dict.insert(d, "key", "value") -dict.get(d, "key") // Result(value, Nil) -dict.from_list([#("a", 1), #("b", 2)]) - -// Tuples -let pair = #("hello", 42) -pair.0 // "hello" -pair.1 // 42 - -// Debug print any value -echo some_value - -// Assert (crash if pattern doesn't match — use sparingly) -let assert Ok(value) = might_fail() -``` - ---- - -*Last updated: 2026-03-13. Based on Gleam ~1.x, gleam_otp 1.2.0, wisp 2.2.1, lustre 5.6.0.* diff --git a/k8s/base-image/Dockerfile b/k8s/base-image/Dockerfile index 5d142ab0..dbe3aab0 100644 --- a/k8s/base-image/Dockerfile +++ b/k8s/base-image/Dockerfile @@ -29,16 +29,10 @@ RUN apt-get update \ curl \ && rm -rf /var/lib/apt/lists/* -# Install Gleam compiler -ARG GLEAM_VERSION=1.15.2 -RUN curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v${GLEAM_VERSION}/gleam-v${GLEAM_VERSION}-x86_64-unknown-linux-musl.tar.gz \ - | tar xz -C /usr/local/bin/ - -# Install hex + rebar + mix_gleam archive (matches k8s/Dockerfile exactly) +# Install hex + rebar (matches k8s/Dockerfile exactly) # This step takes ~10 seconds on every deploy, so we bake it in RUN mix local.hex --force \ - && mix local.rebar --force \ - && mix archive.install hex mix_gleam --force + && mix local.rebar --force # Set build ENV ENV MIX_ENV="prod" diff --git a/lib/towerops/ecto_types/mac_address.ex b/lib/towerops/ecto_types/mac_address.ex index 9c9b7f29..c4df5689 100644 --- a/lib/towerops/ecto_types/mac_address.ex +++ b/lib/towerops/ecto_types/mac_address.ex @@ -51,9 +51,6 @@ 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 @@ -79,7 +76,7 @@ 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 via Gleam + # It's a short string, try string parsing cast_from_string(binary) else # It's a binary MAC address (SNMP format) diff --git a/lib/towerops/proto/agent.ex b/lib/towerops/proto/agent.ex index ae0e8721..fcb6d8e1 100644 --- a/lib/towerops/proto/agent.ex +++ b/lib/towerops/proto/agent.ex @@ -74,11 +74,11 @@ defmodule Towerops.Agent.HeartbeatMetadata do defstruct version: "", hostname: "", uptime_seconds: 0 def encode(%__MODULE__{} = m) do - :towerops@proto@encode.encode_heartbeat_metadata({:heartbeat_metadata, m.version, m.hostname, m.uptime_seconds}) + Towerops.Proto.TupleEncode.encode_heartbeat_metadata({:heartbeat_metadata, m.version, m.hostname, m.uptime_seconds}) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_heartbeat_metadata(binary) do + case Towerops.Proto.TupleDecode.decode_heartbeat_metadata(binary) do {:ok, {:heartbeat_metadata, version, hostname, uptime}} -> %__MODULE__{version: version, hostname: hostname, uptime_seconds: uptime} @@ -93,11 +93,11 @@ defmodule Towerops.Agent.HeartbeatResponse do defstruct status: "" def encode(%__MODULE__{} = r) do - :towerops@proto@encode.encode_heartbeat_response({:heartbeat_response, r.status}) + Towerops.Proto.TupleEncode.encode_heartbeat_response({:heartbeat_response, r.status}) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_heartbeat_response(binary) do + case Towerops.Proto.TupleDecode.decode_heartbeat_response(binary) do {:ok, {:heartbeat_response, status}} -> %__MODULE__{status: status} {:error, _} -> %__MODULE__{} end @@ -109,7 +109,8 @@ defmodule Towerops.Agent.SnmpConfig do defstruct enabled: false, version: "", community: "", port: 0, transport: "" def encode(%__MODULE__{} = s) do - :towerops@proto@encode.encode_snmp_config({:snmp_config, s.enabled, s.version, s.community, s.port, s.transport}) + # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength + Towerops.Proto.TupleEncode.encode_snmp_config({:snmp_config, s.enabled, s.version, s.community, s.port, s.transport}) end end @@ -123,11 +124,11 @@ defmodule Towerops.Agent.Sensor do defstruct id: "", type: "", oid: "", divisor: 0.0, unit: "", metadata: %{} def encode(%__MODULE__{} = s) do - :towerops@proto@encode.encode_sensor({:sensor, s.id, s.type, s.oid, s.divisor, s.unit, s.metadata}) + Towerops.Proto.TupleEncode.encode_sensor({:sensor, s.id, s.type, s.oid, s.divisor, s.unit, s.metadata}) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_sensor(binary) do + case Towerops.Proto.TupleDecode.decode_sensor(binary) do {:ok, {:sensor, id, type, oid, divisor, unit, meta}} -> %__MODULE__{ id: id, @@ -149,11 +150,11 @@ defmodule Towerops.Agent.Interface do defstruct id: "", if_index: 0, if_name: "" def encode(%__MODULE__{} = i) do - :towerops@proto@encode.encode_interface({:interface, i.id, i.if_index, i.if_name}) + Towerops.Proto.TupleEncode.encode_interface({:interface, i.id, i.if_index, i.if_name}) end def decode(binary) when is_binary(binary) do - _ = :towerops@proto@decode.decode_device(binary) + _ = Towerops.Proto.TupleDecode.decode_device(binary) %__MODULE__{} end end @@ -191,7 +192,7 @@ defmodule Towerops.Agent.Device do {:interface, i.id, i.if_index, i.if_name} end) - :towerops@proto@encode.encode_device( + Towerops.Proto.TupleEncode.encode_device( {:device, d.id, d.name, d.ip_address, snmp, d.poll_interval_seconds, sensors, interfaces, d.monitoring_enabled, d.check_interval_seconds} ) @@ -205,7 +206,7 @@ defmodule Towerops.Agent.AgentConfig do defstruct version: "", poll_interval_seconds: 0, devices: [], checks: [] def encode(%__MODULE__{} = c) do - checks = Enum.map(c.checks, &Towerops.Proto.Agent.check_to_gleam/1) + checks = Enum.map(c.checks, &Towerops.Proto.Agent.check_to_tuple/1) device_tuples = Enum.map(c.devices, fn %Device{} = d -> @@ -233,11 +234,11 @@ defmodule Towerops.Agent.AgentConfig do end) config = {:agent_config, c.version, c.poll_interval_seconds, device_tuples, checks} - :towerops@proto@encode.encode_agent_config(config) + Towerops.Proto.TupleEncode.encode_agent_config(config) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_agent_config(binary) do + case Towerops.Proto.TupleDecode.decode_agent_config(binary) do {:ok, {:agent_config, version, poll_interval, _devices, _checks}} -> %__MODULE__{version: version, poll_interval_seconds: poll_interval} @@ -252,7 +253,7 @@ defmodule Towerops.Agent.SensorReading do defstruct sensor_id: "", value: 0.0, status: "", timestamp: 0 def encode(%__MODULE__{} = sr) do - :towerops@proto@encode.encode_sensor_reading({:sensor_reading, sr.sensor_id, sr.value, sr.status, sr.timestamp}) + Towerops.Proto.TupleEncode.encode_sensor_reading({:sensor_reading, sr.sensor_id, sr.value, sr.status, sr.timestamp}) end end @@ -269,7 +270,7 @@ defmodule Towerops.Agent.InterfaceStat do timestamp: 0 def encode(%__MODULE__{} = s) do - :towerops@proto@encode.encode_interface_stat( + Towerops.Proto.TupleEncode.encode_interface_stat( {:interface_stat, s.interface_id, s.if_in_octets, s.if_out_octets, s.if_in_errors, s.if_out_errors, s.if_in_discards, s.if_out_discards, s.timestamp} ) @@ -292,7 +293,7 @@ defmodule Towerops.Agent.NeighborDiscovery do timestamp: 0 def encode(%__MODULE__{} = n) do - gleam_tuple = { + tuple_data = { :neighbor_discovery, n.interface_id, n.protocol, @@ -307,7 +308,7 @@ defmodule Towerops.Agent.NeighborDiscovery do n.timestamp } - :towerops@proto@encode.encode_neighbor_discovery(gleam_tuple) + Towerops.Proto.TupleEncode.encode_neighbor_discovery(tuple_data) end end @@ -316,13 +317,13 @@ defmodule Towerops.Agent.MonitoringCheck do defstruct device_id: "", status: "", response_time_ms: 0.0, timestamp: 0 def encode(%__MODULE__{} = mc) do - :towerops@proto@encode.encode_monitoring_check( + Towerops.Proto.TupleEncode.encode_monitoring_check( {:monitoring_check, mc.device_id, mc.status, mc.response_time_ms, mc.timestamp} ) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_monitoring_check(binary) do + case Towerops.Proto.TupleDecode.decode_monitoring_check(binary) do {:ok, {:monitoring_check, device_id, status, response_time_ms, timestamp}} -> {:ok, %__MODULE__{ @@ -333,7 +334,7 @@ defmodule Towerops.Agent.MonitoringCheck do }} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -343,8 +344,8 @@ defmodule Towerops.Agent.Metric do defstruct metric_type: nil def encode(%__MODULE__{metric_type: mt}) do - gleam_metric = Towerops.Proto.Agent.metric_to_gleam(mt) - :towerops@proto@encode.encode_metric(gleam_metric) + metric_tuple = Towerops.Proto.Agent.metric_to_tuple(mt) + Towerops.Proto.TupleEncode.encode_metric(metric_tuple) end end @@ -353,24 +354,24 @@ defmodule Towerops.Agent.MetricBatch do defstruct metrics: [] def encode(%__MODULE__{} = batch) do - gleam_metrics = + metric_tuples = Enum.map(batch.metrics, fn %Towerops.Agent.Metric{metric_type: mt} -> - Towerops.Proto.Agent.metric_to_gleam(mt) + Towerops.Proto.Agent.metric_to_tuple(mt) end) - :towerops@proto@encode.encode_metric_batch({:metric_batch, gleam_metrics}) + Towerops.Proto.TupleEncode.encode_metric_batch({:metric_batch, metric_tuples}) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_metric_batch(binary) do + case Towerops.Proto.TupleDecode.decode_metric_batch(binary) do {:ok, {:metric_batch, metrics}} -> {:ok, %__MODULE__{ - metrics: Enum.map(metrics, &Towerops.Proto.Agent.gleam_metric_to_elixir/1) + metrics: Enum.map(metrics, &Towerops.Proto.Agent.tuple_to_metric/1) }} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -426,13 +427,13 @@ defmodule Towerops.Agent.CheckResult do defstruct check_id: "", status: 0, output: "", response_time_ms: 0.0, timestamp: 0 def encode(%__MODULE__{} = cr) do - :towerops@proto@encode.encode_check_result( + Towerops.Proto.TupleEncode.encode_check_result( {:check_result, cr.check_id, cr.status, cr.output, cr.response_time_ms, cr.timestamp} ) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_check_result(binary) do + case Towerops.Proto.TupleDecode.decode_check_result(binary) do {:ok, {:check_result, check_id, status, output, response_time_ms, timestamp}} -> {:ok, %__MODULE__{ @@ -444,7 +445,7 @@ defmodule Towerops.Agent.CheckResult do }} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -454,8 +455,8 @@ defmodule Towerops.Agent.CheckList do defstruct checks: [] def encode(%__MODULE__{} = cl) do - gleam_checks = Enum.map(cl.checks, &Towerops.Proto.Agent.check_to_gleam/1) - :towerops@proto@encode.encode_check_list({:check_list, gleam_checks}) + check_tuples = Enum.map(cl.checks, &Towerops.Proto.Agent.check_to_tuple/1) + Towerops.Proto.TupleEncode.encode_check_list({:check_list, check_tuples}) end end @@ -475,7 +476,7 @@ defmodule Towerops.Agent.SnmpDevice do transport: "" def encode(%__MODULE__{} = d) do - :towerops@proto@encode.encode_snmp_device( + Towerops.Proto.TupleEncode.encode_snmp_device( {:snmp_device, d.ip, d.community, d.version, d.port, d.v3_security_level, d.v3_username, d.v3_auth_protocol, d.v3_auth_password, d.v3_priv_protocol, d.v3_priv_password, d.transport} ) @@ -487,8 +488,8 @@ defmodule Towerops.Agent.SnmpQuery do defstruct query_type: :GET, oids: [] def encode(%__MODULE__{} = q) do - :towerops@proto@encode.encode_snmp_query( - {:snmp_query, Towerops.Proto.Agent.query_type_atom_to_gleam(q.query_type), q.oids} + Towerops.Proto.TupleEncode.encode_snmp_query( + {:snmp_query, Towerops.Proto.Agent.query_type_to_atom(q.query_type), q.oids} ) end end @@ -513,17 +514,17 @@ defmodule Towerops.Agent.AgentJobList do defstruct jobs: [] def encode(%__MODULE__{} = jl) do - gleam_jobs = Enum.map(jl.jobs, &Towerops.Proto.Agent.job_to_gleam/1) - :towerops@proto@encode.encode_agent_job_list({:agent_job_list, gleam_jobs}) + job_tuples = Enum.map(jl.jobs, &Towerops.Proto.Agent.job_to_tuple/1) + Towerops.Proto.TupleEncode.encode_agent_job_list({:agent_job_list, job_tuples}) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_agent_job_list(binary) do - {:ok, {:agent_job_list, gleam_jobs}} -> - {:ok, %__MODULE__{jobs: Enum.map(gleam_jobs, &Towerops.Proto.Agent.gleam_job_to_elixir/1)}} + case Towerops.Proto.TupleDecode.decode_agent_job_list(binary) do + {:ok, {:agent_job_list, job_tuples}} -> + {:ok, %__MODULE__{jobs: Enum.map(job_tuples, &Towerops.Proto.Agent.tuple_to_job/1)}} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -540,17 +541,17 @@ defmodule Towerops.Agent.AgentJob do mikrotik_commands: [] def encode(%__MODULE__{} = j) do - gleam_job = Towerops.Proto.Agent.job_to_gleam(j) - :towerops@proto@encode.encode_agent_job(gleam_job) + job_tuple = Towerops.Proto.Agent.job_to_tuple(j) + Towerops.Proto.TupleEncode.encode_agent_job(job_tuple) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_agent_job(binary) do - {:ok, gleam_job} -> - {:ok, Towerops.Proto.Agent.gleam_job_to_elixir(gleam_job)} + case Towerops.Proto.TupleDecode.decode_agent_job(binary) do + {:ok, job_tuple} -> + {:ok, Towerops.Proto.Agent.tuple_to_job(job_tuple)} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -629,11 +630,11 @@ defmodule Towerops.Agent.AgentError do defstruct device_id: "", job_id: "", message: "", timestamp: 0 def encode(%__MODULE__{} = e) do - :towerops@proto@encode.encode_agent_error({:agent_error, e.device_id, e.job_id, e.message, e.timestamp}) + Towerops.Proto.TupleEncode.encode_agent_error({:agent_error, e.device_id, e.job_id, e.message, e.timestamp}) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_agent_error(binary) do + case Towerops.Proto.TupleDecode.decode_agent_error(binary) do {:ok, {:agent_error, device_id, job_id, message, timestamp}} -> {:ok, %__MODULE__{ @@ -644,7 +645,7 @@ defmodule Towerops.Agent.AgentError do }} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -654,13 +655,13 @@ defmodule Towerops.Agent.CredentialTestResult do defstruct test_id: "", success: false, error_message: "", system_description: "", timestamp: 0 def encode(%__MODULE__{} = r) do - :towerops@proto@encode.encode_credential_test_result( + Towerops.Proto.TupleEncode.encode_credential_test_result( {:credential_test_result, r.test_id, r.success, r.error_message, r.system_description, r.timestamp} ) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_credential_test_result(binary) do + case Towerops.Proto.TupleDecode.decode_credential_test_result(binary) do {:ok, {:credential_test_result, test_id, success, error_message, system_description, timestamp}} -> {:ok, %__MODULE__{ @@ -672,7 +673,7 @@ defmodule Towerops.Agent.CredentialTestResult do }} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -694,18 +695,18 @@ defmodule Towerops.Agent.MikrotikResult do defstruct device_id: "", job_id: "", sentences: [], error: "", timestamp: 0 def encode(%__MODULE__{} = r) do - gleam_sentences = + sentence_tuples = Enum.map(r.sentences, fn %MikrotikSentence{attributes: attrs} -> {:mikrotik_sentence, attrs} end) - :towerops@proto@encode.encode_mikrotik_result( - {:mikrotik_result, r.device_id, r.job_id, gleam_sentences, r.error, r.timestamp} + Towerops.Proto.TupleEncode.encode_mikrotik_result( + {:mikrotik_result, r.device_id, r.job_id, sentence_tuples, r.error, r.timestamp} ) end def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_mikrotik_result(binary) do + case Towerops.Proto.TupleDecode.decode_mikrotik_result(binary) do {:ok, {:mikrotik_result, device_id, job_id, sentences, error, timestamp}} -> {:ok, %__MODULE__{ @@ -722,7 +723,7 @@ defmodule Towerops.Agent.MikrotikResult do }} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -742,7 +743,7 @@ defmodule Towerops.Agent.LldpTopologyResult do defstruct device_id: "", job_id: "", local_system_name: "", neighbors: [], timestamp: 0 def decode(binary) when is_binary(binary) do - case :towerops@proto@decode.decode_lldp_topology_result(binary) do + case Towerops.Proto.TupleDecode.decode_lldp_topology_result(binary) do {:ok, {:lldp_topology_result, device_id, job_id, local_system_name, neighbors, timestamp}} -> {:ok, %__MODULE__{ @@ -763,7 +764,7 @@ defmodule Towerops.Agent.LldpTopologyResult do }} {:error, reason} -> - {:error, Towerops.Proto.Agent.gleam_error_to_elixir(reason)} + {:error, Towerops.Proto.Agent.decode_error_to_tuple(reason)} end end end @@ -784,24 +785,24 @@ defmodule Towerops.Proto.Agent do alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpQuery - # Convert Gleam DecodeError to Elixir {atom, string} tuple. + # Convert DecodeError to Elixir {atom, string} tuple. # Most error tags pass through unchanged; :wire_error maps to :decode_error. - def gleam_error_to_elixir({:wire_error, msg}), do: {:decode_error, msg} - def gleam_error_to_elixir({tag, msg}) when is_atom(tag) and is_binary(msg), do: {tag, msg} - def gleam_error_to_elixir(other), do: {:decode_error, inspect(other)} + def decode_error_to_tuple({:wire_error, msg}), do: {:decode_error, msg} + def decode_error_to_tuple({tag, msg}) when is_atom(tag) and is_binary(msg), do: {tag, msg} + def decode_error_to_tuple(other), do: {:decode_error, inspect(other)} - # Convert Elixir metric tagged tuple to Gleam metric variant - def metric_to_gleam({:sensor_reading, %SensorReading{} = sr}) do + # Convert Elixir metric tagged tuple to metric tuple + def metric_to_tuple({:sensor_reading, %SensorReading{} = sr}) do {:sensor_reading_metric, {:sensor_reading, sr.sensor_id, sr.value, sr.status, sr.timestamp}} end - def metric_to_gleam({:interface_stat, %InterfaceStat{} = is}) do + def metric_to_tuple({:interface_stat, %InterfaceStat{} = is}) do {:interface_stat_metric, {:interface_stat, is.interface_id, is.if_in_octets, is.if_out_octets, is.if_in_errors, is.if_out_errors, is.if_in_discards, is.if_out_discards, is.timestamp}} end - def metric_to_gleam({:neighbor_discovery, %NeighborDiscovery{} = nd}) do + def metric_to_tuple({:neighbor_discovery, %NeighborDiscovery{} = nd}) do tuple = { :neighbor_discovery, nd.interface_id, @@ -820,17 +821,17 @@ defmodule Towerops.Proto.Agent do {:neighbor_discovery_metric, tuple} end - def metric_to_gleam({:monitoring_check, %MonitoringCheck{} = mc}) do + def metric_to_tuple({:monitoring_check, %MonitoringCheck{} = mc}) do {:monitoring_check_metric, {:monitoring_check, mc.device_id, mc.status, mc.response_time_ms, mc.timestamp}} end - def metric_to_gleam({:check_result, %CheckResult{} = cr}) do + def metric_to_tuple({:check_result, %CheckResult{} = cr}) do {:check_result_metric, {:check_result, cr.check_id, cr.status, cr.output, cr.response_time_ms, cr.timestamp}} end - # Convert Gleam metric variant to Elixir metric struct - def gleam_metric_to_elixir(gleam_metric) do - case gleam_metric do + # Convert metric tuple to Elixir metric struct + def tuple_to_metric(metric_tuple) do + case metric_tuple do {:sensor_reading_metric, {:sensor_reading, sensor_id, value, status, timestamp}} -> %Metric{ metric_type: @@ -909,7 +910,7 @@ defmodule Towerops.Proto.Agent do end # Job type conversions - def job_type_atom_to_gleam(atom) when is_atom(atom) do + def job_type_to_atom(atom) when is_atom(atom) do case atom do :DISCOVER -> :discover :POLL -> :poll @@ -921,8 +922,8 @@ defmodule Towerops.Proto.Agent do end end - def gleam_job_type_to_atom(gleam_jt) do - case gleam_jt do + def atom_to_job_type(job_type_atom) do + case job_type_atom do :discover -> :DISCOVER :poll -> :POLL :mikrotik -> :MIKROTIK @@ -933,7 +934,7 @@ defmodule Towerops.Proto.Agent do end end - def query_type_atom_to_gleam(atom) do + def query_type_to_atom(atom) do case atom do :GET -> :get :WALK -> :walk @@ -941,30 +942,28 @@ defmodule Towerops.Proto.Agent do end end - def gleam_query_type_to_atom(gleam_qt) do - case gleam_qt do + def atom_to_query_type(query_type_atom) do + case query_type_atom do :get -> :GET :walk -> :WALK other -> other end end - # Convert Gleam AgentJob tuple to Elixir struct - def gleam_job_to_elixir( - {:agent_job, job_id, job_type, device_id, snmp_device, queries, mikrotik_device, mikrotik_commands} - ) do + # Convert AgentJob tuple to Elixir struct + def tuple_to_job({:agent_job, job_id, job_type, device_id, snmp_device, queries, mikrotik_device, mikrotik_commands}) do %AgentJob{ job_id: job_id, - job_type: gleam_job_type_to_atom(job_type), + job_type: atom_to_job_type(job_type), device_id: device_id, - snmp_device: gleam_optional_snmp_device(snmp_device), - queries: Enum.map(queries, &gleam_query_to_elixir/1), - mikrotik_device: gleam_optional_mikrotik_device(mikrotik_device), - mikrotik_commands: Enum.map(mikrotik_commands, &gleam_mikrotik_command_to_elixir/1) + snmp_device: tuple_optional_snmp_device(snmp_device), + queries: Enum.map(queries, &tuple_to_query/1), + mikrotik_device: tuple_optional_mikrotik_device(mikrotik_device), + mikrotik_commands: Enum.map(mikrotik_commands, &tuple_to_mikrotik_command/1) } end - defp gleam_optional_snmp_device( + defp tuple_optional_snmp_device( {:some, {:snmp_device, ip, community, version, port, v3sl, v3u, v3ap, v3apw, v3pp, v3ppw, transport}} ) do %SnmpDevice{ @@ -982,13 +981,13 @@ defmodule Towerops.Proto.Agent do } end - defp gleam_optional_snmp_device(:none), do: nil + defp tuple_optional_snmp_device(:none), do: nil - defp gleam_query_to_elixir({:snmp_query, qt, oids}) do - %SnmpQuery{query_type: gleam_query_type_to_atom(qt), oids: oids} + defp tuple_to_query({:snmp_query, qt, oids}) do + %SnmpQuery{query_type: atom_to_query_type(qt), oids: oids} end - defp gleam_optional_mikrotik_device({:some, {:mikrotik_device, ip, port, username, password, use_ssl, ssh_port}}) do + defp tuple_optional_mikrotik_device({:some, {:mikrotik_device, ip, port, username, password, use_ssl, ssh_port}}) do %MikrotikDevice{ ip: ip, port: port, @@ -999,14 +998,14 @@ defmodule Towerops.Proto.Agent do } end - defp gleam_optional_mikrotik_device(:none), do: nil + defp tuple_optional_mikrotik_device(:none), do: nil - defp gleam_mikrotik_command_to_elixir({:mikrotik_command, command, args}) do + defp tuple_to_mikrotik_command({:mikrotik_command, command, args}) do %MikrotikCommand{command: command, args: args} end - # Convert Elixir AgentJob struct to Gleam tuple - def job_to_gleam(%AgentJob{} = j) do + # Convert Elixir AgentJob struct to tuple + def job_to_tuple(%AgentJob{} = j) do snmp_device = case j.snmp_device do nil -> @@ -1020,7 +1019,7 @@ defmodule Towerops.Proto.Agent do queries = Enum.map(j.queries, fn %SnmpQuery{} = q -> - {:snmp_query, query_type_atom_to_gleam(q.query_type), q.oids} + {:snmp_query, query_type_to_atom(q.query_type), q.oids} end) mikrotik_device = @@ -1037,12 +1036,12 @@ defmodule Towerops.Proto.Agent do {:mikrotik_command, mc.command, mc.args} end) - {:agent_job, j.job_id, job_type_atom_to_gleam(j.job_type), j.device_id, snmp_device, queries, mikrotik_device, + {:agent_job, j.job_id, job_type_to_atom(j.job_type), j.device_id, snmp_device, queries, mikrotik_device, mikrotik_commands} end - # Convert Elixir Check struct to Gleam tuple - def check_to_gleam(%Towerops.Agent.Check{} = c) do + # Convert Elixir Check struct to tuple + def check_to_tuple(%Towerops.Agent.Check{} = c) do config = cond do c.http != nil -> diff --git a/lib/towerops/proto/erlang_compat_decode.ex b/lib/towerops/proto/tuple_decode.ex similarity index 87% rename from lib/towerops/proto/erlang_compat_decode.ex rename to lib/towerops/proto/tuple_decode.ex index 09b8598b..7ca7786f 100644 --- a/lib/towerops/proto/erlang_compat_decode.ex +++ b/lib/towerops/proto/tuple_decode.ex @@ -1,7 +1,7 @@ -defmodule :towerops@proto@decode do +defmodule Towerops.Proto.TupleDecode do @moduledoc """ - Erlang compatibility wrapper for decoding protobuf messages. - Converts Elixir structs to Gleam-style tuples for backward compatibility. + Compatibility wrapper for decoding protobuf messages. + Converts Elixir structs to tagged tuples for backward compatibility. """ alias Towerops.Agent.MikrotikCommand @@ -81,13 +81,13 @@ defmodule :towerops@proto@decode do def decode_mikrotik_result(binary) when is_binary(binary) do case Decode.decode_mikrotik_result(binary) do {:ok, %Types.MikrotikResult{} = mr} -> - # Convert MikrotikSentence structs to Gleam tuples - gleam_sentences = + # Convert MikrotikSentence structs to tuples + tuple_sentences = Enum.map(mr.sentences, fn %Types.MikrotikSentence{attributes: attrs} -> {:mikrotik_sentence, attrs} end) - {:ok, {:mikrotik_result, mr.device_id, mr.job_id, gleam_sentences, mr.error, mr.timestamp}} + {:ok, {:mikrotik_result, mr.device_id, mr.job_id, tuple_sentences, mr.error, mr.timestamp}} {:error, reason} -> {:error, reason} @@ -121,26 +121,26 @@ defmodule :towerops@proto@decode do def decode_metric_batch(binary) when is_binary(binary) do case Decode.decode_metric_batch(binary) do {:ok, %Types.MetricBatch{metrics: metrics}} -> - # Convert metrics to Gleam-style tuples - gleam_metrics = Enum.map(metrics, &metric_to_gleam/1) - {:ok, {:metric_batch, gleam_metrics}} + # Convert metrics to tagged tuples + tuple_metrics = Enum.map(metrics, &metric_to_tuple/1) + {:ok, {:metric_batch, tuple_metrics}} {:error, reason} -> {:error, reason} end end - defp metric_to_gleam({:sensor_reading, %Types.SensorReading{} = sr}) do + defp metric_to_tuple({:sensor_reading, %Types.SensorReading{} = sr}) do {:sensor_reading_metric, {:sensor_reading, sr.sensor_id, sr.value, sr.status, sr.timestamp}} end - defp metric_to_gleam({:interface_stat, %Types.InterfaceStat{} = is}) do + defp metric_to_tuple({:interface_stat, %Types.InterfaceStat{} = is}) do {:interface_stat_metric, {:interface_stat, is.interface_id, is.if_in_octets, is.if_out_octets, is.if_in_errors, is.if_out_errors, is.if_in_discards, is.if_out_discards, is.timestamp}} end - defp metric_to_gleam({:neighbor_discovery, %Types.NeighborDiscovery{} = nd}) do + defp metric_to_tuple({:neighbor_discovery, %Types.NeighborDiscovery{} = nd}) do { :neighbor_discovery_metric, { @@ -177,7 +177,7 @@ defmodule :towerops@proto@decode do def decode_agent_config(binary) when is_binary(binary) do case Decode.decode_agent_config(binary) do {:ok, %Types.AgentConfig{} = ac} -> - # Convert devices and checks to Gleam tuples (not implemented yet, return empty lists) + # Convert devices and checks to tuples (not implemented yet, return empty lists) {:ok, {:agent_config, ac.version, ac.poll_interval_seconds, [], []}} {:error, reason} -> @@ -189,7 +189,7 @@ defmodule :towerops@proto@decode do case Decode.decode_agent_job(binary) do {:ok, %Types.AgentJob{} = job} -> agent_job = types_job_to_agent_job(job) - {:ok, Towerops.Proto.Agent.job_to_gleam(agent_job)} + {:ok, Towerops.Proto.Agent.job_to_tuple(agent_job)} {:error, reason} -> {:error, reason} @@ -199,10 +199,10 @@ defmodule :towerops@proto@decode do def decode_agent_job_list(binary) when is_binary(binary) do case Decode.decode_agent_job_list(binary) do {:ok, %Types.AgentJobList{jobs: jobs}} -> - # Convert Types.AgentJob to Agent.AgentJob, then to Gleam tuples + # Convert Types.AgentJob to Agent.AgentJob, then to tuples agent_jobs = Enum.map(jobs, &types_job_to_agent_job/1) - gleam_jobs = Enum.map(agent_jobs, &Towerops.Proto.Agent.job_to_gleam/1) - {:ok, {:agent_job_list, gleam_jobs}} + tuple_jobs = Enum.map(agent_jobs, &Towerops.Proto.Agent.job_to_tuple/1) + {:ok, {:agent_job_list, tuple_jobs}} {:error, reason} -> {:error, reason} @@ -266,13 +266,13 @@ defmodule :towerops@proto@decode do def decode_lldp_topology_result(binary) when is_binary(binary) do case Decode.decode_lldp_topology_result(binary) do {:ok, %Types.LldpTopologyResult{} = ltr} -> - # Convert LldpNeighbor structs to Gleam tuples - gleam_neighbors = + # Convert LldpNeighbor structs to tuples + tuple_neighbors = Enum.map(ltr.neighbors, fn %Types.LldpNeighbor{} = n -> {:lldp_neighbor, n.neighbor_name, n.local_port, n.remote_port, n.remote_port_id, n.management_addresses} end) - {:ok, {:lldp_topology_result, ltr.device_id, ltr.job_id, ltr.local_system_name, gleam_neighbors, ltr.timestamp}} + {:ok, {:lldp_topology_result, ltr.device_id, ltr.job_id, ltr.local_system_name, tuple_neighbors, ltr.timestamp}} {:error, reason} -> {:error, reason} diff --git a/lib/towerops/proto/erlang_compat_encode.ex b/lib/towerops/proto/tuple_encode.ex similarity index 93% rename from lib/towerops/proto/erlang_compat_encode.ex rename to lib/towerops/proto/tuple_encode.ex index 8bd5dc0d..cfd63291 100644 --- a/lib/towerops/proto/erlang_compat_encode.ex +++ b/lib/towerops/proto/tuple_encode.ex @@ -1,7 +1,7 @@ -defmodule :towerops@proto@encode do +defmodule Towerops.Proto.TupleEncode do @moduledoc """ - Erlang compatibility wrapper for encoding protobuf messages. - Accepts Gleam-style tuples and converts them to Elixir structs for encoding. + Compatibility wrapper for encoding protobuf messages. + Accepts tagged tuples and converts them to Elixir structs for encoding. """ alias Towerops.Proto.Encode @@ -102,8 +102,8 @@ defmodule :towerops@proto@encode do # AgentConfig def encode_agent_config({:agent_config, version, poll_interval, devices, checks}) do - device_structs = Enum.map(devices, &gleam_device_to_types/1) - check_structs = Enum.map(checks, &gleam_check_to_types/1) + device_structs = Enum.map(devices, &tuple_device_to_types/1) + check_structs = Enum.map(checks, &tuple_check_to_types/1) Encode.encode_agent_config(%Types.AgentConfig{ version: version, @@ -171,9 +171,9 @@ defmodule :towerops@proto@encode do end # Metric - def encode_metric(gleam_metric) do + def encode_metric(tuple_metric) do metric_struct = - case gleam_metric do + case tuple_metric do {:sensor_reading_metric, {:sensor_reading, sensor_id, value, status, timestamp}} -> {:sensor_reading, %Types.SensorReading{ @@ -240,8 +240,8 @@ defmodule :towerops@proto@encode do end # MetricBatch - def encode_metric_batch({:metric_batch, gleam_metrics}) do - metric_structs = Enum.map(gleam_metrics, &gleam_metric_to_types/1) + def encode_metric_batch({:metric_batch, tuple_metrics}) do + metric_structs = Enum.map(tuple_metrics, &tuple_metric_to_types/1) Encode.encode_metric_batch(%Types.MetricBatch{metrics: metric_structs}) end @@ -257,8 +257,8 @@ defmodule :towerops@proto@encode do end # CheckList - def encode_check_list({:check_list, gleam_checks}) do - check_structs = Enum.map(gleam_checks, &gleam_check_to_types/1) + def encode_check_list({:check_list, tuple_checks}) do + check_structs = Enum.map(tuple_checks, &tuple_check_to_types/1) Encode.encode_check_list(%Types.CheckList{checks: check_structs}) end @@ -291,14 +291,14 @@ defmodule :towerops@proto@encode do end # AgentJobList - def encode_agent_job_list({:agent_job_list, gleam_jobs}) do - job_structs = Enum.map(gleam_jobs, &gleam_job_to_types/1) + def encode_agent_job_list({:agent_job_list, tuple_jobs}) do + job_structs = Enum.map(tuple_jobs, &tuple_job_to_types/1) Encode.encode_agent_job_list(%Types.AgentJobList{jobs: job_structs}) end # AgentJob - def encode_agent_job(gleam_job) do - job_struct = gleam_job_to_types(gleam_job) + def encode_agent_job(tuple_job) do + job_struct = tuple_job_to_types(tuple_job) Encode.encode_agent_job(job_struct) end @@ -343,8 +343,8 @@ defmodule :towerops@proto@encode do # Helper functions for complex conversions - defp gleam_metric_to_types(gleam_metric) do - case gleam_metric do + defp tuple_metric_to_types(tuple_metric) do + case tuple_metric do {:sensor_reading_metric, {:sensor_reading, sensor_id, value, status, timestamp}} -> {:sensor_reading, %Types.SensorReading{ @@ -389,7 +389,7 @@ defmodule :towerops@proto@encode do end end - defp gleam_check_to_types({:check, id, check_type, interval_seconds, timeout_ms, config}) do + defp tuple_check_to_types({:check, id, check_type, interval_seconds, timeout_ms, config}) do config_struct = case config do {:http_config, @@ -434,12 +434,12 @@ defmodule :towerops@proto@encode do } end - defp gleam_device_to_types(gleam_device) do + defp tuple_device_to_types(tuple_device) do # Stub - implement if needed - gleam_device + tuple_device end - defp gleam_job_to_types( + defp tuple_job_to_types( {:agent_job, job_id, job_type, device_id, snmp_device, queries, mikrotik_device, mikrotik_commands} ) do snmp_device_struct = diff --git a/lib/towerops/snmp/sensor_change_detector.ex b/lib/towerops/snmp/sensor_change_detector.ex index fd9b052d..90ddee61 100644 --- a/lib/towerops/snmp/sensor_change_detector.ex +++ b/lib/towerops/snmp/sensor_change_detector.ex @@ -6,7 +6,7 @@ defmodule Towerops.Snmp.SensorChangeDetector do 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. + as helper functions. PubSub broadcasting, Repo queries, and event building stay here. """ alias Towerops.Snmp.Sensor diff --git a/lib/towerops_web/changelog_parser.ex b/lib/towerops_web/changelog_parser.ex index bb6e2587..cbc884c2 100644 --- a/lib/towerops_web/changelog_parser.ex +++ b/lib/towerops_web/changelog_parser.ex @@ -25,7 +25,7 @@ defmodule ToweropsWeb.ChangelogParser do @doc """ Parse changelog content string into a list of entries. - Returns tuples in Gleam format: {:changelog_entry, date, title_opt, items} + Returns internal tuples: {:changelog_entry, date, title_opt, items} """ def parse_content(content) when is_binary(content) do content diff --git a/nix/shell.nix b/nix/shell.nix index 559fb2ba..cfc1e717 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -3,9 +3,8 @@ stdenv, mkShell, writeShellScriptBin, - # Elixir/Erlang/Gleam + # Elixir/Erlang elixir, - gleam, # Databases and caches postgresql_16, redis, @@ -217,9 +216,8 @@ mkShell { # Development tools buildInputs = [ - # Elixir/Erlang/Gleam + # Elixir/Erlang elixir - gleam # Databases (PostgreSQL with TimescaleDB) pg diff --git a/test/towerops/proto/agent_test.exs b/test/towerops/proto/agent_test.exs index 0c414358..ffda4fcc 100644 --- a/test/towerops/proto/agent_test.exs +++ b/test/towerops/proto/agent_test.exs @@ -3,7 +3,7 @@ defmodule Towerops.Agent.ProtoTest do Tests for Protocol Buffer generated modules. These tests verify that encoding and decoding works correctly for all - protobuf message types used in agent communication. The Gleam-based + protobuf message types used in agent communication. The implementation uses {:ok, struct} tuples for decode where available, and some modules are encode-only. """ diff --git a/test/towerops_web/gleam_changelog_parser_test.exs b/test/towerops_web/changelog_parser_test.exs similarity index 98% rename from test/towerops_web/gleam_changelog_parser_test.exs rename to test/towerops_web/changelog_parser_test.exs index a4921c13..7b0af69b 100644 --- a/test/towerops_web/gleam_changelog_parser_test.exs +++ b/test/towerops_web/changelog_parser_test.exs @@ -1,4 +1,4 @@ -defmodule ToweropsWeb.GleamChangelogParserTest do +defmodule ToweropsWeb.ChangelogParserTest do use ExUnit.Case, async: true alias ToweropsWeb.ChangelogParser