dialyzer: replace suppressions with real fixes where possible

Changes to eliminate @dialyzer suppressions by fixing underlying causes:

NIF stubs (towerops_native.ex, mib_translator.ex):
- Change stubs to :erlang.nif_error(:nif_not_loaded) (no_return type).
  Real NIF replaces stubs at load time; calls to unloaded stubs now fail
  loudly instead of returning fake data. Lets dialyzer trust @spec.
- Remove @dialyzer :nowarn_function on three NIFs and on translate/1.

Discovery sync_* functions (snmp/discovery.ex, channels/agent_channel.ex):
- agent_channel passes %{device_id: _, interfaces: _} and %{id: _} maps
  into Discovery.sync_ip_addresses/sync_processors/sync_storage, which
  @spec'd only %Device{}. Add narrow map-type unions (ip_sync_device,
  snmp_device_ref) reflecting what the functions actually access.
- Remove @dialyzer :nowarn_function on three agent_channel helpers.

remote_ip.ex — real bug caught and fixed:
- `:ranch.get_addr(socket.transport_pid)` was always raising since
  Bandit uses ThousandIsland, not Ranch; the rescue _ -> nil silently
  returned nil every time. Switched to Phoenix's documented
  :peer_data connect_info (already enabled in endpoint.ex) via
  socket.assigns; remote IP now actually works.
- Remove remote_ip.ex entry from .dialyzer_ignore.exs.

Accounts / Organizations (Ecto.Multi opacity):
- Add @specs to Multi-building helpers, refactor into pipe chains.
- 6 @dialyzer :nowarn_function → 0, but 7 :no_opaque remain. Root
  cause is upstream: Ecto.Multi.new/0 returns a struct with a literal
  %MapSet{} whose @opaque internal representation trips dialyzer on
  every subsequent Multi.* call. Unfixable without an Ecto patch or
  bypassing Multi entirely. Comments document the specific upstream
  issue rather than a vague "Ecto.Multi opacity" claim.

Devices.ex:
- Adding @specs made it worse (call_without_opaque → contract_with_
  opaque); inlining the Multi didn't help either — same MapSet root
  cause. Suppression kept with a sharper comment.
This commit is contained in:
Graham McIntire 2026-04-21 11:01:20 -05:00
parent 093c24a783
commit 9cb4c59638
11 changed files with 121 additions and 94 deletions

View file

@ -29,11 +29,6 @@
# containing gen_server.ex path is a CI build artifact, not a local dep path. # containing gen_server.ex path is a CI build artifact, not a local dep path.
~r/gen_server\.ex/, ~r/gen_server\.ex/,
# :ranch is a transitive dep (via Bandit/Cowboy); :ranch.get_addr/1 exists
# but ranch isn't in the default PLT graph. Adding :ranch to plt_add_apps
# destabilises the PLT, so suppress the single call site instead.
{"lib/towerops_web/remote_ip.ex", :unknown_function},
# Vendored SnmpKit — out of scope for typing cleanup # Vendored SnmpKit — out of scope for typing cleanup
~r/lib\/snmpkit/, ~r/lib\/snmpkit/,
~r/lib\/snmp_lib/, ~r/lib\/snmp_lib/,

View file

