"""Mix release + TLS dist test — the final 'is it the same as prod?' check. Builds a minimal Elixir Mix release containing one GenServer registered as Wallet.Bridge, runs it in a docker container with TLS dist enabled using the production-matching inet_tls.conf shape, then exercises erldistpy.Node.call against it. Together with test_node_otp26.py this exhausts every dimension we identified differs between dev box (OTP 24, plain Erlang) and the production portal beam (OTP 25, Mix release, TLS dist, Elixir GenServer). If THIS passes too, the production-only failure is something specific to the live portal beam state that we can't reproduce in isolation — most likely an interaction with portal's other dist connections or its larger supervisor tree. """ from __future__ import annotations import os import shutil import socket import subprocess import time from pathlib import Path import pytest from erldistpy.etf import Atom from erldistpy.node import Node from erldistpy.tls import make_dist_tls_context RELEASE_NAME = "wallet_bridge_test" COOKIE = "WALLET_BRIDGE_TEST_COOKIE" MIX_EXS = """\ defmodule WalletBridgeTest.MixProject do use Mix.Project def project, do: [ app: :wallet_bridge_test, version: "0.1.0", elixir: "~> 1.16", deps: [], releases: [wallet_bridge_test: [include_executables_for: [:unix]]] ] def application, do: [ extra_applications: [:logger, :ssl, :crypto, :public_key], mod: {WalletBridgeTest.Application, []} ] end """ LIB_EX = """\ defmodule WalletBridgeTest.Application do use Application def start(_, _), do: Supervisor.start_link([Wallet.Bridge], strategy: :one_for_one) end defmodule Wallet.Bridge do use GenServer require Logger def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__) def init(s), do: {:ok, s} def handle_call(msg, _from, s), do: {:reply, {:ok, msg}, s} end """ def _docker() -> str | None: return shutil.which("docker") def _epmd_node_present(sname: str) -> bool: try: result = subprocess.run( ["epmd", "-names"], capture_output=True, text=True, timeout=2 ) except (FileNotFoundError, subprocess.TimeoutExpired): return False return f"name {sname} at port" in result.stdout @pytest.fixture(scope="module") def mix_release_peer(tmp_path_factory): if _docker() is None: pytest.skip("docker not installed") if not shutil.which("openssl"): pytest.skip("openssl not installed") # Must be under $HOME for snap docker to bind-mount it. base = Path.home() / ".erldistpy-test" base.mkdir(exist_ok=True) workdir = base / f"mix_release_{int(time.time())}" workdir.mkdir() (workdir / "lib").mkdir() (workdir / "mix.exs").write_text(MIX_EXS) (workdir / "lib" / "wallet_bridge.ex").write_text(LIB_EX) os.chmod(workdir, 0o755) # Build the release build = subprocess.run( ["docker", "run", "--rm", "-v", f"{workdir}:/app", "-w", "/app", "elixir:1.16-otp-25", "sh", "-c", "MIX_ENV=prod mix release wallet_bridge_test --overwrite"], capture_output=True, timeout=180, ) if build.returncode != 0: pytest.skip(f"mix release build failed: {build.stderr.decode()[-500:]}") # Re-use cert generation from test_node_otp26 (we duplicate-import to avoid # cross-file fixture coupling; if you have it imported already, prefer that.) from tests.test_node_otp26 import _gen_certs, _write_ssl_config_portal_match certs = _gen_certs(workdir) container_certs = {k: v.replace(str(workdir), "/app") for k, v in certs.items()} _write_ssl_config_portal_match(workdir, container_certs) # Tweak: ssl_dist_portal.config references /certs paths; rewrite to /app. cfg_path = workdir / "ssl_dist_portal.config" cfg_text = cfg_path.read_text().replace("/certs", "/app/certs") (workdir / "certs").mkdir(exist_ok=True) for f in ("ca.pem", "server.pem", "server.key", "client.pem", "client.key"): if (workdir / f).exists(): shutil.copy(workdir / f, workdir / "certs" / f) cfg_path.write_text(cfg_text) container_id_file = workdir / "cid" cid = subprocess.run( ["docker", "run", "--rm", "-d", "--network", "host", "-v", f"{workdir}:/app", "-w", "/app", "-e", "RELEASE_DISTRIBUTION=name", "-e", f"RELEASE_NODE={RELEASE_NAME}@127.0.0.1", "-e", f"RELEASE_COOKIE={COOKIE}", "-e", "ERL_FLAGS=-proto_dist inet_tls -ssl_dist_optfile /app/ssl_dist_portal.config", "elixir:1.16-otp-25", f"_build/prod/rel/{RELEASE_NAME}/bin/{RELEASE_NAME}", "start"], capture_output=True, text=True, ).stdout.strip() container_id_file.write_text(cid) deadline = time.monotonic() + 20.0 ready = False while time.monotonic() < deadline: time.sleep(0.3) if _epmd_node_present(RELEASE_NAME): time.sleep(0.5) ready = True break if not ready: if cid: subprocess.run(["docker", "kill", cid], capture_output=True) pytest.skip(f"Mix release {RELEASE_NAME} did not register with epmd") yield RELEASE_NAME, COOKIE, certs if cid: subprocess.run(["docker", "kill", cid], capture_output=True) def test_call_against_mix_release_tls(mix_release_peer): """erldistpy → real Mix release Wallet.Bridge over TLS dist on OTP 25. Closest local equivalent of the production failure environment. If THIS passes, the production failure isn't reproducible without a live portal beam in a mesh — meaning the bug is interaction-level (other connections, supervisor state) not protocol-level. """ sname, cookie, certs = mix_release_peer ctx = make_dist_tls_context( cert=certs["client_cert"], key=certs["client_key"], ca=certs["ca"], ) with Node( our_name="probe@127.0.0.1", peer_name=sname, peer_host="127.0.0.1", cookie=cookie, tls_context=ctx, ) as n: assert n.tls is True reply = n.call("Elixir.Wallet.Bridge", (Atom("ping"), 42), timeout=8.0) # Bridge wraps every call as {:ok, msg} — matches what the production # Wallet.Bridge does on the happy path (modulo the cammy forward). assert reply == (Atom("ok"), (Atom("ping"), 42))