crypto_watcher: wallet_dist config + mps_wallet_dist_health CLI

Joining make_post_sell prod to our unsandbox Erlang cluster as a
hidden Python node so the watcher can route wallet RPC through
Wallet.Service on cammy via erldistpy. Two pieces in this commit:

  wallet_dist_config.py
    Reads wallet_dist.* keys from production.ini's [app:main]:
      - enabled
      - our_node_name / peer_name / peer_host / registered_name
      - cert / key / ca (all three or none — partial config errors)
      - cookie_file (path; value read at load time, never logged)
    Returns a frozen WalletDistConfig dataclass with .client_kwargs()
    that maps to ErlangDist*Client constructor signatures.

  wallet_dist_health.py + mps_wallet_dist_health console_script
    Single-shot smoke test: load config, open dist connection, call
    Wallet.Service.monero_get_height + is_synced, print result. Exit
    code 0 = wire works; 2 = config problem; 3 = dist call failed.
    Lets ops verify on prod via:
      mps_wallet_dist_health /opt/make_post_sell/production.ini

erldistpy imported lazily inside the connection helper — deploys
that stay on direct-HTTP wallet clients don't need erldistpy
resolvable.

11 new tests for wallet_dist_config covering enabled/disabled,
TLS-all-or-none, missing-required-key, unreadable cookie file.
Existing 44 erldist_clients tests still green (total 55 dist-related
tests, plus the 110 existing crypto_watcher tests untouched).
This commit is contained in:
russell@unturf.com 2026-06-16 15:51:57 -04:00
parent b6268a95ee
commit 02f55a9b66
No known key found for this signature in database
4 changed files with 427 additions and 0 deletions

View file

@ -0,0 +1,137 @@
"""
Read wallet-dist config from production.ini and build kwargs for the
``ErlangDist*Client`` constructors in ``erldist_clients``.
Config shape (added to ``[app:main]`` in production.ini by Salt/pillar):
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@my.makepostsell.com
wallet_dist.peer_name = wallet
wallet_dist.peer_host = cammy.foxhop.net
wallet_dist.registered_name = Elixir.Wallet.Service
wallet_dist.cert = /etc/make_post_sell/wallet-dist/mps-client.crt
wallet_dist.key = /etc/make_post_sell/wallet-dist/mps-client.key
wallet_dist.ca = /etc/make_post_sell/wallet-dist/ca.crt
wallet_dist.cookie_file = /etc/make_post_sell/wallet-dist/cookie
The cookie value is read from ``cookie_file`` at config-load time. The
caller never sees the value on stdout / args / logs it stays inside
the WalletDistConfig dataclass and gets passed straight to the client
constructor.
"""
from __future__ import annotations
from configparser import ConfigParser
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
@dataclass(frozen=True)
class WalletDistConfig:
enabled: bool
our_node_name: str
peer_name: str
peer_host: str
registered_name: str
tls_cert: Optional[str]
tls_key: Optional[str]
tls_ca: Optional[str]
cookie: str
def client_kwargs(self) -> dict:
"""Kwargs for ErlangDistMoneroClient / ErlangDist*Client.
Caller is responsible for closing the client (or using it as a
context manager)."""
return dict(
our_name=self.our_node_name,
peer_name=self.peer_name,
peer_host=self.peer_host,
cookie=self.cookie,
tls_cert=self.tls_cert,
tls_key=self.tls_key,
tls_ca=self.tls_ca,
)
def _truthy(value: Optional[str]) -> bool:
return (value or "").strip().lower() in ("true", "1", "yes", "on")
def load_wallet_dist_config(ini_path: str) -> WalletDistConfig:
"""Read ``[app:main]`` from ``ini_path`` and assemble a WalletDistConfig.
Raises ``ValueError`` if ``wallet_dist.enabled = true`` but any of the
required keys / files are missing. Returns a disabled config (no
side-effects) if ``wallet_dist.enabled`` is unset or false.
"""
cp = ConfigParser()
if not cp.read(ini_path):
raise FileNotFoundError(f"production.ini not found: {ini_path}")
if "app:main" not in cp:
raise ValueError(f"{ini_path} has no [app:main] section")
app = cp["app:main"]
enabled = _truthy(app.get("wallet_dist.enabled"))
if not enabled:
return WalletDistConfig(
enabled=False,
our_node_name="",
peer_name="",
peer_host="",
registered_name="",
tls_cert=None,
tls_key=None,
tls_ca=None,
cookie="",
)
required = (
"wallet_dist.our_node_name",
"wallet_dist.peer_name",
"wallet_dist.peer_host",
"wallet_dist.cookie_file",
)
missing = [k for k in required if not app.get(k, "").strip()]
if missing:
raise ValueError(
f"wallet_dist enabled but missing keys: {', '.join(missing)}"
)
cookie_file = app["wallet_dist.cookie_file"].strip()
try:
cookie = Path(cookie_file).read_text().strip()
except OSError as e:
raise ValueError(f"cannot read wallet_dist.cookie_file {cookie_file!r}: {e}")
if not cookie:
raise ValueError(f"wallet_dist.cookie_file is empty: {cookie_file}")
# TLS material — all three or none. None = plain inet_tcp_dist
# (acceptable when peer also runs plaintext, e.g. dev). All three =
# inet_tls_dist (production via cammy cluster).
cert = (app.get("wallet_dist.cert") or "").strip() or None
key = (app.get("wallet_dist.key") or "").strip() or None
ca = (app.get("wallet_dist.ca") or "").strip() or None
tls_count = sum(1 for v in (cert, key, ca) if v)
if tls_count not in (0, 3):
raise ValueError(
"wallet_dist.cert/key/ca must be all set or all empty "
f"(got cert={bool(cert)} key={bool(key)} ca={bool(ca)})"
)
return WalletDistConfig(
enabled=True,
our_node_name=app["wallet_dist.our_node_name"].strip(),
peer_name=app["wallet_dist.peer_name"].strip(),
peer_host=app["wallet_dist.peer_host"].strip(),
registered_name=(
app.get("wallet_dist.registered_name", "Elixir.Wallet.Service").strip()
or "Elixir.Wallet.Service"
),
tls_cert=cert,
tls_key=key,
tls_ca=ca,
cookie=cookie,
)

