Closes the loop on docker-based repro: builds a real Elixir Mix release
(with :ssl included) containing one GenServer registered as
Elixir.Wallet.Bridge, runs it under TLS dist with the production
inet_tls.conf shape, hits it from erldistpy.Node.call.
Passes cleanly with erldistpy 0.1.7. So the production portal failure
('peer closed after 0/4 bytes' against portal@unsandbox.com) isn't
reproducible in isolation, even with every dimension matched (Mix
release, OTP 25, TLS dist with permissive verify_fun + tls1.2/1.3 +
secure_renegotiate, Elixir GenServer, long FQDN node names).
The bug must be in interaction with portal's other dist connections or
its larger supervisor tree — beyond what we can repro without standing
up the full portal app locally.
181 lines
6.2 KiB
Python
181 lines
6.2 KiB
Python
"""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))
|