Repository navigation
Expand file tree
/
Copy pathstart.js
More file actions
339 lines (306 loc) · 13.9 KB
/
Copy pathstart.js
File metadata and controls
339 lines (306 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
// Starts Umami on velixir.
//
// Umami's own server does the real work. This file is the part that knows about the platform:
//
// 1. With no DATABASE_URL it serves a setup page instead of exiting, and keeps /healthz at
// 200. A crash loop reads as "the platform is broken".
// 2. It gives Umami a DATABASE_URL its database clients can use over velixir's TLS (db.js),
// and a stable APP_SECRET.
// 3. It runs Umami's own startup check: connect, check the version, apply migrations.
// 4. It takes the default admin/umami login out of play before anything can reach the
// server (admin.js).
// 5. It starts Umami's server on 0.0.0.0:$PORT with telemetry off, and passes the
// platform's stop signal on to it.
//
// While 3 and 4 run, or while the database cannot be reached, a small page on $PORT says so
// and the checks are retried every few seconds, rather than the process exiting.
'use strict';
const { spawn } = require('node:child_process');
const crypto = require('node:crypto');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { forNodePostgres } = require('./db');
const port = Number.parseInt(process.env.PORT, 10) || 8080;
const databaseUrl = process.env.DATABASE_URL;
const umamiDir = path.join(__dirname, 'umami');
const RETRY_SECONDS = 10;
// Umami's check-db.js never closes its database pool, so after its last check it sits idle
// for ten seconds until node-postgres drops the idle connection, and every boot waits for it.
// Its final line means everything it does is done, so it is stopped there instead. Should a
// later Umami word that line differently, this simply waits for the script to exit.
const CHECK_DONE = 'Database is up to date.';
let server = null; // this file's own page, whenever Umami's server is not the one listening
let child = null; // whatever is running right now: a startup check, or Umami's server
let preparing = false; // inside the startup checks, including the admin login step
let stopping = false;
const state = { mode: 'setup', problem: null };
if (!fs.existsSync(path.join(umamiDir, 'server.js'))) {
// Only happens locally: on velixir the build creates ./umami or the deploy fails there.
console.error('Umami has not been built yet. Run `npm run build` first.');
process.exit(1);
}
// The platform stops a container with SIGTERM. Node running as PID 1 has no default handler
// for it, so without this the signal would be ignored and the container killed 30 seconds
// later instead of stopping cleanly.
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
if (stopping) return;
stopping = true;
if (child) {
child.kill(signal);
// Kubernetes allows 30 seconds before it kills the pod anyway.
setTimeout(() => child?.kill('SIGKILL'), 25_000).unref();
} else if (!preparing) {
process.exit(0);
}
// Otherwise the admin login step is mid-flight. boot() exits the moment it returns, so a
// password that has just been replaced is never lost without being printed.
});
}
// ─── Setup mode ───────────────────────────────────────────────────────────────
const problem = checkDatabaseUrl(databaseUrl);
if (problem) {
state.problem = problem;
console.log(
`${problem} This app is serving its setup page.\n` +
'On velixir: create a managed Postgres, bind it to this app as DATABASE_URL, then redeploy.\n' +
'Locally: DATABASE_URL=postgres://user:pass@localhost:5432/umami npm start'
);
listen('setup');
} else {
boot().catch((err) => {
console.error(err);
process.exit(1);
});
}
function checkDatabaseUrl(url) {
if (!url) return 'DATABASE_URL is not set.';
try {
const { protocol } = new URL(url);
if (protocol === 'postgres:' || protocol === 'postgresql:') return null;
} catch {
// Falls through: said the same way as a wrong scheme.
}
return 'DATABASE_URL is set, but it is not a postgres:// URL.';
}
// ─── Boot ─────────────────────────────────────────────────────────────────────
async function boot() {
listen('starting');
const env = umamiEnv();
for (let attempt = 1; ; attempt++) {
preparing = true;
const error = await prepare(env, attempt);
preparing = false;
if (stopping) process.exit(0);
if (!error) break;
state.mode = 'waiting';
if (attempt === 1 || attempt % 6 === 0) {
console.error(`Umami cannot start yet (attempt ${attempt}): ${error}`);
console.error(`Retrying every ${RETRY_SECONDS} seconds.`);
}
await new Promise((resolve) => setTimeout(resolve, RETRY_SECONDS * 1000));
if (stopping) process.exit(0);
}
await closeServer();
startUmami(env);
}
function umamiEnv() {
return {
...process.env,
NODE_ENV: 'production',
PORT: String(port),
// Next.js's standalone server binds $HOSTNAME, and Docker and Kubernetes both set that to
// the container's own name. The platform routes to 0.0.0.0.
HOSTNAME: '0.0.0.0',
DATABASE_URL: forNodePostgres(databaseUrl),
DIRECT_DATABASE_URL: process.env.DIRECT_DATABASE_URL || databaseUrl,
APP_SECRET: process.env.APP_SECRET || deriveAppSecret(databaseUrl),
// Umami's telemetry is on unless this is set. So is the Prisma CLI's, which the migration
// step runs, until CHECKPOINT_DISABLE is.
DISABLE_TELEMETRY: process.env.DISABLE_TELEMETRY ?? '1',
NEXT_TELEMETRY_DISABLED: '1',
CHECKPOINT_DISABLE: '1',
// check-db.js runs `prisma migrate deploy` by name, as Umami's own start script arranges.
PATH: path.join(umamiDir, 'node_modules', '.bin') + path.delimiter + (process.env.PATH || ''),
};
}
// Umami signs logins with APP_SECRET and, when it is unset, falls back to a hash of
// DATABASE_URL. That fallback would now hash the rewritten URL from db.js, so the same idea is
// applied here to the URL exactly as velixir injected it: identical on every boot and every
// replica, and nothing to configure. Rotating the database password changes it, which signs
// everyone out once. Set APP_SECRET yourself to pin it.
function deriveAppSecret(url) {
return crypto.createHmac('sha256', url).update('velixir umami APP_SECRET').digest('hex');
}
// Umami's Docker image runs check-db.js and update-tracker.js before its server. Same order
// here, with the admin login secured once the tables exist.
async function prepare(env, attempt) {
const checkFailed = await runScript('scripts/check-db.js', env, attempt, CHECK_DONE);
if (checkFailed) return checkFailed;
try {
await secureAdminLogin(env.DATABASE_URL);
} catch (err) {
// Fail closed. Starting Umami while the default login might still work is worse than
// not starting it.
return `could not check the admin login: ${err.message}`;
}
// Rewrites the tracker for a custom collect path. It does nothing without the variable.
if (process.env.COLLECT_API_ENDPOINT) {
return runScript('scripts/update-tracker.js', env, attempt);
}
return null;
}
function secureAdminLogin(connectionString) {
// Loaded here rather than at the top: the setup page never needs a database client.
return require('./admin').secureAdminLogin(connectionString);
}
// Runs one of Umami's scripts. Its output goes to the log when it succeeds and, when it fails,
// on the first attempt and then once a minute, so a database that is down for an hour does
// not bury everything else in the log.
function runScript(script, env, attempt, doneLine) {
return new Promise((resolve) => {
let output = '';
let done = false;
const proc = spawn(process.execPath, [script], { cwd: umamiDir, env, stdio: ['ignore', 'pipe', 'pipe'] });
child = proc;
const collect = (chunk) => {
output += chunk;
if (doneLine && !done && output.includes(doneLine)) {
done = true;
proc.kill('SIGTERM');
}
};
proc.stdout.on('data', collect);
proc.stderr.on('data', collect);
proc.on('error', (err) => (output += err.message));
proc.on('close', (code) => {
child = null;
const ok = code === 0 || done;
const text = output.trim();
if ((ok || attempt === 1 || attempt % 6 === 0) && text) console.log(text);
if (ok) return resolve(null);
const lastLine = text.split('\n').pop() || `exit code ${code}`;
resolve(`${script} failed: ${lastLine.replace(/^✗\s*/, '')}`);
});
});
}
function startUmami(env) {
const { version } = JSON.parse(fs.readFileSync(path.join(umamiDir, 'package.json'), 'utf8'));
console.log(`Starting Umami ${version} on 0.0.0.0:${port}`);
child = spawn(process.execPath, ['server.js'], { cwd: umamiDir, env, stdio: 'inherit' });
child.on('error', (err) => {
console.error(`Umami's server could not start: ${err.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
child = null;
if (stopping) process.exit(0);
// Exiting lets the platform restart the container. This only happens when Umami itself
// fails, never for missing configuration.
console.error(`Umami's server stopped unexpectedly (${signal || `exit code ${code}`}).`);
process.exit(code || 1);
});
}
// ─── Setup and startup page ───────────────────────────────────────────────────
function listen(mode) {
state.mode = mode;
server = http.createServer(handle);
// 0.0.0.0, not localhost: the platform's health check comes from outside the container.
server.listen(port, '0.0.0.0', () =>
console.log(`${mode === 'setup' ? 'setup' : 'startup'} page listening on ${port}`));
}
function closeServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
server.closeAllConnections();
server = null;
});
}
function handle(req, res) {
const pathname = (req.url || '/').split('?')[0];
const setup = state.mode === 'setup';
// Setup mode is healthy: the app is doing what it should with what it has. While Umami is
// starting it is not ready yet, so a readiness check keeps traffic away until it is.
const status = setup ? 200 : 503;
const retry = setup ? {} : { 'retry-after': String(RETRY_SECONDS) };
if (pathname === '/healthz') {
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', ...retry });
return res.end(setup ? 'ok (setup mode: no database bound)' : 'starting');
}
if (pathname.startsWith('/api/')) {
res.writeHead(503, { 'content-type': 'application/json', 'retry-after': String(RETRY_SECONDS) });
return res.end(JSON.stringify({
error: setup
? 'No database is bound to this app yet. Bind a managed Postgres as DATABASE_URL and redeploy.'
: 'Umami is starting. Retry in a few seconds.',
}));
}
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', ...retry });
res.end(setup
? page('Connect a database', setupBody())
: page('Starting Umami', startingBody(state.mode === 'waiting'), { refresh: 5 }));
}
function setupBody() {
return `
<h1>Connect a database</h1>
<p class="sub">Umami keeps its analytics in Postgres. ${escapeHtml(state.problem)}</p>
<ol class="steps">
<li><strong>Create a managed Postgres</strong> from the Databases page, or run
<code>velixir db create</code>.</li>
<li><strong>Bind it to this app</strong> as <code>DATABASE_URL</code>: on the database's page
under Bound apps, or <code>velixir db bind <database-id> --app <app-id></code>.</li>
<li><strong>Redeploy</strong> with the Redeploy button on the live release (Deploys tab).
Umami creates its tables on the next boot and prints your admin login to the app's logs.</li>
</ol>
<p class="note">Deploying this template from the velixir gallery does all three for you.</p>`;
}
function startingBody(waiting) {
return waiting
? `
<h1>Waiting for the database</h1>
<p class="sub">Umami is running, but its startup check has not passed yet. It retries every
${RETRY_SECONDS} seconds and this page refreshes itself.</p>
<p class="note">The app's logs say what failed. If the database password was rotated,
redeploy the app to pick up the new one.</p>`
: `
<h1>Starting Umami</h1>
<p class="sub">Checking the database and applying migrations. This page refreshes itself.</p>`;
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[c]);
}
function page(title, body, { refresh } = {}) {
return `<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
${refresh ? `<meta http-equiv="refresh" content="${refresh}">` : ''}
<title>${escapeHtml(title)}</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; background:#0b0b10; color:#e8e8ef;
font:15px/1.6 ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif; }
.wrap { max-width:38rem; margin:0 auto; padding:3rem 1.25rem 4rem; }
h1 { font-size:1.5rem; margin:0 0 .25rem; letter-spacing:-.02em; }
.sub { color:#7a7a8c; font-size:.8125rem; margin:0 0 2rem; }
code { background:#16161f; border:1px solid #26263a; border-radius:5px;
padding:.1rem .35rem; color:#c7d2fe; font-size:.8125rem; overflow-wrap:anywhere; }
ol.steps { padding-left:1.25rem; margin:0 0 1.5rem; }
ol.steps li { margin-bottom:.9rem; }
.note { color:#7a7a8c; font-size:.8125rem; }
footer { color:#4b4b5c; font-size:.75rem; margin-top:2.5rem; text-align:center; }
footer a { color:#7a7a8c; }
</style>
<div class="wrap">
${body}
<footer>
<a href="https://umami.is">Umami</a> · running on <a href="https://velixir.net">velixir</a>
</footer>
</div>
</html>`;
}