@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
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason replica create | list | status` — A HOSTED MASON SERVICE'S REPLICAS, by its documented API alone
|
|
3
|
+
* (docs/HOSTED-API.md "Replicas"). Each is a client of it, as `mason deploy` and `mason user --replica` are:
|
|
4
|
+
*
|
|
5
|
+
* create <org>/<name> POST /v1/replicas/{org} { name, region, sizeGb, sources: [{ name, variable, url }] }
|
|
6
|
+
* 201 { replica, checks }, then GET …/{name} until it is ready, failed or past --timeout
|
|
7
|
+
* list [<org>] GET /v1/replicas/{org} { replicas, more } — each organisation of the login's when none is named
|
|
8
|
+
* status <org>/<name> GET /v1/replicas/{org}/{name} { replica }
|
|
9
|
+
*
|
|
10
|
+
* THE DATABASE'S URL (create) is read here alone, by the variable the project's sources file names (`url: ${NAME}`,
|
|
11
|
+
* src/cli/sources.ts `readSource`, the one reader: `.env.local`, `.env`, then the shell), never from a flag: it never
|
|
12
|
+
* reaches a command line, a log, the output or --json. It is sent once, in the creation's body, for Mason's checks of
|
|
13
|
+
* the source on the replica's side, and the variable's NAME beside it, which the replica reads it by. A `url:` written
|
|
14
|
+
* in the file is refused: the replica has no variable to read it from, and a literal would be kept in the project's files.
|
|
15
|
+
* Its password is held as a secret of this command beside the token, in the one list every boundary reads
|
|
16
|
+
* (`urlPassword`): masked in free text, refused in any other value and by the output's gate, read whole whatever its
|
|
17
|
+
* alphabet; one under `MIN_PASSWORD` characters is not sent, since none of them could tell it from ordinary words.
|
|
18
|
+
*
|
|
19
|
+
* A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts), and A GRAMMAR
|
|
20
|
+
* IS NO MASK (issue 627): before any value is held to its grammar, every string of each answer, keys included, is put to
|
|
21
|
+
* the exact test (`carriesNoSecret`) but the free text alone (a check's name, detail and fix, the names of the checks
|
|
22
|
+
* not run, a pending change's failure), which is masked as every service text is (`serviceText`). Every value printed:
|
|
23
|
+
*
|
|
24
|
+
* value where it is printed what holds it
|
|
25
|
+
* a failure's message, why, fix, code stderr's failure, --json error `serviceFailure` (src/cli/hosted.ts)
|
|
26
|
+
* a failure's next nowhere: never read the step is `refusedNext`'s, by the status
|
|
27
|
+
* org, name every row, headline and step the name asked (`REPLICA_PART` for a list's)
|
|
28
|
+
* region, hostId rows, --json `REGION`, `HOST_ID`
|
|
29
|
+
* sizeGb, attempts rows, --json whole numbers, bounded
|
|
30
|
+
* state, outcome, status, change rows (the CLI's words), --json closed sets (`STATES`, …)
|
|
31
|
+
* createdAt, nextAttemptAt rows, --json `isServiceTime`
|
|
32
|
+
* allowlist a row, --json each `CIDR`, at most `MAX_ALLOWLIST`
|
|
33
|
+
* a check's name, detail, fix; missing; stderr's lines, --json one line each, bounded, masked (`serviceText`)
|
|
34
|
+
* a pending change's failure
|
|
35
|
+
* a check's source stderr's lines, --json the source sent
|
|
36
|
+
*
|
|
37
|
+
* What to run next, the code and the exit are the CLI's own: 0 once the replica is ready (create) or said (list,
|
|
38
|
+
* status); 1 refused (its words as they came), not made, failed or an answer outside the contract; 2 a wrong line;
|
|
39
|
+
* 124 the wait timed out (nothing stopped: `mason replica status` says how it goes).
|
|
40
|
+
*/
|
|
41
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
42
|
+
import { endText, errorJson, exitOf, failed, failureText, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
43
|
+
import { call, canonicalSecret, carriesNoSecret, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
44
|
+
import { loginOrgs, orgNamed, REPLICA_PART, replicaNamed, replicaText, tokenFor } from "./replica.js";
|
|
45
|
+
import { holdOutput, readingsCarry, readingsOf } from "./output-gate.js";
|
|
46
|
+
import { theProject } from "./project.js";
|
|
47
|
+
import { readSource, shownPath } from "./sources.js";
|
|
48
|
+
import { parseSource } from "./preflight.js";
|
|
49
|
+
import { parseDuration } from "../lib/table-sync.js";
|
|
50
|
+
import { sayAgain } from "./commands.js";
|
|
51
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
52
|
+
import { err as styleErr, out as styleOut, PLAIN } from "./style.js";
|
|
53
|
+
/** How long the creation's POST may take: the service answers once Mason's checks of the source are done. */
|
|
54
|
+
export const CREATE_MS = 60_000;
|
|
55
|
+
/** How often a creation's state is asked for while its region makes it, when the service asks no other. */
|
|
56
|
+
export const REPLICA_POLL_MS = 5_000;
|
|
57
|
+
const DEFAULT_TIMEOUT = "15m";
|
|
58
|
+
/** A list's answer (a row a replica) is read up to this; every other answer is `call`'s own 64 KiB. */
|
|
59
|
+
export const MAX_LIST_BYTES = 1024 * 1024;
|
|
60
|
+
const MAX_REPLICAS = 1000;
|
|
61
|
+
const MAX_ALLOWLIST = 1000;
|
|
62
|
+
const MAX_PENDING = 16;
|
|
63
|
+
const MAX_CHECKS = 200;
|
|
64
|
+
/** A region's name and a host id: lower case letters, digits and `-`, as a replica's name is. */
|
|
65
|
+
const REGION = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
66
|
+
const HOST_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
67
|
+
/** An address range of an allowlist: IPv4 or IPv6, with its prefix length. */
|
|
68
|
+
const CIDR = /^(?:\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}|[0-9a-fA-F:.]{2,45}\/\d{1,3})$/;
|
|
69
|
+
const SIZE = /^[1-9]\d{0,5}$/;
|
|
70
|
+
export const STATES = ["checking", "creating", "ready", "failed", "deleting", "deleted"];
|
|
71
|
+
const OUTCOMES = ["passed", "failed", "unknown"];
|
|
72
|
+
const CHECK_STATUSES = ["ok", "warn", "failed", "unknown"];
|
|
73
|
+
export const REPLICA = {
|
|
74
|
+
name: "replica",
|
|
75
|
+
summary: "a hosted Mason service's replicas: create one for this project's database, list them, say how one is",
|
|
76
|
+
usage: `usage: mason replica create <org>/<name> --region <region> --size <gb> [--timeout <interval>] [--json]
|
|
77
|
+
mason replica list [<org>] [--json]
|
|
78
|
+
mason replica status <org>/<name> [--json]
|
|
79
|
+
|
|
80
|
+
A replica is Mason run for you by a hosted Mason service, on a machine of its own, syncing this project's
|
|
81
|
+
database. mason deploy then sends it the project's files, and mason token create a CI token for it.
|
|
82
|
+
|
|
83
|
+
create makes one in the organisation <org>, named <name>, in --region (one the service offers), sized by
|
|
84
|
+
--size: the synced data it holds, in GB (one size the service offers, such as 50). The database is the one this
|
|
85
|
+
project's sources file names, by its variable (url: \${DATABASE_URL}): its value is read here, from .env.local,
|
|
86
|
+
.env or the shell, and sent once to the service for Mason's checks of the source; it is never taken on the
|
|
87
|
+
command line, never printed and never in --json, and the replica reads it by that variable. A url: written in
|
|
88
|
+
the file is refused: write url: \${DATABASE_URL} and put the URL in .env. A URL whose password is under 8
|
|
89
|
+
characters, or is in the words this command prints itself, is refused too, before anything is sent: nothing
|
|
90
|
+
could keep it out of what is printed. create waits until the service has run
|
|
91
|
+
Mason's checks of the database (up to a minute) and says each check, one line each, as the service words them;
|
|
92
|
+
then until its region has made the replica (--timeout bounds that wait; the replica is made all the same).
|
|
93
|
+
An admin or an owner of the organisation creates one. An account's FIRST replica is made in the service's web
|
|
94
|
+
app, where it asks a check that a person is creating it, which a command cannot answer: the service refuses it
|
|
95
|
+
here, and the replicas after it can be made here.
|
|
96
|
+
|
|
97
|
+
list says the replicas of <org>, or of every organisation of this login when none is named (a CI token names
|
|
98
|
+
one); status says one replica: its state (checking, creating, ready, failed, deleting, deleted), its region and
|
|
99
|
+
size, its host, its allowlist (the addresses that may query it), and what of its region's work is not done.
|
|
100
|
+
|
|
101
|
+
The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
|
|
102
|
+
Progress goes to stderr; the answer is printed when it ends.
|
|
103
|
+
|
|
104
|
+
mason replica create acme/shop --region <region> --size 50
|
|
105
|
+
mason replica list
|
|
106
|
+
mason replica status acme/shop --json
|
|
107
|
+
|
|
108
|
+
--region <region> create: the region it runs in, one the service offers
|
|
109
|
+
--size <gb> create: the synced data it holds, in GB, one size the service offers (50)
|
|
110
|
+
--timeout <interval> create: how long to wait for its region to make it (default ${DEFAULT_TIMEOUT}; 1s to 24h)
|
|
111
|
+
--json as data, once it ends: { replica (create, status: { org, name, region, sizeGb, hostId,
|
|
112
|
+
state, createdAt, allowlist, pending: [{ change (create, allowlist), state (pending,
|
|
113
|
+
failed), attempts, failure, nextAttemptAt }] }), checks (create: [{ source, outcome
|
|
114
|
+
(passed, failed, unknown), checks: [{ name, status (ok, warn, failed, unknown), detail,
|
|
115
|
+
fix }], missing }], null when the service's answer was lost), replicas (list: each as
|
|
116
|
+
replica is), more (list: true when an organisation has more than the service lists),
|
|
117
|
+
error (create, when it was not made, failed or the wait timed out: { code, message,
|
|
118
|
+
why, fix }), next }
|
|
119
|
+
|
|
120
|
+
Exit status: 0 the replica is ready (create), or said (list, status); 1 not signed in, refused by the service
|
|
121
|
+
(its words printed as they came, the code the CLI's own), not made (the checks failed: each is said), its
|
|
122
|
+
region could not make it, or the service answered outside its contract; 2 a wrong command line; 124 the wait
|
|
123
|
+
timed out (nothing stopped: mason replica status says how it goes). Whatever the service sends, it never
|
|
124
|
+
makes the exit 0, 2 or 124.
|
|
125
|
+
`,
|
|
126
|
+
options: { region: { type: "string" }, size: { type: "string" }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
|
|
127
|
+
json: ["replica", "checks", "replicas", "more", "error", "next"],
|
|
128
|
+
};
|
|
129
|
+
/** Everything this command prints can hold the service's text, so it is all drawn in serviceStyle (src/cli/command.ts). */
|
|
130
|
+
const outStyle = () => serviceStyle(styleOut());
|
|
131
|
+
const errStyle = () => serviceStyle(styleErr());
|
|
132
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
133
|
+
const say = (m) => { process.stderr.write(`${strict(m, errStyle())}\n`); };
|
|
134
|
+
const fail = (f, json) => failed("replica", f, { json, style: errStyle() });
|
|
135
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
136
|
+
const whole = (v, least, most) => Number.isInteger(v) && v >= least && v <= most;
|
|
137
|
+
// ------------------------------------------------------------------ the answers, held
|
|
138
|
+
/**
|
|
139
|
+
* AN ANSWER WITHOUT ITS FREE TEXT: what is put to the exact test (`carriesNoSecret`) — every string but a check's
|
|
140
|
+
* name, detail and fix, the names of the checks not run, and a pending change's failure, which are words the service
|
|
141
|
+
* says (masked as a failure's words are, `serviceText`). Keys are kept, so each is tested.
|
|
142
|
+
*/
|
|
143
|
+
export function wordless(json) {
|
|
144
|
+
if (!obj(json))
|
|
145
|
+
return json;
|
|
146
|
+
const replica = (r) => obj(r) && Array.isArray(r.pending) ? { ...r, pending: r.pending.map((p) => obj(p) ? { ...p, failure: null } : p) } : r;
|
|
147
|
+
const checks = (c) => !obj(c) ? c : { ...c,
|
|
148
|
+
...(Array.isArray(c.missing) ? { missing: c.missing.map(() => null) } : {}),
|
|
149
|
+
...(Array.isArray(c.checks) ? { checks: c.checks.map((x) => obj(x) ? { ...x, name: null, detail: null, fix: null } : x) } : {}) };
|
|
150
|
+
return { ...json, ...("replica" in json ? { replica: replica(json.replica) } : {}),
|
|
151
|
+
...(Array.isArray(json.replicas) ? { replicas: json.replicas.map(replica) } : {}),
|
|
152
|
+
...(Array.isArray(json.checks) ? { checks: json.checks.map(checks) } : {}) };
|
|
153
|
+
}
|
|
154
|
+
/** Free text of the service's, as it is printed: one line, bounded, masked; null when it is not a string. */
|
|
155
|
+
const words = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
|
|
156
|
+
/**
|
|
157
|
+
* A REPLICA, held to the contract: of the organisation asked (and the name asked, for one replica), every value to its
|
|
158
|
+
* grammar; null when any does not hold. The answer around it has been put to the exact test (`wordless`).
|
|
159
|
+
*/
|
|
160
|
+
export function heldReplica(v, asked, secrets) {
|
|
161
|
+
if (!obj(v) || v.org !== asked.org || typeof v.name !== "string" || !REPLICA_PART.test(v.name) || (asked.name !== undefined && v.name !== asked.name))
|
|
162
|
+
return null;
|
|
163
|
+
if (typeof v.region !== "string" || !REGION.test(v.region) || !whole(v.sizeGb, 1, 1_000_000) || typeof v.hostId !== "string" || !HOST_ID.test(v.hostId))
|
|
164
|
+
return null;
|
|
165
|
+
if (typeof v.state !== "string" || !STATES.includes(v.state) || !isServiceTime(v.createdAt))
|
|
166
|
+
return null;
|
|
167
|
+
if (!Array.isArray(v.allowlist) || v.allowlist.length > MAX_ALLOWLIST || !v.allowlist.every((c) => typeof c === "string" && CIDR.test(c)))
|
|
168
|
+
return null;
|
|
169
|
+
if (!Array.isArray(v.pending) || v.pending.length > MAX_PENDING)
|
|
170
|
+
return null;
|
|
171
|
+
const pending = [];
|
|
172
|
+
for (const p of v.pending) {
|
|
173
|
+
if (!obj(p) || (p.change !== "create" && p.change !== "allowlist") || (p.state !== "pending" && p.state !== "failed") || !whole(p.attempts, 0, 1_000_000))
|
|
174
|
+
return null;
|
|
175
|
+
if (p.failure !== undefined && p.failure !== null && typeof p.failure !== "string")
|
|
176
|
+
return null;
|
|
177
|
+
if (p.nextAttemptAt !== undefined && p.nextAttemptAt !== null && !isServiceTime(p.nextAttemptAt))
|
|
178
|
+
return null;
|
|
179
|
+
pending.push({ change: p.change, state: p.state, attempts: p.attempts, failure: words(p.failure, 500, secrets),
|
|
180
|
+
nextAttemptAt: typeof p.nextAttemptAt === "string" ? p.nextAttemptAt : null });
|
|
181
|
+
}
|
|
182
|
+
return { org: v.org, name: v.name, region: v.region, sizeGb: v.sizeGb, hostId: v.hostId, state: v.state,
|
|
183
|
+
createdAt: v.createdAt, allowlist: [...v.allowlist], pending };
|
|
184
|
+
}
|
|
185
|
+
/** Mason's checks, held: one entry at most, for the source sent, each check's status and the outcome closed sets, every
|
|
186
|
+
* word one line; null when they do not hold. */
|
|
187
|
+
export function heldChecks(v, source, secrets) {
|
|
188
|
+
if (!Array.isArray(v) || v.length > 1)
|
|
189
|
+
return null;
|
|
190
|
+
const held = [];
|
|
191
|
+
for (const s of v) {
|
|
192
|
+
if (!obj(s) || s.source !== source || typeof s.outcome !== "string" || !OUTCOMES.includes(s.outcome))
|
|
193
|
+
return null;
|
|
194
|
+
if (!Array.isArray(s.checks) || s.checks.length > MAX_CHECKS || !Array.isArray(s.missing) || s.missing.length > MAX_CHECKS)
|
|
195
|
+
return null;
|
|
196
|
+
const checks = [];
|
|
197
|
+
for (const c of s.checks) {
|
|
198
|
+
if (!obj(c) || typeof c.name !== "string" || typeof c.detail !== "string" || typeof c.status !== "string" || !CHECK_STATUSES.includes(c.status))
|
|
199
|
+
return null;
|
|
200
|
+
if (c.fix !== undefined && c.fix !== null && typeof c.fix !== "string")
|
|
201
|
+
return null;
|
|
202
|
+
checks.push({ name: words(c.name, 200, secrets), status: c.status, detail: words(c.detail, 2000, secrets), fix: words(c.fix, 2000, secrets) });
|
|
203
|
+
}
|
|
204
|
+
if (!s.missing.every((m) => typeof m === "string"))
|
|
205
|
+
return null;
|
|
206
|
+
held.push({ source, outcome: s.outcome, checks, missing: s.missing.map((m) => words(m, 200, secrets)) });
|
|
207
|
+
}
|
|
208
|
+
return held;
|
|
209
|
+
}
|
|
210
|
+
// ------------------------------------------------------------------ in the CLI's words
|
|
211
|
+
const STATE_WORDS = {
|
|
212
|
+
checking: "checking: Mason checks its database; nothing is made yet",
|
|
213
|
+
creating: "creating: its region is making it",
|
|
214
|
+
ready: "ready",
|
|
215
|
+
failed: "failed: its region could not make it, after every attempt",
|
|
216
|
+
deleting: "deleting",
|
|
217
|
+
deleted: "deleted",
|
|
218
|
+
};
|
|
219
|
+
const MARKS = { ok: "ok", warn: "! ", failed: "!!", unknown: "? " };
|
|
220
|
+
/** Each check, one line each: its mark, its name and its detail, its fix beside it; the checks not run, a line. */
|
|
221
|
+
export function checkLines(checks) {
|
|
222
|
+
return checks.flatMap((s) => [
|
|
223
|
+
`Mason's checks of the database ${s.source} names: ${s.outcome}`,
|
|
224
|
+
...s.checks.map((c) => ` ${MARKS[c.status]} ${c.name}: ${c.detail}${c.fix ? ` (fix: ${c.fix})` : ""}`),
|
|
225
|
+
...(s.missing.length ? [` ? not checked: ${s.missing.join(", ")}`] : []),
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
/** A replica's facts, as rows. */
|
|
229
|
+
function rows(r) {
|
|
230
|
+
return [
|
|
231
|
+
["state", STATE_WORDS[r.state]], ["region", r.region], ["size", `${r.sizeGb} GB of synced data`], ["host", r.hostId], ["created", r.createdAt],
|
|
232
|
+
["allowlist", r.allowlist.length ? r.allowlist.join(", ") : "none: no address may query it yet"],
|
|
233
|
+
...r.pending.map((p) => ["pending", `${p.change === "create" ? "its creation" : "its allowlist"} ${p.state === "failed" ? "failed" : "is under way"}` +
|
|
234
|
+
` (${p.attempts} attempt${p.attempts === 1 ? "" : "s"}${p.nextAttemptAt ? `, the next at ${p.nextAttemptAt}` : ""})${p.failure ? `: ${p.failure}` : ""}`]),
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
const statusStep = (r) => [`mason replica status ${r.org}/${r.name}`, "how it is"];
|
|
238
|
+
const nextOf = (r) => r.state === "ready"
|
|
239
|
+
? [[`mason deploy --replica ${r.org}/${r.name}`, "send this project to it"], [`mason token create --replica ${r.org}/${r.name}`, "a CI token for it"]]
|
|
240
|
+
: [statusStep(r)];
|
|
241
|
+
/**
|
|
242
|
+
* WHAT TO RUN AFTER THE SERVICE REFUSED: the CLI's own, by the status (the service's `next` is never read). A 401 or 403
|
|
243
|
+
* signs in again, naming the role that may (`may`); a 404 lists the organisation's replicas; else `again`.
|
|
244
|
+
*/
|
|
245
|
+
export function refusedNext(status, org, may, again) {
|
|
246
|
+
if (status === 401 || status === 403)
|
|
247
|
+
return [["mason login", `sign in as ${may} of ${org}`]];
|
|
248
|
+
if (status === 404)
|
|
249
|
+
return [[`mason replica list ${org}`, "the organisation's replicas"]];
|
|
250
|
+
return [again];
|
|
251
|
+
}
|
|
252
|
+
// ------------------------------------------------------------------ the command
|
|
253
|
+
export async function replica(argv) {
|
|
254
|
+
const f = parseCommand(REPLICA, argv);
|
|
255
|
+
if (typeof f === "number")
|
|
256
|
+
return f;
|
|
257
|
+
const json = f.values.json === true;
|
|
258
|
+
const usage = (message, fix) => fail({ message, code: "usage", ...(fix ? { fix } : {}), usage: REPLICA.usage }, json);
|
|
259
|
+
const [verb, arg, ...rest] = f.positionals;
|
|
260
|
+
const VERBS = ["create", "list", "status"];
|
|
261
|
+
if (verb === undefined)
|
|
262
|
+
return usage("create, list or status: mason replica list says the replicas");
|
|
263
|
+
if (!VERBS.includes(verb))
|
|
264
|
+
return usage(`there is no mason replica ${verb}: create, list or status${didYouMean(verb, VERBS)}`);
|
|
265
|
+
if (rest.length)
|
|
266
|
+
return usage(`mason replica ${verb} takes ${verb === "list" ? "one organisation at most" : "one replica"}: ${rest.join(" ")} is too many`);
|
|
267
|
+
for (const k of ["region", "size", "timeout"])
|
|
268
|
+
if (verb !== "create" && f.values[k] !== undefined)
|
|
269
|
+
return usage(`--${k} goes with mason replica create`);
|
|
270
|
+
if (verb === "list") {
|
|
271
|
+
const org = arg === undefined ? null : orgNamed(arg, REPLICA.usage);
|
|
272
|
+
if (org !== null && isFailure(org))
|
|
273
|
+
return fail(org, json);
|
|
274
|
+
return list(org, json);
|
|
275
|
+
}
|
|
276
|
+
if (arg === undefined)
|
|
277
|
+
return usage(`mason replica ${verb} names the replica: mason replica ${verb} <org>/<name>`);
|
|
278
|
+
const r = replicaNamed(arg, "argument", REPLICA.usage);
|
|
279
|
+
if (isFailure(r))
|
|
280
|
+
return fail(r, json);
|
|
281
|
+
if (verb === "status")
|
|
282
|
+
return status(r, json);
|
|
283
|
+
const region = typeof f.values.region === "string" ? f.values.region : undefined;
|
|
284
|
+
const size = typeof f.values.size === "string" ? f.values.size : undefined;
|
|
285
|
+
if (region === undefined || size === undefined)
|
|
286
|
+
return usage(`mason replica create needs ${region === undefined ? "--region" : "--size"}`, "name both: mason replica create <org>/<name> --region <region> --size <gb> (the service's web app lists its regions and sizes)");
|
|
287
|
+
if (!REGION.test(region))
|
|
288
|
+
return usage(`--region ${JSON.stringify(serviceText(region, 100))} is not a region's name`, "a region's name is lower case letters, digits and -, as the service lists it");
|
|
289
|
+
if (!SIZE.test(size))
|
|
290
|
+
return usage(`--size ${JSON.stringify(serviceText(size, 100))} is not a size`, "a size is a whole number of GB, as the service lists it: --size 50");
|
|
291
|
+
const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT);
|
|
292
|
+
if ("problem" in timeout)
|
|
293
|
+
return usage(`--timeout ${timeout.problem}`);
|
|
294
|
+
return create(r, { region, sizeGb: Number(size), timeout }, json);
|
|
295
|
+
}
|
|
296
|
+
/** The service and the login a request is sent with, the output's gate holding the token; or the Failure. */
|
|
297
|
+
function signedIn(asked, again, json, more = []) {
|
|
298
|
+
const service = serviceOf();
|
|
299
|
+
if (isFailure(service))
|
|
300
|
+
return service;
|
|
301
|
+
const token = tokenFor(service, { asked, again });
|
|
302
|
+
if (isFailure(token))
|
|
303
|
+
return token;
|
|
304
|
+
// from here every byte written is put to the exact test against what this command holds (src/cli/output-gate.ts)
|
|
305
|
+
holdOutput({ command: "replica", json, secrets: [token, ...more] });
|
|
306
|
+
return { service, token };
|
|
307
|
+
}
|
|
308
|
+
/** One replica's answer (`GET …/{name}`): its strings put to the exact test first, its words masked, by the one list. */
|
|
309
|
+
function replicaAnswer(a, asked, secrets) {
|
|
310
|
+
if (!obj(a.json) || !carriesNoSecret(wordless(a.json), secrets))
|
|
311
|
+
return null;
|
|
312
|
+
return heldReplica(a.json.replica, asked, secrets);
|
|
313
|
+
}
|
|
314
|
+
async function status(r, json) {
|
|
315
|
+
const s = signedIn("a replica's state is asked for", `mason replica status ${replicaText(r)}`, json);
|
|
316
|
+
if (isFailure(s))
|
|
317
|
+
return fail(s, json);
|
|
318
|
+
const { service, token } = s, secrets = [token];
|
|
319
|
+
const a = await call(service, "GET", `/v1/replicas/${r.org}/${r.name}`, { token });
|
|
320
|
+
if (isFailure(a))
|
|
321
|
+
return fail(a, json);
|
|
322
|
+
if (a.status !== 200)
|
|
323
|
+
return fail(serviceFailure(a, secrets, (st) => refusedNext(st, r.org, "a member", statusStep(r))) ?? unexpected(service, a, "a replica's state was asked for"), json);
|
|
324
|
+
const held = replicaAnswer(a, r, secrets);
|
|
325
|
+
if (held === null)
|
|
326
|
+
return fail(unexpected(service, a, "a replica's state was asked for"), json);
|
|
327
|
+
const next = nextOf(held);
|
|
328
|
+
if (json) {
|
|
329
|
+
out(jsonText(REPLICA, { replica: held }, next));
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
out(reportText({ headline: `${replicaText(r)} on ${service.base}: ${held.state}`, details: rows(held), next }, outStyle()));
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
async function list(org, json) {
|
|
336
|
+
const again = "mason replica list <org>";
|
|
337
|
+
const s = signedIn("the replicas are asked for", org === null ? "mason replica list" : `mason replica list ${org}`, json);
|
|
338
|
+
if (isFailure(s))
|
|
339
|
+
return fail(s, json);
|
|
340
|
+
const { service, token } = s, secrets = [token];
|
|
341
|
+
const orgs = org !== null ? [org] : await loginOrgs(service, token, again);
|
|
342
|
+
if (isFailure(orgs))
|
|
343
|
+
return fail(orgs, json);
|
|
344
|
+
const slugs = orgs.map((o) => typeof o === "string" ? o : o.slug);
|
|
345
|
+
const replicas = [];
|
|
346
|
+
const more = [];
|
|
347
|
+
for (const slug of slugs) {
|
|
348
|
+
const a = await call(service, "GET", `/v1/replicas/${slug}`, { token, maxBytes: MAX_LIST_BYTES });
|
|
349
|
+
if (isFailure(a))
|
|
350
|
+
return fail(a, json);
|
|
351
|
+
if (a.status !== 200)
|
|
352
|
+
return fail(serviceFailure(a, secrets, (st) => refusedNext(st, slug, "a member", [`mason replica list ${slug}`, "again"])) ?? unexpected(service, a, "the replicas were asked for"), json);
|
|
353
|
+
const j = a.json;
|
|
354
|
+
if (!obj(j) || !Array.isArray(j.replicas) || j.replicas.length > MAX_REPLICAS || typeof j.more !== "boolean" || !carriesNoSecret(wordless(j), secrets))
|
|
355
|
+
return fail(unexpected(service, a, "the replicas were asked for"), json);
|
|
356
|
+
for (const v of j.replicas) {
|
|
357
|
+
const h = heldReplica(v, { org: slug }, secrets);
|
|
358
|
+
if (h === null)
|
|
359
|
+
return fail(unexpected(service, a, "the replicas were asked for"), json);
|
|
360
|
+
replicas.push(h);
|
|
361
|
+
}
|
|
362
|
+
if (j.more)
|
|
363
|
+
more.push(slug);
|
|
364
|
+
}
|
|
365
|
+
const where = slugs.length === 1 ? slugs[0] : slugs.length === 0 ? "no organisation" : `${slugs.length} organisations`;
|
|
366
|
+
const next = replicas.length
|
|
367
|
+
? [statusStep(replicas[0]), [`mason replica create ${slugs[0]}/<name> --region <region> --size <gb>`, "another"]]
|
|
368
|
+
: slugs.length ? [[`mason replica create ${slugs[0]}/<name> --region <region> --size <gb>`, "make one (an account's first, in the service's web app)"]] : [];
|
|
369
|
+
if (json) {
|
|
370
|
+
out(jsonText(REPLICA, { replicas, more: more.length > 0 }, next));
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
const st = outStyle();
|
|
374
|
+
const head = replicas.length ? `${replicas.length} replica${replicas.length === 1 ? "" : "s"} in ${where}, on ${service.base}`
|
|
375
|
+
: slugs.length ? `No replicas yet in ${where}, on ${service.base}` : `This login is a member of no organisation on ${service.base}`;
|
|
376
|
+
const w = Math.max(0, ...replicas.map((r) => `${r.org}/${r.name}`.length)), sw = Math.max(0, ...replicas.map((r) => r.state.length));
|
|
377
|
+
const lines = replicas.map((r) => ` ${`${r.org}/${r.name}`.padEnd(w)} ${r.state.padEnd(sw)} ${r.region}, ${r.sizeGb} GB, host ${r.hostId}`);
|
|
378
|
+
const tips = more.map((o) => `${o} has more replicas than the service lists at once: mason replica status <org>/<name> says any one of them`);
|
|
379
|
+
out(strict(`${st.bold(head)}${lines.length ? `\n${lines.join("\n")}` : ""}${endText({ tips, next }, st) ? `\n${endText({ tips, next }, st)}` : ""}`, st));
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
/** A password shorter than this, in the one reading (src/cli/hosted.ts `canonicalSecret`), is not sent: no boundary can
|
|
383
|
+
* tell it apart from ordinary words in what is printed. */
|
|
384
|
+
export const MIN_PASSWORD = 8;
|
|
385
|
+
/**
|
|
386
|
+
* THE URL'S PASSWORD, AS THIS COMMAND HOLDS IT (PR 660's review, B1: one rule for every boundary): decoded (a malformed
|
|
387
|
+
* escape as written), held beside the token in the ONE list of secrets every boundary reads — the exact test of every
|
|
388
|
+
* value (`carriesNoSecret`), the masks of free text (`serviceText`) and the output's gate — each by the one reading
|
|
389
|
+
* (src/cli/hosted.ts `readUnits`: whole, and by its runs when its body is long enough). None for a URL without one;
|
|
390
|
+
* `short` for one under `MIN_PASSWORD` characters in that reading, which is not sent.
|
|
391
|
+
*/
|
|
392
|
+
export function urlPassword(url) {
|
|
393
|
+
let raw = "";
|
|
394
|
+
try {
|
|
395
|
+
raw = new URL(url).password;
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
raw = "";
|
|
399
|
+
} // not a URL: parseSource refused it before this
|
|
400
|
+
// decoded, as a mask reads text; a malformed escape held as it is written, never dropped
|
|
401
|
+
const password = (() => { try {
|
|
402
|
+
return decodeURIComponent(raw);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return raw;
|
|
406
|
+
} })();
|
|
407
|
+
if (password === "")
|
|
408
|
+
return { password: null };
|
|
409
|
+
return [...canonicalSecret(password)].length < MIN_PASSWORD ? { short: true } : { password };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* EVERY WORD A CREATION PRINTS OF ITS OWN, by the builders it prints with (`create` prints through these alone): its
|
|
413
|
+
* progress, the lines of a lost answer and of the wait, and each failure and report it can end with. What the service
|
|
414
|
+
* sends goes in where a builder takes it, through the masks and the exact test. `printedCorpus` renders them.
|
|
415
|
+
*/
|
|
416
|
+
export function createWords(c) {
|
|
417
|
+
const name = replicaText(c.r);
|
|
418
|
+
const again = [`mason replica create ${name} --region ${c.region} --size ${c.sizeGb}`, "create it again"];
|
|
419
|
+
const status = statusStep(c.r);
|
|
420
|
+
return {
|
|
421
|
+
again, status,
|
|
422
|
+
progress: `Creating ${name} on ${c.base}: Mason checks the database ${c.variable} names (${c.from}) from the replica's side, up to a minute`,
|
|
423
|
+
lost: (why, st) => ` The creation's answer was lost (${why}); the service has ${name}, ${STATE_WORDS[st]}`,
|
|
424
|
+
wait: (st, unsure) => unsure === null ? STATE_WORDS[st] : `${STATE_WORDS[st]}; the service could not say more just now (${unsure}): asking again`,
|
|
425
|
+
unsureOf: (what, statusCode) => `${what} answered ${statusCode}`,
|
|
426
|
+
/** a 403 says who may, and where an account's first replica is made (the API refuses it: a person is asked there) */
|
|
427
|
+
refusedNext: (st) => st === 403
|
|
428
|
+
? [["mason login", `sign in as an admin or an owner of ${c.r.org}; an account's first replica is made in the service's web app, ${c.base}`]]
|
|
429
|
+
: refusedNext(st, c.r.org, "an admin or an owner", again),
|
|
430
|
+
/** the checks failed: they are Mason's own, which mason doctor runs here against the same database */
|
|
431
|
+
checksFailedNext: [["mason doctor", "the same checks of the database, run here"], again],
|
|
432
|
+
lostFailure: (sent, absent) => ({ ...sent,
|
|
433
|
+
why: `${sent.why ?? "no answer"}; ${absent ? "asked since, the service has no such replica: it was not made" : "whether it was made is not known"}`,
|
|
434
|
+
fix: `mason replica status ${name} says whether it was; if not, create it again`, next: [status, again] }),
|
|
435
|
+
failed: (why) => ({ message: `${name} was not made: its region could not make it, after every attempt`, code: "failed", ...(why ? { why } : {}),
|
|
436
|
+
fix: `an admin or an owner of ${c.r.org} tries it again on its page in the service's web app (${c.base})`, next: [status] }),
|
|
437
|
+
deleted: (st) => ({ message: `${name} is ${st === "deleting" ? "being deleted" : "deleted"}`, code: "refused",
|
|
438
|
+
why: "it was deleted while it was made", fix: `create it again, as ${name} or under another name`, next: [again] }),
|
|
439
|
+
timedOut: (st) => ({ message: `${name} was not ready within ${c.timeout.every}`, code: "timeout", why: `it was last ${STATE_WORDS[st]}`,
|
|
440
|
+
fix: `nothing was stopped, and its region goes on making it: mason replica status ${name} says how it goes; a longer wait is --timeout 30m`, next: [status] }),
|
|
441
|
+
gone: () => ({ message: `the service has no ${name} now`, code: "not_found", why: "it was made, and is gone: deleted, or its creation undone",
|
|
442
|
+
fix: `mason replica list ${c.r.org} says the organisation's replicas`, next: [[`mason replica list ${c.r.org}`, "the organisation's replicas"]] }),
|
|
443
|
+
report: (state, next, st) => reportText({ headline: `Created ${name} on ${c.base}: it is ready`,
|
|
444
|
+
details: rows(state).filter(([k]) => k !== "state"),
|
|
445
|
+
tips: ["The service does not keep the database's URL: the replica reads it by its variable, from its own environment."], next }, st),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/** The `/v1/` failures the contract documents for a creation and a state's answer, each status with its codes. */
|
|
449
|
+
const DOCUMENTED = [[400, ["bad_request"]], [401, ["unauthenticated"]], [403, ["refused"]], [404, ["not_found"]],
|
|
450
|
+
[409, ["refused", "busy"]], [413, ["too_large"]], [415, ["bad_request"]], [422, ["checks_failed"]], [429, ["slow_down"]], [500, ["failed"]],
|
|
451
|
+
[502, ["unavailable"]], [503, ["checks_unknown", "unavailable"]], [504, ["unavailable"]]];
|
|
452
|
+
/**
|
|
453
|
+
* EVERYTHING A CREATION CAN PRINT OF ITS OWN, rendered (R2-B2: a password made of the CLI's own words failed an honest run,
|
|
454
|
+
* after the replica was made): each builder of `createWords`, with this run's own values, in each order it is printed —
|
|
455
|
+
* a person's stderr, stdout and the two together, and --json — every service's value a placeholder that carries no
|
|
456
|
+
* word. The output's gate reads what a run writes; what it could read of the CLI's own is in here.
|
|
457
|
+
*/
|
|
458
|
+
export function printedCorpus(c, tail = 256) {
|
|
459
|
+
const w = createWords(c), service = { base: c.base }, P = "#";
|
|
460
|
+
const failure = (f) => failureText("replica", f, PLAIN);
|
|
461
|
+
const asJson = (v, next) => jsonText(REPLICA, v, next);
|
|
462
|
+
const sample = (state, pending = []) => ({ org: c.r.org, name: c.r.name, region: c.region, sizeGb: c.sizeGb, hostId: P, state, createdAt: P, allowlist: [], pending });
|
|
463
|
+
const pendings = [[], ...["create", "allowlist"].flatMap((change) => ["pending", "failed"].flatMap((st) => [0, 1, 2].map((attempts) => [{ change, state: st, attempts, failure: P, nextAttemptAt: P }])))];
|
|
464
|
+
const checks = OUTCOMES.map((outcome) => ({ source: c.source, outcome, checks: CHECK_STATUSES.map((status) => ({ name: P, status, detail: P, fix: P })), missing: [P] }));
|
|
465
|
+
const ends = [];
|
|
466
|
+
const nexts = [nextOf(sample("ready")), [w.status]];
|
|
467
|
+
const failures = [w.failed(P), w.failed(""), w.deleted("deleting"), w.deleted("deleted"), w.gone(),
|
|
468
|
+
...STATES.map((st) => w.timedOut(st)),
|
|
469
|
+
...[{ why: P }, { why: "no answer within 60 s" }, { why: "no answer within 15 s" }].flatMap((x) => [true, false].map((absent) => w.lostFailure({ message: `could not reach ${c.base}`, code: "failed", why: x.why, fix: `check this machine is online, and that MASON_CLOUD_URL names the service (it is ${c.base})`, next: [] }, absent))),
|
|
470
|
+
...DOCUMENTED.flatMap(([status, codes]) => codes.flatMap((code) => [P, undefined].map((why) => {
|
|
471
|
+
const f = serviceFailure({ status, json: { error: { code, message: P, ...(why ? { why, fix: P } : {}) } }, what: `POST /v1/replicas/${c.r.org}`, retryAfter: null }, [], w.refusedNext);
|
|
472
|
+
return status === 422 ? { ...f, next: w.checksFailedNext } : f;
|
|
473
|
+
}))),
|
|
474
|
+
...[200, 201, ...DOCUMENTED.map(([st]) => st)].flatMap((status) => [true, false].flatMap((isJson) => ["a replica was created", "a replica's state was asked for"].map((step) => unexpected(service, { status, json: isJson ? {} : undefined, what: step === "a replica was created" ? `POST /v1/replicas/${c.r.org}` : `GET /v1/replicas/${c.r.org}/${c.r.name}`, retryAfter: null }, step))))];
|
|
475
|
+
for (const f of failures)
|
|
476
|
+
ends.push({ stderr: failure(f) }, { stdout: asJson({ replica: sample("creating"), checks, error: errorJson(f) }, f.next ?? []) }, { stdout: asJson({ checks, error: errorJson(f) }, f.next ?? []) });
|
|
477
|
+
for (const pending of pendings)
|
|
478
|
+
for (const next of nexts) {
|
|
479
|
+
ends.push({ stdout: w.report(sample("ready", pending), next, PLAIN) }, { stdout: asJson({ replica: sample("ready", pending), checks }, next) }, { stdout: asJson({ replica: sample("ready", pending), checks: null }, next) });
|
|
480
|
+
}
|
|
481
|
+
// the lines on stderr before an end, in the order they are printed: the progress, the checks or a lost answer's line,
|
|
482
|
+
// then the wait's lines (each state, as it is or unsure), in both orders
|
|
483
|
+
const unsure = [null, P, ...[429, 500, 502, 503, 504].map((st) => w.unsureOf(`GET /v1/replicas/${c.r.org}/${c.r.name}`, st)), `could not reach ${c.base} (no answer within 15 s)`];
|
|
484
|
+
const waits = ["checking", "creating"].flatMap((st) => unsure.map((u) => ` ${w.wait(st, u)}`));
|
|
485
|
+
const before = [[w.progress, ...checkLines(checks), ...waits], [w.progress, ...STATES.map((st) => w.lost(P, st)), ...[...waits].reverse()]];
|
|
486
|
+
const spinner = `mason ${REPLICA.name}`;
|
|
487
|
+
const corpus = [];
|
|
488
|
+
for (const lines of before) {
|
|
489
|
+
const err = lines.join("\n");
|
|
490
|
+
corpus.push(`${spinner}\n${err}`);
|
|
491
|
+
// each end after what came before it: the gate reads across writes as far as its tail reaches (`tail`)
|
|
492
|
+
for (const end of ends)
|
|
493
|
+
corpus.push(`${err.slice(-tail)}\n${"stdout" in end ? end.stdout : end.stderr}`);
|
|
494
|
+
}
|
|
495
|
+
for (const end of ends)
|
|
496
|
+
corpus.push("stdout" in end ? end.stdout : end.stderr);
|
|
497
|
+
return corpus;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* WHETHER THE CLI'S OWN WORDS HOLD THE PASSWORD, as the output's gate would read them (its own test, `readingsCarry`, on
|
|
501
|
+
* every text of `printedCorpus`): then an honest run would be refused by the gate, after the replica was made, blaming
|
|
502
|
+
* the service. Such a password is refused before anything is sent, in words that say so. The corpus's readings are made
|
|
503
|
+
* once a run.
|
|
504
|
+
*/
|
|
505
|
+
export function ownWordsHold(password, c) {
|
|
506
|
+
const tail = Math.max(256, 3 * password.length), key = `${tail}\u0000${JSON.stringify(c)}`;
|
|
507
|
+
let reads = CORPUS.get(key);
|
|
508
|
+
if (reads === undefined) {
|
|
509
|
+
reads = printedCorpus(c, tail).map((text) => readingsOf(text));
|
|
510
|
+
CORPUS.set(key, reads);
|
|
511
|
+
}
|
|
512
|
+
return reads.some((read) => readingsCarry(read, [password]));
|
|
513
|
+
}
|
|
514
|
+
const CORPUS = new Map();
|
|
515
|
+
async function create(r, o, json) {
|
|
516
|
+
// THE DATABASE: the project's sources file names it by a variable, read here by the one reader
|
|
517
|
+
const t = theProject(process.cwd());
|
|
518
|
+
if ("problem" in t)
|
|
519
|
+
return fail({ message: t.problem, code: t.code, why: t.why ?? "a replica syncs the database this project's sources file names", fix: t.fix, next: t.next }, json);
|
|
520
|
+
const p = t.project;
|
|
521
|
+
const src = readSource(p.folder, p.root);
|
|
522
|
+
if (isFailure(src))
|
|
523
|
+
return fail(src, json);
|
|
524
|
+
const file = shownPath(p.root, src.file);
|
|
525
|
+
if ("literal" in src.url) {
|
|
526
|
+
return fail({ message: `${file}: url: is written in the file`, code: "refused",
|
|
527
|
+
why: "a replica reads its database's URL from a variable of its own environment, by the name the sources file gives it; a URL written in the file would also be kept with the project's files",
|
|
528
|
+
fix: `write url: \${DATABASE_URL} in ${file}, and put DATABASE_URL=<the URL> in .env (or export it), then mason replica create again` }, json);
|
|
529
|
+
}
|
|
530
|
+
const variable = src.url.env;
|
|
531
|
+
// the value is held to the grammar Mason connects by before it leaves this machine, never repeated (preflight's rule)
|
|
532
|
+
const parsed = parseSource(src.resolved, `${variable} (${src.from})`);
|
|
533
|
+
if ("problem" in parsed || !/^postgres(?:ql)?:\/\//i.test(src.resolved.trim()) || src.resolved.length > 2048) {
|
|
534
|
+
return fail({ message: `${variable} (${src.from}) is not a database URL a replica can be made for`, code: "refused",
|
|
535
|
+
why: "problem" in parsed ? parsed.problem : "it is sent as postgres://… or postgresql://…, at most 2 KiB",
|
|
536
|
+
fix: `set ${variable} to the database's URL, postgres://user:password@host:5432/your_app` }, json);
|
|
537
|
+
}
|
|
538
|
+
const url = src.resolved;
|
|
539
|
+
const service = serviceOf();
|
|
540
|
+
if (isFailure(service))
|
|
541
|
+
return fail(service, json);
|
|
542
|
+
const run = { r, region: o.region, sizeGb: o.sizeGb, timeout: o.timeout, base: service.base, variable, from: src.from, source: src.name };
|
|
543
|
+
const w = createWords(run);
|
|
544
|
+
// THE PASSWORD, before anything is held or sent (neither refusal names it): one too short to be told apart from
|
|
545
|
+
// words, or one the CLI's own words hold, is not sent, since no honest run could print without the gate refusing it
|
|
546
|
+
const pw = urlPassword(url);
|
|
547
|
+
const notSent = (message, why) => fail({ message, code: "refused", why,
|
|
548
|
+
fix: "name a database user with another password in the URL, or create the replica in the service's web app" }, json);
|
|
549
|
+
if ("short" in pw) {
|
|
550
|
+
return notSent(`the password in ${variable} (${src.from}) is shorter than the ${MIN_PASSWORD} characters this command sends`, "a password this short cannot be told apart from ordinary words in what the service answers, so nothing could keep it out of what is printed; it is not sent");
|
|
551
|
+
}
|
|
552
|
+
if (pw.password !== null && ownWordsHold(pw.password, run)) {
|
|
553
|
+
return notSent(`the password in ${variable} (${src.from}) is in the words this command prints itself`, "this command keeps the password out of everything it prints by finding it there, and its own words (its lines, its steps, the replica's name or the service's address) hold this one: it could not print its answer, so it is not sent");
|
|
554
|
+
}
|
|
555
|
+
const held = pw.password === null ? [] : [pw.password];
|
|
556
|
+
const s = signedIn("a replica is created", `mason replica create ${replicaText(r)}`, json, held);
|
|
557
|
+
if (isFailure(s))
|
|
558
|
+
return fail(s, json);
|
|
559
|
+
const { token } = s;
|
|
560
|
+
// ONE list of what this command holds, read by every boundary: the exact test, the masks and the output's gate
|
|
561
|
+
const secrets = [token, ...held];
|
|
562
|
+
const where = `/v1/replicas/${r.org}`;
|
|
563
|
+
const refusedOf = (a, step) => serviceFailure(a, secrets, w.refusedNext) ?? unexpected(service, a, step);
|
|
564
|
+
if (!json)
|
|
565
|
+
say(w.progress);
|
|
566
|
+
// THE CREATION: the service checks the source, then answers 201, or why it was not made (with the checks, said)
|
|
567
|
+
const sent = await call(service, "POST", where, { token, timeoutMs: CREATE_MS,
|
|
568
|
+
json: { name: r.name, region: o.region, sizeGb: o.sizeGb, sources: [{ name: src.name, variable, url }] } });
|
|
569
|
+
let checks = null;
|
|
570
|
+
let state;
|
|
571
|
+
let waitMs = REPLICA_POLL_MS;
|
|
572
|
+
if (isFailure(sent)) {
|
|
573
|
+
// a POST that got no answer may have made the replica: its state says so (the contract)
|
|
574
|
+
const a = await call(service, "GET", `${where}/${r.name}`, { token });
|
|
575
|
+
const found = !isFailure(a) && a.status === 200 ? replicaAnswer(a, r, secrets) : null;
|
|
576
|
+
// why it got no answer is the system's words, never the CLI's own: masked by what this command holds, as a service's are
|
|
577
|
+
const why = serviceText(sent.why ?? sent.message, 300, secrets);
|
|
578
|
+
if (found === null)
|
|
579
|
+
return fail(w.lostFailure({ ...sent, why }, !isFailure(a) && a.status === 404), json);
|
|
580
|
+
if (!json)
|
|
581
|
+
say(w.lost(why, found.state));
|
|
582
|
+
state = found;
|
|
583
|
+
if (!isFailure(a) && a.retryAfter !== null)
|
|
584
|
+
waitMs = a.retryAfter * 1000;
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
if (obj(sent.json) && sent.status !== 201 && "checks" in sent.json && carriesNoSecret(wordless({ checks: sent.json.checks }), secrets))
|
|
588
|
+
checks = heldChecks(sent.json.checks, src.name, secrets);
|
|
589
|
+
if (sent.status !== 201) {
|
|
590
|
+
const f = refusedOf(sent, "a replica was created");
|
|
591
|
+
if (!json && checks !== null)
|
|
592
|
+
for (const l of checkLines(checks))
|
|
593
|
+
say(l);
|
|
594
|
+
const next = sent.status === 422 ? w.checksFailedNext : f.next ?? [];
|
|
595
|
+
const failure = { ...f, next };
|
|
596
|
+
if (json) {
|
|
597
|
+
out(jsonText(REPLICA, { checks, error: errorJson(failure) }, next));
|
|
598
|
+
return exitOf(failure);
|
|
599
|
+
}
|
|
600
|
+
return fail(failure, false);
|
|
601
|
+
}
|
|
602
|
+
const j = sent.json;
|
|
603
|
+
const made = obj(j) && carriesNoSecret(wordless(j), secrets) ? heldReplica(j.replica, r, secrets) : null;
|
|
604
|
+
checks = obj(j) ? heldChecks(j.checks, src.name, secrets) : null;
|
|
605
|
+
if (made === null || checks === null)
|
|
606
|
+
return fail(unexpected(service, sent, "a replica was created"), json);
|
|
607
|
+
if (!json)
|
|
608
|
+
for (const l of checkLines(checks))
|
|
609
|
+
say(l);
|
|
610
|
+
state = made;
|
|
611
|
+
if (sent.retryAfter !== null)
|
|
612
|
+
waitMs = sent.retryAfter * 1000;
|
|
613
|
+
}
|
|
614
|
+
// THE WAIT: until its region has made it (ready) or could not (failed), bounded by --timeout
|
|
615
|
+
const started = Date.now(), deadline = started + o.timeout.seconds * 1000;
|
|
616
|
+
let said = "", saidAt = 0;
|
|
617
|
+
const show = (line) => { if (!json && sayAgain(line, said, saidAt, Date.now())) {
|
|
618
|
+
say(` ${line}`);
|
|
619
|
+
said = line;
|
|
620
|
+
saidAt = Date.now();
|
|
621
|
+
} };
|
|
622
|
+
const ended = (error) => {
|
|
623
|
+
const next = error === null ? nextOf(state) : error.next ?? [];
|
|
624
|
+
if (json) {
|
|
625
|
+
out(jsonText(REPLICA, { replica: state, checks, ...(error ? { error: errorJson(error) } : {}) }, next));
|
|
626
|
+
return error ? exitOf(error) : 0;
|
|
627
|
+
}
|
|
628
|
+
if (error !== null)
|
|
629
|
+
return fail(error, false);
|
|
630
|
+
out(w.report(state, next, outStyle()));
|
|
631
|
+
return 0;
|
|
632
|
+
};
|
|
633
|
+
for (;;) {
|
|
634
|
+
if (state.state === "ready")
|
|
635
|
+
return ended(null);
|
|
636
|
+
if (state.state === "failed")
|
|
637
|
+
return ended(w.failed(state.pending.map((x) => x.failure).filter((x) => x !== null).join("; ")));
|
|
638
|
+
if (state.state === "deleting" || state.state === "deleted")
|
|
639
|
+
return ended(w.deleted(state.state));
|
|
640
|
+
show(w.wait(state.state, null));
|
|
641
|
+
const left = deadline - Date.now();
|
|
642
|
+
if (left <= 0)
|
|
643
|
+
return ended(w.timedOut(state.state));
|
|
644
|
+
await sleep(Math.min(waitMs, left));
|
|
645
|
+
const a = await call(service, "GET", `${where}/${r.name}`, { token });
|
|
646
|
+
waitMs = REPLICA_POLL_MS;
|
|
647
|
+
// an answer that cannot say how it goes is a wait, never an outcome: the timeout bounds it
|
|
648
|
+
if (isFailure(a) || a.status === 429 || a.status >= 500) {
|
|
649
|
+
if (!isFailure(a) && a.retryAfter !== null)
|
|
650
|
+
waitMs = a.retryAfter * 1000;
|
|
651
|
+
show(w.wait(state.state, isFailure(a) ? serviceText(a.why ?? a.message, 300, secrets) : w.unsureOf(a.what, a.status)));
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
if (a.retryAfter !== null)
|
|
655
|
+
waitMs = a.retryAfter * 1000;
|
|
656
|
+
if (a.status === 404)
|
|
657
|
+
return ended(w.gone());
|
|
658
|
+
if (a.status !== 200)
|
|
659
|
+
return ended(refusedOf(a, "a replica's state was asked for"));
|
|
660
|
+
const now = replicaAnswer(a, r, secrets);
|
|
661
|
+
if (now === null)
|
|
662
|
+
return ended(unexpected(service, a, "a replica's state was asked for"));
|
|
663
|
+
state = now;
|
|
664
|
+
}
|
|
665
|
+
}
|