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:
parent
b6268a95ee
commit
02f55a9b66
4 changed files with 427 additions and 0 deletions
137
make_post_sell/lib/crypto_watcher/wallet_dist_config.py
Normal file
137
make_post_sell/lib/crypto_watcher/wallet_dist_config.py
Normal 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,
|
||||
)
|
||||
104
make_post_sell/lib/crypto_watcher/wallet_dist_health.py
Normal file
104
make_post_sell/lib/crypto_watcher/wallet_dist_health.py
Normal 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)
|
||||
185
make_post_sell/tests/test_wallet_dist_config.py
Normal file
185
make_post_sell/tests/test_wallet_dist_config.py
Normal 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))
|
||||
1
setup.py
1
setup.py
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue