@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +143 -2
- package/bin/snowglobe.mjs +69 -0
- package/lib/apps.mjs +97 -0
- package/lib/args.mjs +29 -0
- package/lib/certs.mjs +211 -0
- package/lib/commands/account.mjs +85 -0
- package/lib/commands/app.mjs +86 -0
- package/lib/commands/certs.mjs +36 -0
- package/lib/commands/check.mjs +37 -0
- package/lib/commands/down.mjs +37 -0
- package/lib/commands/env.mjs +145 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/render.mjs +63 -0
- package/lib/commands/run.mjs +109 -0
- package/lib/commands/trust.mjs +45 -0
- package/lib/commands/up.mjs +257 -0
- package/lib/config.mjs +485 -0
- package/lib/env.mjs +364 -0
- package/lib/hosts.mjs +36 -0
- package/lib/render.mjs +570 -0
- package/lib/settings.mjs +146 -0
- package/lib/state.mjs +75 -0
- package/lib/trust.mjs +288 -0
- package/lib/up.mjs +54 -0
- package/lib/xrpc.mjs +49 -0
- package/package.json +55 -4
- package/snowglobe.default.yml +54 -0
- package/stack/docker-compose.relay.yml +105 -0
- package/stack/docker-compose.test.yml +41 -0
- package/stack/docker-compose.yml +178 -0
- package/stack/https/app-names.cjs +31 -0
- package/stack/https/dns/Dockerfile +10 -0
- package/stack/https/dns/dns.sh +40 -0
- package/stack/https/nginx.conf.template +104 -0
- package/stack/https/proxy/Dockerfile +8 -0
- package/stack/https/proxy/server.mjs +213 -0
- package/stack/init/init.sh +118 -0
- package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
- package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
- package/stack/page/index.html +409 -0
- package/stack/page/snowglobe.conf +59 -0
- package/stack/pdsls/Dockerfile +27 -0
- package/stack/pdsls/network.patch +294 -0
- package/stack/pdsls/server.mjs +192 -0
- package/stack/relay/Dockerfile +30 -0
- package/stack/relay/allow-private-hosts.patch +55 -0
- package/stack/slingshot/Dockerfile +36 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// bin/snowglobe.mjs run [--app <name>] [--] <cmd> [args...]
|
|
2
|
+
//
|
|
3
|
+
// run runs a command with the snowglobe around it, for that command's process tree only:
|
|
4
|
+
// - it is handed the app's settings, the apps[].env of the snowglobe.yml (lib/commands/env.mjs;
|
|
5
|
+
// the same as snowglobe env, secrets included, none printed). They win over a value already in
|
|
6
|
+
// the environment, and one line names the ones they replaced. --app picks the app when the
|
|
7
|
+
// file lists several;
|
|
8
|
+
// - the globe's names (its page, each PDS, its services and plc.directory, from the rendered
|
|
9
|
+
// globe), every handle in https/names (account and up add them) and every app in
|
|
10
|
+
// https/apps, as <name>.app.<domain>, resolve to SG_BIND, where nginx answers on
|
|
11
|
+
// 443 (lib/hosts.mjs). HTTPS_RUN_EXTRA_NAMES adds names (space-separated);
|
|
12
|
+
// - Node trusts the globe's CA (NODE_EXTRA_CA_CERTS=<state root>/ca/ca.crt), on top of its own
|
|
13
|
+
// roots. Miniflare passes the same file on to workerd.
|
|
14
|
+
//
|
|
15
|
+
// The globe is the one the snowglobe.yml in this folder (or above) describes, as for up.
|
|
16
|
+
//
|
|
17
|
+
// e.g. bin/snowglobe.mjs run node scripts/https-probe.mjs
|
|
18
|
+
// (cd ../myapp && ../snowglobe/bin/snowglobe.mjs run npm run dev)
|
|
19
|
+
//
|
|
20
|
+
// How: bubblewrap gives the command its own mount namespace with a generated hosts file bound
|
|
21
|
+
// over /etc/hosts. Nothing else changes: same filesystem, same network, same user. Outside,
|
|
22
|
+
// every name resolves as usual. The names are read when the command starts, so a handle or app
|
|
23
|
+
// added later needs a restart of the command. Linux only: a Mac has no bubblewrap, and reaches
|
|
24
|
+
// the globe through snowglobe env instead (the names resolve through public DNS, and the CA is in
|
|
25
|
+
// NODE_EXTRA_CA_CERTS).
|
|
26
|
+
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
import { accessSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { constants as os } from 'node:os';
|
|
30
|
+
import { delimiter, join } from 'node:path';
|
|
31
|
+
import { readApps } from '../apps.mjs';
|
|
32
|
+
import { ROOT, paths, resolveBind, resolveDomain } from '../env.mjs';
|
|
33
|
+
import { hostsFile, runNames } from '../hosts.mjs';
|
|
34
|
+
import { session } from '../state.mjs';
|
|
35
|
+
import { appSettings } from './env.mjs';
|
|
36
|
+
|
|
37
|
+
export const command = {
|
|
38
|
+
name: 'run',
|
|
39
|
+
usage: 'run [--app <name>] [--] <cmd> [args...]',
|
|
40
|
+
// Every word after run is the command's own, dashes and all, except an --app <name> first of
|
|
41
|
+
// all; none is a usage mistake.
|
|
42
|
+
parse: (args) => {
|
|
43
|
+
const named = args[0] === '--app';
|
|
44
|
+
if (named && args.length < 2) return null;
|
|
45
|
+
const rest = named ? args.slice(2) : args;
|
|
46
|
+
const cmd = rest[0] === '--' ? rest.slice(1) : rest;
|
|
47
|
+
return cmd.length ? { cmd, app: named ? args[1] : undefined } : null;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// The first `name` on PATH that can be run, or null.
|
|
52
|
+
function onPath(name, env) {
|
|
53
|
+
for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
54
|
+
try {
|
|
55
|
+
accessSync(join(dir, name), constants.X_OK);
|
|
56
|
+
return join(dir, name);
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const run = ({ cmd, app }) => runUnder(cmd, { settings: appSettings({ app, secrets: true }).vars });
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run `cmd` (a word list) under the globe's names with `settings` ([{ name, value }], from
|
|
66
|
+
* appSettings) in its environment; resolves to its exit code. A setting replaces a value of the same
|
|
67
|
+
* name already in `env`, and `log` is told which.
|
|
68
|
+
*/
|
|
69
|
+
export function runUnder(cmd, { root = ROOT, env = process.env, state = session({ env }).state, settings = [], log = (m) => console.error(m) } = {}) {
|
|
70
|
+
if (process.platform !== 'linux')
|
|
71
|
+
throw new Error('run needs Linux (bubblewrap) for now; on this system eval "$(snowglobe env)" hands the app the same settings');
|
|
72
|
+
const p = paths(root, state);
|
|
73
|
+
if (!existsSync(p.ca)) throw new Error(`no ${p.ca}; run snowglobe up (or certs) first`);
|
|
74
|
+
if (!existsSync(p.leafNames)) throw new Error(`no ${p.leafNames}; run snowglobe up first`);
|
|
75
|
+
const bwrap = onPath('bwrap', env);
|
|
76
|
+
if (!bwrap) throw new Error('bwrap (bubblewrap) is not installed, or not on PATH');
|
|
77
|
+
// Refuses a SG_DOMAIN the render is not for, and a SG_BIND that is not loopback.
|
|
78
|
+
const { domain } = resolveDomain({ root, state, env });
|
|
79
|
+
const bind = resolveBind({ root, env });
|
|
80
|
+
|
|
81
|
+
const read = (f) => (existsSync(f) ? readFileSync(f, 'utf8') : '');
|
|
82
|
+
const names = runNames({ leafNames: read(p.leafNames), handles: read(p.names), apps: readApps(p), domain, extra: env.HTTPS_RUN_EXTRA_NAMES });
|
|
83
|
+
// Written aside and renamed, so a run already holding the old file is undisturbed.
|
|
84
|
+
const hosts = join(p.https, 'hosts');
|
|
85
|
+
writeFileSync(`${hosts}.tmp.${process.pid}`, hostsFile(read('/etc/hosts'), names, bind), { mode: 0o644 });
|
|
86
|
+
renameSync(`${hosts}.tmp.${process.pid}`, hosts);
|
|
87
|
+
|
|
88
|
+
// Never the values: a setting may be a login.
|
|
89
|
+
const replaced = settings.filter((x) => !x.auto && env[x.name] !== undefined && env[x.name] !== x.value).map((x) => x.name);
|
|
90
|
+
if (replaced.length) log(`snowglobe run: the snowglobe's settings replaced ${replaced.join(', ')} from the environment`);
|
|
91
|
+
|
|
92
|
+
const child = spawn(bwrap, ['--dev-bind', '/', '/', '--ro-bind', hosts, '/etc/hosts', '--die-with-parent', '--', ...cmd], {
|
|
93
|
+
stdio: 'inherit',
|
|
94
|
+
env: { ...env, NODE_EXTRA_CA_CERTS: p.ca, ...Object.fromEntries(settings.map((x) => [x.name, x.value])) },
|
|
95
|
+
});
|
|
96
|
+
// A terminal's ^C reaches the whole process group, the command included, so it is left to the
|
|
97
|
+
// command; a signal sent to run alone is passed on.
|
|
98
|
+
process.on('SIGINT', () => {});
|
|
99
|
+
for (const sig of ['SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig));
|
|
100
|
+
return new Promise((done, fail) => {
|
|
101
|
+
// A spawn error can quote an environment value; a setting may be a login, so it is named instead.
|
|
102
|
+
child.on('error', (e) => {
|
|
103
|
+
let msg = e.message;
|
|
104
|
+
for (const x of settings) if (x.value) msg = msg.split(x.value).join(`<${x.name}>`);
|
|
105
|
+
fail(Object.assign(new Error(msg), { code: e.code }));
|
|
106
|
+
});
|
|
107
|
+
child.on('exit', (code, signal) => done(signal ? 128 + (os.signals[signal] ?? 0) : code));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// bin/snowglobe.mjs trust [--remove] [--dry-run] [--no-sudo]
|
|
2
|
+
//
|
|
3
|
+
// trust puts your snowglobe CA (one per user, <state root>/ca/ca.crt, made by certs or up) where a
|
|
4
|
+
// browser or program looks for the CAs it trusts, so https://local.snowglobe.at opens without a
|
|
5
|
+
// warning: the system store and the NSS databases Chrome, Edge and Firefox use on Linux; on WSL
|
|
6
|
+
// also the Windows store, whose command it prints for PowerShell; the login keychain on macOS; the
|
|
7
|
+
// user store on Windows (lib/trust.mjs). Every command is printed as it runs, and the ones that
|
|
8
|
+
// need sudo are marked. Running it again adds nothing. --remove takes the CA out of all of them.
|
|
9
|
+
// --dry-run prints what it would run and changes nothing. --no-sudo skips the steps that need sudo
|
|
10
|
+
// and prints them for you. The CA is limited to the snowglobe's names (lib/certs.mjs), so trusting
|
|
11
|
+
// it does not let it vouch for other sites.
|
|
12
|
+
//
|
|
13
|
+
// Exit 0 when every step is done or in place, 1 when one could not be (a tool is missing, a command
|
|
14
|
+
// failed, sudo skipped), 2 when there is no CA yet. Steps left for you to run (Windows from WSL,
|
|
15
|
+
// Firefox on macOS) do not change the exit code.
|
|
16
|
+
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { stateRoot } from '../env.mjs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const command = {
|
|
22
|
+
name: 'trust',
|
|
23
|
+
usage: 'trust [--remove] [--dry-run] [--no-sudo]',
|
|
24
|
+
options: { remove: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, 'no-sudo': { type: 'boolean' } },
|
|
25
|
+
positionals: [0, 0],
|
|
26
|
+
// Each flag once at most.
|
|
27
|
+
accepts: ({ tokens }) => new Set(tokens.filter((t) => t.kind === 'option').map((t) => t.name)).size === tokens.length,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function run({ values }, { ctx } = {}) {
|
|
31
|
+
const { applySteps, realContext, trustSteps } = await import('../trust.mjs');
|
|
32
|
+
const ca = join(stateRoot(), 'ca', 'ca.crt');
|
|
33
|
+
if (!existsSync(ca)) {
|
|
34
|
+
console.error(`snowglobe trust: no CA at ${ca}. Run snowglobe up (or snowglobe certs) first, which makes it`);
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
const c = ctx ?? realContext();
|
|
38
|
+
const remove = Boolean(values.remove);
|
|
39
|
+
console.log(`${remove ? 'Removing' : 'Trusting'} ${ca}${values['dry-run'] ? ' (dry run: nothing is changed)' : ''}`);
|
|
40
|
+
const r = applySteps(trustSteps(ca, c), { remove, dryRun: Boolean(values['dry-run']), noSudo: Boolean(values['no-sudo']), ctx: c });
|
|
41
|
+
console.log(r.failed ? `${r.failed} step(s) not done.` : 'Done.');
|
|
42
|
+
if (r.manual) console.log(`${r.manual} step(s) are for you to run, as printed above.`);
|
|
43
|
+
if (!remove && !r.failed) console.log('Restart a browser that is open for it to see the CA.');
|
|
44
|
+
return r.failed ? 1 : 0;
|
|
45
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// bin/snowglobe.mjs up [<file>]
|
|
2
|
+
//
|
|
3
|
+
// up brings up the globe a snowglobe.yml describes, as the compose project named for the file's
|
|
4
|
+
// `globe:` (or SG_PROJECT). Safe to rerun: each step keeps what an earlier run made, and a rerun
|
|
5
|
+
// is one `docker compose up`.
|
|
6
|
+
//
|
|
7
|
+
// Everything it makes is kept in the snowglobe's state folder, <state root>/<globe> (lib/env.mjs
|
|
8
|
+
// stateRoot: ~/.local/share/snowglobe, or under XDG_DATA_HOME; %LOCALAPPDATA% on Windows), which
|
|
9
|
+
// remembers the file that owns it and refuses another file with the same `globe:`. Below, data/
|
|
10
|
+
// means that folder. A checkout that still holds a globe in its own data/ is refused (nothing
|
|
11
|
+
// started, nothing moved) with the command that moves it.
|
|
12
|
+
//
|
|
13
|
+
// 1. render: data/globe/ from <file>, else SG_FILE, else the nearest snowglobe.yml here or above
|
|
14
|
+
// (none: up refuses; `up --default` runs the default globe, snowglobe.default.yml: one PDS,
|
|
15
|
+
// a PLC directory and Jetstream).
|
|
16
|
+
// SG_DOMAIN, from the environment or .env, replaces the file's domain.
|
|
17
|
+
// A group host with no image in the file is built from its build folder, else from
|
|
18
|
+
// SG_OPENSOCIAL_BUILD (a checkout of atproto-group-host); with neither, up stops here.
|
|
19
|
+
// 2. certs, if data/https has no leaf yet, or it lacks a name (the user's CA, in ca/ beside the
|
|
20
|
+
// snowglobes' folders, is made once and kept)
|
|
21
|
+
// 3. nginx's app routes and the network page, from data/https/apps (app --render), then each
|
|
22
|
+
// group host built from a folder is built again (Docker's cache keeps that quick)
|
|
23
|
+
// 4. pass 1: up. The base init seeds alice, bob and SG_INVITE_CODE into
|
|
24
|
+
// data/accounts.env on the PDS with bare handles (else the first), as it does for the
|
|
25
|
+
// http stacks.
|
|
26
|
+
// 5. the lexicon authority, lex-authority on the file's lexicons.authority PDS
|
|
27
|
+
// (LEX_AUTHORITY_* in data/accounts.env), then pass 2: up with
|
|
28
|
+
// SG_LEXICON_AUTHORITY_DID, which recreates the PDSes with it
|
|
29
|
+
// 6. what the file seeds: each of its accounts (snowglobe account; the login under <NAME>_* in
|
|
30
|
+
// data/accounts.env, <NAME>_<PDS>_* on a PDS other than the one with bare handles, else the
|
|
31
|
+
// first), and its lexicons.publish folder into the authority (snowglobe lexicons). Its apps'
|
|
32
|
+
// local routes (port, handle ending) are set before step 2, so the leaf and nginx have them.
|
|
33
|
+
// 7. an old data/devnet.env, from before snowglobe env, is removed; nothing writes one now
|
|
34
|
+
//
|
|
35
|
+
// A rerun finds the accounts and leaves unchanged lexicons alone. Anything else an app makes
|
|
36
|
+
// (more accounts, invite codes) is its own, with account, invite and lexicons. Nothing secret is
|
|
37
|
+
// printed. The tool ports come from the usual variables
|
|
38
|
+
// (SG_PLC_PORT, SG_JETSTREAM_PORT, SG_JETSTREAM_METRICS_PORT,
|
|
39
|
+
// SG_MAILDEV_WEB_PORT, SG_MAILDEV_SMTP_PORT, SG_PROXY_PORT), in the environment or
|
|
40
|
+
// .env; pass the same ones on every run. devnet-spaces, the shared devnet, is refused (exit 3) as
|
|
41
|
+
// the project unless SG_RESET_OK=1, before anything else is done.
|
|
42
|
+
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
44
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { dirname, join, resolve } from 'node:path';
|
|
46
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
47
|
+
import { appEnding, appEndingMessage, formatApps, readApps, renderRoutes, writeApps } from '../apps.mjs';
|
|
48
|
+
import { ROOT, addName, dc, ensureAdminPassword, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
|
|
49
|
+
import { claimState, session } from '../state.mjs';
|
|
50
|
+
import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
|
|
51
|
+
import { account } from './account.mjs';
|
|
52
|
+
import { lexicons } from './lexicons.mjs';
|
|
53
|
+
import { checkedGlobe } from './check.mjs';
|
|
54
|
+
import { writeGlobe } from './render.mjs';
|
|
55
|
+
|
|
56
|
+
export const command = {
|
|
57
|
+
name: 'up',
|
|
58
|
+
usage: 'up [<file> | --default]',
|
|
59
|
+
// --default is the only way up runs the repo's default snowglobe; with no file and no
|
|
60
|
+
// snowglobe.yml in this folder or above, up refuses.
|
|
61
|
+
options: { default: { type: 'boolean' } },
|
|
62
|
+
positionals: [0, 1],
|
|
63
|
+
accepts: ({ values, positionals }) => !(values.default && positionals.length),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// How long a one-shot (init, relay-init) may take, and nginx to answer again after a restart.
|
|
67
|
+
const ONESHOT_SECONDS = 300;
|
|
68
|
+
const NGINX_SECONDS = 30;
|
|
69
|
+
|
|
70
|
+
// Until nginx answers the globe's page over TLS, with any status.
|
|
71
|
+
async function waitServing(domain, { root, state, env }) {
|
|
72
|
+
const seconds = Number(env.SG_NGINX_WAIT) || NGINX_SECONDS;
|
|
73
|
+
for (let n = 0; ; n++) {
|
|
74
|
+
try {
|
|
75
|
+
return await snowglobeRequest(`https://${domain}/`, { root, state, env });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (n >= seconds) throw new Error(`nginx did not answer https://${domain}/ within ${seconds} s after its restart: ${e.message}`);
|
|
78
|
+
await sleep(1000);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function run({ values, positionals: [file] }) {
|
|
84
|
+
await up({ file: values.default ? join(ROOT, 'snowglobe.default.yml') : file, log: (m) => console.error(m) });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function up({ file, root = ROOT, env = process.env, log = () => {} }) {
|
|
88
|
+
// The shared devnet is refused before anything is looked up, even by a file that is not there.
|
|
89
|
+
if (env.SG_PROJECT) guardProject(env);
|
|
90
|
+
const found = session({ file, env, allowDefault: false });
|
|
91
|
+
const project = guardProject(env, found.name);
|
|
92
|
+
const state = found.state;
|
|
93
|
+
const old = oldDataNotice(root, state);
|
|
94
|
+
if (old) throw new Error(old);
|
|
95
|
+
const renamed = resolve(found.path) === join(root, 'snowglobe.default.yml') && renamedDefaultNotice(root, state);
|
|
96
|
+
if (renamed) throw new Error(renamed);
|
|
97
|
+
const p = paths(root, state);
|
|
98
|
+
// Both are read as compose reads them, and refused here when they are not a domain and a
|
|
99
|
+
// loopback address.
|
|
100
|
+
const { set } = resolveDomain({ root, state, env, rendering: true });
|
|
101
|
+
resolveBind({ root, env });
|
|
102
|
+
|
|
103
|
+
const { path, globe } = checkedGlobe(found.path, { domain: set || undefined });
|
|
104
|
+
if (!globe) throw new Error(`${path} has problems (above); ${p.globe} is as it was`);
|
|
105
|
+
// From here on the file's globe is the one this state folder is for.
|
|
106
|
+
const took = claimState(found);
|
|
107
|
+
if (took) log(took);
|
|
108
|
+
// Checked before the render changes, so a refused file leaves the running globe's render.
|
|
109
|
+
const kept = readApps(p);
|
|
110
|
+
const badEnding = appEnding(kept);
|
|
111
|
+
if (badEnding) throw new Error(appEndingMessage(p.apps, badEnding));
|
|
112
|
+
const routes = mergeRoutes(kept, globe.apps);
|
|
113
|
+
const home = (globe.pds.find((x) => x.bare_handles) ?? globe.pds[0]).name;
|
|
114
|
+
const seedAccounts = accountPrefixes(globe.accounts, home);
|
|
115
|
+
const clash = endingClash(
|
|
116
|
+
[...globe.pds, ...globe.grouphosts].map((x) => x.name),
|
|
117
|
+
routes,
|
|
118
|
+
);
|
|
119
|
+
if (clash)
|
|
120
|
+
throw new Error(
|
|
121
|
+
`the server ${clash.pds} would take the handles under ${clash.pds}.<domain>, the ending app ${clash.app} claimed; rename the server, or snowglobe app --remove ${clash.app}`,
|
|
122
|
+
);
|
|
123
|
+
if (formatApps(routes) !== formatApps(kept)) {
|
|
124
|
+
mkdirSync(p.https, { recursive: true });
|
|
125
|
+
writeApps(p, routes);
|
|
126
|
+
}
|
|
127
|
+
writeGlobe(globe, path, p.globe, env, state);
|
|
128
|
+
const domain = globe.domain;
|
|
129
|
+
const ghs = globe.grouphosts.length ? `, group hosts ${globe.grouphosts.map((x) => x.name).join(' ')}` : '';
|
|
130
|
+
log(`${path}: globe ${globe.globe} at ${domain}, PDSs ${globe.pds.map((x) => x.name).join(' ')}${ghs}`);
|
|
131
|
+
|
|
132
|
+
// The leaf must carry every name (lib/certs.mjs): a leaf from before the app routes moved lacks
|
|
133
|
+
// *.app.<domain>, and one from before an app claimed a handle ending lacks its wildcard.
|
|
134
|
+
const { ensureCerts, leafLacks, leafNames } = await import('../certs.mjs');
|
|
135
|
+
const names = leafNames(p, domain, env.HTTPS_EXTRA_NAMES);
|
|
136
|
+
const newCerts = leafLacks(p.https, names, p.caDir);
|
|
137
|
+
if (newCerts) await ensureCerts({ dir: p.https, caDir: p.caDir, domain, names, log });
|
|
138
|
+
renderRoutes({ root, state, env });
|
|
139
|
+
|
|
140
|
+
const facts = (name) => envGet(name, p.globeEnv).split(' ').filter(Boolean);
|
|
141
|
+
// compose fills the file's ${SG_LEXICON_AUTHORITY_DID} from this.
|
|
142
|
+
const composeEnv = { ...env, SG_PDS_ADMIN_PASSWORD: ensureAdminPassword({ root, state, env }) };
|
|
143
|
+
const compose = (args, opts = {}) => {
|
|
144
|
+
const res = dc(project, args, { root, state, env: composeEnv, ...opts });
|
|
145
|
+
if (res.error) throw res.error;
|
|
146
|
+
if (res.status !== 0)
|
|
147
|
+
throw new Error(`docker compose ${args.join(' ')} failed (exit ${res.status}). If a container crash-loops after you updated snowglobe, an image built earlier may be stale (the slingshot image is tagged by its commit, so a changed stack/slingshot/ folder alone does not rebuild it): run snowglobe down (without -v, which keeps your data), remove the image with docker rmi, then run up again`);
|
|
148
|
+
return res;
|
|
149
|
+
};
|
|
150
|
+
// The nginx template as the running nginx last started with it (stamped after a restart here).
|
|
151
|
+
const templateFile = join(root, 'stack/https/nginx.conf.template');
|
|
152
|
+
const templateStamp = join(p.https, 'nginx-template.sha256');
|
|
153
|
+
const templateHash = createHash('sha256').update(existsSync(templateFile) ? readFileSync(templateFile) : '').digest('hex');
|
|
154
|
+
const templateChanged = !existsSync(templateStamp) || readFileSync(templateStamp, 'utf8').trim() !== templateHash;
|
|
155
|
+
const put = (file, text) => {
|
|
156
|
+
writeFileSync(`${file}.tmp`, `${text}\n`);
|
|
157
|
+
renameSync(`${file}.tmp`, file);
|
|
158
|
+
};
|
|
159
|
+
// Container ids, none when compose cannot say (a project not made yet).
|
|
160
|
+
const ids = (args) => (dc(project, ['ps', ...args], { root, state, stdio: 'pipe', encoding: 'utf8' }).stdout ?? '').split('\n').filter(Boolean);
|
|
161
|
+
|
|
162
|
+
// `up --wait` alone fails as soon as a one-shot service exits, even with 0. So: up, then wait
|
|
163
|
+
// for the one-shots (init seeds data/accounts.env; relay-init registers every PDS with the
|
|
164
|
+
// relay), then for every long-running service to be healthy.
|
|
165
|
+
async function waitDone(service) {
|
|
166
|
+
for (let n = 0; n < ONESHOT_SECONDS; n++) {
|
|
167
|
+
const st = compose(['ps', '-a', '--format', '{{.State}} {{.ExitCode}}', service], { stdio: 'pipe', encoding: 'utf8' }).stdout.split('\n')[0];
|
|
168
|
+
if (st === 'exited 0') return;
|
|
169
|
+
if (st.startsWith('exited')) throw new Error(`${service} failed (exit ${st.slice('exited '.length)})`);
|
|
170
|
+
await sleep(1000);
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`${service} did not finish within ${ONESHOT_SECONDS} s`);
|
|
173
|
+
}
|
|
174
|
+
const waitServices = () => compose(['up', '-d', '--wait', ...facts('SG_SERVICES')]);
|
|
175
|
+
async function upStack() {
|
|
176
|
+
// A service the file no longer runs (a PDS taken out, a service turned off) is stopped.
|
|
177
|
+
compose(['up', '-d', '--remove-orphans']);
|
|
178
|
+
for (const s of facts('SG_ONESHOTS')) await waitDone(s);
|
|
179
|
+
waitServices();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// What runs before this run: the relay, and every container, to notice a PDS this run makes.
|
|
183
|
+
const relayBefore = ids(['-q', 'relay']);
|
|
184
|
+
const before = new Set(ids(['-aq']));
|
|
185
|
+
|
|
186
|
+
// Every image the file builds is built before anything starts, so `up` never builds while
|
|
187
|
+
// containers are being made (on a runner with no images, the relay and the other built services
|
|
188
|
+
// were made, then made again by the next `up`, and Jetstream lost the relay between the two).
|
|
189
|
+
// A group host built from a folder is the folder as it is now; Docker's cache makes an
|
|
190
|
+
// unchanged image quick.
|
|
191
|
+
const builds = facts('SG_BUILDS');
|
|
192
|
+
log(`Building images${builds.length ? ` (${builds.join(' ')})` : ''}`);
|
|
193
|
+
compose(['build', '--quiet']);
|
|
194
|
+
|
|
195
|
+
// A rerun starts the PDSes with the authority they already have, so pass 1 changes nothing.
|
|
196
|
+
composeEnv.SG_LEXICON_AUTHORITY_DID = envGet('LEX_AUTHORITY_DID', p.accounts);
|
|
197
|
+
log(`Pass 1: up (project ${project})`);
|
|
198
|
+
await upStack();
|
|
199
|
+
// A running nginx keeps the certificate files it started with, and the template it was started
|
|
200
|
+
// with (the image fills it in at start); new ones need a restart, whether or not the leaf was
|
|
201
|
+
// reissued. The globe's servers may have changed with the render, so a running nginx reloads them.
|
|
202
|
+
if (newCerts || templateChanged) {
|
|
203
|
+
compose(['restart', 'nginx'], { stdio: 'ignore' });
|
|
204
|
+
if (templateChanged) put(templateStamp, templateHash);
|
|
205
|
+
// Its health check is nginx -t, which passes before it listens again, and the next step
|
|
206
|
+
// reaches the PDSes through it.
|
|
207
|
+
await waitServing(domain, { root, state, env });
|
|
208
|
+
} else compose(['exec', '-T', 'nginx', 'nginx', '-s', 'reload'], { stdio: 'ignore' });
|
|
209
|
+
|
|
210
|
+
const authority = envGet('SG_AUTHORITY_PDS', p.globeEnv);
|
|
211
|
+
const did = await account({ name: 'lex-authority', prefix: 'LEX_AUTHORITY', pds: authority, file: p.accounts, root, state, env, log });
|
|
212
|
+
if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
|
|
213
|
+
composeEnv.SG_LEXICON_AUTHORITY_DID = did;
|
|
214
|
+
log(`Pass 2: up with the lexicon authority ${did}`);
|
|
215
|
+
await upStack();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A relay that was running when a PDS went away redials it with a growing backoff; a PDS
|
|
219
|
+
// added back under that name was not crawled again until the relay restarted (2026-10-20: no
|
|
220
|
+
// dial in the nine minutes after it came back). So when this run made a PDS container beside a
|
|
221
|
+
// relay that was already running, the relay restarts, and the services wait for it again.
|
|
222
|
+
if (relayBefore.length) {
|
|
223
|
+
const fresh = facts('SG_PDS').find((x) => ids(['-q', `pds-${x}`]).some((id) => !before.has(id)));
|
|
224
|
+
if (fresh) {
|
|
225
|
+
log(`Restarting the relay: pds-${fresh} is new beside it`);
|
|
226
|
+
compose(['restart', 'relay'], { stdio: 'ignore' });
|
|
227
|
+
waitServices();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// init's accounts are on the seed PDS; run maps their handles.
|
|
232
|
+
for (const prefix of ['ALICE', 'BOB']) {
|
|
233
|
+
const handle = envGet(`${prefix}_HANDLE`, p.accounts);
|
|
234
|
+
if (handle) addName(handle, { root, state });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// What the file seeds. A rerun finds each account by its kept login.
|
|
238
|
+
for (const a of seedAccounts) await account({ ...a, file: p.accounts, root, state, env, log });
|
|
239
|
+
if (globe.lexicons.publish) await lexicons({ dir: resolve(dirname(path), globe.lexicons.publish), root, state, env, log });
|
|
240
|
+
|
|
241
|
+
// Written by snowglobe up until snowglobe env replaced it; an app that still sources it would
|
|
242
|
+
// read addresses that no longer follow the file.
|
|
243
|
+
const denv = join(p.data, 'devnet.env');
|
|
244
|
+
if (existsSync(denv)) {
|
|
245
|
+
rmSync(denv);
|
|
246
|
+
log(`Removed the old ${denv}; snowglobe env hands an app these settings now`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// init leaves data/accounts.env and data/accounts.json world-readable on every up; both hold
|
|
250
|
+
// logins.
|
|
251
|
+
makePrivate(p.accounts);
|
|
252
|
+
makePrivate(join(p.data, 'accounts.json'));
|
|
253
|
+
|
|
254
|
+
log('');
|
|
255
|
+
const ps = dc(project, ['ps', '--format', '{{.Service}} {{.State}} {{.Ports}}'], { root, state, stdio: 'pipe', encoding: 'utf8' });
|
|
256
|
+
log(ps.stdout.trimEnd());
|
|
257
|
+
}
|