phase 0 + 1: repo bones and ETF codec
Repo scaffolding (LICENSE, Makefile, pyproject.toml, README) matching unfeed conventions. Flat package layout, ruff config, Unlicense. ETF codec covers the subset needed for gen_call against an Elixir node: small/int/big_int, atom_utf8 (legacy atom_ext on decode), binary, nil, list, small/large tuple, new_pid, newer_reference. Booleans round-trip as atoms true/false; Python None as atom nil; str encodes to utf-8 binary to match Elixir convention. Golden vectors were generated from real Erlang term_to_binary/1 output (generator script at docs/etf_vectors.erl). Decode tests verify wire compatibility; round-trip tests verify encoder consistency. make all green: 58 passed, lint clean. Next phases tracked in docs/ROADMAP.md (EPMD, handshake, channel, gen_call, TLS, unfeed integration).
This commit is contained in:
commit
b9fd28ca3b
11 changed files with 680 additions and 0 deletions
67
docs/ROADMAP.md
Normal file
67
docs/ROADMAP.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# erldistpy roadmap
|
||||
|
||||
Phases below are sized to land as discrete commits. Each phase ends with
|
||||
`make all` green and a real-VM interop test where applicable.
|
||||
|
||||
## Phase 0 — Repo bones ✅
|
||||
|
||||
- LICENSE, README, .gitignore, Makefile, pyproject.toml
|
||||
- Package skeleton, ruff config matches unfeed
|
||||
- `make bootstrap test lint` works on a fresh checkout
|
||||
|
||||
## Phase 1 — ETF codec ✅
|
||||
|
||||
- Subset of External Term Format we need for gen_call to Elixir:
|
||||
small_int, int, big_int, atom_utf8 (legacy atom_ext on decode), binary,
|
||||
nil, list, tuple (small + large), pid (new_pid), ref (newer_reference)
|
||||
- Booleans and `None` ride as atoms `true` / `false` / `nil`
|
||||
- Golden vectors decoded from real `term_to_binary/1` output
|
||||
- Round-trip tests for the encoder
|
||||
- Generator script committed at `docs/etf_vectors.erl`
|
||||
|
||||
## Phase 2 — EPMD client
|
||||
|
||||
EPMD (Erlang Port Mapper Daemon) maps node names to TCP ports.
|
||||
Tiny synchronous TCP client.
|
||||
|
||||
- Connect `epmd_host:4369` (configurable)
|
||||
- `PORT_PLEASE2_REQ` (113) → port + dist version range
|
||||
- Return `EpmdNodeInfo(port, lo_ver, hi_ver, node_type, proto)`
|
||||
- Tests: live EPMD on localhost (skip if not running), unit tests
|
||||
against a recorded byte stream
|
||||
|
||||
## Phase 3 — Distribution handshake
|
||||
|
||||
- TCP connect to the resolved port
|
||||
- `send_name` / `recv_status` / `recv_challenge` / `send_challenge_reply`
|
||||
/ `recv_challenge_ack`
|
||||
- Cookie digest via `erlang:phash2`-equivalent (md5-based per spec)
|
||||
- Version 6 ("v6") handshake, the modern one Elixir 1.15+ uses
|
||||
- Tests: handshake against a live Erlang node started in conftest
|
||||
|
||||
## Phase 4 — Distribution channel
|
||||
|
||||
- After handshake, the socket carries control + payload messages framed
|
||||
by a 4-byte length prefix
|
||||
- Send `SEND_TT` / `REG_SEND` for outgoing messages
|
||||
- Receive replies, route by ref
|
||||
- Tick loop for keepalive (60s default per OTP)
|
||||
|
||||
## Phase 5 — gen_call convenience layer
|
||||
|
||||
- `Node.call(name_or_pid, request, timeout=5.0)` → reply term
|
||||
- Wraps the `$gen_call` protocol used by `:gen_server`
|
||||
- Idempotency-key handling lives in the caller; we just pass the term
|
||||
|
||||
## Phase 6 — TLS dist
|
||||
|
||||
- Wrap the post-EPMD socket in TLS
|
||||
- Match `inet_tls_dist` config on the Erlang side (cert + key + ca paths)
|
||||
- Same handshake, just runs inside the TLS tunnel
|
||||
|
||||
## Phase 7 — unfeed integration
|
||||
|
||||
- `ErlangDistTransport` implementing unfeed's `WalletTransport` Protocol
|
||||
- Maps `/v1/health`, `/v1/address`, ... to gen_call requests
|
||||
- `unfeed` deploys with a config switch: `wallet_transport = http | erldist`
|
||||
- Cutover after a soak period on staging
|
||||
47
docs/etf_vectors.erl
Normal file
47
docs/etf_vectors.erl
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
%% Generator for the golden ETF vectors used in tests/test_etf.py.
|
||||
%% Re-run when adding new test cases:
|
||||
%%
|
||||
%% erlc -o /tmp docs/etf_vectors.erl
|
||||
%% erl -noshell -pa /tmp -eval 'etf_vectors:main([]), init:stop().'
|
||||
%% cat /tmp/etf_vectors.txt
|
||||
%%
|
||||
-module(etf_vectors).
|
||||
-export([main/1]).
|
||||
|
||||
main(_) ->
|
||||
Terms = [
|
||||
{small_int_0, 0},
|
||||
{small_int_42, 42},
|
||||
{small_int_255, 255},
|
||||
{int_256, 256},
|
||||
{int_neg_1, -1},
|
||||
{int_max32, 2147483647},
|
||||
{int_min32, -2147483648},
|
||||
{big_pos, 9999999999999999},
|
||||
{big_neg, -9999999999999999},
|
||||
{atom_hello, hello},
|
||||
{atom_true, true},
|
||||
{atom_false, false},
|
||||
{atom_nil, nil},
|
||||
{atom_unicode, 'привет'},
|
||||
{binary_empty, <<>>},
|
||||
{binary_hi, <<"hi">>},
|
||||
{binary_utf8, <<"héllo"/utf8>>},
|
||||
{tuple_empty, {}},
|
||||
{tuple_pair, {ok, <<"value">>}},
|
||||
{tuple_triple, {1, 2, 3}},
|
||||
{list_empty, []},
|
||||
{list_ints, [1, 2, 3]},
|
||||
{list_mixed, [hello, <<"world">>, 42]},
|
||||
{nested, {gen_call, [{node, 'wallet@cammy'}], {get_balance, xmr}}}
|
||||
],
|
||||
Out = [
|
||||
io_lib:format("~s|~s~n", [atom_to_list(N), bin_to_hex(term_to_binary(T))])
|
||||
|| {N, T} <- Terms
|
||||
],
|
||||
ok = file:write_file("/tmp/etf_vectors.txt", iolist_to_binary(Out)).
|
||||
|
||||
bin_to_hex(B) ->
|
||||
<< <<(hex(N div 16)), (hex(N rem 16))>> || <<N>> <= B >>.
|
||||
hex(N) when N < 10 -> $0 + N;
|
||||
hex(N) -> $a + N - 10.
|
||||
Loading…
Add table
Add a link
Reference in a new issue