@openmeet/snowglobe 0.3.0-rc.0 → 0.3.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/.env.example +1 -1
- package/README.md +11 -6
- package/bin/snowglobe.mjs +4 -2
- package/lib/apps.mjs +3 -3
- package/lib/commands/down.mjs +16 -13
- package/lib/commands/env.mjs +3 -2
- package/lib/commands/ls.mjs +72 -0
- package/lib/commands/run.mjs +1 -1
- package/lib/commands/up.mjs +61 -17
- package/lib/config.mjs +31 -2
- package/lib/env.mjs +190 -28
- package/lib/prebuilt.mjs +56 -0
- package/lib/render.mjs +47 -13
- package/lib/settings.mjs +12 -2
- package/lib/state.mjs +36 -2
- package/package.json +1 -1
- package/snowglobe.default.yml +9 -5
- package/stack/docker-compose.relay.yml +1 -1
- package/stack/docker-compose.test.yml +1 -1
- package/stack/docker-compose.yml +6 -5
- package/stack/https/nginx.conf.template +1 -1
- package/stack/page/index.html +16 -4
package/.env.example
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Database credentials — single source of truth for PLC and test postgres.
|
|
2
2
|
# Consumer overrides these to use their own postgres instance.
|
|
3
3
|
SG_DB_USER=postgres
|
|
4
|
-
SG_DB_PASSWORD=
|
|
4
|
+
# SG_DB_PASSWORD= (unset: snowglobe up makes a random one, in secrets.env in the snowglobe's state folder)
|
|
5
5
|
SG_DB_HOST=postgres
|
|
6
6
|
SG_DB_PORT=5432
|
|
7
7
|
SG_DB_NAME=plc
|
package/README.md
CHANGED
|
@@ -80,14 +80,19 @@ keeps the https names, and they are what make an app run unchanged.
|
|
|
80
80
|
## Quick start
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
npm install
|
|
86
|
-
npm link # puts the snowglobe command on your PATH
|
|
87
|
-
cp snowglobe.default.yml snowglobe.yml # the file says what your snowglobe runs; edit it freely
|
|
88
|
-
snowglobe up # the first run pulls and builds images, which takes a while
|
|
83
|
+
npm install -g @openmeet/snowglobe # puts the snowglobe command on your PATH
|
|
84
|
+
snowglobe up --default # the first run pulls and builds images, which takes a while
|
|
89
85
|
```
|
|
90
86
|
|
|
87
|
+
To change what it runs, copy the default file into your folder, give it a name of its own on the
|
|
88
|
+
`globe:` line (`snowglobe` is the default file's, and a snowglobe belongs to the file that first
|
|
89
|
+
brought it up), edit it, and run `snowglobe up`:
|
|
90
|
+
`cp "$(npm root -g)/@openmeet/snowglobe/snowglobe.default.yml" snowglobe.yml`.
|
|
91
|
+
To keep the command with one project instead, run `npm install --save-dev @openmeet/snowglobe`, then
|
|
92
|
+
`npx snowglobe up --default` for the default, or `npx snowglobe up` once the folder has a
|
|
93
|
+
`snowglobe.yml` (copy it from `node_modules/@openmeet/snowglobe/snowglobe.default.yml`). Either
|
|
94
|
+
command also fits in a `package.json` script.
|
|
95
|
+
|
|
91
96
|
Now try it. `snowglobe run` runs a command that can reach the snowglobe by name, and `--cacert`
|
|
92
97
|
gives curl the snowglobe's certificate authority (CA), so it trusts the snowglobe's https:
|
|
93
98
|
|
package/bin/snowglobe.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// snowglobe: run the globe a snowglobe.yml describes.
|
|
3
3
|
//
|
|
4
4
|
// check checks a snowglobe.yml
|
|
5
|
+
// ls lists the snowglobes on this machine
|
|
5
6
|
// render writes the https globe it describes into its state folder
|
|
6
7
|
// certs makes your CA, once, and the leaf nginx serves
|
|
7
8
|
// trust puts your CA in the system and browser trust stores (--remove takes it out)
|
|
@@ -10,8 +11,8 @@
|
|
|
10
11
|
// invite mints an invite code on one of its PDSes
|
|
11
12
|
// lexicons publishes lexicons into its lexicon authority
|
|
12
13
|
// app routes https://<name>.app.<domain> to a port on this machine
|
|
13
|
-
// up brings the globe up, as the compose project named for its file
|
|
14
|
-
// down stops it
|
|
14
|
+
// up brings the globe up, as the compose project named for its file (or <name>, -f <file>)
|
|
15
|
+
// down stops it (the same ways)
|
|
15
16
|
// run runs a command with the app's settings, and plc.directory pointed at the snowglobe
|
|
16
17
|
//
|
|
17
18
|
// A snowglobe is the snowglobe.yml in the folder you are in or the nearest one above (else
|
|
@@ -30,6 +31,7 @@ import { Refused, UsageError, adoptOldNames } from '../lib/env.mjs';
|
|
|
30
31
|
// Loaded when it runs, so check and render do without the certificate library.
|
|
31
32
|
const COMMANDS = {
|
|
32
33
|
check: () => import('../lib/commands/check.mjs'),
|
|
34
|
+
ls: () => import('../lib/commands/ls.mjs'),
|
|
33
35
|
render: () => import('../lib/commands/render.mjs'),
|
|
34
36
|
certs: () => import('../lib/commands/certs.mjs'),
|
|
35
37
|
trust: () => import('../lib/commands/trust.mjs'),
|
package/lib/apps.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { ROOT, globeFacts, paths } from './env.mjs';
|
|
17
17
|
|
|
18
|
-
const HEADER = '# Written by
|
|
18
|
+
const HEADER = '# Written by snowglobe app from https/apps; rerun it instead of editing.\n';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* The first saved route whose ending is `app`, or null. app.<domain> is where the apps are, so an
|
|
@@ -25,7 +25,7 @@ export const appEnding = (apps) => apps.find((a) => a.ending === 'app') ?? null;
|
|
|
25
25
|
|
|
26
26
|
export const appEndingMessage = (file, a) =>
|
|
27
27
|
`${file}: the line "${formatApps([a]).trim()}" claims the ending app, which is where the apps are (${a.name}.app.<domain>); ` +
|
|
28
|
-
`remove the route (
|
|
28
|
+
`remove the route (snowglobe app --remove ${a.name}) or give it another ending`;
|
|
29
29
|
|
|
30
30
|
/** The routes in an apps file's text: [{ name, port, ending }], ending null when none. */
|
|
31
31
|
export function parseApps(text) {
|
|
@@ -85,7 +85,7 @@ export const writeApps = (p, apps) => put(p.apps, formatApps(apps));
|
|
|
85
85
|
export function renderRoutes({ root = ROOT, state, env = process.env } = {}) {
|
|
86
86
|
const p = paths(root, state);
|
|
87
87
|
const pageJson = join(p.globe, 'page.json');
|
|
88
|
-
if (!existsSync(pageJson)) throw new Error(`No ${pageJson}: run
|
|
88
|
+
if (!existsSync(pageJson)) throw new Error(`No ${pageJson}: run snowglobe up, which renders it`);
|
|
89
89
|
const { domain } = globeFacts({ root, state, env });
|
|
90
90
|
const apps = readApps(p);
|
|
91
91
|
const bad = appEnding(apps);
|
package/lib/commands/down.mjs
CHANGED
|
@@ -1,37 +1,40 @@
|
|
|
1
|
-
// bin/snowglobe.mjs down [-v]
|
|
1
|
+
// bin/snowglobe.mjs down [<file> | <name>] [-f <file>] [-v]
|
|
2
2
|
//
|
|
3
3
|
// down stops the compose project of the snowglobe in this folder (or above, see lib/state.mjs;
|
|
4
|
-
// SG_PROJECT if set),
|
|
4
|
+
// SG_PROJECT if set), or of the one it is pointed at: a file (<file> or -f <file>), or a name
|
|
5
|
+
// from snowglobe ls, which works even when the file that owned it is gone, on the globe up last rendered (globe/ in its state folder), and a service an
|
|
5
6
|
// earlier render ran and this one does not with it.
|
|
6
7
|
// -v also drop the project's volumes: every account, DID and record is gone. The PLC's
|
|
7
8
|
// database is a named volume, so without -v the DIDs survive with the PDSes.
|
|
8
9
|
//
|
|
9
10
|
// The state folder is never deleted. After -v, the next up starts over: the base init reseeds
|
|
10
11
|
// alice and bob and rewrites accounts.env from scratch, dropping the logins the tools wrote there
|
|
11
|
-
// (their accounts are gone too), and the lexicon authority is made again. The
|
|
12
|
+
// (their accounts are gone too), and the lexicon authority is made again. The fixed secrets an
|
|
13
|
+
// older snowglobe kept for its data (secrets.env) are dropped with it, so up makes random ones. The CA, the leaf and
|
|
12
14
|
// the app routes stay either way. The project is guarded as up guards it.
|
|
13
15
|
|
|
14
|
-
import { ROOT, dc, guardProject } from '../env.mjs';
|
|
15
|
-
import { session } from '../state.mjs';
|
|
16
|
+
import { ROOT, dc, dropLegacySecrets, guardProject } from '../env.mjs';
|
|
17
|
+
import { byName, pointedAt, session } from '../state.mjs';
|
|
16
18
|
|
|
17
19
|
export const command = {
|
|
18
20
|
name: 'down',
|
|
19
|
-
usage: 'down [-v]',
|
|
20
|
-
options: { volumes: { type: 'boolean', short: 'v' } },
|
|
21
|
-
positionals: [0,
|
|
22
|
-
accepts: ({ tokens }) => tokens.length <= 1,
|
|
21
|
+
usage: 'down [<file> | <name>] [-f <file>] [-v]',
|
|
22
|
+
options: { volumes: { type: 'boolean', short: 'v' }, file: { type: 'string', short: 'f' } },
|
|
23
|
+
positionals: [0, 1],
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
export function run({ values }) {
|
|
26
|
-
return down({ volumes: values.volumes });
|
|
26
|
+
export function run({ values, positionals: [word] }) {
|
|
27
|
+
return down({ volumes: values.volumes, ...pointedAt({ file: values.file, word }) });
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function down({ volumes = false, file, root = ROOT, env = process.env } = {}) {
|
|
30
|
+
export function down({ volumes = false, file, name, root = ROOT, env = process.env } = {}) {
|
|
30
31
|
// The shared devnet is refused before anything is looked up, even by a file that is not there.
|
|
31
32
|
if (env.SG_PROJECT) guardProject(env);
|
|
32
|
-
const found = session({ file, env });
|
|
33
|
+
const found = name ? byName(name, { env }) : session({ file, env });
|
|
33
34
|
const project = guardProject(env, found.name);
|
|
34
35
|
const res = dc(project, ['down', '--remove-orphans', ...(volumes ? ['-v'] : [])], { root, state: found.state, env });
|
|
35
36
|
if (res.error) throw res.error;
|
|
37
|
+
// The data the fixed secrets were kept for is gone only once compose says it removed it.
|
|
38
|
+
if (volumes && res.status === 0) dropLegacySecrets({ root, state: found.state });
|
|
36
39
|
return res.status ?? 1;
|
|
37
40
|
}
|
package/lib/commands/env.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// lib/settings.mjs has the names.
|
|
13
13
|
|
|
14
14
|
import { existsSync } from 'node:fs';
|
|
15
|
-
import { ROOT, UsageError, envGet, paths, resolveDomain, setting } from '../env.mjs';
|
|
15
|
+
import { ROOT, UsageError, envGet, paths, resolveBind, resolveDomain, setting } from '../env.mjs';
|
|
16
16
|
import { session } from '../state.mjs';
|
|
17
17
|
import { isSecret, lookup, parseValue } from '../settings.mjs';
|
|
18
18
|
import { accountPrefixes } from '../up.mjs';
|
|
@@ -51,7 +51,8 @@ function load({ file, root = ROOT, env = process.env } = {}) {
|
|
|
51
51
|
if (name === 'ca.file') return existsSync(p.ca) ? p.ca : undefined;
|
|
52
52
|
if (name === 'accounts.file') return existsSync(p.accounts) ? p.accounts : undefined;
|
|
53
53
|
// The mail page's port is a setting, not recorded in the state; the rendered globe says up has run.
|
|
54
|
-
if (name === 'services.mail.url') return !existsSync(p.compose) ? undefined : `http
|
|
54
|
+
if (name === 'services.mail.url') return !existsSync(p.compose) ? undefined : `http://${resolveBind({ root, state: found.state, env })}:${setting('SG_MAILDEV_WEB_PORT', { root, env }) || '1081'}`;
|
|
55
|
+
if (name === 'services.tap.url') return !existsSync(p.compose) ? undefined : `http://${resolveBind({ root, state: found.state, env })}:${setting('SG_TAP_PORT', { root, env }) || '2480'}`;
|
|
55
56
|
if (name === 'lexicons.authority.did') return kept('LEX_AUTHORITY_DID');
|
|
56
57
|
if (head === 'accounts') return kept(`${prefixes.get(rest[0])}_${rest[1].toUpperCase()}`);
|
|
57
58
|
return undefined;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// bin/snowglobe.mjs ls
|
|
2
|
+
//
|
|
3
|
+
// ls lists the snowglobes on this machine: every folder in the state root (lib/env.mjs stateRoot;
|
|
4
|
+
// ca/, the one CA, is not one) with its name, the domain it was rendered for, whether its compose
|
|
5
|
+
// project is running, and the file that owns it. Take the name to `up`, `down`, or a file to -f.
|
|
6
|
+
// "up" is a project with a container running (the one-shot init and relay-init have exited by
|
|
7
|
+
// then), "down" is none, or no project yet; "unknown" is what it says when Docker did not answer.
|
|
8
|
+
// Reads only; nothing is started.
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { ROOT, envGet, paths, stateRoot } from '../env.mjs';
|
|
14
|
+
import { ownerOf } from '../state.mjs';
|
|
15
|
+
|
|
16
|
+
export const command = {
|
|
17
|
+
name: 'ls',
|
|
18
|
+
usage: 'ls',
|
|
19
|
+
options: {},
|
|
20
|
+
positionals: [0, 0],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Compose's own status for each project, "running(5)" or "exited(2)" or "exited(1), running(10)", by name; null when Docker did not answer. */
|
|
24
|
+
export function projectStatuses() {
|
|
25
|
+
const res = spawnSync('docker', ['compose', 'ls', '-a', '--format', 'json'], { encoding: 'utf8' });
|
|
26
|
+
if (res.error || res.status !== 0) return null;
|
|
27
|
+
try {
|
|
28
|
+
return new Map(JSON.parse(res.stdout || '[]').map((p) => [p.Name, p.Status ?? '']));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// What a compose status means for the table.
|
|
35
|
+
export function stateWord(status) {
|
|
36
|
+
return /running\(\d+\)/.test(status ?? '') ? 'up' : 'down';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The snowglobes in the state root: [{ name, domain, state, file }], by name. `statuses` is
|
|
41
|
+
* projectStatuses()'s answer, null for "unknown". A file that is gone is shown as "(gone)".
|
|
42
|
+
*/
|
|
43
|
+
export function list({ root = ROOT, env = process.env, statuses = projectStatuses() } = {}) {
|
|
44
|
+
const base = stateRoot(env);
|
|
45
|
+
if (!existsSync(base)) return [];
|
|
46
|
+
return readdirSync(base, { withFileTypes: true })
|
|
47
|
+
.filter((e) => e.isDirectory() && e.name !== 'ca')
|
|
48
|
+
.map((e) => {
|
|
49
|
+
const state = join(base, e.name);
|
|
50
|
+
const owner = ownerOf(state);
|
|
51
|
+
return {
|
|
52
|
+
name: e.name,
|
|
53
|
+
domain: envGet('SG_DOMAIN', paths(root, state).globeEnv) || '-',
|
|
54
|
+
state: statuses === null ? 'unknown' : stateWord(statuses.get(env.SG_PROJECT || e.name)),
|
|
55
|
+
file: owner ? (existsSync(owner) ? owner : `${owner} (gone)`) : '-',
|
|
56
|
+
};
|
|
57
|
+
})
|
|
58
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function run() {
|
|
62
|
+
const rows = list();
|
|
63
|
+
if (!rows.length) {
|
|
64
|
+
console.log(`No snowglobes in ${stateRoot()}. snowglobe up in a folder with a snowglobe.yml makes one.`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const head = { name: 'NAME', domain: 'DOMAIN', state: 'STATE', file: 'FILE' };
|
|
68
|
+
const cols = Object.keys(head);
|
|
69
|
+
const width = Object.fromEntries(cols.map((c) => [c, Math.max(head[c].length, ...rows.map((r) => r[c].length))]));
|
|
70
|
+
for (const r of [head, ...rows]) console.log(cols.map((c, i) => (i === cols.length - 1 ? r[c] : r[c].padEnd(width[c]))).join(' '));
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
package/lib/commands/run.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export function runUnder(cmd, { root = ROOT, env = process.env, state = session(
|
|
|
76
76
|
if (!bwrap) throw new Error('bwrap (bubblewrap) is not installed, or not on PATH');
|
|
77
77
|
// Refuses a SG_DOMAIN the render is not for, and a SG_BIND that is not loopback.
|
|
78
78
|
const { domain } = resolveDomain({ root, state, env });
|
|
79
|
-
const bind = resolveBind({ root, env });
|
|
79
|
+
const bind = resolveBind({ root, state, env });
|
|
80
80
|
|
|
81
81
|
const read = (f) => (existsSync(f) ? readFileSync(f, 'utf8') : '');
|
|
82
82
|
const names = runNames({ leafNames: read(p.leafNames), handles: read(p.names), apps: readApps(p), domain, extra: env.HTTPS_RUN_EXTRA_NAMES });
|
package/lib/commands/up.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// bin/snowglobe.mjs up [<file>]
|
|
1
|
+
// bin/snowglobe.mjs up [<file> | <name>] [-f <file>] [--default]
|
|
2
2
|
//
|
|
3
3
|
// up brings up the globe a snowglobe.yml describes, as the compose project named for the file's
|
|
4
4
|
// `globe:` (or SG_PROJECT). Safe to rerun: each step keeps what an earlier run made, and a rerun
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// means that folder. A checkout that still holds a globe in its own data/ is refused (nothing
|
|
11
11
|
// started, nothing moved) with the command that moves it.
|
|
12
12
|
//
|
|
13
|
-
// 1. render: data/globe/ from <file
|
|
13
|
+
// 1. render: data/globe/ from <file> (or -f <file>), or the file that owns the snowglobe called
|
|
14
|
+
// <name> (snowglobe ls lists them), else SG_FILE, else the nearest snowglobe.yml here or above
|
|
14
15
|
// (none: up refuses; `up --default` runs the default globe, snowglobe.default.yml: one PDS,
|
|
15
16
|
// a PLC directory and Jetstream).
|
|
16
17
|
// SG_DOMAIN, from the environment or .env, replaces the file's domain.
|
|
@@ -34,10 +35,10 @@
|
|
|
34
35
|
//
|
|
35
36
|
// A rerun finds the accounts and leaves unchanged lexicons alone. Anything else an app makes
|
|
36
37
|
// (more accounts, invite codes) is its own, with account, invite and lexicons. Nothing secret is
|
|
37
|
-
// printed. The tool ports
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// printed. The tool ports (SG_PLC_PORT, SG_JETSTREAM_PORT, SG_JETSTREAM_METRICS_PORT,
|
|
39
|
+
// SG_MAILDEV_WEB_PORT, SG_MAILDEV_SMTP_PORT, SG_PROXY_PORT) are published on the snowglobe's own
|
|
40
|
+
// loopback address (SG_BIND, else the one chosen at its first up and kept in its state folder), so
|
|
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
|
|
41
42
|
// the project unless SG_RESET_OK=1, before anything else is done.
|
|
42
43
|
|
|
43
44
|
import { createHash } from 'node:crypto';
|
|
@@ -45,8 +46,10 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
45
46
|
import { dirname, join, resolve } from 'node:path';
|
|
46
47
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
47
48
|
import { appEnding, appEndingMessage, formatApps, readApps, renderRoutes, writeApps } from '../apps.mjs';
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
49
|
+
import { loadGlobe } from '../config.mjs';
|
|
50
|
+
import { ROOT, addName, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
|
|
51
|
+
import { prebuilt } from '../prebuilt.mjs';
|
|
52
|
+
import { byName, claimState, pointedAt, session } from '../state.mjs';
|
|
50
53
|
import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
|
|
51
54
|
import { account } from './account.mjs';
|
|
52
55
|
import { lexicons } from './lexicons.mjs';
|
|
@@ -55,12 +58,12 @@ import { writeGlobe } from './render.mjs';
|
|
|
55
58
|
|
|
56
59
|
export const command = {
|
|
57
60
|
name: 'up',
|
|
58
|
-
usage: 'up [<file> | --default]',
|
|
61
|
+
usage: 'up [<file> | <name>] [-f <file>] [--default]',
|
|
59
62
|
// --default is the only way up runs the repo's default snowglobe; with no file and no
|
|
60
63
|
// snowglobe.yml in this folder or above, up refuses.
|
|
61
|
-
options: { default: { type: 'boolean' } },
|
|
64
|
+
options: { default: { type: 'boolean' }, file: { type: 'string', short: 'f' } },
|
|
62
65
|
positionals: [0, 1],
|
|
63
|
-
accepts: ({ values, positionals }) => !(values.default && positionals.length),
|
|
66
|
+
accepts: ({ values, positionals }) => !(values.default && (positionals.length || values.file)),
|
|
64
67
|
};
|
|
65
68
|
|
|
66
69
|
// How long a one-shot (init, relay-init) may take, and nginx to answer again after a restart.
|
|
@@ -80,8 +83,23 @@ async function waitServing(domain, { root, state, env }) {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
export async function run({ values, positionals: [
|
|
84
|
-
|
|
86
|
+
export async function run({ values, positionals: [word] }) {
|
|
87
|
+
let file;
|
|
88
|
+
if (values.default) file = join(ROOT, 'snowglobe.default.yml');
|
|
89
|
+
else {
|
|
90
|
+
const at = pointedAt({ file: values.file, word });
|
|
91
|
+
file = at.file;
|
|
92
|
+
if (at.name) {
|
|
93
|
+
// A snowglobe by its name is brought up from the file that owns it.
|
|
94
|
+
file = byName(at.name).path;
|
|
95
|
+
if (!file) throw new Error(`the file that owned the snowglobe ${at.name} is gone; bring it up from a file: snowglobe up -f <file>`);
|
|
96
|
+
// The file may have been renamed since (globe: changed): up would then start the new name.
|
|
97
|
+
const { globe } = loadGlobe(file);
|
|
98
|
+
if (globe && globe.globe !== at.name)
|
|
99
|
+
throw new Error(`${file} says globe: ${globe.globe} now, not ${at.name}; put globe: ${at.name} back to bring ${at.name} up, or snowglobe up ${file} to start ${globe.globe} as a new snowglobe`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
await up({ file, log: (m) => console.error(m) });
|
|
85
103
|
}
|
|
86
104
|
|
|
87
105
|
export async function up({ file, root = ROOT, env = process.env, log = () => {} }) {
|
|
@@ -98,13 +116,16 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
98
116
|
// Both are read as compose reads them, and refused here when they are not a domain and a
|
|
99
117
|
// loopback address.
|
|
100
118
|
const { set } = resolveDomain({ root, state, env, rendering: true });
|
|
101
|
-
resolveBind({ root, env });
|
|
119
|
+
resolveBind({ root, state, env });
|
|
102
120
|
|
|
103
121
|
const { path, globe } = checkedGlobe(found.path, { domain: set || undefined });
|
|
104
122
|
if (!globe) throw new Error(`${path} has problems (above); ${p.globe} is as it was`);
|
|
105
123
|
// From here on the file's globe is the one this state folder is for.
|
|
106
124
|
const took = claimState(found);
|
|
107
125
|
if (took) log(took);
|
|
126
|
+
// Its own loopback address for every published port, chosen once, so a second snowglobe starts beside this one.
|
|
127
|
+
const bind = await chooseBind({ root, state, project, env });
|
|
128
|
+
if (bind !== '127.0.0.1') log(`${found.name} publishes its ports on ${bind}`);
|
|
108
129
|
// Checked before the render changes, so a refused file leaves the running globe's render.
|
|
109
130
|
const kept = readApps(p);
|
|
110
131
|
const badEnding = appEnding(kept);
|
|
@@ -139,7 +160,10 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
139
160
|
|
|
140
161
|
const facts = (name) => envGet(name, p.globeEnv).split(' ').filter(Boolean);
|
|
141
162
|
// compose fills the file's ${SG_LEXICON_AUTHORITY_DID} from this.
|
|
142
|
-
const
|
|
163
|
+
const secrets = ensureSecrets({ root, state, env, hasData: () => projectHasData(project, env) });
|
|
164
|
+
if (secrets.kept.length)
|
|
165
|
+
log(`${found.name} has data made before its secrets were random, so it keeps the fixed ${secrets.kept.join(', ')}. snowglobe down -v, then up, starts it over with random ones (every account is lost).`);
|
|
166
|
+
const composeEnv = { ...env, ...secrets.values };
|
|
143
167
|
const compose = (args, opts = {}) => {
|
|
144
168
|
const res = dc(project, args, { root, state, env: composeEnv, ...opts });
|
|
145
169
|
if (res.error) throw res.error;
|
|
@@ -189,8 +213,22 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
189
213
|
// A group host built from a folder is the folder as it is now; Docker's cache makes an
|
|
190
214
|
// unchanged image quick.
|
|
191
215
|
const builds = facts('SG_BUILDS');
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
// The images snowglobe publishes are pulled where the registry has the build of these very files
|
|
217
|
+
// (lib/prebuilt.mjs); only the rest are built. Compose fills in the ${...} defaults (an image's
|
|
218
|
+
// tag follows its commit); if it cannot say, everything is built, as before.
|
|
219
|
+
let toBuild = [];
|
|
220
|
+
try {
|
|
221
|
+
const services = JSON.parse(compose(['config', '--format', 'json'], { stdio: 'pipe', encoding: 'utf8' }).stdout).services;
|
|
222
|
+
const have = prebuilt({ root, services, env, log });
|
|
223
|
+
toBuild = Object.keys(services).filter((s) => services[s].build && !have.includes(s));
|
|
224
|
+
} catch {
|
|
225
|
+
toBuild = [];
|
|
226
|
+
compose(['build', '--quiet']);
|
|
227
|
+
}
|
|
228
|
+
if (toBuild.length) {
|
|
229
|
+
log(`Building images${builds.length ? ` (${builds.join(' ')})` : ''}`);
|
|
230
|
+
compose(['build', '--quiet', ...toBuild]);
|
|
231
|
+
}
|
|
194
232
|
|
|
195
233
|
// A rerun starts the PDSes with the authority they already have, so pass 1 changes nothing.
|
|
196
234
|
composeEnv.SG_LEXICON_AUTHORITY_DID = envGet('LEX_AUTHORITY_DID', p.accounts);
|
|
@@ -212,6 +250,12 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
212
250
|
if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
|
|
213
251
|
composeEnv.SG_LEXICON_AUTHORITY_DID = did;
|
|
214
252
|
log(`Pass 2: up with the lexicon authority ${did}`);
|
|
253
|
+
// Pass 2 recreates every PDS. Jetstream with no relay reads the first PDS itself and exits
|
|
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' });
|
|
215
259
|
await upStack();
|
|
216
260
|
}
|
|
217
261
|
|
package/lib/config.mjs
CHANGED
|
@@ -214,6 +214,7 @@ export function checkGlobe(doc, { baseDir } = {}) {
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
// services
|
|
217
|
+
let tap = null;
|
|
217
218
|
const services = { relay: 'auto', jetstream: true, tap: false, slingshot: true, pdsls: true, cdn: false };
|
|
218
219
|
if (doc.services !== undefined) {
|
|
219
220
|
if (!isMap(doc.services)) err('services', 'must be a map');
|
|
@@ -223,7 +224,10 @@ export function checkGlobe(doc, { baseDir } = {}) {
|
|
|
223
224
|
const v = doc.services[k];
|
|
224
225
|
if (v === undefined) continue;
|
|
225
226
|
if (typeof v === 'boolean' || (k === 'relay' && v === 'auto')) services[k] = v;
|
|
226
|
-
else
|
|
227
|
+
else if (k === 'tap' && isMap(v)) {
|
|
228
|
+
services.tap = true;
|
|
229
|
+
tap = readTap(v, err);
|
|
230
|
+
} else err(`services.${k}`, `must be true or false${k === 'relay' ? ', or auto' : k === 'tap' ? ', or a map of webhook, signal_collection and collection_filters' : ''}`);
|
|
227
231
|
}
|
|
228
232
|
}
|
|
229
233
|
}
|
|
@@ -374,7 +378,7 @@ export function checkGlobe(doc, { baseDir } = {}) {
|
|
|
374
378
|
}
|
|
375
379
|
}
|
|
376
380
|
|
|
377
|
-
const globe = { globe: doc.globe, domain, pds, grouphosts, services, lexicons, accounts, apps, hosted };
|
|
381
|
+
const globe = { globe: doc.globe, domain, pds, grouphosts, services, tap, lexicons, accounts, apps, hosted };
|
|
378
382
|
// The names in the apps' settings, against the file as it now stands. Left until the rest is sound,
|
|
379
383
|
// or a PDS that failed its own check would be reported again as an unknown name.
|
|
380
384
|
if (!errors.length) for (const [where, msg] of checkSettings(globe)) err(where, msg);
|
|
@@ -460,6 +464,31 @@ function label(v, where, err) {
|
|
|
460
464
|
err(where, `${show(v)} must be one lowercase DNS label (letters, digits, inner hyphens)`);
|
|
461
465
|
}
|
|
462
466
|
|
|
467
|
+
// services.tap as a map: where Tap sends events, and which collection it follows. The webhook may
|
|
468
|
+
// hold ${...} placeholders (checkSettings looks them up); an account is found when it writes in the
|
|
469
|
+
// signal collection, so the filters default to that collection alone.
|
|
470
|
+
const NSID_RE = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+){2,}$/;
|
|
471
|
+
function readTap(map, err) {
|
|
472
|
+
unknownKeys(map, ['webhook', 'signal_collection', 'collection_filters'], 'services.tap', err);
|
|
473
|
+
const tap = { webhook: null, signal_collection: null, collection_filters: [] };
|
|
474
|
+
if (map.webhook !== undefined) {
|
|
475
|
+
if (typeof map.webhook !== 'string' || map.webhook === '') err('services.tap.webhook', 'must be a URL (it may hold ${...} names)');
|
|
476
|
+
else tap.webhook = map.webhook;
|
|
477
|
+
}
|
|
478
|
+
if (map.signal_collection !== undefined) {
|
|
479
|
+
if (typeof map.signal_collection !== 'string' || !NSID_RE.test(map.signal_collection))
|
|
480
|
+
err('services.tap.signal_collection', `${show(map.signal_collection)} must be a collection name, such as xyz.statusphere.status`);
|
|
481
|
+
else tap.signal_collection = map.signal_collection;
|
|
482
|
+
}
|
|
483
|
+
if (map.collection_filters !== undefined) {
|
|
484
|
+
if (!Array.isArray(map.collection_filters) || map.collection_filters.some((c) => typeof c !== 'string' || !NSID_RE.test(c)))
|
|
485
|
+
err('services.tap.collection_filters', 'must be a list of collection names');
|
|
486
|
+
else tap.collection_filters = map.collection_filters;
|
|
487
|
+
}
|
|
488
|
+
if (!tap.collection_filters.length && tap.signal_collection) tap.collection_filters = [tap.signal_collection];
|
|
489
|
+
return tap;
|
|
490
|
+
}
|
|
491
|
+
|
|
463
492
|
function unknownKeys(map, allowed, where, err) {
|
|
464
493
|
for (const k of Object.keys(map))
|
|
465
494
|
if (!allowed.includes(k)) err(where ? `${where}.${k}` : k, `unknown setting; known here: ${allowed.join(', ')}`);
|
package/lib/env.mjs
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { randomBytes } from 'node:crypto';
|
|
8
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { request } from 'node:https';
|
|
10
|
+
import net from 'node:net';
|
|
10
11
|
import { homedir } from 'node:os';
|
|
11
12
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
@@ -75,8 +76,12 @@ export function paths(root = ROOT, state) {
|
|
|
75
76
|
// The snowglobe's own logins: the base init's alice, bob and invite code, the lexicon
|
|
76
77
|
// authority, and accounts made by snowglobe account.
|
|
77
78
|
accounts: join(data, 'accounts.env'),
|
|
78
|
-
// The
|
|
79
|
-
// A file of its own: the base init rewrites accounts.env.
|
|
79
|
+
// The secrets the services start with (SECRETS below), made by up, once, and read by account
|
|
80
|
+
// and invite. A file of its own: the base init rewrites accounts.env.
|
|
81
|
+
secrets: join(data, 'secrets.env'),
|
|
82
|
+
// The loopback address this snowglobe publishes its ports on (chooseBind), one line.
|
|
83
|
+
bind: join(data, 'bind'),
|
|
84
|
+
// Where the admin password was kept before secrets.env (read, never written, for a snowglobe made then).
|
|
80
85
|
admin: join(data, 'admin.env'),
|
|
81
86
|
// App routes, "<name> <port> [<ending>]" per line (snowglobe app).
|
|
82
87
|
apps: join(https, 'apps'),
|
|
@@ -248,17 +253,110 @@ export function globeFacts({ root = ROOT, state, env = process.env } = {}) {
|
|
|
248
253
|
};
|
|
249
254
|
}
|
|
250
255
|
|
|
251
|
-
// The address nginx
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
+
// The loopback address nginx and every other published port use, worked out as compose works out
|
|
257
|
+
// ${SG_BIND:-127.0.0.1}: SG_BIND where set, else the one `up` chose for this snowglobe (the bind
|
|
258
|
+
// file in its state folder), else 127.0.0.1. A loopback address only (127.x.y.z, one per globe run
|
|
259
|
+
// side by side): the globe's logins are throwaway, so it is never published beyond this machine.
|
|
260
|
+
export function resolveBind({ root = ROOT, state, env = process.env } = {}) {
|
|
261
|
+
const saved = state && existsSync(paths(root, state).bind) ? readFileSync(paths(root, state).bind, 'utf8').trim() : '';
|
|
262
|
+
const bind = setting('SG_BIND', { root, env }) || saved || '127.0.0.1';
|
|
256
263
|
const parts = bind.split('.');
|
|
257
264
|
if (parts.length !== 4 || parts[0] !== '127' || parts.slice(1).some((x) => !/^[0-9]+$/.test(x) || Number(x) > 255))
|
|
258
265
|
throw new Error(`SG_BIND=${bind} is not a loopback address 127.x.y.z (set in the environment or .env)`);
|
|
259
266
|
return bind;
|
|
260
267
|
}
|
|
261
268
|
|
|
269
|
+
// The ports a snowglobe publishes on its address: nginx, and the tools (each can be moved with its SG_..._PORT).
|
|
270
|
+
// Tap's is probed whether or not this globe runs Tap: an address another globe's Tap answers on is not free.
|
|
271
|
+
const PUBLISHED = [['', 443], ['SG_PROXY_PORT', 1080], ['SG_MAILDEV_WEB_PORT', 1081], ['SG_MAILDEV_SMTP_PORT', 1026], ['SG_PLC_PORT', 2582], ['SG_JETSTREAM_PORT', 6008], ['SG_JETSTREAM_METRICS_PORT', 6009], ['SG_TAP_PORT', 2480]];
|
|
272
|
+
|
|
273
|
+
// Whether something already answers on any port a snowglobe would publish on a loopback address.
|
|
274
|
+
export async function addressBusy(address, { root = ROOT, env = process.env } = {}) {
|
|
275
|
+
const answers = (port) =>
|
|
276
|
+
new Promise((done) => {
|
|
277
|
+
const s = net.connect({ host: address, port, timeout: 500 });
|
|
278
|
+
s.on('connect', () => (s.destroy(), done(true)));
|
|
279
|
+
s.on('timeout', () => (s.destroy(), done(false)));
|
|
280
|
+
s.on('error', () => done(false));
|
|
281
|
+
});
|
|
282
|
+
const ports = PUBLISHED.map(([name, port]) => Number((name && setting(name, { root, env })) || port));
|
|
283
|
+
return (await Promise.all(ports.map(answers))).some(Boolean);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Whether the compose project's own nginx is running now.
|
|
287
|
+
export function ownNginxUp(project, env = process.env) {
|
|
288
|
+
const res = spawnSync('docker', ['ps', '-q', '--filter', `label=com.docker.compose.project=${project}`, '--filter', 'label=com.docker.compose.service=nginx'], { encoding: 'utf8', env });
|
|
289
|
+
return !res.error && res.status === 0 && res.stdout.trim() !== '';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The loopback address this snowglobe publishes on, chosen once and kept in its state folder, so a
|
|
294
|
+
* second snowglobe on the machine starts beside the first with no settings: it takes the first
|
|
295
|
+
* 127.0.0.N that no other snowglobe in the same user folder has kept and nothing answers on 443 or any other port it publishes.
|
|
296
|
+
* SG_BIND, where set, wins and nothing is written. A snowglobe that is already running with no
|
|
297
|
+
* address kept was made before this and is on 127.0.0.1, so it keeps that. Returns the address.
|
|
298
|
+
*/
|
|
299
|
+
export async function chooseBind({ root = ROOT, state, project, env = process.env, listening = (a) => addressBusy(a, { root, env }), ownUp = ownNginxUp } = {}) {
|
|
300
|
+
const p = paths(root, state);
|
|
301
|
+
if (setting('SG_BIND', { root, env }) || existsSync(p.bind)) return resolveBind({ root, state, env });
|
|
302
|
+
// Choosing reads the other bind files, waits on probes, then writes ours, so two first starts at
|
|
303
|
+
// once would both pick the same address and keep it. One at a time, under a lock beside the state folders.
|
|
304
|
+
return withLock(join(dirname(state), '.bind-lock'), async () => {
|
|
305
|
+
if (existsSync(p.bind)) return resolveBind({ root, state, env });
|
|
306
|
+
return pickBind({ p, state, project, env, listening, ownUp });
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Run `fn` holding a lock directory (mkdir is atomic). A lock older than `stale` ms was left by a
|
|
311
|
+
// process that died. It is not taken over, since two starts taking it over at once could both
|
|
312
|
+
// hold it; up stops and names the folder to remove.
|
|
313
|
+
async function withLock(dir, fn, { stale = 60000, wait = 30000 } = {}) {
|
|
314
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
315
|
+
const until = Date.now() + wait;
|
|
316
|
+
for (;;) {
|
|
317
|
+
try {
|
|
318
|
+
mkdirSync(dir);
|
|
319
|
+
break;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (e.code !== 'EEXIST') throw e;
|
|
322
|
+
let age;
|
|
323
|
+
try {
|
|
324
|
+
age = Date.now() - statSync(dir).mtimeMs;
|
|
325
|
+
} catch {
|
|
326
|
+
continue; // released between the two calls
|
|
327
|
+
}
|
|
328
|
+
if (age > stale) throw new Error(`a snowglobe that stopped while choosing an address left ${dir}; remove that folder, then up again`);
|
|
329
|
+
if (Date.now() > until) throw new Error(`another snowglobe is choosing an address (${dir}); remove that folder if none is`);
|
|
330
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
return await fn();
|
|
335
|
+
} finally {
|
|
336
|
+
rmSync(dir, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function pickBind({ p, state, project, env, listening, ownUp }) {
|
|
341
|
+
const used = new Set();
|
|
342
|
+
const top = dirname(state);
|
|
343
|
+
for (const d of existsSync(top) ? readdirSync(top) : []) {
|
|
344
|
+
const f = join(top, d, 'bind');
|
|
345
|
+
if (join(top, d) !== state && existsSync(f)) used.add(readFileSync(f, 'utf8').trim());
|
|
346
|
+
}
|
|
347
|
+
let bind = '127.0.0.1';
|
|
348
|
+
if (!ownUp(project, env)) {
|
|
349
|
+
bind = '';
|
|
350
|
+
for (let n = 1; n <= 50 && !bind; n++) {
|
|
351
|
+
const a = `127.0.0.${n}`;
|
|
352
|
+
if (!used.has(a) && !(await listening(a))) bind = a;
|
|
353
|
+
}
|
|
354
|
+
if (!bind) throw new Error('every loopback address from 127.0.0.1 to 127.0.0.50 is in use; set SG_BIND to one that is free');
|
|
355
|
+
}
|
|
356
|
+
writeFileSync(p.bind, `${bind}\n`);
|
|
357
|
+
return bind;
|
|
358
|
+
}
|
|
359
|
+
|
|
262
360
|
// Replace the lines of an env file matching `drop` with `add` (lines, no newlines). The file is
|
|
263
361
|
// mode 600, and never readable by anyone else, even briefly: it is written aside at 600, then
|
|
264
362
|
// renamed over the old one.
|
|
@@ -299,22 +397,86 @@ export function addName(host, { root = ROOT, state } = {}) {
|
|
|
299
397
|
if (!lines(p.names).includes(host)) appendFileSync(p.names, `${host}\n`);
|
|
300
398
|
}
|
|
301
399
|
|
|
302
|
-
// The
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
400
|
+
// The secrets a snowglobe's services start with: the PDSes' admin password, JWT secret and PLC
|
|
401
|
+
// rotation key, the relay's admin password and the PLC database's. `up` makes each one random,
|
|
402
|
+
// once, per snowglobe (ensureSecrets); the compose files take them as ${SG_...:?}, so none has a
|
|
403
|
+
// default. `bytes` is how many random bytes (the rotation key is a 32-byte secp256k1 key).
|
|
404
|
+
// `legacy` is the fixed value every snowglobe used before they were random: a snowglobe whose data
|
|
405
|
+
// was made under it keeps it, because Postgres keeps the password it was first started with and
|
|
406
|
+
// a DID's PLC record names the rotation key that made it. The admin password was never fixed.
|
|
407
|
+
export const SECRETS = {
|
|
408
|
+
SG_PDS_ADMIN_PASSWORD: { bytes: 24 },
|
|
409
|
+
SG_PDS_JWT_SECRET: { bytes: 24, legacy: 'devnet-jwt-secret-not-for-production' },
|
|
410
|
+
SG_PLC_ROTATION_KEY: { bytes: 32, legacy: '0123456789abcdef'.repeat(4) },
|
|
411
|
+
SG_RELAY_ADMIN_PASSWORD: { bytes: 24, legacy: 'devnet-relay-admin' },
|
|
412
|
+
SG_DB_PASSWORD: { bytes: 24, legacy: 'postgres' },
|
|
413
|
+
};
|
|
307
414
|
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
export function
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
415
|
+
// A secret's value: the environment's or .env's (a globe on the cluster has its own), else the
|
|
416
|
+
// one up made for this snowglobe in its secrets.env (for the admin password, else its older admin.env); '' when none.
|
|
417
|
+
export function secret(name, { root = ROOT, state, env = process.env } = {}) {
|
|
418
|
+
const set = setting(name, { root, env });
|
|
419
|
+
if (set || !state) return set || '';
|
|
313
420
|
const p = paths(root, state);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
421
|
+
// The admin password up made before secrets.env existed is still the snowglobe's: its PDSes
|
|
422
|
+
// were started with it, so it is kept, not replaced by a new one.
|
|
423
|
+
return envGet(name, p.secrets) || (name === 'SG_PDS_ADMIN_PASSWORD' ? envGet(name, p.admin) : '');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export const adminPassword = (opts) => secret('SG_PDS_ADMIN_PASSWORD', opts);
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Every secret in SECRETS, made first where there is none: random, and kept in secrets.env (mode
|
|
430
|
+
* 600) so the same one is used on every later up. Where the snowglobe already has data (`hasData()`
|
|
431
|
+
* is true, asked only when a secret that used to be fixed is missing; null, Docker could not say, is refused) a secret that used to be fixed keeps its fixed
|
|
432
|
+
* value instead. Returns { values, kept }: each secret's value, and the names that kept a fixed
|
|
433
|
+
* one. A value set in the environment or .env is used and not written. Never prints a value.
|
|
434
|
+
*/
|
|
435
|
+
export function ensureSecrets({ root = ROOT, state, env = process.env, hasData = () => false } = {}) {
|
|
436
|
+
const values = {};
|
|
437
|
+
const kept = [];
|
|
438
|
+
const made = [];
|
|
439
|
+
let data;
|
|
440
|
+
for (const [name, { bytes, legacy }] of Object.entries(SECRETS)) {
|
|
441
|
+
let value = secret(name, { root, state, env });
|
|
442
|
+
if (!value) {
|
|
443
|
+
// Only a secret that used to be fixed depends on whether there is data; the answer is asked once.
|
|
444
|
+
if (legacy !== undefined && data === undefined) {
|
|
445
|
+
data = hasData();
|
|
446
|
+
if (data === null) throw new Error('Docker did not say whether this snowglobe already has data (docker volume ls failed), so up will not choose its secrets yet: start Docker, then run up again');
|
|
447
|
+
}
|
|
448
|
+
if (legacy !== undefined && data) {
|
|
449
|
+
value = legacy;
|
|
450
|
+
kept.push(name);
|
|
451
|
+
} else value = randomBytes(bytes).toString('hex');
|
|
452
|
+
made.push(name);
|
|
453
|
+
}
|
|
454
|
+
values[name] = value;
|
|
455
|
+
}
|
|
456
|
+
if (made.length) {
|
|
457
|
+
const p = paths(root, state);
|
|
458
|
+
mkdirSync(p.data, { recursive: true });
|
|
459
|
+
envPut(loginsFile(p.secrets), new RegExp(`^(${made.join('|')})=`), made.map((n) => `${n}=${values[n]}`));
|
|
460
|
+
}
|
|
461
|
+
return { values, kept };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Whether Docker holds volumes for the compose project: null when Docker cannot say, which is not an answer to act on. */
|
|
465
|
+
export function projectHasData(project, env = process.env) {
|
|
466
|
+
const res = spawnSync('docker', ['volume', 'ls', '-q', '--filter', `label=com.docker.compose.project=${project}`], { encoding: 'utf8', env });
|
|
467
|
+
return res.error || res.status !== 0 ? null : res.stdout.trim() !== '';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* After `down -v`, the fixed secrets (SECRETS' legacy values) are dropped from secrets.env: the
|
|
472
|
+
* data they were kept for is gone, so the next up makes random ones. Random ones stay, and a
|
|
473
|
+
* secret set in the environment or .env is not in the file.
|
|
474
|
+
*/
|
|
475
|
+
export function dropLegacySecrets({ root = ROOT, state } = {}) {
|
|
476
|
+
const file = paths(root, state).secrets;
|
|
477
|
+
if (!existsSync(file)) return;
|
|
478
|
+
const old = Object.entries(SECRETS).filter(([n, { legacy }]) => legacy !== undefined && envGet(n, file) === legacy).map(([n]) => n);
|
|
479
|
+
if (old.length) envPut(file, new RegExp(`^(${old.join('|')})=`));
|
|
318
480
|
}
|
|
319
481
|
|
|
320
482
|
// docker compose on `project` with the rendered globe. Its paths are from the repo root, which is
|
|
@@ -326,15 +488,15 @@ export function dc(project, args, { root = ROOT, state, stdio = 'inherit', encod
|
|
|
326
488
|
const old = oldDataNotice(root, state);
|
|
327
489
|
throw new Error(`No ${p.compose}: run snowglobe up, which renders it${old ? `\n${old}` : ''}`);
|
|
328
490
|
}
|
|
329
|
-
// compose fills ${
|
|
330
|
-
//
|
|
331
|
-
//
|
|
491
|
+
// compose fills the file's ${SG_...:?} secrets from these, and refuses to read the file without
|
|
492
|
+
// them. up makes the real ones first; down and ps on a snowglobe that up has not made them for
|
|
493
|
+
// need only a value for compose to read the file.
|
|
332
494
|
const base = env ?? process.env;
|
|
333
|
-
const
|
|
495
|
+
const secrets = Object.fromEntries(Object.keys(SECRETS).map((n) => [n, secret(n, { root, state, env: base }) || 'unset-run-snowglobe-up']));
|
|
334
496
|
return spawnSync('docker', ['compose', '-p', project, '--project-directory', root, '-f', p.compose, ...args], {
|
|
335
497
|
stdio,
|
|
336
498
|
encoding,
|
|
337
|
-
env: { ...base,
|
|
499
|
+
env: { ...base, ...secrets, SG_BIND: resolveBind({ root, state, env: base }) },
|
|
338
500
|
});
|
|
339
501
|
}
|
|
340
502
|
|
|
@@ -346,7 +508,7 @@ export function snowglobeRequest(url, { method = 'GET', headers = {}, body, root
|
|
|
346
508
|
const pub = env.SG_PUBLIC === '1';
|
|
347
509
|
const options = { method, headers };
|
|
348
510
|
if (!pub) {
|
|
349
|
-
const bind = resolveBind({ root, env });
|
|
511
|
+
const bind = resolveBind({ root, state, env });
|
|
350
512
|
options.ca = readFileSync(paths(root, state).ca);
|
|
351
513
|
options.lookup = (_host, opts, cb) =>
|
|
352
514
|
opts?.all ? cb(null, [{ address: bind, family: 4 }]) : cb(null, bind, 4);
|
package/lib/prebuilt.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The images snowglobe builds from its own folders that are also published (scripts/publish-images.mjs),
|
|
2
|
+
// so `up` pulls them where it can and builds only what it cannot. A pulled image is used only when
|
|
3
|
+
// its source label equals the hash of the folder's files in this checkout, so an edited Dockerfile
|
|
4
|
+
// or patch is built, never silently served from the registry.
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { contextHash } from './render.mjs';
|
|
8
|
+
|
|
9
|
+
export const REGISTRY = 'ghcr.io/openmeet-team';
|
|
10
|
+
export const SOURCE_LABEL = 'at.snowglobe.source';
|
|
11
|
+
|
|
12
|
+
// Image name (the part of the compose image before the tag): its folder, and the files its Dockerfile uses.
|
|
13
|
+
export const PUBLISHED = {
|
|
14
|
+
'snowglobe-slingshot': ['stack/slingshot', ['Dockerfile']],
|
|
15
|
+
'snowglobe-pdsls': ['stack/pdsls', ['Dockerfile', 'network.patch', 'server.mjs']],
|
|
16
|
+
'snowglobe-relay': ['stack/relay', ['Dockerfile', 'allow-private-hosts.patch']],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The source label an image of this name should carry for the files in `root`. */
|
|
20
|
+
export const sourceHash = (root, name) => contextHash(root, ...PUBLISHED[name]);
|
|
21
|
+
|
|
22
|
+
const docker = (args) => spawnSync('docker', args, { encoding: 'utf8', stdio: 'pipe' });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Of the rendered compose file's services, those whose image is already here or can be pulled
|
|
26
|
+
* from the registry with a matching source label. Returns their names; the caller builds the rest.
|
|
27
|
+
* SG_PREBUILT=0 pulls nothing and uses nothing (an image is then built, as it always was).
|
|
28
|
+
*/
|
|
29
|
+
export function prebuilt({ root, services, env = process.env, run = docker, log = () => {} }) {
|
|
30
|
+
if (env.SG_PREBUILT === '0') return [];
|
|
31
|
+
const registry = (env.SG_IMAGE_REGISTRY ?? REGISTRY).replace(/\/$/, '');
|
|
32
|
+
const label = (ref) => {
|
|
33
|
+
const r = run(['image', 'inspect', '-f', `{{index .Config.Labels "${SOURCE_LABEL}"}}`, ref]);
|
|
34
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
35
|
+
};
|
|
36
|
+
const ready = [];
|
|
37
|
+
const pulled = [];
|
|
38
|
+
for (const [service, def] of Object.entries(services)) {
|
|
39
|
+
const ref = def.build && def.image;
|
|
40
|
+
const name = ref?.split(':')[0];
|
|
41
|
+
if (!(name in PUBLISHED)) continue;
|
|
42
|
+
const want = sourceHash(root, name);
|
|
43
|
+
if (label(ref) === want) {
|
|
44
|
+
ready.push(service);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!registry) continue;
|
|
48
|
+
if (run(['pull', '-q', `${registry}/${ref}`]).status !== 0) continue;
|
|
49
|
+
if (run(['tag', `${registry}/${ref}`, ref]).status === 0 && label(ref) === want) {
|
|
50
|
+
ready.push(service);
|
|
51
|
+
pulled.push(service);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (pulled.length) log(`Pulled prebuilt images (${pulled.join(' ')})`);
|
|
55
|
+
return ready;
|
|
56
|
+
}
|
package/lib/render.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
|
|
19
19
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
20
20
|
import { parse, stringify } from 'yaml';
|
|
21
21
|
import { IMAGE_NAMES, RESERVED } from './config.mjs';
|
|
22
|
+
import { lookup, parseValue } from './settings.mjs';
|
|
22
23
|
|
|
23
24
|
// Every PDS listens on this port inside its own container, and every group host on the other.
|
|
24
25
|
export const PDS_PORT = 3000;
|
|
@@ -60,6 +61,18 @@ export function loadBase(root) {
|
|
|
60
61
|
};
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** TAP's settings from services.tap's map: where it sends events and which collection it follows. */
|
|
65
|
+
function tapSettings(globe) {
|
|
66
|
+
const t = globe.tap;
|
|
67
|
+
if (!t) return {};
|
|
68
|
+
const webhook = t.webhook && parseValue(t.webhook).parts.map((x) => (x.text !== undefined ? x.text : lookup(globe, x.name).value)).join('');
|
|
69
|
+
return {
|
|
70
|
+
...(webhook && { TAP_WEBHOOK_URL: webhook }),
|
|
71
|
+
...(t.signal_collection && { TAP_SIGNAL_COLLECTION: t.signal_collection }),
|
|
72
|
+
...(t.collection_filters.length && { TAP_COLLECTION_FILTERS: t.collection_filters.join(',') }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* Render a checked globe. stateDir is the snowglobe's state folder (an absolute path): the
|
|
65
78
|
* compose file mounts the CA (in ca/ beside it), the leaf and nginx's routes from it, and init
|
|
@@ -103,15 +116,15 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
103
116
|
services.maildev = {
|
|
104
117
|
...copy(base.test.maildev),
|
|
105
118
|
ports: [
|
|
106
|
-
'127.0.0.1:${SG_MAILDEV_WEB_PORT:-1081}:1080',
|
|
107
|
-
'127.0.0.1:${SG_MAILDEV_SMTP_PORT:-1026}:1025',
|
|
119
|
+
'${SG_BIND:-127.0.0.1}:${SG_MAILDEV_WEB_PORT:-1081}:1080',
|
|
120
|
+
'${SG_BIND:-127.0.0.1}:${SG_MAILDEV_SMTP_PORT:-1026}:1025',
|
|
108
121
|
],
|
|
109
122
|
};
|
|
110
123
|
|
|
111
124
|
services.plc = {
|
|
112
125
|
...copy(base.base.plc),
|
|
113
126
|
depends_on: { postgres: healthy },
|
|
114
|
-
ports: ['127.0.0.1:${SG_PLC_PORT:-2582}:2582'],
|
|
127
|
+
ports: ['${SG_BIND:-127.0.0.1}:${SG_PLC_PORT:-2582}:2582'],
|
|
115
128
|
};
|
|
116
129
|
|
|
117
130
|
const pdsBase = base.base.pds;
|
|
@@ -254,21 +267,26 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
254
267
|
JETSTREAM_WS_URL: `${feed.replace(/^http/, 'ws')}/xrpc/com.atproto.sync.subscribeRepos`,
|
|
255
268
|
},
|
|
256
269
|
ports: [
|
|
257
|
-
'127.0.0.1:${SG_JETSTREAM_PORT:-6008}:6008',
|
|
258
|
-
'127.0.0.1:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
|
|
270
|
+
'${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_PORT:-6008}:6008',
|
|
271
|
+
'${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
|
|
259
272
|
],
|
|
260
273
|
};
|
|
261
274
|
}
|
|
262
275
|
|
|
263
276
|
if (on.tap) {
|
|
264
|
-
//
|
|
265
|
-
//
|
|
277
|
+
// TAP runs in tap-dns's network namespace, whose DNS answers every name under the domain with
|
|
278
|
+
// nginx: so it reaches the webhook's app and checks a handle at its https name. It follows the
|
|
279
|
+
// stream and finds an account when it writes in the signal collection; its crawl of the
|
|
280
|
+
// collection's repos is refused by the PDS (HTTP 401) and finds none.
|
|
266
281
|
services.tap = {
|
|
267
282
|
...copy(base.base.tap),
|
|
268
283
|
depends_on: on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy },
|
|
269
|
-
environment: { ...copy(base.base.tap.environment), TAP_RELAY_URL: feed },
|
|
284
|
+
environment: { ...copy(base.base.tap.environment), TAP_RELAY_URL: feed, ...tapSettings(globe) },
|
|
270
285
|
volumes: [...base.base.tap.volumes, GO_CA],
|
|
286
|
+
network_mode: 'service:tap-dns',
|
|
287
|
+
depends_on: { 'tap-dns': { condition: 'service_started' }, ...(on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy }) },
|
|
271
288
|
};
|
|
289
|
+
delete services.tap.networks;
|
|
272
290
|
delete services.tap.ports;
|
|
273
291
|
}
|
|
274
292
|
|
|
@@ -326,6 +344,19 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
326
344
|
healthcheck: { test: ['CMD', 'nginx', '-t', '-q'], interval: '5s', timeout: '3s', retries: 10 },
|
|
327
345
|
};
|
|
328
346
|
|
|
347
|
+
if (on.tap) {
|
|
348
|
+
services['tap-dns'] = {
|
|
349
|
+
build: './stack/https/dns',
|
|
350
|
+
image: base.tags['snowglobe-dns'],
|
|
351
|
+
restart: 'unless-stopped',
|
|
352
|
+
environment: { SG_DOMAIN: D },
|
|
353
|
+
dns: ['127.0.0.1'],
|
|
354
|
+
depends_on: { nginx: healthy },
|
|
355
|
+
ports: ['${SG_BIND:-127.0.0.1}:${SG_TAP_PORT:-2480}:2480'],
|
|
356
|
+
networks: [NETWORK],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
329
360
|
if (on.slingshot) {
|
|
330
361
|
// Slingshot (microcosm-rs) answers com.bad-example.identity.resolveMiniDoc, a handle's DID
|
|
331
362
|
// and PDS, from this globe's PLC and Jetstream. It reaches the handles' names over TLS,
|
|
@@ -377,7 +408,7 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
377
408
|
restart: 'unless-stopped',
|
|
378
409
|
depends_on: { nginx: healthy },
|
|
379
410
|
environment: { PROXY_DOMAIN: D, PROXY_UPSTREAM: 'nginx:443' },
|
|
380
|
-
ports: ['127.0.0.1:${SG_PROXY_PORT:-1080}:1080'],
|
|
411
|
+
ports: ['${SG_BIND:-127.0.0.1}:${SG_PROXY_PORT:-1080}:1080'],
|
|
381
412
|
networks: [NETWORK],
|
|
382
413
|
healthcheck: { test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:1080/_health'], interval: '5s', timeout: '3s', retries: 10 },
|
|
383
414
|
};
|
|
@@ -421,14 +452,14 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
421
452
|
}
|
|
422
453
|
|
|
423
454
|
const header = (c) =>
|
|
424
|
-
`${c} Written by
|
|
455
|
+
`${c} Written by snowglobe render from ${source}; render again instead of editing.\n`;
|
|
425
456
|
|
|
426
457
|
const compose =
|
|
427
458
|
header('#') +
|
|
428
459
|
`# The https globe "${globe.globe}" at ${D}: ${globe.pds.map((p) => p.name).join(', ')}` +
|
|
429
460
|
(globe.grouphosts.length ? `; group hosts ${globe.grouphosts.map((g) => g.name).join(', ')}` : '') +
|
|
430
461
|
'.\n' +
|
|
431
|
-
'# Run it with
|
|
462
|
+
'# Run it with snowglobe up, which renders this file first.\n\n' +
|
|
432
463
|
// YAML 1.1 quoting, so a value such as restart's "no" stays a string to compose.
|
|
433
464
|
stringify({ services, volumes, networks: { [NETWORK]: null } }, { lineWidth: 0, nullStr: '', aliasDuplicateObjects: false, version: '1.1' });
|
|
434
465
|
|
|
@@ -483,15 +514,18 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
483
514
|
const named = Object.entries(IMAGE_NAMES).find(([, ref]) => ref === image)?.[0];
|
|
484
515
|
return named ?? image.split('/').pop().replace(/@sha256:([0-9a-f]{12})[0-9a-f]+$/, '@sha256:$1…');
|
|
485
516
|
};
|
|
517
|
+
// What the globe is made of, as the page's first line names it: only what runs.
|
|
518
|
+
const parts = [globe.pds.length === 1 ? 'PDS' : 'PDSs', 'PLC directory', on.relay && 'relay', on.jetstream && 'Jetstream'].filter(Boolean);
|
|
486
519
|
const page = {
|
|
487
520
|
globe: globe.globe,
|
|
488
521
|
domain: D,
|
|
489
522
|
about:
|
|
490
|
-
|
|
523
|
+
`An atproto network on this machine: its own ${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}. Its accounts live in its own directory, not the public one, so Bluesky cannot find anything here. An app is handed this network's addresses (snowglobe env), not Bluesky's.`,
|
|
491
524
|
accountsNote:
|
|
492
525
|
`Every account on the network, on each of its ${globe.pds.length} PDS${globe.pds.length === 1 ? '' : 's'}` +
|
|
493
526
|
(globe.grouphosts.length ? `, and every group on its group host${globe.grouphosts.length === 1 ? '' : 's'}.` : '.'),
|
|
494
|
-
|
|
527
|
+
// Without Jetstream the page has no feed to follow, and says so instead of reconnecting.
|
|
528
|
+
feed: on.jetstream ? '/subscribe' : false,
|
|
495
529
|
services: [
|
|
496
530
|
...globe.pds.map((p) => [
|
|
497
531
|
'PDS',
|
package/lib/settings.mjs
CHANGED
|
@@ -93,13 +93,13 @@ export function lookup(globe, name, later) {
|
|
|
93
93
|
slingshot: { url: `https://slingshot.${D}` },
|
|
94
94
|
pdsls: { url: `https://pdsls.${D}` },
|
|
95
95
|
mail: { url: null },
|
|
96
|
+
tap: { url: null },
|
|
96
97
|
};
|
|
97
|
-
if (svc === 'tap') return fail('services.tap has no address an app can take (it has no https name)');
|
|
98
98
|
if (!svc || !Object.hasOwn(svcs, svc)) return fail(`${svc ? `services.${svc}` : name} is not a service with an address: ${list(Object.keys(svcs))}`);
|
|
99
99
|
if (field === undefined || more.length || !Object.hasOwn(svcs[svc], field))
|
|
100
100
|
return fail(`${name} is not a value of services.${svc}; it has ${list(Object.keys(svcs[svc]).map((f) => `services.${svc}.${f}`))}`);
|
|
101
101
|
if (svc !== 'plc' && svc !== 'mail' && !globe.services[svc]) return fail(`services.${svc} is off in this file, so it has no address`);
|
|
102
|
-
return svc === 'mail' ? after() : { value: svcs[svc][field] };
|
|
102
|
+
return svc === 'mail' || svc === 'tap' ? after() : { value: svcs[svc][field] };
|
|
103
103
|
}
|
|
104
104
|
case 'accounts': {
|
|
105
105
|
if (rest.length === 1 && rest[0] === 'file') return after();
|
|
@@ -130,6 +130,16 @@ export function lookup(globe, name, later) {
|
|
|
130
130
|
/** The problems with the names in every apps[].env of `globe`, as [where, message] pairs. */
|
|
131
131
|
export function checkSettings(globe) {
|
|
132
132
|
const problems = [];
|
|
133
|
+
if (globe.tap?.webhook) {
|
|
134
|
+
const { parts, error } = parseValue(globe.tap.webhook);
|
|
135
|
+
if (error) problems.push(['services.tap.webhook', error]);
|
|
136
|
+
else
|
|
137
|
+
for (const part of parts) {
|
|
138
|
+
const found = part.name && lookup(globe, part.name);
|
|
139
|
+
if (found?.error) problems.push(['services.tap.webhook', found.error]);
|
|
140
|
+
else if (found?.later) problems.push(['services.tap.webhook', `${part.name} is known only after snowglobe up, so Tap cannot be given it`]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
133
143
|
globe.apps.forEach((app, i) => {
|
|
134
144
|
for (const [key, text] of Object.entries(app.env)) {
|
|
135
145
|
const at = `apps[${i}].env.${key}`;
|
package/lib/state.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// folder records which file owns it, so two files that say `globe: t1` cannot share one folder,
|
|
6
6
|
// or one compose project.
|
|
7
7
|
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { globeName } from './config.mjs';
|
|
11
11
|
import { ROOT, stateRoot } from './env.mjs';
|
|
@@ -35,6 +35,40 @@ export function findFile({ file, env = process.env, cwd = process.cwd(), allowDe
|
|
|
35
35
|
// The file that owns a state folder is recorded here, as an absolute path.
|
|
36
36
|
const OWNER = 'owner';
|
|
37
37
|
|
|
38
|
+
/** The file recorded as owning the state folder `state`, or '' when none is. */
|
|
39
|
+
export function ownerOf(state) {
|
|
40
|
+
const record = join(state, OWNER);
|
|
41
|
+
return existsSync(record) ? readFileSync(record, 'utf8').trim() : '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The file `up` or `down` was pointed at, from its -f option and its one word, which is a file
|
|
46
|
+
* or the name of a snowglobe (snowglobe ls shows them): { file } for a file, { name } for a
|
|
47
|
+
* snowglobe, {} for neither given (the file found from the folder you are in). A word that is a
|
|
48
|
+
* file that exists is that file; else it is a name when a state folder is called that. Throws
|
|
49
|
+
* when it is neither, or both -f and a word are given.
|
|
50
|
+
*/
|
|
51
|
+
export function pointedAt({ file, word, env = process.env } = {}) {
|
|
52
|
+
if (file && word) throw new Error(`give the snowglobe once: -f ${file} or ${word}, not both`);
|
|
53
|
+
if (file) return { file };
|
|
54
|
+
if (!word) return {};
|
|
55
|
+
if (existsSync(word) && statSync(word).isFile()) return { file: word };
|
|
56
|
+
if (!/[\\/]/.test(word) && existsSync(join(stateRoot(env), word))) return { name: word };
|
|
57
|
+
throw new Error(`${word} is not a file, and no snowglobe is called that (snowglobe ls lists them)`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The snowglobe called `name`, as session returns it, for a command that stops or reads one
|
|
62
|
+
* without its file: `path` is the file recorded as its owner, '' when that file is gone. Throws
|
|
63
|
+
* when no state folder has the name.
|
|
64
|
+
*/
|
|
65
|
+
export function byName(name, { env = process.env } = {}) {
|
|
66
|
+
const state = join(stateRoot(env), name);
|
|
67
|
+
if (!existsSync(state)) throw new Error(`no snowglobe is called ${name} (snowglobe ls lists them)`);
|
|
68
|
+
const owner = ownerOf(state);
|
|
69
|
+
return { path: owner && existsSync(owner) ? owner : '', name, state, project: env.SG_PROJECT || name };
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
/**
|
|
39
73
|
* Refuse a state folder whose recorded owner is another file that still exists. With `claim`, a
|
|
40
74
|
* folder with no owner, or whose owner is gone (moved or deleted), is recorded as this file's.
|
|
@@ -43,7 +77,7 @@ function owned({ name, state }, file, claim) {
|
|
|
43
77
|
// Returns a line to tell the user when a file took over a name whose first file is gone.
|
|
44
78
|
const mine = realpathSync(resolve(file));
|
|
45
79
|
const record = join(state, OWNER);
|
|
46
|
-
const first =
|
|
80
|
+
const first = ownerOf(state);
|
|
47
81
|
if (first && first !== mine && existsSync(first))
|
|
48
82
|
throw new Error(
|
|
49
83
|
`the snowglobe ${name} belongs to ${first} (its state is in ${state}), and ${mine} also says globe: ${name}. ` +
|
package/package.json
CHANGED
package/snowglobe.default.yml
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# The default snowglobe: one current PDS, a PLC directory and Jetstream, all at https names, with
|
|
2
2
|
# Slingshot and PDSls (the record browser) beside them.
|
|
3
|
-
# Copy this file to snowglobe.yml (here, or in your app's repo) and run
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Copy this file to snowglobe.yml (here, or in your app's repo) and run snowglobe up
|
|
4
|
+
# (bin/snowglobe.mjs up from a checkout of snowglobe). The commented sections below add more.
|
|
5
|
+
# What each one does:
|
|
6
|
+
# https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/snowglobe.md
|
|
7
|
+
# The whole thing, three PDSes, a relay and a lexicon authority:
|
|
8
|
+
# https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/examples/atmo.yml
|
|
6
9
|
|
|
7
10
|
globe: snowglobe
|
|
8
11
|
domain: local.snowglobe.at # every name below is under it
|
|
@@ -26,7 +29,7 @@ services: # these are the defaults, each true or false
|
|
|
26
29
|
jetstream: true
|
|
27
30
|
slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
|
|
28
31
|
pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
|
|
29
|
-
tap: false #
|
|
32
|
+
tap: false # sends an app the events of one collection (true, or a map; see snowglobe.md above)
|
|
30
33
|
# relay: auto # auto: on when there is more than one PDS
|
|
31
34
|
|
|
32
35
|
# Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
|
|
@@ -45,7 +48,8 @@ accounts: # <name>@<pds>; up makes each one
|
|
|
45
48
|
- bob@main
|
|
46
49
|
|
|
47
50
|
# Your app, reached at https://myapp.app.local.snowglobe.at, with settings from the snowglobe.
|
|
48
|
-
# `snowglobe env` hands them over
|
|
51
|
+
# `snowglobe env` hands them over. The names:
|
|
52
|
+
# https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/your-app.md
|
|
49
53
|
# apps:
|
|
50
54
|
# - name: myapp
|
|
51
55
|
# local: { port: 5173 }
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# A relay only adds localhost hosts through its admin API, so a PDS cannot register
|
|
15
15
|
# itself with requestCrawl. relay-init adds each PDS once the relay is up.
|
|
16
16
|
|
|
17
|
-
x-relay-admin-password: &relay-admin-password ${SG_RELAY_ADMIN_PASSWORD
|
|
17
|
+
x-relay-admin-password: &relay-admin-password ${SG_RELAY_ADMIN_PASSWORD:?snowglobe up makes it}
|
|
18
18
|
|
|
19
19
|
services:
|
|
20
20
|
pds:
|
|
@@ -9,7 +9,7 @@ services:
|
|
|
9
9
|
restart: unless-stopped
|
|
10
10
|
environment:
|
|
11
11
|
POSTGRES_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
|
|
12
|
-
POSTGRES_PASSWORD: ${SG_DB_PASSWORD
|
|
12
|
+
POSTGRES_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
|
|
13
13
|
POSTGRES_DB: ${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}
|
|
14
14
|
ports:
|
|
15
15
|
- "${SG_POSTGRES_PORT:-${DEVNET_POSTGRES_PORT:-5433}}:5432"
|
package/stack/docker-compose.yml
CHANGED
|
@@ -25,11 +25,11 @@ services:
|
|
|
25
25
|
environment:
|
|
26
26
|
PORT: 2582
|
|
27
27
|
DEVNET_DB_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
|
|
28
|
-
DEVNET_DB_PASSWORD: ${SG_DB_PASSWORD
|
|
28
|
+
DEVNET_DB_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
|
|
29
29
|
DEVNET_DB_HOST: ${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}
|
|
30
30
|
DEVNET_DB_PORT: ${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}
|
|
31
31
|
DEVNET_DB_NAME: ${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}
|
|
32
|
-
DATABASE_URL: "postgres://${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}:${SG_DB_PASSWORD
|
|
32
|
+
DATABASE_URL: "postgres://${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}:${SG_DB_PASSWORD:?snowglobe up makes it}@${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}:${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}/${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}"
|
|
33
33
|
ENABLE_MIGRATIONS: "true"
|
|
34
34
|
LOG_ENABLED: "true"
|
|
35
35
|
LOG_LEVEL: info
|
|
@@ -78,8 +78,8 @@ services:
|
|
|
78
78
|
PDS_DISABLE_SSRF_PROTECTION: "true"
|
|
79
79
|
PDS_INVITE_REQUIRED: "true"
|
|
80
80
|
PDS_ADMIN_PASSWORD: ${SG_PDS_ADMIN_PASSWORD:?snowglobe up makes it}
|
|
81
|
-
PDS_JWT_SECRET:
|
|
82
|
-
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX:
|
|
81
|
+
PDS_JWT_SECRET: ${SG_PDS_JWT_SECRET:?snowglobe up makes it}
|
|
82
|
+
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${SG_PLC_ROTATION_KEY:?snowglobe up makes it}
|
|
83
83
|
PDS_EMAIL_SMTP_URL: ${SG_SMTP_URL:-${DEVNET_SMTP_URL:-smtp://maildev:1025}}
|
|
84
84
|
PDS_EMAIL_FROM_ADDRESS: noreply@devnet.test
|
|
85
85
|
PDS_HANDLE_DOMAINS: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
|
|
@@ -124,7 +124,8 @@ services:
|
|
|
124
124
|
|
|
125
125
|
# ── TAP (repo sync + backfill) ───────────────────────────────────
|
|
126
126
|
tap:
|
|
127
|
-
|
|
127
|
+
# Pinned by digest (indigo revision 4f47add4, built 2026-03-11): the one the Statusphere flow was run against.
|
|
128
|
+
image: ghcr.io/bluesky-social/indigo/tap@sha256:5e20bfe416d29fcd215ed8bf99f10b2ab825a6de4e5599846dd33967ade2abeb
|
|
128
129
|
restart: unless-stopped
|
|
129
130
|
depends_on:
|
|
130
131
|
pds:
|
|
@@ -96,7 +96,7 @@ server {
|
|
|
96
96
|
listen 443 ssl;
|
|
97
97
|
server_name *.app.${SG_DOMAIN};
|
|
98
98
|
if ($snowglobe_app = "") {
|
|
99
|
-
return 404 "No app is registered at $host (
|
|
99
|
+
return 404 "No app is registered at $host (snowglobe app).\n";
|
|
100
100
|
}
|
|
101
101
|
location / {
|
|
102
102
|
proxy_pass http://$snowglobe_app;
|
package/stack/page/index.html
CHANGED
|
@@ -233,7 +233,8 @@
|
|
|
233
233
|
if (tally.people != null) parts.push(plural(tally.people, "person", "people"));
|
|
234
234
|
if (tally.groups) parts.push(plural(tally.groups, "group", "groups"));
|
|
235
235
|
var line = parts.length ? parts.join(" and ") + ". " : "";
|
|
236
|
-
|
|
236
|
+
// writes is null on a network with no feed to count them from.
|
|
237
|
+
document.getElementById("tally").textContent = line + (tally.writes == null ? "" : plural(tally.writes, "write", "writes") + " in the last 24 hours.");
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
function health(domain, services) {
|
|
@@ -380,6 +381,15 @@
|
|
|
380
381
|
connect();
|
|
381
382
|
}
|
|
382
383
|
|
|
384
|
+
// A network with no Jetstream has no feed: the panel says so instead of reconnecting forever.
|
|
385
|
+
function noActivity() {
|
|
386
|
+
document.getElementById("stream").style.display = "none";
|
|
387
|
+
document.getElementById("count").parentNode.style.display = "none";
|
|
388
|
+
var body = document.getElementById("events");
|
|
389
|
+
body.textContent = "";
|
|
390
|
+
add(body, el("p", { "class": "empty" }, "This network runs no Jetstream, so its writes are not followed here."));
|
|
391
|
+
}
|
|
392
|
+
|
|
383
393
|
getJSON("/snowglobe.json").then(function (g) {
|
|
384
394
|
document.title = g.globe + " · Snowglobe network";
|
|
385
395
|
document.getElementById("title").textContent = g.globe;
|
|
@@ -396,12 +406,14 @@
|
|
|
396
406
|
}
|
|
397
407
|
if (g.about) document.getElementById("about").textContent = g.about;
|
|
398
408
|
if (g.accountsNote) document.getElementById("accounts-note").textContent = g.accountsNote;
|
|
399
|
-
|
|
400
|
-
|
|
409
|
+
// feed false: the network runs no Jetstream. Unset, Jetstream is at its usual public name.
|
|
410
|
+
var feed = g.feed === false ? null : g.feed || "wss://jetstream." + g.domain + "/subscribe";
|
|
411
|
+
if (feed && feed.charAt(0) === "/") feed = "wss://" + location.host + feed;
|
|
412
|
+
if (!feed) { tally.writes = null; noActivity(); }
|
|
401
413
|
showTally();
|
|
402
414
|
health(g.domain, g.services || SERVICES);
|
|
403
415
|
// The feed names accounts by handle, so it waits for the account list.
|
|
404
|
-
accounts(g.accounts || ACCOUNTS).catch(function () {}).then(function () { activity(feed); });
|
|
416
|
+
accounts(g.accounts || ACCOUNTS).catch(function () {}).then(function () { if (feed) activity(feed); });
|
|
405
417
|
});
|
|
406
418
|
})();
|
|
407
419
|
</script>
|