@ -122,18 +122,22 @@ defmodule Towerops.Accounts do
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
# Ecto.Multi uses an opaque type that dialyzer can't see through when # `Ecto.Multi.new/0` returns a `%Multi{names: MapSet.new()}` whose
# composing with local helpers; suppress the false-positive opaque warning. # `names` field dialyzer's success-typing sees as `%MapSet{map: %{}}`.
# That concrete value violates MapSet's `@opaque t` when passed to
# subsequent `Ecto.Multi.*` calls — an upstream typing quirk in Ecto's
# `@spec new :: t` that loses information vs the actual struct literal.
# See elixir-ecto/ecto: `Ecto.Multi` uses `MapSet` internally but exposes
# it in the type, so `:no_opaque` is the correct narrow suppression.
@dialyzer {:no_opaque, [register_user: 1]} @dialyzer {:no_opaque, [register_user: 1]}
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do def register_user(attrs) do
user_changeset = User.registration_changeset(%User{}, attrs) user_changeset = User.registration_changeset(%User{}, attrs)
multi = Ecto.Multi.new() multi =
multi = Ecto.Multi.insert(multi, :user, user_changeset) Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
# Grant consents if provided |> grant_consents_on_registration(attrs)
multi = grant_consents_on_registration(multi, attrs)
case Repo.transaction(multi) do case Repo.transaction(multi) do
{:ok, %{user: user}} -> {:ok, user} {:ok, %{user: user}} -> {:ok, user}
@ -154,16 +158,16 @@ defmodule Towerops.Accounts do
{:error, %Ecto.Changeset{}} {:error, %Ecto.Changeset{}}
""" """
@dialyzer {:nowarn_function, register_user_with_organization: 1} # Same MapSet-opacity issue as register_user/1 — see its comment above.
@dialyzer {:no_opaque, [register_user_with_organization: 1]}
@spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user_with_organization(attrs) do def register_user_with_organization(attrs) do
user_changeset = User.registration_changeset(%User{}, attrs) user_changeset = User.registration_changeset(%User{}, attrs)
multi = Ecto.Multi.new()
multi = Ecto.Multi.insert(multi, :user, user_changeset)
multi = multi =
Ecto.Multi.run(multi, :organization, fn _repo, %{user: user} -> Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
|> Ecto.Multi.run(:organization, fn _repo, %{user: user} ->
org_name = attrs["organization_name"] || "Personal" org_name = attrs["organization_name"] || "Personal"
Towerops.Organizations.create_organization( Towerops.Organizations.create_organization(
@ -171,9 +175,7 @@ defmodule Towerops.Accounts do
user.id user.id
) )
end) end)
|> grant_consents_on_registration(attrs)
# Grant consents if provided
multi = grant_consents_on_registration(multi, attrs)
case Repo.transaction(multi) do case Repo.transaction(multi) do
{:ok, %{user: user}} -> {:ok, user} {:ok, %{user: user}} -> {:ok, user}
@ -183,29 +185,26 @@ defmodule Towerops.Accounts do
end end
end end
@spec grant_consents_on_registration(Ecto.Multi.t(), map()) :: Ecto.Multi.t()
defp grant_consents_on_registration(multi, attrs) do defp grant_consents_on_registration(multi, attrs) do
# Only grant consents if checkboxes were checked # Only grant consents if checkboxes were checked
privacy_consent = attrs["privacy_policy_consent"] || attrs[:privacy_policy_consent] privacy_consent = attrs["privacy_policy_consent"] || attrs[:privacy_policy_consent]
terms_consent = attrs["terms_of_service_consent"] || attrs[:terms_of_service_consent] terms_consent = attrs["terms_of_service_consent"] || attrs[:terms_of_service_consent]
multi = multi
if privacy_consent in ["true", true, "on"] do |> maybe_grant_consent(privacy_consent, :privacy_policy_consent, "privacy_policy")
Ecto.Multi.run(multi, :privacy_policy_consent, fn _repo, %{user: user} -> |> maybe_grant_consent(terms_consent, :terms_of_service_consent, "terms_of_service")
grant_consent(user.id, "privacy_policy")
end)
else
multi
end
if terms_consent in ["true", true, "on"] do
Ecto.Multi.run(multi, :terms_of_service_consent, fn _repo, %{user: user} ->
grant_consent(user.id, "terms_of_service")
end)
else
multi
end
end end
@spec maybe_grant_consent(Ecto.Multi.t(), term(), atom(), String.t()) :: Ecto.Multi.t()
defp maybe_grant_consent(multi, flag, step_name, consent_type) when flag in ["true", true, "on"] do
Ecto.Multi.run(multi, step_name, fn _repo, %{user: user} ->
grant_consent(user.id, consent_type)
end)
end
defp maybe_grant_consent(multi, _flag, _step_name, _consent_type), do: multi
## TOTP / Two-Factor Authentication ## TOTP / Two-Factor Authentication
@doc """ @doc """
@ -1774,25 +1773,31 @@ defmodule Towerops.Accounts do
@doc """ @doc """
Confirms a user by the given token. Confirms a user by the given token.
""" """
# Ecto.Multi uses an opaque type that dialyzer can't always see through. @spec confirm_user(String.t()) :: {:ok, User.t()} | :error
@dialyzer {:no_opaque, [confirm_user: 1]}
def confirm_user(token) do def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query), %User{} = user <- Repo.one(query),
{:ok, %{user: user}} <- {:ok, %{user: confirmed_user}} <- confirm_user_transaction(user) do
Ecto.Multi.new() {:ok, confirmed_user}
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(
:tokens,
UserToken.by_user_and_contexts_query(user, ["confirm"])
)
|> Repo.transaction() do
{:ok, user}
else else
_ -> :error _ -> :error
end end
end end
# Same MapSet-opacity issue as register_user/1 — see its comment above.
@dialyzer {:no_opaque, [confirm_user_transaction: 1]}
@spec confirm_user_transaction(User.t()) ::
{:ok, map()} | {:error, any(), any(), map()}
defp confirm_user_transaction(user) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(
:tokens,
UserToken.by_user_and_contexts_query(user, ["confirm"])
)
|> Repo.transaction()
end
## Account Deletion (GDPR Right to Erasure) ## Account Deletion (GDPR Right to Erasure)
@doc """ @doc """