View file

@ -0,0 +1,104 @@
"""
``mps_wallet_dist_health`` single-shot smoke test that MPS can reach
``Wallet.Service`` on the cluster via Erlang dist.
Usage::
mps_wallet_dist_health /opt/make_post_sell/production.ini
Reads the ``wallet_dist`` keys from ``[app:main]``, opens a dist
connection via erldistpy, calls a handful of cheap operations
(``health`` checks against the wallet node), prints results.
Exit code:
0 if connection + RPC succeeded
2 if config is missing / wallet_dist not enabled
3 if the dist connection or RPC call failed (likely cert/cookie
mismatch, network reach, or wallet@cammy down log line above
will name the failure)
"""
from __future__ import annotations
import sys
import traceback
import click
from make_post_sell.lib.crypto_watcher.wallet_dist_config import (
WalletDistConfig,
load_wallet_dist_config,
)
@click.command(name="mps-wallet-dist-health")
@click.argument(
"config_path",
type=click.Path(dir_okay=False, exists=True, readable=True),
default="/opt/make_post_sell/production.ini",
)
@click.option(
"--timeout",
type=float,
default=10.0,
show_default=True,
help="dist call timeout in seconds.",
)
def cli(config_path: str, timeout: float) -> None:
"""Verify MPS can talk to wallet@cammy over Erlang dist."""
try:
cfg = load_wallet_dist_config(config_path)
except Exception as e:
click.echo(f"config error: {e}", err=True)
raise SystemExit(2) from None
if not cfg.enabled:
click.echo("wallet_dist.enabled = false (nothing to test)", err=True)
raise SystemExit(2)
click.echo("=== MPS wallet-dist health check ===")
click.echo(f"our_node_name : {cfg.our_node_name}")
click.echo(f"peer : {cfg.peer_name}@{cfg.peer_host}")
click.echo(f"registered_name : {cfg.registered_name}")
click.echo(f"tls : {'mTLS' if cfg.tls_cert else 'plain'}")
click.echo("")
# Local import — erldistpy is a runtime-optional dep (only required
# when wallet_dist is enabled). Importing at module top would force
# the dep on every MPS process, including ones that never touch dist.
try:
from make_post_sell.lib.crypto_watcher.erldist_clients import (
ErlangDistMoneroClient,
)
except ImportError as e:
click.echo(f"erldistpy not installed: {e}", err=True)
click.echo("hint: pip install 'erldistpy>=0.1.6'", err=True)
raise SystemExit(3) from None
try:
with ErlangDistMoneroClient(
**cfg.client_kwargs(),
call_timeout=timeout,
) as client:
click.echo("connected ✓")
height = client.get_height()
click.echo(f"monero height: {height}")
synced = client.is_synced()
click.echo(f"synced: {synced}")
except Exception as e:
click.echo(f"\nDIST RPC FAILED: {type(e).__name__}: {e}", err=True)
click.echo("traceback:", err=True)
traceback.print_exc()
raise SystemExit(3) from None
click.echo("")
click.echo("OK — MPS can reach Wallet.Service on the cluster ✓")
def main() -> None:
# The console_script entry point. Click handles arg parsing.
cli(standalone_mode=True) # standard click flow
if __name__ == "__main__": # pragma: no cover
cli(standalone_mode=True)

