@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.1
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 +148 -2
- package/bin/snowglobe.mjs +71 -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 +40 -0
- package/lib/commands/env.mjs +146 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/ls.mjs +72 -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 +301 -0
- package/lib/config.mjs +514 -0
- package/lib/env.mjs +526 -0
- package/lib/hosts.mjs +36 -0
- package/lib/prebuilt.mjs +56 -0
- package/lib/render.mjs +601 -0
- package/lib/settings.mjs +156 -0
- package/lib/state.mjs +109 -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 +179 -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,63 @@
|
|
|
1
|
+
// bin/snowglobe.mjs render [<file>] [--out <dir>] [--domain <domain>]
|
|
2
|
+
//
|
|
3
|
+
// render checks the file as check does (exit 1 with one line per problem), then writes the https
|
|
4
|
+
// globe it describes into <dir> (globe/ in the snowglobe's state folder by default, see
|
|
5
|
+
// lib/state.mjs): compose.yml, nginx's servers, the leaf certificate's names and globe.env, the
|
|
6
|
+
// facts the other commands read (lib/render.mjs). Nothing in them is secret. --domain replaces the file's domain, for a copy beside another globe. A group
|
|
7
|
+
// host with no image or build folder in the file is built from the folder its kind's variable
|
|
8
|
+
// names (SG_OPENSOCIAL_BUILD, a checkout of atproto-group-host); with neither, render fails.
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { withGroupHostSources } from '../config.mjs';
|
|
13
|
+
import { ROOT, paths } from '../env.mjs';
|
|
14
|
+
import { loadBase, renderGlobe } from '../render.mjs';
|
|
15
|
+
import { claimState, session } from '../state.mjs';
|
|
16
|
+
import { checkedGlobe } from './check.mjs';
|
|
17
|
+
|
|
18
|
+
export const command = {
|
|
19
|
+
name: 'render',
|
|
20
|
+
usage: 'render [<file>] [--out <dir>] [--domain <domain>]',
|
|
21
|
+
options: { out: { type: 'string' }, domain: { type: 'string' } },
|
|
22
|
+
positionals: [0, 1],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function run({ values, positionals }) {
|
|
26
|
+
const { path, globe } = checkedGlobe(positionals[0], { domain: values.domain });
|
|
27
|
+
if (!globe) return 1;
|
|
28
|
+
let out;
|
|
29
|
+
try {
|
|
30
|
+
// Into the state folder, the file takes it over; into --out, it only reads where the state is.
|
|
31
|
+
const s = session({ file: path });
|
|
32
|
+
if (values.out === undefined) claimState(s);
|
|
33
|
+
out = writeGlobe(globe, path, values.out ?? paths(ROOT, s.state).globe, process.env, s.state);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
for (const line of e.message.split('\n')) console.error(`${path}: ${line}`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
console.log(`${path}: wrote ${out}: globe ${globe.globe} at ${globe.domain}, PDSs ${globe.pds.map((p) => p.name).join(' ')}`);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write the checked `globe`, read from `path`, into `out`, a folder; returns it. `state` is the
|
|
44
|
+
* snowglobe's state folder, which the compose file mounts from. Throws, before writing anything,
|
|
45
|
+
* when a group host has nowhere to get its image from.
|
|
46
|
+
*/
|
|
47
|
+
export function writeGlobe(globe, path, out, env, state) {
|
|
48
|
+
globe = withGroupHostSources(globe, env);
|
|
49
|
+
// The compose file mounts nginx's part of the output, by a path from the repo root, which is
|
|
50
|
+
// the compose project directory (lib/env.mjs dc).
|
|
51
|
+
out = resolve(out);
|
|
52
|
+
const rel = relative(ROOT, out);
|
|
53
|
+
const globeDir = rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : out;
|
|
54
|
+
const files = renderGlobe(globe, loadBase(ROOT), { stateDir: state, globeDir, source: basename(path) });
|
|
55
|
+
for (const [name, text] of Object.entries(files)) {
|
|
56
|
+
const target = join(out, name);
|
|
57
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
58
|
+
// Written aside and renamed, so nothing reads half a file.
|
|
59
|
+
writeFileSync(`${target}.tmp`, text);
|
|
60
|
+
renameSync(`${target}.tmp`, target);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
@@ -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, state, 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,301 @@
|
|
|
1
|
+
// bin/snowglobe.mjs up [<file> | <name>] [-f <file>] [--default]
|
|
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> (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
|
|
15
|
+
// (none: up refuses; `up --default` runs the default globe, snowglobe.default.yml: one PDS,
|
|
16
|
+
// a PLC directory and Jetstream).
|
|
17
|
+
// SG_DOMAIN, from the environment or .env, replaces the file's domain.
|
|
18
|
+
// A group host with no image in the file is built from its build folder, else from
|
|
19
|
+
// SG_OPENSOCIAL_BUILD (a checkout of atproto-group-host); with neither, up stops here.
|
|
20
|
+
// 2. certs, if data/https has no leaf yet, or it lacks a name (the user's CA, in ca/ beside the
|
|
21
|
+
// snowglobes' folders, is made once and kept)
|
|
22
|
+
// 3. nginx's app routes and the network page, from data/https/apps (app --render), then each
|
|
23
|
+
// group host built from a folder is built again (Docker's cache keeps that quick)
|
|
24
|
+
// 4. pass 1: up. The base init seeds alice, bob and SG_INVITE_CODE into
|
|
25
|
+
// data/accounts.env on the PDS with bare handles (else the first), as it does for the
|
|
26
|
+
// http stacks.
|
|
27
|
+
// 5. the lexicon authority, lex-authority on the file's lexicons.authority PDS
|
|
28
|
+
// (LEX_AUTHORITY_* in data/accounts.env), then pass 2: up with
|
|
29
|
+
// SG_LEXICON_AUTHORITY_DID, which recreates the PDSes with it
|
|
30
|
+
// 6. what the file seeds: each of its accounts (snowglobe account; the login under <NAME>_* in
|
|
31
|
+
// data/accounts.env, <NAME>_<PDS>_* on a PDS other than the one with bare handles, else the
|
|
32
|
+
// first), and its lexicons.publish folder into the authority (snowglobe lexicons). Its apps'
|
|
33
|
+
// local routes (port, handle ending) are set before step 2, so the leaf and nginx have them.
|
|
34
|
+
// 7. an old data/devnet.env, from before snowglobe env, is removed; nothing writes one now
|
|
35
|
+
//
|
|
36
|
+
// A rerun finds the accounts and leaves unchanged lexicons alone. Anything else an app makes
|
|
37
|
+
// (more accounts, invite codes) is its own, with account, invite and lexicons. Nothing secret is
|
|
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
|
|
42
|
+
// the project unless SG_RESET_OK=1, before anything else is done.
|
|
43
|
+
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
46
|
+
import { dirname, join, resolve } from 'node:path';
|
|
47
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
48
|
+
import { appEnding, appEndingMessage, formatApps, readApps, renderRoutes, writeApps } from '../apps.mjs';
|
|
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';
|
|
53
|
+
import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
|
|
54
|
+
import { account } from './account.mjs';
|
|
55
|
+
import { lexicons } from './lexicons.mjs';
|
|
56
|
+
import { checkedGlobe } from './check.mjs';
|
|
57
|
+
import { writeGlobe } from './render.mjs';
|
|
58
|
+
|
|
59
|
+
export const command = {
|
|
60
|
+
name: 'up',
|
|
61
|
+
usage: 'up [<file> | <name>] [-f <file>] [--default]',
|
|
62
|
+
// --default is the only way up runs the repo's default snowglobe; with no file and no
|
|
63
|
+
// snowglobe.yml in this folder or above, up refuses.
|
|
64
|
+
options: { default: { type: 'boolean' }, file: { type: 'string', short: 'f' } },
|
|
65
|
+
positionals: [0, 1],
|
|
66
|
+
accepts: ({ values, positionals }) => !(values.default && (positionals.length || values.file)),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// How long a one-shot (init, relay-init) may take, and nginx to answer again after a restart.
|
|
70
|
+
const ONESHOT_SECONDS = 300;
|
|
71
|
+
const NGINX_SECONDS = 30;
|
|
72
|
+
|
|
73
|
+
// Until nginx answers the globe's page over TLS, with any status.
|
|
74
|
+
async function waitServing(domain, { root, state, env }) {
|
|
75
|
+
const seconds = Number(env.SG_NGINX_WAIT) || NGINX_SECONDS;
|
|
76
|
+
for (let n = 0; ; n++) {
|
|
77
|
+
try {
|
|
78
|
+
return await snowglobeRequest(`https://${domain}/`, { root, state, env });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (n >= seconds) throw new Error(`nginx did not answer https://${domain}/ within ${seconds} s after its restart: ${e.message}`);
|
|
81
|
+
await sleep(1000);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
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) });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function up({ file, root = ROOT, env = process.env, log = () => {} }) {
|
|
106
|
+
// The shared devnet is refused before anything is looked up, even by a file that is not there.
|
|
107
|
+
if (env.SG_PROJECT) guardProject(env);
|
|
108
|
+
const found = session({ file, env, allowDefault: false });
|
|
109
|
+
const project = guardProject(env, found.name);
|
|
110
|
+
const state = found.state;
|
|
111
|
+
const old = oldDataNotice(root, state);
|
|
112
|
+
if (old) throw new Error(old);
|
|
113
|
+
const renamed = resolve(found.path) === join(root, 'snowglobe.default.yml') && renamedDefaultNotice(root, state);
|
|
114
|
+
if (renamed) throw new Error(renamed);
|
|
115
|
+
const p = paths(root, state);
|
|
116
|
+
// Both are read as compose reads them, and refused here when they are not a domain and a
|
|
117
|
+
// loopback address.
|
|
118
|
+
const { set } = resolveDomain({ root, state, env, rendering: true });
|
|
119
|
+
resolveBind({ root, state, env });
|
|
120
|
+
|
|
121
|
+
const { path, globe } = checkedGlobe(found.path, { domain: set || undefined });
|
|
122
|
+
if (!globe) throw new Error(`${path} has problems (above); ${p.globe} is as it was`);
|
|
123
|
+
// From here on the file's globe is the one this state folder is for.
|
|
124
|
+
const took = claimState(found);
|
|
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}`);
|
|
129
|
+
// Checked before the render changes, so a refused file leaves the running globe's render.
|
|
130
|
+
const kept = readApps(p);
|
|
131
|
+
const badEnding = appEnding(kept);
|
|
132
|
+
if (badEnding) throw new Error(appEndingMessage(p.apps, badEnding));
|
|
133
|
+
const routes = mergeRoutes(kept, globe.apps);
|
|
134
|
+
const home = (globe.pds.find((x) => x.bare_handles) ?? globe.pds[0]).name;
|
|
135
|
+
const seedAccounts = accountPrefixes(globe.accounts, home);
|
|
136
|
+
const clash = endingClash(
|
|
137
|
+
[...globe.pds, ...globe.grouphosts].map((x) => x.name),
|
|
138
|
+
routes,
|
|
139
|
+
);
|
|
140
|
+
if (clash)
|
|
141
|
+
throw new Error(
|
|
142
|
+
`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}`,
|
|
143
|
+
);
|
|
144
|
+
if (formatApps(routes) !== formatApps(kept)) {
|
|
145
|
+
mkdirSync(p.https, { recursive: true });
|
|
146
|
+
writeApps(p, routes);
|
|
147
|
+
}
|
|
148
|
+
writeGlobe(globe, path, p.globe, env, state);
|
|
149
|
+
const domain = globe.domain;
|
|
150
|
+
const ghs = globe.grouphosts.length ? `, group hosts ${globe.grouphosts.map((x) => x.name).join(' ')}` : '';
|
|
151
|
+
log(`${path}: globe ${globe.globe} at ${domain}, PDSs ${globe.pds.map((x) => x.name).join(' ')}${ghs}`);
|
|
152
|
+
|
|
153
|
+
// The leaf must carry every name (lib/certs.mjs): a leaf from before the app routes moved lacks
|
|
154
|
+
// *.app.<domain>, and one from before an app claimed a handle ending lacks its wildcard.
|
|
155
|
+
const { ensureCerts, leafLacks, leafNames } = await import('../certs.mjs');
|
|
156
|
+
const names = leafNames(p, domain, env.HTTPS_EXTRA_NAMES);
|
|
157
|
+
const newCerts = leafLacks(p.https, names, p.caDir);
|
|
158
|
+
if (newCerts) await ensureCerts({ dir: p.https, caDir: p.caDir, domain, names, log });
|
|
159
|
+
renderRoutes({ root, state, env });
|
|
160
|
+
|
|
161
|
+
const facts = (name) => envGet(name, p.globeEnv).split(' ').filter(Boolean);
|
|
162
|
+
// compose fills the file's ${SG_LEXICON_AUTHORITY_DID} from this.
|
|
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 };
|
|
167
|
+
const compose = (args, opts = {}) => {
|
|
168
|
+
const res = dc(project, args, { root, state, env: composeEnv, ...opts });
|
|
169
|
+
if (res.error) throw res.error;
|
|
170
|
+
if (res.status !== 0)
|
|
171
|
+
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`);
|
|
172
|
+
return res;
|
|
173
|
+
};
|
|
174
|
+
// The nginx template as the running nginx last started with it (stamped after a restart here).
|
|
175
|
+
const templateFile = join(root, 'stack/https/nginx.conf.template');
|
|
176
|
+
const templateStamp = join(p.https, 'nginx-template.sha256');
|
|
177
|
+
const templateHash = createHash('sha256').update(existsSync(templateFile) ? readFileSync(templateFile) : '').digest('hex');
|
|
178
|
+
const templateChanged = !existsSync(templateStamp) || readFileSync(templateStamp, 'utf8').trim() !== templateHash;
|
|
179
|
+
const put = (file, text) => {
|
|
180
|
+
writeFileSync(`${file}.tmp`, `${text}\n`);
|
|
181
|
+
renameSync(`${file}.tmp`, file);
|
|
182
|
+
};
|
|
183
|
+
// Container ids, none when compose cannot say (a project not made yet).
|
|
184
|
+
const ids = (args) => (dc(project, ['ps', ...args], { root, state, stdio: 'pipe', encoding: 'utf8' }).stdout ?? '').split('\n').filter(Boolean);
|
|
185
|
+
|
|
186
|
+
// `up --wait` alone fails as soon as a one-shot service exits, even with 0. So: up, then wait
|
|
187
|
+
// for the one-shots (init seeds data/accounts.env; relay-init registers every PDS with the
|
|
188
|
+
// relay), then for every long-running service to be healthy.
|
|
189
|
+
async function waitDone(service) {
|
|
190
|
+
for (let n = 0; n < ONESHOT_SECONDS; n++) {
|
|
191
|
+
const st = compose(['ps', '-a', '--format', '{{.State}} {{.ExitCode}}', service], { stdio: 'pipe', encoding: 'utf8' }).stdout.split('\n')[0];
|
|
192
|
+
if (st === 'exited 0') return;
|
|
193
|
+
if (st.startsWith('exited')) throw new Error(`${service} failed (exit ${st.slice('exited '.length)})`);
|
|
194
|
+
await sleep(1000);
|
|
195
|
+
}
|
|
196
|
+
throw new Error(`${service} did not finish within ${ONESHOT_SECONDS} s`);
|
|
197
|
+
}
|
|
198
|
+
const waitServices = () => compose(['up', '-d', '--wait', ...facts('SG_SERVICES')]);
|
|
199
|
+
async function upStack() {
|
|
200
|
+
// A service the file no longer runs (a PDS taken out, a service turned off) is stopped.
|
|
201
|
+
compose(['up', '-d', '--remove-orphans']);
|
|
202
|
+
for (const s of facts('SG_ONESHOTS')) await waitDone(s);
|
|
203
|
+
waitServices();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// What runs before this run: the relay, and every container, to notice a PDS this run makes.
|
|
207
|
+
const relayBefore = ids(['-q', 'relay']);
|
|
208
|
+
const before = new Set(ids(['-aq']));
|
|
209
|
+
|
|
210
|
+
// Every image the file builds is built before anything starts, so `up` never builds while
|
|
211
|
+
// containers are being made (on a runner with no images, the relay and the other built services
|
|
212
|
+
// were made, then made again by the next `up`, and Jetstream lost the relay between the two).
|
|
213
|
+
// A group host built from a folder is the folder as it is now; Docker's cache makes an
|
|
214
|
+
// unchanged image quick.
|
|
215
|
+
const builds = facts('SG_BUILDS');
|
|
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
|
+
}
|
|
232
|
+
|
|
233
|
+
// A rerun starts the PDSes with the authority they already have, so pass 1 changes nothing.
|
|
234
|
+
composeEnv.SG_LEXICON_AUTHORITY_DID = envGet('LEX_AUTHORITY_DID', p.accounts);
|
|
235
|
+
log(`Pass 1: up (project ${project})`);
|
|
236
|
+
await upStack();
|
|
237
|
+
// A running nginx keeps the certificate files it started with, and the template it was started
|
|
238
|
+
// with (the image fills it in at start); new ones need a restart, whether or not the leaf was
|
|
239
|
+
// reissued. The globe's servers may have changed with the render, so a running nginx reloads them.
|
|
240
|
+
if (newCerts || templateChanged) {
|
|
241
|
+
compose(['restart', 'nginx'], { stdio: 'ignore' });
|
|
242
|
+
if (templateChanged) put(templateStamp, templateHash);
|
|
243
|
+
// Its health check is nginx -t, which passes before it listens again, and the next step
|
|
244
|
+
// reaches the PDSes through it.
|
|
245
|
+
await waitServing(domain, { root, state, env });
|
|
246
|
+
} else compose(['exec', '-T', 'nginx', 'nginx', '-s', 'reload'], { stdio: 'ignore' });
|
|
247
|
+
|
|
248
|
+
const authority = envGet('SG_AUTHORITY_PDS', p.globeEnv);
|
|
249
|
+
const did = await account({ name: 'lex-authority', prefix: 'LEX_AUTHORITY', pds: authority, file: p.accounts, root, state, env, log });
|
|
250
|
+
if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
|
|
251
|
+
composeEnv.SG_LEXICON_AUTHORITY_DID = did;
|
|
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' });
|
|
259
|
+
await upStack();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A relay that was running when a PDS went away redials it with a growing backoff; a PDS
|
|
263
|
+
// added back under that name was not crawled again until the relay restarted (2026-10-20: no
|
|
264
|
+
// dial in the nine minutes after it came back). So when this run made a PDS container beside a
|
|
265
|
+
// relay that was already running, the relay restarts, and the services wait for it again.
|
|
266
|
+
if (relayBefore.length) {
|
|
267
|
+
const fresh = facts('SG_PDS').find((x) => ids(['-q', `pds-${x}`]).some((id) => !before.has(id)));
|
|
268
|
+
if (fresh) {
|
|
269
|
+
log(`Restarting the relay: pds-${fresh} is new beside it`);
|
|
270
|
+
compose(['restart', 'relay'], { stdio: 'ignore' });
|
|
271
|
+
waitServices();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// init's accounts are on the seed PDS; run maps their handles.
|
|
276
|
+
for (const prefix of ['ALICE', 'BOB']) {
|
|
277
|
+
const handle = envGet(`${prefix}_HANDLE`, p.accounts);
|
|
278
|
+
if (handle) addName(handle, { root, state });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// What the file seeds. A rerun finds each account by its kept login.
|
|
282
|
+
for (const a of seedAccounts) await account({ ...a, file: p.accounts, root, state, env, log });
|
|
283
|
+
if (globe.lexicons.publish) await lexicons({ dir: resolve(dirname(path), globe.lexicons.publish), root, state, env, log });
|
|
284
|
+
|
|
285
|
+
// Written by snowglobe up until snowglobe env replaced it; an app that still sources it would
|
|
286
|
+
// read addresses that no longer follow the file.
|
|
287
|
+
const denv = join(p.data, 'devnet.env');
|
|
288
|
+
if (existsSync(denv)) {
|
|
289
|
+
rmSync(denv);
|
|
290
|
+
log(`Removed the old ${denv}; snowglobe env hands an app these settings now`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// init leaves data/accounts.env and data/accounts.json world-readable on every up; both hold
|
|
294
|
+
// logins.
|
|
295
|
+
makePrivate(p.accounts);
|
|
296
|
+
makePrivate(join(p.data, 'accounts.json'));
|
|
297
|
+
|
|
298
|
+
log('');
|
|
299
|
+
const ps = dc(project, ['ps', '--format', '{{.Service}} {{.State}} {{.Ports}}'], { root, state, stdio: 'pipe', encoding: 'utf8' });
|
|
300
|
+
log(ps.stdout.trimEnd());
|
|
301
|
+
}
|