tests: Mix release + TLS dist + OTP 25 + Wallet.Bridge GenServer (passes)
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.
This commit is contained in:
parent
b716f1487e
commit
35d0eca342
1 changed files with 181 additions and 0 deletions
181
tests/test_node_mix_release.py
Normal file
181
tests/test_node_mix_release.py
Normal file
|
|
@ -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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue