pyrtan / sourcev0.14.0 · main

Add the seed hub at seed.pyrtan.com, deployed on Fly.io

Claude · 2026-10-27 · 9f29c1781c9c0c42d4496dd7a8a27c869806addd · parents ad3c1f6

seeds.json was empty, so the landing page's directory had nothing to ask and `pyrtan follow` with no URL had nothing to follow. deploy/fly holds a federated, listed hub that never sleeps, keeps its follows and directory on a volume, and holds only the examples that read public APIs, with --egress public and a token. Its image is the root Dockerfile's, except that it hands the root-owned Fly volume to the pyrtan user before dropping to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gddz8MgVYLGb2iZ1cRkNdE

Changes

 deploy/README.md           |  3 ++-
 deploy/fly/Dockerfile      | 23 +++++++++++++++++++++++
 deploy/fly/README.md       | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 deploy/fly/fly.toml        | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++
 site/rendezvous/seeds.json |  4 +++-
 5 files changed, 130 insertions(+), 2 deletions(-)
diff --git a/deploy/README.md b/deploy/README.md
index 90721da..631cab7 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -1,7 +1,8 @@
# Running the demo on OpenMediaVault Two ways. Docker through OMV's Compose plugin is the one that fits the box;
-the systemd unit is for running it bare on the host.
+the systemd unit is for running it bare on the host. (The seed hub at
+`seed.pyrtan.com` runs on Fly.io instead; see [`fly/README.md`](fly/README.md).)
Replace `<nas-ip>` below with your NAS's address on the LAN. Both end with the demo page at `http://<nas-ip>:8700/` (Docker) or `http://<nas-ip>:8701/` diff --git a/deploy/fly/Dockerfile b/deploy/fly/Dockerfile new file mode 100644 index 0000000..a7cdf51 --- /dev/null +++ b/deploy/fly/Dockerfile
@@ -0,0 +1,23 @@
+# Pyrtan's image for Fly.io: the root Dockerfile's, but it starts as root
+# just long enough to hand the volume at /data to the pyrtan user, since a
+# Fly volume mounts root-owned, then runs pyrtan as that user. Build it from
+# the repository root; see README.md beside this file.
+FROM python:3.12-slim AS build
+WORKDIR /src
+COPY pyproject.toml README.md LICENSE ./
+COPY src ./src
+RUN pip install --no-cache-dir --prefix=/install .
+
+FROM python:3.12-slim
+LABEL org.opencontainers.image.title="pyrtan" \
+ org.opencontainers.image.source="https://github.com/b-i-t-8/pyrtan" \
+ org.opencontainers.image.licenses="AGPL-3.0-or-later"
+COPY --from=build /install /usr/local
+COPY examples /examples
+RUN chmod -R a+rX /examples \
+ && useradd --system --uid 10001 --home /nonexistent --shell /usr/sbin/nologin pyrtan \
+ && mkdir -p /data && chown pyrtan:pyrtan /data
+ENV PYTHONUNBUFFERED=1
+EXPOSE 8700
+ENTRYPOINT ["sh", "-c", "chown pyrtan:pyrtan /data && exec setpriv --reuid=pyrtan --regid=pyrtan --init-groups --no-new-privs pyrtan \"$@\"", "pyrtan"]
+CMD ["hub", "--host", "0.0.0.0", "--port", "8700"]
diff --git a/deploy/fly/README.md b/deploy/fly/README.md new file mode 100644 index 0000000..8f3e027 --- /dev/null +++ b/deploy/fly/README.md
@@ -0,0 +1,52 @@
+# The seed hub on Fly.io
+
+`seed.pyrtan.com` is the first seed in
+[`site/rendezvous/seeds.json`](../../site/rendezvous/seeds.json): the hub
+`pyrtan follow` with no URL follows first, and the one the landing page
+asks for the directory. It is an ordinary federated hub, started
+`--listed` and `--cors https://pyrtan.com`, that never sleeps, keeps its
+follows and directory on a volume, and holds the examples that only read
+public APIs (weather, hacker-news, github) with `--egress public`. The
+other examples stay off it: they run a shell, read files or a serial
+line, or fetch any host.
+
+Everything is in [`fly.toml`](fly.toml). The image is the root
+Dockerfile's with one change: it starts as root to hand the volume at
+`/data` to the `pyrtan` user (a Fly volume mounts root-owned), then runs
+as that user with no new privileges.
+
+## First deploy
+
+From the repository root, with [`flyctl`](https://fly.io/docs/flyctl/)
+signed in:
+
+```
+fly apps create pyrtan-seed
+fly volumes create pyrtan_seed --app pyrtan-seed --region iad --size 1 --yes
+fly secrets set --app pyrtan-seed --stage PYRTAN_TOKEN="$(openssl rand -hex 24)"
+fly deploy . --config deploy/fly/fly.toml --dockerfile deploy/fly/Dockerfile
+fly ips allocate-v4 --shared --app pyrtan-seed
+fly ips allocate-v6 --app pyrtan-seed
+fly certs add seed.pyrtan.com --app pyrtan-seed
+```
+
+Then at the DNS provider, `CNAME seed -> pyrtan-seed.fly.dev`, and
+`fly certs check seed.pyrtan.com --app pyrtan-seed` until it says the
+certificate is issued. Keep the token: it is what `pyrtan gaps`,
+`unanswered` and the console need to see more than `/actor` and
+`/directory`, which stay open without it, as do signed requests from
+federated hubs.
+
+Check it:
+
+```
+curl https://seed.pyrtan.com/health
+curl https://seed.pyrtan.com/directory
+pyrtan directory
+```
+
+## Updating
+
+`fly deploy . --config deploy/fly/fly.toml --dockerfile deploy/fly/Dockerfile`
+from a checkout of `main`. The volume keeps who the seed follows and is
+followed by, the directory, its blobs and its unanswered sentences.
diff --git a/deploy/fly/fly.toml b/deploy/fly/fly.toml new file mode 100644 index 0000000..4b593a0 --- /dev/null +++ b/deploy/fly/fly.toml
@@ -0,0 +1,50 @@
+# The seed hub at seed.pyrtan.com, the first entry in
+# site/rendezvous/seeds.json: a federated hub that is always up, lists
+# itself in the directory, and holds the examples that only read public
+# APIs. Deploy from the repository root:
+#
+# fly deploy . --config deploy/fly/fly.toml --dockerfile deploy/fly/Dockerfile
+#
+# First time, and the token, in README.md beside this file.
+
+app = "pyrtan-seed"
+primary_region = "iad"
+
+[env]
+ PYRTAN_ADVERTISE = "https://seed.pyrtan.com"
+ PYRTAN_LISTED = "true"
+ PYRTAN_REPORT_GAPS = "true"
+ PYRTAN_STATE = "/data/federation.json"
+ PYRTAN_STORE = "/data/store"
+ PYRTAN_UNANSWERED = "/data/unanswered.jsonl"
+
+# Only the trees that read public APIs: the others run a shell, read files
+# or a serial line, or fetch any host, none of which a public box should do
+# for strangers. PYRTAN_TOKEN is a secret (fly secrets set); /actor,
+# /directory and signed federation requests stay open without it.
+[processes]
+ app = "hub --host 0.0.0.0 --port 8700 --name seed --trees /examples/weather --trees /examples/hacker-news --trees /examples/github --gaps /data/gaps.jsonl --egress public --cors https://pyrtan.com"
+
+[[mounts]]
+ source = "pyrtan_seed"
+ destination = "/data"
+
+[http_service]
+ internal_port = 8700
+ force_https = true
+ # A seed answers follows and gossips the directory; it never sleeps.
+ auto_stop_machines = "off"
+ auto_start_machines = true
+ min_machines_running = 1
+ processes = ["app"]
+
+ [[http_service.checks]]
+ method = "GET"
+ path = "/health"
+ interval = "30s"
+ timeout = "5s"
+ grace_period = "10s"
+
+[[vm]]
+ size = "shared-cpu-1x"
+ memory = "256mb"
diff --git a/site/rendezvous/seeds.json b/site/rendezvous/seeds.json index 0b17941..9a63b00 100644 --- a/site/rendezvous/seeds.json +++ b/site/rendezvous/seeds.json
@@ -1,5 +1,7 @@
{ "pyrtan-seeds": 1, "about": "Hubs to follow first. `pyrtan follow` with no URL takes the first that answers /actor. Add yours by pull request to the site repository; a seed is any federated hub whose operator keeps it up and started it with --listed and --cors https://pyrtan.com.",
- "hubs": []
+ "hubs": [
+ "https://seed.pyrtan.com"
+ ]
}