@mason-data/cli 0.0.0-stage → 0.6.2
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/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LOCAL SERVERS' GUARD (security comes first here). `mason start` serves the command server
|
|
3
|
+
* on the developer's machine, which reads the database and WRITES the sources file of a folder for the `mason`
|
|
4
|
+
* commands, and its web page (`mason ui`, src/web/), whose users section adds and removes the project's users. Any
|
|
5
|
+
* website the developer visits can make their browser send requests to 127.0.0.1, so each request must prove it comes
|
|
6
|
+
* from the run itself (a command) or from a page the run served:
|
|
7
|
+
*
|
|
8
|
+
* - LOOPBACK ONLY: the servers bind 127.0.0.1.
|
|
9
|
+
* - A TOKEN: 32 random bytes, made per run and printed in the URL `mason ui` opens. The first request trades it
|
|
10
|
+
* for an HttpOnly, SameSite=Strict cookie and is redirected to a URL without it; every request after needs the
|
|
11
|
+
* cookie. A page on another site cannot read it, and SameSite=Strict keeps the browser from sending it on a
|
|
12
|
+
* request another site starts.
|
|
13
|
+
* - THE HOST HEADER must name this server (127.0.0.1:<port> or localhost:<port>): a DNS-rebinding page reaches
|
|
14
|
+
* 127.0.0.1 under its own name, and is refused by that name.
|
|
15
|
+
* - THE ORIGIN of every request that changes something (anything but GET / HEAD) must be this server's own: a
|
|
16
|
+
* cross-site form or fetch carries the other site's origin.
|
|
17
|
+
* - No CORS headers, `frame-ancestors 'none'` (no other page may frame these), and `Referrer-Policy: no-referrer`
|
|
18
|
+
* (the token URL never leaks as a referrer).
|
|
19
|
+
*
|
|
20
|
+
* THE COMMAND LINE (`mason status`, `mason add`, …; src/cli/control.ts) sends the same token as `Authorization: Bearer`,
|
|
21
|
+
* read from the run's settings file, which only the developer can read (0600 in a 0700 folder). It needs no cookie and
|
|
22
|
+
* no Origin: a browser sends an Authorization header across sites only after a CORS preflight, which these servers
|
|
23
|
+
* never approve, so a page on another site cannot send one at all.
|
|
24
|
+
*
|
|
25
|
+
* Cookies are per host, not per port, so one cookie serves every page of a run; a page of another run, or one
|
|
26
|
+
* the token does not match, is refused. Pure over the request's headers, so the refusals are unit-tested
|
|
27
|
+
* (tests/cli-security.test.ts).
|
|
28
|
+
*/
|
|
29
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
30
|
+
export const COOKIE = "mason_dev";
|
|
31
|
+
export function newToken() { return randomBytes(32).toString("hex"); }
|
|
32
|
+
/** Constant-time string equality (a token compared byte by byte leaks its prefix through timing). */
|
|
33
|
+
export function sameSecret(a, b) {
|
|
34
|
+
const x = Buffer.from(a), y = Buffer.from(b);
|
|
35
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
36
|
+
}
|
|
37
|
+
export function cookieOf(header, name = COOKIE) {
|
|
38
|
+
for (const part of (header ?? "").split(";")) {
|
|
39
|
+
const eq = part.indexOf("=");
|
|
40
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
41
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function setCookieHeader(token) {
|
|
46
|
+
return `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`;
|
|
47
|
+
}
|
|
48
|
+
/** Whether `req` may be served by the server on `port` whose run token is `token`. */
|
|
49
|
+
export function guard(req, port, token) {
|
|
50
|
+
const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
51
|
+
if (!req.host || !hosts.includes(req.host.toLowerCase())) {
|
|
52
|
+
return { ok: false, status: 421, reason: `this server answers only as ${hosts[0]} (the request named ${JSON.stringify(req.host ?? "")})` };
|
|
53
|
+
}
|
|
54
|
+
if (req.authorization !== undefined) {
|
|
55
|
+
const bearer = /^Bearer\s+(\S+)$/i.exec(req.authorization)?.[1];
|
|
56
|
+
return bearer !== undefined && sameSecret(bearer, token) ? { ok: true }
|
|
57
|
+
: { ok: false, status: 403, reason: "the Authorization header does not carry this run's token" };
|
|
58
|
+
}
|
|
59
|
+
const url = new URL(req.url, `http://${req.host}`);
|
|
60
|
+
const offered = url.searchParams.get("token");
|
|
61
|
+
if (offered !== null) {
|
|
62
|
+
if (!sameSecret(offered, token))
|
|
63
|
+
return { ok: false, status: 403, reason: "the token in the URL is not this run's" };
|
|
64
|
+
url.searchParams.delete("token");
|
|
65
|
+
return { ok: false, status: 303, location: url.pathname + (url.search || "") + url.hash, setCookie: setCookieHeader(token) };
|
|
66
|
+
}
|
|
67
|
+
const cookie = cookieOf(req.cookie);
|
|
68
|
+
if (cookie === null || !sameSecret(cookie, token)) {
|
|
69
|
+
return { ok: false, status: 403, reason: "this is mason start's command server for the mason command: a request carries the run's token (Authorization: Bearer, from .mason/config.json)" };
|
|
70
|
+
}
|
|
71
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
72
|
+
const own = hosts.map((h) => `http://${h}`);
|
|
73
|
+
if (!req.origin || !own.includes(req.origin.toLowerCase())) {
|
|
74
|
+
return { ok: false, status: 403, reason: `a change must come from this page (Origin ${JSON.stringify(req.origin ?? "")})` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
/** The headers every response of a guarded server carries. */
|
|
80
|
+
export const SECURITY_HEADERS = {
|
|
81
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
82
|
+
"X-Frame-Options": "DENY",
|
|
83
|
+
"X-Content-Type-Options": "nosniff",
|
|
84
|
+
"Referrer-Policy": "no-referrer",
|
|
85
|
+
"Cache-Control": "no-store",
|
|
86
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SELF-SIGNED X.509 CERTIFICATE, MADE WITH NODE ALONE (`mason start --self-signed-tls`, src/cli/pg-tls.ts).
|
|
3
|
+
* `node:crypto` generates the key and signs, but has no API that writes a certificate, and the tree has no library that
|
|
4
|
+
* does — so the certificate is DER written here: one shape, no extensions, nothing parsed. RFC 5280 §4.1:
|
|
5
|
+
*
|
|
6
|
+
* Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature BIT STRING }
|
|
7
|
+
* TBSCertificate ::= SEQUENCE { [0] version v3, serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo }
|
|
8
|
+
*
|
|
9
|
+
* RSA 2048 with SHA-256: the key every TLS client takes (a hosted BI tool's JVM included); ECDSA would do for most.
|
|
10
|
+
* Node's own `X509Certificate` reads it back and verifies its signature (tests/pg-tls.test.ts), and a real handshake
|
|
11
|
+
* against the Postgres listener uses it. What a self-signed certificate gives is ENCRYPTION — a client with
|
|
12
|
+
* `sslmode=require` checks no certificate — never proof of whose server it is: that is a certificate a CA signed.
|
|
13
|
+
*/
|
|
14
|
+
import * as crypto from "node:crypto";
|
|
15
|
+
const tlv = (tag, content) => {
|
|
16
|
+
const n = content.length;
|
|
17
|
+
if (n < 0x80)
|
|
18
|
+
return Buffer.concat([Buffer.from([tag, n]), content]);
|
|
19
|
+
const len = [];
|
|
20
|
+
for (let m = n; m > 0; m >>= 8)
|
|
21
|
+
len.unshift(m & 0xff);
|
|
22
|
+
return Buffer.concat([Buffer.from([tag, 0x80 | len.length, ...len]), content]);
|
|
23
|
+
};
|
|
24
|
+
const seq = (...parts) => tlv(0x30, Buffer.concat(parts));
|
|
25
|
+
function oid(dotted) {
|
|
26
|
+
const [a, b, ...rest] = dotted.split(".").map(Number);
|
|
27
|
+
const bytes = [40 * a + b];
|
|
28
|
+
for (const n of rest) {
|
|
29
|
+
const groups = [];
|
|
30
|
+
for (let m = n;; m = Math.floor(m / 128)) {
|
|
31
|
+
groups.unshift(m % 128);
|
|
32
|
+
if (m < 128)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
bytes.push(...groups.map((g, i) => (i < groups.length - 1 ? g | 0x80 : g)));
|
|
36
|
+
}
|
|
37
|
+
return tlv(0x06, Buffer.from(bytes));
|
|
38
|
+
}
|
|
39
|
+
/** a positive INTEGER from its big-endian bytes */
|
|
40
|
+
const integer = (bytes) => tlv(0x02, bytes[0] & 0x80 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes);
|
|
41
|
+
/** UTCTime through 2049, GeneralizedTime from 2050 (RFC 5280 §4.1.2.5) */
|
|
42
|
+
function time(d) {
|
|
43
|
+
const iso = d.toISOString(); // 2026-10-26T12:34:56.789Z
|
|
44
|
+
const digits = iso.slice(0, 19).replace(/[-T:]/g, "");
|
|
45
|
+
return d.getUTCFullYear() < 2050 ? tlv(0x17, Buffer.from(`${digits.slice(2)}Z`)) : tlv(0x18, Buffer.from(`${digits}Z`));
|
|
46
|
+
}
|
|
47
|
+
const name = (commonName) => seq(tlv(0x31, seq(oid("2.5.4.3"), tlv(0x0c, Buffer.from(commonName, "utf8")))));
|
|
48
|
+
const pem = (label, der) => `-----BEGIN ${label}-----\n${der.toString("base64").replace(/.{1,64}/g, "$&\n")}-----END ${label}-----\n`;
|
|
49
|
+
/** A new RSA key and a certificate for it, signed by itself, valid from an hour ago (a client's clock behind) for
|
|
50
|
+
* `years`; both PEM. */
|
|
51
|
+
export function selfSignedCertificate(commonName, o = {}) {
|
|
52
|
+
const now = o.now ?? new Date();
|
|
53
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
54
|
+
const serial = crypto.randomBytes(16);
|
|
55
|
+
serial[0] = (serial[0] & 0x7f) | 0x40; // positive, and never a leading zero byte
|
|
56
|
+
const notAfter = new Date(now);
|
|
57
|
+
notAfter.setUTCFullYear(notAfter.getUTCFullYear() + (o.years ?? 10));
|
|
58
|
+
const sha256WithRsa = seq(oid("1.2.840.113549.1.1.11"), tlv(0x05, Buffer.alloc(0)));
|
|
59
|
+
const tbs = seq(tlv(0xa0, integer(Buffer.from([2]))), // [0] EXPLICIT version: v3
|
|
60
|
+
integer(serial), sha256WithRsa, name(commonName), seq(time(new Date(now.getTime() - 3_600_000)), time(notAfter)), name(commonName), publicKey.export({ type: "spki", format: "der" }));
|
|
61
|
+
const signature = crypto.sign("sha256", tbs, privateKey); // PKCS#1 v1.5, the algorithm named above
|
|
62
|
+
const der = seq(tbs, sha256WithRsa, tlv(0x03, Buffer.concat([Buffer.from([0]), signature])));
|
|
63
|
+
return { cert: pem("CERTIFICATE", der), key: privateKey.export({ type: "pkcs8", format: "pem" }).toString() };
|
|
64
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason serve` (deploying quickly to a server — one's own or a managed platform — must be as easy as possible,
|
|
3
|
+
* wherever the Postgres database is): the DuckDB run of `mason start --attach` (src/cli/dev.ts), for a server or a
|
|
4
|
+
* container. Whatever a platform gives a process, and nothing else:
|
|
5
|
+
*
|
|
6
|
+
* - ITS SETTINGS FROM THE ENVIRONMENT (`serveSettings`): the database's URL, the API key the tokens are minted with,
|
|
7
|
+
* the project folder (the team's `mason/`, committed: its sources file names the database and the tables copied,
|
|
8
|
+
* its URL `${DATABASE_URL}` from this environment) and the data folder.
|
|
9
|
+
* Nothing is asked; every setting missing or wrong is said at once, each with what to set (one report).
|
|
10
|
+
* - ITS STATE ON ONE VOLUME: the data folder's `.mason/` — the config this command writes from the environment at
|
|
11
|
+
* every start (`serveConfig`), the DuckDB file, the logs. A restart resumes the copy; a new volume copies again.
|
|
12
|
+
* - IN THE FOREGROUND, ITS LOG ON STDOUT: a container's log is its stdout. A run that cannot start exits 1, so the
|
|
13
|
+
* platform restarts it; there is no supervisor of its own (src/cli/supervise.ts is `mason start`'s).
|
|
14
|
+
* - A HEALTH ENDPOINT (`healthBody`): `GET /healthz` on MASON_HEALTH_PORT, 503 while it starts and stops, 200 once it
|
|
15
|
+
* answers. It needs no credential, so it names nothing of the database: counts only.
|
|
16
|
+
* - A CLEAN STOP ON SIGTERM: the run's own stop (a copy in flight interrupted and rolled back, DuckDB closed).
|
|
17
|
+
*
|
|
18
|
+
* The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as http from "node:http";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { failed, parseCommand } from "./command.js";
|
|
24
|
+
import { dev } from "./dev.js";
|
|
25
|
+
import { parseSource } from "./preflight.js";
|
|
26
|
+
import { NAME_RE, newConfig, readProject, STATE_DIR, writeConfig } from "./project.js";
|
|
27
|
+
import { newSourceText, OLD_TABLES_DIR, parseSourceText, readSource, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
|
|
28
|
+
import { appEnvValue } from "../lib/dotenv.js";
|
|
29
|
+
import { version } from "./version.js";
|
|
30
|
+
export const SERVE = {
|
|
31
|
+
name: "serve",
|
|
32
|
+
summary: "run Mason on a server or in a container, in the foreground, configured by the environment",
|
|
33
|
+
usage: `usage: mason serve [--json]
|
|
34
|
+
|
|
35
|
+
Runs Mason as mason start --attach does, for a server or a container: nothing asked, its settings from the
|
|
36
|
+
environment, its state in one folder (a volume), its log on stdout, GET /healthz for the platform's health check,
|
|
37
|
+
and a clean stop on SIGTERM. The tables it copies are the ones the project folder's sources file names
|
|
38
|
+
(sources/<name>.yml): make it with mason init and mason add on a laptop and commit it, or with mason add beside the
|
|
39
|
+
running server. A project folder with no sources file gets one, url: \${DATABASE_URL}, with no table yet.
|
|
40
|
+
|
|
41
|
+
DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
|
|
42
|
+
docker compose exec mason mason add public.orders beside it, in its container
|
|
43
|
+
|
|
44
|
+
Settings (the environment):
|
|
45
|
+
DATABASE_URL the database to copy from, as the sources file's url: \${DATABASE_URL} names it (the variable
|
|
46
|
+
the file names is required); read-only: SELECTs, or a replication slot
|
|
47
|
+
MASON_API_KEY what the users' passwords are made with (mason user add), at least 32 characters (required)
|
|
48
|
+
MASON_PROJECT the project folder: sources/, mason.yml (default ./mason)
|
|
49
|
+
MASON_DATA the folder whose .mason/ holds the copy and the state: the volume (default .)
|
|
50
|
+
MASON_NAME the project's name, the database name clients connect to (default mason)
|
|
51
|
+
MASON_CHANGES poll (SELECTs, the default) or replication (the change log; PG_PUBLICATION names one)
|
|
52
|
+
MASON_HEALTH_PORT /healthz's port (default 4140), on MASON_HEALTH_HOST (default 0.0.0.0)
|
|
53
|
+
MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
|
|
54
|
+
MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
|
|
55
|
+
for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
|
|
56
|
+
MASON_DUCKDB_MEMORY, MASON_POLL_MS, MASON_STATEMENT_TIMEOUT, MASON_STORE_JSON_PATHS as for mason start
|
|
57
|
+
MASON_COPY_PARALLEL a copy's chunks read at once, each a backend on the database (1 to 8, default 3)
|
|
58
|
+
MASON_COPY_CHUNK_SECONDS how long each chunk takes to read, at the rate measured (1 to 300, default 12)
|
|
59
|
+
|
|
60
|
+
--json a refusal to start as data: { error, next }; once it serves, its output is its log
|
|
61
|
+
|
|
62
|
+
Exit status: 0 stopped by SIGTERM or SIGINT; 1 it could not start (said); 2 a wrong command line.
|
|
63
|
+
`,
|
|
64
|
+
options: { json: { type: "boolean", default: false } },
|
|
65
|
+
json: ["next"],
|
|
66
|
+
};
|
|
67
|
+
const MIN_API_KEY = 32;
|
|
68
|
+
/** THE SETTINGS, OR EVERY ONE MISSING OR WRONG AT ONCE: a platform's deploy is slow, and one variable a deploy wastes it. */
|
|
69
|
+
export function serveSettings(env, cwd) {
|
|
70
|
+
const wrong = [];
|
|
71
|
+
const project = path.resolve(cwd, env.MASON_PROJECT || "mason");
|
|
72
|
+
const data = path.resolve(cwd, env.MASON_DATA || ".");
|
|
73
|
+
// the database's URL, with the rest: the variable the sources file names (DATABASE_URL for the one a serve writes)
|
|
74
|
+
wrong.push(...urlSettingProblems(project, data, env));
|
|
75
|
+
const apiKey = env.MASON_API_KEY ?? "";
|
|
76
|
+
if (!apiKey)
|
|
77
|
+
wrong.push("MASON_API_KEY is not set: what the users' passwords are made with (mason user add; openssl rand -hex 24 makes one)");
|
|
78
|
+
else if (apiKey.length < MIN_API_KEY)
|
|
79
|
+
wrong.push(`MASON_API_KEY is ${apiKey.length} characters: at least ${MIN_API_KEY}, as openssl rand -hex 24 makes`);
|
|
80
|
+
const name = env.MASON_NAME || "mason";
|
|
81
|
+
if (!NAME_RE.test(name))
|
|
82
|
+
wrong.push(`MASON_NAME=${JSON.stringify(name)} is not a project name: lower case letters, digits, - and _, at most 48`);
|
|
83
|
+
const changes = env.MASON_CHANGES || null;
|
|
84
|
+
if (changes !== null && changes !== "poll" && changes !== "replication")
|
|
85
|
+
wrong.push(`MASON_CHANGES=${JSON.stringify(changes)}: poll or replication`);
|
|
86
|
+
const selfSigned = env.MASON_PG_SELF_SIGNED_TLS || "0";
|
|
87
|
+
if (selfSigned !== "0" && selfSigned !== "1")
|
|
88
|
+
wrong.push(`MASON_PG_SELF_SIGNED_TLS=${JSON.stringify(selfSigned)}: 1, or 0 (the default)`);
|
|
89
|
+
const portText = env.MASON_HEALTH_PORT || "4140";
|
|
90
|
+
const port = Number(portText);
|
|
91
|
+
if (!/^\d+$/.test(portText) || port < 1 || port > 65_535)
|
|
92
|
+
wrong.push(`MASON_HEALTH_PORT=${JSON.stringify(portText)}: a port number, 1-65535`);
|
|
93
|
+
if (wrong.length) {
|
|
94
|
+
return { message: `${wrong.length === 1 ? "a setting is" : `${wrong.length} settings are`} missing or wrong: ${wrong.join("; ")}`, code: "bad_config",
|
|
95
|
+
why: "mason serve reads its settings from the environment, and asks nothing",
|
|
96
|
+
fix: "set them in the environment (a compose file's .env, the platform's variables), then start it again",
|
|
97
|
+
next: [["mason serve --help", "every setting, with its default"]] };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
name, apiKey, changes: changes, selfSignedTls: selfSigned === "1",
|
|
101
|
+
project, data,
|
|
102
|
+
health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* THE DATABASE'S URL AS A SETTING (the review of the sources file: an unset URL was found only after the other
|
|
107
|
+
* settings passed, two deploys for two variables): the variable the project's sources file names — DATABASE_URL when it
|
|
108
|
+
* has none yet (serveSource writes one naming it) — set and a postgres:// URL, read from the environment first (a .env in
|
|
109
|
+
* the data folder never overrides the platform's). The file's own mistakes are serveSource's, on its own report.
|
|
110
|
+
*/
|
|
111
|
+
function urlSettingProblems(project, data, env) {
|
|
112
|
+
const names = sourceFileNames(project);
|
|
113
|
+
let variable = "DATABASE_URL";
|
|
114
|
+
if (names.length === 0) {
|
|
115
|
+
if (fs.existsSync(path.join(project, OLD_TABLES_DIR)))
|
|
116
|
+
return []; // refused by readSource, with the way out
|
|
117
|
+
if (!env.DATABASE_URL)
|
|
118
|
+
return [`DATABASE_URL is not set, and the project folder (${project}) has no sources file to name the database another way`];
|
|
119
|
+
}
|
|
120
|
+
else if (names.length === 1) {
|
|
121
|
+
const file = path.join(sourcesDir(project), names[0]);
|
|
122
|
+
let text;
|
|
123
|
+
// a file that cannot be read is serveSource's to say (readSource), with the file and the error, on its own report
|
|
124
|
+
try {
|
|
125
|
+
text = fs.readFileSync(file, "utf8");
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const parsed = parseSourceText(text, names[0].replace(/\.ya?ml$/, ""), file);
|
|
131
|
+
if ("problems" in parsed || "literal" in parsed.url)
|
|
132
|
+
return [];
|
|
133
|
+
variable = parsed.url.env;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
return [];
|
|
137
|
+
const found = appEnvValue(data, variable, env, { shellFirst: true });
|
|
138
|
+
if (found === null)
|
|
139
|
+
return [`${variable} is not set: the database to copy from, as the sources file's url: \${${variable}} names it`];
|
|
140
|
+
const p = parseSource(found.value);
|
|
141
|
+
return "problem" in p ? [`${variable}: ${p.problem}`] : [];
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* THE CONFIG A SERVE RUNS ON, written from its settings at every start, over what the runs before recorded (the server
|
|
145
|
+
* the copy was taken from, the publication, the certificate): the environment is the operator's, the rest the run's. A
|
|
146
|
+
* project `mason init` made is a developer's and is never taken over; a name that moved would be another slot and
|
|
147
|
+
* another database name over the same copy, so it is refused.
|
|
148
|
+
*/
|
|
149
|
+
export function serveConfig(s, existing) {
|
|
150
|
+
const dir = path.join(s.data, STATE_DIR);
|
|
151
|
+
if (existing === null) {
|
|
152
|
+
const config = { ...newConfig(s.name, s.project, true), apiKey: s.apiKey, served: true };
|
|
153
|
+
return { config, said: [`a new copy in ${dir}`] };
|
|
154
|
+
}
|
|
155
|
+
if ("broken" in existing) {
|
|
156
|
+
return { message: `the state in ${dir} cannot be read: ${existing.file}: ${existing.broken}`, code: "bad_config",
|
|
157
|
+
fix: `MASON_DATA names a folder of its own for mason serve; move ${dir} aside to copy again` };
|
|
158
|
+
}
|
|
159
|
+
const was = existing.config;
|
|
160
|
+
if (was.served !== true) {
|
|
161
|
+
return { message: `${dir} is a project mason init made, and mason serve keeps its own state`, code: "refused",
|
|
162
|
+
why: "its config is a developer's (its database, its key), which a serve would overwrite from the environment",
|
|
163
|
+
fix: "point MASON_DATA at an empty folder (the volume), or run mason start here" };
|
|
164
|
+
}
|
|
165
|
+
if (was.name !== s.name) {
|
|
166
|
+
return { message: `the copy in ${dir} is the project ${was.name}, and MASON_NAME says ${s.name}`, code: "refused",
|
|
167
|
+
why: "the name is the database name clients connect to and names the replication slot: another name over the same copy is another project",
|
|
168
|
+
fix: `set MASON_NAME=${was.name} again, or give the new name a new MASON_DATA (an empty volume), which copies again` };
|
|
169
|
+
}
|
|
170
|
+
const said = [`the copy in ${dir}, resumed`];
|
|
171
|
+
if (was.apiKey !== s.apiKey)
|
|
172
|
+
said.push("MASON_API_KEY changed: every mason user is removed (mason user add each again)");
|
|
173
|
+
// the source is the sources file's; a database restored elsewhere, or another one, is the run's to see (sourceMove)
|
|
174
|
+
const { source: _old, ...kept } = was;
|
|
175
|
+
return { config: { ...kept, apiKey: s.apiKey, folder: s.project, remote: true, served: true }, said };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* THE PROJECT FOLDER'S SOURCES FILE, for a serve: one there is checked as the run reads it (src/cli/sources.ts
|
|
179
|
+
* readSource: its URL resolved from this environment, every mistake on one report); a folder with none gets the file
|
|
180
|
+
* `mason init` writes, `url: ${DATABASE_URL}` and no table, when DATABASE_URL is set — else what to set is said.
|
|
181
|
+
*/
|
|
182
|
+
export function serveSource(s, env) {
|
|
183
|
+
let wrote = null;
|
|
184
|
+
if (sourceFileNames(s.project).length === 0 && !fs.existsSync(path.join(s.project, OLD_TABLES_DIR))) {
|
|
185
|
+
const url = env.DATABASE_URL ?? "";
|
|
186
|
+
if (!url) {
|
|
187
|
+
return { message: "DATABASE_URL is not set, and the project folder has no sources file to name the database another way", code: "bad_config",
|
|
188
|
+
fix: "set DATABASE_URL=postgres://user:password@host:5432/app (the sources file mason init writes names it), or mount the project folder with its sources/ (MASON_PROJECT)",
|
|
189
|
+
next: [["mason serve --help", "every setting, with its default"]] };
|
|
190
|
+
}
|
|
191
|
+
const p = parseSource(url);
|
|
192
|
+
const file = path.join(sourcesDir(s.project), `${sourceNameFor("source" in p ? p.source.database : "")}.yml`);
|
|
193
|
+
try {
|
|
194
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
195
|
+
fs.writeFileSync(file, newSourceText());
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
return { message: `the project folder has no sources file, and one cannot be written: ${e.message}`, code: "refused",
|
|
199
|
+
fix: `MASON_PROJECT names the project folder (sources/, mason.yml): mount it there with its sources file, or make ${file} in it` };
|
|
200
|
+
}
|
|
201
|
+
wrote = file;
|
|
202
|
+
}
|
|
203
|
+
const r = readSource(s.project, s.data, env, { shellFirst: true });
|
|
204
|
+
return "message" in r ? r : { wrote };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* /healthz's answer: 200 once the run answers, whatever its copies are doing (a table still copying, or one that failed,
|
|
208
|
+
* leaves the rest served: the counts say it); 503 while it starts and stops. Counts only, never a table's name: the
|
|
209
|
+
* endpoint takes no credential.
|
|
210
|
+
*/
|
|
211
|
+
export function healthBody(state, startedAt, now = Date.now()) {
|
|
212
|
+
const base = { status: state.phase, version: version(), uptimeSeconds: Math.floor((now - startedAt) / 1000) };
|
|
213
|
+
if (state.phase !== "ready")
|
|
214
|
+
return { status: 503, body: base };
|
|
215
|
+
const counts = { total: 0, copied: 0, copying: 0, waiting: 0, failed: 0 };
|
|
216
|
+
for (const t of state.tables()) {
|
|
217
|
+
counts.total++;
|
|
218
|
+
counts[t.phase]++;
|
|
219
|
+
}
|
|
220
|
+
return { status: 200, body: { ...base, tables: counts } };
|
|
221
|
+
}
|
|
222
|
+
/** The health endpoint: GET /healthz, nothing else. */
|
|
223
|
+
export function healthServer(state, startedAt) {
|
|
224
|
+
return http.createServer((req, res) => {
|
|
225
|
+
const url = new URL(req.url ?? "/", "http://health");
|
|
226
|
+
if (url.pathname !== "/healthz") {
|
|
227
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("GET /healthz\n");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
231
|
+
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const h = healthBody(state(), startedAt);
|
|
235
|
+
res.writeHead(h.status, { "content-type": "application/json", "cache-control": "no-store" }).end(`${JSON.stringify(h.body)}\n`);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const say = (m) => { process.stdout.write(`${m}\n`); };
|
|
239
|
+
export async function serve(argv) {
|
|
240
|
+
const f = parseCommand(SERVE, argv);
|
|
241
|
+
if (typeof f === "number")
|
|
242
|
+
return f;
|
|
243
|
+
const json = f.values.json === true;
|
|
244
|
+
if (f.positionals.length)
|
|
245
|
+
return failed("serve", { message: "it takes no arguments: its settings are the environment's", code: "usage", usage: SERVE.usage }, { json });
|
|
246
|
+
const s = serveSettings(process.env, process.cwd());
|
|
247
|
+
if ("message" in s)
|
|
248
|
+
return failed("serve", s, { json });
|
|
249
|
+
// the project folder: its sources file is the list of what is copied, one with no table written when there is none
|
|
250
|
+
const made = serveSource(s, process.env);
|
|
251
|
+
if ("message" in made)
|
|
252
|
+
return failed("serve", made, { json });
|
|
253
|
+
const c = serveConfig(s, readProject(s.data));
|
|
254
|
+
if ("message" in c)
|
|
255
|
+
return failed("serve", c, { json });
|
|
256
|
+
try {
|
|
257
|
+
writeConfig(path.join(s.data, STATE_DIR), c.config);
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
return failed("serve", { message: `the state cannot be written to ${path.join(s.data, STATE_DIR)}: ${e.message}`, code: "refused",
|
|
261
|
+
fix: "MASON_DATA names a folder this user can write: the volume's mount point" }, { json });
|
|
262
|
+
}
|
|
263
|
+
const startedAt = Date.now();
|
|
264
|
+
let state = { phase: "starting" };
|
|
265
|
+
const health = healthServer(() => state, startedAt);
|
|
266
|
+
const listening = await new Promise((resolve) => {
|
|
267
|
+
health.once("error", resolve);
|
|
268
|
+
health.listen(s.health.port, s.health.host, () => resolve(null));
|
|
269
|
+
});
|
|
270
|
+
if (listening !== null) {
|
|
271
|
+
return failed("serve", { message: `the health endpoint cannot listen on ${s.health.host}:${s.health.port}: ${listening.message}`, code: "refused",
|
|
272
|
+
fix: "MASON_HEALTH_PORT names a port free on this machine" }, { json });
|
|
273
|
+
}
|
|
274
|
+
say(`mason serve: ${s.name}, the project in ${s.project}, the state in ${path.join(s.data, STATE_DIR)}`);
|
|
275
|
+
if (made.wrote)
|
|
276
|
+
say(` wrote ${made.wrote}: the database (url: \${DATABASE_URL}), no table yet (mason add beside it adds one)`);
|
|
277
|
+
for (const l of c.said)
|
|
278
|
+
say(` ${l}`);
|
|
279
|
+
say(` health http://${s.health.host}:${s.health.port}/healthz`);
|
|
280
|
+
// the run reads the project of the folder it runs in (src/cli/project.ts): the data folder's
|
|
281
|
+
process.chdir(s.data);
|
|
282
|
+
let code;
|
|
283
|
+
try {
|
|
284
|
+
code = await dev({ port: 4039, version: version(), changes: s.changes, publication: null, selfSignedTls: s.selfSignedTls,
|
|
285
|
+
serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables }; }, stopping: () => { state = { phase: "stopping" }; } } });
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
// a platform's health check may hold a keep-alive connection open, which close() alone would wait for
|
|
289
|
+
health.closeAllConnections();
|
|
290
|
+
await new Promise((resolve) => health.close(() => resolve()));
|
|
291
|
+
}
|
|
292
|
+
if (code !== 0)
|
|
293
|
+
return failed("serve", { message: "Mason did not start: what stopped it is said above", code: "failed",
|
|
294
|
+
fix: "fix what it says, then start it again (a platform restarts it by itself)" }, { json: false });
|
|
295
|
+
return 0;
|
|
296
|
+
}
|