pyrtan / sourcev0.14.0 · main

Bake an hourly snapshot of the directory into pyrtan.com

Claude · 2026-10-25 · 4ee901825f70813e637b1b61c07c87e7d6c3e0f8 · parents 2c4c3ff

Seeds are home boxes, and home boxes go off. site/snapshot.py (standard library only) asks each seed's /directory, merges the answers with the snapshot already published so a build with every seed down keeps the last good entries, drops anything unheard of for a week, and writes rendezvous/directory.json with its time. The pages workflow runs it on every build and once an hour on a schedule, so GitHub does the polling and nothing of ours runs.

The landing page shows the live directory when a seed answers the visitor's browser and the snapshot, marked with its time, when none does, with each entry's link recomputed from when it was last heard. `pyrtan directory` falls back to the snapshot the same way, and `pyrtan follow` with no URL tries the snapshot's hubs after the seeds, most recently heard first, so a new hub can bootstrap from any listed hub that is up, not only from a seed. The snapshot is consulted only when the seeds fail, and tests point $PYRTAN_SNAPSHOT at a file so they never reach pyrtan.com.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YChr78vDHtQJ15HNbsaRLZ

Changes

 .github/workflows/pages.yml |   9 ++++--
 README.md                   |   5 +--
 _meta/federation.md         |  12 +++++++
 site/README.md              |  16 +++++++---
 site/build.sh               |   6 ++++
 site/index.html             |  14 ++++++--
 site/snapshot.py            | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/pyrtan/cli.py           |  27 +++++++++++++---
 src/pyrtan/federation.py    |  47 ++++++++++++++++++++++-----
 tests/test_federation.py    |  51 ++++++++++++++++++++++++++++-
 10 files changed, 275 insertions(+), 24 deletions(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 0b228bd..4330fcd 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -1,6 +1,7 @@
-# pyrtan.com: this repository's GitHub Pages site. On every push to main,
-# build the landing page, the console (from this checkout's pyrtan), the
-# catalog (from examples/) and the seed list, and publish them. Set the
+# pyrtan.com: this repository's GitHub Pages site. On every push to main
+# and once an hour, build the landing page, the console (from this
+# checkout's pyrtan), the catalog (from examples/), the seed list and a
+# snapshot of the directory as the seeds know it, and publish them. Set the
# repository's Pages source to "GitHub Actions" and its custom domain to # pyrtan.com; site/CNAME carries the domain.
@@ -10,6 +11,8 @@ on:
push: branches: [main] workflow_dispatch:
+ schedule:
+ - cron: "23 * * * *" # hourly: refresh rendezvous/directory.json from the seeds
permissions: contents: read diff --git a/README.md b/README.md index 1526317..d9ec28a 100644 --- a/README.md +++ b/README.md
@@ -190,8 +190,9 @@ entry for itself (name, URL, the trees it holds) in every announce it
sends, every hub keeps the entries it hears and passes them on in both directions of every follow, and `pyrtan directory` (or the console, or any hub's `/directory`) shows the result. pyrtan.com publishes only
-`rendezvous/seeds.json`, a static list of hubs to follow first; `pyrtan
-follow` with no URL takes the first that answers. An entry is a hint,
+`rendezvous/seeds.json`, a static list of hubs to follow first, and an
+hourly snapshot of what they know; `pyrtan follow` with no URL takes
+the first seed that answers, then the first hub from the snapshot. An entry is a hint,
not a route: to use a hub you found, follow it. A hub can also hold trees itself, `pyrtan hub --trees DIR`, so a whole federated swarm can be one process on one box. The answer to a federated diff --git a/_meta/federation.md b/_meta/federation.md index 487bc93..3d6fbd4 100644 --- a/_meta/federation.md +++ b/_meta/federation.md
@@ -218,6 +218,18 @@ ordinary federated hub whose operator keeps it up; the first one is the
author's NAS. `$PYRTAN_SEEDS` points at another list (a URL or a file), `$PYRTAN_RENDEZVOUS` at one hub.
+**The snapshot.** Seeds are home boxes, and home boxes go off. So the
+site's build, which GitHub runs on every push and once an hour, asks each
+seed's `/directory` and bakes the merged answer into
+`rendezvous/directory.json` with the time, keeping the last snapshot's
+entries for a seed that did not answer and dropping anything a week old
+(`site/snapshot.py`, standard library, no pyrtan). The landing page
+shows it when no seed answers the visitor's browser; `pyrtan directory`
+falls back to it; `pyrtan follow` with no URL tries its hubs after the
+seeds (`$PYRTAN_SNAPSHOT` overrides). A new hub can therefore bootstrap
+from any listed hub that is up, and the only way the directory is empty
+is if nobody has been up for a week. Nothing of ours polls: GitHub does.
+
**What an entry is not.** A hub two follows away cannot verify who made an entry: there are no public keys in the standard library, and HMAC proves things only between two hubs that share a secret. So an entry is diff --git a/site/README.md b/site/README.md index 53139fe..aabecff 100644 --- a/site/README.md +++ b/site/README.md
@@ -1,8 +1,8 @@
# pyrtan.com
-pyrtan.com is this repository's GitHub Pages site. Nothing runs: on every
-push to `main`, the `pages` workflow builds a static directory and
-publishes it.
+pyrtan.com is this repository's GitHub Pages site. Nothing of ours runs:
+on every push to `main`, and once an hour, the `pages` workflow builds a
+static directory and publishes it.
| Path | What | Built from | | :-- | :-- | :-- |
@@ -10,6 +10,7 @@ publishes it.
| `/console/` | the console; point it at any hub started with `--cors https://pyrtan.com` | `pyrtan console --out` | | `/catalog/` | the trees people can `pyrtan pull`, and `index.json` the command reads | `pyrtan index examples`, and the tree files | | `/rendezvous/seeds.json` | hubs to follow first | `site/rendezvous/seeds.json` |
+| `/rendezvous/directory.json` | a snapshot of the directory as the seeds know it, refreshed hourly | `site/snapshot.py` |
`site/build.sh out` builds the same thing locally; open `out/index.html`.
@@ -53,7 +54,14 @@ behind TLS; [`deploy/README.md`](../deploy/README.md) says how). Put
yours in by pull request. The landing page asks each seed's `/directory` from the visitor's browser, so the directory on pyrtan.com is live and needs no backend; the console link on each row opens
-`/console/?hub=<url>`.
+`/console/?hub=<url>`. When no seed answers (every seed is a home box,
+and home boxes go off), the page shows the snapshot instead, marked with
+its time: `site/snapshot.py` asks the seeds on every build, hourly on
+the schedule, keeps the last published snapshot's entries for any seed
+that is down, and drops entries unheard of for a week. `pyrtan follow`
+with no URL tries the snapshot's hubs after the seeds, and `pyrtan
+directory` falls back to it, so a new hub can bootstrap from any listed
+hub that happens to be up, not only from a seed.
## Later: hosting diff --git a/site/build.sh b/site/build.sh index 5e33dc7..5811e0f 100755 --- a/site/build.sh +++ b/site/build.sh
@@ -10,6 +10,12 @@ mkdir -p "$out/console" "$out/catalog" "$out/rendezvous"
out="$(cd "$out" && pwd)" cp site/index.html site/CNAME "$out/" cp site/rendezvous/seeds.json "$out/rendezvous/"
+# The directory as the seeds know it now, keeping the last published
+# snapshot's entries when a seed is down (SNAPSHOT_PREVIOUS may add more).
+python3 site/snapshot.py site/rendezvous/seeds.json "$out/rendezvous/directory.json" \
+ --previous https://pyrtan.com/rendezvous/directory.json \
+ --previous https://b-i-t-8.github.io/pyrtan/rendezvous/directory.json \
+ ${SNAPSHOT_PREVIOUS:+--previous "$SNAPSHOT_PREVIOUS"}
cp site/catalog/index.html "$out/catalog/" pyrtan console --out "$out/console/index.html" pyrtan check examples --quiet diff --git a/site/index.html b/site/index.html index dc17dfe..37bc9ef 100644 --- a/site/index.html +++ b/site/index.html
@@ -72,7 +72,7 @@ pyrtan serve trees --port 8701 # then open http://127
<p>Every hub and service serves the console at <code>/</code>. The same console is at <a href="console/">pyrtan.com/console</a>: point it at your own hub, started with <code>--token</code> and <code>--cors https://pyrtan.com</code>, and ask it from anywhere. Nothing about your swarm passes through this site; the page talks to your hub directly.</p> <h2 id="directory">The directory</h2>
- <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This page asks the <a href="rendezvous/seeds.json">seed hubs</a> from your browser and shows what they know. An entry is a hint, not a route: to use a hub, follow it from your own.</p>
+ <p>Hubs find each other without a server. A hub started <code>--listed</code> announces itself to the hubs it follows and that follow it; every hub keeps what it hears and passes it on. This page asks the <a href="rendezvous/seeds.json">seed hubs</a> from your browser and shows what they know; when none answers, it shows the <a href="rendezvous/directory.json">snapshot</a> pyrtan.com takes from them every hour. An entry is a hint, not a route: to use a hub, follow it from your own.</p>
<div class="card"><table id="hubs"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th></th></tr></thead><tbody><tr><td colspan="4" class="mute" id="hubs-note">Asking the seeds…</td></tr></tbody></table></div> <pre>pyrtan hub --federate --advertise https://your-hub --listed --token … # your hub, behind a tunnel or TLS pyrtan follow --at https://your-hub --token … # follow the first seed; route to it
@@ -102,9 +102,19 @@ const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">
for (const h of d.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h); } catch (e) {} }
- if (!answered) { note.textContent = "No seed answered from this browser (a seed must run with --cors https://pyrtan.com)."; return; }
+ let source = answered ? `live from ${answered} seed${answered > 1 ? "s" : ""}` : "";
+ if (!answered) {
+ try {
+ const snap = await (await fetch("rendezvous/directory.json")).json();
+ for (const h of snap.hubs || []) if (!seen.has(h.id) || seen.get(h.id).seen < h.seen) seen.set(h.id, h);
+ source = `no seed answered from this browser; snapshot as of ${snap.as_of || "?"}`;
+ } catch (e) { note.textContent = "No seed answered from this browser, and there is no snapshot yet."; return; }
+ }
+ const now = Date.now() / 1000;
+ for (const h of seen.values()) { const age = now - (h.seen || 0); h.link = age < 600 ? "up" : age < 86400 ? "stale" : "down"; }
const body = document.querySelector("#hubs tbody"); body.innerHTML = ""; const hubs = [...seen.values()].sort((a, b) => ({up:0, stale:1, down:2}[a.link] - {up:0, stale:1, down:2}[b.link]) || a.id.localeCompare(b.id));
+ const cap = document.createElement("tr"); cap.innerHTML = `<td colspan="4" class="mute">${esc(source)}</td>`; body.appendChild(cap);
for (const h of hubs) { const tr = document.createElement("tr"); tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code><div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td><a href="console/?hub=${encodeURIComponent(h.id)}">open in console</a></td></tr>`; diff --git a/site/snapshot.py b/site/snapshot.py new file mode 100644 index 0000000..4d9998a --- /dev/null +++ b/site/snapshot.py
@@ -0,0 +1,112 @@
+"""Bake a snapshot of the directory into the site.
+
+GitHub Actions runs this on every build and on a schedule: it asks each
+seed hub for its ``/directory``, merges what they say with the snapshot
+already published (so a build when every seed is down keeps the last good
+one rather than publishing an empty list), drops entries nobody has heard
+of for a week, and writes ``rendezvous/directory.json``. The landing page
+shows the live directory when a seed answers the visitor's browser and
+this snapshot, marked with its time, when none does; ``pyrtan follow``
+with no URL tries the hubs in it after the seeds.
+
+Standard library only; no pyrtan import, so it runs before anything is
+installed.
+
+ python3 site/snapshot.py SEEDS OUT [--previous URL_OR_FILE ...]
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as _dt
+import json
+import sys
+import time
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+TTL = 7 * 86400
+TIMEOUT = 8.0
+
+
+def fetch(source: str) -> Any:
+ if source.startswith(("http://", "https://")):
+ req = urllib.request.Request(source, headers={"Accept": "application/json", "User-Agent": "pyrtan.com snapshot"})
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+ return json.loads(Path(source).read_text(encoding="utf-8"))
+
+
+def merge(into: dict[str, dict[str, Any]], entries: Any, via: str, now: float) -> int:
+ taken = 0
+ for e in entries if isinstance(entries, list) else []:
+ if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not e["id"].startswith(("http://", "https://")):
+ continue
+ try:
+ seen = min(float(e.get("seen", 0)), now)
+ except (TypeError, ValueError):
+ continue
+ if now - seen >= TTL:
+ continue
+ hub_id = e["id"].rstrip("/")
+ current = into.get(hub_id)
+ if current is not None and current["seen"] >= seen:
+ continue
+ into[hub_id] = {"id": hub_id, "name": str(e.get("name") or "")[:80], "trees": [str(t)[:80] for t in e.get("trees", []) if isinstance(t, (str, int))][:200], "seen": seen, "via": via}
+ taken += 1
+ return taken
+
+
+def main(argv: list[str] | None = None) -> int:
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
+ ap.add_argument("seeds", help="seeds.json, a path or URL")
+ ap.add_argument("out", help="where to write directory.json")
+ ap.add_argument("--previous", action="append", default=[], help="an earlier directory.json to keep entries from (URL or path; repeatable, first that answers wins)")
+ args = ap.parse_args(argv)
+ now = time.time()
+ hubs: dict[str, dict[str, Any]] = {}
+ report: dict[str, Any] = {"answered": [], "silent": [], "previous": None}
+
+ for source in args.previous:
+ try:
+ prev = fetch(source)
+ except Exception:
+ continue
+ if isinstance(prev, dict) and isinstance(prev.get("hubs"), list):
+ merge(hubs, prev["hubs"], "snapshot", now)
+ report["previous"] = source
+ break
+
+ try:
+ seeds = [h.rstrip("/") for h in fetch(args.seeds).get("hubs", []) if isinstance(h, str)]
+ except Exception as e:
+ print(f"snapshot: cannot read seeds from {args.seeds}: {e}", file=sys.stderr)
+ seeds = []
+ for seed in seeds:
+ try:
+ d = fetch(f"{seed}/directory")
+ except Exception as e:
+ report["silent"].append({"seed": seed, "error": str(e)[:120]})
+ continue
+ if isinstance(d, dict) and isinstance(d.get("hubs"), list):
+ merge(hubs, d["hubs"], seed, now)
+ report["answered"].append(seed)
+ else:
+ report["silent"].append({"seed": seed, "error": "not a directory"})
+
+ out = {
+ "pyrtan-directory": 1,
+ "as_of": _dt.datetime.fromtimestamp(now, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "seeds": report,
+ "hubs": sorted(hubs.values(), key=lambda h: (-h["seen"], h["id"])),
+ }
+ path = Path(args.out)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8")
+ print(f"snapshot: {len(out['hubs'])} hub(s); {len(report['answered'])} seed(s) answered, {len(report['silent'])} silent" + (f"; kept entries from {report['previous']}" if report["previous"] else ""))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 716e529..7768748 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -39,7 +39,7 @@ from typing import Any
from . import __version__ from .catalog import Catalog, CatalogError, build_index, catalog_url
-from .federation import first_live_seed, seed_urls
+from .federation import bootstrap_candidates, first_live_seed
from .grow import Proposal, propose from .page import PAGE from .registry import Registry, default_paths
@@ -465,15 +465,15 @@ def cmd_follow(args: argparse.Namespace) -> int:
if not url: url = first_live_seed() if url is None:
- seeds = seed_urls()
- print("pyrtan follow: no seed answers" + (f" (tried {', '.join(seeds)})" if seeds else " (no seeds listed; set $PYRTAN_SEEDS or $PYRTAN_RENDEZVOUS)") + "; give a hub URL", file=sys.stderr)
+ tried = bootstrap_candidates()
+ print("pyrtan follow: no seed or snapshot hub answers" + (f" (tried {', '.join(tried)})" if tried else " (none listed; set $PYRTAN_SEEDS or $PYRTAN_RENDEZVOUS)") + "; give a hub URL", file=sys.stderr)
return 3 return _hub_post(args, "/follow", {"url": url, "listed": bool(args.listed)}, lambda b: f"{args.at} now follows {b['following']['url']} ({b['following']['state']}); it routes there once accepted" + (" and is listed in the directory" if b.get("listed") else "")) def cmd_directory(args: argparse.Namespace) -> int: tried: list[str] = []
- candidates = [args.at] if args.at else [os.environ.get("PYRTAN_AT") or "http://127.0.0.1:8700", *seed_urls()]
+ candidates = [args.at] if args.at else [os.environ.get("PYRTAN_AT") or "http://127.0.0.1:8700", *bootstrap_candidates()]
body: Any = None at = "" for at in candidates:
@@ -487,6 +487,25 @@ def cmd_directory(args: argparse.Namespace) -> int:
if status == 200 and isinstance(body, dict) and "hubs" in body: break body = None
+ if body is None and not args.at:
+ # Nobody live: the snapshot pyrtan.com bakes from the seeds, with its age.
+ source = os.environ.get("PYRTAN_SNAPSHOT") or "https://pyrtan.com/rendezvous/directory.json"
+ try:
+ if source.startswith(("http://", "https://")):
+ status, snap = http_json("GET", source, timeout=10.0)
+ else:
+ status, snap = 200, json.loads(Path(source).read_text(encoding="utf-8"))
+ except Exception:
+ status, snap = 0, None
+ if status == 200 and isinstance(snap, dict) and isinstance(snap.get("hubs"), list):
+ import time as _time
+
+ now = _time.time()
+ for h in snap["hubs"]:
+ age = now - float(h.get("seen", 0) or 0)
+ h["link"] = "up" if age < 600 else "stale" if age < 86400 else "down"
+ body = {**snap, "name": f"snapshot as of {snap.get('as_of', '?')}", "hub": source}
+ at = source
if body is None: print(f"pyrtan directory: no federated hub answered (tried {', '.join(tried)})", file=sys.stderr) return 3 diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 8c89bf9..759d376 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py
@@ -73,6 +73,7 @@ from . import __version__
from .service import http_json DEFAULT_SEEDS = "https://pyrtan.com/rendezvous/seeds.json"
+DEFAULT_SNAPSHOT = "https://pyrtan.com/rendezvous/directory.json"
SIGNATURE_WINDOW = 300.0 # seconds a signed message stays valid MAX_HOPS = 1 # federation is one hop; raise with care STALE_AFTER = 30.0
@@ -111,15 +112,45 @@ def seed_urls() -> list[str]:
return [h.rstrip("/") for h in body["hubs"] if isinstance(h, str) and h.startswith(("http://", "https://"))]
+def snapshot_hubs() -> list[str]:
+ """Hubs from the directory snapshot pyrtan.com bakes hourly
+ (``$PYRTAN_SNAPSHOT`` overrides the URL or names a file), most recently
+ heard first. What a new hub can try when every seed is down."""
+ source = os.environ.get("PYRTAN_SNAPSHOT") or DEFAULT_SNAPSHOT
+ try:
+ if source.startswith(("http://", "https://")):
+ status, body = http_json("GET", source, timeout=10.0)
+ if status != 200:
+ return []
+ else:
+ body = json.loads(Path(source).read_text(encoding="utf-8"))
+ except Exception:
+ return []
+ if not isinstance(body, dict) or not isinstance(body.get("hubs"), list):
+ return []
+ hubs = [h for h in body["hubs"] if isinstance(h, dict) and isinstance(h.get("id"), str) and h["id"].startswith(("http://", "https://"))]
+ hubs.sort(key=lambda h: -float(h.get("seen", 0) or 0))
+ return [h["id"].rstrip("/") for h in hubs]
+
+
+def bootstrap_candidates() -> list[str]:
+ """Where to look for a first hub: the seeds, then the snapshot's hubs."""
+ seeds = seed_urls()
+ return seeds + [h for h in snapshot_hubs() if h not in seeds]
+
+
def first_live_seed(seeds: list[str] | None = None) -> str | None:
- """The first seed whose ``/actor`` answers as a hub, or None."""
- for url in seeds if seeds is not None else seed_urls():
- try:
- status, actor = http_json("GET", f"{url}/actor", timeout=5.0)
- except Exception:
- continue
- if status == 200 and isinstance(actor, dict) and actor.get("type") == "Hub":
- return url
+ """The first seed whose ``/actor`` answers as a hub; when none does, the
+ first hub from the snapshot that answers; else None."""
+ tried = seeds if seeds is not None else seed_urls()
+ for batch in ([tried] if seeds is not None else [tried, lambda: [h for h in snapshot_hubs() if h not in tried]]):
+ for url in (batch() if callable(batch) else batch):
+ try:
+ status, actor = http_json("GET", f"{url}/actor", timeout=5.0)
+ except Exception:
+ continue
+ if status == 200 and isinstance(actor, dict) and actor.get("type") == "Hub":
+ return url
return None diff --git a/tests/test_federation.py b/tests/test_federation.py index 716e955..7d47ab6 100644 --- a/tests/test_federation.py +++ b/tests/test_federation.py
@@ -330,6 +330,7 @@ def test_seeds_and_cli_defaults(two, capsys, monkeypatch, tmp_path):
seeds = tmp_path / "seeds.json" seeds.write_text(json.dumps({"pyrtan-seeds": 1, "hubs": ["http://127.0.0.1:9", b.url, a.url]})) monkeypatch.setenv("PYRTAN_SEEDS", str(seeds))
+ monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "no-snapshot.json")) # never reach pyrtan.com from a test
monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False) assert seed_urls() == ["http://127.0.0.1:9", b.url, a.url] assert first_live_seed() == b.url # the dead one is skipped
@@ -349,7 +350,7 @@ def test_seeds_and_cli_defaults(two, capsys, monkeypatch, tmp_path):
monkeypatch.setenv("PYRTAN_SEEDS", str(tmp_path / "missing.json")) assert seed_urls() == [] code, _, err = run("follow", "--at", a.url, "--token", "token-a")
- assert code == 3 and "no seed answers" in err
+ assert code == 3 and "no seed or snapshot hub answers" in err
def test_hub_holds_trees_itself(tmp_path):
@@ -399,3 +400,51 @@ say = "picnic says {{ state.a.say }}"
finally: hub.stop() srv.stop()
+
+
+def test_snapshot_bakes_and_bootstraps(two, tmp_path, monkeypatch, capsys):
+ """site/snapshot.py merges the seeds' directories with the last snapshot,
+ and the command falls back to the snapshot when nothing is live."""
+ import subprocess
+ import sys as _sys
+
+ a, b = two
+ b.fed.set_listed(True)
+ a.post("/follow", {"url": b.url})
+ seeds = tmp_path / "seeds.json"
+ seeds.write_text(json.dumps({"pyrtan-seeds": 1, "hubs": ["http://127.0.0.1:9", a.url]}))
+ old = tmp_path / "old.json"
+ 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}]}))
+ out = tmp_path / "directory.json"
+ run = subprocess.run([_sys.executable, "site/snapshot.py", str(seeds), str(out), "--previous", str(tmp_path / "missing.json"), "--previous", str(old)], capture_output=True, text=True, cwd=str(EXAMPLES.parent))
+ assert run.returncode == 0, run.stderr
+ snap = json.loads(out.read_text())
+ assert snap["pyrtan-directory"] == 1 and snap["seeds"]["answered"] == [a.url] and snap["seeds"]["silent"][0]["seed"] == "http://127.0.0.1:9"
+ assert snap["seeds"]["previous"] == str(old)
+ ids = [h["id"] for h in snap["hubs"]]
+ assert b.url in ids and "http://gone.example" in ids and "http://ancient.example" not in ids # live from the seed, kept from the snapshot, expired
+ assert next(h for h in snap["hubs"] if h["id"] == b.url)["via"] == a.url
+
+ def cli(*argv):
+ code = main(list(argv))
+ o = capsys.readouterr()
+ return code, o.out, o.err
+
+ from pyrtan.federation import bootstrap_candidates, first_live_seed, snapshot_hubs
+
+ # With every seed dead, the snapshot's hubs are the next place to look.
+ monkeypatch.setenv("PYRTAN_SEEDS", str(tmp_path / "dead-seeds.json"))
+ (tmp_path / "dead-seeds.json").write_text(json.dumps({"hubs": ["http://127.0.0.1:9"]}))
+ monkeypatch.setenv("PYRTAN_SNAPSHOT", str(out))
+ monkeypatch.delenv("PYRTAN_RENDEZVOUS", raising=False)
+ assert snapshot_hubs()[0] == b.url # most recently heard first
+ assert bootstrap_candidates() == ["http://127.0.0.1:9", b.url, "http://gone.example"]
+ assert first_live_seed() == b.url
+ code, o, _ = cli("follow", "--at", a.url, "--token", "token-a")
+ assert code == 0 and f"now follows {b.url}" in o
+ monkeypatch.setenv("PYRTAN_AT", "http://127.0.0.1:9")
+ b.stop() # nothing live at all now: the snapshot, with its age, and link recomputed
+ code, o, _ = cli("directory")
+ assert code == 0 and "snapshot as of" in o and b.url in o and "gone" in o
+ monkeypatch.setenv("PYRTAN_SNAPSHOT", str(tmp_path / "nope.json"))
+ assert cli("directory")[0] == 3