@openmeet/snowglobe 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/commands/up.mjs +20 -19
- package/lib/config.mjs +5 -3
- package/lib/render.mjs +9 -6
- package/lib/up.mjs +14 -1
- package/package.json +3 -3
- package/snowglobe.default.yml +3 -3
- package/stack/docker-compose.yml +17 -9
- package/stack/jetstream/Dockerfile +12 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# Snowglobe
|
|
1
|
+
# <img src="assets/snowglobe.svg" width="40" alt=""> Snowglobe
|
|
2
2
|
|
|
3
3
|
Your own isolated [AT Protocol](https://atproto.com) network, for testing, research, and demos. A snowglobe has its own
|
|
4
|
-
PDS, PLC directory and Jetstream
|
|
4
|
+
PDS, PLC directory, relay and Jetstream, and nothing in it reaches Bluesky's network. It uses
|
|
5
5
|
https and the real network's sign-in (OAuth), so an app built for the real network runs against it
|
|
6
6
|
with no test-only code.
|
|
7
7
|
|
package/lib/commands/up.mjs
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
// two snowglobes need no port settings; a port variable moves one, in the environment or .env. devnet-spaces, the shared devnet, is refused (exit 3) as
|
|
42
42
|
// the project unless SG_RESET_OK=1, before anything else is done.
|
|
43
43
|
|
|
44
|
+
import { spawnSync } from 'node:child_process';
|
|
44
45
|
import { createHash } from 'node:crypto';
|
|
45
46
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
46
47
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -50,7 +51,7 @@ import { loadGlobe } from '../config.mjs';
|
|
|
50
51
|
import { ROOT, addName, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
|
|
51
52
|
import { prebuilt } from '../prebuilt.mjs';
|
|
52
53
|
import { byName, claimState, pointedAt, session } from '../state.mjs';
|
|
53
|
-
import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
|
|
54
|
+
import { accountPrefixes, endingClash, mergeRoutes, startedAfterRelay } from '../up.mjs';
|
|
54
55
|
import { account } from './account.mjs';
|
|
55
56
|
import { lexicons } from './lexicons.mjs';
|
|
56
57
|
import { checkedGlobe } from './check.mjs';
|
|
@@ -203,10 +204,6 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
203
204
|
waitServices();
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
// What runs before this run: the relay, and every container, to notice a PDS this run makes.
|
|
207
|
-
const relayBefore = ids(['-q', 'relay']);
|
|
208
|
-
const before = new Set(ids(['-aq']));
|
|
209
|
-
|
|
210
207
|
// Every image the file builds is built before anything starts, so `up` never builds while
|
|
211
208
|
// containers are being made (on a runner with no images, the relay and the other built services
|
|
212
209
|
// were made, then made again by the next `up`, and Jetstream lost the relay between the two).
|
|
@@ -250,23 +247,27 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
250
247
|
if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
|
|
251
248
|
composeEnv.SG_LEXICON_AUTHORITY_DID = did;
|
|
252
249
|
log(`Pass 2: up with the lexicon authority ${did}`);
|
|
253
|
-
// Pass 2 recreates every PDS
|
|
254
|
-
// while it is gone; on a slow machine (a CI runner) it was still restarting when compose
|
|
255
|
-
// checked it, and up failed with "jetstream is unhealthy". Stopped first, it is started
|
|
256
|
-
// again only once the PDS is healthy.
|
|
257
|
-
const running = facts('SG_SERVICES');
|
|
258
|
-
if (running.includes('jetstream') && !running.includes('relay')) compose(['stop', 'jetstream'], { stdio: 'ignore' });
|
|
250
|
+
// Pass 2 recreates every PDS, so the relay restarts after it (below).
|
|
259
251
|
await upStack();
|
|
260
252
|
}
|
|
261
253
|
|
|
262
|
-
// A relay that was running when a PDS
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
254
|
+
// A relay that was running when a PDS, or nginx in front of it, went away redials it, and after
|
|
255
|
+
// ten quick failures it waits 30 s between dials: what the PDS writes meanwhile reaches it half a
|
|
256
|
+
// minute late, or not for minutes. A PDS added back was not dialed in the nine minutes after it
|
|
257
|
+
// came back (2026-10-20), and a record written right after a first up in CI had not reached Tap
|
|
258
|
+
// 20 s later (2026-10-30). Pass 2 recreates every PDS, new certificates restart nginx, and a rerun
|
|
259
|
+
// can make a PDS. So when one of them started after the relay did, the relay restarts, and the
|
|
260
|
+
// services wait for it again.
|
|
261
|
+
if (ids(['-q', 'relay']).length) {
|
|
262
|
+
const started = (service) => {
|
|
263
|
+
const containers = ids(['-q', service]);
|
|
264
|
+
if (!containers.length) return [];
|
|
265
|
+
return (spawnSync('docker', ['inspect', '-f', '{{.State.StartedAt}}', ...containers], { encoding: 'utf8', env }).stdout ?? '').split('\n').filter(Boolean);
|
|
266
|
+
};
|
|
267
|
+
const dialed = Object.fromEntries([...facts('SG_PDS').map((x) => `pds-${x}`), 'nginx'].map((s) => [s, started(s)]));
|
|
268
|
+
const later = startedAfterRelay(started('relay'), dialed);
|
|
269
|
+
if (later) {
|
|
270
|
+
log(`Restarting the relay: ${later} started after it`);
|
|
270
271
|
compose(['restart', 'relay'], { stdio: 'ignore' });
|
|
271
272
|
waitServices();
|
|
272
273
|
}
|
package/lib/config.mjs
CHANGED
|
@@ -231,11 +231,13 @@ export function checkGlobe(doc, { baseDir } = {}) {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
|
-
// Without a relay
|
|
235
|
-
//
|
|
236
|
-
|
|
234
|
+
// Without a relay TAP follows one PDS's own firehose, and the others' writes are seen by
|
|
235
|
+
// nothing. Jetstream needs the relay even with one PDS: at start it asks the relay for the
|
|
236
|
+
// network's hosts (com.atproto.sync.listHosts), which a PDS refuses.
|
|
237
|
+
if (services.relay === 'auto') services.relay = pds.length > 1 || services.jetstream;
|
|
237
238
|
else if (services.relay === false && pds.length > 1)
|
|
238
239
|
err('services.relay', 'cannot be off with more than one PDS: only one PDS would be followed');
|
|
240
|
+
else if (services.relay === false && services.jetstream) err('services.jetstream', 'needs the relay');
|
|
239
241
|
// Slingshot reads this network's Jetstream.
|
|
240
242
|
if (services.slingshot && !services.jetstream) err('services.slingshot', 'needs jetstream');
|
|
241
243
|
|
package/lib/render.mjs
CHANGED
|
@@ -46,6 +46,7 @@ const BUILT = {
|
|
|
46
46
|
'snowglobe-dns': ['stack/https/dns', ['Dockerfile', 'dns.sh']],
|
|
47
47
|
'snowglobe-proxy': ['stack/https/proxy', ['Dockerfile', 'server.mjs']],
|
|
48
48
|
'snowglobe-pdsls': ['stack/pdsls', ['Dockerfile', 'network.patch', 'server.mjs']],
|
|
49
|
+
'snowglobe-jetstream': ['stack/jetstream', ['Dockerfile']],
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
/** The base compose files' services, which the globe's shared services start from. */
|
|
@@ -100,7 +101,8 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
100
101
|
// init seeds alice, bob and the invite code on the PDS with bare handles, else the first.
|
|
101
102
|
const seed = globe.pds.find((p) => p.bare_handles) ?? globe.pds[0];
|
|
102
103
|
const on = globe.services;
|
|
103
|
-
// Where Jetstream and TAP read: the relay, or with one PDS that PDS's own
|
|
104
|
+
// Where Jetstream and TAP read: the relay, or with one PDS and no relay, that PDS's own
|
|
105
|
+
// firehose (TAP only: Jetstream always has the relay, see config.mjs).
|
|
104
106
|
const feed = on.relay ? 'http://relay:2470' : `http://${svc(globe.pds[0])}:${PDS_PORT}`;
|
|
105
107
|
const copy = (o) => structuredClone(o);
|
|
106
108
|
|
|
@@ -259,13 +261,14 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
259
261
|
}
|
|
260
262
|
|
|
261
263
|
if (on.jetstream) {
|
|
264
|
+
// Its first backfill asks each PDS for its repos at the PDS's https name, through nginx.
|
|
262
265
|
services.jetstream = {
|
|
263
266
|
...copy(base.base.jetstream),
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
build: './stack/jetstream',
|
|
268
|
+
image: base.tags['snowglobe-jetstream'],
|
|
269
|
+
depends_on: { relay: healthy },
|
|
270
|
+
environment: { ...copy(base.base.jetstream.environment), JETSTREAM_RELAY_URL: feed },
|
|
271
|
+
volumes: [...base.base.jetstream.volumes, GO_CA],
|
|
269
272
|
ports: [
|
|
270
273
|
'${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_PORT:-6008}:6008',
|
|
271
274
|
'${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
|
package/lib/up.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// What snowglobe up decides without Docker: the app routes the file adds, whether the globe's
|
|
2
2
|
// PDSes clash with an app's handle ending, the env names the file's accounts keep their logins
|
|
3
|
-
// under. Pure: inputs in, text or lists out (lib/commands/up.mjs runs
|
|
3
|
+
// under, whether the relay restarts. Pure: inputs in, text or lists out (lib/commands/up.mjs runs
|
|
4
|
+
// the globe).
|
|
4
5
|
|
|
5
6
|
const envName = (label) => label.toUpperCase().replace(/-/g, '_');
|
|
6
7
|
|
|
@@ -52,3 +53,15 @@ export function endingClash(pdsNames, apps) {
|
|
|
52
53
|
}
|
|
53
54
|
return null;
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The first of `dialed` (service name -> its containers' start times) with a container that started
|
|
59
|
+
* after the relay did (`relay`, its containers' start times), or null. The times are Docker's
|
|
60
|
+
* State.StartedAt. The relay dials each PDS through nginx, and one that was running when either
|
|
61
|
+
* went away can stop picking up what the PDS sends, so up restarts it.
|
|
62
|
+
*/
|
|
63
|
+
export function startedAfterRelay(relay, dialed) {
|
|
64
|
+
const since = Math.max(...relay.map((t) => Date.parse(t)));
|
|
65
|
+
for (const [service, times] of Object.entries(dialed)) if (times.some((t) => Date.parse(t) > since)) return service;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmeet/snowglobe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Standalone, testable ATProto development network for local development and CI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"node": ">=20"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@peculiar/asn1-schema": "2.11.0",
|
|
41
|
-
"@peculiar/asn1-x509": "2.11.0",
|
|
40
|
+
"@peculiar/asn1-schema": "^2.11.0",
|
|
41
|
+
"@peculiar/asn1-x509": "^2.11.0",
|
|
42
42
|
"@peculiar/x509": "2.2.0",
|
|
43
43
|
"reflect-metadata": "0.2.2",
|
|
44
44
|
"yaml": "^2.9.1"
|
package/snowglobe.default.yml
CHANGED
|
@@ -14,8 +14,8 @@ pds:
|
|
|
14
14
|
- name: main # https://main.local.snowglobe.at
|
|
15
15
|
image: ghcr.io/bluesky-social/pds:0.4.5037 # a current release
|
|
16
16
|
bare_handles: true # its handles are <account>.local.snowglobe.at
|
|
17
|
-
# A second PDS, with handles <name>.other.local.snowglobe.at.
|
|
18
|
-
#
|
|
17
|
+
# A second PDS, with handles <name>.other.local.snowglobe.at. The relay crawls it too, so
|
|
18
|
+
# Jetstream sees its writes.
|
|
19
19
|
# - name: other
|
|
20
20
|
# image: ghcr.io/bluesky-social/pds:0.4.5009
|
|
21
21
|
#
|
|
@@ -30,7 +30,7 @@ services: # these are the defaults, each true or false
|
|
|
30
30
|
slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
|
|
31
31
|
pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
|
|
32
32
|
tap: false # sends an app the events of one collection (true, or a map; see snowglobe.md above)
|
|
33
|
-
# relay: auto # auto: on
|
|
33
|
+
# relay: auto # auto: on with Jetstream, which reads it, or more than one PDS
|
|
34
34
|
|
|
35
35
|
# Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
|
|
36
36
|
# lexicons:
|
package/stack/docker-compose.yml
CHANGED
|
@@ -98,17 +98,21 @@ services:
|
|
|
98
98
|
timeout: 3s
|
|
99
99
|
retries: 10
|
|
100
100
|
|
|
101
|
-
# ── Jetstream (JSON event stream from
|
|
101
|
+
# ── Jetstream (JSON event stream from the relay's firehose) ──────
|
|
102
|
+
# Built from stack/jetstream: the pinned Jetstream, plus busybox for the health check. It reads
|
|
103
|
+
# a relay, not a PDS: at start it asks the relay for the network's hosts, which a PDS refuses.
|
|
102
104
|
jetstream:
|
|
103
|
-
|
|
105
|
+
build: ./jetstream
|
|
106
|
+
image: snowglobe-jetstream
|
|
104
107
|
restart: unless-stopped
|
|
105
|
-
depends_on:
|
|
106
|
-
pds:
|
|
107
|
-
condition: service_healthy
|
|
108
108
|
environment:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
JETSTREAM_RELAY_URL: http://relay:2470
|
|
110
|
+
JETSTREAM_PLC_URL: http://plc:2582
|
|
111
|
+
# A folder of its own: a volume from an older snowglobe holds the older Jetstream's files.
|
|
112
|
+
JETSTREAM_DATA_DIR: /data/v2
|
|
113
|
+
JETSTREAM_ADDR: ":6008"
|
|
114
|
+
# Metrics, at /metrics.
|
|
115
|
+
JETSTREAM_DEBUG_ADDR: ":6009"
|
|
112
116
|
volumes:
|
|
113
117
|
- jetstream-data:/data
|
|
114
118
|
ports:
|
|
@@ -116,11 +120,15 @@ services:
|
|
|
116
120
|
- "${SG_JETSTREAM_METRICS_PORT:-${DEVNET_JETSTREAM_METRICS_PORT:-6009}}:6009"
|
|
117
121
|
networks:
|
|
118
122
|
- atproto-devnet
|
|
123
|
+
# Healthy once it serves: its archive answers 503 until then, as its streams do. The debug
|
|
124
|
+
# port's /readyz says only that it is listening.
|
|
119
125
|
healthcheck:
|
|
120
|
-
test: ["CMD
|
|
126
|
+
test: ["CMD", "/busybox", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6008/xrpc/network.bsky.jetstream.listSegments?limit=1"]
|
|
121
127
|
interval: 5s
|
|
122
128
|
timeout: 3s
|
|
123
129
|
retries: 10
|
|
130
|
+
# A new snowglobe's first start takes a minute or two (docs/how-it-works.md).
|
|
131
|
+
start_period: 120s
|
|
124
132
|
|
|
125
133
|
# ── TAP (repo sync + backfill) ───────────────────────────────────
|
|
126
134
|
tap:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Jetstream (github.com/bluesky-social/jetstream), the firehose as JSON for apps. It serves v2,
|
|
2
|
+
# /xrpc/network.bsky.jetstream.subscribeEvents, which contrail and @bsky/jetstream read, and the
|
|
3
|
+
# older /subscribe, which Slingshot, PDSls and the network page read.
|
|
4
|
+
# To move to another Jetstream, change the second FROM line. snowglobe up builds the new image,
|
|
5
|
+
# since the image's tag is a hash of this file.
|
|
6
|
+
# The image has no shell, so busybox comes along for the health check. It runs as root so it can
|
|
7
|
+
# write the jetstream-data volume, which Docker makes root's.
|
|
8
|
+
FROM busybox:1.37.0-musl@sha256:f6a95d611e0a8780fabdb2aec43e31275b2720dfefa3e41b16835bfc42454478 AS tools
|
|
9
|
+
|
|
10
|
+
FROM ghcr.io/bluesky-social/jetstream:v0.4.6@sha256:38e927566ed2aca19ffd2e9f3c440db74ffb404000aaf493b215dfefc6c7e54c
|
|
11
|
+
COPY --from=tools /bin/busybox /busybox
|
|
12
|
+
USER 0:0
|