diff --git a/tests/test_node_mix_release.py b/tests/test_node_mix_release.py new file mode 100644 index 0000000..95cd5bd --- /dev/null +++ b/tests/test_node_mix_release.py @@ -0,0 +1,181 @@ +"""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))