@mason-data/cli 0.7.0 → 0.8.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/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
package/dist/src/cli/runs.js
CHANGED
|
@@ -61,7 +61,7 @@ export const END_GRACE_MS = 15_000;
|
|
|
61
61
|
export function notAnswering(p, run) {
|
|
62
62
|
return { problem: `Mason for ${p.config.name} is running (pid ${run.pid}) but does not answer its commands (${run.control.url}, 3 s)`, code: "not_answering",
|
|
63
63
|
why: "a statement may be holding its main thread",
|
|
64
|
-
next: [["mason stop", `end it (SIGTERM, then SIGKILL after ${END_GRACE_MS / 1000} s); its
|
|
64
|
+
next: [["mason stop", `end it (SIGTERM, then SIGKILL after ${END_GRACE_MS / 1000} s); its synced data stays and the next mason start resumes`], ["mason logs", "what it was doing"]] };
|
|
65
65
|
}
|
|
66
66
|
/** Whether `pid` is a Mason run, by its command line (`mason start … --attach`, src/cli/background.ts). ps exits 1 for a pid that is gone. */
|
|
67
67
|
export function masonProcess(pid) {
|
package/dist/src/cli/security.js
CHANGED
|
@@ -22,19 +22,21 @@
|
|
|
22
22
|
* no Origin: a browser sends an Authorization header across sites only after a CORS preflight, which these servers
|
|
23
23
|
* never approve, so a page on another site cannot send one at all.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* A browser keeps cookies per host, never per port, so the cookie is NAMED for the run's port (`cookieName`,
|
|
26
|
+
* `mason_dev_<port>`): two runs on one machine each keep their own, and opening one's page never logs the other's out
|
|
27
|
+
* (one name for every run did). A page of another run, or one the token does not match, is refused. Pure over the request's headers, so the refusals are unit-tested
|
|
27
28
|
* (tests/cli-security.test.ts).
|
|
28
29
|
*/
|
|
29
30
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
30
|
-
|
|
31
|
+
/** The cookie of the run whose server is on `port`: one per run, as a browser shares a host's cookies across ports. */
|
|
32
|
+
export const cookieName = (port) => `mason_dev_${port}`;
|
|
31
33
|
export function newToken() { return randomBytes(32).toString("hex"); }
|
|
32
34
|
/** Constant-time string equality (a token compared byte by byte leaks its prefix through timing). */
|
|
33
35
|
export function sameSecret(a, b) {
|
|
34
36
|
const x = Buffer.from(a), y = Buffer.from(b);
|
|
35
37
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
36
38
|
}
|
|
37
|
-
export function cookieOf(header, name
|
|
39
|
+
export function cookieOf(header, name) {
|
|
38
40
|
for (const part of (header ?? "").split(";")) {
|
|
39
41
|
const eq = part.indexOf("=");
|
|
40
42
|
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
@@ -42,8 +44,8 @@ export function cookieOf(header, name = COOKIE) {
|
|
|
42
44
|
}
|
|
43
45
|
return null;
|
|
44
46
|
}
|
|
45
|
-
export function setCookieHeader(token) {
|
|
46
|
-
return `${
|
|
47
|
+
export function setCookieHeader(token, port) {
|
|
48
|
+
return `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/`;
|
|
47
49
|
}
|
|
48
50
|
/** Whether `req` may be served by the server on `port` whose run token is `token`. */
|
|
49
51
|
export function guard(req, port, token) {
|
|
@@ -62,9 +64,9 @@ export function guard(req, port, token) {
|
|
|
62
64
|
if (!sameSecret(offered, token))
|
|
63
65
|
return { ok: false, status: 403, reason: "the token in the URL is not this run's" };
|
|
64
66
|
url.searchParams.delete("token");
|
|
65
|
-
return { ok: false, status: 303, location: url.pathname + (url.search || "") + url.hash, setCookie: setCookieHeader(token) };
|
|
67
|
+
return { ok: false, status: 303, location: url.pathname + (url.search || "") + url.hash, setCookie: setCookieHeader(token, port) };
|
|
66
68
|
}
|
|
67
|
-
const cookie = cookieOf(req.cookie);
|
|
69
|
+
const cookie = cookieOf(req.cookie, cookieName(port));
|
|
68
70
|
if (cookie === null || !sameSecret(cookie, token)) {
|
|
69
71
|
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
72
|
}
|
package/dist/src/cli/serve.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* platform restarts it; there is no supervisor of its own (src/cli/supervise.ts is `mason start`'s).
|
|
14
14
|
* - A HEALTH ENDPOINT (`healthBody`): `GET /healthz` on MASON_HEALTH_PORT, 503 while it starts and stops, 200 once it
|
|
15
15
|
* answers. It needs no credential, so it names nothing of the database: counts only.
|
|
16
|
+
* - METRICS BESIDE IT (src/cli/metrics.ts): `GET /metrics` on the same port, Prometheus's text format, numbers only
|
|
17
|
+
* (counts, lag, ages, sizes) for the same reason: never a table's, a user's or a host's name, never a statement.
|
|
16
18
|
* - A CLEAN STOP ON SIGTERM: the run's own stop (a copy in flight interrupted and rolled back, DuckDB closed).
|
|
17
19
|
*
|
|
18
20
|
* The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
|
|
@@ -24,9 +26,17 @@ import { failed, parseCommand } from "./command.js";
|
|
|
24
26
|
import { dev } from "./dev.js";
|
|
25
27
|
import { parseSource } from "./preflight.js";
|
|
26
28
|
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";
|
|
29
|
+
import { newSourceText, OLD_TABLES_DIR, parseSourceText, readSource, sourceFileNames, sourceNameFor, sourcesDir, sourcesTop, writtenDatabase } from "./sources.js";
|
|
30
|
+
import { connectDatabase, DATABASE_SETTING, databaseNameProblem } from "../lib/database-name.js";
|
|
28
31
|
import { appEnvValue } from "../lib/dotenv.js";
|
|
29
|
-
import {
|
|
32
|
+
import { imagePinProblem, PIN_FILE } from "../lib/project-version.js";
|
|
33
|
+
import { commandPinProblem } from "./pin.js";
|
|
34
|
+
import { recordRefusal } from "./replication-mode.js";
|
|
35
|
+
import { changesRetiredText, pollMsRetiredText } from "../lib/table-sync.js";
|
|
36
|
+
import { historyDaysSetting, historyDaysWrongText } from "../duck/history.js";
|
|
37
|
+
import { runningVersion, version } from "./version.js";
|
|
38
|
+
import { phaseCounts } from "./readiness.js";
|
|
39
|
+
import { METRICS_CONTENT_TYPE, metricsText } from "./metrics.js";
|
|
30
40
|
export const SERVE = {
|
|
31
41
|
name: "serve",
|
|
32
42
|
summary: "run Mason on a server or in a container, in the foreground, configured by the environment",
|
|
@@ -34,32 +44,38 @@ export const SERVE = {
|
|
|
34
44
|
|
|
35
45
|
Runs Mason as mason start --attach does, for a server or a container: nothing asked, its settings from the
|
|
36
46
|
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
|
|
38
|
-
(sources/<name>.yml): make it with mason init and mason
|
|
39
|
-
|
|
47
|
+
GET /metrics for Prometheus (numbers only: no name, no statement), and a clean stop on SIGTERM. The tables it
|
|
48
|
+
syncs are the ones the project folder's sources file names (sources/<name>.yml): make it with mason init and mason
|
|
49
|
+
add on a laptop and commit it, or with mason add beside the running server. A project folder with no sources file
|
|
50
|
+
gets one, url: \${DATABASE_URL}, with no table yet.
|
|
40
51
|
|
|
41
52
|
DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
|
|
42
53
|
docker compose exec mason mason add public.orders beside it, in its container
|
|
43
54
|
|
|
44
55
|
Settings (the environment):
|
|
45
|
-
DATABASE_URL the database to
|
|
56
|
+
DATABASE_URL the database to sync from, as the sources file's url: \${DATABASE_URL} names it (the variable
|
|
46
57
|
the file names is required); read-only: SELECTs, or a replication slot
|
|
58
|
+
PG_PUBLICATION the publication the tables under sync: replication are read through, the database owner's,
|
|
59
|
+
never altered: the sources file's publication: first (publication: \${PG_PUBLICATION} reads
|
|
60
|
+
this one), else this; default none: Mason makes its own
|
|
47
61
|
MASON_API_KEY what the users' passwords are made with (mason user add), at least 32 characters (required)
|
|
48
62
|
MASON_PROJECT the project folder: sources/, mason.yml (default ./mason)
|
|
49
|
-
MASON_DATA the folder whose .mason/ holds the
|
|
50
|
-
MASON_NAME the project's name, the
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
MASON_DATA the folder whose .mason/ holds the synced data and the state: the volume (default .)
|
|
64
|
+
MASON_NAME the project's name, which the replication slot is named for (default mason); renaming it
|
|
65
|
+
over the same synced data is refused
|
|
66
|
+
MASON_PG_DATABASE the database name a tool connects with, for a sources file with no database: (default mason;
|
|
67
|
+
the two must agree when both are set)
|
|
68
|
+
MASON_HEALTH_PORT /healthz's and /metrics' port (default 4140), on MASON_HEALTH_HOST (default 0.0.0.0)
|
|
53
69
|
MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
|
|
54
70
|
MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
|
|
55
71
|
for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
|
|
56
|
-
MASON_DUCKDB_MEMORY the
|
|
72
|
+
MASON_DUCKDB_MEMORY the engine's memory, 4GB or 512MiB (default half the container's memory limit, else half the
|
|
57
73
|
machine's RAM; mason doctor says it); a statement past it fails alone with 53200
|
|
58
|
-
MASON_POLL_MS how often the poll reads the tables' changes, in ms (default 5000)
|
|
59
74
|
MASON_STATEMENT_TIMEOUT each statement's limit, as Postgres's statement_timeout (default 5min; 0 for none)
|
|
60
|
-
|
|
75
|
+
MASON_HISTORY_DAYS how many days Mason keeps its history: the request log, the sync's events, the usage
|
|
76
|
+
samples and the synced tables' sizes (default 7)
|
|
61
77
|
MASON_STORE_JSON_PATHS on (the default) stores the JSON paths statements keep reading as columns; off stores none
|
|
62
|
-
MASON_COPY_PARALLEL a
|
|
78
|
+
MASON_COPY_PARALLEL a first sync's chunks read at once, each a backend on the database (1 to 8, default 3)
|
|
63
79
|
MASON_COPY_CHUNK_SECONDS how long each chunk takes to read, at the rate measured (1 to 300, default 12)
|
|
64
80
|
|
|
65
81
|
--json a refusal to start as data: { error, next }; once it serves, its output is its log
|
|
@@ -85,9 +101,21 @@ export function serveSettings(env, cwd) {
|
|
|
85
101
|
const name = env.MASON_NAME || "mason";
|
|
86
102
|
if (!NAME_RE.test(name))
|
|
87
103
|
wrong.push(`MASON_NAME=${JSON.stringify(name)} is not a project name: lower case letters, digits, - and _, at most 48`);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
// the database name a tool connects with: the sources file's, else this, and never two names at once
|
|
105
|
+
wrong.push(...databaseSettingProblems(project, env));
|
|
106
|
+
// how each table is kept current, and how often a table is polled, are the sources file's sync: now, per table
|
|
107
|
+
// (src/lib/table-sync.ts): the old settings refused HERE with the others, before anything is written or connected
|
|
108
|
+
const top = () => sourcesTop(path.dirname(project), project);
|
|
109
|
+
const changes = changesRetiredText(env, top);
|
|
110
|
+
if (changes !== null)
|
|
111
|
+
wrong.push(changes);
|
|
112
|
+
const pollMs = pollMsRetiredText(env, top);
|
|
113
|
+
if (pollMs !== null)
|
|
114
|
+
wrong.push(pollMs);
|
|
115
|
+
// the history's days: MASON_HISTORY_DAYS, a number of days; the old name refused (src/duck/history.ts)
|
|
116
|
+
const days = historyDaysSetting(env);
|
|
117
|
+
if (typeof days !== "number")
|
|
118
|
+
wrong.push(historyDaysWrongText(days));
|
|
91
119
|
const selfSigned = env.MASON_PG_SELF_SIGNED_TLS || "0";
|
|
92
120
|
if (selfSigned !== "0" && selfSigned !== "1")
|
|
93
121
|
wrong.push(`MASON_PG_SELF_SIGNED_TLS=${JSON.stringify(selfSigned)}: 1, or 0 (the default)`);
|
|
@@ -102,11 +130,27 @@ export function serveSettings(env, cwd) {
|
|
|
102
130
|
next: [["mason serve --help", "every setting, with its default"]] };
|
|
103
131
|
}
|
|
104
132
|
return {
|
|
105
|
-
name, apiKey,
|
|
133
|
+
name, apiKey, selfSignedTls: selfSigned === "1",
|
|
106
134
|
project, data,
|
|
107
135
|
health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
|
|
108
136
|
};
|
|
109
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* THE DATABASE NAME AS A SETTING (src/lib/database-name.ts): a name, and one the sources file does not contradict, since
|
|
140
|
+
* two names for one thing would have to be chosen between. A file that cannot be read is serveSource's to say.
|
|
141
|
+
*/
|
|
142
|
+
function databaseSettingProblems(project, env) {
|
|
143
|
+
const set = env[DATABASE_SETTING];
|
|
144
|
+
if (!set)
|
|
145
|
+
return [];
|
|
146
|
+
const bad = databaseNameProblem(set);
|
|
147
|
+
if (bad !== null)
|
|
148
|
+
return [`${DATABASE_SETTING}: ${bad}`];
|
|
149
|
+
const written = writtenDatabase(project, path.dirname(project));
|
|
150
|
+
if ("message" in written || written.database === null || written.database === set)
|
|
151
|
+
return [];
|
|
152
|
+
return [`${DATABASE_SETTING}=${JSON.stringify(set)}, and the sources file says database: ${written.database}: keep one`];
|
|
153
|
+
}
|
|
110
154
|
/**
|
|
111
155
|
* THE DATABASE'S URL AS A SETTING (the review of the sources file: an unset URL was found only after the other
|
|
112
156
|
* settings passed, two deploys for two variables): the variable the project's sources file names — DATABASE_URL when it
|
|
@@ -141,25 +185,25 @@ function urlSettingProblems(project, data, env) {
|
|
|
141
185
|
return [];
|
|
142
186
|
const found = appEnvValue(data, variable, env, { shellFirst: true });
|
|
143
187
|
if (found === null)
|
|
144
|
-
return [`${variable} is not set: the database to
|
|
188
|
+
return [`${variable} is not set: the database to sync from, as the sources file's url: \${${variable}} names it`];
|
|
145
189
|
const p = parseSource(found.value);
|
|
146
190
|
return "problem" in p ? [`${variable}: ${p.problem}`] : [];
|
|
147
191
|
}
|
|
148
192
|
/**
|
|
149
193
|
* THE CONFIG A SERVE RUNS ON, written from its settings at every start, over what the runs before recorded (the server
|
|
150
194
|
* the copy was taken from, the publication, the certificate): the environment is the operator's, the rest the run's. A
|
|
151
|
-
* project `mason init` made is a developer's and is never taken over; a name that moved would be another slot
|
|
152
|
-
*
|
|
195
|
+
* project `mason init` made is a developer's and is never taken over; a name that moved would be another slot over the
|
|
196
|
+
* same copy, so it is refused.
|
|
153
197
|
*/
|
|
154
198
|
export function serveConfig(s, existing) {
|
|
155
199
|
const dir = path.join(s.data, STATE_DIR);
|
|
156
200
|
if (existing === null) {
|
|
157
201
|
const config = { ...newConfig(s.name, s.project, true), apiKey: s.apiKey, served: true };
|
|
158
|
-
return { config, said: [`
|
|
202
|
+
return { config, said: [`new synced data in ${dir}`] };
|
|
159
203
|
}
|
|
160
204
|
if ("broken" in existing) {
|
|
161
205
|
return { message: `the state in ${dir} cannot be read: ${existing.file}: ${existing.broken}`, code: "bad_config",
|
|
162
|
-
fix: `MASON_DATA names a folder of its own for mason serve; move ${dir} aside to
|
|
206
|
+
fix: `MASON_DATA names a folder of its own for mason serve; move ${dir} aside to sync again` };
|
|
163
207
|
}
|
|
164
208
|
const was = existing.config;
|
|
165
209
|
if (was.served !== true) {
|
|
@@ -168,17 +212,27 @@ export function serveConfig(s, existing) {
|
|
|
168
212
|
fix: "point MASON_DATA at an empty folder (the volume), or run mason start here" };
|
|
169
213
|
}
|
|
170
214
|
if (was.name !== s.name) {
|
|
171
|
-
return { message: `the
|
|
172
|
-
why: "the name
|
|
173
|
-
fix: `set MASON_NAME=${was.name} again, or give the new name a new MASON_DATA (an empty volume), which
|
|
215
|
+
return { message: `the synced data in ${dir} is the project ${was.name}, and MASON_NAME says ${s.name}`, code: "refused",
|
|
216
|
+
why: "the name names the replication slot: another name over the same synced data is another project (the database name a tool connects with is not this: MASON_PG_DATABASE, or database: in the sources file)",
|
|
217
|
+
fix: `set MASON_NAME=${was.name} again, or give the new name a new MASON_DATA (an empty volume), which syncs again` };
|
|
174
218
|
}
|
|
175
|
-
const said = [`the
|
|
219
|
+
const said = [`the synced data in ${dir}, resumed`];
|
|
176
220
|
if (was.apiKey !== s.apiKey)
|
|
177
221
|
said.push("MASON_API_KEY changed: every mason user is removed (mason user add each again)");
|
|
178
222
|
// the source is the sources file's; a database restored elsewhere, or another one, is the run's to see (sourceMove)
|
|
179
223
|
const { source: _old, ...kept } = was;
|
|
180
224
|
return { config: { ...kept, apiKey: s.apiKey, folder: s.project, remote: true, served: true }, said };
|
|
181
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* THE PROJECT'S PIN, read from the project folder (MASON_PROJECT), in the words of what is running (src/lib/project-version.ts):
|
|
228
|
+
* the image names the image to run, the package the npm line to run, each to the reader who can act on it. Before
|
|
229
|
+
* anything is written or connected, so a project of another release line leaves a volume as it found it. A build that
|
|
230
|
+
* is no release checks nothing.
|
|
231
|
+
*/
|
|
232
|
+
export function servePin(s, running) {
|
|
233
|
+
const p = (running.from === "image" ? imagePinProblem : commandPinProblem)(s.project, running.text);
|
|
234
|
+
return p && { message: p.problem, code: "refused", fix: p.ways.join(", or ") || `correct ${PIN_FILE} in the project folder (MASON_PROJECT), then start it again` };
|
|
235
|
+
}
|
|
182
236
|
/**
|
|
183
237
|
* THE PROJECT FOLDER'S SOURCES FILE, for a serve: one there is checked as the run reads it (src/cli/sources.ts
|
|
184
238
|
* readSource: its URL resolved from this environment, every mistake on one report); a folder with none gets the file
|
|
@@ -206,36 +260,75 @@ export function serveSource(s, env) {
|
|
|
206
260
|
wrote = file;
|
|
207
261
|
}
|
|
208
262
|
const r = readSource(s.project, s.data, env, { shellFirst: true });
|
|
209
|
-
return "message" in r ? r : { wrote };
|
|
263
|
+
return "message" in r ? r : { wrote, database: connectDatabase(r, { served: true }, env) };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* WHAT THE VOLUME'S CONFIG RECORDS AND THE SOURCES FILE DOES NOT SAY (src/cli/replication-mode.ts recordRefusal), asked
|
|
267
|
+
* BEFORE `serveSource` and the config are written: a file a serve writes itself says `sync: off`, no one's choice, and a
|
|
268
|
+
* refused serve leaves the volume and the project folder as it found them. A file that cannot be read, and a project a
|
|
269
|
+
* serve does not take over, are said by the steps after this.
|
|
270
|
+
*/
|
|
271
|
+
export function serveRecordRefusal(s, existing, env) {
|
|
272
|
+
if (existing === null || "broken" in existing || existing.config.served !== true)
|
|
273
|
+
return null;
|
|
274
|
+
const written = sourceFileNames(s.project).length > 0;
|
|
275
|
+
if (!written && fs.existsSync(path.join(s.project, OLD_TABLES_DIR)))
|
|
276
|
+
return null;
|
|
277
|
+
const file = written ? readSource(s.project, s.data, env, { shellFirst: true }) : null;
|
|
278
|
+
if (file !== null && "message" in file)
|
|
279
|
+
return null;
|
|
280
|
+
return recordRefusal(existing.config, file, env, sourcesTop(path.dirname(s.project), s.project).shown, "mason serve");
|
|
210
281
|
}
|
|
282
|
+
/** seconds since the serve started: /healthz's `uptimeSeconds` and /metrics' `mason_uptime_seconds`, one number */
|
|
283
|
+
const uptimeSeconds = (startedAt, now) => Math.floor((now - startedAt) / 1000);
|
|
211
284
|
/**
|
|
212
285
|
* /healthz's answer: 200 once the run answers, whatever its copies are doing (a table still copying, or one that failed,
|
|
213
286
|
* leaves the rest served: the counts say it); 503 while it starts and stops. Counts only, never a table's name: the
|
|
214
287
|
* endpoint takes no credential.
|
|
215
288
|
*/
|
|
216
289
|
export function healthBody(state, startedAt, now = Date.now()) {
|
|
217
|
-
const base = { status: state.phase, version: version(), uptimeSeconds:
|
|
290
|
+
const base = { status: state.phase, version: version(), uptimeSeconds: uptimeSeconds(startedAt, now) };
|
|
218
291
|
if (state.phase !== "ready")
|
|
219
292
|
return { status: 503, body: base };
|
|
220
|
-
|
|
221
|
-
for (const t of state.tables()) {
|
|
222
|
-
counts.total++;
|
|
223
|
-
counts[t.phase]++;
|
|
224
|
-
}
|
|
225
|
-
return { status: 200, body: { ...base, tables: counts } };
|
|
293
|
+
return { status: 200, body: { ...base, tables: phaseCounts(state.tables()) } };
|
|
226
294
|
}
|
|
227
|
-
/**
|
|
228
|
-
|
|
295
|
+
/**
|
|
296
|
+
* /metrics' answer: ALWAYS 200 (a scrape that errors while Mason starts is an alert about nothing; whether it is up is
|
|
297
|
+
* /healthz's to say) — `mason_ready 0` and `mason_info` while it starts or stops, the run's numbers once it serves.
|
|
298
|
+
*/
|
|
299
|
+
export async function metricsBody(state, startedAt, now = Date.now()) {
|
|
300
|
+
if (state.phase !== "ready")
|
|
301
|
+
return metricsText({ version: version(), run: null });
|
|
302
|
+
const run = await state.metrics();
|
|
303
|
+
return metricsText({ version: version(), run: { ...run, uptimeSeconds: uptimeSeconds(startedAt, now) } });
|
|
304
|
+
}
|
|
305
|
+
/** The health server: GET /healthz and GET /metrics, nothing else. `log` says a scrape whose numbers could not be read. */
|
|
306
|
+
export function healthServer(state, startedAt, log = say) {
|
|
229
307
|
return http.createServer((req, res) => {
|
|
230
308
|
const url = new URL(req.url ?? "/", "http://health");
|
|
231
|
-
if (url.pathname !== "/healthz") {
|
|
232
|
-
res.writeHead(404, { "content-type": "text/plain" }).end("GET /healthz\n");
|
|
309
|
+
if (url.pathname !== "/healthz" && url.pathname !== "/metrics") {
|
|
310
|
+
res.writeHead(404, { "content-type": "text/plain" }).end("GET /healthz or GET /metrics\n");
|
|
233
311
|
return;
|
|
234
312
|
}
|
|
235
313
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
236
314
|
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
237
315
|
return;
|
|
238
316
|
}
|
|
317
|
+
if (url.pathname === "/metrics") {
|
|
318
|
+
const asked = state();
|
|
319
|
+
metricsBody(asked, startedAt).then((body) => { res.writeHead(200, { "content-type": METRICS_CONTENT_TYPE, "cache-control": "no-store" }).end(req.method === "HEAD" ? undefined : body); }, (e) => {
|
|
320
|
+
// a run stopping under the scrape (its copy closing): said as stopping, as it now is
|
|
321
|
+
if (state().phase !== "ready") {
|
|
322
|
+
res.writeHead(200, { "content-type": METRICS_CONTENT_TYPE, "cache-control": "no-store" }).end(req.method === "HEAD" ? undefined : metricsText({ version: version(), run: null }));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// anything else fails the scrape (Prometheus's `up` turns 0) and is said in the log; the error's text stays in
|
|
326
|
+
// the log, never in the answer, which takes no credential (it could name a table or a host)
|
|
327
|
+
log(` /metrics: the run's numbers could not be read: ${e.message}`);
|
|
328
|
+
res.writeHead(500, { "content-type": "text/plain", "cache-control": "no-store" }).end("the run's numbers could not be read: mason serve's log says why\n");
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
239
332
|
const h = healthBody(state(), startedAt);
|
|
240
333
|
res.writeHead(h.status, { "content-type": "application/json", "cache-control": "no-store" }).end(`${JSON.stringify(h.body)}\n`);
|
|
241
334
|
});
|
|
@@ -251,11 +344,18 @@ export async function serve(argv) {
|
|
|
251
344
|
const s = serveSettings(process.env, process.cwd());
|
|
252
345
|
if ("message" in s)
|
|
253
346
|
return failed("serve", s, { json });
|
|
347
|
+
const pinned = servePin(s, runningVersion());
|
|
348
|
+
if (pinned)
|
|
349
|
+
return failed("serve", pinned, { json });
|
|
350
|
+
const existing = readProject(s.data);
|
|
351
|
+
const unsaid = serveRecordRefusal(s, existing, process.env);
|
|
352
|
+
if (unsaid !== null)
|
|
353
|
+
return failed("serve", unsaid, { json });
|
|
254
354
|
// the project folder: its sources file is the list of what is copied, one with no table written when there is none
|
|
255
355
|
const made = serveSource(s, process.env);
|
|
256
356
|
if ("message" in made)
|
|
257
357
|
return failed("serve", made, { json });
|
|
258
|
-
const c = serveConfig(s,
|
|
358
|
+
const c = serveConfig(s, existing);
|
|
259
359
|
if ("message" in c)
|
|
260
360
|
return failed("serve", c, { json });
|
|
261
361
|
try {
|
|
@@ -281,13 +381,15 @@ export async function serve(argv) {
|
|
|
281
381
|
say(` wrote ${made.wrote}: the database (url: \${DATABASE_URL}), no table yet (mason add beside it adds one)`);
|
|
282
382
|
for (const l of c.said)
|
|
283
383
|
say(` ${l}`);
|
|
384
|
+
say(` database ${made.database}, what a tool connects with`);
|
|
284
385
|
say(` health http://${s.health.host}:${s.health.port}/healthz`);
|
|
386
|
+
say(` metrics http://${s.health.host}:${s.health.port}/metrics`);
|
|
285
387
|
// the run reads the project of the folder it runs in (src/cli/project.ts): the data folder's
|
|
286
388
|
process.chdir(s.data);
|
|
287
389
|
let code;
|
|
288
390
|
try {
|
|
289
|
-
code = await dev({ port: 4039, version: version(),
|
|
290
|
-
serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables }; }, stopping: () => { state = { phase: "stopping" }; } } });
|
|
391
|
+
code = await dev({ port: 4039, version: version(), selfSignedTls: s.selfSignedTls,
|
|
392
|
+
serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables, metrics: run.metrics }; }, stopping: () => { state = { phase: "stopping" }; } } });
|
|
291
393
|
}
|
|
292
394
|
finally {
|
|
293
395
|
// a platform's health check may hold a keep-alive connection open, which close() alone would wait for
|