From 73c362de96c21f023a32bd64589adb2d16ef99da Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 02:53:58 +0200 Subject: [PATCH] Town: Pleroma joins, and a rerun finds what it made Pleroma 2.10.2 runs in the pasture from its OTP release (libvips, the dedupe question, and hackney pointed at the system CA bundle); its driver is Akkoma's on its own container and database. The pair passes 241 cells, after its first run showed Pleroma dropping Follows it could not verify yet. The PrivaPub driver finds the group an earlier run made instead of failing on the name; the seeder posts a quote or reply of a post from its own round after that post. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- CLAUDE.md | 4 ++++ docs/INTEROP.md | 12 ++++++++++ tools/pasture/Caddyfile | 5 ++++ tools/pasture/images/pleroma/Containerfile | 16 +++++++++++++ tools/pasture/images/pleroma/entrypoint.sh | 27 ++++++++++++++++++++++ tools/pasture/peers/pleroma.sh | 19 +++++++++++++++ tools/pasture/town/dialects/__init__.py | 2 ++ tools/pasture/town/dialects/pleroma.py | 11 +++++++++ tools/pasture/town/dialects/privapub.py | 12 ++++++++-- tools/pasture/town/gen.py | 10 ++++---- tools/pasture/town/seed.py | 8 ++++++- tools/pasture/town/specs/pleroma-pair.json | 22 ++++++++++++++++++ 12 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 tools/pasture/images/pleroma/Containerfile create mode 100755 tools/pasture/images/pleroma/entrypoint.sh create mode 100644 tools/pasture/peers/pleroma.sh create mode 100644 tools/pasture/town/dialects/pleroma.py create mode 100644 tools/pasture/town/specs/pleroma-pair.json diff --git a/CLAUDE.md b/CLAUDE.md index de13642..b14bcda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -494,6 +494,10 @@ tools/pasture/run.sh down # removes e - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. +- **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs + libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only + certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers + an inbox POST at once and verifies in a worker (`oban_jobs` shows what it refused and why). Town only, no scenario. - **Iceshrimp.NET (2026.1.2-beta):** on the shared Postgres with AuthorizedFetch on and open registrations, trusting Caddy's CA through `SSL_CERT_FILE`. Accounts come from its own `/api/iceshrimp/auth/register` (its login cuts the connection short for a name it does not know), Mastodon API tokens from its OAuth form, which prints the out-of-band diff --git a/docs/INTEROP.md b/docs/INTEROP.md index b6588a5..68b269f 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -417,6 +417,18 @@ Seen along the way: - It records our `Block` (`user_relationships`) but never reports a remote blocker as `blocked_by`. - Its streamer crashes rendering a new DM conversation (`ConversationView`, a nil `last_status`); delivery is unaffected. +**Town evidence (2026-10-05, Pleroma 2.10.2, `town.sh seed pleroma-pair`):** 241 cells pass between two personas and +three Pleroma accounts: delivery and confinement at every visibility both ways, likes, boosts, reactions and votes +counted, threads, edits and deletes, follows of locked accounts, and the privacy rows. Its first run found two things: +- **A Follow taken and then lost.** Pleroma answers an inbox POST 200 and checks the signature in a worker; while it + could not fetch our actor, it dropped our Follows there, and they stayed pending on our side for good. A pending + request is now sent again when the persona follows again (FEDERATION.md, Delivery). +- **Duplicates are refused, harmlessly.** A Create whose object Pleroma already holds (fetched for a reply, or + delivered to another inbox first) is cancelled with "The object to create already exists". + +Pleroma federates through hackney, which trusts certifi's compiled-in roots, never a file: the pasture's image names the +system bundle in its config. + ### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled` diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index c95a520..6e2c02c 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -52,3 +52,8 @@ iceshrimp.test { tls internal reverse_proxy pasture-iceshrimp:3000 } + +pleroma.test { + tls internal + reverse_proxy pasture-pleroma:4000 +} diff --git a/tools/pasture/images/pleroma/Containerfile b/tools/pasture/images/pleroma/Containerfile new file mode 100644 index 0000000..3bb7606 --- /dev/null +++ b/tools/pasture/images/pleroma/Containerfile @@ -0,0 +1,16 @@ +# Pleroma publishes no image, so this is its OTP release on the Ubuntu it is built for (24.04 for 2.10). The "stable" +# zip moves; the checksum pins 2.10.2 and fails the build when stable has moved on. Its image handling (vix) needs libvips. +FROM docker.io/library/ubuntu:24.04 +ARG PLEROMA_ZIP=https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-stable-amd64/latest/pleroma.zip +ARG PLEROMA_SHA256=5b6ab701685c1254145e33426d8775d4e159b5a17cc54a8c45d81ae0d9adc0b5 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl unzip libncurses6 libmagic1 libmagic-mgc file postgresql-client libimage-exiftool-perl ffmpeg imagemagick libvips42t64 \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL "$PLEROMA_ZIP" -o /tmp/pleroma.zip \ + && echo "$PLEROMA_SHA256 /tmp/pleroma.zip" | sha256sum -c - \ + && unzip -q /tmp/pleroma.zip -d /tmp && mv /tmp/release /opt/pleroma && rm /tmp/pleroma.zip \ + && mkdir -p /var/lib/pleroma/uploads /var/lib/pleroma/static /etc/pleroma +ENV PLEROMA_CONFIG_PATH=/etc/pleroma/config.exs LANG=C.UTF-8 ELIXIR_ERL_OPTIONS=+fnu +COPY entrypoint.sh /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/tools/pasture/images/pleroma/entrypoint.sh b/tools/pasture/images/pleroma/entrypoint.sh new file mode 100755 index 0000000..f6e666f --- /dev/null +++ b/tools/pasture/images/pleroma/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Trusts the pasture's CA, writes the config on first start, migrates, and runs Pleroma in the foreground. +set -e +cp /pasture/ca/root.crt /usr/local/share/ca-certificates/pasture.crt && update-ca-certificates >/dev/null +# Mint and Gun read the bundles shipped in the release (CAStore, certifi), never the system's +for bundle in /opt/pleroma/lib/castore-*/priv/cacerts.pem /opt/pleroma/lib/certifi-*/priv/cacerts.pem; do + [ -f "$bundle" ] || continue + grep -q "pasture" "$bundle" || { echo "# pasture"; cat /pasture/ca/root.crt; } >> "$bundle" +done +if [ ! -f /etc/pleroma/config.exs ]; then + # pleroma_ctl passes its arguments on unquoted, so no value may contain a space + env -u PLEROMA_CONFIG_PATH /opt/pleroma/bin/pleroma_ctl instance gen --force --output /etc/pleroma/config.exs --output-psql /tmp/setup.psql \ + --domain pleroma.test --instance-name PasturePleroma --admin-email admin@pleroma.test --notify-email admin@pleroma.test \ + --dbhost postgres --dbname pleroma --dbuser pasture --dbpass pasture --rum N --indexable N --db-configurable N \ + --uploads-dir /var/lib/pleroma/uploads --static-dir /var/lib/pleroma/static --listen-ip 0.0.0.0 --listen-port 4000 \ + --strip-uploads-location N --read-uploads-description Y --anonymize-uploads N --dedupe-uploads N \ + > /etc/pleroma/config.exs <<'EOF' + +# pasture: system CA bundle +config :pleroma, :http, adapter: [ssl_options: [cacertfile: "/etc/ssl/certs/ca-certificates.crt"]] +EOF +/opt/pleroma/bin/pleroma_ctl migrate +exec /opt/pleroma/bin/pleroma start diff --git a/tools/pasture/peers/pleroma.sh b/tools/pasture/peers/pleroma.sh new file mode 100644 index 0000000..272abd7 --- /dev/null +++ b/tools/pasture/peers/pleroma.sh @@ -0,0 +1,19 @@ +# Pleroma 2.10: our own image (images/pleroma: the OTP release, pinned by checksum) on the shared Postgres. Akkoma's +# parent, with its inbox guard (400 to Move, QuoteRequest and Bite), InlineQuotePolicy's "RT:" rewrite of quotes, +# chats, and outgoing Block on by default. Users are made with pleroma_ctl and sign in with the password grant. +PLEROMA_IMAGE=${PLEROMA_IMAGE:-localhost/pasture-pleroma:2.10.2} +. "$here/peers/shared.sh" + +pleroma_up() { + shared_postgres_up + podman image exists "$PLEROMA_IMAGE" || podman build -q -t "$PLEROMA_IMAGE" "$here/images/pleroma" >/dev/null + pg_db pleroma citext pg_trgm uuid-ossp + mkdir -p "$here/.state/pleroma" + podman run -d --replace --name pasture-pleroma --network $net \ + -v "$here/.state/pleroma:/etc/pleroma:z" -v "$ca:/pasture/ca:z,ro" "$PLEROMA_IMAGE" >/dev/null + for _ in $(seq 1 150); do + site pleroma.test -s -o /dev/null -w '%{http_code}' https://pleroma.test:6443/api/v1/instance 2>/dev/null | grep -q 200 && break + sleep 3 + done + echo "pleroma: https://pleroma.test:6443" +} diff --git a/tools/pasture/town/dialects/__init__.py b/tools/pasture/town/dialects/__init__.py index e6cfab9..3f364f8 100644 --- a/tools/pasture/town/dialects/__init__.py +++ b/tools/pasture/town/dialects/__init__.py @@ -6,6 +6,7 @@ from dialects.iceshrimp import Iceshrimp from dialects.lemmy_api import LemmyApi from dialects.mastodon import Mastodon from dialects.misskey_api import Misskey, Sharkey +from dialects.pleroma import Pleroma from dialects.privapub import PrivaPub DRIVERS = { @@ -18,6 +19,7 @@ DRIVERS = { "lemmy": (LemmyApi, "lemmy.test"), "hollo": (Hollo, "hollo.test"), "iceshrimp": (Iceshrimp, "iceshrimp.test"), + "pleroma": (Pleroma, "pleroma.test"), } _made = {} diff --git a/tools/pasture/town/dialects/pleroma.py b/tools/pasture/town/dialects/pleroma.py new file mode 100644 index 0000000..52a2a5b --- /dev/null +++ b/tools/pasture/town/dialects/pleroma.py @@ -0,0 +1,11 @@ +"""Pleroma 2.10: Akkoma's parent, driven exactly as Akkoma (pleroma_ctl, the password grant, its Postgres) from its own +container and database. What differs on the wire (the inbox's 400 to Move, QuoteRequest and Bite; quotes rewritten to +"RT:" by InlineQuotePolicy) is the checker's business, not the driver's.""" +from dialects.akkoma import Akkoma + + +class Pleroma(Akkoma): + platform = "pleroma" + container = "pasture-pleroma" + ctl = "/opt/pleroma/bin/pleroma_ctl" + db = "pleroma" diff --git a/tools/pasture/town/dialects/privapub.py b/tools/pasture/town/dialects/privapub.py index 6cb6dd9..cfd8af2 100644 --- a/tools/pasture/town/dialects/privapub.py +++ b/tools/pasture/town/dialects/privapub.py @@ -69,11 +69,19 @@ class PrivaPub(MastodonApi): # -- groups: communities (FEP-1b12) and circles def group(self, s, username, name, community, policy="followers", approve=False): jwt = self.jwt(s.account.root, s.account.password) - r = self.http.post(self.base + "/clientapi/group/insert", headers={"Authorization": f"Bearer {jwt}"}, ok={200, 201}, + auth = {"Authorization": f"Bearer {jwt}"} + r = self.http.post(self.base + "/clientapi/group/insert", headers=auth, json={"avatarId": s.local_id, "userName": username, "name": name, "description": name, "isCommunity": community, "postingPolicy": policy, "isDiscoverable": community, "manuallyApprovesMembers": approve}) - return r.json() + if r.ok: + return r.json() + # a run on accounts an earlier run made finds the group it made + mine = self.http.get(self.base + "/clientapi/group/list", headers=auth, params={"avatarId": s.local_id}, ok={200}).json() or [] + found = next((g for g in mine if g.get("userName") == username), None) + if found is None: + raise RuntimeError(f"/clientapi/group/insert answered {r.status}: {r.text[:300]}") + return found # -- content def post(self, s, spec): diff --git a/tools/pasture/town/gen.py b/tools/pasture/town/gen.py index 24646e3..0f7d05f 100644 --- a/tools/pasture/town/gen.py +++ b/tools/pasture/town/gen.py @@ -20,10 +20,10 @@ from core.rng import Rng GENERATOR_VERSION = 1 HOSTS = {"privapub": "privapub.test", "gts": "gts.test", "mastodon": "mastodon.test", "misskey": "misskey.test", "sharkey": "sharkey.test", "akkoma": "akkoma.test", "lemmy": "lemmy.test", "hollo": "hollo.test", - "iceshrimp": "iceshrimp.test"} + "iceshrimp": "iceshrimp.test", "pleroma": "pleroma.test"} SHORT = {"privapub": "pp", "gts": "gt", "mastodon": "ms", "misskey": "mk", "sharkey": "sk", "akkoma": "ak", "lemmy": "lm", - "hollo": "ho", "iceshrimp": "is"} -MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp") + "hollo": "ho", "iceshrimp": "is", "pleroma": "pl"} +MICRO = ("privapub", "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo", "iceshrimp", "pleroma") CAPS = { "privapub": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", "dm", "delete", "edit", "vote", "react", "quote", "profile", "circle", "community", "located"}, @@ -37,6 +37,8 @@ CAPS = { "report", "dm", "delete", "vote", "quote", "profile", "edit"}, "akkoma": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", "dm", "delete", "edit", "vote", "react", "quote", "profile"}, + "pleroma": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", + "dm", "delete", "edit", "vote", "react", "quote", "profile"}, "lemmy": {"thread", "comment", "upvote", "downvote", "dm", "delete", "community", "edit", "block"}, "hollo": {"post", "reply", "cw", "media", "poll", "like", "boost", "bookmark", "follow", "block", "mute", "report", "dm", "delete", "edit", "vote", "quote", "react", "profile"}, @@ -195,7 +197,7 @@ class Planner: # circles: members are local siblings' neighbours and remote followers of the owner for ref, grp in sorted(self.groups.items()): if grp["kind"] == "circle": - pool = sorted(k for k in micro if k != grp["owner"] and k.split("/")[0] in ("privapub", "gts", "mastodon", "akkoma", "hollo", "iceshrimp")) + pool = sorted(k for k in micro if k != grp["owner"] and k.split("/")[0] in ("privapub", "gts", "mastodon", "akkoma", "hollo", "iceshrimp", "pleroma")) for member in rng.sample(pool, min(len(pool), self.spec.get("circleMembers", 4))): grp["members"].add(member) self.step("graph", member, "join", {"group": ref}) diff --git a/tools/pasture/town/seed.py b/tools/pasture/town/seed.py index 2d8d0bd..e0e5a9a 100644 --- a/tools/pasture/town/seed.py +++ b/tools/pasture/town/seed.py @@ -97,7 +97,13 @@ class Seeder: for r in rounds: if r > 0: time.sleep(self.timing.get("roundSettleSeconds", 20)) - self.parallel([s for s in steps if s["round"] == r]) + # a post quoting or answering one of its own round goes once that one is made + these = [s for s in steps if s["round"] == r] + made_here = {s["ref"] for s in these} + later = [s for s in these if {s["args"].get("quote"), s["args"].get("reply_to")} & made_here] + self.parallel([s for s in these if s not in later]) + if later: + self.parallel(later) else: if phase in ("interact", "mutate"): time.sleep(self.timing.get("roundSettleSeconds", 20)) diff --git a/tools/pasture/town/specs/pleroma-pair.json b/tools/pasture/town/specs/pleroma-pair.json new file mode 100644 index 0000000..b446478 --- /dev/null +++ b/tools/pasture/town/specs/pleroma-pair.json @@ -0,0 +1,22 @@ +{ + "schema": "pasture-town/1", + "name": "pleroma-pair", + "seed": 20261007, + "peers": { + "privapub": {"roots": 1, "personas": [2, 2], "circles": 1, "communities": 0}, + "pleroma": {"accounts": 3} + }, + "profiles": {"locked": 0.2, "bot": 0.0, "fields": [0, 2], "avatar": 0.9, "header": 0.3, "bioWords": [5, 12], + "langs": {"en": 80, "it": 20}}, + "graph": {"follows": [2, 4], "mutual": 0.6, "pending": 0.0, "rejected": 0.0}, + "circleMembers": 2, + "content": { + "posts": 24, + "mix": {"text": 40, "cw": 10, "tags": 10, "mention": 10, "image": 10, "poll": 10, "quote": 10}, + "visibility": {"public": 50, "unlisted": 10, "followers": 20, "direct": 10, "circle": 10}, + "replyRounds": 2, "replies": 0.5, "deeperReplies": 0.4 + }, + "interactions": {"likes": [0, 3], "boosts": [0, 1], "react": 0.4, "vote": 0.8, "bookmark": 0.1}, + "mutations": {"edit": 0.15, "delete": 0.05, "blocks": 0, "mutes": 1, "reports": 0}, + "time": {"roundSettleSeconds": 15, "graphSettleSeconds": 20, "settleSeconds": 30, "deadlineSeconds": 120} +}