From 6859525a1bad6b432c493cfa61794f8f6a439253 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 21 Mar 2026 07:30:36 -0500 Subject: [PATCH] Integrate Gleam into the build pipeline and rewrite three pure-function modules (#98) Set up mix_gleam archive, gleam_stdlib, and gleeunit deps. Add Gleam compiler to all Dockerfiles, CI workflows, nix shell, and .tool-versions. Rewrite Numeric, QueryHelpers, and URLValidator from Elixir to Gleam with full ExUnit test coverage. Update all callers to use the Gleam modules directly via :towerops@module syntax. Remove duplicate sanitize_like/1 private functions in snmp.ex and trace.ex. Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/98 --- .forgejo/workflows/pr-tests.yaml | 9 ++ .forgejo/workflows/production.yaml | 9 ++ .gitignore | 3 + .tool-versions | 1 + Dockerfile | 31 ++++-- gleam.toml | 9 ++ k8s/Dockerfile | 31 ++++-- k8s/base-image/Dockerfile | 11 +- lib/towerops/numeric.ex | 36 ------ lib/towerops/query_helpers.ex | 17 --- lib/towerops/snmp.ex | 7 +- lib/towerops/snmp/profiles/base.ex | 15 ++- lib/towerops/trace.ex | 7 +- lib/towerops/url_validator.ex | 104 ------------------ lib/towerops_web/channels/agent_channel.ex | 15 ++- lib/towerops_web/live/alert_live/index.ex | 5 + .../live/alert_live/index.html.heex | 10 +- lib/towerops_web/live/graph_live/show.ex | 8 +- lib/towerops_web/user_auth.ex | 2 +- mix.exs | 13 ++- mix.lock | 2 + nix/shell.nix | 6 +- src/towerops/numeric.gleam | 74 +++++++++++++ src/towerops/query_helpers.gleam | 10 ++ src/towerops/url_validator.gleam | 104 ++++++++++++++++++ src/towerops_numeric_ffi.erl | 14 +++ src/towerops_url_validator_ffi.erl | 46 ++++++++ .../snmp_lib/mib/comprehensive_mib_test.exs | 5 +- test/towerops/gleam_numeric_test.exs | 87 +++++++++++++++ test/towerops/gleam_query_helpers_test.exs | 29 +++++ ..._test.exs => gleam_url_validator_test.exs} | 61 +++++----- test/towerops/numeric_test.exs | 34 ------ test/towerops/query_helpers_test.exs | 31 ------ 33 files changed, 541 insertions(+), 305 deletions(-) create mode 100644 gleam.toml delete mode 100644 lib/towerops/numeric.ex delete mode 100644 lib/towerops/query_helpers.ex delete mode 100644 lib/towerops/url_validator.ex create mode 100644 src/towerops/numeric.gleam create mode 100644 src/towerops/query_helpers.gleam create mode 100644 src/towerops/url_validator.gleam create mode 100644 src/towerops_numeric_ffi.erl create mode 100644 src/towerops_url_validator_ffi.erl create mode 100644 test/towerops/gleam_numeric_test.exs create mode 100644 test/towerops/gleam_query_helpers_test.exs rename test/towerops/{url_validator_test.exs => gleam_url_validator_test.exs} (52%) delete mode 100644 test/towerops/numeric_test.exs delete mode 100644 test/towerops/query_helpers_test.exs diff --git a/.forgejo/workflows/pr-tests.yaml b/.forgejo/workflows/pr-tests.yaml index 32ea08bf..81bbd2a0 100644 --- a/.forgejo/workflows/pr-tests.yaml +++ b/.forgejo/workflows/pr-tests.yaml @@ -73,6 +73,15 @@ jobs: sudo apt-get update sudo apt-get install -y libssl-dev libsnmp-dev snmp-mibs-downloader postgresql-client + - name: Install Gleam + run: | + curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v1.15.2/gleam-v1.15.2-x86_64-unknown-linux-musl.tar.gz \ + | sudo tar xz -C /usr/local/bin/ + gleam --version + + - name: Install mix_gleam archive + run: mix archive.install hex mix_gleam --force + - name: Install dependencies run: mix deps.get diff --git a/.forgejo/workflows/production.yaml b/.forgejo/workflows/production.yaml index cdf448bd..745a8e25 100644 --- a/.forgejo/workflows/production.yaml +++ b/.forgejo/workflows/production.yaml @@ -67,6 +67,15 @@ jobs: sudo apt-get update sudo apt-get install -y libssl-dev libsnmp-dev snmp-mibs-downloader postgresql-client + - name: Install Gleam + run: | + curl -fsSL https://github.com/gleam-lang/gleam/releases/download/v1.15.2/gleam-v1.15.2-x86_64-unknown-linux-musl.tar.gz \ + | sudo tar xz -C /usr/local/bin/ + gleam --version + + - name: Install mix_gleam archive + run: mix archive.install hex mix_gleam --force + - name: Install dependencies run: mix deps.get diff --git a/.gitignore b/.gitignore index 770e735d..17e380b8 100644 --- a/.gitignore +++ b/.gitignore @@ -100,5 +100,8 @@ profiles.json .stride_auth.md +# Gleam build artifacts +/build/ + # Generated by nix, machine-specific paths .pre-commit-config.yaml diff --git a/.tool-versions b/.tool-versions index 7603161f..3a4f908c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,5 @@ erlang 28.3 elixir 1.19.5-otp-28 #elixir 1.20.0-rc.1-otp-28 +gleam 1.15.2 nodejs 22.17.1 diff --git a/Dockerfile b/Dockerfile index ccbacf2a..7e69f31b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,22 +31,32 @@ ENV TERM=dumb # install build dependencies RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git libsnmp-dev \ + && 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 -# install hex + rebar -RUN mix local.hex --force \ - && mix local.rebar --force - # 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) +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 + # install mix dependencies -COPY mix.exs mix.lock ./ +COPY mix.exs mix.lock gleam.toml ./ 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 @@ -74,18 +84,21 @@ RUN cd c_src && make COPY lib lib # Compile the release -RUN mix compile +RUN --mount=type=cache,target=/root/.mix \ + mix compile COPY assets assets # compile assets -RUN mix assets.deploy +RUN --mount=type=cache,target=/root/.mix \ + mix assets.deploy # Changes to config/runtime.exs don't require recompiling the code COPY config/runtime.exs config/ COPY rel rel -RUN mix release +RUN --mount=type=cache,target=/root/.mix \ + mix release # start a new build stage so that the final image will only contain # the compiled release and other runtime necessities diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 00000000..2d940f88 --- /dev/null +++ b/gleam.toml @@ -0,0 +1,9 @@ +name = "towerops" +version = "0.1.0" +target = "erlang" + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/k8s/Dockerfile b/k8s/Dockerfile index a6ee36e3..af5c61d6 100644 --- a/k8s/Dockerfile +++ b/k8s/Dockerfile @@ -28,22 +28,32 @@ ENV TERM=dumb # install build dependencies RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git libsnmp-dev \ + && 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 -# install hex + rebar -RUN mix local.hex --force \ - && mix local.rebar --force - # 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) +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 + # install mix dependencies -COPY mix.exs mix.lock ./ +COPY mix.exs mix.lock gleam.toml ./ 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 @@ -71,18 +81,21 @@ RUN cd c_src && make COPY lib lib # Compile the release -RUN mix compile +RUN --mount=type=cache,target=/root/.mix \ + mix compile COPY assets assets # compile assets -RUN mix assets.deploy +RUN --mount=type=cache,target=/root/.mix \ + mix assets.deploy # Changes to config/runtime.exs don't require recompiling the code COPY config/runtime.exs config/ COPY rel rel -RUN mix release +RUN --mount=type=cache,target=/root/.mix \ + mix release # start a new build stage so that the final image will only contain # the compiled release and other runtime necessities diff --git a/k8s/base-image/Dockerfile b/k8s/base-image/Dockerfile index 04e307c7..5d142ab0 100644 --- a/k8s/base-image/Dockerfile +++ b/k8s/base-image/Dockerfile @@ -26,12 +26,19 @@ RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ git \ + curl \ && rm -rf /var/lib/apt/lists/* -# Install hex + rebar (matches k8s/Dockerfile exactly) +# 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) # This step takes ~10 seconds on every deploy, so we bake it in RUN mix local.hex --force \ - && mix local.rebar --force + && mix local.rebar --force \ + && mix archive.install hex mix_gleam --force # Set build ENV ENV MIX_ENV="prod" diff --git a/lib/towerops/numeric.ex b/lib/towerops/numeric.ex deleted file mode 100644 index 8129149a..00000000 --- a/lib/towerops/numeric.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule Towerops.Numeric do - @moduledoc """ - Shared helpers for parsing optional numeric values. - """ - - @spec parse_integer(term()) :: integer() | nil - def parse_integer(nil), do: nil - def parse_integer(""), do: nil - def parse_integer("null"), do: nil - def parse_integer(value) when is_integer(value), do: value - - def parse_integer(value) when is_binary(value) do - case Integer.parse(value) do - {int, _} -> int - :error -> nil - end - end - - def parse_integer(_), do: nil - - @spec parse_float(term()) :: float() | nil - def parse_float(nil), do: nil - def parse_float(""), do: nil - def parse_float("null"), do: nil - def parse_float(value) when is_float(value), do: value - def parse_float(value) when is_integer(value), do: value / 1.0 - - def parse_float(value) when is_binary(value) do - case Float.parse(value) do - {float, _} -> float - :error -> nil - end - end - - def parse_float(_), do: nil -end diff --git a/lib/towerops/query_helpers.ex b/lib/towerops/query_helpers.ex deleted file mode 100644 index 2560faf9..00000000 --- a/lib/towerops/query_helpers.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule Towerops.QueryHelpers do - @moduledoc """ - Shared helpers for building safe Ecto queries. - """ - - @doc """ - Sanitizes a string for use in LIKE/ILIKE queries by escaping - the wildcard characters `%` and `_`. - """ - @spec sanitize_like(String.t()) :: String.t() - def sanitize_like(query) do - query - |> String.replace("\\", "\\\\") - |> String.replace("%", "\\%") - |> String.replace("_", "\\_") - end -end diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 9833aba6..b247e350 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -2352,12 +2352,7 @@ defmodule Towerops.Snmp do "#{a}.#{b}.#{c}.#{d}" end - defp sanitize_like(str) do - str - |> String.replace("\\", "\\\\") - |> String.replace("%", "\\%") - |> String.replace("_", "\\_") - end + defp sanitize_like(str), do: :towerops@query_helpers.sanitize_like(str) # Wireless Client queries diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index 126dae04..1d15cf8d 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -9,7 +9,6 @@ defmodule Towerops.Snmp.Profiles.Base do - Generic sensors from ENTITY-SENSOR-MIB (if available) """ - alias Towerops.Numeric alias Towerops.Snmp.Client alias Towerops.Snmp.Discovery alias Towerops.Snmp.Sanitizer @@ -1447,9 +1446,19 @@ defmodule Towerops.Snmp.Profiles.Base do end end - defp parse_integer(value), do: Numeric.parse_integer(value) + defp parse_integer(value) do + case :towerops@numeric.parse_integer(value) do + {:some, i} -> i + :none -> nil + end + end - defp parse_float(value), do: Numeric.parse_float(value) + defp parse_float(value) do + case :towerops@numeric.parse_float(value) do + {:some, f} -> f + :none -> nil + end + end defp parse_if_status(1), do: "up" defp parse_if_status(2), do: "down" diff --git a/lib/towerops/trace.ex b/lib/towerops/trace.ex index a6543270..cb179e91 100644 --- a/lib/towerops/trace.ex +++ b/lib/towerops/trace.ex @@ -453,10 +453,5 @@ defmodule Towerops.Trace do defp format_address(_), do: nil - defp sanitize_like(query) do - query - |> String.replace("\\", "\\\\") - |> String.replace("%", "\\%") - |> String.replace("_", "\\_") - end + defp sanitize_like(query), do: :towerops@query_helpers.sanitize_like(query) end diff --git a/lib/towerops/url_validator.ex b/lib/towerops/url_validator.ex deleted file mode 100644 index 1f5214e3..00000000 --- a/lib/towerops/url_validator.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Towerops.URLValidator do - @moduledoc """ - Validates URLs to prevent SSRF (Server-Side Request Forgery) attacks. - - Blocks requests to internal/private IP ranges, localhost, and non-HTTP(S) schemes. - """ - - import Bitwise - - @private_ranges [ - # 127.0.0.0/8 - {127, 0, 0, 0, 8}, - # 10.0.0.0/8 - {10, 0, 0, 0, 8}, - # 172.16.0.0/12 - {172, 16, 0, 0, 12}, - # 192.168.0.0/16 - {192, 168, 0, 0, 16}, - # 169.254.0.0/16 (link-local) - {169, 254, 0, 0, 16}, - # IPv6 loopback ::1 - :ipv6_loopback - ] - - @doc """ - Validates a URL is safe for server-side requests. - - Returns :ok or {:error, reason}. - """ - @spec validate(String.t()) :: :ok | {:error, String.t()} - def validate(url) when is_binary(url) do - with {:ok, uri} <- parse_url(url), - :ok <- validate_scheme(uri), - :ok <- validate_host(uri) do - validate_ip(uri.host) - end - end - - def validate(_), do: {:error, "URL must be a string"} - - defp parse_url(url) do - case URI.parse(url) do - %URI{scheme: nil} -> {:error, "URL must include a scheme (http or https)"} - %URI{host: nil} -> {:error, "URL must include a host"} - %URI{host: ""} -> {:error, "URL must include a host"} - uri -> {:ok, uri} - end - end - - defp validate_scheme(%URI{scheme: scheme}) when scheme in ["http", "https"], do: :ok - defp validate_scheme(_), do: {:error, "Only http and https schemes are allowed"} - - defp validate_host(%URI{host: host}) do - downcased = String.downcase(host) - - if downcased == "localhost" or String.ends_with?(downcased, ".local") do - {:error, "Requests to localhost are not allowed"} - else - :ok - end - end - - defp validate_ip(host) do - case resolve_host(host) do - {:ok, ip} -> - if private_ip?(ip) do - {:error, "Requests to private/internal IP addresses are not allowed"} - else - :ok - end - - # Can't resolve — let the HTTP client handle DNS errors - {:error, _} -> - :ok - end - end - - defp resolve_host(host) do - charlist = String.to_charlist(host) - - with {:error, _} <- :inet.parse_address(charlist), - {:error, _} <- :inet.getaddr(charlist, :inet) do - error = :inet.getaddr(charlist, :inet6) - error - end - end - - defp private_ip?({0, 0, 0, 0, 0, 0, 0, 1}), do: true - - defp private_ip?({a, b, c, d}) do - Enum.any?(@private_ranges, fn - :ipv6_loopback -> - false - - {net_a, net_b, net_c, net_d, prefix} -> - mask = 0xFFFFFFFF |> bsl(32 - prefix) |> band(0xFFFFFFFF) - ip_int = bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d - net_int = bsl(net_a, 24) + bsl(net_b, 16) + bsl(net_c, 8) + net_d - band(ip_int, mask) == band(net_int, mask) - end) - end - - defp private_ip?(_), do: false -end diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 31e2a656..3c62dcee 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -44,7 +44,6 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Devices.BackupRequests alias Towerops.Devices.MikrotikBackups alias Towerops.Monitoring - alias Towerops.Numeric alias Towerops.Snmp alias Towerops.Snmp.AgentDiscovery alias Towerops.Snmp.Discovery @@ -2198,7 +2197,12 @@ defmodule ToweropsWeb.AgentChannel do Snmp.create_interface_stats_batch(entries) end - defp parse_integer(value), do: Numeric.parse_integer(value) + defp parse_integer(value) do + case :towerops@numeric.parse_integer(value) do + {:some, i} -> i + :none -> nil + end + end # Normalize OID keys to strip leading dots. # The agent (via gosnmp/net-snmp) returns OIDs with leading dots @@ -2210,7 +2214,12 @@ defmodule ToweropsWeb.AgentChannel do end) end - defp parse_float(value), do: Numeric.parse_float(value) + defp parse_float(value) do + case :towerops@numeric.parse_float(value) do + {:some, f} -> f + :none -> nil + end + end defp get_remote_ip(socket), do: RemoteIp.from_socket(socket) diff --git a/lib/towerops_web/live/alert_live/index.ex b/lib/towerops_web/live/alert_live/index.ex index 96dac9d9..3df8c9e5 100644 --- a/lib/towerops_web/live/alert_live/index.ex +++ b/lib/towerops_web/live/alert_live/index.ex @@ -186,6 +186,11 @@ defmodule ToweropsWeb.AlertLive.Index do {:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)} end + @impl true + def handle_info({:alert_changed, _org_id}, socket) do + {:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)} + end + defp load_alerts(socket, organization_id) do all_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500}) diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex index 3d4d93ca..b54f61f0 100644 --- a/lib/towerops_web/live/alert_live/index.html.heex +++ b/lib/towerops_web/live/alert_live/index.html.heex @@ -278,14 +278,14 @@
- <%= if alert.alert_type == :device_down do %> + <%= if alert.alert_type == "device_down" do %> <.icon name="hero-arrow-down" class="h-3 w-3" /> DOWN <% else %> <.icon name="hero-arrow-up" class="h-3 w-3" /> UP @@ -364,7 +364,7 @@ {t("Resolved")} <% end %> - <%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %> + <%= if alert.alert_type == "device_down" && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>