View file

@ -0,0 +1,185 @@
"""Tests for ``wallet_dist_config`` — production.ini → WalletDistConfig."""
from __future__ import annotations
import textwrap
from pathlib import Path
import pytest
from make_post_sell.lib.crypto_watcher.wallet_dist_config import (
load_wallet_dist_config,
)
def _write_ini(tmp_path: Path, body: str) -> Path:
p = tmp_path / "test.ini"
p.write_text(textwrap.dedent(body).lstrip())
return p
def _write_cookie(tmp_path: Path, value: str = "TEST_COOKIE_VALUE") -> Path:
p = tmp_path / "cookie"
p.write_text(value)
return p
class TestDisabled:
def test_disabled_when_key_absent(self, tmp_path):
ini = _write_ini(tmp_path, """
[app:main]
unused = x
""")
cfg = load_wallet_dist_config(str(ini))
assert cfg.enabled is False
def test_disabled_when_value_false(self, tmp_path):
ini = _write_ini(tmp_path, """
[app:main]
wallet_dist.enabled = false
""")
cfg = load_wallet_dist_config(str(ini))
assert cfg.enabled is False
class TestEnabled:
def test_full_config_with_tls(self, tmp_path):
cookie = _write_cookie(tmp_path)
cert = tmp_path / "client.crt"
cert.write_text("cert-pem")
key = tmp_path / "client.key"
key.write_text("key-pem")
ca = tmp_path / "ca.crt"
ca.write_text("ca-pem")
ini = _write_ini(tmp_path, f"""
[app:main]
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@my.makepostsell.com
wallet_dist.peer_name = wallet
wallet_dist.peer_host = cammy.foxhop.net
wallet_dist.registered_name = Elixir.Wallet.Service
wallet_dist.cert = {cert}
wallet_dist.key = {key}
wallet_dist.ca = {ca}
wallet_dist.cookie_file = {cookie}
""")
cfg = load_wallet_dist_config(str(ini))
assert cfg.enabled is True
assert cfg.our_node_name == "mps@my.makepostsell.com"
assert cfg.peer_name == "wallet"
assert cfg.peer_host == "cammy.foxhop.net"
assert cfg.registered_name == "Elixir.Wallet.Service"
assert cfg.tls_cert == str(cert)
assert cfg.tls_key == str(key)
assert cfg.tls_ca == str(ca)
assert cfg.cookie == "TEST_COOKIE_VALUE"
def test_plaintext_dist_no_tls(self, tmp_path):
"""All three TLS keys empty = plain inet_tcp_dist."""
cookie = _write_cookie(tmp_path)
ini = _write_ini(tmp_path, f"""
[app:main]
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@host
wallet_dist.peer_name = wallet
wallet_dist.peer_host = localhost
wallet_dist.cookie_file = {cookie}
""")
cfg = load_wallet_dist_config(str(ini))
assert cfg.enabled is True
assert cfg.tls_cert is None
assert cfg.tls_key is None
assert cfg.tls_ca is None
def test_registered_name_defaults(self, tmp_path):
cookie = _write_cookie(tmp_path)
ini = _write_ini(tmp_path, f"""
[app:main]
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@host
wallet_dist.peer_name = wallet
wallet_dist.peer_host = localhost
wallet_dist.cookie_file = {cookie}
""")
cfg = load_wallet_dist_config(str(ini))
assert cfg.registered_name == "Elixir.Wallet.Service"
def test_client_kwargs_match_erldist_client_signature(self, tmp_path):
cookie = _write_cookie(tmp_path, "cookie-1")
ini = _write_ini(tmp_path, f"""
[app:main]
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@host
wallet_dist.peer_name = wallet
wallet_dist.peer_host = remote
wallet_dist.cookie_file = {cookie}
""")
cfg = load_wallet_dist_config(str(ini))
kw = cfg.client_kwargs()
# Required keys for ErlangDistMoneroClient/UTXO subclasses
assert kw["our_name"] == "mps@host"
assert kw["peer_name"] == "wallet"
assert kw["peer_host"] == "remote"
assert kw["cookie"] == "cookie-1"
assert kw["tls_cert"] is None
assert kw["tls_key"] is None
assert kw["tls_ca"] is None
class TestErrors:
def test_missing_ini(self, tmp_path):
with pytest.raises(FileNotFoundError):
load_wallet_dist_config(str(tmp_path / "nope.ini"))
def test_missing_required_key(self, tmp_path):
cookie = _write_cookie(tmp_path)
ini = _write_ini(tmp_path, f"""
[app:main]
wallet_dist.enabled = true
wallet_dist.peer_name = wallet
wallet_dist.peer_host = host
wallet_dist.cookie_file = {cookie}
""")
with pytest.raises(ValueError, match="our_node_name"):
load_wallet_dist_config(str(ini))
def test_cookie_file_unreadable(self, tmp_path):
ini = _write_ini(tmp_path, """
[app:main]
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@host
wallet_dist.peer_name = wallet
wallet_dist.peer_host = host
wallet_dist.cookie_file = /nonexistent/path/cookie
""")
with pytest.raises(ValueError, match="cookie_file"):
load_wallet_dist_config(str(ini))
def test_empty_cookie_file(self, tmp_path):
cookie = _write_cookie(tmp_path, "")
ini = _write_ini(tmp_path, f"""
[app:main]
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@host
wallet_dist.peer_name = wallet
wallet_dist.peer_host = host
wallet_dist.cookie_file = {cookie}
""")
with pytest.raises(ValueError, match="empty"):
load_wallet_dist_config(str(ini))
def test_partial_tls_config_rejected(self, tmp_path):
"""Setting only cert without key/ca is config-time error,
not a deferred-at-connect crash."""
cookie = _write_cookie(tmp_path)
ini = _write_ini(tmp_path, f"""
[app:main]
wallet_dist.enabled = true
wallet_dist.our_node_name = mps@host
wallet_dist.peer_name = wallet
wallet_dist.peer_host = host
wallet_dist.cert = /some/path
wallet_dist.cookie_file = {cookie}
""")
with pytest.raises(ValueError, match="cert/key/ca"):
load_wallet_dist_config(str(ini))

View file

@ -85,6 +85,7 @@ setup(
"initialize_make_post_sell_db = make_post_sell.scripts.initialize_db:main",
"crypto_watcher = make_post_sell.lib.crypto_watcher:main",
"digest_sender = make_post_sell.lib.digest_sender:main",
"mps_wallet_dist_health = make_post_sell.lib.crypto_watcher.wallet_dist_health:main",
],
},
)