pyrtan / sourcev0.14.0 · main

pyrtan / tests / test_federation.py

58,937 bytes · last changed 2026-10-27 in No seeds: pyrtan.com is GitHub Pages and a hub list, nothing more

1"""Two hubs, each with its own service, following each other over real
2sockets: who may ask whom, what is announced, and what is refused."""
3
4import json
5import threading
6import time
7
8import pytest
9
10from pyrtan import Registry, RecordedTransport
11from pyrtan.cli import main
12from pyrtan.federation import Federation, Remote, sign, verify
13from pyrtan.hub import Hub
14from pyrtan.service import Server, Service, http_json
15from conftest import EXAMPLES, FIXTURES, ROOT
16
17ECHO = """
18name = "{name}"
19description = "echoes"
20start = "say"
21
22[[triggers]]
23pattern = "{word} {{text}}"
24
25[inputs.text]
26type = "str"
27
28[nodes.say]
29kind = "done"
30say = "{name}: {{{{ inputs.text }}}}"
31output = {{ text = "{{{{ inputs.text }}}}" }}
32"""
33
34
35class Swarm:
36 """A hub with a token, federated, holding one service with the given trees."""
37
38 def __init__(self, name: str, tree_dir, token: str | None = None, **fed):
39 self.name = name
40 self.token = token
41 self.hub = Hub(name=name, poll_every=60, token=token)
42 self.hub_srv = Server(self.hub.route, quiet=True, token=token, federated=True).start()
43 self.fed = self.hub.federate(self.hub_srv.url, **fed)
44 self.svc = Service(Registry.from_paths([tree_dir]), name=f"{name}-svc", transport=RecordedTransport.from_file(FIXTURES / "examples.json"), token=token, hub=self.hub_srv.url)
45 self.svc_srv = Server(self.svc.route, quiet=True, token=token).start()
46 assert self.svc.register(self.svc_srv.url)
47
48 @property
49 def url(self):
50 return self.hub_srv.url
51
52 def get(self, path):
53 return http_json("GET", f"{self.url}{path}", token=self.token)
54
55 def post(self, path, body):
56 return http_json("POST", f"{self.url}{path}", body, token=self.token)
57
58 def stop(self):
59 self.hub.stop()
60 self.hub_srv.stop()
61 self.svc_srv.stop()
62
63
64@pytest.fixture
65def two(tmp_path):
66 (tmp_path / "a").mkdir()
67 (tmp_path / "b").mkdir()
68 (tmp_path / "a" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha"))
69 (tmp_path / "b" / "beta.toml").write_text(ECHO.format(name="beta", word="beta"))
70 a = Swarm("a", tmp_path / "a", token="token-a")
71 b = Swarm("b", tmp_path / "b", token="token-b")
72 yield a, b
73 a.stop()
74 b.stop()
75
76
77def test_follow_accept_announce_and_route(two):
78 a, b = two
79 status, actor = http_json("GET", f"{b.url}/actor") # public, no token
80 assert status == 200 and actor["type"] == "Hub" and actor["inbox"] == f"{b.url}/inbox" and actor["follows"] == "open"
81
82 status, out = a.post("/follow", {"url": b.url})
83 assert status == 200 and out["following"]["url"] == b.url
84 # The handshake is synchronous: B fetched A's actor, accepted, announced.
85 assert a.fed.following[b.url].state == "accepted" and a.fed.following[b.url].link == "up"
86 assert b.fed.followers[a.url].state == "accepted"
87 assert [t["name"] for t in a.fed.following[b.url].trees] == ["beta"]
88
89 _, caps = a.get("/capabilities")
90 beta = next(t for t in caps["trees"] if t["name"] == "beta")
91 assert beta["via"] == "federation" and beta["peer"] == b.url and beta["link"] == "up"
92 _, peers = a.get("/peers")
93 assert peers["id"] == a.url and peers["following"][0]["url"] == b.url and peers["followers"] == []
94 _, peers = b.get("/peers")
95 assert peers["followers"][0]["url"] == a.url and peers["following"] == []
96
97 # A asks for B's tree: routed over federation, signed, one hop, and the answer says so.
98 _, out = a.post("/intent", {"text": "beta hello"})
99 assert out["status"] == "done" and out["say"] == "beta: hello"
100 assert out["peer"] == b.svc_srv.url and out["via"] == [b.url]
101 assert out["trace"]["steps"][0]["kind"] == "intent"
102 _, out = a.post("/run", {"tree": "beta", "inputs": {"text": "x"}})
103 assert out["status"] == "done" and out["via"] == [b.url]
104 # Its own service still comes first for its own trees.
105 _, out = a.post("/intent", {"text": "alpha hi"})
106 assert out["status"] == "done" and "via" not in out
107
108 # Being followed grants nothing: B does not route to A.
109 _, caps = b.get("/capabilities")
110 assert [t["name"] for t in caps["trees"]] == ["beta"]
111 _, out = b.post("/intent", {"text": "alpha hi"})
112 assert out["reason"] == "no-match"
113
114 _, box = a.get("/outbox")
115 assert [x["type"] for x in box["outbox"]] == ["Follow", "Announce"] and all(x["delivered"] for x in box["outbox"]) # the Follow, then our announce upward
116 assert [x["type"] for x in box["inbox"]] == ["Accept", "Announce"]
117
118
119def test_signed_requests_replace_the_token_only_for_hubs_we_know(two):
120 a, b = two
121 a.post("/follow", {"url": b.url})
122 secret = a.fed.following[b.url].secret
123 body = {"tree": "beta", "inputs": {"text": "x"}}
124 # No token, no signature: refused.
125 assert http_json("POST", f"{b.url}/run", body)[0] == 401
126 # Signed by A with the pair secret: served.
127 status, out = http_json("POST", f"{b.url}/run", body, headers=sign(secret, a.url, "POST", "/run", body))
128 assert status == 200 and out["status"] == "done"
129 # Wrong secret, wrong actor, stale date: refused.
130 assert http_json("POST", f"{b.url}/run", body, headers=sign("nope", a.url, "POST", "/run", body))[0] == 401
131 assert http_json("POST", f"{b.url}/run", body, headers=sign(secret, "http://stranger", "POST", "/run", body))[0] == 401
132 assert http_json("POST", f"{b.url}/run", body, headers=sign(secret, a.url, "POST", "/run", body, date=time.time() - 3600))[0] == 401
133 # A signature does not open the operator's paths.
134 assert http_json("GET", f"{b.url}/peers", headers=sign(secret, a.url, "GET", "/peers", None))[0] == 401
135 assert http_json("POST", f"{b.url}/follow", {"url": a.url}, headers=sign(secret, a.url, "POST", "/follow", {"url": a.url}))[0] == 401
136 # The inbox checks the activity's actor against the signature's.
137 announce = {"type": "Announce", "actor": a.url, "object": {"type": "Capabilities", "trees": []}}
138 status, out = http_json("POST", f"{b.url}/inbox", announce, headers=sign(secret, a.url, "POST", "/inbox", announce))
139 # A follows B, so B takes A's announce for the directory it carries, and nothing else:
140 # being followed grants nothing, so B still routes only to its own trees.
141 assert status == 200 and out["status"] == "ok" and out["trees"] == 0
142 assert [t["name"] for t in b.get("/capabilities")[1]["trees"]] == ["beta"]
143 stranger = {"type": "Announce", "actor": "http://stranger", "object": {"type": "Capabilities", "trees": []}}
144 assert http_json("POST", f"{b.url}/inbox", stranger, headers=sign(secret, "http://stranger", "POST", "/inbox", stranger))[0] == 403
145 bad = {"type": "Announce", "actor": b.url, "object": {"type": "Capabilities", "trees": []}}
146 assert http_json("POST", f"{a.url}/inbox", bad, headers=sign("nope", b.url, "POST", "/inbox", bad))[0] == 401
147 assert http_json("POST", f"{a.url}/inbox", {"type": "Dance", "actor": b.url}, headers=sign(secret, b.url, "POST", "/inbox", {"type": "Dance", "actor": b.url}))[0] == 400
148
149
150def test_federation_is_one_hop(two, tmp_path):
151 a, b = two
152 (tmp_path / "c").mkdir()
153 (tmp_path / "c" / "gamma.toml").write_text(ECHO.format(name="gamma", word="gamma"))
154 c = Swarm("c", tmp_path / "c")
155 try:
156 c.post("/follow", {"url": a.url})
157 a.post("/follow", {"url": b.url})
158 # C knows A's own trees, not what A learned from B.
159 _, caps = c.get("/capabilities")
160 assert {t["name"] for t in caps["trees"]} == {"gamma", "alpha"}
161 assert c.post("/intent", {"text": "beta hello"})[1]["reason"] == "no-match"
162 assert c.post("/run", {"tree": "beta", "inputs": {"text": "x"}})[1]["reason"] == "no-such-tree"
163 # A request that already crossed a hub is not forwarded again.
164 secret = c.fed.following[a.url].secret
165 body = {"tree": "beta", "inputs": {"text": "x"}, "hops": [c.url]}
166 status, out = http_json("POST", f"{a.url}/run", body, headers=sign(secret, c.url, "POST", "/run", body))
167 assert status == 200 and out["reason"] == "hop-limit"
168 # A mutual follow does not loop: a sentence nobody answers is simply unanswered.
169 b.post("/follow", {"url": a.url})
170 assert a.post("/intent", {"text": "nobody answers this"})[1]["reason"] == "no-match"
171 assert b.post("/intent", {"text": "alpha hi"})[1]["status"] == "done"
172 finally:
173 c.stop()
174
175
176def test_follow_policy_secret_and_closed(tmp_path):
177 (tmp_path / "t").mkdir()
178 (tmp_path / "t" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha"))
179 guarded = Swarm("guarded", tmp_path / "t", secret="federation-secret")
180 stranger = Swarm("stranger", tmp_path / "t")
181 insider = Swarm("insider", tmp_path / "t", secret="federation-secret")
182 closed = Swarm("closed", tmp_path / "t", open_follows=False)
183 try:
184 assert http_json("GET", f"{guarded.url}/actor")[1]["follows"] == "secret"
185 stranger.post("/follow", {"url": guarded.url})
186 assert stranger.fed.following[guarded.url].state == "pending"
187 assert "federation secret" in stranger.fed.following[guarded.url].last_error
188 assert stranger.url not in guarded.fed.followers
189 insider.post("/follow", {"url": guarded.url})
190 assert insider.fed.following[guarded.url].state == "accepted"
191 stranger.post("/follow", {"url": closed.url})
192 assert stranger.fed.following[closed.url].state == "pending" and "does not take follows" in stranger.fed.following[closed.url].last_error
193 # A closed hub can still follow.
194 closed.post("/follow", {"url": stranger.url})
195 assert closed.fed.following[stranger.url].state == "accepted"
196 status, out = stranger.post("/follow", {"url": stranger.url})
197 assert status == 400 and "itself" in out["say"]
198 status, out = stranger.post("/follow", {"url": stranger.svc_srv.url})
199 assert status == 400 and "does not answer /actor" in out["say"]
200 finally:
201 for s in (guarded, stranger, insider, closed):
202 s.stop()
203
204
205def test_unfollow_announce_on_change_and_state(two, tmp_path):
206 a, b = two
207 state = tmp_path / "a-state.json"
208 a.fed.state_path = state
209 a.post("/follow", {"url": b.url})
210 assert json.loads(state.read_text())["following"][0]["url"] == b.url
211 # B gains a tree; its next poll notices and announces; A learns without polling B.
212 (tmp_path / "b" / "beta2.toml").write_text(ECHO.format(name="beta-two", word="betatwo"))
213 b.hub.refresh_all()
214 assert {t["name"] for t in a.fed.following[b.url].trees} == {"beta", "beta-two"}
215 assert b.fed.announce() == 0 # nothing changed since: no delivery
216 # A fresh federation over the same file remembers the pair.
217 again = Federation(a.url, state=state)
218 assert again.following[b.url].secret == a.fed.following[b.url].secret and again.following[b.url].state == "accepted"
219 # Unfollow: both sides forget, and A stops routing there.
220 status, out = a.post("/unfollow", {"url": b.url})
221 assert status == 200 and out["removed"] is True
222 assert b.url not in a.fed.following and a.url not in b.fed.followers
223 assert a.post("/intent", {"text": "beta hello"})[1]["reason"] == "no-match"
224 assert a.post("/unfollow", {"url": b.url})[1]["removed"] is False
225 saved = json.loads(state.read_text())
226 assert saved["following"] == [] and saved["followers"] == [] and saved["listed"] is False
227
228
229def test_unfederated_hub_has_no_inbox(tmp_path):
230 hub = Hub()
231 srv = Server(hub.route, quiet=True).start()
232 try:
233 assert http_json("GET", f"{srv.url}/actor")[0] == 404
234 assert http_json("POST", f"{srv.url}/follow", {"url": "http://x"})[0] == 404
235 assert http_json("GET", f"{srv.url}/api")[1]["federation"] is None
236 finally:
237 srv.stop()
238
239
240def test_cli_follow_peers_unfollow(two, capsys):
241 a, b = two
242
243 def run(*argv):
244 code = main(list(argv))
245 out = capsys.readouterr()
246 return code, out.out, out.err
247
248 code, out, _ = run("follow", b.url, "--at", a.url, "--token", "token-a")
249 assert code == 0 and "now follows" in out and "accepted" in out
250 code, out, _ = run("peers", "--at", a.url, "--token", "token-a")
251 assert code == 0 and "follows" in out and "beta" in out and "a-svc" in out
252 code, out, _ = run("peers", "--at", b.url, "--token", "token-b")
253 assert "follower" in out
254 assert run("follow", b.url, "--at", a.url)[0] == 3 # no token
255 code, out, _ = run("unfollow", b.url, "--at", a.url, "--token", "token-a")
256 assert code == 0 and "no longer follows" in out
257 code, _, err = run("follow", "http://127.0.0.1:9", "--at", a.url, "--token", "token-a")
258 assert code == 1 and "does not answer /actor" in err
259
260
261def test_signing_primitives():
262 headers = sign("s", "http://a", "POST", "/inbox", {"b": 1, "a": [1, 2]})
263 assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 1}, headers) is None # key order does not matter
264 assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 2}, headers) == "bad signature"
265 assert verify("t", "POST", "/inbox", {"a": [1, 2], "b": 1}, headers) == "bad signature"
266 assert verify("s", "POST", "/other", {"a": [1, 2], "b": 1}, headers) == "bad signature"
267 assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 1}, {}) == "no signature"
268 assert verify("s", "POST", "/inbox", {"a": [1, 2], "b": 1}, headers, now=time.time() + 1000) == "signature too old"
269 assert Remote("http://b", "http://b/inbox", "s").link == "pending"
270 assert Remote("http://b", "http://b/inbox", "s", state="accepted").link == "pending" # accepted, not heard from yet
271 assert Remote("http://b", "http://b/inbox", "s", state="accepted", last_error="x").link == "down"
272 assert Remote("http://b", "http://b/inbox", "s", state="rejected").link == "down"
273
274
275def test_directory_is_gossiped_in_both_directions_and_routes_to_nobody(two, tmp_path):
276 a, b = two
277 (tmp_path / "c").mkdir()
278 (tmp_path / "c" / "gamma.toml").write_text(ECHO.format(name="gamma", word="gamma"))
279 c = Swarm("c", tmp_path / "c", listed=True)
280 try:
281 # B lists itself; A follows B and hears about B in the Accept's Announce.
282 b.fed.set_listed(True)
283 a.post("/follow", {"url": b.url})
284 status, d = http_json("GET", f"{a.url}/directory") # public
285 assert status == 200 and d["hub"] == a.url and [h["id"] for h in d["hubs"]] == [b.url]
286 assert d["hubs"][0]["trees"] == ["beta"] and d["hubs"][0]["link"] == "up" and d["hubs"][0]["via"] == b.url
287 # C (listed) follows B: it announces upward, so B hears of C; A hears of C from B on B's next announce.
288 c.post("/follow", {"url": b.url})
289 assert {h["id"] for h in b.fed.directory()["hubs"]} == {b.url, c.url}
290 assert c.url not in {h["id"] for h in a.fed.directory()["hubs"]}
291 b.hub.refresh_all() # B announces downward (the directory changed) and A merges
292 assert {h["id"] for h in a.fed.directory()["hubs"]} == {b.url, c.url}
293 assert next(h for h in a.fed.directory()["hubs"] if h["id"] == c.url)["via"] == b.url
294 # And C hears of nobody but B until B announces to it; after that, the same two.
295 assert {h["id"] for h in c.fed.directory()["hubs"]} >= {b.url, c.url}
296 # A is not listed, so it appears nowhere; listing itself puts it in its own gossip and up to B.
297 assert a.url not in {h["id"] for h in b.fed.directory()["hubs"]}
298 a.post("/listed", {"listed": True})
299 assert a.url in {h["id"] for h in b.fed.directory()["hubs"]}
300 assert a.fed.directory()["hubs"][0]["id"] == a.url and a.fed.directory()["hubs"][0]["via"] == "self"
301 # Nothing in the directory is routed to: B still answers only for itself.
302 assert b.post("/intent", {"text": "alpha hello"})[1]["reason"] == "no-match"
303 assert [t["name"] for t in b.get("/capabilities")[1]["trees"]] == ["beta"]
304 # The directory survives a restart through the state file.
305 state = tmp_path / "state.json"
306 a.fed.state_path = state
307 a.fed._save()
308 again = Federation(a.url, state=state)
309 assert again.listed and {h["id"] for h in again.directory()["hubs"]} == {a.url, b.url, c.url}
310 # Merge rules: our own entry is never overwritten, the future is not believed, old news is dropped.
311 a.fed.merge_directory([{"id": a.url, "name": "impostor", "trees": ["x"], "seen": time.time() + 1e6}], via="x")
312 assert a.fed.directory()["hubs"][0]["name"] == "a"
313 a.fed.merge_directory([{"id": "http://future", "trees": [], "seen": time.time() + 1e6}], via="x")
314 assert abs(a.fed.directory_entries["http://future"]["seen"] - time.time()) < 5
315 assert a.fed.merge_directory([{"id": "http://old", "trees": [], "seen": time.time() - 30 * 86400}], via="x") == 0
316 assert a.fed.merge_directory([{"id": "not a url"}, "junk", {"id": "http://ok", "seen": "soon"}], via="x") == 0
317 assert "/directory" in http_json("GET", f"{a.url}/api")[1]["endpoints"]
318 finally:
319 c.stop()
320
321
322def test_hub_list_and_cli_defaults(two, capsys, monkeypatch, tmp_path):
323 a, b = two
324 from pyrtan.federation import first_live_hub, rendezvous_hubs
325
326 def run(*argv):
327 code = main(list(argv))
328 out = capsys.readouterr()
329 return code, out.out, out.err
330
331 listed = tmp_path / "hubs.json"
332 listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", b.url, a.url]}))
333 monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed))
334 monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) # never reach pyrtan.com from a test
335 monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False)
336 assert rendezvous_hubs() == ["http://127.0.0.1:9", b.url, a.url]
337 assert first_live_hub() == b.url # the dead one is skipped
338 monkeypatch.setenv("PYRTAN_RENDEZVOUS", a.url)
339 assert rendezvous_hubs() == [a.url]
340 monkeypatch.delenv("PYRTAN_RENDEZVOUS")
341 b.fed.set_listed(True)
342 code, out, _ = run("follow", "--at", a.url, "--token", "token-a", "--listed") # no URL: the first listed hub that answers
343 assert code == 0 and f"now follows {b.url}" in out and "listed in the directory" in out
344 assert a.fed.listed and a.url in {h["id"] for h in b.fed.directory()["hubs"]}
345 monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9") # the local default is dead: fall through to the hub list
346 code, out, _ = run("directory")
347 assert code == 0 and f"as b ({b.url})" in out and a.url in out and b.url in out
348 code, out, _ = run("directory", "--at", a.url, "--json")
349 assert {h["id"] for h in json.loads(out)["hubs"]} == {a.url, b.url}
350 assert run("directory", "--at", b.svc_srv.url)[0] == 3
351 monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "missing.json"))
352 assert rendezvous_hubs() == []
353 code, _, err = run("follow", "--at", a.url, "--token", "token-a")
354 assert code == 3 and "no listed or snapshot hub answers" in err
355
356
357def test_hub_holds_trees_itself(tmp_path):
358 (tmp_path / "t").mkdir()
359 (tmp_path / "t" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha"))
360 (tmp_path / "t" / "picnic.toml").write_text("""
361name = "picnic"
362description = "delegates to the hub it lives in"
363start = "ask"
364
365[[triggers]]
366pattern = "picnic {text}"
367
368[inputs.text]
369type = "str"
370
371[nodes.ask]
372kind = "delegate"
373intent = "alpha {{ inputs.text }}"
374into = "a"
375next = "say"
376
377[nodes.say]
378kind = "done"
379say = "picnic says {{ state.a.say }}"
380""")
381 from pyrtan.hub import serve_hub
382
383 hub, srv = serve_hub(port=0, quiet=True, name="solo", trees=[tmp_path / "t"], federate=True)
384 try:
385 _, health = http_json("GET", f"{srv.url}/health")
386 assert health["local"] == 2 and health["trees"] == 2 and health["peers"] == 0
387 _, caps = http_json("GET", f"{srv.url}/capabilities")
388 assert {(t["name"], t["peer"]) for t in caps["trees"]} == {("alpha", "local"), ("picnic", "local")}
389 _, out = http_json("POST", f"{srv.url}/intent", {"text": "alpha hi"})
390 assert out["status"] == "done" and out["say"] == "alpha: hi" and out["peer"] == "local"
391 assert out["trace"]["steps"][0]["kind"] == "intent"
392 # A local tree's delegate goes back through the hub, in-process.
393 _, out = http_json("POST", f"{srv.url}/run", {"tree": "picnic", "inputs": {"text": "now"}})
394 assert out["status"] == "done" and out["say"] == "picnic says alpha: now"
395 _, peers = http_json("GET", f"{srv.url}/peers")
396 assert peers["peers"][0]["kind"] == "local" and peers["peers"][0]["trees"] == ["alpha", "picnic"]
397 assert http_json("GET", f"{srv.url}/gaps")[1]["gaps"] == []
398 # Hot reload holds for local trees too, and the announce digest follows.
399 (tmp_path / "t" / "beta.toml").write_text(ECHO.format(name="beta", word="beta"))
400 assert "beta" in {t["name"] for t in hub.local_capabilities()}
401 finally:
402 hub.stop()
403 srv.stop()
404
405
406def test_snapshot_bakes_and_bootstraps(two, tmp_path, monkeypatch, capsys):
407 """site/snapshot.py merges the listed hubs' directories with the last snapshot,
408 and the command falls back to the snapshot when nothing is live."""
409 import subprocess
410 import sys as _sys
411
412 a, b = two
413 b.fed.set_listed(True)
414 a.post("/follow", {"url": b.url})
415 listed = tmp_path / "hubs.json"
416 listed.write_text(json.dumps({"pyrtan-hubs": 1, "hubs": ["http://127.0.0.1:9", a.url]}))
417 old = tmp_path / "old.json"
418 old.write_text(json.dumps({"pyrtan-directory": 1, "hubs": [{"id": "http://gone.example", "name": "gone", "trees": ["x"], "seen": time.time() - 3600}, {"id": "http://ancient.example", "trees": [], "seen": time.time() - 30 * 86400}]}))
419 out = tmp_path / "directory.json"
420 run = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--previous", str(tmp_path / "missing.json"), "--previous", str(old)], capture_output=True, text=True, cwd=str(EXAMPLES.parent))
421 assert run.returncode == 0, run.stderr
422 snap = json.loads(out.read_text())
423 assert snap["pyrtan-directory"] == 1 and snap["listed"]["answered"] == [a.url] and snap["listed"]["silent"][0]["hub"] == "http://127.0.0.1:9"
424 assert snap["listed"]["previous"] == str(old)
425 ids = [h["id"] for h in snap["hubs"]]
426 assert b.url in ids and "http://gone.example" in ids and "http://ancient.example" not in ids # live from the listed hub, kept from the snapshot, expired
427 # B was named by A, so it was asked too, and its own word on itself is the newest.
428 assert snap["listed"]["crawled"]["answered"] == [b.url] and next(h for h in snap["hubs"] if h["id"] == b.url)["via"] == b.url
429
430 def cli(*argv):
431 code = main(list(argv))
432 o = capsys.readouterr()
433 return code, o.out, o.err
434
435 from pyrtan.federation import bootstrap_candidates, first_live_hub, snapshot_hubs
436
437 # With every listed hub dead, the snapshot's hubs are the next place to look.
438 monkeypatch.setenv("PYRTAN_HUB_LIST", str(tmp_path / "dead-hubs.json"))
439 (tmp_path / "dead-hubs.json").write_text(json.dumps({"hubs": ["http://127.0.0.1:9"]}))
440 monkeypatch.setenv("PYRTAN_SNAPSHOT", str(out))
441 monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False)
442 assert snapshot_hubs()[0] == b.url # most recently heard first
443 assert bootstrap_candidates() == ["http://127.0.0.1:9", b.url, "http://gone.example"]
444 assert first_live_hub() == b.url
445 code, o, _ = cli("follow", "--at", a.url, "--token", "token-a")
446 assert code == 0 and f"now follows {b.url}" in o
447 monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9")
448 b.stop() # nothing live at all now: the snapshot, with its age, and link recomputed
449 code, o, _ = cli("directory")
450 assert code == 0 and "snapshot as of" in o and b.url in o and "gone" in o
451 monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "nope.json"))
452 assert cli("directory")[0] == 3
453
454
455# ---------------------------------------------------------------- members and keeping
456
457
458class Member:
459 """A browser or a phone, in Python: no inbox, a mailbox it polls."""
460
461 def __init__(self, hub_url: str, name: str = "phone"):
462 import secrets as _secrets
463
464 self.id = f"urn:pyrtan:member:{_secrets.token_hex(16)}"
465 self.secret = _secrets.token_hex(32)
466 self.hub = hub_url.rstrip("/")
467 self.name = name
468 self.kept: dict[str, dict] = {}
469 self.directory: list[dict] = []
470
471 def post(self, activity: dict, secret: str | None = None):
472 activity = {"id": f"{self.id}/activities/{len(self.kept)}", "actor": self.id, "published": "now", **activity}
473 return http_json("POST", f"{self.hub}/inbox", activity, headers=sign(secret or self.secret, self.id, "POST", "/inbox", activity))
474
475 def follow(self, secret: str | None = None):
476 return self.post({"type": "Follow", "object": self.hub, "secret": self.secret, "inbox": "mailbox", "name": self.name}, secret)
477
478 def poll(self) -> list[dict]:
479 status, body = http_json("GET", f"{self.hub}/mailbox", headers=sign(self.secret, self.id, "GET", "/mailbox", None))
480 assert status == 200, body
481 acts = body["activities"]
482 for a in acts:
483 if a["type"] == "Keep":
484 self.kept[a["object"]["key"]] = a["object"]
485 self.post({"type": "Kept", "object": {"key": a["object"]["key"], "sha256": a["object"]["sha256"]}})
486 elif a["type"] == "Fetch":
487 blob = self.kept.get(a["object"]["key"])
488 if blob:
489 self.post({"type": "Kept", "object": {"key": blob["key"], "sha256": blob["sha256"], "data": blob["data"]}})
490 elif a["type"] == "Announce":
491 self.directory = a["object"].get("directory", [])
492 return acts
493
494 def announce(self, listed: bool = True):
495 own = [{"id": self.id, "name": self.name, "kind": "member", "trees": [], "seen": time.time()}] if listed else []
496 return self.post({"type": "Announce", "object": {"type": "Capabilities", "name": self.name, "trees": [], "directory": own, "kept": sorted(self.kept)}})
497
498
499def test_member_follows_through_a_mailbox_and_keeps_blobs(two, tmp_path):
500 a, b = two
501 a.fed.store = __import__("pyrtan.store", fromlist=["BlobStore"]).BlobStore(tmp_path / "store-a")
502 m = Member(a.url)
503 status, out = m.follow()
504 assert status == 202 and out["mailbox"] == f"{a.url}/mailbox"
505 assert a.fed.followers[m.id].kind == "member" and a.fed.followers[m.id].inbox == "mailbox"
506 # The Accept and the first Announce wait in the mailbox; polling drains it.
507 acts = m.poll()
508 assert [x["type"] for x in acts] == ["Accept", "Announce"] and m.poll() == []
509 _, peers = a.get("/peers")
510 assert peers["followers"][0]["kind"] == "member" and peers["followers"][0]["link"] == "up"
511 # The member lists itself by announcing upward; the hub's directory carries it as a member.
512 status, out = m.announce()
513 assert status == 200 and out["directory"] is not None
514 entry = next(h for h in a.fed.directory()["hubs"] if h["id"] == m.id)
515 assert entry["kind"] == "member" and entry["via"] == m.id
516 # A blob of the hub's own goes to the member through its mailbox; the member's Kept confirms it.
517 status, out = a.post("/keep", {"key": "notes/today", "data": {"todo": ["water plants"]}})
518 assert status == 200 and out["keeping"]["holders"] == [] # nobody has confirmed yet
519 acts = m.poll()
520 assert [x["type"] for x in acts] == ["Keep"] and m.kept["notes/today"]["data"] == {"todo": ["water plants"]}
521 assert a.fed.holders("notes/today") == [m.id]
522 _, out = a.get("/keep")
523 assert out["keeping"][0]["key"] == "notes/today" and out["keeping"][0]["holders"] == [m.id] and out["replicas"] == 2
524 # Fetch: the hub still has its own copy, so no round trip.
525 assert a.post("/fetch", {"key": "notes/today"})[1]["source"] == "own"
526 # The hub loses its store; the member's Announce says what it keeps, the hub asks, the member answers, the blob is back.
527 a.fed.store = __import__("pyrtan.store", fromlist=["BlobStore"]).BlobStore(None)
528 assert a.get("/keep/notes/today")[0] == 404
529 _, out = a.post("/fetch", {"key": "notes/today"})
530 assert out["status"] == "asked" and out["asked"] == [m.id]
531 acts = m.poll()
532 assert [x["type"] for x in acts] == ["Fetch"]
533 _, out = a.get("/keep/notes/today")
534 assert out["data"] == {"todo": ["water plants"]} and out["holders"] == [m.id]
535 # Or without asking: the member's next upward Announce names the key, the hub notices it has no copy and fetches.
536 a.fed.store = __import__("pyrtan.store", fromlist=["BlobStore"]).BlobStore(None)
537 m.announce()
538 assert [x["type"] for x in m.poll()] == ["Fetch"]
539 assert a.get("/keep/notes/today")[1]["data"] == {"todo": ["water plants"]}
540 # Leaving takes the mailbox and the kept blobs with it.
541 assert m.post({"type": "Undo", "object": {"type": "Follow", "actor": m.id, "object": a.url}})[0] == 200
542 assert m.id not in a.fed.followers and m.id not in a.fed.mailboxes
543 assert http_json("GET", f"{a.url}/mailbox", headers=sign(m.secret, m.id, "GET", "/mailbox", None))[0] == 401
544 # A member's id has a shape; a stranger's signature opens nothing.
545 bad = Member(a.url); bad.id = "urn:pyrtan:member:short"
546 assert bad.follow()[0] == 400
547 assert http_json("GET", f"{a.url}/mailbox", headers=sign("x", "urn:pyrtan:member:" + "0" * 32, "GET", "/mailbox", None))[0] == 401
548
549
550def test_hubs_keep_for_each_other_and_the_store_has_limits(two, tmp_path):
551 from pyrtan.store import MAX_BLOB, BlobStore, StoreError
552
553 a, b = two
554 a.fed.store = BlobStore(tmp_path / "a"); b.fed.store = BlobStore(tmp_path / "b")
555 a.post("/follow", {"url": b.url})
556 # A keeps a blob; B (a hub A follows) is a keeper and confirms in the same round trip.
557 status, out = a.post("/keep", {"key": "gaps-archive", "data": [{"tree": "x"}]})
558 assert status == 200 and out["keeping"]["holders"] == [b.url]
559 assert b.fed.store.list_kept(a.url)[0]["key"] == "gaps-archive"
560 assert [k["key"] for k in b.get("/keep")[1]["kept"]] == ["gaps-archive"]
561 # A takes it back from B over the kept path, signed; a stranger cannot.
562 path = "/kept/gaps-archive"
563 status, blob = http_json("GET", f"{b.url}{path}", headers=sign(a.fed.following[b.url].secret, a.url, "GET", path, None))
564 assert status == 200 and blob["data"] == [{"tree": "x"}]
565 assert http_json("GET", f"{b.url}{path}", headers=sign("nope", a.url, "GET", path, None))[0] == 401
566 assert http_json("GET", f"{b.url}/kept/other", headers=sign(a.fed.following[b.url].secret, a.url, "GET", "/kept/other", None))[0] == 404
567 # Fetch after A's store is gone: B answers at once.
568 a.fed.store = BlobStore(tmp_path / "a2")
569 _, out = a.post("/fetch", {"key": "gaps-archive"})
570 assert out["status"] == "ok" and out["source"] == b.url and out["blob"]["data"] == [{"tree": "x"}]
571 # The automatic blobs appear on the poll and change when the directory does.
572 a.hub.refresh_all()
573 keys = {k["key"] for k in a.get("/keep")[1]["keeping"]}
574 assert {"directory", "gaps", "unanswered", "gaps-archive"} <= keys
575 # Limits: a key has a shape, a blob a size, an actor a budget; and the state file remembers who holds what.
576 assert a.post("/keep", {"key": "Bad Key", "data": 1})[0] == 400
577 assert a.post("/keep", {"key": "big", "data": "x" * (MAX_BLOB + 1)})[0] == 400
578 with pytest.raises(StoreError):
579 b.fed.store.put_kept(a.url, "k", {"x": 1}, sha256="wrong")
580 state = tmp_path / "state.json"
581 a.fed.state_path = state; a.fed._save()
582 again = Federation(a.url, state=state, store=BlobStore(tmp_path / "a2"))
583 assert again.keeping["gaps-archive"]["holders"][b.url]["sha256"] == a.fed.keeping["gaps-archive"]["sha256"]
584 # Unfollow drops what B kept for A.
585 a.post("/unfollow", {"url": b.url})
586 assert b.fed.store.list_kept(a.url) == []
587
588
589def test_replication_counts_fresh_holders_only(two, tmp_path):
590 from pyrtan.store import BlobStore
591
592 a, b = two
593 a.fed.store = BlobStore(None); a.fed.replicas = 2
594 m1, m2, m3 = Member(a.url, "m1"), Member(a.url, "m2"), Member(a.url, "m3")
595 for m in (m1, m2, m3):
596 m.follow(); m.poll()
597 a.post("/keep", {"key": "k", "data": 1})
598 # Two members got a Keep (replicas = 2), the third did not.
599 got = [m for m in (m1, m2, m3) if any(x["type"] == "Keep" for x in m.poll())]
600 assert len(got) == 2 and a.fed.holders("k") == sorted(m.id for m in got)
601 assert a.fed.replicate() == 0 # enough holders: nothing sent
602 # A holder goes stale: the next replicate sends to the third.
603 a.fed.keeping["k"]["holders"][got[0].id]["at"] -= 2 * 86400
604 assert a.fed.replicate() == 1
605 third = next(m for m in (m1, m2, m3) if m not in got)
606 assert [x["type"] for x in third.poll()] == ["Keep"] and len(a.fed.holders("k")) == 2
607 # A new version of the blob needs confirming again.
608 a.post("/keep", {"key": "k", "data": 2})
609 assert a.fed.holders("k") == []
610 for m in (m1, m2, m3):
611 m.poll()
612 assert len(a.fed.holders("k")) == 2
613
614
615def test_cli_keep_kept_fetch(two, tmp_path, capsys):
616 from pyrtan.store import BlobStore
617
618 a, b = two
619 a.fed.store = BlobStore(None)
620 a.post("/follow", {"url": b.url})
621
622 def run(*argv):
623 code = main(list(argv))
624 o = capsys.readouterr()
625 return code, o.out, o.err
626
627 f = tmp_path / "blob.json"; f.write_text(json.dumps({"hello": "world"}))
628 code, out, _ = run("keep", "recipes/soup", str(f), "--at", a.url, "--token", "token-a")
629 assert code == 0 and "keeps recipes/soup" in out and "held by 1 of 2" in out
630 code, out, _ = run("kept", "--at", a.url, "--token", "token-a")
631 assert code == 0 and "recipes/soup" in out and b.url in out
632 code, out, _ = run("kept", "--at", b.url, "--token", "token-b")
633 assert f"for {a.url}: recipes/soup" in out
634 a.fed.store = BlobStore(None)
635 code, out, _ = run("fetch", "recipes/soup", "--at", a.url, "--token", "token-a", "--out", str(tmp_path / "back.json"))
636 assert code == 0 and f"from {b.url}" in out and json.loads((tmp_path / "back.json").read_text()) == {"hello": "world"}
637 assert run("fetch", "nothing", "--at", a.url, "--token", "token-a")[0] == 1
638 assert run("keep", "bad key", str(f), "--at", a.url, "--token", "token-a")[0] == 1
639
640
641class TrustedMember(Member):
642 """A member that runs trees: it holds what the hub hands it and answers Asks."""
643
644 def __init__(self, hub_url: str, name: str = "phone", answers: dict | None = None):
645 super().__init__(hub_url, name)
646 self.trees: dict[str, dict] = {}
647 self.asks: list[dict] = []
648 self.answers = answers or {}
649
650 def follow(self, secret: str | None = None, token: str | None = None):
651 activity = {"id": f"{self.id}/activities/follow", "actor": self.id, "published": "now", "type": "Follow", "object": self.hub, "secret": self.secret, "inbox": "mailbox", "name": self.name}
652 headers = sign(secret or self.secret, self.id, "POST", "/inbox", activity)
653 if token:
654 headers["Authorization"] = f"Bearer {token}"
655 return http_json("POST", f"{self.hub}/inbox", activity, headers=headers)
656
657 def poll(self) -> list[dict]:
658 acts = super().poll()
659 for a in acts:
660 if a["type"] == "Keep" and a["object"]["key"].startswith("trees/"):
661 self.trees[a["object"]["data"]["name"]] = a["object"]["data"]
662 elif a["type"] == "Ask":
663 self.asks.append(a["object"])
664 outcome = self.answers.get(a["object"]["tree"])
665 if outcome is not None:
666 self.post({"type": "Answer", "object": {"run": a["object"]["run"], "outcome": {**outcome, "output": {"inputs": a["object"]["inputs"]}}}})
667 return acts
668
669 def announce(self, listed: bool = True, trees: list | None = None):
670 caps = trees if trees is not None else [{"name": n, "triggers": ["whatever the member says"]} for n in self.trees]
671 return self.post({"type": "Announce", "object": {"type": "Capabilities", "name": self.name, "trees": caps, "directory": [], "kept": sorted(self.kept)}})
672
673
674def test_trusted_member_runs_the_trees_its_hub_hands_out(tmp_path):
675 (tmp_path / "t").mkdir()
676 (tmp_path / "h").mkdir()
677 (tmp_path / "t" / "alpha.toml").write_text(ECHO.format(name="alpha", word="alpha"))
678 (tmp_path / "h" / "gamma.toml").write_text(ECHO.format(name="gamma", word="gamma"))
679 (tmp_path / "h" / "box.toml").write_text('''
680name = "box"
681commands = ["uptime"]
682start = "up"
683[nodes.up]
684kind = "observe"
685shell = { command = "uptime" }
686next = "say"
687[nodes.say]
688kind = "done"
689say = "{{ state.last.stdout }}"
690''')
691 from pyrtan.hub import serve_hub
692
693 hub, srv = serve_hub(port=0, quiet=True, name="solo", trees=[tmp_path / "t"], handout=[tmp_path / "h"], federate=True, token="tok", poll_every=60)
694 fed = hub.federation
695 try:
696 # Joining with the hub's token makes a member trusted; without it, not.
697 stranger = TrustedMember(srv.url, "stranger")
698 assert stranger.follow()[1]["trusted"] is False
699 assert [a["type"] for a in stranger.poll()] == ["Accept", "Announce"]
700 stranger.announce(trees=[{"name": "alpha", "triggers": ["alpha {text}"]}])
701 assert fed.followers[stranger.id].trees == [] and "alpha" not in stranger.trees
702 phone = TrustedMember(srv.url, "phone", answers={"gamma": {"status": "done", "say": "gamma, from the phone", "trace": {"tree": "gamma", "steps": [{"node": "say", "kind": "done", "depth": 0}], "gaps": []}}})
703 status, out = phone.follow(token="tok")
704 assert status == 202 and out["trusted"] is True and fed.followers[phone.id].trusted
705 # The hub hands out its own trees and the --handout ones at once, in the mailbox after the Accept and the Announce.
706 acts = phone.poll()
707 assert [a["type"] for a in acts] == ["Accept", "Announce", "Keep", "Keep"]
708 assert sorted(phone.trees) == ["alpha", "gamma"] and phone.trees["gamma"]["name"] == "gamma" # not "box": a phone has no shell
709 assert all(h["confirmed"] for h in fed._handed[phone.id].values())
710 assert fed.hand_out() == 0 # nothing new to give
711 # What the member says it runs is filtered to what it was handed, with the hub's own triggers.
712 status, out = phone.announce(trees=[{"name": "gamma", "triggers": ["nonsense"]}, {"name": "alpha", "triggers": ["alpha {text}"]}, {"name": "zeta", "triggers": ["zeta"]}])
713 assert status == 200
714 caps = {t["name"]: t for t in fed.followers[phone.id].trees}
715 assert sorted(caps) == ["alpha", "gamma"] and caps["gamma"]["triggers"] == ["gamma {text}"]
716 _, out = http_json("GET", f"{srv.url}/capabilities", token="tok")
717 assert {(t["name"], t["peer"], t.get("via")) for t in out["trees"]} == {("alpha", "local", None), ("alpha", phone.id, "member"), ("gamma", phone.id, "member")}
718 # A sentence for gamma goes to the phone: an Ask in its mailbox, its Answer back to the caller.
719 got: dict = {}
720
721 def call():
722 got["out"] = http_json("POST", f"{srv.url}/intent", {"text": "gamma hi there"}, token="tok")[1]
723
724 t = threading.Thread(target=call)
725 t.start()
726 deadline = time.time() + 5
727 while not phone.asks and time.time() < deadline:
728 phone.poll()
729 time.sleep(0.05)
730 t.join(5)
731 assert phone.asks and phone.asks[0]["tree"] == "gamma" and phone.asks[0]["inputs"] == {"text": "hi there"}
732 out = got["out"]
733 assert out["status"] == "done" and out["say"] == "gamma, from the phone"
734 assert out["peer"] == phone.id and out["via"] == ["member"] and out["output"] == {"inputs": {"text": "hi there"}}
735 assert [s["kind"] for s in out["trace"]["steps"]] == ["intent", "done"] and out["trace"]["steps"][0]["next"] == "say"
736 # A tree the hub runs itself stays on the hub, even though the phone holds it too.
737 _, out = http_json("POST", f"{srv.url}/intent", {"text": "alpha hi"}, token="tok")
738 assert out["status"] == "done" and out["peer"] == "local"
739 # An answer nobody waits for, or from the wrong member, lands nowhere.
740 assert phone.post({"type": "Answer", "object": {"run": "nope", "outcome": {"status": "done"}}})[0] == 404
741 fed.ask_timeout = 0.2
742 pending = fed.ask(fed.followers[phone.id], "gamma", {"text": "x"}, timeout=0.5) # the phone is not polling
743 assert pending["status"] == "error" and pending["reason"] == "member-timeout" and pending["tree"] == "gamma"
744 phone.answers = {}
745 _, out = http_json("POST", f"{srv.url}/run", {"tree": "gamma", "inputs": {"text": "x"}}, token="tok")
746 assert out["reason"] == "member-timeout" and out["peer"] == phone.id
747 # The Ask is still in the mailbox; an Answer arriving late is refused, not misfiled.
748 acts = phone.poll()
749 asks = [a for a in acts if a["type"] == "Ask"]
750 assert len(asks) == 2
751 assert phone.post({"type": "Answer", "object": {"run": asks[-1]["object"]["run"], "outcome": {"status": "done"}}})[0] == 404
752 # Leaving forgets what was handed out; joining again hands it out again.
753 assert phone.post({"type": "Undo", "object": {"type": "Follow", "actor": phone.id, "object": srv.url}})[0] == 200
754 assert phone.id not in fed._handed
755 assert phone.follow(token="tok")[0] == 202 and [a["type"] for a in phone.poll()].count("Keep") == 2
756 # Trust survives a restart of the hub's state.
757 assert fed.followers[phone.id].to_dict()["trusted"] is True
758 finally:
759 hub.stop()
760 srv.stop()
761
762
763GAPPY = '''
764name = "gappy"
765version = 2
766namespace = "pyrtan"
767description = "a tree with a decide that covers one value"
768start = "go"
769
770[inputs.v]
771type = "int"
772
773[nodes.go]
774kind = "set"
775set = { x = "{{ inputs.v }}" }
776next = "pick"
777
778[nodes.pick]
779kind = "decide"
780on = "x"
781branches = [{ when = { eq = 1 }, then = "one" }]
782
783[nodes.one]
784kind = "done"
785say = "one"
786'''
787
788
789def test_gap_reports_travel_with_announces_when_a_hub_opts_in(tmp_path):
790 (tmp_path / "g").mkdir()
791 (tmp_path / "g" / "gappy.toml").write_text(GAPPY)
792 (tmp_path / "g" / "plain.toml").write_text(ECHO.format(name="plain", word="plain"))
793 from pyrtan.hub import serve_hub
794
795 a_hub, a_srv = serve_hub(port=0, quiet=True, name="reporter", trees=[tmp_path / "g"], federate=True, report_gaps=True, poll_every=60)
796 b_hub, b_srv = serve_hub(port=0, quiet=True, name="carrier", federate=True, poll_every=60, state=str(tmp_path / "b.json"))
797 try:
798 # Two gaps on the catalog tree (namespace + version), seen twice and once.
799 for v in (7, 7, 9):
800 out = http_json("POST", f"{a_srv.url}/run", {"tree": "gappy", "inputs": {"v": v}})[1]
801 assert out["reason"] == "gap"
802 own = sorted(a_hub.gap_reports(), key=lambda r: r["observed"])
803 assert [(r["id"], r["node"], r["on"], r["observed"], r["count"]) for r in own] == [("pyrtan/gappy@2", "pick", "state.x", 7, 2), ("pyrtan/gappy@2", "pick", "state.x", 9, 1)]
804 _, view = http_json("GET", f"{a_srv.url}/reports") # public, like /directory
805 assert view["reporting"] is True and view["count"] == 2 and view["reports"][0] == {"id": "pyrtan/gappy@2", "node": "pick", "on": "state.x", "observed": 7, "count": 2, "reporters": 1, "last": view["reports"][0]["last"]}
806 # B follows A: A's announce carries the reports; B keeps them, says who reported, and passes them on.
807 assert http_json("POST", f"{b_srv.url}/follow", {"url": a_srv.url})[0] == 200
808 _, bview = http_json("GET", f"{b_srv.url}/reports")
809 assert bview["reporting"] is False and bview["count"] == 2 and bview["reports"][0]["reporters"] == 1
810 assert {r["reporter"] for r in b_hub.federation.reports.values()} == {a_srv.url}
811 assert any(r["id"] == "pyrtan/gappy@2" for r in b_hub.federation.report_gossip())
812 # The same gap reported by a second hub counts that hub too; B's own announce (not opted in) carries only what it heard.
813 b_hub.federation.merge_reports([{"id": "pyrtan/gappy@2", "node": "pick", "on": "state.x", "observed": 7, "count": 5, "last": time.time()}], via="http://other.example")
814 row = next(r for r in http_json("GET", f"{b_srv.url}/reports")[1]["reports"] if r["observed"] == 7)
815 assert row["count"] == 7 and row["reporters"] == 2
816 # Shaping: never an input, no id outside namespace/name@version, no future, no old news, long values summarised.
817 now = time.time()
818 assert b_hub.federation.merge_reports([{"id": "gappy", "node": "pick", "on": "x", "observed": 1}, {"id": "pyrtan/gappy@2", "node": "pick", "on": "x", "observed": 1, "last": now - 60 * 86400}, "junk"], via="x") == 0
819 assert b_hub.federation.merge_reports([{"id": "a/b@1", "node": "n", "on": "o", "observed": "v" * 200, "last": now + 1e6, "count": "3"}], via="http://x") == 1
820 shaped = next(r for r in b_hub.federation.reports.values() if r["id"] == "a/b@1")
821 assert shaped["observed"] == "<str>" and shaped["count"] == 3 and shaped["last"] <= time.time() and shaped["reporter"] == "http://x"
822 assert b_hub.federation.merge_reports([{"id": "a/b@1", "node": "n", "on": "o", "observed": {"k": 1}, "reporter": "https://r.example/"}], via="http://x") == 1
823 assert any(r["reporter"] == "https://r.example" and r["observed"] == "<dict>" for r in b_hub.federation.reports.values())
824 # Reports survive a restart through the state file.
825 b_hub.federation._save()
826 again = Federation(b_srv.url, state=str(tmp_path / "b.json"))
827 assert {r["id"] for r in again.reports.values()} == {"pyrtan/gappy@2", "a/b@1"}
828 # A tree without a namespace reports nothing.
829 assert not any(r["id"].endswith("/plain@1") for r in a_hub.gap_reports())
830 finally:
831 a_hub.stop(); a_srv.stop(); b_hub.stop(); b_srv.stop()
832
833
834def test_reports_command_and_snapshot(two, tmp_path, capsys, monkeypatch):
835 a, b = two
836 a.fed.report_gaps = True
837 a.fed.own_reports = lambda: [{"id": "pyrtan/weather@1", "node": "code", "on": "state.code", "observed": 99, "count": 4, "last": time.time()}]
838 assert main(["reports", "--at", a.url]) == 0
839 assert "pyrtan/weather@1 code: state.code = 99 ×4" in capsys.readouterr().out
840 assert main(["reports", "nobody/x", "--at", a.url]) == 0 and "no gap reports" in capsys.readouterr().out
841 assert main(["reports", "--at", a.url, "--json"]) == 0 and json.loads(capsys.readouterr().out)["reports"][0]["id"] == "pyrtan/weather@1"
842 # The site's snapshot asks the listed hubs for /reports and bakes reports.json; the command reads that when no hub is given.
843 import subprocess, sys as _sys
844
845 listed = tmp_path / "hubs.json"
846 listed.write_text(json.dumps({"hubs": [a.url]}))
847 out = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(tmp_path / "directory.json"), "--reports", str(tmp_path / "reports.json")], capture_output=True, text=True, cwd=str(ROOT))
848 assert out.returncode == 0, out.stderr
849 baked = json.loads((tmp_path / "reports.json").read_text())
850 assert baked["count"] == 1 and baked["reports"][0]["id"] == "pyrtan/weather@1" and baked["reports"][0]["count"] == 4
851 monkeypatch.setenv("PYRTAN_REPORTS", str(tmp_path / "reports.json"))
852 monkeypatch.delenv("PYRTAN_AT", raising=False)
853 assert main(["reports"]) == 0 and "pyrtan/weather@1 code" in capsys.readouterr().out
854
855
856# ---------------------------------------------------------------- networks
857
858
859def test_invites_and_network_ids():
860 from pyrtan.federation import Network, network_id, parse_invite
861
862 net, hub = parse_invite("home:abcdefghijklmnop1234@https://nas.example.ts.net/")
863 assert (net.name, net.secret, hub) == ("home", "abcdefghijklmnop1234", "https://nas.example.ts.net")
864 assert parse_invite("home:abcdefghijklmnop1234")[1] is None
865 assert net.invite("https://x/") == "home:abcdefghijklmnop1234@https://x" and net.invite() == "home:abcdefghijklmnop1234"
866 for bad in ("home", "Home:abcdefghijklmnop1234", "home:short", "home:abcdefghijklmnop1234@ftp://x", ""):
867 with pytest.raises(ValueError):
868 parse_invite(bad)
869 # The id comes from name and secret: same name, other secret, another network; and it holds neither.
870 assert network_id("home", "a" * 20) != network_id("home", "b" * 20) == Network("home", "b" * 20).id
871 assert "a" * 20 not in network_id("home", "a" * 20)
872
873
874def test_a_hub_starts_a_network_and_others_join_by_invite(tmp_path):
875 from pyrtan.federation import Network
876
877 for n in "abc":
878 (tmp_path / n).mkdir()
879 (tmp_path / n / f"{n}x.toml").write_text(ECHO.format(name=f"{n}x", word=f"{n}x"))
880 a = Swarm("a", tmp_path / "a", token="token-a", open_follows=False) # takes no follows outside its networks
881 b = Swarm("b", tmp_path / "b", token="token-b")
882 c = Swarm("c", tmp_path / "c")
883 try:
884 status, made = a.post("/networks", {"name": "home"})
885 assert status == 200 and made["network"] == "home" and made["invite"].startswith("home:") and made["invite"].endswith(f"@{a.url}")
886 assert a.post("/networks", {"name": "home"})[0] == 400 and a.post("/networks", {"name": "Not OK"})[0] == 400
887 assert a.fed.following == {} and a.fed.followers == {} # a network of one
888 # B joins by invite: it is in home, follows A in it, and routes to A's trees.
889 status, joined = b.post("/join", {"invite": made["invite"]})
890 assert status == 200 and joined["network"] == "home" and joined["following"]["url"] == a.url and joined["following"]["state"] == "accepted"
891 assert b.fed.following[a.url].network == a.fed.networks["home"].id == a.fed.followers[b.url].network
892 assert b.post("/intent", {"text": "ax hi"})[1]["say"] == "ax: hi"
893 # Outside the network A takes nobody (it is --closed); in another network of the same name, nobody either.
894 assert c.fed.follow(a.url).state == "pending" and "closed" in c.fed.following[a.url].last_error
895 c.fed.add_network(Network("home", "another-secret-entirely"))
896 assert c.fed.follow(a.url, network="home").state == "pending"
897 assert "not-in-network" in c.fed.following[a.url].last_error
898 # A Follow naming home's id but signed with another secret is refused as unsigned.
899 forged = {"type": "Follow", "id": "x", "actor": c.url, "object": a.url, "secret": "p" * 32, "inbox": f"{c.url}/inbox", "network": a.fed.networks["home"].id}
900 status, answer = http_json("POST", f"{a.url}/inbox", forged, headers=sign("another-secret-entirely", c.url, "POST", "/inbox", forged))
901 assert status == 401 and answer["reason"] == "unsigned"
902 # The invite lets C in, and joining again changes nothing; a name taken by another network is refused.
903 assert c.post("/leave", {"network": "home"})[1]["left"] is True and c.url not in a.fed.followers
904 status, joined = c.post("/join", {"invite": made["invite"]})
905 assert status == 200 and joined["following"]["state"] == "accepted"
906 assert c.post("/join", {"invite": made["invite"]})[1]["following"]["url"] == a.url
907 assert c.post("/join", {"invite": "home:yet-another-secret-xyz"})[0] == 400
908 assert c.post("/join", {"invite": "nonsense"})[1]["reason"] == "bad-invite"
909 # An invite with no hub: in the network, following nobody.
910 status, alone = b.post("/join", {"invite": "solo:abcdefghijklmnopqrstu"})
911 assert status == 200 and alone["following"] is None and "network of one" in alone["say"]
912 _, view = b.get("/networks")
913 assert {n["name"]: n["following"] for n in view["networks"]} == {"home": [a.url], "solo": []}
914 assert all(n["invite"].endswith(f"@{b.url}") for n in view["networks"])
915 assert http_json("GET", f"{b.url}/networks")[0] == 401 # the invites are the operator's
916 # Leaving cuts the links made in it, both ways, and forgets it.
917 assert b.post("/leave", {"network": "home"})[1]["left"] is True
918 assert a.url not in b.fed.following and b.url not in a.fed.followers and "home" not in b.fed.networks
919 assert b.post("/leave", {"network": "home"})[1]["left"] is False
920 finally:
921 for s in (a, b, c):
922 s.stop()
923
924
925def test_a_private_network_never_reaches_the_public_directory(tmp_path):
926 from pyrtan.federation import Network
927
928 for n in "abc":
929 (tmp_path / n).mkdir()
930 (tmp_path / n / f"{n}x.toml").write_text(ECHO.format(name=f"{n}x", word=f"{n}x"))
931 public = Network("pyrtan", "public-default-network-of-pyrtan-com", public=True)
932 a = Swarm("a", tmp_path / "a", listed=True)
933 b = Swarm("b", tmp_path / "b", token="token-b")
934 c = Swarm("c", tmp_path / "c", listed=True)
935 try:
936 # A is in the public network; B joins it through A. C starts "home", and B joins that too.
937 a.fed.add_network(public)
938 assert b.fed.join(Network(public.name, public.secret, public=True), [a.url]).state == "accepted"
939 home = c.fed.create_network("home")
940 assert b.fed.join(Network("home", home.secret), [c.url]).state == "accepted"
941 b.hub.refresh_all()
942 # B hears A in the public network and C in home, and passes each on only within its own network.
943 heard = {e["id"]: e["network"] for e in b.fed.directory_entries.values()}
944 assert heard == {a.url: public.id, c.url: home.id}
945 assert c.url not in {h["id"] for h in a.fed.directory()["hubs"]}
946 assert a.url not in {h["id"] for h in c.fed.directory()["hubs"]}
947 assert [e["id"] for e in b.fed.gossip(public.id)] == [a.url] and [e["id"] for e in b.fed.gossip(home.id)] == [c.url]
948 # B's public /directory shows the public network; the operator also sees home.
949 _, shown = http_json("GET", f"{b.url}/directory")
950 assert {h["id"] for h in shown["hubs"]} == {a.url} and shown["networks"] == {public.id: "pyrtan"}
951 _, full = b.get("/directory")
952 assert {h["id"]: h["networks"] for h in full["hubs"]} == {a.url: [public.id], c.url: [home.id]}
953 assert full["networks"] == {public.id: "pyrtan", home.id: "home"}
954 # The directory blob B's keepers hold is the public part only.
955 assert {e["id"] for e in b.fed.directory_backup()} == {a.url}
956 # State keeps networks and the tags; an entry of a network since left is not taken back.
957 state = tmp_path / "b-state.json"
958 b.fed.state_path = state
959 b.fed._save()
960 again = Federation(b.url, state=state)
961 assert set(again.networks) == {"pyrtan", "home"} and again.networks["pyrtan"].public
962 assert {e["id"]: e["network"] for e in again.directory_entries.values()} == {a.url: public.id, c.url: home.id}
963 stale = json.loads(state.read_text())
964 stale["networks"] = [n for n in stale["networks"] if n["name"] != "home"]
965 state.write_text(json.dumps(stale))
966 assert {e["id"] for e in Federation(b.url, state=state).directory_entries.values()} == {a.url}
967 finally:
968 for s in (a, b, c):
969 s.stop()
970
971
972def test_the_default_network_from_pyrtan_com(two, tmp_path, monkeypatch, capsys):
973 import subprocess
974 import sys as _sys
975
976 from pyrtan.federation import default_network
977 from pyrtan.hub import serve_hub
978
979 a, b = two
980
981 def run(*argv):
982 code = main(list(argv))
983 out = capsys.readouterr()
984 return code, out.out, out.err
985
986 network = ROOT / "site" / "rendezvous" / "network.json"
987 monkeypatch.setenv("PYRTAN_NETWORK", str(network))
988 listed = tmp_path / "hubs.json"
989 listed.write_text(json.dumps({"hubs": ["http://127.0.0.1:9", b.url]}))
990 monkeypatch.setenv("PYRTAN_HUB_LIST", str(listed))
991 monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json"))
992 monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False)
993 default = default_network()
994 assert default is not None and default.name == "pyrtan" and default.public
995 # The first hub in the default network is a network of one: nobody to follow yet.
996 code, out, _ = run("join", "--at", b.url, "--token", "token-b", "--listed")
997 assert code == 0 and "in pyrtan; no hub in it accepted a follow yet" in out and b.fed.following == {}
998 # `pyrtan follow` with no URL is `pyrtan join`: A is in the default network, following the listed hub in it.
999 code, out, _ = run("follow", "--at", a.url, "--token", "token-a")
1000 assert code == 0 and f"in pyrtan, following {b.url}" in out and "invite others" not in out # a public network needs no invite
1001 assert a.fed.following[b.url].network == default.id
1002 code, out, _ = run("network", "list", "--at", a.url, "--token", "token-a")
1003 assert code == 0 and f"pyrtan {default.id} public following 1" in out
1004 # Its own network, by command: the invite, then another hub joins with it.
1005 code, out, _ = run("network", "new", "home", "--at", b.url, "--token", "token-b")
1006 invite = next(line.split("pyrtan join ", 1)[1] for line in out.splitlines() if "pyrtan join " in line)
1007 assert code == 0 and invite.startswith("home:") and invite.endswith(f"@{b.url}")
1008 code, out, _ = run("join", invite, "--at", a.url, "--token", "token-a")
1009 assert code == 0 and "in home" in out and a.fed.following[b.url].network == b.fed.networks["home"].id
1010 assert run("follow", "--at", a.url, "--token", "token-a", "--network", "home")[0] == 2 # a network needs a hub to follow in it
1011 code, out, _ = run("network", "leave", "home", "--at", a.url, "--token", "token-a")
1012 assert code == 0 and "left home" in out and "home" not in a.fed.networks
1013 assert run("network", "new", "--at", a.url, "--token", "token-a")[0] == 2
1014 # A hub started with --join default is in it from the start.
1015 hub, srv = serve_hub(port=0, quiet=True, name="d", join=["default"])
1016 try:
1017 assert hub.federation is not None and hub.federation.following[b.url].network == default.id
1018 finally:
1019 hub.stop()
1020 srv.stop()
1021 # The snapshot keeps the default network's part of each directory and nobody else's.
1022 b.fed.merge_directory([{"id": "http://private.example", "name": "p", "trees": [], "seen": time.time()}], via="x", network="0123456789abcdef")
1023 b.fed.merge_directory([{"id": "http://public.example", "name": "q", "trees": [], "seen": time.time()}], via="x", network=default.id)
1024 b.hub.token = None # as anyone sees it, the private part included: the snapshot must still drop it
1025 out = tmp_path / "directory.json"
1026 done = subprocess.run([_sys.executable, "site/snapshot.py", str(listed), str(out), "--network", str(network)], capture_output=True, text=True, cwd=str(ROOT))
1027 assert done.returncode == 0, done.stderr
1028 ids = {h["id"] for h in json.loads(out.read_text())["hubs"]}
1029 assert "http://public.example" in ids and "http://private.example" not in ids and b.url in ids