View file

@ -19,8 +19,19 @@ defmodule Towerops.Devices do
@device_preloads [:organization, site: :organization] @device_preloads [:organization, site: :organization]
# Ecto.Multi uses opaque types that dialyzer can't see through when composed # Ecto.Multi's struct is declared as an opaque type in Ecto, but
# with local helper functions for locking and quota checks. # Ecto.Multi.new/0 and the Ecto.Multi.{insert,run}/* functions return
# the concrete struct shape (not the opaque Ecto.Multi.t()). Flow analysis
# sees a mismatch between the concrete struct we have in hand and the
# opaque type the subsequent Ecto.Multi.* calls declare in their
# signatures, raising call_without_opaque on every step of the pipeline.
# Passing the Multi through private helpers that take/return
# Ecto.Multi.t() compounds the issue. Adding @spec Ecto.Multi.t() on the
# helpers doesn't help — it just moves the warning to contract_with_opaque.
# Inlining the whole pipeline (even without helpers) still warns on
# `Ecto.Multi.new() |> Ecto.Multi.insert(...)` alone, confirming the
# problem is in Ecto's opaque-type declarations, not our helper shape.
# Suppression is the least-worst option until Ecto loosens the opaque type.
@dialyzer {:no_opaque, @dialyzer {:no_opaque,
[ [
create_device: 1, create_device: 1,

View file

@ -85,11 +85,8 @@ defmodule Towerops.Organizations do
## Options ## Options
* `:bypass_limits` - When true, bypasses free organization limit (for superusers) * `:bypass_limits` - When true, bypasses free organization limit (for superusers)
""" """
@dialyzer {:nowarn_function, create_organization: 2} @spec create_organization(map(), String.t(), Keyword.t()) ::
@dialyzer {:nowarn_function, create_organization: 3} {:ok, Organization.t()} | {:error, Ecto.Changeset.t()}
# Ecto.Multi/Changeset opaqueness trips dialyzer on this helper too.
@dialyzer {:no_opaque, [do_create_organization: 4, set_default_organization: 2]}
@spec create_organization(map(), String.t(), Keyword.t()) :: {:ok, Organization.t()} | {:error, Ecto.Changeset.t()}
def create_organization(attrs, user_id, opts \\ []) do def create_organization(attrs, user_id, opts \\ []) do
bypass_limits = Keyword.get(opts, :bypass_limits, false) bypass_limits = Keyword.get(opts, :bypass_limits, false)
subscription_plan = Map.get(attrs, :subscription_plan) || Map.get(attrs, "subscription_plan") || "free" subscription_plan = Map.get(attrs, :subscription_plan) || Map.get(attrs, "subscription_plan") || "free"
@ -97,6 +94,14 @@ defmodule Towerops.Organizations do
do_create_organization(attrs, user_id, bypass_limits, subscription_plan) do_create_organization(attrs, user_id, bypass_limits, subscription_plan)
end end
# `Ecto.Multi.new/0` returns `%Multi{names: MapSet.new()}`. Dialyzer's
# success-typing sees the concrete `%MapSet{map: %{}}` value, which
# violates MapSet's `@opaque t` when passed to subsequent `Ecto.Multi.*`
# calls. This is an upstream quirk of Ecto exposing MapSet in the struct
# without an opacity-preserving `@spec`.
@dialyzer {:no_opaque, [do_create_organization: 4]}
@spec do_create_organization(map(), String.t(), boolean(), String.t()) ::
{:ok, Organization.t()} | {:error, Ecto.Changeset.t()}
defp do_create_organization(attrs, user_id, bypass_limits, subscription_plan) do defp do_create_organization(attrs, user_id, bypass_limits, subscription_plan) do
multi = multi =
Ecto.Multi.new() Ecto.Multi.new()
@ -346,6 +351,10 @@ defmodule Towerops.Organizations do
Sets an organization as the user's default. Sets an organization as the user's default.
Clears any existing default and sets the new one. Clears any existing default and sets the new one.
""" """
# Same MapSet-opacity issue as do_create_organization/4 above.
@dialyzer {:no_opaque, [set_default_organization: 2]}
@spec set_default_organization(String.t(), String.t()) ::
{:ok, Organization.t()} | {:error, Ecto.Changeset.t() | :not_found}
def set_default_organization(user_id, organization_id) do def set_default_organization(user_id, organization_id) do
# Verify user has access to this organization # Verify user has access to this organization
if user_has_access?(user_id, organization_id) do if user_has_access?(user_id, organization_id) do
@ -443,20 +452,20 @@ defmodule Towerops.Organizations do
@doc """ @doc """
Accepts an invitation and creates a membership. Accepts an invitation and creates a membership.
""" """
@dialyzer {:nowarn_function, accept_invitation: 2} # Same MapSet-opacity issue as do_create_organization/4 above.
@dialyzer {:no_opaque, [accept_invitation: 2]}
@spec accept_invitation(Invitation.t(), String.t()) ::
{:ok, Membership.t()} | {:error, Ecto.Changeset.t()}
def accept_invitation(invitation, user_id) do def accept_invitation(invitation, user_id) do
multi = Ecto.Multi.new()
multi = multi =
Ecto.Multi.update(multi, :invitation, fn _ -> Ecto.Multi.new()
|> Ecto.Multi.update(:invitation, fn _ ->
Ecto.Changeset.change(invitation, %{ Ecto.Changeset.change(invitation, %{
accepted_at: Towerops.Time.now(), accepted_at: Towerops.Time.now(),
accepted_by_id: user_id accepted_by_id: user_id
}) })
end) end)
|> Ecto.Multi.insert(:membership, fn _ ->
multi =
Ecto.Multi.insert(multi, :membership, fn _ ->
Membership.changeset(%Membership{}, %{ Membership.changeset(%Membership{}, %{
organization_id: invitation.organization_id, organization_id: invitation.organization_id,
user_id: user_id, user_id: user_id,

View file

@ -1154,7 +1154,8 @@ defmodule Towerops.Snmp.Discovery do
end) end)
end end
@spec sync_ip_addresses(Device.t(), [map()]) :: :ok @type ip_sync_device :: Device.t() | %{required(:device_id) => String.t(), required(:interfaces) => list()}
@spec sync_ip_addresses(ip_sync_device(), [map()]) :: :ok
@doc """ @doc """
Sync IP addresses from discovery/polling results to database. Sync IP addresses from discovery/polling results to database.
@ -1238,7 +1239,8 @@ defmodule Towerops.Snmp.Discovery do
end end
end end
@spec sync_processors(Device.t(), [map()]) :: :ok @type snmp_device_ref :: Device.t() | %{required(:id) => String.t()}
@spec sync_processors(snmp_device_ref(), [map()]) :: :ok
@doc """ @doc """
Sync processors from discovery/polling results to database. Sync processors from discovery/polling results to database.
""" """
@ -1305,7 +1307,7 @@ defmodule Towerops.Snmp.Discovery do
Logger.error("Failed to upsert processor: #{inspect(changeset)}") Logger.error("Failed to upsert processor: #{inspect(changeset)}")
end end
@spec sync_storage(Device.t(), [map()]) :: :ok @spec sync_storage(snmp_device_ref(), [map()]) :: :ok
@doc """ @doc """
Sync storage from discovery/polling results to database. Sync storage from discovery/polling results to database.
""" """

View file

@ -27,10 +27,6 @@ defmodule Towerops.Snmp.MibTranslator do
iex> MibTranslator.translate("INVALID-MIB::badObject") iex> MibTranslator.translate("INVALID-MIB::badObject")
{:error, :translation_failed} {:error, :translation_failed}
""" """
# The Rust NIF stub returns a narrow type but at runtime the real NIF returns
# either a binary OID on success or {:error, reason} on failure. Dialyzer sees
# only the stub's narrow type, causing a false guard_fail on the is_binary clause.
@dialyzer {:nowarn_function, translate: 1}
@spec translate(String.t()) :: {:ok, String.t()} | {:error, :translation_failed} @spec translate(String.t()) :: {:ok, String.t()} | {:error, :translation_failed}
def translate(mib_name) when is_binary(mib_name) do def translate(mib_name) when is_binary(mib_name) do
cond do cond do

View file

@ -22,10 +22,6 @@ defmodule ToweropsNative do
@on_load :load_nif @on_load :load_nif
# NIF stubs return fallback values when the NIF fails to load (defensive).
# Dialyzer only sees the stub bodies, so tell it to trust the @spec instead.
@dialyzer {:nowarn_function, [resolve_oid: 1, init_mib_library: 0, load_mib_directory: 1]}
def load_nif do def load_nif do
nif_path = :filename.join(:code.priv_dir(:towerops), ~c"towerops_nif") nif_path = :filename.join(:code.priv_dir(:towerops), ~c"towerops_nif")
@ -62,7 +58,7 @@ defmodule ToweropsNative do
{:error, "Failed to resolve MIB name"} {:error, "Failed to resolve MIB name"}
""" """
@spec resolve_oid(String.t()) :: String.t() | {:error, String.t()} @spec resolve_oid(String.t()) :: String.t() | {:error, String.t()}
def resolve_oid(_mib_name), do: {:error, "NIF not loaded"} def resolve_oid(_mib_name), do: :erlang.nif_error(:nif_not_loaded)
@doc """ @doc """
Add a MIB directory to the search path. Add a MIB directory to the search path.
@ -78,7 +74,7 @@ defmodule ToweropsNative do
:ok :ok
""" """
@spec load_mib_directory(Path.t()) :: :ok @spec load_mib_directory(Path.t()) :: :ok
def load_mib_directory(_path), do: :ok def load_mib_directory(_path), do: :erlang.nif_error(:nif_not_loaded)
@doc """ @doc """
Initialize the net-snmp library and load all MIBs into memory. Initialize the net-snmp library and load all MIBs into memory.
@ -101,5 +97,5 @@ defmodule ToweropsNative do
"already_initialized" "already_initialized"
""" """
@spec init_mib_library() :: String.t() @spec init_mib_library() :: String.t()
def init_mib_library, do: "initialized" def init_mib_library, do: :erlang.nif_error(:nif_not_loaded)
end end

View file

@ -62,11 +62,6 @@ defmodule ToweropsWeb.AgentChannel do
@typep base64_string :: String.t() @typep base64_string :: String.t()
@typep protobuf_binary :: binary() @typep protobuf_binary :: binary()
# These helpers pass lightweight maps (not full Device structs) to
# Discovery.sync_* functions; the sync functions only need a few fields
# (`:id`, `:device_id`, `:interfaces`), so this is safe at runtime.
@dialyzer {:nowarn_function, [process_ip_addresses: 3, process_processors: 2, process_storage: 2]}
# Heartbeat timeout: close channel if no heartbeat in 5 minutes # Heartbeat timeout: close channel if no heartbeat in 5 minutes
@heartbeat_timeout_seconds 300 @heartbeat_timeout_seconds 300
# Check heartbeat every 2 minutes # Check heartbeat every 2 minutes

View file

@ -14,9 +14,12 @@ defmodule ToweropsWeb.AgentSocket do
@impl true @impl true
@spec connect(map(), Phoenix.Socket.t(), map()) :: {:ok, Phoenix.Socket.t()} @spec connect(map(), Phoenix.Socket.t(), map()) :: {:ok, Phoenix.Socket.t()}
def connect(_params, socket, _connect_info) do def connect(_params, socket, connect_info) do
# Allow all connections - authentication happens at channel join # Allow all connections - authentication happens at channel join.
{:ok, socket} # Stash peer_data so we can extract the client IP later without
# reaching into the transport process (which is adapter-specific).
peer_data = Map.get(connect_info, :peer_data)
{:ok, Phoenix.Socket.assign(socket, :peer_data, peer_data)}
end end
@impl true @impl true

View file

@ -21,18 +21,13 @@ defmodule ToweropsWeb.RemoteIp do
@spec from_socket(Phoenix.Socket.t()) :: String.t() | nil @spec from_socket(Phoenix.Socket.t()) :: String.t() | nil
def from_socket(socket) do def from_socket(socket) do
case socket.transport_pid do # Use peer_data stashed into socket assigns at connect time via
nil -> # `connect_info: [:peer_data]`. This is the documented Phoenix API
nil # and is adapter-agnostic (works with both Bandit and Cowboy).
case socket.assigns[:peer_data] do
pid when is_pid(pid) -> %{address: ip} when is_tuple(ip) -> format(ip)
case :ranch.get_addr(pid) do _ -> nil
{ip, _port} when is_tuple(ip) -> format(ip)
_ -> nil
end
end end
rescue
_ -> nil
end end
@spec format(tuple() | nil) :: String.t() | nil @spec format(tuple() | nil) :: String.t() | nil

View file

@ -15,4 +15,24 @@ defmodule ToweropsWeb.RemoteIpTest do
test "formats IPv6 addresses in uppercase hex" do test "formats IPv6 addresses in uppercase hex" do
assert RemoteIp.format({8193, 3512, 0, 0, 0, 0, 0, 1}) == "2001:DB8:0:0:0:0:0:1" assert RemoteIp.format({8193, 3512, 0, 0, 0, 0, 0, 1}) == "2001:DB8:0:0:0:0:0:1"
end end
describe "from_socket/1" do
test "returns formatted IP from peer_data in assigns" do
socket = %Phoenix.Socket{
assigns: %{peer_data: %{address: {192, 168, 1, 42}, port: 12_345}}
}
assert RemoteIp.from_socket(socket) == "192.168.1.42"
end
test "returns nil when peer_data is missing" do
socket = %Phoenix.Socket{assigns: %{}}
assert RemoteIp.from_socket(socket) == nil
end
test "returns nil when peer_data has no address tuple" do
socket = %Phoenix.Socket{assigns: %{peer_data: %{}}}
assert RemoteIp.from_socket(socket) == nil
end
end
end end