@mason-data/cli 0.7.0 → 0.9.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 +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- 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 +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +15 -13
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- 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 +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -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 +12 -4
package/dist/src/cli/ui.js
CHANGED
|
@@ -1,42 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): what `mason
|
|
3
|
-
*
|
|
2
|
+
* `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): the Overview (what `mason
|
|
3
|
+
* status` says), the Tables (`mason tables`, `add`, `remove`, `sync`), the Users (`mason user`) and Observability (`mason
|
|
4
|
+
* logs`, `mason usage`), refreshed while the tab is seen. It prints the page's URL WITH the run's token (src/cli/security.ts: the first request trades it for an HttpOnly cookie and is redirected to a URL without
|
|
4
5
|
* it) and opens the browser. The reader is the person at this terminal, whom `mason user add` already shows a
|
|
5
6
|
* password once; the token is never written anywhere else — not in `mason start`'s output (its log is a file), not in
|
|
6
7
|
* `mason status`, which prints the URL without it.
|
|
7
8
|
*
|
|
8
9
|
* Before it prints anything it ASKS the page (`GET /` with the run's token as the commands send it), so a run that serves
|
|
9
|
-
* none (
|
|
10
|
+
* none (one from before the page) is said, never a URL that answers 404.
|
|
11
|
+
*
|
|
12
|
+
* `--ssh <user@server>` (src/cli/ui-ssh.ts, docs/WEB-UI.md "On a server") opens a server's page from this machine, through
|
|
13
|
+
* the machine's own ssh, and stays in the foreground until Ctrl-C, as `mason share` does; it needs no project here. Its
|
|
14
|
+
* URL carries a ticket for one use, never the run's token, which stays on the server. `--bridge` is what it runs there.
|
|
10
15
|
*/
|
|
11
16
|
import { spawn } from "node:child_process";
|
|
12
17
|
import { failed, jsonText, parseCommand, reportText } from "./command.js";
|
|
13
18
|
import { reach } from "./state.js";
|
|
19
|
+
import { bridge, openSshTunnel, targetProblem } from "./ui-ssh.js";
|
|
20
|
+
import { version } from "./version.js";
|
|
14
21
|
import { RunUnreachable } from "./control.js";
|
|
15
22
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
23
|
+
const say = (m) => { process.stderr.write(`${m}\n`); };
|
|
24
|
+
const DEFAULT_TIMEOUT_S = 60;
|
|
16
25
|
export const UI = {
|
|
17
26
|
needs: "run",
|
|
18
27
|
name: "ui",
|
|
19
|
-
summary: "the status
|
|
28
|
+
summary: "the status, tables, users and statements, in your browser",
|
|
20
29
|
usage: `usage: mason ui [--no-open] [--json]
|
|
30
|
+
mason ui --ssh <user@server> [--mason <command>] [--timeout <seconds>] [--no-open] [--json]
|
|
21
31
|
|
|
22
|
-
Opens the running Mason's web page in your browser
|
|
23
|
-
|
|
24
|
-
the
|
|
32
|
+
Opens the running Mason's web page in your browser, refreshed while the tab is open: the Overview (what mason status
|
|
33
|
+
says: whether the synced data is ready, the database, the sync, the statements and the recent errors); the Tables (add,
|
|
34
|
+
remove or sync one, as mason add, remove and sync do); the Users of mason user (add one, its password shown once with
|
|
35
|
+
the URL to paste, or remove one); and Observability: the statements running and that ran (mason logs --requests,
|
|
36
|
+
cancel one), usage over time (mason usage), and the sync's events (mason logs --events).
|
|
25
37
|
|
|
26
38
|
The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
|
|
27
39
|
bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
|
|
28
|
-
token, so after a restart run mason ui again.
|
|
29
|
-
|
|
40
|
+
token, so after a restart run mason ui again.
|
|
41
|
+
|
|
42
|
+
--ssh opens a server's page (mason serve's) from this machine, with nothing public: it runs your ssh, so your
|
|
43
|
+
~/.ssh/config, keys and SSO apply, starts mason there, and forwards a free local port to the page, which listens on the
|
|
44
|
+
server's loopback alone. It stays in the foreground: Ctrl-C closes the tunnel. It needs no project here. Its URL
|
|
45
|
+
carries a ticket for one use, never the run's token, which stays on the server. The server's Mason must be this
|
|
46
|
+
version or close to it, and must be a command ssh can run: --mason says how, for a container or a folder.
|
|
30
47
|
|
|
31
48
|
mason ui
|
|
32
49
|
mason ui --no-open print the URL, open no browser
|
|
33
50
|
mason ui --json
|
|
51
|
+
mason ui --ssh deploy@mason.example.com
|
|
52
|
+
mason ui --ssh deploy@mason.example.com --mason "docker exec -i mason-mason-1 mason"
|
|
53
|
+
mason ui --ssh deploy@mason.example.com --mason "cd /var/lib/mason && mason" --timeout 120 --no-open
|
|
54
|
+
mason ui --bridge --local-port 51000 what --ssh runs on the server; its stdout is a stream, not for reading
|
|
34
55
|
|
|
35
|
-
--no-open
|
|
36
|
-
--
|
|
56
|
+
--no-open print the URL only
|
|
57
|
+
--ssh <target> the server, as you give it to ssh: user@server, or a Host of your ssh config
|
|
58
|
+
--mason <command> how to run mason on the server (default mason); a shell's text, kept to ssh's command line
|
|
59
|
+
--timeout <s> how long ssh and the server have to answer (default ${DEFAULT_TIMEOUT_S}): a passphrase or a touch counts
|
|
60
|
+
--bridge the server's end of --ssh, on stdin and stdout
|
|
61
|
+
--local-port <n> the port the browser names, for --bridge: the laptop's
|
|
62
|
+
--json as data: { url (with this run's token; with --ssh a ticket for one use; null when Ctrl-C came
|
|
63
|
+
before the server answered, as port is, exit 0), port, server, project,
|
|
64
|
+
version (with --ssh: the server's Mason, for what its answer may lack) }, next; it never opens a
|
|
65
|
+
browser. With --ssh it then holds the tunnel: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
|
|
37
66
|
`,
|
|
38
|
-
options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false } },
|
|
39
|
-
|
|
67
|
+
options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false }, ssh: { type: "string" }, mason: { type: "string" },
|
|
68
|
+
timeout: { type: "string" }, bridge: { type: "boolean", default: false }, "local-port": { type: "string" } },
|
|
69
|
+
json: ["url", "port", "server", "project", "version", "next"],
|
|
40
70
|
};
|
|
41
71
|
const NEXT = [["mason status", "the same, in this terminal"], ["mason user list", "the users"]];
|
|
42
72
|
/** The command that opens a URL in the person's browser, by platform (macOS `open`, Windows `start`, else `xdg-open`). */
|
|
@@ -49,7 +79,7 @@ export function openCommand(platform, url) {
|
|
|
49
79
|
return ["xdg-open", [url]];
|
|
50
80
|
}
|
|
51
81
|
/** Open `url` in the browser: null once the opener started, else why it could not (the URL is printed either way). */
|
|
52
|
-
function openBrowser(url) {
|
|
82
|
+
export function openBrowser(url) {
|
|
53
83
|
const [command, args] = openCommand(process.platform, url);
|
|
54
84
|
return new Promise((resolve) => {
|
|
55
85
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
@@ -69,21 +99,98 @@ async function servesPage(run) {
|
|
|
69
99
|
await res.arrayBuffer();
|
|
70
100
|
return res.status === 200 && /^text\/html/.test(res.headers.get("content-type") ?? "");
|
|
71
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* THE PAGE OF A SERVER (the header): the tunnel, the URL once, and held until Ctrl-C or until it drops. As `mason share`:
|
|
104
|
+
* what `--json` prints is one object, once, and everything after it is on stderr, so stdout stays one document. The
|
|
105
|
+
* signals are heard before ssh starts: a Ctrl-C at a passphrase ends both.
|
|
106
|
+
*/
|
|
107
|
+
async function overSsh(target, mason, timeoutMs, open, json) {
|
|
108
|
+
let signalled = null;
|
|
109
|
+
const signal = new Promise((resolve) => {
|
|
110
|
+
const heard = (s) => { signalled = s; resolve(s); };
|
|
111
|
+
process.once("SIGINT", heard);
|
|
112
|
+
process.once("SIGTERM", heard);
|
|
113
|
+
});
|
|
114
|
+
const t = await openSshTunnel({ target, mason, timeoutMs, interactive: !json && process.stdin.isTTY === true, stopped: signal });
|
|
115
|
+
const next = [[`mason ui --ssh ${target}`, "again, once this one is closed: a new port and a new ticket"]];
|
|
116
|
+
if ("message" in t) {
|
|
117
|
+
if (signalled === null)
|
|
118
|
+
return failed("ui", t, { json });
|
|
119
|
+
// stopped on purpose, exit 0: under --json still the one object every answer is, its url null as nothing opened
|
|
120
|
+
if (json)
|
|
121
|
+
out(jsonText(UI, { url: null, port: null, server: target }, next));
|
|
122
|
+
else
|
|
123
|
+
say(`stopped before ${target} answered: nothing was left open`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
if (json)
|
|
127
|
+
out(jsonText(UI, { url: t.url, port: t.port, server: target, project: t.project, version: t.serverVersion }, next));
|
|
128
|
+
else {
|
|
129
|
+
const couldNot = open ? await openBrowser(t.url) : null;
|
|
130
|
+
out(reportText({
|
|
131
|
+
headline: open && couldNot === null ? `${t.project} on ${target}, opened in your browser` : `${t.project} on ${target}`,
|
|
132
|
+
details: [["url", t.url], ["server", target], ["project", t.project], ["version", t.serverVersion]],
|
|
133
|
+
tips: [
|
|
134
|
+
...(couldNot !== null ? [`Could not open a browser (${couldNot}): open the URL yourself.`] : []),
|
|
135
|
+
...(t.serverVersion !== version() ? [`This machine's Mason is ${version()}: the page is the server's.`] : []),
|
|
136
|
+
"The URL carries a ticket for one use, and works for 5 minutes. Ctrl-C closes the tunnel.",
|
|
137
|
+
],
|
|
138
|
+
next,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
const how = await Promise.race([signal.then((s) => ({ signal: s })), t.ended.then((why) => ({ why }))]);
|
|
142
|
+
if ("signal" in how) {
|
|
143
|
+
await t.stop();
|
|
144
|
+
say(`the tunnel to ${target} is closed`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
return failed("ui", { message: `the tunnel to ${target} dropped`, code: "failed", why: how.why, fix: "is the server up, and this machine online? The page in your browser stops answering until a new tunnel opens one", next }, { json: false });
|
|
148
|
+
}
|
|
72
149
|
export async function ui(argv) {
|
|
73
150
|
const f = parseCommand(UI, argv);
|
|
74
151
|
if (typeof f === "number")
|
|
75
152
|
return f;
|
|
76
153
|
const json = f.values.json === true;
|
|
154
|
+
const v = f.values;
|
|
155
|
+
const wrong = (message) => failed("ui", { message, code: "usage", usage: UI.usage }, { json });
|
|
77
156
|
if (f.positionals.length)
|
|
78
|
-
return
|
|
157
|
+
return wrong(`mason ui takes no argument: ${f.positionals.join(" ")}`);
|
|
158
|
+
if (v.bridge) {
|
|
159
|
+
if (v.ssh !== undefined || v.mason !== undefined || v.timeout !== undefined || json)
|
|
160
|
+
return wrong("--bridge is mason ui --ssh's own end on the server: it goes with --local-port alone");
|
|
161
|
+
const port = Number(v["local-port"]);
|
|
162
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
163
|
+
return wrong(`--bridge needs --local-port, a port number, not ${JSON.stringify(v["local-port"] ?? "")}`);
|
|
164
|
+
return bridge(port);
|
|
165
|
+
}
|
|
166
|
+
if (v["local-port"] !== undefined)
|
|
167
|
+
return wrong("--local-port goes with --bridge");
|
|
168
|
+
if (v.ssh === undefined && (v.mason !== undefined || v.timeout !== undefined))
|
|
169
|
+
return wrong(`${v.mason !== undefined ? "--mason" : "--timeout"} goes with --ssh <user@server>`);
|
|
170
|
+
if (v.ssh !== undefined) {
|
|
171
|
+
const problem = targetProblem(v.ssh);
|
|
172
|
+
if (problem !== null)
|
|
173
|
+
return wrong(`--ssh: ${problem}`);
|
|
174
|
+
if (v.mason !== undefined && (v.mason.trim() === "" || /[\r\n]/.test(v.mason)))
|
|
175
|
+
return wrong("--mason is one line of shell that runs mason on the server");
|
|
176
|
+
const seconds = v.timeout === undefined ? DEFAULT_TIMEOUT_S : Number(v.timeout);
|
|
177
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
178
|
+
return wrong(`--timeout is a number of seconds, not ${JSON.stringify(v.timeout)}`);
|
|
179
|
+
return overSsh(v.ssh, v.mason ?? "mason", seconds * 1000, !v["no-open"], json);
|
|
180
|
+
}
|
|
79
181
|
const w = await reach(UI, json);
|
|
80
182
|
if (w === null || w.state !== "up")
|
|
81
183
|
return 1;
|
|
82
184
|
const run = w.run;
|
|
185
|
+
if (w.project.config.served === true) {
|
|
186
|
+
return failed("ui", { message: `Mason for ${run.name} runs on this server: its page is opened from your own machine`, code: "refused",
|
|
187
|
+
why: "the page listens on this machine's loopback alone, and a browser on a server has no one in front of it",
|
|
188
|
+
fix: "on your machine, mason ui --ssh <user@server> (mason ui --help: for a container, --mason)", next: [["mason status", "what it is doing, in this terminal"]] }, { json });
|
|
189
|
+
}
|
|
83
190
|
if (!await servesPage(run)) {
|
|
84
191
|
return failed("ui", { message: `Mason for ${run.name} serves no web page`, code: "refused",
|
|
85
|
-
why: "
|
|
86
|
-
fix: "
|
|
192
|
+
why: "this run was started by a Mason from before the page",
|
|
193
|
+
fix: "mason stop && mason start runs it with the page",
|
|
87
194
|
next: [["mason status", "what it is doing, in this terminal"]] }, { json });
|
|
88
195
|
}
|
|
89
196
|
const url = `${run.control.url}/?token=${encodeURIComponent(run.control.token)}`;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason usage`: what the run was and did over time — a sample a minute (src/duck/usage.ts) of the copy's size and
|
|
3
|
+
* rows, the copies failed, how old the stalest copy is, the statements and their duration, the stream's lag and the
|
|
4
|
+
* memory — and each copy's own size. The run's `GET /api/usage` (src/cli/run-server.ts) answers it; `--json` prints that
|
|
5
|
+
* answer as it is, so an agent and a chart read the same samples a person reads here.
|
|
6
|
+
*/
|
|
7
|
+
import { failed, jsonText, parseCommand, endText } from "./command.js";
|
|
8
|
+
import { out as styleOut } from "./style.js";
|
|
9
|
+
import { callRun } from "./control.js";
|
|
10
|
+
import { reach } from "./state.js";
|
|
11
|
+
import { fileSize, guarded, runFailure } from "./commands.js";
|
|
12
|
+
import { timeOf } from "./request-filter.js";
|
|
13
|
+
import { duration, msText } from "./readiness.js";
|
|
14
|
+
import { SIZE_EVERY_MS } from "../duck/usage.js";
|
|
15
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
16
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
17
|
+
export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <span>] [--json]
|
|
18
|
+
|
|
19
|
+
What Mason was and did over time, from a sample it takes every minute and keeps MASON_HISTORY_DAYS days
|
|
20
|
+
(default 7): a line each span — the statements of the Postgres API (how many, how many were refused, failed or
|
|
21
|
+
cancelled — the errors column —, the completed ones' p95 duration, the rows sent), the synced data's size and rows, the
|
|
22
|
+
tables whose sync is failing, how old the stalest table was, the stream's lag (a table under sync: replication), and
|
|
23
|
+
the memory of the process and of the engine against its limit (MASON_DUCKDB_MEMORY). Then each synced table's own size, measured every
|
|
24
|
+
10 minutes. A span sums its statements and shows each level at its highest. Read through the running Mason.
|
|
25
|
+
|
|
26
|
+
mason usage the last hour, 5 minutes a line
|
|
27
|
+
mason usage --since 7d --step 1h the last week, an hour a line
|
|
28
|
+
mason usage --since 2026-10-28T09:00:00Z --until 2026-10-28T10:00:00Z --step 0
|
|
29
|
+
|
|
30
|
+
--since <when> from when: an age (90s, 30m, 6h, 7d) or an ISO 8601 date or time with its zone; default 1h
|
|
31
|
+
--until <when> until when, the same way; default now
|
|
32
|
+
--step <span> a line per span (1m to the days kept; default 5m), or 0 for every sample as it was taken
|
|
33
|
+
--json as data: { since, until, stepSeconds (null: every sample), sampleSeconds, keptDays, samples: [{ at
|
|
34
|
+
(with a step, the span's start), seconds, copyBytes, copyRows, tablesAdded, tablesCopied,
|
|
35
|
+
tablesFailed, oldestSyncSeconds, statements, statementsRefused, statementsFailed, statementsCancelled,
|
|
36
|
+
rowsSent, bytesSent, durationP50Ms, durationP95Ms, streamLagBytes, streamLagSeconds, rssBytes,
|
|
37
|
+
heapUsedBytes, copyMemoryBytes, copyMemoryLimitBytes, slotWalHeldBytes (replication: the WAL the
|
|
38
|
+
slot holds on the database), diskFreeBytes (free where the synced data is; with a step, the lowest),
|
|
39
|
+
sessionsOpen (the Postgres API's sessions logged in) }],
|
|
40
|
+
copySizes: [{ table, bytes, at }], next }
|
|
41
|
+
`;
|
|
42
|
+
export const USAGE = {
|
|
43
|
+
needs: "run",
|
|
44
|
+
name: "usage",
|
|
45
|
+
summary: "what Mason did over time: statements, the synced data's size and age, memory (a sample a minute; --since 7d --step 1h)",
|
|
46
|
+
usage: USAGE_USAGE,
|
|
47
|
+
options: { since: { type: "string" }, until: { type: "string" }, step: { type: "string" }, json: { type: "boolean" } },
|
|
48
|
+
json: ["since", "until", "stepSeconds", "sampleSeconds", "keptDays", "samples", "copySizes", "next"],
|
|
49
|
+
};
|
|
50
|
+
const UNIT = { s: 1, m: 60, h: 3600, d: 86_400 };
|
|
51
|
+
/** A span as the options write it (90s, 30m, 6h, 7d; 0), in seconds, or null when it is not one. */
|
|
52
|
+
export function spanSeconds(text) {
|
|
53
|
+
if (text.trim() === "0")
|
|
54
|
+
return 0;
|
|
55
|
+
const m = /^\s*(\d+)\s*([smhd])\s*$/.exec(text);
|
|
56
|
+
return m ? Number(m[1]) * UNIT[m[2]] : null;
|
|
57
|
+
}
|
|
58
|
+
/** One sample on one line: the time, the statements, those refused, failed or cancelled, p95, rows sent, the copy, its rows, the stalest copy, the lag, the memory. */
|
|
59
|
+
export function sampleLine(s, withLag) {
|
|
60
|
+
const time = s.at.slice(0, 16).replace("T", " ");
|
|
61
|
+
const p95 = s.durationP95Ms === null ? "–" : msText(s.durationP95Ms);
|
|
62
|
+
const bad = s.statementsRefused + s.statementsFailed + s.statementsCancelled;
|
|
63
|
+
const age = s.oldestSyncSeconds === null ? "–" : duration(s.oldestSyncSeconds);
|
|
64
|
+
const lag = !withLag ? "" : ` ${(s.streamLagSeconds === null ? "–" : duration(s.streamLagSeconds)).padStart(9)}`;
|
|
65
|
+
const memory = `${fileSize(s.rssBytes)}${s.copyMemoryBytes === null ? "" : ` (engine ${fileSize(s.copyMemoryBytes)} of ${fileSize(s.copyMemoryLimitBytes)})`}`;
|
|
66
|
+
return `${time} ${n(s.statements).padStart(10)} ${(bad === 0 ? "" : n(bad)).padStart(7)} ${p95.padStart(10)} ${n(s.rowsSent).padStart(11)} ` +
|
|
67
|
+
`${(s.copyBytes === null ? "–" : fileSize(s.copyBytes)).padStart(10)} ${n(s.copyRows).padStart(13)} ${(s.tablesFailed === 0 ? "" : n(s.tablesFailed)).padStart(7)} ` +
|
|
68
|
+
`${age.padStart(10)}${lag} ${memory}`;
|
|
69
|
+
}
|
|
70
|
+
/** The heading over `sampleLine`'s columns. */
|
|
71
|
+
export function sampleHeading(withLag) {
|
|
72
|
+
return `${"time (UTC)".padEnd(16)} ${"statements".padStart(10)} ${"errors".padStart(7)} ${"p95".padStart(10)} ${"rows sent".padStart(11)} ` +
|
|
73
|
+
`${"size".padStart(10)} ${"rows".padStart(13)} ${"failing".padStart(7)} ${"oldest".padStart(10)}${withLag ? ` ${"lag".padStart(9)}` : ""} memory`;
|
|
74
|
+
}
|
|
75
|
+
export async function usage(argv, now = Date.now()) {
|
|
76
|
+
const f = parseCommand(USAGE, argv);
|
|
77
|
+
if (typeof f === "number")
|
|
78
|
+
return f;
|
|
79
|
+
const v = f.values;
|
|
80
|
+
const json = v.json === true;
|
|
81
|
+
if (f.positionals.length)
|
|
82
|
+
return failed("usage", { message: `it takes no ${f.positionals.length === 1 ? "argument" : "arguments"}: ${f.positionals.join(" ")}`, code: "usage", usage: USAGE_USAGE }, { json });
|
|
83
|
+
// the one time grammar (src/cli/request-filter.ts timeOf): an age, or an ISO 8601 date or time WITH its zone
|
|
84
|
+
const since = timeOf("--since", v.since ?? "1h", now), until = v.until === undefined ? new Date(now).toISOString() : timeOf("--until", v.until, now);
|
|
85
|
+
for (const t of [since, until])
|
|
86
|
+
if (typeof t !== "string")
|
|
87
|
+
return failed("usage", { ...t, usage: USAGE_USAGE }, { json });
|
|
88
|
+
const step = spanSeconds(v.step ?? "5m");
|
|
89
|
+
if (step === null)
|
|
90
|
+
return failed("usage", { message: `--step is a span (1m, 5m, 1h, 1d) or 0 for every sample, not ${JSON.stringify(v.step)}`, code: "usage", usage: USAGE_USAGE }, { json });
|
|
91
|
+
const w = await reach(USAGE, json);
|
|
92
|
+
if (w === null || !("run" in w))
|
|
93
|
+
return 1;
|
|
94
|
+
const run = w.run;
|
|
95
|
+
return guarded("usage", json, async () => {
|
|
96
|
+
const q = new URLSearchParams({ since: since, until: until, ...(step === 0 ? {} : { step: String(step) }) });
|
|
97
|
+
const r = await callRun(run, "GET", `/api/usage?${q}`);
|
|
98
|
+
// the run's refusal (a step past the days kept, a since after until) in its own words and code
|
|
99
|
+
if (r.status !== 200)
|
|
100
|
+
return failed("usage", runFailure(r, [["mason usage --help", "the spans and times it takes"], ["mason logs", "the run's log"]]), { json });
|
|
101
|
+
const b = r.body;
|
|
102
|
+
const next = [["mason usage --since 7d --step 1h", "the week, an hour a line"], ["mason status", "where everything is now"]];
|
|
103
|
+
if (json) {
|
|
104
|
+
out(jsonText(USAGE, b, next));
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
const st = styleOut();
|
|
108
|
+
const span = b.stepSeconds === null ? "every sample" : `${duration(b.stepSeconds)} a line`;
|
|
109
|
+
out(st.bold(`Usage from ${b.since.slice(0, 16).replace("T", " ")} to ${b.until.slice(0, 16).replace("T", " ")} UTC, ${span} (a sample every ${duration(b.sampleSeconds)}, kept ${b.keptDays} day${b.keptDays === 1 ? "" : "s"}):`));
|
|
110
|
+
if (!b.samples.length)
|
|
111
|
+
out(st.dim(" no sample in this time: Mason takes the first a minute after it starts"));
|
|
112
|
+
else {
|
|
113
|
+
const withLag = b.samples.some((s) => s.streamLagBytes !== null || s.streamLagSeconds !== null);
|
|
114
|
+
out(st.dim(sampleHeading(withLag)));
|
|
115
|
+
for (const s of b.samples)
|
|
116
|
+
out(sampleLine(s, withLag));
|
|
117
|
+
}
|
|
118
|
+
if (b.copySizes.length) {
|
|
119
|
+
out("");
|
|
120
|
+
out(st.bold(`Each synced table's own size (measured every ${duration(SIZE_EVERY_MS / 1000)}):`));
|
|
121
|
+
const wide = Math.max(...b.copySizes.map((c) => c.table.length));
|
|
122
|
+
for (const c of b.copySizes)
|
|
123
|
+
out(` ${c.table.padEnd(wide)} ${fileSize(c.bytes).padStart(10)} ${st.dim(`at ${c.at.slice(11, 16)} UTC`)}`);
|
|
124
|
+
}
|
|
125
|
+
out(endText({ next }, st));
|
|
126
|
+
return 0;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason user add | list | disable | enable | remove … --replica <org>/<name>` — THE USERS OF A REPLICA ON A HOSTED MASON
|
|
3
|
+
* SERVICE (docs/HOSTED-API.md "The users of a replica"). Each subcommand is ONE request, the one the run's own command
|
|
4
|
+
* server takes for it (src/cli/run-server.ts `/api/users`), sent to the service under `/v1/replicas/{org}/{name}/users`:
|
|
5
|
+
*
|
|
6
|
+
* list GET …/users?lastUsed=1 mason user list --json's shape, beside its next
|
|
7
|
+
* add POST …/users { name, role?, set? } mason user add --json's shape (the run is up: its url)
|
|
8
|
+
* disable POST …/users/{user}/disable { disabled, already }
|
|
9
|
+
* enable POST …/users/{user}/enable { enabled, already }
|
|
10
|
+
* remove DELETE …/users/{user} { removed }
|
|
11
|
+
*
|
|
12
|
+
* `--role` and `--set` go as they were given: Mason on the replica checks them with the functions it checks them with
|
|
13
|
+
* here (src/cli/user.ts `roleToAdd`, `attributesToAdd`). What only this command line says (a verb, a user's name, a
|
|
14
|
+
* role's name, a `--set`'s form) is checked first, by the same functions, and refused as a wrong line before a byte is
|
|
15
|
+
* sent. Nothing of a project here is read or written: the users are the replica's.
|
|
16
|
+
*
|
|
17
|
+
* THE ANSWER IS HELD TO THE RUN'S OWN ANSWER SHAPE, then printed by the renderers the local command prints with
|
|
18
|
+
* (src/cli/user.ts `listText`, `addedReport`, …), in serviceStyle: the same answer prints the same lines. A SERVICE'S
|
|
19
|
+
* VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every value the
|
|
20
|
+
* service sends that this command prints, and how it is held:
|
|
21
|
+
*
|
|
22
|
+
* value where it is printed what holds it
|
|
23
|
+
* a failure's message, why, fix stderr's failure, --json error `serviceFailure`: one line each (`serviceText`), masked
|
|
24
|
+
* a failure's code inside why: "(the service said …)" `^[a-z][a-z0-9_]*$`, 64 chars; the code is `codeOfStatus`'s
|
|
25
|
+
* a failure's next nowhere: never read the step is `failureNext`'s, by the status
|
|
26
|
+
* a user's name every row, headline and step it names `USER_NAME_RE`; add, disable, enable, remove: the name asked
|
|
27
|
+
* an id list's rows, --json 16 hex digits
|
|
28
|
+
* addedAt, removedAt, disabledAt, rows, list's lines, --json `isServiceTime`
|
|
29
|
+
* lastUsedAt
|
|
30
|
+
* the password add's url, psql and password rows, --json `mason_<its id>_<32 hex>` (src/duck/users.ts), add's alone;
|
|
31
|
+
* anything shaped as one elsewhere refused (`carriesNoSecret`)
|
|
32
|
+
* or, in a failure's words, masked (`serviceText`)
|
|
33
|
+
* database, host, port, sslmode add's rows, --json the database name's rule, a host's letters, 1-65535, a closed set
|
|
34
|
+
* url, psql (add) add's rows, --json rebuilt here (`connectionUrl`, `psqlLine`): the service's must equal them
|
|
35
|
+
* url (list) --json `postgres://<its name>@<host>:<port>/<database>`, no password
|
|
36
|
+
* role, roleFrom, readsNothing, rows, list's lines, --json `ROLE_NAME_RE`; closed sets; booleans; each consistent with the others
|
|
37
|
+
* roleKnown, live
|
|
38
|
+
* attributes, missingAttributes rows, list's lines, --json `ATTRIBUTE_NAME_RE` names; each value one line (`serviceText`), bounded
|
|
39
|
+
* lastUsedNote, urlNote list's tip "Last used: …", --json one line (`serviceText`), bounded
|
|
40
|
+
* requestLogDays list's lines, --json a whole number, 1 to 36500
|
|
41
|
+
* already the headline chosen, --json a boolean
|
|
42
|
+
* an HTTP status a failure's why a number
|
|
43
|
+
*
|
|
44
|
+
* BEFORE ANY OF THESE, every string of the answer, its keys included, is put to the exact test (src/cli/hosted.ts
|
|
45
|
+
* `carriesNoSecret`, `carriesSecret` on each: an echo of the held token, `ECHO_RUN` of its body in a row in one string; a
|
|
46
|
+
* token's or a password's shape — add's own password, url and psql for the token alone): an answer that carries one
|
|
47
|
+
* anywhere is refused whole, so no grammar is ever the only thing between a secret and the output, and an honest answer
|
|
48
|
+
* is refused with a probability under 1 in a million whatever its size. A failure's code, printed inside why, is put to
|
|
49
|
+
* the same test (`failureShape`). The free text (the notes, a refusal's words) is masked too, by #591's quarter rule.
|
|
50
|
+
*
|
|
51
|
+
* An answer any of which does not hold is outside the contract: said by the request and its status, never its body.
|
|
52
|
+
* What to run next is the CLI's own (`onReplica`: the local command's steps, each `mason user …` naming the replica).
|
|
53
|
+
*/
|
|
54
|
+
import { failed, jsonText, serviceStyle } from "./command.js";
|
|
55
|
+
import { call, carriesNoSecret, isFailure, isServiceTime, serviceFailure, serviceNext, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
56
|
+
import { replicaText, tokenFor } from "./replica.js";
|
|
57
|
+
import { holdOutput } from "./output-gate.js";
|
|
58
|
+
import { err as styleErr, out as styleOut } from "./style.js";
|
|
59
|
+
import { connectionUrl, psqlLine } from "./pg-url.js";
|
|
60
|
+
import { USER_NAME_RE } from "../duck/users.js";
|
|
61
|
+
import { ROLE_NAME_RE } from "./roles.js";
|
|
62
|
+
import { ATTRIBUTE_NAME_RE } from "../duck/row-filter.js";
|
|
63
|
+
import { databaseNameProblem } from "../lib/database-name.js";
|
|
64
|
+
import { NO_ROLES } from "../duck/grants.js";
|
|
65
|
+
import { addedNext, addedReport, disabledNext, disabledReport, enabledNext, enabledReport, listNext, listText, REMOVED_NEXT, removedReport, USER } from "./user.js";
|
|
66
|
+
/** The bound of a list's answer (a replica's users, a row each), and of the number of users in it; every other answer
|
|
67
|
+
* is `call`'s own 64 KiB. */
|
|
68
|
+
export const MAX_LIST_BYTES = 1024 * 1024;
|
|
69
|
+
export const MAX_USERS = 2000;
|
|
70
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
71
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
72
|
+
const ID = /^[0-9a-f]{16}$/;
|
|
73
|
+
const HOST = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}))*\.?|[0-9A-Fa-f:.]{2,45})$/;
|
|
74
|
+
const ROLE_FROM = ["file", "default", "none"];
|
|
75
|
+
/** The local command's next steps, as a replica's: each `mason user …` names the replica, so it runs against it. */
|
|
76
|
+
export const onReplica = (steps, r) => steps.map(([command, ...what]) => [command.startsWith("mason user ") ? `${command} --replica ${replicaText(r)}` : command, ...what]);
|
|
77
|
+
/** What to run after the service refused a request about a replica's users: the CLI's own, by the status (never the
|
|
78
|
+
* service's `next`): sign in again for a 401 or 403, else the replica's users. */
|
|
79
|
+
export const failureNext = (status, r) => status === 401 || status === 403 ? serviceNext(status) : [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]];
|
|
80
|
+
/** The request a subcommand is: the run's own `/api/users` request, under the replica's path. */
|
|
81
|
+
export function requestOf(r, o) {
|
|
82
|
+
const base = `/v1/replicas/${r.org}/${r.name}/users`;
|
|
83
|
+
// a user's name is USER_NAME_RE's (checked before this): letters, digits, `_`, `.` and `-`, safe in a path as it is
|
|
84
|
+
switch (o.verb) {
|
|
85
|
+
case "list": return { method: "GET", path: `${base}?lastUsed=1` };
|
|
86
|
+
case "add": return { method: "POST", path: base, json: { name: o.name, ...(o.role !== undefined ? { role: o.role } : {}), ...(o.sets.length ? { set: [...o.sets] } : {}) } };
|
|
87
|
+
case "disable":
|
|
88
|
+
case "enable": return { method: "POST", path: `${base}/${o.name}/${o.verb}` };
|
|
89
|
+
case "remove": return { method: "DELETE", path: `${base}/${o.name}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// ------------------------------------------------------------------ the answers, held to the run's own shapes
|
|
93
|
+
/** A string the service sends, as one line: null when it is not a string. */
|
|
94
|
+
const line = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
|
|
95
|
+
/** A user's role fields, each consistent with the others (src/cli/roles.ts userRole): a role for `file` and `default`,
|
|
96
|
+
* none for `none`, `readsNothing` only where there is none. */
|
|
97
|
+
function roleOf(u) {
|
|
98
|
+
if (typeof u.roleFrom !== "string" || !ROLE_FROM.includes(u.roleFrom))
|
|
99
|
+
return null;
|
|
100
|
+
const roleFrom = u.roleFrom;
|
|
101
|
+
if (u.role !== undefined && (typeof u.role !== "string" || !ROLE_NAME_RE.test(u.role)))
|
|
102
|
+
return null;
|
|
103
|
+
if ((roleFrom === "none") !== (u.role === undefined))
|
|
104
|
+
return null;
|
|
105
|
+
if (u.readsNothing !== undefined && (u.readsNothing !== true || roleFrom !== "none"))
|
|
106
|
+
return null;
|
|
107
|
+
return { ...(typeof u.role === "string" ? { role: u.role } : {}), roleFrom, ...(u.readsNothing === true ? { readsNothing: true } : {}) };
|
|
108
|
+
}
|
|
109
|
+
/** A user's attributes: at most 64 names, each a value (one line, bounded) or a list of 1 to 100 of them. A value is a
|
|
110
|
+
* value (a tenant's id), put to the exact test with every string (`carriesNoSecret`), so it is made one line here
|
|
111
|
+
* without the quarter rule of free text, which a thousand random ids would trip by chance (PR 609's review). */
|
|
112
|
+
function attributesOf(v) {
|
|
113
|
+
if (!obj(v) || Object.keys(v).length > 64)
|
|
114
|
+
return null;
|
|
115
|
+
const outv = {};
|
|
116
|
+
for (const [k, x] of Object.entries(v)) {
|
|
117
|
+
if (!ATTRIBUTE_NAME_RE.test(k))
|
|
118
|
+
return null;
|
|
119
|
+
if (typeof x === "string")
|
|
120
|
+
outv[k] = serviceText(x, 1000);
|
|
121
|
+
else if (Array.isArray(x) && x.length >= 1 && x.length <= 100 && x.every((y) => typeof y === "string"))
|
|
122
|
+
outv[k] = x.map((y) => serviceText(y, 1000));
|
|
123
|
+
else
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return outv;
|
|
127
|
+
}
|
|
128
|
+
const names = (v) => Array.isArray(v) && v.length >= 1 && v.length <= 64 && v.every((x) => typeof x === "string" && ATTRIBUTE_NAME_RE.test(x));
|
|
129
|
+
const timeOrNull = (v) => v === null || isServiceTime(v);
|
|
130
|
+
/** One row of a list, as `listUsers` makes it (src/cli/user.ts `UserRow`); null when it is not that shape. */
|
|
131
|
+
function rowOf(v) {
|
|
132
|
+
if (!obj(v))
|
|
133
|
+
return null;
|
|
134
|
+
const u = v;
|
|
135
|
+
if (typeof u.name !== "string" || !USER_NAME_RE.test(u.name) || typeof u.id !== "string" || !ID.test(u.id) || !isServiceTime(u.addedAt)
|
|
136
|
+
|| !timeOrNull(u.removedAt) || !timeOrNull(u.disabledAt) || !timeOrNull(u.lastUsedAt) || typeof u.live !== "boolean")
|
|
137
|
+
return null;
|
|
138
|
+
// the URL a tool pastes, WITHOUT the password: its own name, then where the replica listens
|
|
139
|
+
if (u.url !== null && !(typeof u.url === "string" && u.url.length <= 600 && u.url.startsWith(`postgres://${u.name}@`)
|
|
140
|
+
&& /^postgres:\/\/[a-z0-9_.-]+@(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/[A-Za-z0-9%._~!*'()-]+(?:\?sslmode=require)?$/.test(u.url)))
|
|
141
|
+
return null;
|
|
142
|
+
const role = roleOf(u);
|
|
143
|
+
if (role === null)
|
|
144
|
+
return null;
|
|
145
|
+
const attributes = u.attributes === undefined ? undefined : attributesOf(u.attributes);
|
|
146
|
+
if (attributes === null)
|
|
147
|
+
return null;
|
|
148
|
+
if (u.missingAttributes !== undefined && !names(u.missingAttributes))
|
|
149
|
+
return null;
|
|
150
|
+
if (u.roleKnown !== undefined && (typeof u.roleKnown !== "boolean" || role.roleFrom !== "file"))
|
|
151
|
+
return null;
|
|
152
|
+
return { name: u.name, id: u.id, addedAt: u.addedAt, removedAt: u.removedAt, disabledAt: u.disabledAt, live: u.live, lastUsedAt: u.lastUsedAt,
|
|
153
|
+
url: u.url, ...role, ...(attributes !== undefined ? { attributes } : {}),
|
|
154
|
+
...(u.missingAttributes !== undefined ? { missingAttributes: u.missingAttributes } : {}),
|
|
155
|
+
...(typeof u.roleKnown === "boolean" ? { roleKnown: u.roleKnown } : {}) };
|
|
156
|
+
}
|
|
157
|
+
/** `GET …/users?lastUsed=1`'s answer: mason user list --json's shape (its `next` never read); null when it is not it. */
|
|
158
|
+
export function heldList(json, secrets) {
|
|
159
|
+
if (!obj(json) || !Array.isArray(json.users) || json.users.length > MAX_USERS || !carriesNoSecret(json, secrets))
|
|
160
|
+
return null;
|
|
161
|
+
const users = [];
|
|
162
|
+
for (const v of json.users) {
|
|
163
|
+
const u = rowOf(v);
|
|
164
|
+
if (u === null)
|
|
165
|
+
return null;
|
|
166
|
+
users.push(u);
|
|
167
|
+
}
|
|
168
|
+
const note = (v) => v === null ? null : line(v, 500, secrets) ?? undefined;
|
|
169
|
+
const lastUsedNote = note(json.lastUsedNote), urlNote = note(json.urlNote);
|
|
170
|
+
const days = json.requestLogDays;
|
|
171
|
+
if (lastUsedNote === undefined || urlNote === undefined || !(days === null || (Number.isInteger(days) && days >= 1 && days <= 36500)))
|
|
172
|
+
return null;
|
|
173
|
+
return { users, lastUsedNote, urlNote, requestLogDays: days };
|
|
174
|
+
}
|
|
175
|
+
/** `POST …/users`'s answer: mason user add --json's shape with Mason up — where it listens, the URL rebuilt here by the
|
|
176
|
+
* one builder and equal to the service's —, for the name asked; null when it is not it. */
|
|
177
|
+
export function heldAdded(json, asked, secrets) {
|
|
178
|
+
if (!obj(json) || !carriesNoSecret(json, secrets, ["password", "url", "psql"]))
|
|
179
|
+
return null;
|
|
180
|
+
const a = json;
|
|
181
|
+
if (a.user !== asked || typeof a.id !== "string" || !ID.test(a.id) || !isServiceTime(a.addedAt))
|
|
182
|
+
return null;
|
|
183
|
+
// the password is the user's, by its one grammar: its id, then its MAC
|
|
184
|
+
if (typeof a.password !== "string" || !new RegExp(`^mason_${a.id}_[0-9a-f]{32}$`).test(a.password))
|
|
185
|
+
return null;
|
|
186
|
+
if (typeof a.database !== "string" || databaseNameProblem(a.database) !== null || serviceText(a.database, 63) !== a.database)
|
|
187
|
+
return null;
|
|
188
|
+
if (typeof a.host !== "string" || !HOST.test(a.host) || !Number.isInteger(a.port) || a.port < 1 || a.port > 65535)
|
|
189
|
+
return null;
|
|
190
|
+
if (a.sslmode !== "require" && a.sslmode !== "disable")
|
|
191
|
+
return null;
|
|
192
|
+
const url = connectionUrl({ url: "", host: a.host, port: a.port, database: a.database, ...(a.sslmode === "require" ? { sslmode: "require" } : {}) }, asked, a.password);
|
|
193
|
+
if (a.url !== url || a.psql !== psqlLine(url))
|
|
194
|
+
return null;
|
|
195
|
+
const role = roleOf(a);
|
|
196
|
+
if (role === null)
|
|
197
|
+
return null;
|
|
198
|
+
const attributes = a.attributes === undefined ? undefined : attributesOf(a.attributes);
|
|
199
|
+
if (attributes === null)
|
|
200
|
+
return null;
|
|
201
|
+
return { user: asked, id: a.id, addedAt: a.addedAt, password: a.password, database: a.database, url, psql: psqlLine(url), host: a.host, port: a.port, sslmode: a.sslmode,
|
|
202
|
+
...role, ...(attributes !== undefined ? { attributes } : {}) };
|
|
203
|
+
}
|
|
204
|
+
/** A user as disable, enable and remove name it: the name asked, its id, when it was added, and `more` times. */
|
|
205
|
+
function userOf(v, asked, more) {
|
|
206
|
+
if (!obj(v) || v.name !== asked || typeof v.id !== "string" || !ID.test(v.id) || !isServiceTime(v.addedAt) || !more.every((k) => isServiceTime(v[k])))
|
|
207
|
+
return null;
|
|
208
|
+
return { name: asked, id: v.id, addedAt: v.addedAt, ...Object.fromEntries(more.map((k) => [k, v[k]])) };
|
|
209
|
+
}
|
|
210
|
+
export function heldDisabled(json, asked, secrets) {
|
|
211
|
+
if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
|
|
212
|
+
return null;
|
|
213
|
+
const d = userOf(json.disabled, asked, ["disabledAt"]);
|
|
214
|
+
return d === null ? null : { disabled: d, already: json.already };
|
|
215
|
+
}
|
|
216
|
+
export function heldEnabled(json, asked, secrets) {
|
|
217
|
+
if (!obj(json) || typeof json.already !== "boolean" || !carriesNoSecret(json, secrets))
|
|
218
|
+
return null;
|
|
219
|
+
const e = userOf(json.enabled, asked, []);
|
|
220
|
+
return e === null ? null : { enabled: e, already: json.already };
|
|
221
|
+
}
|
|
222
|
+
export function heldRemoved(json, asked, secrets) {
|
|
223
|
+
if (!obj(json) || !carriesNoSecret(json, secrets))
|
|
224
|
+
return null;
|
|
225
|
+
const r = userOf(json.removed, asked, ["removedAt"]);
|
|
226
|
+
return r === null ? null : { removed: r };
|
|
227
|
+
}
|
|
228
|
+
// ------------------------------------------------------------------ the command
|
|
229
|
+
/** What each subcommand asked, in the words of a failure outside the contract (`unexpected`). */
|
|
230
|
+
const STEP = { list: "the replica's users were asked for", add: "a user was added", disable: "a user was disabled", enable: "a user was enabled", remove: "a user was removed" };
|
|
231
|
+
/** `mason user <verb> … --replica <org>/<name>`: one request, its answer held and printed as the local command prints it. */
|
|
232
|
+
export async function hostedUser(r, o) {
|
|
233
|
+
const outStyle = serviceStyle(styleOut());
|
|
234
|
+
const fail = (f) => failed("user", f, { json: o.json, style: serviceStyle(styleErr()) });
|
|
235
|
+
const service = serviceOf();
|
|
236
|
+
if (isFailure(service))
|
|
237
|
+
return fail(service);
|
|
238
|
+
const token = tokenFor(service, { asked: "a replica's users are asked for", again: `mason user ${o.verb}` });
|
|
239
|
+
if (isFailure(token))
|
|
240
|
+
return fail(token);
|
|
241
|
+
// from here every byte written is put to the exact test against the token (src/cli/output-gate.ts); add prints the
|
|
242
|
+
// password it made, so a password's shape is its answer's there
|
|
243
|
+
holdOutput({ command: "user", json: o.json, secrets: [token], passwords: o.verb !== "add" });
|
|
244
|
+
const secrets = [token];
|
|
245
|
+
const req = requestOf(r, o);
|
|
246
|
+
const a = await call(service, req.method, req.path, { token, ...(req.json !== undefined ? { json: req.json } : {}), ...(o.verb === "list" ? { maxBytes: MAX_LIST_BYTES } : {}) });
|
|
247
|
+
if (isFailure(a))
|
|
248
|
+
return fail(a);
|
|
249
|
+
const refused = (x) => serviceFailure(x, secrets, (status) => failureNext(status, r)) ?? unexpected(service, x, STEP[o.verb]);
|
|
250
|
+
if (a.status !== 200)
|
|
251
|
+
return fail(refused(a));
|
|
252
|
+
const outside = () => fail(unexpected(service, a, STEP[o.verb]));
|
|
253
|
+
const print = (value, next, text) => {
|
|
254
|
+
out(o.json ? jsonText(USER, value, next) : text());
|
|
255
|
+
return 0;
|
|
256
|
+
};
|
|
257
|
+
switch (o.verb) {
|
|
258
|
+
case "list": {
|
|
259
|
+
const l = heldList(a.json, secrets);
|
|
260
|
+
if (l === null)
|
|
261
|
+
return outside();
|
|
262
|
+
const next = onReplica(listNext(l.users), r);
|
|
263
|
+
return print({ ...l }, next, () => listText(replicaText(r), l, NO_ROLES, next, outStyle));
|
|
264
|
+
}
|
|
265
|
+
case "add": {
|
|
266
|
+
const added = heldAdded(a.json, o.name, secrets);
|
|
267
|
+
// the service answered 200: the replica may have made the user, whose password nobody has now been shown
|
|
268
|
+
if (added === null) {
|
|
269
|
+
const f = unexpected(service, a, STEP[o.verb]);
|
|
270
|
+
return fail({ ...f, why: `${f.why}; the replica may have added ${o.name} all the same, and its password was not shown`,
|
|
271
|
+
fix: `mason user list --replica ${replicaText(r)} says whether it did; if so, mason user remove ${o.name} --replica ${replicaText(r)}, then add it again`,
|
|
272
|
+
next: [[`mason user list --replica ${replicaText(r)}`, "the replica's users"]] });
|
|
273
|
+
}
|
|
274
|
+
const next = onReplica(addedNext(added.user, true), r);
|
|
275
|
+
return print({ ...added }, next, () => addedReport(added, { roles: NO_ROLES, listener: null, next }, outStyle));
|
|
276
|
+
}
|
|
277
|
+
case "disable": {
|
|
278
|
+
const d = heldDisabled(a.json, o.name, secrets);
|
|
279
|
+
if (d === null)
|
|
280
|
+
return outside();
|
|
281
|
+
const next = onReplica(disabledNext(d.disabled.name), r);
|
|
282
|
+
return print({ ...d }, next, () => disabledReport(d, true, next, outStyle));
|
|
283
|
+
}
|
|
284
|
+
case "enable": {
|
|
285
|
+
const e = heldEnabled(a.json, o.name, secrets);
|
|
286
|
+
if (e === null)
|
|
287
|
+
return outside();
|
|
288
|
+
const next = onReplica(enabledNext(e.enabled.name), r);
|
|
289
|
+
return print({ ...e }, next, () => enabledReport(e, next, outStyle));
|
|
290
|
+
}
|
|
291
|
+
case "remove": {
|
|
292
|
+
const x = heldRemoved(a.json, o.name, secrets);
|
|
293
|
+
if (x === null)
|
|
294
|
+
return outside();
|
|
295
|
+
const next = onReplica(REMOVED_NEXT, r);
|
|
296
|
+
return print({ ...x }, next, () => removedReport(x, true, next, outStyle));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|